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.
This commit is contained in:
1 parent
2d89e80410
commit
e898f283ab
28 files changed
+2377
-2379
No files matched your search
@@ -23,6 +23,24 @@ function Invoke-MinerCommandRaw {
|
||||
[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 {
|
||||
@@ -38,7 +56,7 @@ function Invoke-MinerCommandRaw {
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n")
|
||||
$payload = [System.Text.Encoding]::ASCII.GetBytes($requestJson + "`n")
|
||||
$stream.Write($payload, 0, $payload.Length)
|
||||
|
||||
try {
|
||||
|
||||
@@ -743,61 +743,7 @@ function Invoke-MinerPlainCommand {
|
||||
|
||||
[int]$TimeoutMs = 2500
|
||||
)
|
||||
|
||||
$client = $null
|
||||
|
||||
try {
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
|
||||
|
||||
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
|
||||
throw "Connect timeout to $IpAddress`:$Port"
|
||||
}
|
||||
|
||||
$client.EndConnect($async)
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n")
|
||||
$stream.Write($payload, 0, $payload.Length)
|
||||
|
||||
try {
|
||||
$client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
|
||||
} catch {
|
||||
}
|
||||
|
||||
$buffer = New-Object byte[] 65536
|
||||
$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 1048576) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim()
|
||||
} finally {
|
||||
if ($client -ne $null) {
|
||||
$client.Close()
|
||||
}
|
||||
}
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
function Get-MinerOpsApi3Credential {
|
||||
@@ -894,61 +840,7 @@ function Invoke-MinerOpsApi3Payload {
|
||||
|
||||
[int]$TimeoutMs = 10000
|
||||
)
|
||||
|
||||
$client = $null
|
||||
$started = Get-Date
|
||||
|
||||
try {
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
|
||||
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
|
||||
|
||||
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
|
||||
throw "API3 connect timeout to $IpAddress`:$Port"
|
||||
}
|
||||
|
||||
$client.EndConnect($async)
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
|
||||
$json = $Payload | ConvertTo-Json -Depth 20 -Compress
|
||||
$requestBytes = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||
$lengthBytes = [System.BitConverter]::GetBytes([UInt32]$requestBytes.Length)
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$stream.Write($lengthBytes, 0, $lengthBytes.Length)
|
||||
$stream.Write($requestBytes, 0, $requestBytes.Length)
|
||||
|
||||
$responseLengthBytes = Read-MinerOpsExactBytes -Stream $stream -Length 4 -TimeoutMs $TimeoutMs
|
||||
$responseLength = [System.BitConverter]::ToUInt32($responseLengthBytes, 0)
|
||||
|
||||
if ($responseLength -le 0 -or $responseLength -gt 1048576) {
|
||||
throw "API3 response length looked invalid: $responseLength"
|
||||
}
|
||||
|
||||
$responseBytes = Read-MinerOpsExactBytes -Stream $stream -Length ([int]$responseLength) -TimeoutMs $TimeoutMs
|
||||
$responseText = [System.Text.Encoding]::UTF8.GetString($responseBytes)
|
||||
$responseJson = $responseText | ConvertFrom-Json
|
||||
|
||||
return @{
|
||||
sent = $true
|
||||
protocol = "whatsminer_api3"
|
||||
target_ip = $IpAddress
|
||||
target_port = $Port
|
||||
duration_seconds = [math]::Round(((Get-Date) - $started).TotalSeconds, 3)
|
||||
request = @{
|
||||
cmd = $Payload.cmd
|
||||
param = $Payload.param
|
||||
account = $Payload.account
|
||||
has_token = [bool]$Payload.token
|
||||
}
|
||||
response = $responseJson
|
||||
}
|
||||
} finally {
|
||||
if ($client -ne $null) {
|
||||
$client.Close()
|
||||
}
|
||||
}
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
function Get-MinerOpsApi3Salt {
|
||||
@@ -989,43 +881,7 @@ function Invoke-MinerOpsApi3Service {
|
||||
[ValidateSet("start", "stop", "restart")]
|
||||
[string]$ServiceAction
|
||||
)
|
||||
|
||||
$account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue ""
|
||||
$password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue ""
|
||||
$portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433"
|
||||
|
||||
try {
|
||||
$port = [int]$portRaw
|
||||
} catch {
|
||||
$port = 4433
|
||||
}
|
||||
|
||||
$command = "set.miner.service"
|
||||
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $port
|
||||
$salt = [string]$saltInfo.salt
|
||||
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$token = Get-MinerOpsApi3Token -Command $command -Password $password -Salt $salt -Timestamp $timestamp
|
||||
|
||||
$payload = @{
|
||||
cmd = $command
|
||||
param = $ServiceAction
|
||||
ts = $timestamp
|
||||
token = $token
|
||||
account = $account
|
||||
}
|
||||
|
||||
$result = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $port -Payload $payload
|
||||
|
||||
$result["salt_response"] = $saltInfo.salt_response
|
||||
$result["api3_command"] = $command
|
||||
$result["api3_param"] = $ServiceAction
|
||||
$result["api3_account"] = $account
|
||||
|
||||
if ([int]$result.response.code -ne 0) {
|
||||
throw "API3 $command $ServiceAction failed: $($result.response | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
||||
return $result
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
|
||||
@@ -1054,45 +910,7 @@ function Invoke-MinerOpsApi3AuthenticatedCommand {
|
||||
|
||||
[object]$Param = $null
|
||||
)
|
||||
|
||||
$account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue ""
|
||||
$password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue ""
|
||||
$portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433"
|
||||
|
||||
try {
|
||||
$port = [int]$portRaw
|
||||
} catch {
|
||||
$port = 4433
|
||||
}
|
||||
|
||||
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $port
|
||||
$salt = [string]$saltInfo.salt
|
||||
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$token = Get-MinerOpsApi3Token -Command $Command -Password $password -Salt $salt -Timestamp $timestamp
|
||||
|
||||
$payload = @{
|
||||
cmd = $Command
|
||||
ts = $timestamp
|
||||
token = $token
|
||||
account = $account
|
||||
}
|
||||
|
||||
if ($null -ne $Param) {
|
||||
$payload["param"] = $Param
|
||||
}
|
||||
|
||||
$result = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $port -Payload $payload
|
||||
|
||||
$result["salt_response"] = $saltInfo.salt_response
|
||||
$result["api3_command"] = $Command
|
||||
$result["api3_param"] = $Param
|
||||
$result["api3_account"] = $account
|
||||
|
||||
if ([int]$result.response.code -ne 0) {
|
||||
throw "API3 $Command failed: $($result.response | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
||||
return $result
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
function Get-MinerOpsPayloadValue {
|
||||
@@ -1129,113 +947,14 @@ function Invoke-MinerOpsLegacy4028Command {
|
||||
[int] $Port = 4028,
|
||||
[int] $TimeoutMs = 7000
|
||||
)
|
||||
|
||||
$normalizedIp = Normalize-MinerOpsIpAddress -IpAddress $IpAddress
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$connectResult = $client.BeginConnect($normalizedIp, $Port, $null, $null)
|
||||
|
||||
if (-not $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||
try { $client.Close() } catch {}
|
||||
throw "Legacy 4028 connection timed out to $normalizedIp`:$Port"
|
||||
}
|
||||
|
||||
$client.EndConnect($connectResult)
|
||||
|
||||
try {
|
||||
$stream = $client.GetStream()
|
||||
$stream.ReadTimeout = $TimeoutMs
|
||||
$stream.WriteTimeout = $TimeoutMs
|
||||
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($CommandJson)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
$buffer = New-Object byte[] 65535
|
||||
$builder = New-Object System.Text.StringBuilder
|
||||
|
||||
do {
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
|
||||
if ($read -gt 0) {
|
||||
[void]$builder.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $read))
|
||||
}
|
||||
} while ($stream.DataAvailable)
|
||||
|
||||
$rawResponse = $builder.ToString().Trim([char]0).Trim()
|
||||
|
||||
$parsedResponse = $null
|
||||
|
||||
try {
|
||||
if ($rawResponse) {
|
||||
$parsedResponse = $rawResponse | ConvertFrom-Json
|
||||
}
|
||||
} catch {
|
||||
$parsedResponse = $null
|
||||
}
|
||||
|
||||
return @{
|
||||
success = $true
|
||||
control_method = "legacy_4028"
|
||||
port = $Port
|
||||
request_json = $CommandJson
|
||||
raw_response = $rawResponse
|
||||
parsed_response = $parsedResponse
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try { $stream.Close() } catch {}
|
||||
try { $client.Close() } catch {}
|
||||
}
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsM30Legacy4028Test {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] $Payload
|
||||
)
|
||||
|
||||
if ($Payload -is [string]) {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
}
|
||||
|
||||
$ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "ip_address"
|
||||
|
||||
if (-not $ipAddress) {
|
||||
$ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "current_ip"
|
||||
}
|
||||
|
||||
if (-not $ipAddress) {
|
||||
throw "Legacy 4028 test failed: no IP address in payload."
|
||||
}
|
||||
|
||||
$legacyJson = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_json"
|
||||
$legacyCommand = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_command"
|
||||
|
||||
if (-not $legacyJson) {
|
||||
if (-not $legacyCommand) {
|
||||
throw "Legacy 4028 test failed: no legacy command in payload."
|
||||
}
|
||||
|
||||
$legacyJson = '{"command":"' + $legacyCommand + '"}'
|
||||
}
|
||||
|
||||
Write-AgentLog "Running legacy 4028 M30 test against $ipAddress command=$legacyCommand"
|
||||
|
||||
$result = Invoke-MinerOpsLegacy4028Command -IpAddress $ipAddress -CommandJson $legacyJson -Port 4028
|
||||
|
||||
return @{
|
||||
success = $true
|
||||
action = "legacy_4028"
|
||||
test_lab = "m30"
|
||||
ip_address = $ipAddress
|
||||
legacy_4028_command = $legacyCommand
|
||||
legacy_4028_request = $legacyJson
|
||||
legacy_4028_response = $result.raw_response
|
||||
legacy_4028_parsed = $result.parsed_response
|
||||
control_method = "legacy_4028"
|
||||
}
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
function Run-MinerControlCommand {
|
||||
@@ -1244,105 +963,7 @@ function Run-MinerControlCommand {
|
||||
[Parameter(Mandatory=$true)]
|
||||
[object]$Payload
|
||||
)
|
||||
# MinerOps M30 legacy 4028 test path
|
||||
if ($Payload -is [string]) {
|
||||
try {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if ($Payload -and (($Payload.control_method -eq "legacy_4028") -or ($Payload.action -eq "legacy_4028"))) {
|
||||
return Invoke-MinerOpsM30Legacy4028Test -Payload $Payload
|
||||
}
|
||||
|
||||
|
||||
if ($Payload -is [string]) {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
}
|
||||
|
||||
if ($null -eq $Payload) {
|
||||
throw "Miner control payload was empty."
|
||||
}
|
||||
|
||||
$action = ([string]$Payload.action).ToLower()
|
||||
$minerId = [string]$Payload.miner_id
|
||||
$macAddress = [string]$Payload.mac_address
|
||||
$dryRun = [bool]$Payload.dry_run
|
||||
|
||||
$ipAddress = ([string]$Payload.ip_address).Trim()
|
||||
|
||||
if ($ipAddress.Contains("/")) {
|
||||
$ipAddress = $ipAddress.Split("/")[0]
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ipAddress)) {
|
||||
throw "Miner control payload did not include ip_address."
|
||||
}
|
||||
|
||||
$serviceAction = ""
|
||||
$api3Command = ""
|
||||
$api3Param = $null
|
||||
$performanceMode = ""
|
||||
|
||||
if ($action -eq "reboot") {
|
||||
$serviceAction = "restart"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "suspend") {
|
||||
$serviceAction = "stop"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "resume") {
|
||||
$serviceAction = "start"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "performance_mode") {
|
||||
$performanceMode = ([string]$Payload.performance_mode).ToLower()
|
||||
|
||||
if ($performanceMode -in @("low", "normal", "high")) {
|
||||
$api3Command = "set.miner.power_mode"
|
||||
$api3Param = $performanceMode
|
||||
} else {
|
||||
throw "Invalid performance_mode: $performanceMode"
|
||||
}
|
||||
} else {
|
||||
throw "Unsupported miner control action: $action"
|
||||
}
|
||||
|
||||
Write-AgentLog "Miner control requested. Action=$action API3Command=$api3Command API3Service=$serviceAction PerformanceMode=$performanceMode IP=$ipAddress MAC=$macAddress DryRun=$dryRun"
|
||||
|
||||
if ($dryRun) {
|
||||
return @{
|
||||
ok = $true
|
||||
dry_run = $true
|
||||
action = $action
|
||||
api3_command = $api3Command
|
||||
api3_service_action = $serviceAction
|
||||
performance_mode = $performanceMode
|
||||
ip_address = $ipAddress
|
||||
miner_id = $minerId
|
||||
mac_address = $macAddress
|
||||
message = "Dry run only. No miner command sent."
|
||||
}
|
||||
}
|
||||
|
||||
if ($action -eq "performance_mode") {
|
||||
$api3Result = Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress $ipAddress -Command $api3Command -Param $api3Param
|
||||
} else {
|
||||
$api3Result = Invoke-MinerOpsApi3Service -IpAddress $ipAddress -ServiceAction $serviceAction
|
||||
}
|
||||
|
||||
return @{
|
||||
ok = $true
|
||||
dry_run = $false
|
||||
action = $action
|
||||
api3_command = $api3Command
|
||||
api3_service_action = $serviceAction
|
||||
performance_mode = $performanceMode
|
||||
ip_address = $ipAddress
|
||||
miner_id = $minerId
|
||||
mac_address = $macAddress
|
||||
message = "API3 miner control command sent by Windows Agent."
|
||||
api3_result = $api3Result
|
||||
}
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
|
||||
@@ -1368,8 +989,8 @@ function Process-PendingCommands {
|
||||
Write-AgentLog "Command completed: $commandId"
|
||||
} elseif ($commandType -eq "miner_control") {
|
||||
$result = Run-MinerControlCommand -Payload $cmd.request_payload
|
||||
Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload $result | Out-Null
|
||||
Write-AgentLog "Miner control command completed: $commandId"
|
||||
Send-AgentCommandResult -CommandId $commandId -Status "failed" -ResultPayload $result | Out-Null
|
||||
Write-AgentLog "Miner control rejected by Stage A containment: $commandId"
|
||||
|
||||
} elseif ($commandType -eq "enrich_match_discovery") {
|
||||
$payloadObj = $cmd.request_payload
|
||||
|
||||
@@ -30,11 +30,57 @@ function Invoke-WhatsMinerApi3Read {
|
||||
[int] $TimeoutMs = 2500
|
||||
)
|
||||
|
||||
# Exact operation and parameter allowlist at the shared transport boundary.
|
||||
# No extra keys (including credentials, command aliases, or parameter overrides)
|
||||
# reach the wire, even when this function is called outside the poller loop.
|
||||
$readCommands = @("get.device.info", "get.miner.setting", "get.miner.status",
|
||||
"get.fan.setting", "get.device.custom_data")
|
||||
$command = $Request["cmd"]
|
||||
$approvedCommand = $null
|
||||
if ($command -is [string]) {
|
||||
foreach ($candidate in $readCommands) {
|
||||
if ([string]::Equals($candidate, $command, [StringComparison]::Ordinal)) {
|
||||
$approvedCommand = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($null -eq $approvedCommand) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
$expectedKeys = @("cmd")
|
||||
$wireRequest = [ordered]@{ cmd = $approvedCommand }
|
||||
if ([string]::Equals($approvedCommand, "get.miner.status", [StringComparison]::Ordinal)) {
|
||||
$expectedKeys += "param"
|
||||
if ($Request["param"] -isnot [string] -or
|
||||
-not [string]::Equals($Request["param"], "summary+pools+edevs", [StringComparison]::Ordinal)) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
$wireRequest["param"] = "summary+pools+edevs"
|
||||
}
|
||||
if ($Request.Count -ne $expectedKeys.Count) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
foreach ($key in $Request.Keys) {
|
||||
$approvedKey = $false
|
||||
if ($key -is [string]) {
|
||||
foreach ($expectedKey in $expectedKeys) {
|
||||
if ([string]::Equals($expectedKey, $key, [StringComparison]::Ordinal)) {
|
||||
$approvedKey = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $approvedKey) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
}
|
||||
|
||||
$client = $null
|
||||
$stream = $null
|
||||
|
||||
try {
|
||||
$json = ($Request | ConvertTo-Json -Depth 20 -Compress)
|
||||
$json = ($wireRequest | ConvertTo-Json -Depth 20 -Compress)
|
||||
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
|
||||
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
|
||||
|
||||
|
||||
@@ -26,29 +26,8 @@ function Get-MinerOpsControlCredentials {
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsControlApi3Command {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$IpAddress,
|
||||
[Parameter(Mandatory=$true)][string]$Command,
|
||||
[object]$Param = $null
|
||||
)
|
||||
$credentials = Get-MinerOpsControlCredentials
|
||||
if (-not $credentials.available) { throw "credentials_missing" }
|
||||
|
||||
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $credentials.port
|
||||
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$token = Get-MinerOpsApi3Token -Command $Command -Password $credentials.password `
|
||||
-Salt ([string]$saltInfo.salt) -Timestamp $timestamp
|
||||
$payload = @{
|
||||
cmd = $Command
|
||||
ts = $timestamp
|
||||
token = $token
|
||||
account = $credentials.account
|
||||
}
|
||||
if ($null -ne $Param) { $payload["param"] = $Param }
|
||||
$response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress `
|
||||
-Port $credentials.port -Payload $payload
|
||||
if ([int]$response.response.code -ne 0) { throw "command_rejected" }
|
||||
return $response
|
||||
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
function New-MinerOpsControlResult {
|
||||
@@ -74,90 +53,11 @@ function New-MinerOpsControlResult {
|
||||
|
||||
function Run-MinerControlCommand {
|
||||
param([Parameter(Mandatory=$true)][object]$Payload)
|
||||
|
||||
try {
|
||||
if ($Payload -is [string]) { $Payload = $Payload | ConvertFrom-Json }
|
||||
$action = ([string]$Payload.action).Trim().ToLower()
|
||||
$ipAddress = ([string]$Payload.ip_address).Trim()
|
||||
$requested = ([string]$Payload.requested_value).Trim().ToLower()
|
||||
if (-not $requested -and $Payload.performance_mode) {
|
||||
$requested = ([string]$Payload.performance_mode).Trim().ToLower()
|
||||
}
|
||||
if ($action -notin @("suspend", "resume", "performance_mode")) {
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "invalid_action" -QueueStatus "failed"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($ipAddress)) {
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "target_missing" -QueueStatus "failed"
|
||||
}
|
||||
$credentials = Get-MinerOpsControlCredentials
|
||||
if (-not $credentials.available) {
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "credentials_missing" -QueueStatus "failed"
|
||||
}
|
||||
|
||||
if ($action -eq "performance_mode") {
|
||||
if ($requested -notin @("low", "normal", "high")) {
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "invalid_mode" -QueueStatus "failed"
|
||||
}
|
||||
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||
-Command "set.miner.power_mode" -Param $requested | Out-Null
|
||||
|
||||
$observed = ""
|
||||
$readUnavailable = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
$read = Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||
-Command "get.miner.setting"
|
||||
$candidate = ([string]$read.response.msg.'power-mode').Trim().ToLower()
|
||||
if ($candidate -in @("low", "normal", "high")) {
|
||||
$observed = $candidate
|
||||
if ($observed -eq $requested) {
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $true -VerificationStatus "confirmed" `
|
||||
-ObservedMode $observed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if ($_.Exception.Message -match "unsupported|unknown_command") {
|
||||
$readUnavailable = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($attempt -lt 3) { Start-Sleep -Milliseconds 750 }
|
||||
}
|
||||
if ($readUnavailable) {
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $true -VerificationStatus "unavailable"
|
||||
}
|
||||
if ($observed) {
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $true `
|
||||
-VerificationStatus "verification_failed" -ObservedMode $observed
|
||||
}
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $true -VerificationStatus "unconfirmed"
|
||||
}
|
||||
|
||||
$serviceAction = if ($action -eq "suspend") { "stop" } else { "start" }
|
||||
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||
-Command "set.miner.service" -Param $serviceAction | Out-Null
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $false -VerificationStatus "unconfirmed"
|
||||
} catch {
|
||||
$failure = if ($_.Exception.Message -eq "credentials_missing") {
|
||||
"credentials_missing"
|
||||
} else { "write_rejected" }
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode $failure -QueueStatus "failed"
|
||||
}
|
||||
# No current request schema binds authorization to physical identity.
|
||||
# Do not parse, normalize, reinterpret, or transmit an old queued payload.
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "physical_control_disabled_stage_a" -QueueStatus "failed"
|
||||
}
|
||||
|
||||
function Send-AgentCommandResult {
|
||||
@@ -185,7 +85,6 @@ function Send-AgentCommandResult {
|
||||
|
||||
function Send-AgentCheckin {
|
||||
$config = Read-AgentConfig
|
||||
$credentials = Get-MinerOpsControlCredentials
|
||||
$body = [ordered]@{
|
||||
agent_id = [string]$config.agent_id
|
||||
site_id = [string]$config.site_id
|
||||
@@ -193,8 +92,8 @@ function Send-AgentCheckin {
|
||||
hostname = $env:COMPUTERNAME
|
||||
version = Get-MinerOpsAgentVersion
|
||||
ip_address = ""
|
||||
control_available = [bool]$credentials.available
|
||||
control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" }
|
||||
control_available = $false
|
||||
control_unavailable_reason = "physical_control_disabled_stage_a"
|
||||
local_time = (Get-Date).ToString("o")
|
||||
}
|
||||
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
# MinerOps Windows Agent
|
||||
|
||||
## Stage A physical-control containment
|
||||
|
||||
Physical miner control is disabled in this version, including suspend, resume,
|
||||
performance-mode changes, reboot, M30 tests, and miner-side password changes.
|
||||
No environment flag, configured API3 credential, or Agent capability report can
|
||||
enable it. This is containment, not completion of Blocker #3 identity binding.
|
||||
|
||||
The Hub returns HTTP 410 for retired legacy command, scheduling, and unrestricted
|
||||
diagnostic endpoints (after authorization where applicable). Typed control POSTs
|
||||
require `miners.control` and return HTTP 409 `physical_control_disabled_stage_a`.
|
||||
The unrestricted `whatsminer-read-test` endpoint is not a supported read-only tool.
|
||||
|
||||
Authenticated queue polling cancels pending/claimed physical and unknown command
|
||||
types, records an atomic `agent.command.rejected` audit entry, and only delivers
|
||||
fixed telemetry/discovery command types. Results cannot promote retired entries.
|
||||
Original payloads, prior results/errors, timestamps, and terminal history remain.
|
||||
Cancellation of an already claimed command does **not** prove it was never sent;
|
||||
the audit explicitly records its previous execution outcome as unknown.
|
||||
|
||||
The Windows executor rejects all current physical-control schemas without any
|
||||
device connection and advertises physical control as unavailable. Existing Agents
|
||||
must be stopped/drained and upgraded before claiming fleet-wide containment:
|
||||
server cancellation cannot recall payloads cached by older running Agents.
|
||||
Do not enable control or deploy this work before separate security review.
|
||||
The ordered, fail-closed procedure is in
|
||||
[the Stage A old-Agent cutover runbook](../../docs/operations/STAGE_A_OLD_AGENT_CUTOVER.txt).
|
||||
Native Windows PowerShell 5.1/SYSTEM validation and verified fleet network fencing
|
||||
remain production gates; server cancellation alone cannot contain an old executor.
|
||||
|
||||
Retained legacy/API3 enrichment helpers validate exact read commands and fixed
|
||||
parameters before opening a socket. Unknown operations and extra keys fail closed.
|
||||
Schedule cancel/delete require `miners.control` and retire matching live unsupported
|
||||
commands for the stored Agent with atomic actor-attributed rejection audits.
|
||||
Deletion retains schedule history; retries do not duplicate audits. Previously
|
||||
claimed execution remains unknown. Manual cutover retirement does not claim read
|
||||
work and must sweep offline/queue-only Agent IDs until the global live count is zero.
|
||||
|
||||
The Windows Agent runs at a mining site and performs local discovery, polling,
|
||||
API3 enrichment, pool polling, and approved queued miner-control operations.
|
||||
API3 enrichment, and pool polling. Physical miner control is disabled in Stage A.
|
||||
MinerOps does not send API3 credentials through the hub command queue; those
|
||||
credentials remain in the Agent's local `agent-config.json`.
|
||||
|
||||
|
||||
@@ -1,84 +1,115 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
|
||||
$script:calls = @()
|
||||
$script:outcomes = @()
|
||||
$script:outcomeIndex = 0
|
||||
|
||||
function Read-AgentConfig { return $script:config }
|
||||
. (Join-Path (Split-Path $PSScriptRoot -Parent) "MinerOpsControl.ps1")
|
||||
|
||||
function Invoke-MinerOpsControlApi3Command {
|
||||
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
|
||||
$script:calls += [pscustomobject]@{ command = $Command; param = $Param }
|
||||
$outcome = $script:outcomes[$script:outcomeIndex]
|
||||
$script:outcomeIndex += 1
|
||||
if ($outcome -is [System.Exception]) { throw $outcome }
|
||||
return $outcome
|
||||
}
|
||||
$script:assertions = 0
|
||||
$script:physicalCalls = 0
|
||||
$script:readCalls = 0
|
||||
$script:apiCalls = @()
|
||||
$script:commands = @()
|
||||
|
||||
function Assert-Equal($Actual, $Expected, [string]$Message) {
|
||||
$script:assertions++
|
||||
if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
|
||||
}
|
||||
function Reset-Harness([object[]]$Outcomes) {
|
||||
$script:calls = @()
|
||||
$script:outcomes = @($Outcomes)
|
||||
$script:outcomeIndex = 0
|
||||
}
|
||||
function Response([string]$Mode = "") {
|
||||
return @{ response = @{ code = 0; msg = @{ "power-mode" = $Mode } } }
|
||||
}
|
||||
|
||||
$payload = @{ action = "performance_mode"; requested_value = "normal"; ip_address = "192.0.2.1" }
|
||||
|
||||
$script:config = [pscustomobject]@{ api3_account = ""; api3_password = ""; api3_port = 4433 }
|
||||
Reset-Harness @()
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.failure_code "credentials_missing" "Missing credentials code"
|
||||
Assert-Equal $script:calls.Count 0 "Missing credentials must not contact API3"
|
||||
|
||||
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
|
||||
Reset-Harness @((Response), (Response "normal"))
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.verification_status "confirmed" "Performance confirmation"
|
||||
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "Exactly one write"
|
||||
|
||||
Reset-Harness @((Response), (Response "low"), (Response "low"), (Response "low"))
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.verification_status "verification_failed" "Contradictory mode"
|
||||
Assert-Equal $script:calls.Count 4 "One write and three bounded reads"
|
||||
|
||||
Reset-Harness @((Response), (Response), (Response), (Response))
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.verification_status "unconfirmed" "Ambiguous reads"
|
||||
|
||||
Reset-Harness @((Response), [System.Exception]::new("unknown_command"))
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.verification_status "unavailable" "Unavailable read path"
|
||||
|
||||
foreach ($action in @("suspend", "resume")) {
|
||||
Reset-Harness @((Response))
|
||||
$result = Run-MinerControlCommand -Payload @{
|
||||
action = $action; requested_value = $null; ip_address = "192.0.2.1"
|
||||
function Assert-Throws($Block, [string]$Message) {
|
||||
$caught = $false
|
||||
try { & $Block | Out-Null } catch {
|
||||
$caught = $true
|
||||
Assert-Equal $_.Exception.Message "physical_control_disabled_stage_a" $Message
|
||||
}
|
||||
Assert-Equal $result.command_accepted $true "$action accepted"
|
||||
Assert-Equal $result.verification_status "unconfirmed" "$action remains unconfirmed"
|
||||
Assert-Equal $result.observed_service_state $null "$action has no invented state"
|
||||
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "$action one write"
|
||||
Assert-Equal $caught $true "$Message must reject"
|
||||
}
|
||||
|
||||
Reset-Harness @([System.Exception]::new("vendor response included secret"))
|
||||
$result = Run-MinerControlCommand -Payload @{
|
||||
action = "suspend"; requested_value = $null; ip_address = "192.0.2.1"
|
||||
# Define functions from the real service AST, without executing its service loop,
|
||||
# config loading, startup, or any scheduled tasks.
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$tokens = $null; $parseErrors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Join-Path $root "MinerOpsAgentService.ps1"), [ref]$tokens, [ref]$parseErrors)
|
||||
Assert-Equal $parseErrors.Count 0 "Service parser"
|
||||
foreach ($function in $ast.FindAll({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
|
||||
}, $true)) {
|
||||
Invoke-Expression $function.Extent.Text
|
||||
}
|
||||
Assert-Equal $result.failure_code "write_rejected" "Write rejection is sanitized"
|
||||
$json = $result | ConvertTo-Json -Compress
|
||||
if ($json -match "vendor|secret|192\\.0\\.2\\.1") { throw "Typed result leaked raw data" }
|
||||
|
||||
Reset-Harness @()
|
||||
$result = Run-MinerControlCommand -Payload @{
|
||||
action = "performance_mode"; requested_value = "turbo"; ip_address = "192.0.2.1"
|
||||
$script:productionApi3Payload = (Get-Command Invoke-MinerOpsApi3Payload).ScriptBlock
|
||||
$script:productionPlainCommand = (Get-Command Invoke-MinerPlainCommand).ScriptBlock
|
||||
function Invoke-MinerOpsApi3Payload { $script:physicalCalls++; throw "Unexpected API3 transport" }
|
||||
function Get-MinerOpsApi3Salt { $script:physicalCalls++; throw "Unexpected salt probe" }
|
||||
function Invoke-MinerPlainCommand { $script:physicalCalls++; throw "Unexpected legacy transport" }
|
||||
function Read-AgentConfig { return [pscustomobject]@{
|
||||
agent_id = "simulated-agent"; site_id = ""; site_name = "Simulation"
|
||||
api3_account = "operator"; api3_password = "mock-only"; api3_port = 4433
|
||||
} }
|
||||
function Write-AgentLog { param([string]$Message) }
|
||||
function Invoke-AgentApi {
|
||||
param([string]$Method, [string]$Path, [object]$Body)
|
||||
$script:apiCalls += @{ method = $Method; path = $Path; body = $Body }
|
||||
return @{ ok = $true }
|
||||
}
|
||||
Assert-Equal $result.failure_code "invalid_mode" "Invalid mode code"
|
||||
Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3"
|
||||
function Get-PendingAgentCommands { return $script:commands }
|
||||
function Run-KnownMinerPoll { $script:readCalls++; return @{ simulated = $true } }
|
||||
function Run-NewMinerDiscovery { $script:readCalls++; return @{ simulated = $true } }
|
||||
|
||||
Write-Host "MinerOps typed Agent control tests passed."
|
||||
# The pre-module legacy implementation must also be inert, including helpers
|
||||
# which used to support direct generic API3 or M30/4028 writes.
|
||||
Assert-Throws { Run-MinerControlCommand -Payload @{ action = "reboot"; dry_run = $false } } "Legacy executor"
|
||||
Assert-Throws { Invoke-MinerOpsApi3Service -IpAddress "192.0.2.1" -ServiceAction "stop" } "Legacy service helper"
|
||||
Assert-Throws { & $script:productionApi3Payload -IpAddress "192.0.2.1" -Payload @{ cmd = "set.miner.service"; param = "stop" } } "Generic API3 payload helper"
|
||||
Assert-Throws { & $script:productionPlainCommand -IpAddress "192.0.2.1" -Command "reboot" } "Generic legacy command helper"
|
||||
Assert-Throws { Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress "192.0.2.1" -Command "set.miner.service" -Param "stop" } "Legacy API3"
|
||||
Assert-Throws { Invoke-MinerOpsLegacy4028Command -IpAddress "192.0.2.1" -CommandJson '{"command":"reboot"}' } "Legacy 4028"
|
||||
Assert-Throws { Invoke-MinerOpsM30Legacy4028Test -Payload @{ legacy_4028_command = "reboot" } } "M30 helper"
|
||||
|
||||
. (Join-Path $root "MinerOpsControl.ps1")
|
||||
Assert-Throws { Invoke-MinerOpsControlApi3Command -IpAddress "192.0.2.1" -Command "set.miner.service" -Param "stop" } "Typed transport"
|
||||
Assert-Throws { Invoke-MinerOpsControlApi3Command -IpAddress "192.0.2.1" -Command "get.read_then_write" } "Disguised diagnostic"
|
||||
|
||||
$payloads = @(
|
||||
@{ action = "legacy_4028"; legacy_4028_command = "reboot"; dry_run = $false },
|
||||
@{ action = "reboot"; ip_address = "192.0.2.1" },
|
||||
@{ schema_version = 1; action = "suspend"; ip_address = "192.0.2.1"; miner_id = "spoofed" },
|
||||
@{ schema_version = 1; action = "resume"; ip_address = "192.0.2.1" },
|
||||
@{ schema_version = 1; action = "performance_mode"; requested_value = "high"; ip_address = "192.0.2.1" },
|
||||
@{ schema_version = 999; action = "suspend" },
|
||||
@{ schema_version = "1"; action = "performance_mode"; performance_mode = "high" },
|
||||
@{ command = "summary"; params = @{ cmd = "set.miner.service"; param = "stop" } },
|
||||
@{ action = "read_reboot"; dry_run = $true },
|
||||
@{},
|
||||
'{"schema_version":0,"action":"reboot"}',
|
||||
"{invalid-json"
|
||||
)
|
||||
foreach ($payload in $payloads) {
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.command_accepted $false "Rejected payload acceptance"
|
||||
Assert-Equal $result.failure_code "physical_control_disabled_stage_a" "Rejected payload reason"
|
||||
Assert-Equal $result._queue_status "failed" "Rejected payload terminal result"
|
||||
Assert-Equal $result.verification_attempted $false "No physical verification/probe"
|
||||
Assert-Equal $result.observed_service_state $null "No invented device state"
|
||||
Assert-Equal $script:physicalCalls 0 "No physical calls"
|
||||
$script:commands = @(@{ command_id = "legacy-delivery"; command_type = "miner_control"; request_payload = $payload })
|
||||
$script:apiCalls = @()
|
||||
Assert-Equal (Process-PendingCommands) 1 "Delivered legacy command handled"
|
||||
Assert-Equal $script:apiCalls.Count 1 "One failed result submitted"
|
||||
Assert-Equal $script:apiCalls[0].body.status "failed" "Legacy delivery failed"
|
||||
Assert-Equal $script:apiCalls[0].body.result_payload.command_accepted $false "Legacy delivery not accepted"
|
||||
Assert-Equal $script:physicalCalls 0 "No writes after delivery"
|
||||
}
|
||||
foreach ($kind in @("raw_api3", "miner_control_v2", "read_reboot", "")) {
|
||||
$script:commands = @(@{ command_id = "unsupported"; command_type = $kind; request_payload = @{ action = "reboot" } })
|
||||
$script:apiCalls = @()
|
||||
Assert-Equal (Process-PendingCommands) 1 "Unknown command handled"
|
||||
Assert-Equal $script:apiCalls[0].body.status "failed" "Unknown command fails closed"
|
||||
}
|
||||
Assert-Equal $script:readCalls 0 "No physical request reinterpreted as telemetry"
|
||||
$script:commands = @(@{ command_id = "read-only"; command_type = "poll_known_miners_now"; request_payload = @{} })
|
||||
$script:apiCalls = @()
|
||||
Assert-Equal (Process-PendingCommands) 1 "Fixed read-only command available"
|
||||
Assert-Equal $script:readCalls 1 "Only simulated read performed"
|
||||
Assert-Equal $script:physicalCalls 0 "No physical calls in entire suite"
|
||||
$script:apiCalls = @()
|
||||
Send-AgentCheckin | Out-Null
|
||||
Assert-Equal $script:apiCalls[0].body.control_available $false "Cannot advertise physical capability"
|
||||
Assert-Equal $script:apiCalls[0].body.control_unavailable_reason "physical_control_disabled_stage_a" "Containment advertised"
|
||||
Write-Host "Stage A Windows containment passed: $($script:assertions) assertions; zero physical transport calls."
|
||||
@@ -0,0 +1,199 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:assertions = 0
|
||||
$script:connections = 0
|
||||
$TimeoutMs = 1000
|
||||
|
||||
function Assert-True([bool]$Condition, [string]$Message) {
|
||||
$script:assertions++
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
function Assert-Rejected([scriptblock]$Call) {
|
||||
$before = $script:connections
|
||||
$caught = $false
|
||||
try { & $Call | Out-Null } catch {
|
||||
$caught = $true
|
||||
Assert-True ($_.Exception.Message -eq "unsupported_diagnostic_operation_stage_a") "Must reject before transport"
|
||||
}
|
||||
Assert-True $caught "Unsupported diagnostics must throw"
|
||||
Assert-True ($script:connections -eq $before) "Rejected request must create zero sockets and write zero bytes"
|
||||
}
|
||||
function Import-Definitions([string]$Path) {
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
|
||||
Assert-True ($errors.Count -eq 0) "Production script must parse"
|
||||
foreach ($definition in $ast.FindAll({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
|
||||
}, $true)) {
|
||||
. ([scriptblock]::Create("function global:" + $definition.Name + " " + $definition.Body.Extent.Text))
|
||||
}
|
||||
}
|
||||
|
||||
# Load only real function definitions: no config, Hub request, or scheduled loop.
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
Import-Definitions (Join-Path $root "EnrichOneMiner.ps1")
|
||||
Import-Definitions (Join-Path $root "MinerOpsApi3EnrichmentPoller.ps1")
|
||||
Import-Definitions (Join-Path $root "MinerOpsPoolPoller.ps1")
|
||||
|
||||
function New-Object {
|
||||
param([Parameter(Position=0)][string]$TypeName,
|
||||
[Parameter(Position=1)][object[]]$ArgumentList)
|
||||
if ($TypeName -eq "System.Net.Sockets.TcpClient") { $script:connections++ }
|
||||
if ($null -ne $ArgumentList) {
|
||||
return Microsoft.PowerShell.Utility\New-Object -TypeName $TypeName -ArgumentList $ArgumentList
|
||||
}
|
||||
return Microsoft.PowerShell.Utility\New-Object -TypeName $TypeName
|
||||
}
|
||||
|
||||
# Auditor reproductions plus write families, alias/parameter overrides, injection,
|
||||
# malformed structures and operations that merely contain a read-looking substring.
|
||||
foreach ($command in @("reboot", "set.miner.service", "power_off", "switchpool", "ascset",
|
||||
"summary|reboot", "summary`nreboot", 'summary","cmd":"reboot', "read_then_reboot",
|
||||
"SUMMARY", "", "get.device.info", "get_miner_info|all")) {
|
||||
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $command }
|
||||
}
|
||||
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command "summary" -Port 4433 }
|
||||
$badRequests = @(
|
||||
@{ cmd = "set.miner.service"; param = "stop" },
|
||||
@{ cmd = "reboot" }, @{ cmd = "set.miner.power_mode"; param = "high" },
|
||||
@{ cmd = "set.device.password"; param = "changed" }, @{ cmd = "get.read_then_write" },
|
||||
@{ cmd = "get.device.info"; command = "reboot" },
|
||||
@{ cmd = "get.device.info"; param = "stop" },
|
||||
@{ cmd = "get.device.info"; account = "operator"; token = "anything" },
|
||||
@{ cmd = "get.miner.status" }, @{ cmd = "get.miner.status"; param = "all" },
|
||||
@{ cmd = "get.miner.status"; param = @("summary+pools+edevs") },
|
||||
@{ cmd = "get.miner.status"; param = "summary+pools+edevs"; command = "reboot" },
|
||||
@{ Cmd = "get.device.info" }, @{ cmd = "GET.DEVICE.INFO" },
|
||||
@{ cmd = "get.device.info`nreboot" }, @{ cmd = @("get.device.info", "reboot") },
|
||||
@{ cmd = $null }, @{}
|
||||
)
|
||||
foreach ($request in $badRequests) {
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
|
||||
Assert-Rejected { Invoke-Api3OkCommand -Ip "127.0.0.1" -Request $request }
|
||||
}
|
||||
# Exact re-audit suffixes: U+0000, U+00AD and U+200B; also prefix/embedded
|
||||
# invisible characters, controls and whitespace. Never trim or normalize inputs.
|
||||
$originalCulture = [Threading.Thread]::CurrentThread.CurrentCulture
|
||||
try {
|
||||
foreach ($cultureName in @("en-US", "tr-TR", "")) {
|
||||
[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::GetCultureInfo($cultureName)
|
||||
$variants = @()
|
||||
foreach ($code in @(0, 0x00ad, 0x200b, 0x200c, 0x200d, 0x2060, 0xfeff,
|
||||
0x001f, 13, 10, 9, 32, 0x00a0)) {
|
||||
$character = [string][char]$code
|
||||
foreach ($position in @("prefix", "embedded", "suffix")) {
|
||||
$variants += @{ Character = $character; Position = $position }
|
||||
}
|
||||
}
|
||||
foreach ($variant in $variants) {
|
||||
$legacy = "summary"; $api = "get.device.info"; $key = "cmd"; $param = "summary+pools+edevs"
|
||||
if ($variant.Position -eq "prefix") {
|
||||
$legacy = $variant.Character + $legacy; $api = $variant.Character + $api
|
||||
$key = $variant.Character + $key; $param = $variant.Character + $param
|
||||
} elseif ($variant.Position -eq "embedded") {
|
||||
$legacy = "sum" + $variant.Character + "mary"; $api = "get.device." + $variant.Character + "info"
|
||||
$key = "c" + $variant.Character + "md"; $param = "summary+" + $variant.Character + "pools+edevs"
|
||||
} else {
|
||||
$legacy += $variant.Character; $api += $variant.Character
|
||||
$key += $variant.Character; $param += $variant.Character
|
||||
}
|
||||
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $legacy }
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{ cmd = $api } }
|
||||
Assert-Rejected { Invoke-Api3OkCommand -Ip "127.0.0.1" -Request @{ cmd = $api } }
|
||||
$request = @{}; $request[$key] = "get.device.info"
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{ cmd = "get.miner.status"; param = $param } }
|
||||
$request = @{ cmd = "get.miner.status" }; $request[$key.Replace("cmd", "param")] = "summary+pools+edevs"
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
|
||||
}
|
||||
foreach ($legacy in @(("summar" + [char]0x0443), ([char]0xff53 + "ummary"),
|
||||
("summ" + [char]0x0061 + [char]0x0301 + "ry"),
|
||||
("summ" + [char]0x00e1 + "ry"), "Summary", "summary/", "summary+")) {
|
||||
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $legacy }
|
||||
}
|
||||
foreach ($api in @(("get.dev" + [char]0x0456 + "ce.info"), ("get" + [char]0xff0e + "device.info"),
|
||||
([char]0xff47 + "et.device.info"), "Get.Device.Info", "get.device.info/", "get.device.info+")) {
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd=$api} }
|
||||
}
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd="get.miner.status"; Param="summary+pools+edevs"} }
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd="get.miner.status"; param="Summary+pools+edevs"} }
|
||||
}
|
||||
} finally { [Threading.Thread]::CurrentThread.CurrentCulture = $originalCulture }
|
||||
Assert-True ($script:connections -eq 0) "All negative cases produced zero transport connections/writes"
|
||||
|
||||
# Real TCP compatibility tests: bind loopback only, one request per simulator.
|
||||
# This suite must be run with physical networks disconnected (Docker --network none).
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
public class MinerOpsDiagnosticSimulator : IDisposable {
|
||||
TcpListener listener;
|
||||
Thread thread;
|
||||
public string Payload;
|
||||
public string Error;
|
||||
public MinerOpsDiagnosticSimulator(int port, bool framed) {
|
||||
listener = new TcpListener(IPAddress.Loopback, port); listener.Start();
|
||||
thread = new Thread(() => {
|
||||
try {
|
||||
using (var client = listener.AcceptTcpClient()) {
|
||||
client.ReceiveTimeout = 2000;
|
||||
var stream = client.GetStream(); var buffer = new byte[65536]; int count;
|
||||
if (framed) {
|
||||
var length = new byte[4]; ReadExact(stream, length, 4);
|
||||
count = BitConverter.ToInt32(length, 0);
|
||||
if(count < 1 || count > buffer.Length) throw new Exception("Bad request length");
|
||||
ReadExact(stream, buffer, count);
|
||||
} else { count = stream.Read(buffer, 0, buffer.Length); }
|
||||
Payload = Encoding.ASCII.GetString(buffer, 0, count);
|
||||
var response = Encoding.ASCII.GetBytes("{\"code\":0,\"POOLS\":[],\"msg\":{}}");
|
||||
if(framed) { var length = BitConverter.GetBytes(response.Length); stream.Write(length,0,4); }
|
||||
stream.Write(response, 0, response.Length);
|
||||
}
|
||||
} catch(Exception e) { Error = e.Message; }
|
||||
finally { listener.Stop(); }
|
||||
}); thread.IsBackground = true; thread.Start();
|
||||
}
|
||||
static void ReadExact(NetworkStream stream, byte[] bytes, int count) {
|
||||
int offset=0;
|
||||
while(offset<count) { int n=stream.Read(bytes,offset,count-offset); if(n==0) throw new Exception("EOF"); offset+=n; }
|
||||
}
|
||||
public void Dispose() { listener.Stop(); thread.Join(3000); }
|
||||
}
|
||||
'@
|
||||
|
||||
foreach ($command in @("summary", "devs", "edevs", "stats", "pools", "devdetails", "version", "get_version", "get_miner_info")) {
|
||||
$miner = [MinerOpsDiagnosticSimulator]::new(4028, $false)
|
||||
try {
|
||||
$null = Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $command -TimeoutMs 100
|
||||
$observed = $miner.Payload | ConvertFrom-Json
|
||||
Assert-True ([string]::Equals($observed.cmd, $command, [StringComparison]::Ordinal)) "Legacy query must remain compatible"
|
||||
Assert-True ($observed.PSObject.Properties.Count -eq 1) "Legacy request contains only selected read"
|
||||
Assert-True (-not $miner.Error) "Simulator received a valid legacy request"
|
||||
} finally { $miner.Dispose() }
|
||||
}
|
||||
foreach ($command in @("get.device.info", "get.miner.setting", "get.miner.status", "get.fan.setting", "get.device.custom_data")) {
|
||||
$request = @{ cmd = $command }
|
||||
if ($command -ceq "get.miner.status") { $request.param = "summary+pools+edevs" }
|
||||
$miner = [MinerOpsDiagnosticSimulator]::new(4433, $true)
|
||||
try {
|
||||
$result = Invoke-Api3OkCommand -Ip "127.0.0.1" -Request $request
|
||||
$observed = $miner.Payload | ConvertFrom-Json
|
||||
Assert-True ($null -ne $result) "Existing API3 reader wrapper remains compatible"
|
||||
Assert-True ([string]::Equals($observed.cmd, $command, [StringComparison]::Ordinal)) "Exact API3 query transmitted"
|
||||
Assert-True (@($observed.PSObject.Properties).Count -eq $request.Count) "No additional API3 request keys"
|
||||
if ($command -ceq "get.miner.status") {
|
||||
Assert-True ([string]::Equals($observed.param, "summary+pools+edevs", [StringComparison]::Ordinal)) "Only exact read parameter transmitted"
|
||||
}
|
||||
Assert-True (-not $miner.Error) "Simulator received a valid framed read"
|
||||
} finally { $miner.Dispose() }
|
||||
}
|
||||
$miner = [MinerOpsDiagnosticSimulator]::new(4028, $false)
|
||||
try {
|
||||
$null = Invoke-MinerOps4028PoolsSimple -Ip "127.0.0.1"
|
||||
Assert-True (($miner.Payload | ConvertFrom-Json).command -ceq "pools") "Pool poller sends a constant read"
|
||||
} finally { $miner.Dispose() }
|
||||
Write-Host "Diagnostic containment passed: $($script:assertions) assertions; all rejected operations made zero connections/writes."
|
||||
@@ -0,0 +1,46 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$BaselineRoot,
|
||||
[Parameter(Mandatory=$true)][string]$CachedPayloadPath,
|
||||
[Parameter(Mandatory=$true)][string]$MinerIp,
|
||||
[switch]$ExpectFenced
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MINEROPS_STAGE_A_SIMULATION -ne "1") {
|
||||
throw "Requires an explicitly isolated Stage A simulation; never use physical targets."
|
||||
}
|
||||
|
||||
# Supply a git-archived 2d89e80 Windows source directory, NOT a running installation.
|
||||
# Only AST function definitions execute; no startup, config loading, or task loops.
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Join-Path $BaselineRoot "MinerOpsAgentService.ps1"), [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count) { throw "Baseline parser error" }
|
||||
foreach ($definition in $ast.FindAll({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
|
||||
}, $true)) {
|
||||
Invoke-Expression $definition.Extent.Text
|
||||
}
|
||||
. (Join-Path $BaselineRoot "MinerOpsControl.ps1")
|
||||
function Read-AgentConfig {
|
||||
return [pscustomobject]@{
|
||||
api3_account = "simulation"; api3_password = "simulation-only"; api3_port = 4433
|
||||
}
|
||||
}
|
||||
function Get-MinerOpsApi3Salt { return @{ salt = "simulation-only"; salt_response = @{} } }
|
||||
|
||||
$payload = Get-Content -LiteralPath $CachedPayloadPath -Raw | ConvertFrom-Json
|
||||
if ($payload.action -ne "suspend") { throw "This simulation expects the audited cached suspend command" }
|
||||
# Remap ONLY the target to the address of the dedicated disposable miner container.
|
||||
$payload.ip_address = $MinerIp
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
if ($ExpectFenced) {
|
||||
if ($result.command_accepted -ne $false -or $result.failure_code -ne "write_rejected") {
|
||||
throw "Old executor unexpectedly reached/accepted a simulated miner through the fence"
|
||||
}
|
||||
Write-Host "Fenced old cached command failed; independently verify simulator write count stayed zero."
|
||||
} else {
|
||||
if ($result.command_accepted -ne $true) { throw "Unfenced control simulation did not reproduce old execution" }
|
||||
Write-Host "Unfenced old cached command reproduced; simulated miner received service stop."
|
||||
}
|
||||
Reference in new issue
Block a user