Files
minerops/agents/windows/Enroll-MinerOpsAgent.ps1
T

1173 lines
42 KiB
PowerShell

#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
}