#Requires -RunAsAdministrator [CmdletBinding()] param( [switch]$ApproveFilesystemTrustResetAfterReboot ) $ErrorActionPreference = "Stop" $Target = "C:\MinerOpsAgent" $ConfigPath = Join-Path $Target "agent-config.json" $PendingConfigPath = Join-Path $Target "agent-config.json.pending" $RecoveryConfigPath = Join-Path $Target "agent-config.recovery.json" $EnrollmentPath = Join-Path $PSScriptRoot "enrollment.json" $Installer = Join-Path $PSScriptRoot "install.ps1" $RequiredFiles = @( "install.ps1", "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", "MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1", "VERSION", "enrollment.json" ) function Get-MinerOpsEnrollmentDirectorySids { return @(@( "S-1-5-18", "S-1-5-32-544" ) | Select-Object -Unique) } function New-MinerOpsEnrollmentCredentialAcl { $acl = New-Object System.Security.AccessControl.FileSecurity $acl.SetAccessRuleProtection($true, $false) $acl.SetOwner((New-Object ` System.Security.Principal.SecurityIdentifier("S-1-5-32-544"))) foreach ($sidValue in (Get-MinerOpsEnrollmentDirectorySids)) { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( (New-Object System.Security.Principal.SecurityIdentifier($sidValue)), [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.InheritanceFlags]::None, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($rule) } return $acl } function Test-MinerOpsEnrollmentCredentialAcl { param([Parameter(Mandatory = $true)]$Acl) if (-not $Acl.AreAccessRulesProtected) { return $false } try { if ($Acl.GetOwner( [System.Security.Principal.SecurityIdentifier] ).Value -ne "S-1-5-32-544") { return $false } } catch { return $false } $expected = @{} foreach ($sid in (Get-MinerOpsEnrollmentDirectorySids)) { $expected[$sid] = $false } $rules = @($Acl.GetAccessRules( $true, $false, [System.Security.Principal.SecurityIdentifier] )) if ($rules.Count -ne $expected.Count) { return $false } foreach ($rule in $rules) { $sid = $rule.IdentityReference.Value if ( -not $expected.ContainsKey($sid) -or $rule.IsInherited -or $rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $rule.FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl ) { return $false } $expected[$sid] = $true } return -not ($expected.Values -contains $false) } function Set-MinerOpsEnrollmentStreamAcl { param([Parameter(Mandatory)]$Stream, [Parameter(Mandatory)]$Acl) if ($Stream.PSObject.Methods.Name -contains "SetAccessControl") { $Stream.SetAccessControl($Acl) } else { [System.IO.FileSystemAclExtensions]::SetAccessControl($Stream, $Acl) } } function New-MinerOpsEnrollmentProtectedFileCreateNewStream { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)] [System.Security.AccessControl.FileSecurity]$Acl ) $rights = [System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::Synchronize ) return [IO.FileStream]::new( $Path, [IO.FileMode]::CreateNew, $rights, [IO.FileShare]::None, 4096, [IO.FileOptions]::None, $Acl ) } function Open-MinerOpsEnrollmentFileStreamWithRights { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)] [System.Security.AccessControl.FileSystemRights]$Rights ) return [IO.FileStream]::new( $Path, [IO.FileMode]::Open, $Rights, [IO.FileShare]::None, 4096, [IO.FileOptions]::None ) } function Get-MinerOpsEnrollmentStreamAcl { param([Parameter(Mandatory)]$Stream) if ($Stream.PSObject.Methods.Name -contains "GetAccessControl") { return $Stream.GetAccessControl() } return [System.IO.FileSystemAclExtensions]::GetAccessControl($Stream) } function Assert-MinerOpsEnrollmentSingleLinkStream { param( [Parameter(Mandatory)]$Stream, [Parameter(Mandatory)][string]$Description ) if (-not ("MinerOpsFileIdentityNative" -as [type])) { Add-Type -TypeDefinition @' using System; using System.ComponentModel; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; public static class MinerOpsFileIdentityNative { [StructLayout(LayoutKind.Sequential)] private struct ByHandleFileInformation { public uint FileAttributes; public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; public uint VolumeSerialNumber; public uint FileSizeHigh; public uint FileSizeLow; public uint NumberOfLinks; public uint FileIndexHigh; public uint FileIndexLow; } [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetFileInformationByHandle( SafeFileHandle file, out ByHandleFileInformation information); public static uint GetNumberOfLinks(SafeFileHandle file) { ByHandleFileInformation information; if (!GetFileInformationByHandle(file, out information)) throw new Win32Exception(Marshal.GetLastWin32Error()); return information.NumberOfLinks; } } '@ } if ([MinerOpsFileIdentityNative]::GetNumberOfLinks( $Stream.SafeFileHandle ) -ne 1) { throw "$Description must have exactly one filesystem link." } } function Get-MinerOpsEnrollmentTrustBlockerPaths { param([Parameter(Mandatory)][string]$Path) $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $parent = [IO.Directory]::GetParent($fullPath) if ($null -eq $parent) { throw "Agent directory cannot be a filesystem root." } $pattern = ([IO.Path]::GetFileName($fullPath)) + ".minerops-filesystem-trust.blocked-*.state" return @(Get-ChildItem -LiteralPath $parent.FullName ` -Filter $pattern -File -Force -ErrorAction Stop) } function ConvertFrom-MinerOpsEnrollmentKernelBootEventRecord { param([Parameter(Mandatory)]$EventRecord) if ( $null -eq $EventRecord -or $EventRecord.PSObject.Properties.Name -cnotcontains "ProviderName" -or $EventRecord.ProviderName -isnot [string] -or [string]$EventRecord.ProviderName -cne "Microsoft-Windows-Kernel-Boot" -or $EventRecord.PSObject.Properties.Name -cnotcontains "Id" -or $EventRecord.Id -isnot [int] -or [int]$EventRecord.Id -ne 27 -or $EventRecord.PSObject.Properties.Name -cnotcontains "RecordId" -or $EventRecord.RecordId -isnot [long] -or [long]$EventRecord.RecordId -le 0 -or $EventRecord.PSObject.Properties.Name -cnotcontains "Version" -or $EventRecord.Version -isnot [byte] -or [byte]$EventRecord.Version -notin @([byte]0, [byte]1) -or $EventRecord.PSObject.Methods.Name -cnotcontains "ToXml" ) { throw "Kernel-Boot event metadata is invalid or unsupported." } $xmlText = $EventRecord.ToXml() if ($xmlText -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$xmlText)) { throw "Kernel-Boot event XML is missing." } try { [xml]$eventXml = [string]$xmlText } catch { throw "Kernel-Boot event XML is malformed." } $eventNamespace = "http://schemas.microsoft.com/win/2004/08/events/event" if ( $null -eq $eventXml.DocumentElement -or $eventXml.DocumentElement.LocalName -cne "Event" -or $eventXml.DocumentElement.NamespaceURI -cne $eventNamespace ) { throw "Kernel-Boot event XML root is invalid." } $namespaceManager = [Xml.XmlNamespaceManager]::new($eventXml.NameTable) $namespaceManager.AddNamespace("evt", $eventNamespace) $eventNodes = @($eventXml.SelectNodes("/evt:Event", $namespaceManager)) $systemNodes = @($eventXml.SelectNodes( "/evt:Event/evt:System", $namespaceManager )) $eventDataNodes = @($eventXml.SelectNodes( "/evt:Event/evt:EventData", $namespaceManager )) $bootTypeNodes = @($eventXml.SelectNodes( "/evt:Event/evt:EventData/evt:Data[@Name='BootType']", $namespaceManager )) if ( $eventNodes.Count -ne 1 -or $systemNodes.Count -ne 1 -or $eventDataNodes.Count -ne 1 -or $bootTypeNodes.Count -ne 1 -or $bootTypeNodes[0].ChildNodes.Count -ne 1 -or $bootTypeNodes[0].FirstChild.NodeType -ne [Xml.XmlNodeType]::Text ) { throw "Kernel-Boot event XML schema is invalid or ambiguous." } $bootTypeText = [string]$bootTypeNodes[0].InnerText if ($bootTypeText -cnotmatch '^(0|[1-9][0-9]*)$') { throw "Kernel-Boot event BootType is not canonical." } $bootType = [UInt32]0 if (-not [UInt32]::TryParse( $bootTypeText, [Globalization.NumberStyles]::None, [Globalization.CultureInfo]::InvariantCulture, [ref]$bootType )) { throw "Kernel-Boot event BootType is invalid." } return [pscustomobject]@{ RecordId = [long]$EventRecord.RecordId Version = [byte]$EventRecord.Version BootType = [UInt32]$bootType } } function Get-MinerOpsEnrollmentBootIdentifier { try { $events = @(Get-WinEvent -FilterHashtable @{ LogName = "System" ProviderName = "Microsoft-Windows-Kernel-Boot" Id = 27 } -ErrorAction Stop) $fullBootRecordIds = @($events | ForEach-Object { $record = ConvertFrom-MinerOpsEnrollmentKernelBootEventRecord ` -EventRecord $_ if ($record.BootType -eq 0) { [long]$record.RecordId } } | Sort-Object -Descending) if ($fullBootRecordIds.Count -eq 0) { throw "Windows full-kernel-boot event is missing." } for ($index = 1; $index -lt $fullBootRecordIds.Count; $index++) { if ([long]$fullBootRecordIds[$index - 1] -eq [long]$fullBootRecordIds[$index]) { throw "Windows full-kernel-boot event is ambiguous." } } return "windows-full-boot-event-v1:{0:x16}" -f ` [UInt64]$fullBootRecordIds[0] } catch { throw "Windows boot identity could not be verified." } } function ConvertFrom-MinerOpsEnrollmentBootIdentifier { param([Parameter(Mandatory)]$Value) if ($Value -isnot [string] -or $Value -cnotmatch '^windows-full-boot-event-v1:[0-9a-f]{16}$') { throw "Enrollment filesystem trust blocker boot identity is invalid." } $parsed = [UInt64]0 if (-not [UInt64]::TryParse( $Value.Substring(27), [Globalization.NumberStyles]::AllowHexSpecifier, [Globalization.CultureInfo]::InvariantCulture, [ref]$parsed ) -or $parsed -eq 0) { throw "Enrollment filesystem trust blocker boot identity is invalid." } return "windows-full-boot-event-v1:{0:x16}" -f $parsed } function New-MinerOpsEnrollmentTrustBlocker { param([Parameter(Mandatory)][string]$TargetRoot) $fullTarget = [IO.Path]::GetFullPath($TargetRoot).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $parent = [IO.Directory]::GetParent($fullTarget).FullName $path = Join-Path $parent ( (([IO.Path]::GetFileName($fullTarget)) + ".minerops-filesystem-trust.blocked-{0}.state") -f [guid]::NewGuid().ToString("N") ) $stream = $null try { # The marker is outside the contaminated directory so a handle opened # inside that directory before repair cannot remove the durable block. $stream = New-MinerOpsEnrollmentProtectedFileCreateNewStream ` -Path $path -Acl (New-MinerOpsEnrollmentCredentialAcl) if (-not (Test-MinerOpsEnrollmentCredentialAcl ` -Acl (Get-MinerOpsEnrollmentStreamAcl -Stream $stream))) { throw "Enrollment filesystem trust blocker ACL could not be verified." } Assert-MinerOpsEnrollmentSingleLinkStream -Stream $stream ` -Description "Enrollment filesystem trust blocker" $record = [ordered]@{ version = 2 state = "blocked" reason = "existing-agent-directory-was-untrusted" target_path = [IO.Path]::GetFullPath($TargetRoot) detected_boot_id = Get-MinerOpsEnrollmentBootIdentifier reset_boot_id = $null original_task_running = $null created_at = [DateTime]::UtcNow.ToString("o") } $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes( (($record | ConvertTo-Json -Depth 10) + [Environment]::NewLine) ) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } finally { if ($null -ne $stream) { $stream.Dispose() } } return $path } function Open-MinerOpsEnrollmentCredentialFileCreateNew { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$ExpectedParent ) [void](Assert-MinerOpsEnrollmentDirectory -Path $ExpectedParent) $fullPath = [IO.Path]::GetFullPath($Path) $actualParent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $expected = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) if (-not $actualParent.Equals( $expected, [StringComparison]::OrdinalIgnoreCase )) { throw "Enrollment credential path is outside the Agent directory." } $stream = $null $createdByThisCall = $false try { $stream = New-MinerOpsEnrollmentProtectedFileCreateNewStream ` -Path $fullPath -Acl (New-MinerOpsEnrollmentCredentialAcl) $createdByThisCall = $true if (-not (Test-MinerOpsEnrollmentCredentialAcl ` -Acl (Get-MinerOpsEnrollmentStreamAcl -Stream $stream))) { throw "Enrollment credential file ACL could not be verified." } Assert-MinerOpsEnrollmentSingleLinkStream -Stream $stream ` -Description "Enrollment credential file" return $stream } catch { if ($null -ne $stream) { $stream.Dispose() $stream = $null } if ($createdByThisCall -and (Test-Path -LiteralPath $fullPath -PathType Leaf)) { Remove-Item -LiteralPath $fullPath -Force ` -ErrorAction SilentlyContinue } throw } } function Write-MinerOpsEnrollmentCredentialJson { param( [Parameter(Mandatory)]$Stream, [Parameter(Mandatory)][string]$Json ) Assert-MinerOpsEnrollmentSingleLinkStream -Stream $Stream ` -Description "Enrollment credential file" $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes( $Json + [Environment]::NewLine ) $Stream.Write($bytes, 0, $bytes.Length) $Stream.Flush($true) Assert-MinerOpsEnrollmentSingleLinkStream -Stream $Stream ` -Description "Enrollment credential file" } function Assert-MinerOpsEnrollmentCredentialFile { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$ExpectedParent ) [void](Assert-MinerOpsEnrollmentDirectory -Path $ExpectedParent) $fullPath = [IO.Path]::GetFullPath($Path) $actualParent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $expected = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) if (-not $actualParent.Equals( $expected, [StringComparison]::OrdinalIgnoreCase )) { throw "Enrollment credential path is outside the Agent directory." } $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop if ( $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($item.PSObject.Properties.Name -contains "LinkType" -and [string]$item.LinkType) ) { throw "Enrollment credential path is not a regular single-link file." } $stream = $null try { $stream = Open-MinerOpsEnrollmentFileStreamWithRights ` -Path $fullPath ` -Rights ([System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::Synchronize )) Assert-MinerOpsEnrollmentSingleLinkStream -Stream $stream ` -Description "Enrollment credential file" if (-not (Test-MinerOpsEnrollmentCredentialAcl ` -Acl (Get-MinerOpsEnrollmentStreamAcl -Stream $stream))) { throw "Enrollment credential file protection could not be verified." } } finally { if ($null -ne $stream) { $stream.Dispose() } } } function New-MinerOpsEnrollmentDirectoryAcl { $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $administrators = New-Object System.Security.Principal.SecurityIdentifier( "S-1-5-32-544" ) $acl.SetOwner($administrators) foreach ($sidValue in (Get-MinerOpsEnrollmentDirectorySids)) { $identity = New-Object System.Security.Principal.SecurityIdentifier( $sidValue ) $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $identity, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit", [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($rule) } $users = New-Object System.Security.Principal.SecurityIdentifier( "S-1-5-32-545" ) $readRule = New-Object System.Security.AccessControl.FileSystemAccessRule( $users, [System.Security.AccessControl.FileSystemRights]::ReadAndExecute, [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit", [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($readRule) return $acl } function Assert-MinerOpsEnrollmentNoReparsePath { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = [IO.Path]::GetFullPath($Path) $root = [IO.Path]::GetPathRoot($fullPath) if (-not $root) { throw "Agent directory must use an absolute path." } if (-not $fullPath.Equals( $root, [StringComparison]::OrdinalIgnoreCase )) { $fullPath = $fullPath.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) } $current = $root $rootItem = Get-Item -LiteralPath $current -Force -ErrorAction Stop if ( -not $rootItem.PSIsContainer -or ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Agent directory path root is a reparse point or non-directory component." } $relative = $fullPath.Substring($root.Length) foreach ($segment in @($relative.Split( @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), [StringSplitOptions]::RemoveEmptyEntries ))) { $current = Join-Path $current $segment $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop if ( -not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Agent directory path contains a reparse point or non-directory component." } } return $fullPath } function Assert-MinerOpsEnrollmentTrustedAncestors { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = [IO.Path]::GetFullPath($Path) $root = [IO.Path]::GetPathRoot($fullPath) if (-not $fullPath.Equals( $root, [StringComparison]::OrdinalIgnoreCase )) { $fullPath = $fullPath.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) } $parentInfo = [IO.Directory]::GetParent($fullPath) if ($null -eq $parentInfo) { throw "Agent directory cannot be a filesystem root." } $parent = $parentInfo.FullName $root = [IO.Path]::GetPathRoot($parent) if (-not $parent.Equals( $root, [StringComparison]::OrdinalIgnoreCase )) { $parent = $parent.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) } $current = $root $components = [System.Collections.Generic.List[string]]::new() $components.Add($current) foreach ($segment in @($parent.Substring($root.Length).Split( @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), [StringSplitOptions]::RemoveEmptyEntries ))) { $current = Join-Path $current $segment $components.Add($current) } $trusted = @{} foreach ($sid in @( "S-1-5-18", "S-1-5-32-544", "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value )) { $trusted[$sid] = $true } $dangerous = [System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership ) foreach ($component in $components) { $item = Get-Item -LiteralPath $component -Force -ErrorAction Stop if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Agent directory ancestor is not trusted." } $acl = Get-Acl -LiteralPath $component -ErrorAction Stop try { $owner = $acl.GetOwner( [System.Security.Principal.SecurityIdentifier] ).Value } catch { throw "Agent directory ancestor owner could not be verified." } if (-not $trusted.ContainsKey($owner)) { throw "Agent directory ancestor has an untrusted owner." } foreach ($rule in @($acl.GetAccessRules( $true, $true, [System.Security.Principal.SecurityIdentifier] ))) { if ( $rule.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and -not $trusted.ContainsKey($rule.IdentityReference.Value) -and ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -eq 0 -and ($rule.FileSystemRights -band $dangerous) -ne 0 ) { throw "Agent directory ancestor permits untrusted namespace replacement." } } } } function Open-MinerOpsEnrollmentDirectoryLock { param([Parameter(Mandatory = $true)][string]$Path) if (-not ("MinerOpsDirectoryLockNative" -as [type])) { Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; public static class MinerOpsDirectoryLockNative { [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern SafeFileHandle CreateFile(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr templateFile); } '@ } $handle = [MinerOpsDirectoryLockNative]::CreateFile( [IO.Path]::GetFullPath($Path), 0, 3, [IntPtr]::Zero, 3, 0x02200000, [IntPtr]::Zero ) if ($handle.IsInvalid) { $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() $handle.Dispose() throw (New-Object ComponentModel.Win32Exception( $code, "Agent directory could not be locked against replacement." )) } return $handle } function Test-MinerOpsEnrollmentDirectoryAcl { param([Parameter(Mandatory = $true)]$Acl) if (-not $Acl.AreAccessRulesProtected) { return $false } $trusted = @{} foreach ($sid in (Get-MinerOpsEnrollmentDirectorySids)) { $trusted[$sid] = $false } $usersRead = $false try { $owner = $Acl.GetOwner( [System.Security.Principal.SecurityIdentifier] ).Value } catch { return $false } if (-not $trusted.ContainsKey($owner)) { return $false } $writeLike = [System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::Write -bor [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership ) foreach ($rule in @($Acl.GetAccessRules( $true, $false, [System.Security.Principal.SecurityIdentifier] ))) { $sid = $rule.IdentityReference.Value if ( $rule.IsInherited -or $rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow ) { return $false } if ($trusted.ContainsKey($sid)) { if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { return $false } $trusted[$sid] = $true } elseif ($sid -eq "S-1-5-32-545") { if ( ($rule.FileSystemRights -band $writeLike) -ne 0 -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::ReadAndExecute) -ne [System.Security.AccessControl.FileSystemRights]::ReadAndExecute ) { return $false } $usersRead = $true } else { return $false } } return $usersRead -and -not ($trusted.Values -contains $false) } function Assert-MinerOpsEnrollmentDirectory { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = Assert-MinerOpsEnrollmentNoReparsePath -Path $Path [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $fullPath) if (-not (Test-MinerOpsEnrollmentDirectoryAcl ` -Acl (Get-Acl -LiteralPath $fullPath -ErrorAction Stop))) { throw "Agent directory owner or DACL is not trusted." } return $fullPath } function Protect-MinerOpsEnrollmentExistingConfig { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ExpectedParent ) if (-not (Test-Path -LiteralPath $Path)) { return } [void](Assert-MinerOpsEnrollmentDirectory -Path $ExpectedParent) $fullPath = [IO.Path]::GetFullPath($Path) $parent = [IO.Directory]::GetParent($fullPath).FullName.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $expected = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop if ( -not $parent.Equals($expected, [StringComparison]::OrdinalIgnoreCase) -or $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) ) { throw "Existing Agent config is not a regular file in the Agent directory." } $stream = $null try { $stream = Open-MinerOpsEnrollmentFileStreamWithRights ` -Path $fullPath ` -Rights ([System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership -bor [System.Security.AccessControl.FileSystemRights]::Synchronize )) Assert-MinerOpsEnrollmentSingleLinkStream -Stream $stream ` -Description "Existing Agent config" Set-MinerOpsEnrollmentStreamAcl -Stream $stream ` -Acl (New-MinerOpsEnrollmentCredentialAcl) if (-not (Test-MinerOpsEnrollmentCredentialAcl ` -Acl (Get-MinerOpsEnrollmentStreamAcl -Stream $stream))) { throw "Existing Agent config ACL could not be verified." } } finally { if ($null -ne $stream) { $stream.Dispose() } } } function Initialize-MinerOpsEnrollmentDirectory { param( [Parameter(Mandatory = $true)][string]$Path, [switch]$ApproveFilesystemTrustResetAfterReboot ) $requestedPath = [IO.Path]::GetFullPath($Path) if (-not (Test-Path -LiteralPath $requestedPath)) { $parent = [IO.Directory]::GetParent($requestedPath).FullName [void](Assert-MinerOpsEnrollmentNoReparsePath -Path $parent) [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $requestedPath) $staging = Join-Path $parent ( ".minerops-agent-enroll-{0}" -f [guid]::NewGuid().ToString("N") ) $stagingCreated = $false try { (New-Object IO.DirectoryInfo($staging)).Create( (New-MinerOpsEnrollmentDirectoryAcl) ) $stagingCreated = $true if (-not (Test-MinerOpsEnrollmentDirectoryAcl ` -Acl (Get-Acl -LiteralPath $staging -ErrorAction Stop))) { throw "Staged Agent directory DACL could not be verified." } [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $requestedPath) [IO.Directory]::Move($staging, $requestedPath) $stagingCreated = $false } finally { if ($stagingCreated -and (Test-Path -LiteralPath $staging)) { Remove-Item -LiteralPath $staging -Force -ErrorAction SilentlyContinue } } } $fullPath = Assert-MinerOpsEnrollmentNoReparsePath -Path $requestedPath [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $fullPath) $wasTrusted = Test-MinerOpsEnrollmentDirectoryAcl ` -Acl (Get-Acl -LiteralPath $fullPath -ErrorAction Stop) if (-not $wasTrusted) { $directoryLock = $null $trustBlockerPath = New-MinerOpsEnrollmentTrustBlocker ` -TargetRoot $fullPath try { $directoryLock = Open-MinerOpsEnrollmentDirectoryLock -Path $fullPath [void](Assert-MinerOpsEnrollmentNoReparsePath -Path $fullPath) [void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $fullPath) Set-Acl -LiteralPath $fullPath ` -AclObject (New-MinerOpsEnrollmentDirectoryAcl) [void](Assert-MinerOpsEnrollmentDirectory -Path $fullPath) } finally { if ($null -ne $directoryLock) { $directoryLock.Dispose() } } throw @" The existing Agent directory was untrusted. It was hardened for containment, but enrollment is blocked for this and every ordinary rerun. Reboot and follow the documented protected-recreation trust-reset procedure. No token was redeemed and no permanent credential was generated or written. "@ } [void](Assert-MinerOpsEnrollmentDirectory -Path $fullPath) $trustBlockers = @( Get-MinerOpsEnrollmentTrustBlockerPaths -Path $fullPath ) if ($trustBlockers.Count -gt 0) { throw @" Enrollment cannot clear an existing filesystem trust blocker, including with -ApproveFilesystemTrustResetAfterReboot. Preserve evidence and use the documented protected-recreation incident procedure. Only the authoritative existing-Agent credential-transition parent has in-script blocker-deletion authority after its complete convergence checks. "@ } Protect-MinerOpsEnrollmentExistingConfig ` -Path (Join-Path $fullPath "agent-config.json") ` -ExpectedParent $fullPath if (@(Get-ChildItem -LiteralPath $fullPath ` -Filter "minerops-credential-transition.blocked*.state" ` -Force -ErrorAction Stop).Count -gt 0) { throw "Credential transition is blocked pending operator review." } return $fullPath } function ConvertFrom-MinerOpsSecureString { param( [Parameter(Mandatory = $true)] [Security.SecureString] $SecureValue ) $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR( $SecureValue ) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } } # Validate the complete package before modifying the Agent host or consuming # the single-use enrollment token. foreach ($name in $RequiredFiles) { $source = Join-Path $PSScriptRoot $name if (!(Test-Path -LiteralPath $source -PathType Leaf)) { throw "Enrollment package is missing $name" } } $enrollment = Get-Content -LiteralPath $EnrollmentPath -Raw | ConvertFrom-Json if ([string]$enrollment.workflow -ne "new_agent") { throw @" This package is not authorized for a new Agent enrollment. Use the bootstrap script named by INSTALL.txt. "@ } $HubUrl = ([string] $enrollment.hub_url).TrimEnd("/") $EnrollmentToken = ([string] $enrollment.token).Trim() $parsedHub = $null if ( -not [Uri]::TryCreate( $HubUrl, [UriKind]::Absolute, [ref] $parsedHub ) -or $parsedHub.Scheme -notin @("http", "https") ) { throw "enrollment.json contains an invalid MinerOps Hub URL." } if ( $EnrollmentToken.Length -lt 32 -or $EnrollmentToken.Length -gt 256 ) { throw "enrollment.json contains an invalid enrollment token." } $Target = Initialize-MinerOpsEnrollmentDirectory ` -Path $Target ` -ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot $ConfigPath = Join-Path $Target "agent-config.json" $PendingConfigPath = Join-Path $Target "agent-config.json.pending" $RecoveryConfigPath = Join-Path $Target "agent-config.recovery.json" if (Test-Path -LiteralPath $ConfigPath) { throw @" C:\MinerOpsAgent\agent-config.json already exists. This enrollment bootstrapper is for a new Agent identity and will not overwrite an existing identity. Use the normal MinerOps upgrade procedure instead. "@ } foreach ($recoveryPath in @($PendingConfigPath, $RecoveryConfigPath)) { if (Test-Path -LiteralPath $recoveryPath) { throw @" A previous enrollment recovery file exists at: $recoveryPath Do not create or redeem another enrollment until that recovery state has been reviewed. "@ } } Write-Host "" Write-Host "MinerOps Windows Agent enrollment" Write-Host "Hub: $HubUrl" Write-Host "" Write-Host "API3 credentials stay only on this Windows Agent." Write-Host "Leave the account blank if API3 features are not being configured yet." Write-Host "" $Api3Account = (Read-Host "WhatsMiner API3 account").Trim() $Api3PasswordPlain = "" if ($Api3Account) { $Api3PasswordSecure = Read-Host ` "WhatsMiner API3 password" ` -AsSecureString $Api3PasswordPlain = ConvertFrom-MinerOpsSecureString ` -SecureValue $Api3PasswordSecure } $Redeemed = $false $ConfigPersisted = $false $pendingStream = $null $recoveryStream = $null $PendingCreatedByThisInvocation = $false $RecoveryCreatedByThisInvocation = $false try { [void](Assert-MinerOpsEnrollmentDirectory -Path $Target) # Create protected empty files BEFORE token redemption. If persistence is # not possible, fail before consuming the single-use token. $pendingStream = Open-MinerOpsEnrollmentCredentialFileCreateNew ` -Path $PendingConfigPath -ExpectedParent $Target $PendingCreatedByThisInvocation = $true $recoveryStream = Open-MinerOpsEnrollmentCredentialFileCreateNew ` -Path $RecoveryConfigPath -ExpectedParent $Target $RecoveryCreatedByThisInvocation = $true $requestBody = @{ token = $EnrollmentToken } | ConvertTo-Json -Compress Write-Host "" Write-Host "Redeeming one-time MinerOps enrollment..." $response = Invoke-RestMethod ` -Method Post ` -Uri "$HubUrl/api/agent/enroll" ` -ContentType "application/json" ` -Body $requestBody ` -TimeoutSec 30 $Redeemed = $true if (-not $response.data) { throw "MinerOps enrollment returned no Agent data." } $agent = $response.data foreach ($required in @( "agent_id", "agent_secret", "site_id", "site_name", "agent_zone_id", "agent_zone_name" )) { if (-not ([string] $agent.$required).Trim()) { throw "MinerOps enrollment response is missing $required." } } $ranges = @() foreach ($cidr in @($agent.ip_ranges)) { $network = ([string] $cidr).Trim() if (-not $network) { continue } $ranges += [pscustomobject]@{ name = "$($agent.agent_zone_name) - $network" cidr = $network enabled = $true api_port = 4028 api3_port = 4433 } } $config = [ordered]@{ hub_url = $HubUrl agent_id = [string] $agent.agent_id site_id = [string] $agent.site_id agent_zone_id = [string] $agent.agent_zone_id site_name = [string] $agent.site_name agent_secret = [string] $agent.agent_secret poll_interval_seconds = 120 ip_ranges = @($ranges) api3_account = $Api3Account api3_password = $Api3PasswordPlain api3_port = 4433 } $configJson = $config | ConvertTo-Json -Depth 20 # Write a protected recovery copy first. If anything after redemption # fails, the permanent Agent credential is not lost with the consumed token. Write-MinerOpsEnrollmentCredentialJson ` -Stream $recoveryStream -Json $configJson Write-MinerOpsEnrollmentCredentialJson ` -Stream $pendingStream -Json $configJson $recoveryStream.Dispose() $recoveryStream = $null $pendingStream.Dispose() $pendingStream = $null [void](Assert-MinerOpsEnrollmentDirectory -Path $Target) [IO.File]::Move($PendingConfigPath, $ConfigPath) $PendingCreatedByThisInvocation = $false Assert-MinerOpsEnrollmentCredentialFile ` -Path $ConfigPath -ExpectedParent $Target $ConfigPersisted = $true [void](Assert-MinerOpsEnrollmentDirectory -Path $Target) Remove-Item -LiteralPath $RecoveryConfigPath -Force $RecoveryCreatedByThisInvocation = $false # The extracted bootstrap token is no longer needed once permanent # credentials have been persisted locally. Remove-Item -LiteralPath $EnrollmentPath -Force $EnrollmentToken = $null $requestBody = $null $Api3PasswordPlain = $null Write-Host "Permanent Agent identity saved locally." Write-Host "Installing MinerOps Agent runtime..." & $Installer $mainTask = Get-ScheduledTask ` -TaskName "MinerOpsAgent" ` -ErrorAction Stop if ([string] $mainTask.State -ne "Running") { Start-ScheduledTask -TaskName "MinerOpsAgent" -ErrorAction Stop } Write-Host "" Write-Host "MinerOps Agent enrollment completed." Write-Host "Agent ID: $($agent.agent_id)" Write-Host "Site: $($agent.site_name)" Write-Host "Agent Zone: $($agent.agent_zone_name)" Write-Host "" Write-Host "Delete the downloaded ZIP and extracted setup folder after verification." } catch { if ($null -ne $pendingStream) { $pendingStream.Dispose() $pendingStream = $null } if ($null -ne $recoveryStream) { $recoveryStream.Dispose() $recoveryStream = $null } if (-not $Redeemed) { foreach ($temporary in @( @{ Path = $PendingConfigPath; Created = $PendingCreatedByThisInvocation }, @{ Path = $RecoveryConfigPath; Created = $RecoveryCreatedByThisInvocation } )) { if ([bool]$temporary.Created -and (Test-Path -LiteralPath ([string]$temporary.Path))) { Remove-Item ` -LiteralPath ([string]$temporary.Path) ` -Force ` -ErrorAction SilentlyContinue } } } elseif ($ConfigPersisted) { Write-Warning @" The enrollment token was consumed and the permanent Agent identity WAS saved to C:\MinerOpsAgent\agent-config.json. Do not create a replacement enrollment. Correct the installation problem and rerun install.ps1 from this package. "@ } else { Write-Warning @" The enrollment token was consumed. A protected credential recovery file may exist at: C:\MinerOpsAgent\agent-config.recovery.json Do not create or redeem another enrollment until that recovery file has been reviewed. "@ } throw }