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.
200 lines
11 KiB
PowerShell
200 lines
11 KiB
PowerShell
$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."
|