feat: add self-enrolling Windows Agent package

This commit is contained in:
lussierj committed 2026-08-28 17:50:39 +00:00
1 parent b9b5e5e36f
commit abf5654a25
20 files changed
+1834 -42

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
#Requires -RunAsAdministrator
[CmdletBinding()]
param()
$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 Protect-MinerOpsCredentialFile {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$identities = @(
(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")),
(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")),
[System.Security.Principal.WindowsIdentity]::GetCurrent().User
)
foreach ($identity in $identities) {
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$identity,
[System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.InheritanceFlags]::None,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Allow
)
$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $Path -AclObject $acl
}
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"
}
}
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.
"@
}
}
$enrollment = Get-Content -LiteralPath $EnrollmentPath -Raw |
ConvertFrom-Json
$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."
}
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
try {
New-Item -ItemType Directory -Force -Path $Target | Out-Null
# Create protected empty files BEFORE token redemption. If persistence is
# not possible, fail before consuming the single-use token.
[IO.File]::WriteAllText($PendingConfigPath, "")
Protect-MinerOpsCredentialFile -Path $PendingConfigPath
[IO.File]::WriteAllText($RecoveryConfigPath, "")
Protect-MinerOpsCredentialFile -Path $RecoveryConfigPath
$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
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Write a protected recovery copy first. If anything after redemption
# fails, the permanent Agent credential is not lost with the consumed token.
[IO.File]::WriteAllText(
$RecoveryConfigPath,
$configJson + [Environment]::NewLine,
$utf8NoBom
)
[IO.File]::WriteAllText(
$PendingConfigPath,
$configJson + [Environment]::NewLine,
$utf8NoBom
)
Move-Item `
-LiteralPath $PendingConfigPath `
-Destination $ConfigPath
$ConfigPersisted = $true
Remove-Item -LiteralPath $RecoveryConfigPath -Force
# 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 (-not $Redeemed) {
foreach ($temporaryPath in @(
$PendingConfigPath,
$RecoveryConfigPath
)) {
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item `
-LiteralPath $temporaryPath `
-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
}
+49 -29
View File
@@ -30,9 +30,12 @@ API3 account and password values are local secrets. Missing credentials make
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
must not be written to normal logs or returned to MinerOps command history.
The Internal Alpha supports one Agent identity protected by the deployment
shared secret. A second active Agent requires per-Agent credentials before it
may be enabled.
New Agent identities use per-Agent credentials created through secure
one-time enrollment. The permanent Agent secret is returned only to the
enrolling Agent and is stored locally in `agent-config.json`; MinerOps stores
only its hash. The legacy deployment shared secret remains only for explicitly
retained compatibility behavior and must not authenticate a newly enrolled
per-Agent identity.
## Legacy Compatibility
@@ -43,35 +46,52 @@ Agent queue; the modular control backend does not connect directly to miners.
## Deployment
Deploy Agent compatibility before enabling Hub enforcement of the
`X-MinerOps-Agent-ID` header:
### New Agent enrollment
1. Copy this directory to a temporary folder on the Windows Agent host.
2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass
secrets as installer or scheduled-task arguments.
3. Run `install.ps1` from an elevated Windows PowerShell session.
4. Confirm the four scheduled tasks are registered and that Agent check-in
reports the expected non-secret `VERSION`.
5. Enable the matching Agent ID and shared secret on the Hub.
For a new Agent, use Agent Management in MinerOps:
The installer copies only managed runtime code to `C:\MinerOpsAgent` and
preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe
output, and other runtime data. During an upgrade, existing definitions for
`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and
`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are
created for a fresh or incomplete installation without hard-coded user names.
1. Choose the Site and an enabled, unassigned Agent Zone.
2. Enter a descriptive Agent name.
3. Create a Windows enrollment.
4. Download MinerOpsAgent-Windows.zip before the enrollment expires.
5. Copy the ZIP to the intended Windows machine and extract it.
6. Open Windows PowerShell as Administrator in the extracted
MinerOpsAgent-Windows directory.
7. Run:
If `MinerOpsAgent` was running before an upgrade, the installer stops only that
.\Enroll-MinerOpsAgent.ps1
8. Enter the local WhatsMiner API3 account/password when prompted, or leave
the account blank if API3 functionality is not being configured yet.
9. The bootstrapper exchanges the short-lived token with POST /api/agent/enroll,
saves the resulting permanent Agent identity locally, and hands off to the
safe runtime installer.
10. Verify the Agent appears in Agent Management and checks in successfully.
11. Delete the downloaded ZIP and extracted setup directory.
Do not place enrollment.json, enrollment tokens, Agent secrets, or API3
credentials in screenshots, chat, tickets, logs, or source control.
### Existing Agent upgrade
For an already enrolled/configured Agent, do not run the enrollment
bootstrapper. Copy the new canonical Agent files to a temporary directory and
run install.ps1 from an elevated Windows PowerShell session.
The installer copies only managed runtime code to C:\MinerOpsAgent and
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
and other runtime data. Existing Scheduled Task definitions are authoritative
and are not rewritten. Missing tasks are created for a fresh or incomplete
installation without hard-coded user names.
If MinerOpsAgent was running before an upgrade, the installer stops only that
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
again. A previously stopped Agent remains stopped. The tray and periodic tasks
are not unnecessarily restarted.
again. A previously stopped Agent remains stopped. Before replacing runtime
files, the installer copies them to a timestamped backup under
C:\MinerOpsAgent\backups.
Before replacing existing runtime files, the installer copies them to a
timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment
automatically restores those managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state. For a manual
rollback, stop `MinerOpsAgent`, restore the selected backup's runtime files,
and restart it only if it was previously running. `uninstall.ps1` unregisters
the four tasks and removes runtime code while preserving local configuration,
state, logs, and backups.
A failed deployment restores managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state.
uninstall.ps1 unregisters the four MinerOps Scheduled Tasks and removes runtime
code while preserving local configuration, state, logs, and backups.
@@ -0,0 +1,117 @@
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
$bootstrap = Join-Path $root "Enroll-MinerOpsAgent.ps1"
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$bootstrap,
[ref] $tokens,
[ref] $parseErrors
) | Out-Null
if ($parseErrors.Count -gt 0) {
throw "Enroll-MinerOpsAgent.ps1 parser errors: $($parseErrors.Message -join '; ')"
}
$source = Get-Content -LiteralPath $bootstrap -Raw
function Assert-SourceContains {
param(
[Parameter(Mandatory)][string] $Pattern,
[Parameter(Mandatory)][string] $Message
)
if ($source -notmatch $Pattern) {
throw $Message
}
}
Assert-SourceContains 'enrollment\.json' `
"Bootstrap enrollment file is not explicit."
Assert-SourceContains '/api/agent/enroll' `
"Bootstrap does not use the secure enrollment endpoint."
Assert-SourceContains 'Read-Host[\s\S]*-AsSecureString' `
"API3 password is not prompted as a SecureString."
Assert-SourceContains 'agent-config\.json\.pending' `
"Atomic pending config path is missing."
Assert-SourceContains 'agent-config\.recovery\.json' `
"Post-redemption credential recovery path is missing."
Assert-SourceContains 'Protect-MinerOpsCredentialFile' `
"Credential file ACL protection is missing."
Assert-SourceContains 'S-1-5-18' `
"SYSTEM ACL entry is missing."
Assert-SourceContains 'S-1-5-32-544' `
"Administrators ACL entry is missing."
Assert-SourceContains 'WindowsIdentity\]::GetCurrent\(\)\.User' `
"Installing user ACL entry is missing."
Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' `
"Atomic final config move is missing."
Assert-SourceContains '& \$Installer' `
"Bootstrap does not hand off to the safe installer."
Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
"Fresh Agent startup is missing."
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
"Consumed extracted enrollment token is not removed."
$existingConfigGuard = $source.IndexOf(
'Test-Path -LiteralPath $ConfigPath'
)
$redeemCall = $source.IndexOf(
'"$HubUrl/api/agent/enroll"'
)
if (
$existingConfigGuard -lt 0 -or
$redeemCall -lt 0 -or
$existingConfigGuard -gt $redeemCall
) {
throw "Existing Agent identity must be rejected before token redemption."
}
$configMove = $source.IndexOf(
'-Destination $ConfigPath'
)
$installerCall = $source.IndexOf(
'& $Installer'
)
if (
$configMove -lt 0 -or
$installerCall -lt 0 -or
$configMove -gt $installerCall
) {
throw "Permanent Agent credentials must be persisted before installation."
}
foreach ($sensitiveVariable in @(
'EnrollmentToken',
'Api3PasswordPlain'
)) {
if (
$source -match (
'(?im)Write-(Host|Output|Verbose|Information).*?\$' +
$sensitiveVariable
)
) {
throw "$sensitiveVariable must not be written to output."
}
}
Write-Host "MinerOps enrollment bootstrap source safety tests passed."