Files
minerops/agents/windows/EnrichOneMiner.ps1
T
lussierj e898f283ab security: contain physical miner commands in Stage A
Disable physical control through legacy HTTP/M30 producers, typed admission,
queue dispatch and upgraded Windows executors. Restrict diagnostic transports
to exact read-only operations, with ordinal PowerShell validation before sockets.

Retire unsupported pending/claimed work with atomic, idempotent security audits.
Preserve prior delivery evidence and report unknown outcomes after a prior claim,
including explicitly repaired pending rows. Include containment, cancellation,
concurrency and malformed-payload regressions and the old-Agent cutover runbook.

Validation: independent final application-security revalidation PASS. Two unchanged
baseline source-assertion failures are documented as exceptions. Production release
still requires native Windows validation and verified fencing/stop/drain/upgrade;
server cancellation alone cannot revoke a cached command held by an old Agent.
2026-10-15 20:39:45 +00:00

270 lines
7.3 KiB
PowerShell

param(
[Parameter(Mandatory=$true)]
[string]$IpAddress,
[string]$MinerId = ""
)
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
function Read-AgentConfig {
if (!(Test-Path $ConfigPath)) {
throw "Agent config not found: $ConfigPath"
}
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
}
function Invoke-MinerCommandRaw {
param(
[string]$IpAddress,
[string]$Command,
[int]$Port = 4028,
[int]$TimeoutMs = 2500
)
# This transport accepts only the exact queries used by this enrichment script.
# Validate before creating a socket; callers cannot reinterpret a write as a read.
$readCommands = @("summary", "devs", "edevs", "stats", "pools", "devdetails",
"version", "get_version", "get_miner_info")
# PowerShell containment operators use culture comparisons, even with -c.
# Match ordinally and serialize the canonical literal; never trim/normalize.
$approvedCommand = $null
foreach ($candidate in $readCommands) {
if ([string]::Equals($candidate, $Command, [StringComparison]::Ordinal)) {
$approvedCommand = $candidate
break
}
}
if ($Port -ne 4028 -or $null -eq $approvedCommand) {
throw "unsupported_diagnostic_operation_stage_a"
}
$requestJson = @{ cmd = $approvedCommand } | ConvertTo-Json -Compress
$client = $null
try {
$client = New-Object System.Net.Sockets.TcpClient
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
return ""
}
$client.EndConnect($async)
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$stream = $client.GetStream()
$payload = [System.Text.Encoding]::ASCII.GetBytes($requestJson + "`n")
$stream.Write($payload, 0, $payload.Length)
try {
$client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
} catch {
}
$buffer = New-Object byte[] 262144
$chunks = New-Object System.Collections.ArrayList
$started = Get-Date
while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) {
if (-not $stream.DataAvailable) {
Start-Sleep -Milliseconds 20
continue
}
$read = $stream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) {
break
}
for ($i = 0; $i -lt $read; $i++) {
[void]$chunks.Add([byte]$buffer[$i])
}
if ($chunks.Count -gt 2097152) {
break
}
}
return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim()
} catch {
return ""
} finally {
if ($client -ne $null) {
$client.Close()
}
}
}
function Try-ParseJson {
param([string]$Raw)
if (!$Raw -or $Raw.Trim().Length -eq 0) {
return $null
}
try {
return $Raw | ConvertFrom-Json
} catch {
return $null
}
}
function Find-FirstValue {
param(
[object]$Object,
[string[]]$Names
)
if ($null -eq $Object) {
return ""
}
$json = $Object | ConvertTo-Json -Depth 80 -Compress
foreach ($name in $Names) {
$escaped = [regex]::Escape($name)
$pattern = '"' + $escaped + '"\s*:\s*"([^"]+)"'
$m = [regex]::Match($json, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($m.Success) {
return $m.Groups[1].Value
}
$pattern2 = '"' + $escaped + '"\s*:\s*([0-9A-Za-z\.\-_:]+)'
$m2 = [regex]::Match($json, $pattern2, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($m2.Success) {
return $m2.Groups[1].Value
}
}
return ""
}
function Find-MacAddress {
param([string]$Text)
if (!$Text) {
return ""
}
$m = [regex]::Match($Text, '([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}')
if ($m.Success) {
return $m.Value.Replace("-", ":").ToUpper()
}
return ""
}
$config = Read-AgentConfig
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
$secret = [string]$config.agent_secret
$agentId = [string]$config.agent_id
$commands = @(
"summary",
"devs",
"edevs",
"stats",
"pools",
"devdetails",
"version",
"get_version",
"get_miner_info"
)
$raws = [ordered]@{}
$parsed = [ordered]@{}
foreach ($cmd in $commands) {
Write-Host "Querying $IpAddress command: $cmd"
$raw = Invoke-MinerCommandRaw -IpAddress $IpAddress -Command $cmd -TimeoutMs 2500
$raws[$cmd] = $raw
$parsed[$cmd] = Try-ParseJson -Raw $raw
Write-Host " bytes: $($raw.Length)"
}
$allText = ($raws.GetEnumerator() | ForEach-Object { $_.Value }) -join "`n"
$allParsed = [ordered]@{
summary = $parsed["summary"]
devs = $parsed["devs"]
edevs = $parsed["edevs"]
stats = $parsed["stats"]
pools = $parsed["pools"]
devdetails = $parsed["devdetails"]
version = $parsed["version"]
get_version = $parsed["get_version"]
get_miner_info = $parsed["get_miner_info"]
}
$mac = Find-FirstValue -Object $allParsed -Names @("MAC", "Mac", "mac", "macaddr", "mac_address", "Net MAC", "ethaddr", "Ethernet MAC")
if (!$mac) {
$mac = Find-MacAddress -Text $allText
}
$hostname = Find-FirstValue -Object $allParsed -Names @("Hostname", "hostname", "host", "Host")
$minerSn = Find-FirstValue -Object $allParsed -Names @("Miner SN", "miner_sn", "miner sn", "Serial", "serial", "SN", "sn", "M_Serial")
$firmware = Find-FirstValue -Object $allParsed -Names @("Firmware", "firmware", "firmware_version", "Soft Version", "Version", "CompileTime")
$apiVersion = Find-FirstValue -Object $allParsed -Names @("API", "api", "api_version", "Api Version")
$platform = Find-FirstValue -Object $allParsed -Names @("Platform", "platform", "Machine", "machine")
$model = Find-FirstValue -Object $allParsed -Names @("Model", "model", "Miner Type", "miner_type", "Type", "type")
if (!$model) {
$model = "WhatsMiner"
}
$payload = [ordered]@{
ip_address = $IpAddress
miner_id = $MinerId
mac_address = $mac
hostname = $hostname
miner_sn = $minerSn
model = $model
miner_type = $model
api_version = $apiVersion
firmware_version = $firmware
platform = $platform
raw_version = @{
version = $raws["version"]
get_version = $raws["get_version"]
}
raw_miner_info = @{
devs = $raws["devs"]
edevs = $raws["edevs"]
stats = $raws["stats"]
pools = $raws["pools"]
devdetails = $raws["devdetails"]
get_miner_info = $raws["get_miner_info"]
}
}
Write-Host ""
Write-Host "Detected:"
Write-Host " MAC: $mac"
Write-Host " Hostname: $hostname"
Write-Host " SN: $minerSn"
Write-Host " Model: $model"
Write-Host " Firmware: $firmware"
Write-Host " API: $apiVersion"
Write-Host " Platform: $platform"
Write-Host ""
$response = Invoke-RestMethod `
-Method Post `
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
-Headers @{
"X-MinerOps-Agent-ID" = $agentId
"X-MinerOps-Agent-Secret" = $secret
} `
-ContentType "application/json" `
-Body ($payload | ConvertTo-Json -Depth 80)
$response | ConvertTo-Json -Depth 20