feat: add self-enrolling Windows Agent package
This commit is contained in:
1 parent
b9b5e5e36f
commit
abf5654a25
20 files changed
+1834
-42
No files matched your search
@@ -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
@@ -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."
|
||||
Reference in new issue
Block a user