#Requires -RunAsAdministrator [CmdletBinding()] param( [switch]$ApproveFilesystemTrustResetAfterReboot ) $ErrorActionPreference = "Stop" $Target = "C:\MinerOpsAgent" $BackupRoot = Join-Path $Target "backups" $BackupTimestamp = Get-Date -Format "yyyyMMdd-HHmmss" $BackupSuffix = [guid]::NewGuid().ToString("N").Substring(0, 8) $Backup = Join-Path $BackupRoot "$BackupTimestamp-$BackupSuffix" $RuntimeFiles = @( "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", "MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1", "VERSION" ) function Get-MinerOpsInstallerDirectorySids { return @(@( "S-1-5-18", "S-1-5-32-544" ) | Select-Object -Unique) } function New-MinerOpsInstallerDirectoryAcl { $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-MinerOpsInstallerDirectorySids)) { $sid = New-Object System.Security.Principal.SecurityIdentifier($sidValue) $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $sid, [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 New-MinerOpsInstallerProtectedFileAcl { $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-MinerOpsInstallerDirectorySids)) { $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 New-MinerOpsInstallerRuntimeFileAcl { $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-MinerOpsInstallerDirectorySids)) { $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) } $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]::None, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($readRule) return $acl } function Test-MinerOpsInstallerRuntimeFileAcl { param([Parameter(Mandatory)]$Acl) $expected = @{ "S-1-5-18" = "full" "S-1-5-32-544" = "full" "S-1-5-32-545" = "read" } $seen = @{} try { $owner = $Acl.GetOwner( [System.Security.Principal.SecurityIdentifier] ).Value } catch { return $false } $trustedOwners = @( "S-1-5-18", "S-1-5-32-544", [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value ) | Select-Object -Unique if ($owner -notin $trustedOwners) { return $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.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $rule.InheritanceFlags -ne [System.Security.AccessControl.InheritanceFlags]::None -or $rule.PropagationFlags -ne [System.Security.AccessControl.PropagationFlags]::None ) { return $false } if ($expected[$sid] -eq "full" -and ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { return $false } if ($expected[$sid] -eq "read") { $writeLike = [System.Security.AccessControl.FileSystemRights]( [System.Security.AccessControl.FileSystemRights]::Write -bor [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership ) if ( ($rule.FileSystemRights -band $writeLike) -ne 0 -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::ReadAndExecute) -ne [System.Security.AccessControl.FileSystemRights]::ReadAndExecute ) { return $false } } $seen[$sid] = $true } return $seen.Count -eq $expected.Count } function Protect-MinerOpsInstallerRuntimeFile { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Description ) Assert-MinerOpsInstallerSingleLinkPath ` -Path $Path -Description $Description Set-Acl -LiteralPath $Path ` -AclObject (New-MinerOpsInstallerRuntimeFileAcl) ` -ErrorAction Stop if (-not (Test-MinerOpsInstallerRuntimeFileAcl ` -Acl (Get-Acl -LiteralPath $Path -ErrorAction Stop))) { throw "$Description ACL could not be verified." } Assert-MinerOpsInstallerSingleLinkPath ` -Path $Path -Description $Description } function Assert-MinerOpsInstallerTrustedRuntimeFile { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Description ) Assert-MinerOpsInstallerSingleLinkPath ` -Path $Path -Description $Description if (-not (Test-MinerOpsInstallerRuntimeFileAcl ` -Acl (Get-Acl -LiteralPath $Path -ErrorAction Stop))) { throw "$Description owner or DACL is not trusted rollback provenance." } } function Test-MinerOpsInstallerProtectedFileAcl { param([Parameter(Mandatory)]$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-MinerOpsInstallerDirectorySids)) { $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-MinerOpsInstallerStreamAcl { 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-MinerOpsInstallerProtectedFileCreateNewStream { 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-MinerOpsInstallerFileStreamWithRights { 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-MinerOpsInstallerStreamAcl { param([Parameter(Mandatory)]$Stream) if ($Stream.PSObject.Methods.Name -contains "GetAccessControl") { return $Stream.GetAccessControl() } return [System.IO.FileSystemAclExtensions]::GetAccessControl($Stream) } function Assert-MinerOpsInstallerSingleLinkStream { 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 Assert-MinerOpsInstallerSingleLinkPath { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Description ) $item = Get-Item -LiteralPath $Path -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 "$Description is not a regular single-link file." } $stream = $null try { $stream = [IO.File]::Open( [IO.Path]::GetFullPath($Path), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read ) Assert-MinerOpsInstallerSingleLinkStream ` -Stream $stream -Description $Description } finally { if ($null -ne $stream) { $stream.Dispose() } } } function Get-MinerOpsInstallerTrustBlockerPaths { 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-MinerOpsInstallerKernelBootEventRecord { 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-MinerOpsInstallerBootIdentifier { try { $events = @(Get-WinEvent -FilterHashtable @{ LogName = "System" ProviderName = "Microsoft-Windows-Kernel-Boot" Id = 27 } -ErrorAction Stop) $fullBootRecordIds = @($events | ForEach-Object { $record = ConvertFrom-MinerOpsInstallerKernelBootEventRecord ` -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 New-MinerOpsInstallerTrustBlocker { 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 { # This external sibling is durable even if a pre-repair handle can # still mutate children of the contaminated Agent directory. $stream = New-MinerOpsInstallerProtectedFileCreateNewStream ` -Path $path -Acl (New-MinerOpsInstallerProtectedFileAcl) if (-not (Test-MinerOpsInstallerProtectedFileAcl ` -Acl (Get-MinerOpsInstallerStreamAcl -Stream $stream))) { throw "Installer filesystem trust blocker ACL could not be verified." } Assert-MinerOpsInstallerSingleLinkStream -Stream $stream ` -Description "Installer 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-MinerOpsInstallerBootIdentifier 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 Assert-MinerOpsInstallerNoReparsePath { param([Parameter(Mandatory)][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-MinerOpsInstallerTrustedAncestors { param([Parameter(Mandatory)][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-MinerOpsInstallerDirectoryLock { param([Parameter(Mandatory)][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-MinerOpsInstallerDirectoryAcl { param([Parameter(Mandatory)]$Acl) if (-not $Acl.AreAccessRulesProtected) { return $false } $trusted = @{} foreach ($sid in (Get-MinerOpsInstallerDirectorySids)) { $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-MinerOpsInstallerDirectory { param([Parameter(Mandatory)][string]$Path) $fullPath = Assert-MinerOpsInstallerNoReparsePath -Path $Path [void](Assert-MinerOpsInstallerTrustedAncestors -Path $fullPath) if (-not (Test-MinerOpsInstallerDirectoryAcl ` -Acl (Get-Acl -LiteralPath $fullPath -ErrorAction Stop))) { throw "Agent directory owner or DACL is not trusted." } return $fullPath } function Get-MinerOpsInstallerTransitionArtifacts { param( [Parameter(Mandatory)][string]$Path, [string]$ExceptPath = "" ) $exceptFull = if ($ExceptPath) { [IO.Path]::GetFullPath($ExceptPath) } else { "" } return @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop | Where-Object { $full = [IO.Path]::GetFullPath($_.FullName) -not ($exceptFull -and $full.Equals( $exceptFull, [StringComparison]::OrdinalIgnoreCase )) -and ( $_.Name -eq "agent-credential-transition.recovery.json" -or $_.Name -eq "agent-config.json.pending" -or $_.Name -like "agent-config.pre-credential-transition*" -or $_.Name -like "agent-config.json.*" -or $_.Name -like "minerops-quarantine-credential-transition-*" -or $_.Name -eq "minerops-credential-transition-quarantine" -or $_.Name -like "minerops-credential-transition.blocked*.state" ) }) } function Assert-MinerOpsInstallerChildPath { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$ExpectedParent, [switch]$Directory ) $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 ) if (-not $parent.Equals($expected, [StringComparison]::OrdinalIgnoreCase)) { throw "Installer target is outside the trusted Agent directory." } if (-not (Test-Path -LiteralPath $fullPath)) { return $fullPath } $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop if ( ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($Directory -and -not $item.PSIsContainer) -or (-not $Directory -and $item.PSIsContainer) ) { throw "Installer target is a reparse point or has the wrong type." } return $fullPath } function Protect-MinerOpsInstallerExistingConfig { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$ExpectedParent ) if (-not (Test-Path -LiteralPath $Path)) { return } [void](Assert-MinerOpsInstallerDirectory -Path $ExpectedParent) $fullPath = Assert-MinerOpsInstallerChildPath ` -Path $Path -ExpectedParent $ExpectedParent Assert-MinerOpsInstallerSingleLinkPath ` -Path $fullPath -Description "Existing Agent config" $stream = $null try { $stream = Open-MinerOpsInstallerFileStreamWithRights ` -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-MinerOpsInstallerSingleLinkStream -Stream $stream ` -Description "Existing Agent config" Set-MinerOpsInstallerStreamAcl -Stream $stream ` -Acl (New-MinerOpsInstallerProtectedFileAcl) if (-not (Test-MinerOpsInstallerProtectedFileAcl ` -Acl (Get-MinerOpsInstallerStreamAcl -Stream $stream))) { throw "Existing Agent config ACL could not be verified." } } finally { if ($null -ne $stream) { $stream.Dispose() } } } function Initialize-MinerOpsInstallerDirectory { param( [Parameter(Mandatory)][string]$Path, [switch]$ApproveFilesystemTrustResetAfterReboot ) $requestedPath = [IO.Path]::GetFullPath($Path) $createdNew = $false if (-not (Test-Path -LiteralPath $requestedPath)) { $parent = [IO.Directory]::GetParent($requestedPath).FullName [void](Assert-MinerOpsInstallerNoReparsePath -Path $parent) [void](Assert-MinerOpsInstallerTrustedAncestors -Path $requestedPath) $staging = Join-Path $parent ( ".minerops-agent-create-{0}" -f [guid]::NewGuid().ToString("N") ) $stagingCreated = $false try { $directoryInfo = New-Object IO.DirectoryInfo($staging) $directoryInfo.Create((New-MinerOpsInstallerDirectoryAcl)) $stagingCreated = $true if (-not (Test-MinerOpsInstallerDirectoryAcl ` -Acl (Get-Acl -LiteralPath $staging -ErrorAction Stop))) { throw "Staged Agent directory DACL could not be verified." } [void](Assert-MinerOpsInstallerTrustedAncestors -Path $requestedPath) [IO.Directory]::Move($staging, $requestedPath) $stagingCreated = $false $createdNew = $true } finally { if ($stagingCreated -and (Test-Path -LiteralPath $staging)) { Remove-Item -LiteralPath $staging -Force -ErrorAction SilentlyContinue } } } $fullPath = Assert-MinerOpsInstallerNoReparsePath -Path $requestedPath [void](Assert-MinerOpsInstallerTrustedAncestors -Path $fullPath) $wasTrusted = Test-MinerOpsInstallerDirectoryAcl ` -Acl (Get-Acl -LiteralPath $fullPath -ErrorAction Stop) if (-not $wasTrusted) { $directoryLock = $null $trustBlockerPath = New-MinerOpsInstallerTrustBlocker ` -TargetRoot $fullPath try { $directoryLock = Open-MinerOpsInstallerDirectoryLock -Path $fullPath [void](Assert-MinerOpsInstallerNoReparsePath -Path $fullPath) [void](Assert-MinerOpsInstallerTrustedAncestors -Path $fullPath) Set-Acl -LiteralPath $fullPath ` -AclObject (New-MinerOpsInstallerDirectoryAcl) [void](Assert-MinerOpsInstallerDirectory -Path $fullPath) } finally { if ($null -ne $directoryLock) { $directoryLock.Dispose() } } throw @" The existing Agent directory was untrusted. It was hardened for containment, but this invocation will not back up, restore, or publish runtime content. Reboot and use the documented explicit trust-reset procedure. The external filesystem trust blocker remains in force. "@ } [void](Assert-MinerOpsInstallerDirectory -Path $fullPath) $trustResetMode = $false $trustBlockers = @( Get-MinerOpsInstallerTrustBlockerPaths -Path $fullPath ) if ($trustBlockers.Count -gt 0) { if (-not $ApproveFilesystemTrustResetAfterReboot) { throw "Installer is blocked pending an explicit post-reboot filesystem trust reset." } # This mode is publication only. The credential-transition parent is # solely responsible for boot authorization, strict task validation, # convergence, and eventual blocker deletion. A direct installer run # can therefore never complete the filesystem trust reset. $trustResetMode = $true } Protect-MinerOpsInstallerExistingConfig ` -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 [pscustomobject]@{ Path = $fullPath CreatedNew = $createdNew TrustResetMode = $trustResetMode TrustBlockerPaths = @($trustBlockers.FullName) } } function Get-MinerOpsScheduledTask { param([Parameter(Mandatory)][string]$Name) $tasks = @(Get-ScheduledTask -ErrorAction Stop | Where-Object { [string]$_.TaskName -ceq $Name }) if ($tasks.Count -gt 1) { throw "Scheduled Task query returned multiple results for $Name." } if ($tasks.Count -eq 0) { return $null } return $tasks[0] } function Test-MinerOpsInstallerShouldCreateMissingTask { param( [Parameter(Mandatory)]$Definition, [Parameter(Mandatory)][bool]$TrustResetMode ) if (-not $TrustResetMode) { return $true } if ($null -eq $Definition.CreateWhenMissingDuringTrustReset) { throw "Scheduled Task trust-reset creation policy is missing." } return [bool]$Definition.CreateWhenMissingDuringTrustReset } function Stop-MinerOpsScheduledTaskForUpgrade { param( [Parameter(Mandatory)][string]$Name, [TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30) ) Stop-ScheduledTask -TaskName $Name -ErrorAction Stop $deadline = [DateTime]::UtcNow.Add($Timeout) do { Start-Sleep -Milliseconds 500 $task = Get-MinerOpsScheduledTask -Name $Name if ($null -eq $task) { throw "Scheduled Task $Name disappeared while stopping it." } if ([string]$task.State -eq "Ready") { return } if ([string]$task.State -ne "Running") { throw "Scheduled Task $Name entered invalid state '$($task.State)' while stopping." } } while ([DateTime]::UtcNow -lt $deadline) throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds." } function Start-MinerOpsScheduledTask { param([Parameter(Mandatory)][string]$Name) Start-ScheduledTask -TaskName $Name -ErrorAction Stop } # Validate the complete source before creating directories, stopping tasks, or # replacing any target file. foreach ($name in $RuntimeFiles) { $source = Join-Path $PSScriptRoot $name if (!(Test-Path -LiteralPath $source -PathType Leaf)) { throw "Deployment source is missing $name" } Assert-MinerOpsInstallerSingleLinkPath ` -Path $source -Description "Deployment source $name" } $mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent" $mainTaskExisted = $null -ne $mainTask $mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running" $mainTaskStoppedForUpgrade = $false $deploymentStarted = $false $trustResetMode = $false $existing = @() $createdTaskNames = [System.Collections.Generic.List[string]]::new() try { $directoryState = Initialize-MinerOpsInstallerDirectory ` -Path $Target ` -ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot $Target = [string]$directoryState.Path $trustResetMode = [bool]$directoryState.TrustResetMode $BackupRoot = Join-Path $Target "backups" [void](Assert-MinerOpsInstallerDirectory -Path $Target) $existing = @($RuntimeFiles | Where-Object { $candidate = Join-Path $Target $_ [void](Assert-MinerOpsInstallerChildPath ` -Path $candidate -ExpectedParent $Target) if (Test-Path -LiteralPath $candidate -PathType Leaf) { Assert-MinerOpsInstallerSingleLinkPath ` -Path $candidate -Description "Installed runtime $_" if (-not $trustResetMode) { Assert-MinerOpsInstallerTrustedRuntimeFile ` -Path $candidate -Description "Installed runtime $_" } return $true } return $false }) if (-not $trustResetMode) { [void](Assert-MinerOpsInstallerChildPath ` -Path $BackupRoot -ExpectedParent $Target -Directory) if (-not (Test-Path -LiteralPath $BackupRoot)) { New-Item -ItemType Directory -Path $BackupRoot | Out-Null } Set-Acl -LiteralPath $BackupRoot ` -AclObject (New-MinerOpsInstallerDirectoryAcl) if (-not (Test-MinerOpsInstallerDirectoryAcl ` -Acl (Get-Acl -LiteralPath $BackupRoot -ErrorAction Stop))) { throw "Backup directory owner or DACL could not be verified." } if ($existing.Count -gt 0) { [void](Assert-MinerOpsInstallerDirectory -Path $Target) if (Test-Path -LiteralPath $Backup) { throw "Random installer backup destination already exists." } New-Item -ItemType Directory -Path $Backup | Out-Null Set-Acl -LiteralPath $Backup ` -AclObject (New-MinerOpsInstallerDirectoryAcl) foreach ($name in $existing) { $backupPath = Join-Path $Backup $name Copy-Item -LiteralPath (Join-Path $Target $name) ` -Destination $backupPath Protect-MinerOpsInstallerRuntimeFile ` -Path $backupPath -Description "Installer rollback copy $name" } } } if ($mainTaskWasRunning) { $mainTaskStoppedForUpgrade = $true Stop-MinerOpsScheduledTaskForUpgrade -Name "MinerOpsAgent" } $deploymentStarted = $true foreach ($name in $RuntimeFiles) { [void](Assert-MinerOpsInstallerDirectory -Path $Target) [void](Assert-MinerOpsInstallerChildPath ` -Path (Join-Path $Target $name) -ExpectedParent $Target) $targetPath = Join-Path $Target $name if ($trustResetMode -and (Test-Path -LiteralPath $targetPath)) { # Pre-reset runtime is evidence, never rollback authority. Reject # aliases, then remove it without reading before publishing trusted # package content into the now handle-clean namespace. Assert-MinerOpsInstallerSingleLinkPath ` -Path $targetPath -Description "Pre-reset runtime $name" Remove-Item -LiteralPath $targetPath -Force -ErrorAction Stop } Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) ` -Destination $targetPath -Force Protect-MinerOpsInstallerRuntimeFile ` -Path $targetPath -Description "Published runtime $name" } $powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" $system = New-ScheduledTaskPrincipal -UserId "SYSTEM" ` -LogonType ServiceAccount -RunLevel Highest $startup = New-ScheduledTaskTrigger -AtStartup $fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` -RepetitionInterval (New-TimeSpan -Minutes 5) $commonSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew ` -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit (New-TimeSpan -Hours 72) $agentSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew ` -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit (New-TimeSpan -Hours 72) -RestartCount 3 ` -RestartInterval (New-TimeSpan -Minutes 1) $definitions = @( @{ Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1" Trigger = $startup; Principal = $system; Settings = $agentSettings ExtraArguments = "" CreateWhenMissingDuringTrustReset = $true }, @{ Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1" Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden" CreateWhenMissingDuringTrustReset = $false }, @{ Name = "MinerOpsApi3Enrichment" Script = "MinerOpsApi3EnrichmentPoller.ps1" Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings ExtraArguments = "-Limit 0 -TimeoutMs 8000" CreateWhenMissingDuringTrustReset = $true }, @{ Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1" Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings ExtraArguments = "" CreateWhenMissingDuringTrustReset = $true } ) foreach ($definition in $definitions) { $existingTask = Get-MinerOpsScheduledTask -Name $definition.Name if ($null -ne $existingTask) { # Existing definitions remain authoritative and are never silently # rewritten under SYSTEM. A suspect auxiliary task requires # separate operator review; it is not credential authority. Write-Host "Preserving existing Scheduled Task $($definition.Name)." continue } if (-not (Test-MinerOpsInstallerShouldCreateMissingTask ` -Definition $definition -TrustResetMode $trustResetMode)) { # Trust-reset publication can run noninteractively as SYSTEM. A # missing per-user tray has no safe interactive principal in that # context. Its runtime file is already published; task creation is # intentionally deferred to a later interactive maintenance run. Write-Host "Deferring missing per-user Scheduled Task $($definition.Name) until an interactive install." continue } $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f ` (Join-Path $Target $definition.Script) if ($definition.ExtraArguments) { $arguments = "$arguments $($definition.ExtraArguments)" } $action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments $registration = @{ TaskName = $definition.Name Action = $action Trigger = $definition.Trigger Settings = $definition.Settings } if ($definition.Principal) { $registration.Principal = $definition.Principal } Register-ScheduledTask @registration | Out-Null $createdTaskNames.Add($definition.Name) } if ($mainTaskWasRunning -and -not $trustResetMode) { Start-MinerOpsScheduledTask -Name "MinerOpsAgent" $mainTaskStoppedForUpgrade = $false } elseif ($trustResetMode) { # A trust reset never starts an Agent against a merely contained # pre-reset config. The credential-transition workflow positively # verifies that config and restores the durable task intent later. $mainTaskStoppedForUpgrade = $false } if ($trustResetMode) { Write-Host "MinerOps Windows Agent runtime was published; the parent-owned filesystem trust blocker remains." } else { Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved." } } catch { $deploymentError = $_ $rollbackErrors = [System.Collections.Generic.List[string]]::new() foreach ($taskName in $createdTaskNames) { try { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false ` -ErrorAction Stop } catch { $rollbackErrors.Add("could not remove new task ${taskName}: $($_.Exception.Message)") } } if ($deploymentStarted -and -not $trustResetMode) { foreach ($name in $RuntimeFiles) { try { $targetPath = Join-Path $Target $name if ($existing -contains $name) { Assert-MinerOpsInstallerSingleLinkPath ` -Path (Join-Path $Backup $name) ` -Description "Installer rollback source $name" Assert-MinerOpsInstallerTrustedRuntimeFile ` -Path (Join-Path $Backup $name) ` -Description "Installer rollback source $name" if (Test-Path -LiteralPath $targetPath) { Assert-MinerOpsInstallerSingleLinkPath ` -Path $targetPath ` -Description "Failed published runtime $name" } Copy-Item -LiteralPath (Join-Path $Backup $name) ` -Destination $targetPath -Force Protect-MinerOpsInstallerRuntimeFile ` -Path $targetPath ` -Description "Restored runtime $name" } elseif (Test-Path -LiteralPath $targetPath) { Remove-Item -LiteralPath $targetPath -Force } } catch { $rollbackErrors.Add("could not restore ${name}: $($_.Exception.Message)") } } } if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade -and -not $trustResetMode) { try { Start-MinerOpsScheduledTask -Name "MinerOpsAgent" $mainTaskStoppedForUpgrade = $false } catch { $rollbackErrors.Add("could not restart restored MinerOpsAgent: $($_.Exception.Message)") } } if ($rollbackErrors.Count -gt 0) { $rollbackSummary = $rollbackErrors -join "; " throw "Deployment failed: $($deploymentError.Exception.Message). Rollback errors: $rollbackSummary" } throw $deploymentError }