Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e898f283ab | ||
|
|
2d89e80410 | ||
|
|
97a0139e3d | ||
|
|
3cfb0d1abe |
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 {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -204,9 +204,11 @@ $testButton.Add_Click({
|
||||
}
|
||||
|
||||
$hub = [string]$config.hub_url
|
||||
$agentId = [string]$config.agent_id
|
||||
$secret = [string]$config.agent_secret
|
||||
|
||||
$headers = @{
|
||||
"X-MinerOps-Agent-ID" = $agentId
|
||||
"X-MinerOps-Agent-Secret" = $secret
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+176
-4
@@ -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`.
|
||||
|
||||
@@ -74,9 +111,122 @@ 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.
|
||||
An existing legacy Agent must receive a per-Agent credential before the global
|
||||
shared-secret compatibility path can be retired for its identity. In Agent
|
||||
Management, use **Upgrade credential** on that Agent. Download the resulting
|
||||
Windows package, transfer it only to that Agent computer, and run:
|
||||
|
||||
.\Upgrade-MinerOpsAgentCredential.ps1
|
||||
|
||||
The authorization is bound server-side to the existing Agent ID and its
|
||||
current Site and Agent Zone. The script validates the local Agent ID, upgrades
|
||||
the canonical runtime, stops the main Agent task, generates the permanent
|
||||
secret locally, and recoverably replaces only the credential and durable routing
|
||||
fields in `agent-config.json` using protected same-directory pending and prior
|
||||
names. Existing API3 credentials, polling settings,
|
||||
network ranges, state, logs, and backups are preserved. Before any secret is
|
||||
written, the script creates and verifies a protected recovery file containing
|
||||
the original Scheduled Task state, exact transition token, and exact locally
|
||||
generated credential. The Hub accepts an ambiguous retry only for that same
|
||||
token and credential.
|
||||
|
||||
The Agent directory is a filesystem security boundary. The transition rejects
|
||||
junctions/reparse points, verifies trusted ancestors, denies standard users
|
||||
create/delete/rename rights, and revalidates the boundary before each rename.
|
||||
If an existing Agent directory is discovered insecure, the script first writes
|
||||
`C:\MinerOpsAgent.minerops-filesystem-trust.blocked-<random>.state` under the trusted
|
||||
parent, then hardens the directory only to contain the incident. The blocker is
|
||||
outside the contaminated namespace because changing a DACL does not revoke
|
||||
file or directory handles opened before repair. The same process never redeems
|
||||
a token, creates or activates a permanent credential, runs the installer, or
|
||||
removes that blocker. Ordinary second, third, and later invocations remain
|
||||
blocked even when every current ACL looks correct.
|
||||
|
||||
Recovery, pending, replacement remnants, and late-arriving artifacts are
|
||||
non-authoritative while the external blocker exists. Known transition state is
|
||||
quarantined without parsing. A correct filename, syntax, or ACL cannot promote
|
||||
it on a later run. Invalidate any possibly exposed authorization and retain or
|
||||
remove evidence only under the approved incident procedure.
|
||||
|
||||
### Filesystem trust-epoch recovery
|
||||
|
||||
Use this procedure only after confirming the blocker was created by the
|
||||
containment event and preserving required evidence:
|
||||
|
||||
1. Do not delete or edit the external blocker. Do not run an ordinary retry.
|
||||
2. Use **Restart** to perform a full Windows kernel boot. Shutdown with Fast
|
||||
Startup is not sufficient: Microsoft documents Fast Startup as hibernating
|
||||
and restoring kernel session 0. The script identifies the kernel epoch by
|
||||
the Event Log record anchor of the newest
|
||||
`Microsoft-Windows-Kernel-Boot` event 27 whose `BootType` is exactly `0`.
|
||||
Provider, event ID, record ID, and supported event version come from typed
|
||||
EventRecord properties; namespace-aware XML selection reads only the
|
||||
`BootType` payload. The new full-boot record ID must be strictly greater
|
||||
than the stored record ID. Equality, an older/restored record, log clearing
|
||||
or numbering reset, missing history, duplication, malformed XML, and an
|
||||
unsupported provider schema all fail closed and require safe operator
|
||||
intervention. There is no timestamp fallback. The anchor is unchanged by
|
||||
clock or timezone changes, sleep, hibernation, and Fast Startup. See
|
||||
Microsoft's [system power-state documentation](https://learn.microsoft.com/windows/win32/power/system-power-states)
|
||||
and [Fast Startup support guidance](https://learn.microsoft.com/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail).
|
||||
3. For a credential transition, use a fresh transition package if any recovery,
|
||||
pending, remnant, quarantine, or internal blocker existed. Remove those
|
||||
artifacts only under the approved incident procedure after invalidating the
|
||||
old authorization.
|
||||
4. From the reviewed package, run:
|
||||
|
||||
.\Upgrade-MinerOpsAgentCredential.ps1 `
|
||||
-ApproveFilesystemTrustResetAfterReboot
|
||||
|
||||
The script verifies that Windows boot identity changed, rescans the protected
|
||||
namespace, positively verifies and stops the expected Scheduled Task, and asks the
|
||||
installer to replace managed runtime without backing up or restoring any
|
||||
pre-reset runtime. The installer leaves both the Agent task and external
|
||||
blocker untouched after publication; it has no blocker-deletion path. Before
|
||||
its first fallible stop/install step, the parent durably records the strictly
|
||||
validated original task intent as `reset-authorized`; after installer
|
||||
success it advances the blocker to `runtime-published`. It then verifies the
|
||||
config against the package/server identity, commits the credential, and
|
||||
restores the recorded task intent. Only the parent removes the blocker,
|
||||
after active config and final task state have both been positively verified
|
||||
and non-recovery transition cleanup has succeeded. Protected recovery stays
|
||||
until after blocker deletion so an interrupted final cleanup remains safe.
|
||||
5. For a genuinely new enrollment, preserve evidence and use the approved
|
||||
protected-recreation incident procedure to remove the contaminated namespace
|
||||
and its external blocker, then let `Enroll-MinerOpsAgent.ps1` stage a new
|
||||
protected directory. Enrollment has no in-script blocker-deletion path; its
|
||||
explicit compatibility switch cannot override a retained blocker.
|
||||
|
||||
The explicit switch without a strictly newer canonical full-kernel-boot anchor
|
||||
fails.
|
||||
Any suspicious transition artifact, failed runtime publication, failed
|
||||
credential convergence, invalid final task, or interrupted cleanup retains the
|
||||
blocker. Runtime publication alone never authorizes deletion. Protected
|
||||
directory recreation is the alternative when the retained namespace cannot be
|
||||
verified.
|
||||
|
||||
If the script is interrupted after the Hub commits, rerun the same extracted
|
||||
package without deleting or editing `agent-credential-transition.recovery.json`
|
||||
or `agent-config.json.pending`. A protected, matching pending config is resumed;
|
||||
an insecure, malformed, or mismatched artifact fails closed. The original task
|
||||
intent is durable: an Agent which was running before the first attempt ends
|
||||
running, while an intentionally stopped Agent remains stopped. Success is not
|
||||
reported and transition artifacts are not removed until both the active config
|
||||
and expected final task state are verified. The verifier requires exactly one
|
||||
enabled root `MinerOpsAgent` task, the SYSTEM service-account/highest principal,
|
||||
the installed Windows PowerShell action and service-script arguments, and an
|
||||
exact final state of `Running` for running intent or `Ready` for stopped intent.
|
||||
Missing, query failure, access denied, malformed, multiple, disabled, queued,
|
||||
unknown, wrong-action, and wrong-principal results all fail closed and retain
|
||||
recovery.
|
||||
|
||||
After a successful transition, the existing Agent ID, Site, and Zone are
|
||||
unchanged. Explicit Agent ID plus the new per-Agent secret is required, and the
|
||||
legacy shared secret is permanently disabled for that identity.
|
||||
|
||||
For later code-only upgrades of an Agent that already has a per-Agent
|
||||
credential, 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,
|
||||
@@ -84,12 +234,34 @@ 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.
|
||||
|
||||
The per-user `MinerOpsAgentTray` task is optional UI and is not credential or
|
||||
core Agent authority. During an explicit filesystem trust reset, runtime
|
||||
publication may run noninteractively as `NT AUTHORITY\SYSTEM`, where no safe
|
||||
interactive user principal can be inferred. The installer still publishes
|
||||
`MinerOpsAgentTray.ps1`, but deliberately defers creation of a missing tray task
|
||||
until a later interactive maintenance install. It never creates the tray task
|
||||
as SYSTEM or invents a username. An existing tray task remains authoritative
|
||||
and is preserved unchanged rather than silently repaired under SYSTEM; inspect
|
||||
and remediate a suspected malformed definition separately. The core
|
||||
`MinerOpsAgent` task remains mandatory and strictly verified by credential
|
||||
recovery. `MinerOpsApi3Enrichment` and `MinerOpsPoolPoller` are non-interactive
|
||||
background tasks with explicit SYSTEM principals, so missing instances continue
|
||||
to be created during trust-reset publication.
|
||||
|
||||
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. Before replacing runtime
|
||||
files, the installer copies them to a timestamped backup under
|
||||
C:\MinerOpsAgent\backups.
|
||||
|
||||
Runtime sources, installed destinations, active config, and rollback copies are
|
||||
rejected when they are reparse points or have more than one hard link. A normal
|
||||
trusted upgrade creates a fresh random backup directory and never adopts an
|
||||
existing backup destination. During an explicit post-reboot trust reset, old
|
||||
runtime is never copied into backup or restored; fresh package content replaces
|
||||
it while the external blocker remains, and the Agent task stays stopped for
|
||||
credential verification.
|
||||
|
||||
A failed deployment restores managed files, removes tasks created by that
|
||||
failed attempt, and restores the main Agent's prior running state.
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1066
-15
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Operation,
|
||||
[Parameter(Mandatory = $true)][string]$InputPath,
|
||||
[string]$DestinationPath = "",
|
||||
[ValidateSet("None", "Path")][string]$BackupMode = "None",
|
||||
[string]$BackupPath = "",
|
||||
[Parameter(Mandatory = $true)][string]$ResultPath,
|
||||
[Parameter(Mandatory = $true)][string]$SupportPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. $SupportPath
|
||||
|
||||
$sourceCreatedByCaller = $false
|
||||
$sourceReadVerified = $false
|
||||
$sourceModifyVerified = $false
|
||||
$sourceRenameVerified = $false
|
||||
$sourceDeleteAccessVerified = $false
|
||||
$sourceReplaceAccessVerified = $false
|
||||
$replaceInvoked = $false
|
||||
$sourceExistedBeforeAttack = $null
|
||||
$destinationExistedBeforeAttack = $null
|
||||
$backupExistedBeforeAttack = $null
|
||||
$inputFullPath = ""
|
||||
$destinationFullPath = ""
|
||||
$backupFullPath = ""
|
||||
$backupArgumentWasNativeNull = $false
|
||||
|
||||
function Get-ProbeMetadata {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Status,
|
||||
[Exception]$Exception
|
||||
)
|
||||
|
||||
$inputOwnedByCaller = $false
|
||||
if ([IO.File]::Exists($InputPath)) {
|
||||
try {
|
||||
$callerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$ownerSid = [IO.File]::GetAccessControl($InputPath).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]
|
||||
)
|
||||
$inputOwnedByCaller = $callerSid.Equals($ownerSid)
|
||||
}
|
||||
catch {
|
||||
$inputOwnedByCaller = $false
|
||||
}
|
||||
}
|
||||
|
||||
$details = if ($null -ne $Exception) {
|
||||
Get-MinerOpsNativeExceptionDetails -Exception $Exception
|
||||
}
|
||||
else { $null }
|
||||
return [ordered]@{
|
||||
status = $Status
|
||||
operation = $Operation
|
||||
# Retain these two outer-exception fields for compatibility with
|
||||
# earlier native-harness result readers.
|
||||
exception_type = if ($null -ne $Exception) {
|
||||
$Exception.GetType().FullName
|
||||
}
|
||||
else {
|
||||
""
|
||||
}
|
||||
hresult = if ($null -ne $details) { $details.OuterHResult } else { 0 }
|
||||
outer_exception_type = if ($null -ne $details) {
|
||||
$details.OuterExceptionType
|
||||
}
|
||||
else { "" }
|
||||
outer_hresult = if ($null -ne $details) {
|
||||
$details.OuterHResult
|
||||
}
|
||||
else { 0 }
|
||||
leaf_exception_type = if ($null -ne $details) {
|
||||
$details.LeafExceptionType
|
||||
}
|
||||
else { "" }
|
||||
leaf_hresult = if ($null -ne $details) {
|
||||
$details.LeafHResult
|
||||
}
|
||||
else { 0 }
|
||||
native_error = if ($null -ne $details) {
|
||||
$details.NativeError
|
||||
}
|
||||
else { $null }
|
||||
exception_chain_types = if ($null -ne $details) {
|
||||
@($details.ExceptionChainTypes)
|
||||
}
|
||||
else { @() }
|
||||
exception_chain_truncated = if ($null -ne $details) {
|
||||
[bool]$details.ExceptionChainTruncated
|
||||
}
|
||||
else { $false }
|
||||
source_exists = [IO.File]::Exists($InputPath)
|
||||
destination_exists = if (
|
||||
[string]::IsNullOrWhiteSpace($DestinationPath)
|
||||
) {
|
||||
$null
|
||||
}
|
||||
else {
|
||||
[IO.File]::Exists($DestinationPath)
|
||||
}
|
||||
input_owned_by_caller = $inputOwnedByCaller
|
||||
source_created_by_caller = $sourceCreatedByCaller
|
||||
source_read_verified = $sourceReadVerified
|
||||
source_modify_verified = $sourceModifyVerified
|
||||
source_rename_verified = $sourceRenameVerified
|
||||
source_delete_access_verified = $sourceDeleteAccessVerified
|
||||
source_replace_access_verified = $sourceReplaceAccessVerified
|
||||
input_full_path = $inputFullPath
|
||||
destination_full_path = $destinationFullPath
|
||||
backup_mode = $BackupMode
|
||||
backup_full_path = $backupFullPath
|
||||
backup_argument_was_native_null = $backupArgumentWasNativeNull
|
||||
source_existed_before_attack = $sourceExistedBeforeAttack
|
||||
destination_existed_before_attack = $destinationExistedBeforeAttack
|
||||
backup_existed_before_attack = $backupExistedBeforeAttack
|
||||
replace_overload = "System.IO.File.Replace(String,String,String)"
|
||||
replace_invoked = $replaceInvoked
|
||||
}
|
||||
}
|
||||
|
||||
function Write-ProbeResult {
|
||||
param([Parameter(Mandatory = $true)]$Value)
|
||||
|
||||
$json = $Value | ConvertTo-Json -Compress
|
||||
[IO.File]::WriteAllText($ResultPath, $json)
|
||||
}
|
||||
|
||||
$exception = $null
|
||||
$status = "succeeded"
|
||||
try {
|
||||
switch ($Operation) {
|
||||
"read" { [void][IO.File]::ReadAllText($InputPath) }
|
||||
"create" { [IO.File]::WriteAllText($InputPath, "untrusted") }
|
||||
"modify" { [IO.File]::WriteAllText($InputPath, "untrusted") }
|
||||
"delete" { [IO.File]::Delete($InputPath) }
|
||||
"rename" { [IO.File]::Move($InputPath, $DestinationPath) }
|
||||
"prepare_swap" {
|
||||
if ([IO.File]::Exists($InputPath)) {
|
||||
throw [ArgumentException]::new(
|
||||
"swap source unexpectedly existed before caller creation"
|
||||
)
|
||||
}
|
||||
$renameProof = "$InputPath.rename-proof-$([guid]::NewGuid().ToString('N'))"
|
||||
[IO.File]::WriteAllText(
|
||||
$InputPath,
|
||||
"attacker-controlled-swap-source"
|
||||
)
|
||||
$sourceCreatedByCaller = $true
|
||||
$sourceReadVerified = [IO.File]::ReadAllText($InputPath) -ceq
|
||||
"attacker-controlled-swap-source"
|
||||
[IO.File]::AppendAllText($InputPath, "-modified")
|
||||
$sourceModifyVerified = [IO.File]::ReadAllText($InputPath) -ceq
|
||||
"attacker-controlled-swap-source-modified"
|
||||
[IO.File]::Move($InputPath, $renameProof)
|
||||
[IO.File]::Move($renameProof, $InputPath)
|
||||
$sourceRenameVerified = [IO.File]::Exists($InputPath) -and
|
||||
-not [IO.File]::Exists($renameProof)
|
||||
|
||||
$requiredRights =
|
||||
[Security.AccessControl.FileSystemRights]::ReadData -bor
|
||||
[Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[Security.AccessControl.FileSystemRights]::Synchronize
|
||||
$rightsProbe = [IO.FileStream]::new(
|
||||
$InputPath,
|
||||
[IO.FileMode]::Open,
|
||||
$requiredRights,
|
||||
[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete,
|
||||
4096,
|
||||
[IO.FileOptions]::None
|
||||
)
|
||||
try {
|
||||
$sourceDeleteAccessVerified = $true
|
||||
$sourceReplaceAccessVerified = $true
|
||||
}
|
||||
finally {
|
||||
$rightsProbe.Dispose()
|
||||
}
|
||||
}
|
||||
"swap" {
|
||||
$inputFullPath = [IO.Path]::GetFullPath($InputPath)
|
||||
$destinationFullPath = [IO.Path]::GetFullPath($DestinationPath)
|
||||
if ($inputFullPath.Equals(
|
||||
$destinationFullPath,
|
||||
[StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw [ArgumentException]::new(
|
||||
"swap source and destination are the same path"
|
||||
)
|
||||
}
|
||||
|
||||
$sourceExistedBeforeAttack = [IO.File]::Exists($inputFullPath)
|
||||
$destinationExistedBeforeAttack =
|
||||
[IO.File]::Exists($destinationFullPath)
|
||||
if (-not $sourceExistedBeforeAttack) {
|
||||
throw [IO.FileNotFoundException]::new(
|
||||
"swap source does not exist",
|
||||
$inputFullPath
|
||||
)
|
||||
}
|
||||
if (-not $destinationExistedBeforeAttack) {
|
||||
throw [IO.FileNotFoundException]::new(
|
||||
"swap destination does not exist",
|
||||
$destinationFullPath
|
||||
)
|
||||
}
|
||||
|
||||
if ($BackupMode -ceq "None") {
|
||||
if (-not [string]::IsNullOrEmpty($BackupPath)) {
|
||||
throw [ArgumentException]::new(
|
||||
"backup path was supplied while backup mode is None"
|
||||
)
|
||||
}
|
||||
# A bare PowerShell $null is coerced to String.Empty for this
|
||||
# .NET string parameter. NullString is the explicit native
|
||||
# null required to request no ReplaceFile backup.
|
||||
$backupArgumentWasNativeNull = $true
|
||||
}
|
||||
else {
|
||||
throw [ArgumentException]::new(
|
||||
"backup-producing swap fixtures are prohibited because " +
|
||||
"they can expose protected destination content"
|
||||
)
|
||||
}
|
||||
|
||||
$replaceInvoked = $true
|
||||
[IO.File]::Replace(
|
||||
$inputFullPath,
|
||||
$destinationFullPath,
|
||||
[NullString]::Value
|
||||
)
|
||||
}
|
||||
default { throw [ArgumentException]::new("unknown operation") }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$exception = $_.Exception
|
||||
$status = Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation $Operation -Exception $exception
|
||||
}
|
||||
|
||||
$result = Get-ProbeMetadata -Status $status -Exception $exception
|
||||
Write-ProbeResult -Value $result
|
||||
exit (Get-MinerOpsNativeStatusExitCode -Status $status)
|
||||
@@ -0,0 +1,475 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-MinerOpsNativeGetItemState {
|
||||
$effective = Get-Command Get-Item -ErrorAction Stop
|
||||
$function = if (
|
||||
$effective.CommandType -eq
|
||||
[Management.Automation.CommandTypes]::Function
|
||||
) {
|
||||
$effective
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
FunctionExisted = $null -ne $function
|
||||
FunctionScriptBlock = if ($null -ne $function) {
|
||||
$function.ScriptBlock
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
EffectiveCommandType = [string]$effective.CommandType
|
||||
EffectiveName = [string]$effective.Name
|
||||
EffectiveModuleName = [string]$effective.ModuleName
|
||||
EffectiveSource = [string]$effective.Source
|
||||
EffectiveDefinition = [string]$effective.Definition
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeGetItemState {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Expected,
|
||||
[string]$Context = "Get-Item state check"
|
||||
)
|
||||
|
||||
$actual = Get-MinerOpsNativeGetItemState
|
||||
if ([bool]$actual.FunctionExisted -ne [bool]$Expected.FunctionExisted) {
|
||||
throw "$Context changed the global Get-Item function's existence."
|
||||
}
|
||||
if (
|
||||
[bool]$Expected.FunctionExisted -and
|
||||
(
|
||||
-not [object]::ReferenceEquals(
|
||||
$actual.FunctionScriptBlock,
|
||||
$Expected.FunctionScriptBlock
|
||||
) -or
|
||||
$actual.FunctionScriptBlock.ToString() -cne
|
||||
$Expected.FunctionScriptBlock.ToString()
|
||||
)
|
||||
) {
|
||||
throw "$Context changed the global Get-Item function's ScriptBlock."
|
||||
}
|
||||
foreach ($property in @(
|
||||
"EffectiveCommandType",
|
||||
"EffectiveName",
|
||||
"EffectiveModuleName",
|
||||
"EffectiveSource",
|
||||
"EffectiveDefinition"
|
||||
)) {
|
||||
if ([string]$actual.$property -cne [string]$Expected.$property) {
|
||||
throw "$Context changed effective Get-Item resolution ($property)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsNativeGetItemTrace {
|
||||
param([Parameter(Mandatory = $true)][scriptblock]$Action)
|
||||
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
$observedGetItemPaths = [System.Collections.Generic.List[string]]::new()
|
||||
$invokeWithLocalHook = {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][scriptblock]$TraceAction,
|
||||
[Parameter(Mandatory = $true)]$ObservedPaths
|
||||
)
|
||||
|
||||
# Use a child scope because PowerShell can continue resolving a
|
||||
# removed active function until the scope that defined it exits.
|
||||
# Exiting this child scope makes cleanup deterministic before the
|
||||
# caller-state assertion runs.
|
||||
Set-Item -LiteralPath "Function:\local:Get-Item" -Value {
|
||||
[CmdletBinding(DefaultParameterSetName = "Path")]
|
||||
param(
|
||||
[Parameter(Position = 0, ParameterSetName = "Path")]
|
||||
[string[]]$Path,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "LiteralPath")]
|
||||
[Alias("PSPath")]
|
||||
[string[]]$LiteralPath,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$observed = if ($PSCmdlet.ParameterSetName -eq "LiteralPath") {
|
||||
$LiteralPath
|
||||
}
|
||||
else {
|
||||
$Path
|
||||
}
|
||||
foreach ($candidate in @($observed)) {
|
||||
$ObservedPaths.Add([string]$candidate)
|
||||
}
|
||||
Microsoft.PowerShell.Management\Get-Item @PSBoundParameters
|
||||
}
|
||||
try {
|
||||
& $TraceAction | Out-Null
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath "Function:\local:Get-Item" -Force `
|
||||
-ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
try {
|
||||
& $invokeWithLocalHook $Action $observedGetItemPaths
|
||||
return @($observedGetItemPaths)
|
||||
}
|
||||
finally {
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "Invoke-MinerOpsNativeGetItemTrace cleanup"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeWin32Error {
|
||||
param([Parameter(Mandatory = $true)][int]$HResult)
|
||||
|
||||
# HRESULT_FROM_WIN32 values have the 0x8007 facility prefix. Do not treat
|
||||
# the low word of an arbitrary CLR or PowerShell HRESULT as a Win32 error.
|
||||
$unsignedHResult = [int64]$HResult -band 4294967295L
|
||||
if (
|
||||
($unsignedHResult -band 4294901760L) -eq 2147942400L
|
||||
) {
|
||||
return [int]($unsignedHResult -band 65535L)
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeExceptionDetails {
|
||||
param([Parameter(Mandatory = $true)][Exception]$Exception)
|
||||
|
||||
$maximumDepth = 32
|
||||
$visited = [System.Collections.Generic.List[Exception]]::new()
|
||||
$chainTypes = [System.Collections.Generic.List[string]]::new()
|
||||
$current = $Exception
|
||||
$leaf = $Exception
|
||||
$truncated = $false
|
||||
|
||||
for ($depth = 0; $depth -lt $maximumDepth; $depth++) {
|
||||
if ($null -eq $current) { break }
|
||||
|
||||
$alreadyVisited = $false
|
||||
foreach ($candidate in $visited) {
|
||||
if ([object]::ReferenceEquals($candidate, $current)) {
|
||||
$alreadyVisited = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($alreadyVisited) {
|
||||
$truncated = $true
|
||||
break
|
||||
}
|
||||
|
||||
$visited.Add($current)
|
||||
$chainTypes.Add($current.GetType().FullName)
|
||||
$leaf = $current
|
||||
$current = $current.InnerException
|
||||
}
|
||||
if ($null -ne $current) {
|
||||
$truncated = $true
|
||||
}
|
||||
|
||||
$nativeError = Get-MinerOpsNativeWin32Error -HResult ([int]$leaf.HResult)
|
||||
return [pscustomobject]@{
|
||||
OuterException = $Exception
|
||||
OuterExceptionType = $Exception.GetType().FullName
|
||||
OuterHResult = [int]$Exception.HResult
|
||||
LeafException = $leaf
|
||||
LeafExceptionType = $leaf.GetType().FullName
|
||||
LeafHResult = [int]$leaf.HResult
|
||||
NativeError = $nativeError
|
||||
ExceptionChainTypes = @($chainTypes)
|
||||
ExceptionChainTruncated = $truncated
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeExceptionStatus {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Operation,
|
||||
[Parameter(Mandatory = $true)][Exception]$Exception
|
||||
)
|
||||
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $Exception
|
||||
$meaningfulException = $details.LeafException
|
||||
$nativeError = $details.NativeError
|
||||
|
||||
if ($details.ExceptionChainTruncated) {
|
||||
return "unexpected_failure"
|
||||
}
|
||||
|
||||
if ($meaningfulException -is [UnauthorizedAccessException]) {
|
||||
return "security_denied"
|
||||
}
|
||||
|
||||
if (
|
||||
$meaningfulException -is [ArgumentException] -or
|
||||
$meaningfulException -is [IO.FileNotFoundException] -or
|
||||
$meaningfulException -is [IO.DirectoryNotFoundException] -or
|
||||
$meaningfulException -is [IO.DriveNotFoundException] -or
|
||||
$meaningfulException -is [IO.PathTooLongException] -or
|
||||
$meaningfulException -is [NotSupportedException]
|
||||
) {
|
||||
return "fixture_error"
|
||||
}
|
||||
|
||||
if (
|
||||
$meaningfulException -is [IO.IOException] -and
|
||||
$null -ne $nativeError
|
||||
) {
|
||||
if ([int]$nativeError -eq 5) {
|
||||
return "security_denied"
|
||||
}
|
||||
|
||||
if ($Operation -ceq "swap" -and [int]$nativeError -eq 1175) {
|
||||
# ReplaceFileW documents ERROR_UNABLE_TO_REMOVE_REPLACED as
|
||||
# retaining both original names. The caller must still prove every
|
||||
# security and identity postcondition before treating this as a
|
||||
# passing denial.
|
||||
return "security_denied_requires_postconditions"
|
||||
}
|
||||
|
||||
if ([int]$nativeError -eq 17) {
|
||||
# ReplaceFile requires source and destination on the same volume.
|
||||
# This says nothing about the protected target's access boundary.
|
||||
return "api_precondition_failure"
|
||||
}
|
||||
}
|
||||
|
||||
return "unexpected_failure"
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeStatusExitCode {
|
||||
param([Parameter(Mandatory = $true)][string]$Status)
|
||||
|
||||
switch ($Status) {
|
||||
"succeeded" { return 0 }
|
||||
"security_denied" { return 5 }
|
||||
"fixture_error" { return 6 }
|
||||
"unexpected_failure" { return 7 }
|
||||
"security_denied_requires_postconditions" { return 8 }
|
||||
"api_precondition_failure" { return 9 }
|
||||
default { throw "Unknown standard-user helper status '$Status'." }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeSwapFixtureStatus {
|
||||
param([Parameter(Mandatory = $true)]$Fixture)
|
||||
|
||||
$fixtureFailures = [System.Collections.Generic.List[string]]::new()
|
||||
$requiredFixtureChecks = [ordered]@{
|
||||
SourceExists = "source is missing"
|
||||
TargetExists = "target is missing"
|
||||
PathsDistinct = "source and target are the same path"
|
||||
SourceCreatedByStandardUser = `
|
||||
"source was not created by the standard-user process"
|
||||
SourceOwnedByStandardUser = `
|
||||
"standard user does not own the source"
|
||||
SourceReadable = "standard user cannot read the source"
|
||||
SourceModifiable = "standard user cannot modify the source"
|
||||
SourceRenameable = "standard user cannot rename the source"
|
||||
SourceDeleteCapable = `
|
||||
"standard user does not have required delete access to the source"
|
||||
SourceReplaceAccessVerified = `
|
||||
"standard user lacks source access required by ReplaceFile"
|
||||
TargetAclTrusted = "target ACL was not trusted before attack"
|
||||
TargetOwnerTrusted = "target owner was not trusted before attack"
|
||||
TargetIdentityCaptured = "target identity was not captured"
|
||||
SourceIdentityCaptured = "source identity was not captured"
|
||||
FileIdentitiesDistinct = "source and target resolve to the same file"
|
||||
BackupArgumentValid = "backup argument is invalid"
|
||||
BackupPathsDistinct = `
|
||||
"backup path is not distinct from source and target"
|
||||
BackupExpectedStateVerified = `
|
||||
"backup pre-attack state was not verified"
|
||||
BackupConfidentialitySafe = `
|
||||
"backup choice could expose protected target content"
|
||||
}
|
||||
foreach ($entry in $requiredFixtureChecks.GetEnumerator()) {
|
||||
if (-not [bool]$Fixture.($entry.Key)) {
|
||||
$fixtureFailures.Add([string]$entry.Value)
|
||||
}
|
||||
}
|
||||
if ($fixtureFailures.Count -gt 0) {
|
||||
return [pscustomobject]@{
|
||||
Status = "fixture_error"
|
||||
Failures = @($fixtureFailures)
|
||||
}
|
||||
}
|
||||
|
||||
$apiFailures = [System.Collections.Generic.List[string]]::new()
|
||||
if (-not [bool]$Fixture.SameVolume) {
|
||||
$apiFailures.Add("source, target, and backup are not on one volume")
|
||||
}
|
||||
if (-not [bool]$Fixture.NtfsVolume) {
|
||||
$apiFailures.Add("attack fixture is not on NTFS")
|
||||
}
|
||||
if ($apiFailures.Count -gt 0) {
|
||||
return [pscustomobject]@{
|
||||
Status = "api_precondition_failure"
|
||||
Failures = @($apiFailures)
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Status = "ready"
|
||||
Failures = @()
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeSwapFixtureReady {
|
||||
param([Parameter(Mandatory = $true)]$Fixture)
|
||||
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $Fixture
|
||||
switch ([string]$classification.Status) {
|
||||
"ready" { return }
|
||||
"fixture_error" {
|
||||
throw "Standard-user swap fixture is invalid: " +
|
||||
"$(@($classification.Failures) -join '; ')."
|
||||
}
|
||||
"api_precondition_failure" {
|
||||
throw "Standard-user swap has an API precondition failure: " +
|
||||
"$(@($classification.Failures) -join '; ')."
|
||||
}
|
||||
default {
|
||||
throw "Standard-user swap fixture classification failed closed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeSwapSecurityOutcome {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Result,
|
||||
[Parameter(Mandatory = $true)]$Postconditions
|
||||
)
|
||||
|
||||
if ([string]$Result.operation -cne "swap") {
|
||||
throw "Swap result recorded the wrong operation."
|
||||
}
|
||||
$detail = "outer=$([string]$Result.outer_exception_type), " +
|
||||
"outer_hresult=$([string]$Result.outer_hresult), " +
|
||||
"leaf=$([string]$Result.leaf_exception_type), " +
|
||||
"leaf_hresult=$([string]$Result.leaf_hresult), " +
|
||||
"native=$([string]$Result.native_error), " +
|
||||
"chain=$(@($Result.exception_chain_types) -join ' -> ')"
|
||||
switch ([string]$Result.status) {
|
||||
"succeeded" {
|
||||
if (-not [bool]$Result.replace_invoked) {
|
||||
throw "Successful swap result did not record File.Replace invocation."
|
||||
}
|
||||
throw "Standard user completed the protected replacement swap."
|
||||
}
|
||||
"security_denied" { }
|
||||
"security_denied_requires_postconditions" { }
|
||||
"fixture_error" {
|
||||
throw "Standard-user swap fixture was invalid ($detail)."
|
||||
}
|
||||
"api_precondition_failure" {
|
||||
throw "Standard-user swap hit an unrelated API precondition ($detail)."
|
||||
}
|
||||
default {
|
||||
throw "Standard-user swap failed unexpectedly ($detail)."
|
||||
}
|
||||
}
|
||||
if (-not [bool]$Result.replace_invoked) {
|
||||
throw "Standard-user swap did not reach System.IO.File.Replace."
|
||||
}
|
||||
|
||||
$required = [ordered]@{
|
||||
TargetExists = "protected target is missing"
|
||||
TargetIdentityUnchanged = "protected target identity changed"
|
||||
TargetContentUnchanged = "protected target content changed"
|
||||
TargetAclTrusted = "protected target ACL is not trusted"
|
||||
TargetAclUnchanged = "protected target ACL changed"
|
||||
TargetOwnerTrusted = "protected target owner is not trusted"
|
||||
TargetOwnerUnchanged = "protected target owner changed"
|
||||
TargetSingleLink = "protected target is not single-link"
|
||||
SourceExists = "attacker source is missing"
|
||||
SourceIdentityUnchanged = "attacker source identity changed"
|
||||
SourceContentUnchanged = "attacker source content changed"
|
||||
AttackerSourceNotPromoted = "attacker source was promoted to authority"
|
||||
TargetDeniedStandardUserRead = `
|
||||
"protected content became readable by the standard user"
|
||||
TargetDeniedStandardUserModify = `
|
||||
"protected target became modifiable by the standard user"
|
||||
TargetDeniedStandardUserDelete = `
|
||||
"protected target became deletable by the standard user"
|
||||
TargetDeniedStandardUserRename = `
|
||||
"protected target became renameable by the standard user"
|
||||
NoAttackerReadableBackup = `
|
||||
"replacement exposed protected content through a backup"
|
||||
}
|
||||
$failures = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($entry in $required.GetEnumerator()) {
|
||||
if (-not [bool]$Postconditions.($entry.Key)) {
|
||||
$failures.Add([string]$entry.Value)
|
||||
}
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
throw "Swap security postconditions failed ($detail): " +
|
||||
"$($failures -join '; ')."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeFileIdentity {
|
||||
param([Parameter(Mandatory = $true)]$Stream)
|
||||
|
||||
if (-not ("MinerOpsNativeHarnessFileIdentity" -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
public sealed class MinerOpsNativeHarnessFileIdentityResult
|
||||
{
|
||||
public string Identity { get; set; }
|
||||
public uint VolumeSerialNumber { get; set; }
|
||||
public ulong FileIndex { get; set; }
|
||||
public uint NumberOfLinks { get; set; }
|
||||
}
|
||||
|
||||
public static class MinerOpsNativeHarnessFileIdentity
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ByHandleFileInformation
|
||||
{
|
||||
public uint FileAttributes;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle file, out ByHandleFileInformation information);
|
||||
|
||||
public static MinerOpsNativeHarnessFileIdentityResult Get(
|
||||
SafeFileHandle file)
|
||||
{
|
||||
ByHandleFileInformation information;
|
||||
if (!GetFileInformationByHandle(file, out information))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
ulong fileIndex = ((ulong)information.FileIndexHigh << 32) |
|
||||
information.FileIndexLow;
|
||||
return new MinerOpsNativeHarnessFileIdentityResult {
|
||||
Identity = String.Format(
|
||||
"{0:X8}:{1:X8}{2:X8}",
|
||||
information.VolumeSerialNumber,
|
||||
information.FileIndexHigh,
|
||||
information.FileIndexLow),
|
||||
VolumeSerialNumber = information.VolumeSerialNumber,
|
||||
FileIndex = fileIndex,
|
||||
NumberOfLinks = information.NumberOfLinks
|
||||
};
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
return [MinerOpsNativeHarnessFileIdentity]::Get($Stream.SafeFileHandle)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1"
|
||||
. $scriptPath
|
||||
|
||||
$script:Passed = 0
|
||||
$script:BootEventRecords = @()
|
||||
$script:ToXmlCalls = 0
|
||||
|
||||
function Assert-True {
|
||||
param(
|
||||
[Parameter(Mandatory)][bool]$Condition,
|
||||
[Parameter(Mandatory)][string]$Message
|
||||
)
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-Throws {
|
||||
param(
|
||||
[Parameter(Mandatory)][scriptblock]$Action,
|
||||
[string]$Pattern = ""
|
||||
)
|
||||
try { & $Action | Out-Null }
|
||||
catch {
|
||||
if ($Pattern -and $_.Exception.Message -notmatch $Pattern) {
|
||||
throw "Expected error '$Pattern', got '$($_.Exception.Message)'."
|
||||
}
|
||||
return
|
||||
}
|
||||
throw "Expected action to fail."
|
||||
}
|
||||
|
||||
function Invoke-ParserTest {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][scriptblock]$Action
|
||||
)
|
||||
& $Action
|
||||
$script:Passed++
|
||||
Write-Host "PASS: $Name"
|
||||
}
|
||||
|
||||
function New-TestKernelBootXml {
|
||||
param(
|
||||
[string]$Namespace =
|
||||
"http://schemas.microsoft.com/win/2004/08/events/event",
|
||||
[string]$BootTypeContent = '<Data Name="BootType">0</Data>',
|
||||
[switch]$OmitSystem,
|
||||
[switch]$OmitEventData,
|
||||
[switch]$DuplicateEventData
|
||||
)
|
||||
|
||||
$system = if ($OmitSystem) { "" } else {
|
||||
@"
|
||||
<System>
|
||||
<Provider Name="Microsoft-Windows-Kernel-Boot" />
|
||||
<EventID>27</EventID>
|
||||
<Version>1</Version>
|
||||
<Level>4</Level>
|
||||
<Task>33</Task>
|
||||
<Opcode>0</Opcode>
|
||||
<EventRecordID>42</EventRecordID>
|
||||
<Channel>System</Channel>
|
||||
</System>
|
||||
"@
|
||||
}
|
||||
$eventData = if ($OmitEventData) { "" } else {
|
||||
" <EventData>$BootTypeContent</EventData>"
|
||||
}
|
||||
if ($DuplicateEventData) {
|
||||
$eventData += "`n <EventData>$BootTypeContent</EventData>"
|
||||
}
|
||||
return "<Event xmlns=`"$Namespace`">`n$system$eventData`n</Event>"
|
||||
}
|
||||
|
||||
function New-TestEventRecord {
|
||||
param(
|
||||
[AllowNull()]$ProviderName = "Microsoft-Windows-Kernel-Boot",
|
||||
[AllowNull()]$Id = [int]27,
|
||||
[AllowNull()]$RecordId = [long]42,
|
||||
[AllowNull()]$Version = [byte]1,
|
||||
[AllowNull()]$Xml = (New-TestKernelBootXml)
|
||||
)
|
||||
|
||||
$record = [pscustomobject]@{
|
||||
ProviderName = $ProviderName
|
||||
Id = $Id
|
||||
RecordId = $RecordId
|
||||
Version = $Version
|
||||
XmlPayload = $Xml
|
||||
}
|
||||
$record | Add-Member -MemberType ScriptMethod -Name ToXml -Value {
|
||||
$script:ToXmlCalls++
|
||||
return $this.XmlPayload
|
||||
}
|
||||
return $record
|
||||
}
|
||||
|
||||
function Get-WinEvent {
|
||||
param($FilterHashtable, $ErrorAction)
|
||||
return $script:BootEventRecords
|
||||
}
|
||||
|
||||
Invoke-ParserTest "conventional namespaced Event XML and typed metadata parse" {
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord)
|
||||
Assert-True ($parsed.ProviderName -ceq
|
||||
"Microsoft-Windows-Kernel-Boot") "Typed provider was not retained."
|
||||
Assert-True ($parsed.EventId -is [int] -and $parsed.EventId -eq 27) `
|
||||
"Typed event ID was not retained."
|
||||
Assert-True ($parsed.RecordId -is [long] -and $parsed.RecordId -eq 42) `
|
||||
"Typed record ID was not retained."
|
||||
Assert-True ($parsed.Version -is [byte] -and $parsed.Version -eq 1) `
|
||||
"Typed event version was not retained."
|
||||
Assert-True ($parsed.BootType -is [UInt32] -and $parsed.BootType -eq 0) `
|
||||
"BootType payload was not parsed."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "supported event version zero parses" {
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version ([byte]0))
|
||||
Assert-True ($parsed.Version -eq 0) "Version zero was not accepted."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "production Get-WinEvent path invokes ToXml parser" {
|
||||
$script:ToXmlCalls = 0
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -RecordId ([long]43)
|
||||
)
|
||||
$identifier = Get-MinerOpsBootIdentifier
|
||||
Assert-True ($identifier -ceq
|
||||
"windows-full-boot-event-v1:000000000000002b") `
|
||||
"Production event path returned the wrong identifier."
|
||||
Assert-True ($script:ToXmlCalls -eq 1) `
|
||||
"Production event path did not invoke ToXml exactly once."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "typed metadata is authoritative over XML scalar metadata" {
|
||||
$xml = (New-TestKernelBootXml).Replace(
|
||||
"<EventID>27</EventID>", "<EventID>999</EventID>"
|
||||
).Replace(
|
||||
"<EventRecordID>42</EventRecordID>",
|
||||
"<EventRecordID>999</EventRecordID>"
|
||||
)
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Xml $xml)
|
||||
Assert-True ($parsed.EventId -eq 27 -and $parsed.RecordId -eq 42) `
|
||||
"XML scalar metadata overrode typed EventRecord properties."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "null typed RecordId fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -RecordId $null)
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong typed provider fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -ProviderName "Unexpected-Provider")
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong typed event ID fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Id ([int]28))
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong RecordId type fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -RecordId ([UInt64]42))
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "null event version fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version $null)
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "unsupported event version fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version ([byte]2))
|
||||
} "unsupported"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "unexpected Event XML namespace fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-Namespace "urn:unexpected"))
|
||||
} "root"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "malformed Event XML fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Xml "<Event>")
|
||||
} "malformed"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing System element fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitSystem))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing EventData fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitEventData))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicated EventData fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml `
|
||||
(New-TestKernelBootXml -DuplicateEventData))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="LoadOptions"></Data>'))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicate BootType fails closed" {
|
||||
$payload = '<Data Name="BootType">0</Data>' +
|
||||
'<Data Name="BootType">0</Data>'
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent $payload))
|
||||
} "ambiguous"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "nonnumeric BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">full</Data>'))
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "noncanonical BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">00</Data>'))
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "structured BootType content fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent `
|
||||
'<Data Name="BootType"><Value>0</Value></Data>'))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "BootType outside UInt32 fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent `
|
||||
'<Data Name="BootType">4294967296</Data>'))
|
||||
} "invalid"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "nonzero BootType is not a full-boot anchor" {
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">1</Data>')
|
||||
)
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "empty event query fails closed" {
|
||||
$script:BootEventRecords = @()
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicate qualifying record IDs fail closed" {
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -RecordId ([long]42)
|
||||
New-TestEventRecord -RecordId ([long]43)
|
||||
New-TestEventRecord -RecordId ([long]42)
|
||||
)
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "equal stored and current anchors deny reset" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a"
|
||||
} "without a verified full Restart"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "strictly greater current anchor passes ordering" {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "smaller current anchor fails closed" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029"
|
||||
} "older than the"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "malformed stored anchor fails closed" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:not-a-record" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Write-Host "$script:Passed boot-event parser tests passed."
|
||||
@@ -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."
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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."
|
||||
@@ -32,6 +32,9 @@ function Assert-SourceContains {
|
||||
Assert-SourceContains 'enrollment\.json' `
|
||||
"Bootstrap enrollment file is not explicit."
|
||||
|
||||
Assert-SourceContains 'workflow\s*-ne\s*"new_agent"' `
|
||||
"New-Agent bootstrap does not reject transition packages."
|
||||
|
||||
Assert-SourceContains '/api/agent/enroll' `
|
||||
"Bootstrap does not use the secure enrollment endpoint."
|
||||
|
||||
@@ -44,8 +47,20 @@ Assert-SourceContains 'agent-config\.json\.pending' `
|
||||
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 'Open-MinerOpsEnrollmentCredentialFileCreateNew' `
|
||||
"Handle-based credential creation is missing."
|
||||
|
||||
Assert-SourceContains 'FileMode\]::CreateNew' `
|
||||
"Credential files are not created with fail-if-present semantics."
|
||||
|
||||
Assert-SourceContains 'FileShare\]::None' `
|
||||
"Credential files are not held exclusively while being created."
|
||||
|
||||
Assert-SourceContains 'Flush\(\$true\)' `
|
||||
"Credential writes are not durably flushed through the trusted handle."
|
||||
|
||||
Assert-SourceContains 'GetNumberOfLinks' `
|
||||
"Enrollment does not reject hard-linked credential targets."
|
||||
|
||||
Assert-SourceContains 'S-1-5-18' `
|
||||
"SYSTEM ACL entry is missing."
|
||||
@@ -53,12 +68,73 @@ Assert-SourceContains 'S-1-5-18' `
|
||||
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 'SetOwner\(\$administrators\)' `
|
||||
"Stable Administrators ownership is missing from credential ACLs."
|
||||
|
||||
Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' `
|
||||
Assert-SourceContains '\[IO\.File\]::Move\(\$PendingConfigPath, \$ConfigPath\)' `
|
||||
"Atomic final config move is missing."
|
||||
|
||||
Assert-SourceContains 'Assert-MinerOpsEnrollmentDirectory' `
|
||||
"Enrollment does not revalidate the directory trust boundary."
|
||||
|
||||
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
|
||||
"Enrollment directory repair lacks an external durable trust blocker."
|
||||
|
||||
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
|
||||
"Enrollment lacks an explicit post-reboot trust-reset gate."
|
||||
|
||||
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
||||
"Enrollment blockers do not use canonical full-kernel-boot records."
|
||||
|
||||
if ($source -match 'LastBootUpTime') {
|
||||
throw "Enrollment must not use a wall-clock boot timestamp."
|
||||
}
|
||||
|
||||
if ($source -match 'Remove-MinerOpsEnrollmentTrustBlocker') {
|
||||
throw "Enrollment must not have independent trust-blocker deletion authority."
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Enrollment cannot clear an existing filesystem trust blocker' `
|
||||
"Enrollment does not fail closed on retained filesystem trust blockers."
|
||||
|
||||
Assert-SourceContains 'Assert-MinerOpsEnrollmentTrustedAncestors' `
|
||||
"Enrollment does not validate ancestor namespace protection."
|
||||
|
||||
foreach ($functionName in @(
|
||||
"Assert-MinerOpsEnrollmentNoReparsePath",
|
||||
"Assert-MinerOpsEnrollmentTrustedAncestors"
|
||||
)) {
|
||||
$functionSource = [regex]::Match(
|
||||
$source,
|
||||
"(?s)function $functionName \{.*?\n\}"
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($functionSource)) {
|
||||
throw "$functionName source was not found."
|
||||
}
|
||||
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
|
||||
throw "$functionName does not preserve the filesystem root."
|
||||
}
|
||||
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued full path from trimming."
|
||||
}
|
||||
if ($functionSource -match '\$root\.TrimEnd\(') {
|
||||
throw "$functionName converts a root-qualified path to drive-relative form."
|
||||
}
|
||||
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
|
||||
throw "$functionName trims the filesystem root before identifying it."
|
||||
}
|
||||
if ($functionName -eq "Assert-MinerOpsEnrollmentTrustedAncestors" -and
|
||||
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued parent from trimming."
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Open-MinerOpsEnrollmentDirectoryLock' `
|
||||
"Enrollment does not lock an insecure target during repair."
|
||||
|
||||
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
|
||||
"Enrollment does not securely stage a new directory."
|
||||
|
||||
Assert-SourceContains '& \$Installer' `
|
||||
"Bootstrap does not hand off to the safe installer."
|
||||
|
||||
@@ -68,6 +144,78 @@ Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
|
||||
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
|
||||
"Consumed extracted enrollment token is not removed."
|
||||
|
||||
if ($source -match '\[IO\.File\]::WriteAllText\(\s*\$(Pending|Recovery)ConfigPath') {
|
||||
throw "Enrollment must not truncate or adopt a credential pathname."
|
||||
}
|
||||
|
||||
$createFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Open-MinerOpsEnrollmentCredentialFileCreateNew \{.*?\n\}'
|
||||
).Value
|
||||
$createIndex = $createFunction.IndexOf(
|
||||
'New-MinerOpsEnrollmentProtectedFileCreateNewStream'
|
||||
)
|
||||
$verifyIndex = $createFunction.IndexOf('Test-MinerOpsEnrollmentCredentialAcl')
|
||||
$linkIndex = $createFunction.IndexOf('Assert-MinerOpsEnrollmentSingleLinkStream')
|
||||
if (
|
||||
$createIndex -lt 0 -or $verifyIndex -le $createIndex -or
|
||||
$linkIndex -le $verifyIndex -or
|
||||
$createFunction -match 'Set-MinerOpsEnrollmentStreamAcl'
|
||||
) {
|
||||
throw "Enrollment credential creation must apply FileSecurity at CreateNew, verify it, then reject links before content."
|
||||
}
|
||||
|
||||
$createStreamFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsEnrollmentProtectedFileCreateNewStream \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredCreateContract in @(
|
||||
'\[IO\.FileStream\]::new',
|
||||
'FileMode\]::CreateNew',
|
||||
'FileSystemRights\]::WriteData',
|
||||
'FileSystemRights\]::ReadPermissions',
|
||||
'FileSystemRights\]::Synchronize',
|
||||
'FileShare\]::None',
|
||||
'\$Acl'
|
||||
)) {
|
||||
if ($createStreamFunction -notmatch $requiredCreateContract) {
|
||||
throw "Enrollment protected CreateNew stream contract is incomplete."
|
||||
}
|
||||
}
|
||||
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
|
||||
throw "Enrollment protected creation requests excess rights or repairs by path."
|
||||
}
|
||||
|
||||
$repairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Protect-MinerOpsEnrollmentExistingConfig \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredRepairRight in @(
|
||||
'ReadPermissions',
|
||||
'ChangePermissions',
|
||||
'TakeOwnership',
|
||||
'Synchronize'
|
||||
)) {
|
||||
if ($repairFunction -notmatch $requiredRepairRight) {
|
||||
throw "Enrollment existing-config repair is missing $requiredRepairRight."
|
||||
}
|
||||
}
|
||||
if ($repairFunction -match 'Set-Acl|FileAccess') {
|
||||
throw "Enrollment existing-config repair must remain handle-bound."
|
||||
}
|
||||
|
||||
$writeFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Write-MinerOpsEnrollmentCredentialJson \{.*?\n\}'
|
||||
).Value
|
||||
if (
|
||||
$writeFunction -notmatch 'Assert-MinerOpsEnrollmentSingleLinkStream' -or
|
||||
$writeFunction -notmatch '\$Stream\.Write' -or
|
||||
$writeFunction -notmatch '\$Stream\.Flush\(\$true\)'
|
||||
) {
|
||||
throw "Enrollment credential content is not written and flushed through the verified handle."
|
||||
}
|
||||
|
||||
$existingConfigGuard = $source.IndexOf(
|
||||
'Test-Path -LiteralPath $ConfigPath'
|
||||
)
|
||||
@@ -85,7 +233,7 @@ if (
|
||||
}
|
||||
|
||||
$configMove = $source.IndexOf(
|
||||
'-Destination $ConfigPath'
|
||||
'[IO.File]::Move($PendingConfigPath, $ConfigPath)'
|
||||
)
|
||||
|
||||
$installerCall = $source.IndexOf(
|
||||
|
||||
@@ -33,10 +33,14 @@ if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
||||
throw "Source preflight must happen before target changes."
|
||||
}
|
||||
|
||||
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
|
||||
Assert-SourceContains '\$existingTask\s*=\s*Get-MinerOpsScheduledTask' `
|
||||
"Existing task preservation guard is missing."
|
||||
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
||||
"Existing task preservation is not explicit."
|
||||
Assert-SourceContains 'Test-MinerOpsInstallerShouldCreateMissingTask' `
|
||||
"Missing-task trust-reset policy helper is absent."
|
||||
Assert-SourceContains 'Deferring missing per-user Scheduled Task' `
|
||||
"Missing per-user task deferral is not explicit."
|
||||
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
|
||||
"Fresh enrichment task arguments are missing."
|
||||
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
||||
@@ -49,6 +53,228 @@ Assert-SourceContains 'Unregister-ScheduledTask' `
|
||||
"Rollback does not remove tasks created by a failed install."
|
||||
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
||||
"Rollback restart failure is not surfaced."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedAncestors' `
|
||||
"Installer does not validate ancestor namespace protection."
|
||||
|
||||
$taskDefinitionsStart = $source.IndexOf('$definitions = @(')
|
||||
$taskLoopStart = $source.IndexOf(
|
||||
'foreach ($definition in $definitions)', $taskDefinitionsStart
|
||||
)
|
||||
if ($taskDefinitionsStart -lt 0 -or $taskLoopStart -le $taskDefinitionsStart) {
|
||||
throw "Scheduled Task definitions or reconciliation loop were not found."
|
||||
}
|
||||
$taskDefinitions = $source.Substring(
|
||||
$taskDefinitionsStart, $taskLoopStart - $taskDefinitionsStart
|
||||
)
|
||||
foreach ($policy in @(
|
||||
@{ Name = "MinerOpsAgent"; Expected = '\$true' },
|
||||
@{ Name = "MinerOpsAgentTray"; Expected = '\$false' },
|
||||
@{ Name = "MinerOpsApi3Enrichment"; Expected = '\$true' },
|
||||
@{ Name = "MinerOpsPoolPoller"; Expected = '\$true' }
|
||||
)) {
|
||||
$definition = [regex]::Match(
|
||||
$taskDefinitions,
|
||||
('(?s)@\{{.*?Name\s*=\s*"{0}".*?\}}' -f
|
||||
[regex]::Escape($policy.Name))
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($definition) -or
|
||||
$definition -notmatch (
|
||||
'CreateWhenMissingDuringTrustReset\s*=\s*{0}' -f
|
||||
$policy.Expected
|
||||
)) {
|
||||
throw "Scheduled Task $($policy.Name) has the wrong trust-reset creation policy."
|
||||
}
|
||||
}
|
||||
|
||||
$taskLoopEnd = $source.IndexOf(
|
||||
'if ($mainTaskWasRunning', $taskLoopStart
|
||||
)
|
||||
if ($taskLoopEnd -le $taskLoopStart) {
|
||||
throw "Scheduled Task reconciliation loop end was not found."
|
||||
}
|
||||
$taskLoop = $source.Substring($taskLoopStart, $taskLoopEnd - $taskLoopStart)
|
||||
$preserveIndex = $taskLoop.IndexOf('$null -ne $existingTask')
|
||||
$deferIndex = $taskLoop.IndexOf(
|
||||
'Test-MinerOpsInstallerShouldCreateMissingTask'
|
||||
)
|
||||
$actionIndex = $taskLoop.IndexOf('New-ScheduledTaskAction')
|
||||
if (
|
||||
$preserveIndex -lt 0 -or
|
||||
$deferIndex -le $preserveIndex -or
|
||||
$actionIndex -le $deferIndex
|
||||
) {
|
||||
throw "Existing-task preservation and missing-task deferral are ordered unsafely."
|
||||
}
|
||||
if ($taskLoop -match '80070534|No mapping between account names') {
|
||||
throw "Missing per-user task handling must be policy-based, not HRESULT-based."
|
||||
}
|
||||
|
||||
$taskPolicyFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Test-MinerOpsInstallerShouldCreateMissingTask \{.*?\n\}'
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($taskPolicyFunction)) {
|
||||
throw "Missing-task trust-reset policy function source was not found."
|
||||
}
|
||||
Invoke-Expression $taskPolicyFunction
|
||||
$trayPolicy = [pscustomobject]@{
|
||||
CreateWhenMissingDuringTrustReset = $false
|
||||
}
|
||||
$systemTaskPolicy = [pscustomobject]@{
|
||||
CreateWhenMissingDuringTrustReset = $true
|
||||
}
|
||||
if (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||
-Definition $trayPolicy -TrustResetMode $true) {
|
||||
throw "Trust reset would create a missing per-user tray task."
|
||||
}
|
||||
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||
-Definition $trayPolicy -TrustResetMode $false)) {
|
||||
throw "Normal interactive installation would not repair a missing tray task."
|
||||
}
|
||||
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||
-Definition $systemTaskPolicy -TrustResetMode $true)) {
|
||||
throw "Trust reset would skip a missing explicit-SYSTEM task."
|
||||
}
|
||||
$missingPolicyRejected = $false
|
||||
try {
|
||||
[void](Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||
-Definition ([pscustomobject]@{}) -TrustResetMode $true)
|
||||
}
|
||||
catch {
|
||||
$missingPolicyRejected = $_.Exception.Message -match
|
||||
'trust-reset creation policy is missing'
|
||||
}
|
||||
if (-not $missingPolicyRejected) {
|
||||
throw "Trust reset accepted a task definition without explicit policy."
|
||||
}
|
||||
|
||||
foreach ($functionName in @(
|
||||
"Assert-MinerOpsInstallerNoReparsePath",
|
||||
"Assert-MinerOpsInstallerTrustedAncestors"
|
||||
)) {
|
||||
$functionSource = [regex]::Match(
|
||||
$source,
|
||||
"(?s)function $functionName \{.*?\n\}"
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($functionSource)) {
|
||||
throw "$functionName source was not found."
|
||||
}
|
||||
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
|
||||
throw "$functionName does not preserve the filesystem root."
|
||||
}
|
||||
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued full path from trimming."
|
||||
}
|
||||
if ($functionSource -match '\$root\.TrimEnd\(') {
|
||||
throw "$functionName converts a root-qualified path to drive-relative form."
|
||||
}
|
||||
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
|
||||
throw "$functionName trims the filesystem root before identifying it."
|
||||
}
|
||||
if ($functionName -eq "Assert-MinerOpsInstallerTrustedAncestors" -and
|
||||
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued parent from trimming."
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Open-MinerOpsInstallerDirectoryLock' `
|
||||
"Installer does not lock an insecure target against replacement."
|
||||
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
|
||||
"Installer directory repair lacks an external durable provenance blocker."
|
||||
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
|
||||
"Installer lacks an explicit post-reboot trust-reset gate."
|
||||
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
|
||||
"Installer does not stage a protected new directory before publication."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerDirectory' `
|
||||
"Installer does not read back the final directory security invariant."
|
||||
Assert-SourceContains 'Protect-MinerOpsInstallerExistingConfig' `
|
||||
"Installer does not contain the existing active-config ACL."
|
||||
Assert-SourceContains 'GetNumberOfLinks' `
|
||||
"Installer does not reject hard-linked runtime, config, or backup files."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedRuntimeFile' `
|
||||
"Installer does not verify existing runtime rollback provenance."
|
||||
Assert-SourceContains 'Protect-MinerOpsInstallerRuntimeFile' `
|
||||
"Installer does not secure and verify published runtime and rollback copies."
|
||||
Assert-SourceContains 'Pre-reset runtime is evidence, never rollback authority' `
|
||||
"Installer trust reset can adopt pre-reset runtime as rollback authority."
|
||||
Assert-SourceContains 'parent-owned filesystem trust blocker remains' `
|
||||
"Installer does not explicitly retain the parent-owned trust blocker."
|
||||
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
||||
"Installer blockers do not use canonical full-kernel-boot records."
|
||||
if ($source -match 'Remove-MinerOpsInstallerTrustBlocker|Remove-Item[^\r\n]*TrustBlocker') {
|
||||
throw "Installer must have no filesystem trust-blocker deletion path."
|
||||
}
|
||||
if ($source -match 'LastBootUpTime') {
|
||||
throw "Installer must not create wall-clock boot-epoch records."
|
||||
}
|
||||
|
||||
$createStreamFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsInstallerProtectedFileCreateNewStream \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredCreateContract in @(
|
||||
'\[IO\.FileStream\]::new',
|
||||
'FileMode\]::CreateNew',
|
||||
'FileSystemRights\]::WriteData',
|
||||
'FileSystemRights\]::ReadPermissions',
|
||||
'FileSystemRights\]::Synchronize',
|
||||
'FileShare\]::None',
|
||||
'\$Acl'
|
||||
)) {
|
||||
if ($createStreamFunction -notmatch $requiredCreateContract) {
|
||||
throw "Installer protected CreateNew stream contract is incomplete."
|
||||
}
|
||||
}
|
||||
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
|
||||
throw "Installer protected creation requests excess rights or repairs by path."
|
||||
}
|
||||
|
||||
$trustBlockerFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsInstallerTrustBlocker \{.*?\n\}'
|
||||
).Value
|
||||
$trustCreateIndex = $trustBlockerFunction.IndexOf(
|
||||
'New-MinerOpsInstallerProtectedFileCreateNewStream'
|
||||
)
|
||||
$trustVerifyIndex = $trustBlockerFunction.IndexOf(
|
||||
'Test-MinerOpsInstallerProtectedFileAcl'
|
||||
)
|
||||
$trustContentIndex = $trustBlockerFunction.IndexOf('$record =')
|
||||
if (
|
||||
$trustCreateIndex -lt 0 -or
|
||||
$trustVerifyIndex -le $trustCreateIndex -or
|
||||
$trustContentIndex -le $trustVerifyIndex
|
||||
) {
|
||||
throw "Installer trust blockers must be protected and verified before content."
|
||||
}
|
||||
|
||||
$configRepairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Protect-MinerOpsInstallerExistingConfig \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredRepairRight in @(
|
||||
'ReadPermissions',
|
||||
'ChangePermissions',
|
||||
'TakeOwnership',
|
||||
'Synchronize'
|
||||
)) {
|
||||
if ($configRepairFunction -notmatch $requiredRepairRight) {
|
||||
throw "Installer existing-config repair is missing $requiredRepairRight."
|
||||
}
|
||||
}
|
||||
if ($configRepairFunction -match 'Set-Acl|FileAccess') {
|
||||
throw "Installer existing-config repair must remain handle-bound."
|
||||
}
|
||||
|
||||
$repairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Initialize-MinerOpsInstallerDirectory \{.*?\n\}'
|
||||
).Value
|
||||
$repairIndex = $repairFunction.IndexOf('Set-Acl -LiteralPath $fullPath')
|
||||
$abortIndex = $repairFunction.IndexOf('this invocation will not back up')
|
||||
if ($repairIndex -lt 0 -or $abortIndex -le $repairIndex) {
|
||||
throw "Insecure existing-directory containment must abort before publication."
|
||||
}
|
||||
|
||||
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
|
||||
throw "Installer must not force-overwrite Scheduled Tasks."
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$supportPath = Join-Path $PSScriptRoot "MinerOpsNativeHarnessSupport.ps1"
|
||||
. $supportPath
|
||||
|
||||
function Assert-True {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][bool]$Condition,
|
||||
[Parameter(Mandatory = $true)][string]$Message
|
||||
)
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-Throws {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Action,
|
||||
[Parameter(Mandatory = $true)][string]$Pattern
|
||||
)
|
||||
|
||||
try {
|
||||
& $Action | Out-Null
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message -notmatch $Pattern) {
|
||||
throw "Expected '$Pattern', got '$($_.Exception.Message)'."
|
||||
}
|
||||
return
|
||||
}
|
||||
throw "Expected action to fail with '$Pattern'."
|
||||
}
|
||||
|
||||
$script:Passed = 0
|
||||
function Invoke-HarnessTest {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||
)
|
||||
|
||||
& $Action
|
||||
$script:Passed++
|
||||
Write-Host "PASS: $Name"
|
||||
}
|
||||
|
||||
function New-PassingSwapPostconditions {
|
||||
return [pscustomobject]@{
|
||||
TargetExists = $true
|
||||
TargetIdentityUnchanged = $true
|
||||
TargetContentUnchanged = $true
|
||||
TargetAclTrusted = $true
|
||||
TargetAclUnchanged = $true
|
||||
TargetOwnerTrusted = $true
|
||||
TargetOwnerUnchanged = $true
|
||||
TargetSingleLink = $true
|
||||
SourceExists = $true
|
||||
SourceIdentityUnchanged = $true
|
||||
SourceContentUnchanged = $true
|
||||
AttackerSourceNotPromoted = $true
|
||||
TargetDeniedStandardUserRead = $true
|
||||
TargetDeniedStandardUserModify = $true
|
||||
TargetDeniedStandardUserDelete = $true
|
||||
TargetDeniedStandardUserRename = $true
|
||||
NoAttackerReadableBackup = $true
|
||||
}
|
||||
}
|
||||
|
||||
function New-ReadySwapFixture {
|
||||
return [pscustomobject]@{
|
||||
SourceExists = $true
|
||||
TargetExists = $true
|
||||
PathsDistinct = $true
|
||||
SourceCreatedByStandardUser = $true
|
||||
SourceOwnedByStandardUser = $true
|
||||
SourceReadable = $true
|
||||
SourceModifiable = $true
|
||||
SourceRenameable = $true
|
||||
SourceDeleteCapable = $true
|
||||
SourceReplaceAccessVerified = $true
|
||||
TargetAclTrusted = $true
|
||||
TargetOwnerTrusted = $true
|
||||
TargetIdentityCaptured = $true
|
||||
SourceIdentityCaptured = $true
|
||||
FileIdentitiesDistinct = $true
|
||||
BackupArgumentValid = $true
|
||||
BackupPathsDistinct = $true
|
||||
BackupExpectedStateVerified = $true
|
||||
BackupConfidentialitySafe = $true
|
||||
SameVolume = $true
|
||||
NtfsVolume = $true
|
||||
}
|
||||
}
|
||||
|
||||
function New-SwapResult {
|
||||
param([Parameter(Mandatory = $true)][string]$Status)
|
||||
return [pscustomobject]@{
|
||||
operation = "swap"
|
||||
status = $Status
|
||||
exception_type = ""
|
||||
hresult = 0
|
||||
outer_exception_type = ""
|
||||
outer_hresult = 0
|
||||
leaf_exception_type = ""
|
||||
leaf_hresult = 0
|
||||
native_error = $null
|
||||
exception_chain_types = @()
|
||||
replace_invoked = $true
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper classifies success and status exit codes" {
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "succeeded") -eq 0) `
|
||||
"Success exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "security_denied") -eq 5) `
|
||||
"Security-denial exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "fixture_error") -eq 6) `
|
||||
"Fixture-error exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "unexpected_failure") -eq 7) `
|
||||
"Unexpected-failure exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode `
|
||||
-Status "security_denied_requires_postconditions") -eq 8) `
|
||||
"Postcondition-gated security exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode `
|
||||
-Status "api_precondition_failure") -eq 9) `
|
||||
"API-precondition exit code changed."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper distinguishes security, fixture, and unexpected failures" {
|
||||
$denied = [UnauthorizedAccessException]::new("denied")
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $denied) -ceq "security_denied") `
|
||||
"UnauthorizedAccessException was not a security denial."
|
||||
|
||||
$unableToRemove = [IO.IOException]::new("expected", -2147023721)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $unableToRemove) -ceq
|
||||
"security_denied_requires_postconditions") `
|
||||
"ERROR_UNABLE_TO_REMOVE_REPLACED was not postcondition-gated."
|
||||
|
||||
$missing = [IO.FileNotFoundException]::new("missing")
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $missing) -ceq "fixture_error") `
|
||||
"Missing swap source was not a fixture error."
|
||||
|
||||
$invalidArgument = [ArgumentException]::new("illegal backup path")
|
||||
Assert-True ($invalidArgument.HResult -eq -2147024809) `
|
||||
"ArgumentException no longer carries 0x80070057."
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $invalidArgument) -ceq "fixture_error") `
|
||||
"ERROR_INVALID_PARAMETER-like argument failure was not a fixture error."
|
||||
|
||||
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $crossVolume) -ceq
|
||||
"api_precondition_failure") `
|
||||
"Cross-volume swap was not an API-precondition failure."
|
||||
|
||||
$partialMove = [IO.IOException]::new("unexpected", -2147023720)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $partialMove) -ceq "unexpected_failure") `
|
||||
"Partial ReplaceFile failure was accepted."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper unwraps MethodInvocationException security denial" {
|
||||
$denied = [UnauthorizedAccessException]::new("denied")
|
||||
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$denied
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $wrapped) -ceq "security_denied") `
|
||||
"Wrapped UnauthorizedAccessException was not a security denial."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper unwraps multiple PowerShell exception layers" {
|
||||
$denied = [UnauthorizedAccessException]::new("denied")
|
||||
$methodWrapper = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$denied
|
||||
)
|
||||
$runtimeWrapper = [Management.Automation.RuntimeException]::new(
|
||||
"runtime failed",
|
||||
$methodWrapper
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $runtimeWrapper) -ceq "security_denied") `
|
||||
"Nested PowerShell wrappers were not unwrapped."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper decodes only HRESULT_FROM_WIN32 values" {
|
||||
Assert-True ((Get-MinerOpsNativeWin32Error -HResult -2147024891) -eq 5) `
|
||||
"0x80070005 did not decode as ERROR_ACCESS_DENIED."
|
||||
$accessDenied = [IO.IOException]::new("access denied", -2147024891)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $accessDenied) -ceq "security_denied") `
|
||||
"Win32-derived ERROR_ACCESS_DENIED was not security evidence."
|
||||
Assert-True ($null -eq (
|
||||
Get-MinerOpsNativeWin32Error -HResult -2146233087
|
||||
)) "CLR HRESULT 0x80131501 produced a bogus native error."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "unrelated wrapped IOException is not a security denial" {
|
||||
$ioFailure = [IO.IOException]::new("unrelated")
|
||||
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$ioFailure
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $wrapped) -ceq "unexpected_failure") `
|
||||
"Unrelated wrapped IOException was accepted as security evidence."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "wrapped fixture and precondition failures stay distinct" {
|
||||
$missing = [IO.FileNotFoundException]::new("missing")
|
||||
$wrappedMissing = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$missing
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $wrappedMissing) -ceq "fixture_error") `
|
||||
"Wrapped fixture error changed classification."
|
||||
|
||||
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
|
||||
$wrappedCrossVolume = [Reflection.TargetInvocationException]::new(
|
||||
"target invocation failed",
|
||||
$crossVolume
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $wrappedCrossVolume) -ceq
|
||||
"api_precondition_failure") `
|
||||
"Wrapped API precondition changed classification."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "exception diagnostics report outer leaf and chain types" {
|
||||
$denied = [UnauthorizedAccessException]::new("denied")
|
||||
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$denied
|
||||
)
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapped
|
||||
Assert-True ($details.OuterExceptionType -ceq
|
||||
"System.Management.Automation.MethodInvocationException") `
|
||||
"Exception diagnostics recorded the wrong outer type."
|
||||
Assert-True ($details.LeafExceptionType -ceq
|
||||
"System.UnauthorizedAccessException") `
|
||||
"Exception diagnostics recorded the wrong leaf type."
|
||||
Assert-True ($details.ExceptionChainTypes.Count -eq 2) `
|
||||
"Exception diagnostics recorded the wrong chain length."
|
||||
Assert-True (-not $details.ExceptionChainTruncated) `
|
||||
"A short exception chain was reported as truncated."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "exception diagnostics handle null InnerException" {
|
||||
$wrapperOnly = [Management.Automation.RuntimeException]::new(
|
||||
"wrapper without inner exception"
|
||||
)
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapperOnly
|
||||
Assert-True ($details.OuterExceptionType -ceq $details.LeafExceptionType) `
|
||||
"Null InnerException did not leave the wrapper as the leaf."
|
||||
Assert-True ($details.ExceptionChainTypes.Count -eq 1) `
|
||||
"Null InnerException produced an invalid chain."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "exception-chain traversal is bounded defensively" {
|
||||
[Exception]$deep = [UnauthorizedAccessException]::new("denied")
|
||||
foreach ($level in 1..40) {
|
||||
$deep = [Exception]::new("wrapper $level", $deep)
|
||||
}
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $deep
|
||||
Assert-True ($details.ExceptionChainTypes.Count -eq 32) `
|
||||
"Deep exception traversal did not stop at its bound."
|
||||
Assert-True ([bool]$details.ExceptionChainTruncated) `
|
||||
"Deep exception traversal did not report truncation."
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $deep) -ceq "unexpected_failure") `
|
||||
"Truncated exception evidence did not fail closed."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "swap probe parses and passes an explicit native null" {
|
||||
$probePath = Join-Path $PSScriptRoot `
|
||||
"Invoke-MinerOpsStandardUserProbe.ps1"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
[Management.Automation.Language.Parser]::ParseFile(
|
||||
$probePath,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors
|
||||
) | Out-Null
|
||||
Assert-True ($parseErrors.Count -eq 0) `
|
||||
"Standard-user probe has parser errors."
|
||||
$probeSource = [IO.File]::ReadAllText($probePath)
|
||||
Assert-True ($probeSource -match '\[NullString\]::Value') `
|
||||
"Swap probe does not pass an explicit native null backup argument."
|
||||
Assert-True ($probeSource -notmatch
|
||||
'File\]::Replace\([^\)]*\$null') `
|
||||
"Swap probe regressed to a bare PowerShell null backup argument."
|
||||
Assert-True ($probeSource -match
|
||||
'backup-producing swap fixtures are prohibited') `
|
||||
"Swap probe no longer prohibits confidentiality-unsafe backups."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "NullString preserves native null for a .NET string" {
|
||||
if (-not ("MinerOpsNativeHarnessNullStringProbe" -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
public static class MinerOpsNativeHarnessNullStringProbe
|
||||
{
|
||||
public static bool IsNull(string value)
|
||||
{
|
||||
return value == null;
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
Assert-True (-not (
|
||||
[MinerOpsNativeHarnessNullStringProbe]::IsNull($null)
|
||||
)) "A bare PowerShell null was not coerced to String.Empty."
|
||||
Assert-True (
|
||||
[MinerOpsNativeHarnessNullStringProbe]::IsNull([NullString]::Value)
|
||||
) "NullString did not preserve native null for a .NET string."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "same source and destination is a fixture failure" {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.PathsDistinct = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Equal source and destination paths were accepted."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "missing source is a fixture failure" {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.SourceExists = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Missing source was accepted."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "missing target is a fixture failure" {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.TargetExists = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Missing target was accepted."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "invalid backup argument is a fixture failure" {
|
||||
foreach ($property in @(
|
||||
"BackupArgumentValid",
|
||||
"BackupPathsDistinct",
|
||||
"BackupExpectedStateVerified",
|
||||
"BackupConfidentialitySafe"
|
||||
)) {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.$property = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Invalid backup property $property was accepted."
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "cross-volume limitation is not a security pass" {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.SameVolume = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "api_precondition_failure") `
|
||||
"Cross-volume fixture was not an API-precondition failure."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "valid controlled same-volume source is attack-ready" {
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus `
|
||||
-Fixture (New-ReadySwapFixture)
|
||||
Assert-True ($classification.Status -ceq "ready") `
|
||||
"Valid same-volume fixture was not attack-ready."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "source ownership and control are mandatory" {
|
||||
foreach ($property in @(
|
||||
"SourceCreatedByStandardUser",
|
||||
"SourceOwnedByStandardUser",
|
||||
"SourceReadable",
|
||||
"SourceModifiable",
|
||||
"SourceRenameable",
|
||||
"SourceDeleteCapable",
|
||||
"SourceReplaceAccessVerified"
|
||||
)) {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.$property = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Missing source-control proof $property was accepted."
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "invalid swap fixture cannot pass as security" {
|
||||
Assert-Throws -Pattern "fixture was invalid" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "fixture_error") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "unrelated swap API precondition cannot pass as security" {
|
||||
Assert-Throws -Pattern "unrelated API precondition" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "api_precondition_failure") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "successful replacement is a security failure" {
|
||||
Assert-Throws -Pattern "completed the protected replacement" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "succeeded") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "security denial passes only with all postconditions" {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult `
|
||||
-Status "security_denied_requires_postconditions") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.TargetAclTrusted = $false
|
||||
Assert-Throws -Pattern "target ACL is not trusted" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "denial with changed target identity is a security failure" {
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.TargetIdentityUnchanged = $false
|
||||
Assert-Throws -Pattern "target identity changed" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "denial with changed target content is a security failure" {
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.TargetContentUnchanged = $false
|
||||
Assert-Throws -Pattern "target content changed" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "denial with changed ACL or owner is a security failure" {
|
||||
foreach ($property in @(
|
||||
"TargetAclTrusted",
|
||||
"TargetAclUnchanged",
|
||||
"TargetOwnerTrusted",
|
||||
"TargetOwnerUnchanged"
|
||||
)) {
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.$property = $false
|
||||
Assert-Throws -Pattern "target (ACL|owner)" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "attacker-readable protected backup is a security failure" {
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.NoAttackerReadableBackup = $false
|
||||
Assert-Throws -Pattern "through a backup" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "denial must record that File.Replace was reached" {
|
||||
$result = New-SwapResult -Status "security_denied"
|
||||
$result.replace_invoked = $false
|
||||
Assert-Throws -Pattern "did not reach System.IO.File.Replace" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result $result -Postconditions (New-PassingSwapPostconditions)
|
||||
}
|
||||
}
|
||||
|
||||
$initialState = Get-MinerOpsNativeGetItemState
|
||||
try {
|
||||
Invoke-HarnessTest "trace leaves no global function when none existed" {
|
||||
$childScript = Join-Path $PSScriptRoot `
|
||||
"Test-MinerOpsNativeHarnessGetItemState.ps1"
|
||||
$powerShell = (Get-Process -Id $PID).Path
|
||||
& $powerShell -NoLogo -NoProfile -File $childScript `
|
||||
-Mode NoExistingFunction -SupportPath $supportPath
|
||||
Assert-True ($LASTEXITCODE -eq 0) `
|
||||
"No-existing-function child test failed."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "trace restores an existing global function exactly" {
|
||||
$childScript = Join-Path $PSScriptRoot `
|
||||
"Test-MinerOpsNativeHarnessGetItemState.ps1"
|
||||
$powerShell = (Get-Process -Id $PID).Path
|
||||
& $powerShell -NoLogo -NoProfile -File $childScript `
|
||||
-Mode ExistingFunction -SupportPath $supportPath
|
||||
Assert-True ($LASTEXITCODE -eq 0) `
|
||||
"Existing-function child test failed."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "trace cleanup survives Action failure" {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
Assert-Throws -Pattern "injected trace failure" -Action {
|
||||
Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $supportPath | Out-Null
|
||||
throw "injected trace failure"
|
||||
}
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "throwing trace action"
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "trace cleanup survives a later assertion failure" {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
try {
|
||||
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $supportPath
|
||||
})
|
||||
throw "injected post-trace assertion failure"
|
||||
}
|
||||
catch {
|
||||
Assert-True (
|
||||
$_.Exception.Message -match "post-trace assertion failure"
|
||||
) "Unexpected post-trace failure."
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "post-trace assertion failure"
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "multiple sequential traces preserve command state" {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
foreach ($iteration in 1..3) {
|
||||
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $supportPath
|
||||
})
|
||||
Assert-True ($observed.Count -eq 1) `
|
||||
"Sequential trace $iteration recorded the wrong count."
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "sequential trace $iteration"
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "simulated top-level failure restores Get-Item" {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
try {
|
||||
try {
|
||||
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $supportPath
|
||||
})
|
||||
throw "injected top-level harness failure"
|
||||
}
|
||||
finally {
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "simulated harness final cleanup"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Assert-True (
|
||||
$_.Exception.Message -match "top-level harness failure"
|
||||
) "Unexpected simulated top-level failure."
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "after simulated top-level failure"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Assert-MinerOpsNativeGetItemState -Expected $initialState `
|
||||
-Context "portable native-harness tests final cleanup"
|
||||
}
|
||||
|
||||
Write-Host "$Passed native-harness regression tests passed."
|
||||
@@ -0,0 +1,48 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("NoExistingFunction", "ExistingFunction")]
|
||||
[string]$Mode,
|
||||
[Parameter(Mandatory = $true)][string]$SupportPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. $SupportPath
|
||||
|
||||
if ($Mode -ceq "NoExistingFunction") {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
if ([bool]$before.FunctionExisted) {
|
||||
throw "Child process unexpectedly began with a global Get-Item function."
|
||||
}
|
||||
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $SupportPath
|
||||
})
|
||||
if ($observed -cnotcontains $SupportPath) {
|
||||
throw "Trace did not observe Get-Item."
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "no-preexisting-function child test"
|
||||
return
|
||||
}
|
||||
|
||||
$existing = { param($LiteralPath) "preexisting:$LiteralPath" }
|
||||
Set-Item -LiteralPath "Function:\global:Get-Item" -Value $existing -Force
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $SupportPath
|
||||
})
|
||||
if ($observed -cnotcontains $SupportPath) {
|
||||
throw "Trace did not observe Get-Item over an existing global function."
|
||||
}
|
||||
$after = Get-MinerOpsNativeGetItemState
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "preexisting-function child test"
|
||||
if ($after.FunctionScriptBlock.ToString() -cne $existing.ToString()) {
|
||||
throw "Existing Get-Item ScriptBlock was not restored exactly."
|
||||
}
|
||||
if (-not [object]::ReferenceEquals(
|
||||
$after.FunctionScriptBlock,
|
||||
$before.FunctionScriptBlock
|
||||
)) {
|
||||
throw "Existing Get-Item ScriptBlock instance changed."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
# Disposable Windows filesystem-trust validation
|
||||
|
||||
Native Windows evidence is required before deployment. The cross-platform
|
||||
behavioral harness proves deterministic state-machine decisions; it cannot
|
||||
prove NTFS access checks, directory-handle sharing, junction behavior,
|
||||
same-directory rename semantics, or Scheduled Task behavior.
|
||||
|
||||
## Isolation
|
||||
|
||||
Use a newly created, disposable Windows 10/11 or Windows Server VM. Do not join
|
||||
it to a production domain or connect it to a production MinerOps Hub, database,
|
||||
or miner network. Never use a production package, URL, Agent ID, transition
|
||||
token, shared secret, or API3 credential. Copy only the candidate
|
||||
`agents\windows` directory to the VM and take a snapshot first.
|
||||
|
||||
Run 64-bit Windows PowerShell 5.1 as Administrator:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
.\tests\Test-MinerOpsCredentialTransition.Windows.ps1 `
|
||||
-AcknowledgeDisposableVm
|
||||
```
|
||||
|
||||
Record the exact pass count printed by the candidate harness. Do not claim the
|
||||
native suite passed unless this command actually completed on Windows. Save the
|
||||
complete transcript plus:
|
||||
|
||||
```powershell
|
||||
Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber
|
||||
$PSVersionTable
|
||||
```
|
||||
|
||||
The native primitive suite verifies:
|
||||
|
||||
- protected credential, enrollment, installer, and trust-blocker files are
|
||||
created with their final `FileSecurity`, held exclusively, and read back
|
||||
through the same still-empty handle before any content is written;
|
||||
- the directory and credential-file DACLs deny standard-user read, create,
|
||||
delete, rename, and replacement-swap operations;
|
||||
- ACL setup or read-back failure cannot leave secret-bearing content;
|
||||
- a pre-created pathname remains unchanged when `CreateNew` fails;
|
||||
- an insecure artifact is repaired through an exclusive handle with explicit
|
||||
security rights, then rejected; inability to acquire those rights fails
|
||||
closed without a path-based fallback;
|
||||
- the durable blocker exists before an ACL-repaired artifact could be
|
||||
reconsidered, and remains effective on the next check;
|
||||
- quarantine evidence is unreadable to the standard user;
|
||||
- the two-rename install leaves the new active file and a non-authoritative
|
||||
prior remnant, and an active-missing install does not invent prior authority;
|
||||
- a directory junction is rejected;
|
||||
- a permissive directory is hardened, gets an external durable blocker, and
|
||||
cannot continue in that process or an ordinary rerun;
|
||||
- pre-repair standard-user file and directory handles are exercised after ACL
|
||||
repair while automatic transition remains blocked regardless of whether the
|
||||
handle operation succeeds;
|
||||
- hard-linked enrollment, runtime, and backup targets are rejected; and
|
||||
- a real harmless expected Scheduled Task is positively verified for exact
|
||||
`Running` and `Ready`, while missing, query failure, disabled, malformed,
|
||||
wrong-action, and wrong-principal cases fail closed.
|
||||
|
||||
## Full-kernel-boot epoch matrix
|
||||
|
||||
The source uses the canonical value
|
||||
`windows-full-boot-event-v1:<16 lowercase hex digits>`. The payload is the
|
||||
Event Log `RecordId` anchor of the newest System-log event from
|
||||
`Microsoft-Windows-Kernel-Boot`, event ID 27, whose namespace-aware XML payload
|
||||
contains exactly one canonical `BootType` value equal to `0`. `ProviderName`,
|
||||
`Id`, nullable `RecordId`, and nullable `Version` must have the real typed
|
||||
EventLogRecord shapes; supported versions are 0 and 1. The record ID is not an
|
||||
immutable boot GUID. A reset requires a valid current anchor strictly greater
|
||||
than the stored anchor. Equal, lower/restored/reused, missing, and ambiguous
|
||||
anchors fail closed. The implementation deliberately does not use wall-clock
|
||||
time or the Windows `BootId` counter: Fast Startup is a hibernated kernel
|
||||
restoration and must not authorize reset merely because Windows reports a new
|
||||
startup sequence.
|
||||
|
||||
On a disposable VM, dot-source the transition script and record
|
||||
`Get-MinerOpsBootIdentifier` before and after every row. Preserve the raw event
|
||||
object and `ToXml()` output from `Get-WinEvent`. For each event, record the
|
||||
runtime types and values of `ProviderName`, `Id`, `RecordId`, and `Version`, the
|
||||
root namespace, and the `System/EventData/Data[@Name='BootType']` shape.
|
||||
|
||||
| Transition | Required canonical identity |
|
||||
| --- | --- |
|
||||
| Change system clock within the running session | unchanged |
|
||||
| Change timezone within the running session | unchanged |
|
||||
| Sleep and resume | unchanged |
|
||||
| Hibernate and resume, where supported | unchanged |
|
||||
| Shutdown and power-on with Fast Startup, where supported | unchanged |
|
||||
| Restart from the Windows power menu | strictly greater record anchor |
|
||||
|
||||
For the Fast Startup row, independently confirm that Windows restored kernel
|
||||
session 0 and that the latest event 27 has a nonzero `BootType`; the newest
|
||||
full-boot (`BootType=0`) event record must remain the same. For the Restart row,
|
||||
confirm a new exact `BootType=0` event and a new canonical identity. Also test a
|
||||
missing System log/provider event, duplicated `BootType`, malformed XML value,
|
||||
wrong namespace, unsupported/null version, wrong provider/event ID,
|
||||
zero/malformed record ID, and query access failure; every case must fail
|
||||
closed.
|
||||
|
||||
Use a separate disposable snapshot for log-retention behavior. Preserve the
|
||||
pre-clear stored anchor and evidence first, then clear only the disposable VM's
|
||||
System log. Verify that missing history cannot authorize reset. After a full
|
||||
Restart, if Event Log numbering restarts at or below the stored anchor, verify
|
||||
that automatic reset remains blocked and requires operator intervention. Also
|
||||
restore a snapshot exposing only an older valid full-boot event and confirm the
|
||||
lower record ID is rejected. Do not add a timestamp fallback for any row. A
|
||||
native result is required because EventLogRecord property types, provider
|
||||
namespace/version, log-clear numbering, and power-state behavior cannot be
|
||||
proven by the Linux harness.
|
||||
|
||||
## Loopback workflow fixture
|
||||
|
||||
Use a fresh snapshot for full workflow testing. All values must be disposable.
|
||||
|
||||
1. Create a test `C:\MinerOpsAgent\agent-config.json` containing random Agent,
|
||||
Site, and Zone UUIDs plus placeholder secrets.
|
||||
2. Place a harmless `MinerOpsAgentService.ps1` in the target and register
|
||||
`MinerOpsAgent` with the exact installer-controlled SYSTEM/highest action:
|
||||
`powershell.exe -NoProfile -ExecutionPolicy Bypass -File
|
||||
"C:\MinerOpsAgent\MinerOpsAgentService.ps1"`.
|
||||
3. Serve a loopback-only mock at `http://127.0.0.1:<port>`. Its
|
||||
`POST /api/agent/credential-transition` handler records only the token hash
|
||||
and submitted `agent_secret_hash`, returns the fixed disposable
|
||||
Agent/Site/Zone identity, returns the same response for the same token and
|
||||
hash, and rejects a changed hash.
|
||||
4. Build a local package with that loopback origin and a random disposable
|
||||
43-character token. Never download from or call a real Hub.
|
||||
5. For each case, restore the snapshot or recreate the fixture. Run once with
|
||||
the task initially running and once with it intentionally stopped.
|
||||
|
||||
The child-process shape for hard termination is:
|
||||
|
||||
```powershell
|
||||
$checkpoint = 'pending-written'
|
||||
$code = @"
|
||||
. 'C:\validation\agents\windows\Upgrade-MinerOpsAgentCredential.ps1'
|
||||
Invoke-MinerOpsAgentCredentialTransition `
|
||||
-TargetRoot 'C:\MinerOpsAgent' `
|
||||
-PackageRoot 'C:\validation\agents\windows' `
|
||||
-ScheduledTaskName 'MinerOpsAgent' `
|
||||
-CheckpointAction { param(`$name) if (`$name -eq '$checkpoint') { Stop-Process -Id `$PID -Force } }
|
||||
"@
|
||||
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code))
|
||||
Start-Process powershell.exe `
|
||||
-ArgumentList '-NoProfile','-EncodedCommand',$encoded -Wait
|
||||
```
|
||||
|
||||
After termination, rerun normally without deleting, repairing, or editing any
|
||||
artifact. Every retry request must use the same token and
|
||||
`agent_secret_hash`; no retry may generate a replacement secret.
|
||||
|
||||
## Hard-termination matrix
|
||||
|
||||
| Checkpoint | State before rerun | Required rerun result |
|
||||
| --- | --- | --- |
|
||||
| `directory-repair-blocker-written` | external protected blocker durable before repair | no artifact is trusted; every ordinary invocation remains blocked |
|
||||
| `directory-trust-established` | hardened directory; external blocker retained | pre-repair recovery/pending/remnants are quarantined without parsing; even a clean repair cannot continue in this process |
|
||||
| `directory-containment-scan-complete` | initial known-name scan finished; external blocker retained | a late recovery/pending file never becomes authority on this or later ordinary invocations |
|
||||
| `trust-reset-authorized` | changed full-boot epoch and strict original task intent are durable; blocker retained | approved retry preserves that intent and repeats publication; it cannot delete early |
|
||||
| `trust-reset-runtime-published` | fresh package runtime is durable; blocker is `runtime-published` | approved retry resumes credentials/config/task convergence with the blocker retained |
|
||||
| `quarantine-blocker-written` | blocker durable; insecure authoritative artifact may remain | second run never parses or redeems it |
|
||||
| `artifact-moved-to-quarantine` | authority name absent; evidence is beneath quarantine | evidence remains non-authoritative and the quarantine directory is reverified |
|
||||
| `quarantine-directory-verified` | evidence is under the protected non-traversable quarantine directory | rerun remains blocked and never reads or ACL-repairs evidence contents |
|
||||
| `recovery-written` | protected recovery; original task state retained | exact saved token/secret is reused |
|
||||
| `task-stopped` | recovery protected; task stopped; no request yet | transition completes and restores original task intent |
|
||||
| `server-committed` | server may have committed; task stopped | idempotent same-hash redemption completes |
|
||||
| `pending-written` | protected matching recovery and pending | pending is validated and reused, not overwritten |
|
||||
| `before-replace` | active legacy config plus pending | first rename may proceed; rerun converges |
|
||||
| `active-moved-to-prior` | active missing; matching pending and legacy prior present | prior is never promoted; pending becomes active |
|
||||
| `pending-moved-to-active` | committed active plus prior; pending absent | active is verified; prior is cleanup-only |
|
||||
| `after-replace` | same as prior row | active is verified before task restoration |
|
||||
| `task-restored` | active and final task state verified | retry verifies again and cleans up |
|
||||
| `before-trust-blocker-removal` | active config and final strict task state verified; external blocker still present | only an explicitly approved parent retry may verify again and remove the blocker |
|
||||
| `before-recovery-cleanup` | active/task verified; recovery remains last | retry uses recovery and completes cleanup |
|
||||
|
||||
For each row inspect ACLs and task state without displaying secret contents:
|
||||
|
||||
```powershell
|
||||
Get-Acl C:\MinerOpsAgent | Format-List
|
||||
Get-Acl C:\MinerOpsAgent\agent-credential-transition.recovery.json `
|
||||
-ErrorAction SilentlyContinue | Format-List
|
||||
Get-Acl C:\MinerOpsAgent\agent-config.json.pending `
|
||||
-ErrorAction SilentlyContinue | Format-List
|
||||
Get-ScheduledTask -TaskName MinerOpsAgent | Select-Object TaskName, State
|
||||
```
|
||||
|
||||
## Provenance and quarantine cases
|
||||
|
||||
Run these as separate restored-snapshot cases:
|
||||
|
||||
1. **Malicious recovery, three invocations.** Stop after `recovery-written`, add
|
||||
a Users read/write ACE to recovery, replace its secret and
|
||||
`original_task_running`, and run twice. The first run must create a blocker,
|
||||
quarantine without parsing, and send no request. The second must remain
|
||||
blocked, send no request, keep the real original task state, and omit the
|
||||
attacker secret from all output. A third ordinary run must remain blocked.
|
||||
2. **ACL-repair laundering attempt.** Terminate at
|
||||
`quarantine-blocker-written`, then repair the still-authoritative recovery
|
||||
file ACL by hand without deleting the blocker. The next run must quarantine
|
||||
it without reading or further ACL mutation and must not redeem it. This
|
||||
proves ACL correctness cannot override the durable provenance decision.
|
||||
3. **Malicious pending, three invocations.** Stop after `pending-written`, add a
|
||||
Users ACE and alter the credential/Agent data. All runs must send no new
|
||||
request and must never promote pending.
|
||||
4. **Permissive directory with attacker state.** Add Users Modify to the target
|
||||
directory, place recovery and pending before repair, and run. Both artifacts
|
||||
must be quarantined, a blocker retained, and no request sent. A hard stop at
|
||||
`directory-trust-established` must have the same second-run result.
|
||||
5. **Quarantine preservation.** A later successful, separately authorized
|
||||
transition must not silently delete unresolved blocker or evidence files.
|
||||
Operator review must remove them only after invalidating the exposed token
|
||||
and obtaining a new package.
|
||||
6. **Late arrival after scan.** At `directory-containment-scan-complete`, use a
|
||||
handle opened before repair to attempt creation/rename of recovery and
|
||||
pending names, including a file whose ACL now looks correct. The current,
|
||||
second, and third ordinary runs must send no request and perform no
|
||||
activation regardless of the handle operation's Windows result.
|
||||
7. **Trust-epoch reset.** Use Restart, confirm the canonical full-kernel-boot
|
||||
event identity changed, clear suspicious transition state only under the
|
||||
approved procedure, and run with
|
||||
`-ApproveFilesystemTrustResetAfterReboot`. Confirm fresh runtime publication
|
||||
occurs while the external blocker exists, old runtime is never a rollback
|
||||
source, the task remains stopped until transition verification, and the
|
||||
blocker remains through credential/config convergence and final strict task
|
||||
verification. It may disappear only after all of those parent checks pass.
|
||||
The same switch before Restart must fail. Repeat after clock/timezone change,
|
||||
sleep, hibernate, and Fast Startup; each must still fail as the same epoch.
|
||||
8. **Direct installer reset attempts.** With a real external blocker present,
|
||||
invoke `install.ps1 -ApproveFilesystemTrustResetAfterReboot` directly for a
|
||||
successful publication and an injected publication failure. Repeat with the
|
||||
task disabled, `Unknown`, `Queued`, wrong action, and wrong principal. The
|
||||
blocker must remain in every case. Then run the complete parent workflow and
|
||||
prove deletion occurs only after its final convergence checks.
|
||||
|
||||
In every quarantine case, test standard-user reads against every
|
||||
`minerops-quarantine-credential-transition-*.evidence` file. Access must be
|
||||
denied, and neither console nor error transcripts may contain its contents.
|
||||
|
||||
## Hard-link cases
|
||||
|
||||
On NTFS, create a second name for each candidate with `New-Item -ItemType
|
||||
HardLink`. Test attacker-created enrollment pending, recovery, and final config
|
||||
paths; installed runtime destinations; and rollback sources/destinations. Every
|
||||
case must fail before secret write, redemption, publication, or restore. Verify
|
||||
that no attacker-readable alias contains a permanent credential and that a
|
||||
failed `CreateNew` does not delete or adopt the pre-existing file.
|
||||
|
||||
## Strict Scheduled Task matrix
|
||||
|
||||
Use the exact installer-controlled action and principal as the valid fixture.
|
||||
Run both durable intents through `Running`, `Ready`, missing, provider/query
|
||||
exception, access denied where practical, null/empty state, `Disabled`,
|
||||
`Unknown`, `Queued`, malformed object/definition, multiple results, wrong
|
||||
executable/script arguments, and wrong principal. Only running plus `Running`
|
||||
and stopped plus `Ready` succeed. Stopped plus `Running` must issue stop and
|
||||
then positively verify `Ready`.
|
||||
|
||||
Capture the actual provider types for `TaskPath`, `State`, `Settings.Enabled`,
|
||||
`Actions`, action `Execute`/`Arguments`, and principal `UserId`, `LogonType`, and
|
||||
`RunLevel`. Verify root `TaskPath` is an actual string equal to `\` and Enabled
|
||||
is an actual Boolean `$true`. Missing, null, empty, array, numeric, and truthy
|
||||
string substitutions must fail; provider enum normalization is allowed only for
|
||||
`State`, `LogonType`, and `RunLevel`.
|
||||
|
||||
After the server-commit checkpoint, inject final task verification failure.
|
||||
Recovery must remain and no success output or cleanup may occur. Repair the task
|
||||
and rerun; the recorded token and `agent_secret_hash` must be identical and the
|
||||
retry must converge without generating a new secret.
|
||||
|
||||
## Active-missing and replacement cases
|
||||
|
||||
Run all of these with a cryptographically matching protected recovery record:
|
||||
|
||||
- active missing, protected matching pending present, no prior (legacy
|
||||
`ReplaceFile` partial state): converges with the same token and secret;
|
||||
- active missing, matching pending and valid legacy prior present (new first
|
||||
rename completed): converges through pending; prior is never authority;
|
||||
- committed active and valid prior present, pending absent (new second rename
|
||||
completed): verifies active and cleans prior last;
|
||||
- failure before the first rename: active and pending remain, rerun converges;
|
||||
- failure between the two renames: active is absent, pending and prior remain,
|
||||
rerun converges;
|
||||
- failure immediately after pending becomes active: active and prior remain,
|
||||
rerun verifies active before cleanup;
|
||||
- active missing with no recovery or with unknown pending: fails closed;
|
||||
- active missing with malformed pending: fails before redemption;
|
||||
- active missing with mismatched Agent, secret, or secret hash: fails before
|
||||
redemption; and
|
||||
- active missing with only a stale prior: fails closed and never promotes it.
|
||||
|
||||
For every successful active-missing case, confirm exact Agent/Hub/Site/Zone
|
||||
identity, the same token and secret hash on every request, active-file DACL,
|
||||
and final Scheduled Task intent. Initially running must end running;
|
||||
intentionally stopped must remain stopped.
|
||||
|
||||
## Completion and cleanup
|
||||
|
||||
At successful termination, recovery, pending, prior, and consumed enrollment
|
||||
files are absent; unresolved quarantine/blocker evidence is never silently
|
||||
removed. Console/error transcripts contain neither token nor generated secret.
|
||||
|
||||
Record any disposable account, Scheduled Task, junction, directory, or mock
|
||||
service left by an interrupted validation, remove only those validation
|
||||
resources, then restore the snapshot or destroy the VM.
|
||||
@@ -0,0 +1,259 @@
|
||||
"""route pre-Zone Agent installations without rewriting ownership history
|
||||
|
||||
Revision ID: 0018_legacy_agent_routing
|
||||
Revises: 0017_agent_enrollment
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0018_legacy_agent_routing"
|
||||
down_revision: Union[str, None] = "0017_agent_enrollment"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
DEFAULT_ZONE_NAME = "Default Zone"
|
||||
|
||||
# Every preflight and the eventual Site UPDATE use this exact resolver. An
|
||||
# exact trim/case-folded name match wins. The sole-Site fallback applies only
|
||||
# when there are zero exact matches and exactly one durable Site in the whole
|
||||
# database. Ambiguous exact matches and unresolved multi-Site installations
|
||||
# deliberately produce NULL resolved_site_id and fail before any UPDATE.
|
||||
RESOLVED_AGENT_SITES_CTE = """
|
||||
site_catalog AS (
|
||||
SELECT count(*) AS site_count,
|
||||
(array_agg(id ORDER BY id))[1] AS sole_site_id
|
||||
FROM sites
|
||||
),
|
||||
unassigned_exact_matches AS (
|
||||
SELECT agent.agent_id,
|
||||
agent.site_name,
|
||||
count(site.id) AS exact_match_count,
|
||||
(
|
||||
array_agg(site.id ORDER BY site.id)
|
||||
FILTER (WHERE site.id IS NOT NULL)
|
||||
)[1] AS exact_site_id
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN sites AS site
|
||||
ON lower(btrim(site.name)) = lower(btrim(agent.site_name))
|
||||
WHERE agent.site_id IS NULL
|
||||
GROUP BY agent.agent_id, agent.site_name
|
||||
),
|
||||
resolved_unassigned_agents AS (
|
||||
SELECT match.agent_id,
|
||||
match.site_name,
|
||||
match.exact_match_count,
|
||||
catalog.site_count,
|
||||
CASE
|
||||
WHEN match.exact_match_count = 1
|
||||
THEN match.exact_site_id
|
||||
WHEN match.exact_match_count = 0
|
||||
AND catalog.site_count = 1
|
||||
THEN catalog.sole_site_id
|
||||
ELSE NULL
|
||||
END AS resolved_site_id
|
||||
FROM unassigned_exact_matches AS match
|
||||
CROSS JOIN site_catalog AS catalog
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _format_rows(rows: list[tuple]) -> str:
|
||||
return ", ".join("/".join(repr(str(value)) for value in row) for row in rows)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
# Keep every preflight result stable until the corresponding routing and
|
||||
# history updates commit. Production must still take the Hub and all queue
|
||||
# producers offline before migration; these locks are the database-level
|
||||
# backstop against an accidental concurrent Site, Zone, Agent, snapshot, or
|
||||
# discovery write changing an already-checked assumption.
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
LOCK TABLE
|
||||
agent_zones,
|
||||
minerops_agent_discoveries,
|
||||
minerops_agent_snapshots,
|
||||
minerops_site_agents,
|
||||
sites
|
||||
IN SHARE ROW EXCLUSIVE MODE
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Run every fail-closed preflight before the first UPDATE. Site resolution
|
||||
# is intentionally limited to legacy rows without an explicit Site
|
||||
# assignment; explicit site_id values are never candidates for replacement.
|
||||
site_match_failures = connection.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
WITH {RESOLVED_AGENT_SITES_CTE}
|
||||
SELECT agent_id, site_name, exact_match_count, site_count
|
||||
FROM resolved_unassigned_agents
|
||||
WHERE resolved_site_id IS NULL
|
||||
ORDER BY agent_id
|
||||
"""
|
||||
)
|
||||
).all()
|
||||
if site_match_failures:
|
||||
raise RuntimeError(
|
||||
"Cannot route legacy Agent installations: each unassigned Agent "
|
||||
"must have exactly one Site name match after trim/case folding, "
|
||||
"or zero exact matches while the database contains exactly one "
|
||||
"Site. Ambiguous exact matches and unmatched multi-Site "
|
||||
"installations fail closed. Failing "
|
||||
"agent/site/exact-match-count/site-count rows: "
|
||||
f"{_format_rows(list(site_match_failures))}. Assign site_id "
|
||||
"explicitly, make Site names unambiguous, or identify the correct "
|
||||
"Site before retrying; no "
|
||||
"Agent, miner, MAC, queue, snapshot, or discovery data was changed."
|
||||
)
|
||||
|
||||
default_zone_failures = connection.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
WITH {RESOLVED_AGENT_SITES_CTE},
|
||||
effective_agents AS (
|
||||
SELECT agent.agent_id,
|
||||
coalesce(
|
||||
agent.site_id,
|
||||
resolved.resolved_site_id
|
||||
) AS site_id
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN resolved_unassigned_agents AS resolved
|
||||
ON resolved.agent_id = agent.agent_id
|
||||
WHERE agent.agent_zone_id IS NULL
|
||||
)
|
||||
SELECT agent.agent_id, agent.site_id::text, count(zone.id)
|
||||
FROM effective_agents AS agent
|
||||
LEFT JOIN agent_zones AS zone
|
||||
ON zone.site_id = agent.site_id
|
||||
AND zone.is_enabled
|
||||
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||
GROUP BY agent.agent_id, agent.site_id
|
||||
HAVING count(zone.id) <> 1
|
||||
ORDER BY agent.agent_id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
).all()
|
||||
if default_zone_failures:
|
||||
raise RuntimeError(
|
||||
"Cannot route legacy Agent installations: each Agent without an "
|
||||
"explicit Zone must have exactly one enabled Default Zone in its "
|
||||
"effective Site. Failing agent/site/count rows: "
|
||||
f"{_format_rows(list(default_zone_failures))}. Repair the Site's "
|
||||
"Default Zone or assign agent_zone_id explicitly before retrying; "
|
||||
"no data was changed."
|
||||
)
|
||||
|
||||
zone_conflicts = connection.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
WITH {RESOLVED_AGENT_SITES_CTE},
|
||||
targets AS (
|
||||
SELECT agent.agent_id, zone.id AS agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN resolved_unassigned_agents AS resolved
|
||||
ON resolved.agent_id = agent.agent_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.site_id = coalesce(
|
||||
agent.site_id,
|
||||
resolved.resolved_site_id
|
||||
)
|
||||
AND zone.is_enabled
|
||||
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||
WHERE agent.agent_zone_id IS NULL
|
||||
)
|
||||
SELECT target.agent_zone_id::text,
|
||||
string_agg(target.agent_id, ',' ORDER BY target.agent_id),
|
||||
coalesce(string_agg(occupant.agent_id, ','
|
||||
ORDER BY occupant.agent_id), '')
|
||||
FROM targets AS target
|
||||
LEFT JOIN minerops_site_agents AS occupant
|
||||
ON occupant.agent_zone_id = target.agent_zone_id
|
||||
GROUP BY target.agent_zone_id
|
||||
HAVING count(DISTINCT target.agent_id) > 1
|
||||
OR count(occupant.agent_id) > 0
|
||||
ORDER BY target.agent_zone_id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
).all()
|
||||
if zone_conflicts:
|
||||
raise RuntimeError(
|
||||
"Cannot route legacy Agent installations: one-Agent-per-Zone would "
|
||||
"be violated. Failing zone/target Agents/current Agent rows: "
|
||||
f"{_format_rows(list(zone_conflicts))}. Assign distinct enabled "
|
||||
"Zones explicitly before retrying; no data was changed."
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
WITH {RESOLVED_AGENT_SITES_CTE}
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET site_id = resolved.resolved_site_id
|
||||
FROM resolved_unassigned_agents AS resolved
|
||||
WHERE agent.agent_id = resolved.agent_id
|
||||
AND agent.site_id IS NULL
|
||||
AND resolved.resolved_site_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET agent_zone_id = zone.id
|
||||
FROM agent_zones AS zone
|
||||
WHERE agent.site_id = zone.site_id
|
||||
AND zone.is_enabled
|
||||
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||
AND agent.agent_zone_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_snapshots AS snapshot
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE snapshot.agent_id = agent.agent_id
|
||||
AND snapshot.agent_zone_id IS NULL
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_discoveries AS discovery
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE discovery.agent_id = agent.agent_id
|
||||
AND discovery.agent_zone_id IS NULL
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# There is no durable, trustworthy way to distinguish values assigned here
|
||||
# from assignments made by an operator after upgrade. Refuse instead of
|
||||
# erasing potentially explicit production routing or historical attribution.
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0018 automatically: legacy Site/Zone routing and "
|
||||
"snapshot/discovery attribution may now be production data. Restore the "
|
||||
"pre-upgrade database backup for rollback; no data was changed."
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""make legacy Agent authentication retirement durable and one-way
|
||||
|
||||
Revision ID: 0019_agent_legacy_auth_retire
|
||||
Revises: 0018_legacy_agent_routing
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0019_agent_legacy_auth_retire"
|
||||
down_revision: Union[str, None] = "0018_legacy_agent_routing"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Keep the row-level value as a projection for operators and defense in
|
||||
# depth. The independent identity table below is the authoritative record:
|
||||
# it deliberately has no foreign key to the deletable current Agent row.
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column(
|
||||
"legacy_auth_disabled_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"agent_legacy_auth_retirements",
|
||||
sa.Column("agent_id", sa.Text(), primary_key=True),
|
||||
sa.Column(
|
||||
"retired_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("clock_timestamp()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"btrim(agent_id) <> ''",
|
||||
name="ck_agent_legacy_auth_retirements_agent_id",
|
||||
),
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
# Stabilize both credential sources until backfill and all enforcement
|
||||
# triggers are installed. This preserves a single atomic migration view.
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
LOCK TABLE minerops_site_agents, agent_enrollments
|
||||
IN SHARE ROW EXCLUSIVE MODE
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Backfill every reliable indication that a per-Agent secret was issued.
|
||||
# A used enrollment remains evidence even if credential columns were later
|
||||
# cleared. Merely existing as an uncredentialed Agent is not evidence, so
|
||||
# the production-shaped legacy Plexus identity remains eligible.
|
||||
op.execute(
|
||||
"""
|
||||
WITH credential_evidence AS (
|
||||
SELECT
|
||||
agent_id,
|
||||
coalesce(
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
clock_timestamp()
|
||||
) AS retired_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_secret_hash IS NOT NULL
|
||||
OR credential_created_at IS NOT NULL
|
||||
OR credential_revoked_at IS NOT NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT enrolled_agent_id AS agent_id, used_at AS retired_at
|
||||
FROM agent_enrollments
|
||||
WHERE enrolled_agent_id IS NOT NULL
|
||||
AND used_at IS NOT NULL
|
||||
)
|
||||
INSERT INTO agent_legacy_auth_retirements (agent_id, retired_at)
|
||||
SELECT agent_id, min(retired_at)
|
||||
FROM credential_evidence
|
||||
GROUP BY agent_id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET legacy_auth_disabled_at = retirement.retired_at
|
||||
FROM agent_legacy_auth_retirements AS retirement
|
||||
WHERE retirement.agent_id = agent.agent_id
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_credential_retires_legacy_auth",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
legacy_auth_disabled_at IS NOT NULL
|
||||
OR (
|
||||
agent_secret_hash IS NULL
|
||||
AND credential_created_at IS NULL
|
||||
AND credential_revoked_at IS NULL
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
# The retirement ledger is append-only for ordinary database operations.
|
||||
# Migration rollback can still DROP the empty table, and a DBA retains the
|
||||
# usual PostgreSQL superuser disaster-recovery capabilities.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'legacy Agent authentication retirement history is append-only'
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_agent_legacy_auth_retirements_immutable
|
||||
BEFORE UPDATE OR DELETE
|
||||
ON agent_legacy_auth_retirements
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_agent_legacy_auth_retirements_no_truncate
|
||||
BEFORE TRUNCATE
|
||||
ON agent_legacy_auth_retirements
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||
"""
|
||||
)
|
||||
|
||||
# Any database path that stores credential evidence also creates the
|
||||
# identity tombstone in the same transaction. The row marker is always
|
||||
# synchronized from that tombstone, including on recreation of an Agent
|
||||
# row whose identity was previously retired.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION minerops_sync_agent_legacy_auth_retirement()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
identity_retired_at timestamptz;
|
||||
BEGIN
|
||||
IF NEW.agent_secret_hash IS NOT NULL
|
||||
OR NEW.credential_created_at IS NOT NULL
|
||||
OR NEW.credential_revoked_at IS NOT NULL
|
||||
OR NEW.legacy_auth_disabled_at IS NOT NULL THEN
|
||||
INSERT INTO agent_legacy_auth_retirements (
|
||||
agent_id,
|
||||
retired_at
|
||||
)
|
||||
VALUES (
|
||||
NEW.agent_id,
|
||||
coalesce(
|
||||
NEW.credential_created_at,
|
||||
NEW.legacy_auth_disabled_at,
|
||||
NEW.credential_revoked_at,
|
||||
clock_timestamp()
|
||||
)
|
||||
)
|
||||
ON CONFLICT (agent_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
SELECT retired_at
|
||||
INTO identity_retired_at
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = NEW.agent_id;
|
||||
|
||||
IF identity_retired_at IS NOT NULL THEN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND NEW.agent_id = OLD.agent_id
|
||||
AND NEW.legacy_auth_disabled_at
|
||||
IS DISTINCT FROM OLD.legacy_auth_disabled_at
|
||||
AND NEW.legacy_auth_disabled_at
|
||||
IS DISTINCT FROM identity_retired_at THEN
|
||||
RAISE EXCEPTION
|
||||
'legacy Agent authentication retirement is permanent'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
NEW.legacy_auth_disabled_at := identity_retired_at;
|
||||
ELSE
|
||||
NEW.legacy_auth_disabled_at := NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_minerops_site_agents_legacy_auth_retirement
|
||||
BEFORE INSERT OR UPDATE OF
|
||||
agent_id,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
legacy_auth_disabled_at
|
||||
ON minerops_site_agents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION minerops_sync_agent_legacy_auth_retirement()
|
||||
"""
|
||||
)
|
||||
|
||||
# Direct, one-way ledger inserts also refresh the optional row projection.
|
||||
# Skip the nested case where the Agent trigger itself inserted the ledger
|
||||
# row; that outer trigger already has NEW and will set the projection.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION minerops_project_agent_legacy_auth_retirement()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF pg_trigger_depth() = 1 THEN
|
||||
UPDATE minerops_site_agents
|
||||
SET legacy_auth_disabled_at = NEW.retired_at
|
||||
WHERE agent_id = NEW.agent_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_agent_legacy_auth_retirements_project
|
||||
AFTER INSERT
|
||||
ON agent_legacy_auth_retirements
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION minerops_project_agent_legacy_auth_retirement()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
retired_count = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM agent_legacy_auth_retirements
|
||||
"""
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
if retired_count:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0019: durable identity-level legacy-auth "
|
||||
"retirement history exists. Restore the pre-upgrade database "
|
||||
"backup rather than making retired Agent identities eligible for "
|
||||
"legacy auth again."
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER trg_agent_legacy_auth_retirements_project
|
||||
ON agent_legacy_auth_retirements
|
||||
"""
|
||||
)
|
||||
op.execute("DROP FUNCTION minerops_project_agent_legacy_auth_retirement()")
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER trg_minerops_site_agents_legacy_auth_retirement
|
||||
ON minerops_site_agents
|
||||
"""
|
||||
)
|
||||
op.execute("DROP FUNCTION minerops_sync_agent_legacy_auth_retirement()")
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER trg_agent_legacy_auth_retirements_no_truncate
|
||||
ON agent_legacy_auth_retirements
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER trg_agent_legacy_auth_retirements_immutable
|
||||
ON agent_legacy_auth_retirements
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"DROP FUNCTION minerops_prevent_legacy_auth_retirement_mutation()"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_credential_retires_legacy_auth",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("minerops_site_agents", "legacy_auth_disabled_at")
|
||||
op.drop_table("agent_legacy_auth_retirements")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""bind one-time credential transitions to existing Agent identities
|
||||
|
||||
Revision ID: 0020_existing_agent_transition
|
||||
Revises: 0019_agent_legacy_auth_retire
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0020_existing_agent_transition"
|
||||
down_revision: Union[str, None] = "0019_agent_legacy_auth_retire"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# NULL continues to mean a normal new-Agent enrollment. A non-NULL value
|
||||
# is server-derived from the current Agent row and binds the one-time token
|
||||
# to that exact durable identity without accepting browser routing input.
|
||||
op.add_column(
|
||||
"agent_enrollments",
|
||||
sa.Column("target_agent_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_agent_enrollments_target_agent",
|
||||
"agent_enrollments",
|
||||
"minerops_site_agents",
|
||||
["target_agent_id"],
|
||||
["agent_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_agent_enrollments_target_agent",
|
||||
"agent_enrollments",
|
||||
"target_agent_id IS NULL OR btrim(target_agent_id) <> ''",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_agent_enrollments_target_result",
|
||||
"agent_enrollments",
|
||||
"""
|
||||
target_agent_id IS NULL
|
||||
OR enrolled_agent_id IS NULL
|
||||
OR enrolled_agent_id = target_agent_id
|
||||
""",
|
||||
)
|
||||
op.create_index(
|
||||
"uq_agent_enrollments_open_target_agent",
|
||||
"agent_enrollments",
|
||||
["target_agent_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"target_agent_id IS NOT NULL "
|
||||
"AND used_at IS NULL AND revoked_at IS NULL"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
transition_count = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM agent_enrollments
|
||||
WHERE target_agent_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
if transition_count:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0020: existing-Agent credential transition "
|
||||
"history exists. Restore the pre-upgrade database backup rather "
|
||||
"than discarding the identity binding."
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"uq_agent_enrollments_open_target_agent",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_agent_enrollments_target_result",
|
||||
"agent_enrollments",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_agent_enrollments_target_agent",
|
||||
"agent_enrollments",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_agent_enrollments_target_agent",
|
||||
"agent_enrollments",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("agent_enrollments", "target_agent_id")
|
||||
@@ -32,12 +32,18 @@ class AgentAuthRepository:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
SELECT identity.agent_id,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
retirement.retired_at AS legacy_auth_disabled_at
|
||||
FROM (VALUES (%s::text)) AS identity(agent_id)
|
||||
LEFT JOIN minerops_site_agents AS agent
|
||||
ON agent.agent_id = identity.agent_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = identity.agent_id
|
||||
WHERE agent.agent_id IS NOT NULL
|
||||
OR retirement.agent_id IS NOT NULL
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
|
||||
@@ -25,19 +25,50 @@ class AgentAuthService:
|
||||
identity = (agent_id or "").strip()
|
||||
secret = (provided_secret or "").strip()
|
||||
|
||||
if not identity:
|
||||
raise AgentAuthenticationFailed("Missing Agent identity")
|
||||
if not secret:
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
# The production Alpha Agent predates the identity header. Missing-ID
|
||||
# compatibility is therefore bound to exactly the configured legacy
|
||||
# identity; no request value is allowed to select another Agent.
|
||||
legacy_id = settings.minerops_agent_id.strip()
|
||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||
missing_identity = not identity
|
||||
if missing_identity:
|
||||
if not legacy_id:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
)
|
||||
identity = legacy_id
|
||||
|
||||
credential = self.repository.get_credential(identity)
|
||||
|
||||
# Revocation is authoritative regardless of which credential path
|
||||
# would otherwise apply. Never allow malformed or historical
|
||||
# credential state to fall through to the legacy shared secret.
|
||||
if credential and credential.get("credential_revoked_at") is not None:
|
||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||
|
||||
# This value comes from the identity-level retirement ledger, not the
|
||||
# deletable current Agent row. It is therefore authoritative even when
|
||||
# the Agent was deleted and has not yet been recreated.
|
||||
legacy_auth_retired = bool(
|
||||
credential
|
||||
and credential.get("legacy_auth_disabled_at") is not None
|
||||
)
|
||||
|
||||
# Any Agent with a per-Agent credential is permanently on the new
|
||||
# authentication path. The legacy shared secret must never authenticate
|
||||
# that identity, even if the Agent later becomes revoked.
|
||||
if credential and credential.get("agent_secret_hash"):
|
||||
if credential.get("credential_revoked_at") is not None:
|
||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||
# Once the configured legacy identity has ever received a
|
||||
# per-Agent credential, secret-only fallback is permanently
|
||||
# disabled. The legacy global secret cannot authenticate it and a
|
||||
# per-Agent secret cannot be used without an explicit identity.
|
||||
if missing_identity:
|
||||
raise AgentAuthenticationFailed(
|
||||
"Explicit Agent identity is required for enrolled Agents"
|
||||
)
|
||||
|
||||
supplied_hash = hash_agent_secret(secret)
|
||||
if not hmac.compare_digest(
|
||||
@@ -48,10 +79,15 @@ class AgentAuthService:
|
||||
|
||||
return identity
|
||||
|
||||
# Temporary compatibility for the single pre-enrollment Agent.
|
||||
legacy_id = settings.minerops_agent_id.strip()
|
||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||
# The durable identity retirement survives Agent-row deletion,
|
||||
# credential-field corruption, administrative mistakes, and future
|
||||
# credential rotation workflows.
|
||||
if legacy_auth_retired:
|
||||
raise AgentAuthenticationFailed(
|
||||
"Legacy Agent authentication has been permanently disabled"
|
||||
)
|
||||
|
||||
# Temporary compatibility for the single pre-enrollment Agent.
|
||||
if not legacy_id or not legacy_secret:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Stage A containment: no current payload authorizes a physical write.
|
||||
|
||||
There is deliberately no environment-variable override. Re-enabling physical
|
||||
control requires a separately reviewed identity-binding protocol.
|
||||
"""
|
||||
|
||||
PHYSICAL_CONTROL_DISABLED = "physical_control_disabled_stage_a"
|
||||
UNSUPPORTED_COMMAND = "unsupported_command_schema_stage_a"
|
||||
READ_ONLY_COMMAND_TYPES = (
|
||||
"poll_known_miners_now",
|
||||
"discover_new_miners",
|
||||
"enrich_match_discovery",
|
||||
)
|
||||
|
||||
|
||||
def rejection_reason(command_type: str) -> str | None:
|
||||
if command_type == "miner_control":
|
||||
return PHYSICAL_CONTROL_DISABLED
|
||||
if command_type not in READ_ONLY_COMMAND_TYPES:
|
||||
return UNSUPPORTED_COMMAND
|
||||
return None
|
||||
|
||||
|
||||
def rejection_execution_outcome(row: dict) -> str:
|
||||
"""A current pending state alone says nothing about earlier delivery.
|
||||
|
||||
The repository supplies an explicit pre-transition evidence check while
|
||||
holding the queue row lock. Missing/ambiguous checks and result submissions
|
||||
are unknown. This describes queue delivery, never proof of physical outcome.
|
||||
"""
|
||||
if (row.get("previous_status") == "pending"
|
||||
and row.get("no_delivery_evidence") is True
|
||||
and row.get("delivery_reported", False) is False):
|
||||
return "not_dispatched"
|
||||
return "unknown"
|
||||
@@ -8,6 +8,7 @@ from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
|
||||
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@@ -19,6 +20,7 @@ WITH candidates AS (
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE agent_id = %s
|
||||
AND command_status = 'pending'
|
||||
AND command_type = ANY(%s)
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT %s
|
||||
@@ -32,6 +34,70 @@ RETURNING queue.id AS command_id, queue.command_type,
|
||||
queue.request_payload, queue.created_at
|
||||
"""
|
||||
|
||||
# Inspect existing durable history before changing status/completed_at/error.
|
||||
# A linked non-creation audit is conservatively ambiguous, even if timestamps
|
||||
# were lost. Legacy results need not agree on Agent ID to prevent a false claim
|
||||
# of non-delivery. Missing tables/permissions fail the transaction closed.
|
||||
NO_DELIVERY_EVIDENCE_SQL = """
|
||||
q.created_at IS NOT NULL
|
||||
AND q.claimed_at IS NULL AND q.completed_at IS NULL
|
||||
AND q.result_payload IS NULL AND COALESCE(q.error_message, '') = ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM minerops_agent_command_results r
|
||||
WHERE r.command_id = q.id
|
||||
OR (r.agent_id = q.agent_id AND NULLIF(btrim(r.command_id), '') IS NULL)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM audit_logs a
|
||||
WHERE a.action <> 'miner.control.queued'
|
||||
AND (a.old_value_json ->> 'command_id' = q.id
|
||||
OR a.new_value_json ->> 'command_id' = q.id
|
||||
OR a.old_value_json ->> 'operation_id' = q.id
|
||||
OR a.new_value_json ->> 'operation_id' = q.id
|
||||
OR (a.entity_type = 'agent_command' AND a.entity_id::text = q.id))
|
||||
)
|
||||
"""
|
||||
|
||||
RETIRE_COMMANDS_SQL = f"""
|
||||
WITH candidates AS (
|
||||
SELECT q.id, q.command_status, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||
FROM minerops_agent_command_queue q
|
||||
WHERE agent_id = %s AND command_status IN ('pending', 'claimed')
|
||||
AND NOT (command_type = ANY(%s))
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1000
|
||||
)
|
||||
UPDATE minerops_agent_command_queue AS queue
|
||||
SET command_status = 'cancelled', completed_at = COALESCE(queue.completed_at, now()),
|
||||
error_message = concat_ws(chr(10), NULLIF(queue.error_message, ''), CASE WHEN queue.command_type = 'miner_control'
|
||||
THEN 'physical_control_disabled_stage_a'
|
||||
ELSE 'unsupported_command_schema_stage_a' END)
|
||||
FROM candidates WHERE queue.id = candidates.id
|
||||
RETURNING queue.id, queue.agent_id, queue.command_type,
|
||||
candidates.command_status AS previous_status, candidates.no_delivery_evidence, queue.error_message
|
||||
"""
|
||||
|
||||
RETIRE_SCHEDULE_COMMANDS_SQL = f"""
|
||||
WITH candidates AS (
|
||||
SELECT q.id, q.command_status, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||
FROM minerops_agent_command_queue q
|
||||
WHERE agent_id = %s AND request_payload ->> 'scheduled_action_id' = %s
|
||||
AND command_status IN ('pending', 'claimed')
|
||||
AND NOT (command_type = ANY(%s))
|
||||
ORDER BY id
|
||||
FOR UPDATE
|
||||
)
|
||||
UPDATE minerops_agent_command_queue AS queue
|
||||
SET command_status = 'cancelled', completed_at = COALESCE(queue.completed_at, now()),
|
||||
error_message = concat_ws(chr(10), NULLIF(queue.error_message, ''), CASE WHEN queue.command_type = 'miner_control'
|
||||
THEN 'physical_control_disabled_stage_a'
|
||||
ELSE 'unsupported_command_schema_stage_a' END)
|
||||
FROM candidates WHERE queue.id = candidates.id
|
||||
RETURNING queue.id, queue.agent_id, queue.command_type,
|
||||
candidates.command_status AS previous_status, candidates.no_delivery_evidence
|
||||
"""
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
@@ -54,7 +120,8 @@ class AgentCommandRepository:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit))
|
||||
self._retire_unsupported(cur, agent_id)
|
||||
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
connection.commit()
|
||||
return rows
|
||||
@@ -64,6 +131,105 @@ class AgentCommandRepository:
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _audit_rejection(
|
||||
cur, row: dict, *, actor_user_id: str | None = None,
|
||||
source: str = "agent_poll",
|
||||
) -> None:
|
||||
# Queue IDs can be legacy text, not UUIDs. Preserve them in JSON rather
|
||||
# than coercing them into audit_logs.entity_id. Never log raw payloads.
|
||||
cur.execute(
|
||||
"""INSERT INTO audit_logs
|
||||
(actor_user_id, action, entity_type, old_value_json, new_value_json)
|
||||
VALUES (%s, 'agent.command.rejected', 'agent_command', %s, %s)""",
|
||||
(actor_user_id, Jsonb({"command_id": row["id"], "agent_id": row["agent_id"],
|
||||
"status": row["previous_status"]}),
|
||||
Jsonb({"status": "cancelled", "reason": rejection_reason(row["command_type"]),
|
||||
"source": source,
|
||||
"execution_outcome": rejection_execution_outcome(row)})),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _retire_unsupported(
|
||||
cls, cur, agent_id: str, *, source: str = "agent_poll",
|
||||
actor_user_id: str | None = None,
|
||||
) -> int:
|
||||
cur.execute(RETIRE_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES)))
|
||||
rows = list(cur.fetchall())
|
||||
for row in rows:
|
||||
cls._audit_rejection(cur, row, source=source, actor_user_id=actor_user_id)
|
||||
return len(rows)
|
||||
|
||||
def retire_unsupported(self, agent_id: str, *, actor_user_id: str) -> int:
|
||||
"""Audited cutover batch, including offline Agents; never claim any work.
|
||||
|
||||
A zero batch can mean locked rows were skipped. Operators must separately
|
||||
verify no unsupported live rows remain before declaring a drain complete.
|
||||
"""
|
||||
if not actor_user_id:
|
||||
raise ValueError("Cutover retirement requires an accountable operator actor")
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
count = self._retire_unsupported(cur, agent_id, source="stage_a_cutover",
|
||||
actor_user_id=actor_user_id)
|
||||
connection.commit()
|
||||
return count
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def retire_schedule(self, schedule_id: str, *, actor_user_id: str, operation: str) -> int:
|
||||
"""Cancel live unsupported work and retain schedule history atomically.
|
||||
|
||||
The stored schedule determines Agent scope. Delete retires the schedule;
|
||||
neither operation erases evidence or changes terminal command history.
|
||||
"""
|
||||
if not actor_user_id or operation not in {"cancel", "delete"}:
|
||||
raise ValueError("An authenticated actor and cancel/delete operation are required")
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
# Serialize retries and cancel/delete workers for this schedule.
|
||||
cur.execute(
|
||||
"""SELECT id, agent_id, status FROM minerops_scheduled_bulk_actions
|
||||
WHERE id = %s FOR UPDATE""", (schedule_id,),
|
||||
)
|
||||
schedule = cur.fetchone()
|
||||
if not schedule:
|
||||
raise AgentCommandNotFound("Scheduled action not found")
|
||||
cur.execute(RETIRE_SCHEDULE_COMMANDS_SQL,
|
||||
(schedule["agent_id"], schedule_id, list(READ_ONLY_COMMAND_TYPES)))
|
||||
rows = list(cur.fetchall())
|
||||
for row in rows:
|
||||
self._audit_rejection(cur, row, actor_user_id=actor_user_id,
|
||||
source=f"schedule_{operation}")
|
||||
if schedule["status"] in {"pending", "queued", "running"}:
|
||||
cur.execute(
|
||||
"""UPDATE minerops_scheduled_bulk_actions
|
||||
SET status = 'cancelled', updated_at = now(),
|
||||
last_error = concat_ws(chr(10), NULLIF(last_error, ''),
|
||||
'physical_control_disabled_stage_a')
|
||||
WHERE id = %s""", (schedule_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""INSERT INTO audit_logs
|
||||
(actor_user_id, action, entity_type, old_value_json, new_value_json)
|
||||
VALUES (%s, 'scheduled_action.retired', 'scheduled_action', %s, %s)""",
|
||||
(actor_user_id, Jsonb(dict(schedule)),
|
||||
Jsonb({"status": "cancelled", "operation": operation,
|
||||
"reason": "physical_control_disabled_stage_a"})),
|
||||
)
|
||||
connection.commit()
|
||||
return len(rows)
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_command(self, command_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
@@ -91,6 +257,32 @@ class AgentCommandRepository:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
# Enforce the same boundary on results: an old claimed entry
|
||||
# cannot be promoted by an old Agent, retry, or direct caller.
|
||||
cur.execute(
|
||||
f"""SELECT q.id, q.agent_id, q.command_type, q.command_status AS previous_status,
|
||||
({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||
FROM minerops_agent_command_queue q
|
||||
WHERE q.id = %s AND q.agent_id = %s FOR UPDATE""",
|
||||
(command_id, agent_id),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing and rejection_reason(existing["command_type"]):
|
||||
reason = rejection_reason(existing["command_type"])
|
||||
if existing["previous_status"] in {"pending", "claimed"}:
|
||||
cur.execute(
|
||||
"""UPDATE minerops_agent_command_queue
|
||||
SET command_status = 'cancelled',
|
||||
error_message = concat_ws(chr(10), NULLIF(error_message, ''), %s::text),
|
||||
completed_at = COALESCE(completed_at, now()) WHERE id = %s""",
|
||||
(reason, command_id),
|
||||
)
|
||||
# A result submission itself reports possible delivery, even
|
||||
# when earlier durable claim metadata is absent.
|
||||
existing["delivery_reported"] = True
|
||||
self._audit_rejection(cur, existing, source="agent_result")
|
||||
connection.commit()
|
||||
raise AgentCommandConflict(reason)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_agent_command_queue
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""HTTP boundary for authenticated Agent command transport."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -18,8 +16,6 @@ from app.agent_commands.models import (
|
||||
)
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
|
||||
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
|
||||
@@ -29,14 +25,7 @@ AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)
|
||||
def claim_pending_commands(
|
||||
request: Request, agent_id: AgentIdentity, service: AgentService
|
||||
) -> AgentCommandListResponse:
|
||||
before_claim = getattr(
|
||||
request.app.state, "agent_command_before_claim", None
|
||||
)
|
||||
if callable(before_claim):
|
||||
try:
|
||||
before_claim()
|
||||
except Exception:
|
||||
logger.exception("Scheduled command materialization failed before Agent claim")
|
||||
del request
|
||||
return AgentCommandListResponse(commands=service.claim(agent_id))
|
||||
|
||||
|
||||
|
||||
@@ -2,19 +2,16 @@
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.agent_commands.exceptions import (
|
||||
AgentCommandConflict,
|
||||
AgentCommandNotFound,
|
||||
AgentResultValidationError,
|
||||
)
|
||||
from app.agent_commands.models import (
|
||||
AgentCommand,
|
||||
AgentCommandResultRequest,
|
||||
MinerControlResultPayload,
|
||||
)
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.agent_commands.policy import rejection_reason
|
||||
|
||||
|
||||
class AgentCommandService:
|
||||
@@ -25,6 +22,7 @@ class AgentCommandService:
|
||||
return [
|
||||
AgentCommand.model_validate(row)
|
||||
for row in self.repository.claim_pending(agent_id)
|
||||
if rejection_reason(row["command_type"]) is None
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -42,6 +40,14 @@ class AgentCommandService:
|
||||
raise AgentCommandNotFound("Agent command not found")
|
||||
if command["agent_id"] != agent_id:
|
||||
raise AgentCommandConflict("Agent command belongs to another Agent")
|
||||
reason = rejection_reason(command["command_type"])
|
||||
if reason:
|
||||
# Repository repeats this check under a row lock and records the
|
||||
# cancellation atomically; never accept an old physical result.
|
||||
self.repository.finish_command(
|
||||
request.command_id, agent_id, "cancelled", {}, reason
|
||||
)
|
||||
raise AgentCommandConflict(reason)
|
||||
if command["command_status"] != "claimed":
|
||||
raise AgentCommandConflict(
|
||||
f"Agent command is {command['command_status']}, not claimed"
|
||||
@@ -49,34 +55,6 @@ class AgentCommandService:
|
||||
|
||||
result_payload = request.result_payload
|
||||
error_message = self._legacy_error(request.error_message)
|
||||
if command["command_type"] == "miner_control":
|
||||
try:
|
||||
result = MinerControlResultPayload.model_validate(request.result_payload)
|
||||
except ValidationError as exc:
|
||||
raise AgentResultValidationError(
|
||||
"Invalid typed miner-control result"
|
||||
) from exc
|
||||
if request.status == "completed" and not result.command_accepted:
|
||||
raise AgentResultValidationError(
|
||||
"Completed miner-control results must be accepted"
|
||||
)
|
||||
if request.status == "failed" and result.command_accepted:
|
||||
raise AgentResultValidationError(
|
||||
"Failed miner-control results cannot be accepted"
|
||||
)
|
||||
action = (command.get("request_payload") or {}).get("action")
|
||||
if (
|
||||
action in {"suspend", "resume"}
|
||||
and result.command_accepted
|
||||
and result.verification_status != "unconfirmed"
|
||||
):
|
||||
raise AgentResultValidationError(
|
||||
"Accepted suspend and resume results must remain unconfirmed"
|
||||
)
|
||||
result_payload = result.model_dump(mode="json")
|
||||
error_message = (
|
||||
result.failure_code.replace("_", " ") if result.failure_code else None
|
||||
)
|
||||
|
||||
self.repository.finish_command(
|
||||
request.command_id,
|
||||
|
||||
@@ -53,7 +53,9 @@ class AgentEnrollmentSummary(BaseModel):
|
||||
expires_at: datetime
|
||||
used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
target_agent_id: str | None = None
|
||||
enrolled_agent_id: str | None = None
|
||||
purpose: Literal["new_agent", "existing_agent_credential_transition"]
|
||||
status: Literal["waiting", "used", "revoked", "expired"]
|
||||
|
||||
|
||||
@@ -96,3 +98,56 @@ class AgentEnrollResponse(BaseModel):
|
||||
|
||||
class AgentWindowsPackageRequest(AgentEnrollRequest):
|
||||
"""Short-lived token used to construct a Windows bootstrap package."""
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
agent_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@field_validator("agent_id")
|
||||
@classmethod
|
||||
def normalize_agent_id(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("agent_id cannot be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreated(BaseModel):
|
||||
id: UUID
|
||||
target_agent_id: str
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
transition_token: str
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreateResponse(BaseModel):
|
||||
data: AgentCredentialTransitionCreated
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentCredentialTransitionRedeemRequest(AgentEnrollRequest):
|
||||
agent_secret_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class AgentCredentialTransitionCredentials(BaseModel):
|
||||
agent_id: str
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
ip_ranges: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentCredentialTransitionResponse(BaseModel):
|
||||
data: AgentCredentialTransitionCredentials
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -1,5 +1,6 @@
|
||||
"""PostgreSQL persistence for secure Agent enrollment."""
|
||||
|
||||
import hmac
|
||||
from collections.abc import Callable
|
||||
from uuid import UUID
|
||||
|
||||
@@ -21,6 +22,7 @@ ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
|
||||
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
|
||||
OPEN_TARGET_CONSTRAINT = "uq_agent_enrollments_open_target_agent"
|
||||
AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
|
||||
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
|
||||
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
|
||||
@@ -214,6 +216,184 @@ class AgentEnrollmentRepository:
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def create_credential_transition(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
target_agent_id: str,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
agent.display_name,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
agent.legacy_auth_disabled_at,
|
||||
site.name AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
retirement.retired_at
|
||||
FROM minerops_site_agents AS agent
|
||||
JOIN sites AS site ON site.id = agent.site_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
AND zone.site_id = agent.site_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
WHERE agent.agent_id = %s
|
||||
FOR UPDATE OF agent, zone
|
||||
""",
|
||||
(target_agent_id,),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
|
||||
if not agent:
|
||||
raise AgentEnrollmentNotFound(
|
||||
"Existing Agent with durable Site and Zone routing was not found"
|
||||
)
|
||||
|
||||
if agent["agent_zone_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict("Agent Zone is disabled")
|
||||
|
||||
if any(
|
||||
agent[field] is not None
|
||||
for field in (
|
||||
"agent_secret_hash",
|
||||
"credential_created_at",
|
||||
"credential_revoked_at",
|
||||
"legacy_auth_disabled_at",
|
||||
"retired_at",
|
||||
)
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent is not eligible for legacy credential transition"
|
||||
)
|
||||
|
||||
cur.execute("SELECT clock_timestamp() AS operation_now")
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET revoked_at = %s
|
||||
WHERE agent_zone_id = %s
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(operation_now, agent["agent_zone_id"]),
|
||||
)
|
||||
replaced_ids = [str(row["id"]) for row in cur.fetchall()]
|
||||
|
||||
display_name = (
|
||||
str(agent["display_name"] or agent["agent_id"]).strip()[:120]
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_enrollments (
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
target_agent_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s + interval '30 minutes', %s)
|
||||
RETURNING
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_at,
|
||||
expires_at,
|
||||
target_agent_id
|
||||
""",
|
||||
(
|
||||
token_hash,
|
||||
agent["site_id"],
|
||||
agent["agent_zone_id"],
|
||||
display_name,
|
||||
actor_user_id,
|
||||
operation_now,
|
||||
operation_now,
|
||||
agent["agent_id"],
|
||||
),
|
||||
)
|
||||
transition = dict(cur.fetchone())
|
||||
transition["site_name"] = agent["site_name"]
|
||||
transition["agent_zone_name"] = agent["agent_zone_name"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
'agent.credential_transition.created',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
actor_user_id,
|
||||
transition["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"target_agent_id": agent["agent_id"],
|
||||
"site_id": str(agent["site_id"]),
|
||||
"agent_zone_id": str(agent["agent_zone_id"]),
|
||||
"expires_at": transition["expires_at"].isoformat(),
|
||||
"replaced_enrollment_ids": replaced_ids,
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
return transition
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
constraint = exc.diag.constraint_name
|
||||
|
||||
if constraint == TOKEN_HASH_CONSTRAINT:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Generated transition token collided"
|
||||
) from exc
|
||||
|
||||
if constraint in {OPEN_ZONE_CONSTRAINT, OPEN_TARGET_CONSTRAINT}:
|
||||
raise AgentEnrollmentConflict(
|
||||
"An active credential transition already exists for this Agent"
|
||||
) from exc
|
||||
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_enrollments(
|
||||
self,
|
||||
*,
|
||||
@@ -238,7 +418,13 @@ class AgentEnrollmentRepository:
|
||||
enrollment.expires_at,
|
||||
enrollment.used_at,
|
||||
enrollment.revoked_at,
|
||||
enrollment.target_agent_id,
|
||||
enrollment.enrolled_agent_id,
|
||||
CASE
|
||||
WHEN enrollment.target_agent_id IS NULL
|
||||
THEN 'new_agent'
|
||||
ELSE 'existing_agent_credential_transition'
|
||||
END AS purpose,
|
||||
CASE
|
||||
WHEN enrollment.used_at IS NOT NULL
|
||||
THEN 'used'
|
||||
@@ -292,7 +478,8 @@ class AgentEnrollmentRepository:
|
||||
display_name,
|
||||
expires_at,
|
||||
used_at,
|
||||
revoked_at
|
||||
revoked_at,
|
||||
target_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE token_hash = %s
|
||||
FOR UPDATE
|
||||
@@ -309,6 +496,7 @@ class AgentEnrollmentRepository:
|
||||
if (
|
||||
enrollment["used_at"] is not None
|
||||
or enrollment["revoked_at"] is not None
|
||||
or enrollment["target_agent_id"] is not None
|
||||
):
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired enrollment token"
|
||||
@@ -371,6 +559,22 @@ class AgentEnrollmentRepository:
|
||||
"Invalid or expired enrollment token"
|
||||
)
|
||||
|
||||
# Retire the identity before storing its credential. Both
|
||||
# writes use this transaction, so a failed issuance cannot
|
||||
# leave a new misleading tombstone. ON CONFLICT preserves an
|
||||
# earlier retirement if this identity has credential history.
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_legacy_auth_retirements (
|
||||
agent_id,
|
||||
retired_at
|
||||
)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (agent_id) DO NOTHING
|
||||
""",
|
||||
(agent_id, operation_now),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
@@ -380,7 +584,8 @@ class AgentEnrollmentRepository:
|
||||
site_name,
|
||||
display_name,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
@@ -389,6 +594,7 @@ class AgentEnrollmentRepository:
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
@@ -400,6 +606,7 @@ class AgentEnrollmentRepository:
|
||||
enrollment["display_name"],
|
||||
agent_secret_hash,
|
||||
operation_now,
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -504,3 +711,258 @@ class AgentEnrollmentRepository:
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def redeem_credential_transition(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
agent_secret_hash: str,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
expires_at,
|
||||
used_at,
|
||||
revoked_at,
|
||||
target_agent_id,
|
||||
enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE token_hash = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(token_hash,),
|
||||
)
|
||||
enrollment = cur.fetchone()
|
||||
|
||||
if (
|
||||
not enrollment
|
||||
or enrollment["target_agent_id"] is None
|
||||
or enrollment["revoked_at"] is not None
|
||||
):
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired credential transition token"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
agent.display_name,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
agent.legacy_auth_disabled_at,
|
||||
site.name AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
retirement.retired_at
|
||||
FROM minerops_site_agents AS agent
|
||||
JOIN sites AS site ON site.id = agent.site_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
AND zone.site_id = agent.site_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
WHERE agent.agent_id = %s
|
||||
FOR UPDATE OF agent, zone
|
||||
""",
|
||||
(enrollment["target_agent_id"],),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
|
||||
if not agent:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition target no longer exists with durable routing"
|
||||
)
|
||||
|
||||
if (
|
||||
agent["site_id"] != enrollment["site_id"]
|
||||
or agent["agent_zone_id"] != enrollment["agent_zone_id"]
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent routing changed after transition authorization"
|
||||
)
|
||||
|
||||
if agent["agent_zone_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict("Agent Zone is disabled")
|
||||
|
||||
def transition_result() -> dict:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT network::text AS network
|
||||
FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = %s
|
||||
ORDER BY network, id
|
||||
""",
|
||||
(enrollment["agent_zone_id"],),
|
||||
)
|
||||
return {
|
||||
"agent_id": agent["agent_id"],
|
||||
"site_id": agent["site_id"],
|
||||
"site_name": agent["site_name"],
|
||||
"agent_zone_id": agent["agent_zone_id"],
|
||||
"agent_zone_name": agent["agent_zone_name"],
|
||||
"display_name": str(
|
||||
agent["display_name"] or enrollment["display_name"]
|
||||
),
|
||||
"ip_ranges": [row["network"] for row in cur.fetchall()],
|
||||
}
|
||||
|
||||
# A retry after an ambiguous network response is successful only
|
||||
# for the exact locally generated credential that won the first
|
||||
# transaction. It never rotates or reveals a credential.
|
||||
if enrollment["used_at"] is not None:
|
||||
stored_hash = str(agent["agent_secret_hash"] or "")
|
||||
if (
|
||||
enrollment["enrolled_agent_id"]
|
||||
== enrollment["target_agent_id"]
|
||||
and stored_hash
|
||||
and hmac.compare_digest(stored_hash, agent_secret_hash)
|
||||
and agent["credential_created_at"] is not None
|
||||
and agent["credential_revoked_at"] is None
|
||||
and agent["legacy_auth_disabled_at"] is not None
|
||||
and agent["retired_at"] is not None
|
||||
):
|
||||
result = transition_result()
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Credential transition token has already been consumed"
|
||||
)
|
||||
|
||||
cur.execute("SELECT clock_timestamp() AS operation_now")
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
if enrollment["expires_at"] <= operation_now:
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired credential transition token"
|
||||
)
|
||||
|
||||
if any(
|
||||
agent[field] is not None
|
||||
for field in (
|
||||
"agent_secret_hash",
|
||||
"credential_created_at",
|
||||
"credential_revoked_at",
|
||||
"legacy_auth_disabled_at",
|
||||
"retired_at",
|
||||
)
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent is no longer eligible for credential transition"
|
||||
)
|
||||
|
||||
# The durable retirement, Agent credential, enrollment terminal
|
||||
# state, and audit record commit together. Any later failure
|
||||
# rolls all of them back.
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_legacy_auth_retirements (
|
||||
agent_id,
|
||||
retired_at
|
||||
)
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
(agent["agent_id"], operation_now),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET agent_secret_hash = %s,
|
||||
credential_created_at = %s,
|
||||
legacy_auth_disabled_at = %s
|
||||
WHERE agent_id = %s
|
||||
RETURNING agent_id
|
||||
""",
|
||||
(
|
||||
agent_secret_hash,
|
||||
operation_now,
|
||||
operation_now,
|
||||
agent["agent_id"],
|
||||
),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition target disappeared"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET used_at = %s,
|
||||
enrolled_agent_id = target_agent_id
|
||||
WHERE id = %s
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
RETURNING used_at, enrolled_agent_id
|
||||
""",
|
||||
(operation_now, enrollment["id"]),
|
||||
)
|
||||
completed = cur.fetchone()
|
||||
if not completed:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition state changed concurrently"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
NULL,
|
||||
'agent.credential_transition.redeemed',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
enrollment["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"agent_id": agent["agent_id"],
|
||||
"site_id": str(agent["site_id"]),
|
||||
"agent_zone_id": str(agent["agent_zone_id"]),
|
||||
"used_at": completed["used_at"].isoformat(),
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
result = transition_result()
|
||||
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
if exc.diag.constraint_name == AGENT_SECRET_HASH_CONSTRAINT:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Agent credential is already in use"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -8,10 +8,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response,
|
||||
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionCreateResponse,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentCredentialTransitionResponse,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollmentCreateResponse,
|
||||
AgentEnrollmentListResponse,
|
||||
@@ -70,6 +75,31 @@ def create_agent_enrollment(
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent-credential-transitions",
|
||||
response_model=AgentCredentialTransitionCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_agent_credential_transition(
|
||||
request: AgentCredentialTransitionCreateRequest,
|
||||
response: Response,
|
||||
current_user: EnrollmentManager,
|
||||
service: EnrollmentService,
|
||||
) -> AgentCredentialTransitionCreateResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
transition = service.create_credential_transition(
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
return AgentCredentialTransitionCreateResponse(data=transition)
|
||||
except AgentEnrollmentNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/agent-enrollments",
|
||||
response_model=AgentEnrollmentListResponse,
|
||||
@@ -118,6 +148,7 @@ def download_windows_agent_package(
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=package_request.token,
|
||||
hub_url=hub_url,
|
||||
workflow="new_agent",
|
||||
)
|
||||
except AgentWindowsPackageSourceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
@@ -136,6 +167,50 @@ def download_windows_agent_package(
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/agent-credential-transitions/windows-package")
|
||||
def download_windows_agent_credential_transition_package(
|
||||
package_request: AgentWindowsPackageRequest,
|
||||
http_request: Request,
|
||||
current_user: EnrollmentManager,
|
||||
) -> Response:
|
||||
del current_user
|
||||
|
||||
browser_origin = http_request.headers.get("origin", "").strip()
|
||||
try:
|
||||
hub_url = normalize_hub_url(browser_origin)
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Windows Agent package downloads require "
|
||||
"a valid browser Origin"
|
||||
),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=package_request.token,
|
||||
hub_url=hub_url,
|
||||
workflow="existing_agent_credential_transition",
|
||||
)
|
||||
except AgentWindowsPackageSourceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return Response(
|
||||
content=package,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"Content-Disposition": (
|
||||
'attachment; filename="MinerOpsAgent-Credential-Upgrade-Windows.zip"'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/api/agent/enroll",
|
||||
response_model=AgentEnrollResponse,
|
||||
@@ -157,3 +232,27 @@ def enroll_agent(
|
||||
) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent/credential-transition",
|
||||
response_model=AgentCredentialTransitionResponse,
|
||||
)
|
||||
def transition_agent_credential(
|
||||
request: AgentCredentialTransitionRedeemRequest,
|
||||
response: Response,
|
||||
service: EnrollmentService,
|
||||
) -> AgentCredentialTransitionResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
return AgentCredentialTransitionResponse(
|
||||
data=service.redeem_credential_transition(request)
|
||||
)
|
||||
except AgentEnrollmentTokenInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired credential transition token",
|
||||
) from exc
|
||||
except (AgentEnrollmentConflict, AgentEnrollmentCredentialCollision) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
@@ -9,6 +9,8 @@ from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentCredentialCollision,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
@@ -73,6 +75,31 @@ class AgentEnrollmentService:
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def create_credential_transition(
|
||||
self,
|
||||
request: AgentCredentialTransitionCreateRequest,
|
||||
*,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||
token = generate_enrollment_token()
|
||||
try:
|
||||
transition = self.repository.create_credential_transition(
|
||||
token_hash=hash_agent_secret(token),
|
||||
target_agent_id=request.agent_id,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
return {
|
||||
**transition,
|
||||
"transition_token": token,
|
||||
}
|
||||
except AgentEnrollmentCredentialCollision:
|
||||
continue
|
||||
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate a unique credential transition token"
|
||||
)
|
||||
|
||||
def redeem(self, request: AgentEnrollRequest) -> dict:
|
||||
token_hash = hash_agent_secret(request.token)
|
||||
|
||||
@@ -96,3 +123,12 @@ class AgentEnrollmentService:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate unique Agent credentials"
|
||||
)
|
||||
|
||||
def redeem_credential_transition(
|
||||
self,
|
||||
request: AgentCredentialTransitionRedeemRequest,
|
||||
) -> dict:
|
||||
return self.repository.redeem_credential_transition(
|
||||
token_hash=hash_agent_secret(request.token),
|
||||
agent_secret_hash=request.agent_secret_hash,
|
||||
)
|
||||
@@ -14,6 +14,7 @@ WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
|
||||
|
||||
WINDOWS_PACKAGE_FILES = (
|
||||
"Enroll-MinerOpsAgent.ps1",
|
||||
"Upgrade-MinerOpsAgentCredential.ps1",
|
||||
"install.ps1",
|
||||
"uninstall.ps1",
|
||||
"MinerOpsAgentService.ps1",
|
||||
@@ -80,6 +81,7 @@ def build_windows_agent_package(
|
||||
*,
|
||||
enrollment_token: str,
|
||||
hub_url: str,
|
||||
workflow: str = "new_agent",
|
||||
source_root: Path | None = None,
|
||||
) -> bytes:
|
||||
token = enrollment_token.strip()
|
||||
@@ -91,6 +93,12 @@ def build_windows_agent_package(
|
||||
|
||||
normalized_hub_url = normalize_hub_url(hub_url)
|
||||
|
||||
if workflow not in {
|
||||
"new_agent",
|
||||
"existing_agent_credential_transition",
|
||||
}:
|
||||
raise AgentWindowsPackageError("Unsupported Windows Agent workflow")
|
||||
|
||||
root = (
|
||||
Path(source_root)
|
||||
if source_root is not None
|
||||
@@ -117,18 +125,27 @@ def build_windows_agent_package(
|
||||
enrollment = {
|
||||
"hub_url": normalized_hub_url,
|
||||
"token": token,
|
||||
"workflow": workflow,
|
||||
}
|
||||
|
||||
instructions = """MinerOps Windows Agent
|
||||
if workflow == "new_agent":
|
||||
command = ".\\Enroll-MinerOpsAgent.ps1"
|
||||
description = "enrollment"
|
||||
else:
|
||||
command = ".\\Upgrade-MinerOpsAgentCredential.ps1"
|
||||
description = "existing-Agent credential transition"
|
||||
|
||||
instructions = f"""MinerOps Windows Agent
|
||||
|
||||
1. Extract this ZIP on the target Windows machine.
|
||||
2. Open Windows PowerShell as Administrator.
|
||||
3. Change to the extracted MinerOpsAgent-Windows folder.
|
||||
4. Run:
|
||||
|
||||
.\\Enroll-MinerOpsAgent.ps1
|
||||
{command}
|
||||
|
||||
The enrollment credential is short-lived and single-use.
|
||||
This package is for the {description} workflow. The credential is short-lived
|
||||
and single-use. Do not use the other bootstrap script with this package.
|
||||
Do not post enrollment.json in chat, tickets, screenshots, or logs.
|
||||
Delete the ZIP and extracted setup folder after successful installation.
|
||||
"""
|
||||
|
||||
@@ -7,7 +7,7 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
CredentialState = Literal["legacy", "active", "revoked"]
|
||||
CredentialState = Literal["legacy", "active", "revoked", "retired"]
|
||||
RoutingState = Literal["assigned", "unassigned", "disabled"]
|
||||
|
||||
|
||||
|
||||
@@ -103,9 +103,13 @@ class AgentRegistryRepository:
|
||||
THEN 'revoked'
|
||||
WHEN agent.agent_secret_hash IS NOT NULL
|
||||
THEN 'active'
|
||||
WHEN retirement.retired_at IS NOT NULL
|
||||
THEN 'retired'
|
||||
ELSE 'legacy'
|
||||
END AS credential_state
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
LEFT JOIN sites AS site
|
||||
ON site.id = agent.site_id
|
||||
LEFT JOIN agent_zones AS zone
|
||||
|
||||
@@ -250,6 +250,50 @@ class AgentZoneRepository:
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_agent_configuration(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
coalesce(site.name, agent.site_name) AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
coalesce(
|
||||
array_agg(
|
||||
network.network::text
|
||||
ORDER BY network.network::text, network.id
|
||||
) FILTER (WHERE network.id IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
) AS networks
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN sites AS site
|
||||
ON site.id = agent.site_id
|
||||
LEFT JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
LEFT JOIN agent_zone_network_ranges AS network
|
||||
ON network.agent_zone_id = zone.id
|
||||
WHERE agent.agent_id = %s
|
||||
GROUP BY
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
site.name,
|
||||
agent.site_name,
|
||||
zone.name,
|
||||
zone.is_enabled
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def assign_miners_to_zone(
|
||||
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||
) -> dict[UUID, str]:
|
||||
|
||||
@@ -177,6 +177,28 @@ class AgentZoneService:
|
||||
site_id=assignment.get("site_id"), agent_zone_id=zone_id
|
||||
)
|
||||
|
||||
def configuration(self, agent_id: str) -> dict[str, Any] | None:
|
||||
row = self.repository.get_agent_configuration(agent_id)
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"agent_id": row["agent_id"],
|
||||
"site_id": row["site_id"],
|
||||
"site_name": row["site_name"],
|
||||
"agent_zone_id": row["agent_zone_id"],
|
||||
"agent_zone_name": row["agent_zone_name"],
|
||||
"ip_ranges": [
|
||||
{
|
||||
"name": row["agent_zone_name"],
|
||||
"cidr": network,
|
||||
"enabled": row["agent_zone_enabled"] is True,
|
||||
"api_port": 4028,
|
||||
"api3_port": 4433,
|
||||
}
|
||||
for network in row.get("networks") or []
|
||||
],
|
||||
}
|
||||
|
||||
def associate_miners_from_agent(
|
||||
self, agent_id: str, miner_ids: list[UUID]
|
||||
) -> MinerZoneAssociationResult:
|
||||
|
||||
@@ -74,7 +74,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<p id="agentEnrollmentResultCopy">
|
||||
This token is displayed only for this enrollment step.
|
||||
Keep it private. MinerOps does not store the plaintext token.
|
||||
</p>
|
||||
@@ -152,7 +152,9 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementContent" hidden>
|
||||
<div id="agentManagementContent"
|
||||
data-can-manage-agents="{str(can_manage_agents).lower()}"
|
||||
hidden>
|
||||
<section class="agent-summary-grid" aria-label="Agent Management summary">
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Registered Agents</span>
|
||||
@@ -197,6 +199,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
<th>Last check-in</th>
|
||||
<th>Credential</th>
|
||||
<th>Routing</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentRows"></tbody>
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"agentCreateSubmit",
|
||||
"agentCreateStatus",
|
||||
"agentEnrollmentResult",
|
||||
"agentEnrollmentResultTitle",
|
||||
"agentEnrollmentResultCopy",
|
||||
"agentEnrollmentToken",
|
||||
"agentEnrollmentExpires",
|
||||
"agentEnrollmentDownload",
|
||||
@@ -48,6 +50,8 @@
|
||||
};
|
||||
|
||||
let enrollments = [];
|
||||
let packageEndpoint = "/api/agent-enrollments/windows-package";
|
||||
let packageFilename = "MinerOpsAgent-Windows.zip";
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
@@ -143,6 +147,27 @@
|
||||
cell(words(agent.routing_state))
|
||||
);
|
||||
|
||||
const action = document.createElement("td");
|
||||
const canManage =
|
||||
el.agentManagementContent.dataset.canManageAgents === "true";
|
||||
if (
|
||||
canManage &&
|
||||
agent.credential_state === "legacy" &&
|
||||
agent.routing_state === "assigned"
|
||||
) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "quiet-button";
|
||||
button.textContent = "Upgrade credential";
|
||||
button.addEventListener("click", function () {
|
||||
createCredentialTransition(agent);
|
||||
});
|
||||
action.appendChild(button);
|
||||
} else {
|
||||
action.textContent = "—";
|
||||
}
|
||||
row.appendChild(action);
|
||||
|
||||
el.agentRows.appendChild(row);
|
||||
});
|
||||
|
||||
@@ -188,7 +213,10 @@
|
||||
cell(words(enrollment.status)),
|
||||
cell(dateTime(enrollment.created_at)),
|
||||
cell(dateTime(enrollment.expires_at)),
|
||||
cell(text(enrollment.enrolled_agent_id, "Not enrolled"))
|
||||
cell(text(
|
||||
enrollment.enrolled_agent_id || enrollment.target_agent_id,
|
||||
"Not enrolled"
|
||||
))
|
||||
);
|
||||
|
||||
el.agentEnrollmentRows.appendChild(row);
|
||||
@@ -338,15 +366,15 @@
|
||||
});
|
||||
|
||||
const enrollment = payload.data;
|
||||
|
||||
el.agentEnrollmentToken.textContent =
|
||||
enrollment.enrollment_token;
|
||||
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(enrollment.expires_at);
|
||||
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
showOneTimeToken({
|
||||
token: enrollment.enrollment_token,
|
||||
expiresAt: enrollment.expires_at,
|
||||
title: "Enrollment created",
|
||||
copy: "This token creates a new Agent identity. Keep it private. " +
|
||||
"MinerOps does not store the plaintext token.",
|
||||
endpoint: "/api/agent-enrollments/windows-package",
|
||||
filename: "MinerOpsAgent-Windows.zip"
|
||||
});
|
||||
el.agentCreateStatus.textContent = "Enrollment created.";
|
||||
|
||||
el.agentDisplayName.value = "";
|
||||
@@ -360,6 +388,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showOneTimeToken(options) {
|
||||
el.agentEnrollmentToken.textContent = options.token;
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(options.expiresAt);
|
||||
el.agentEnrollmentResultTitle.textContent = options.title;
|
||||
el.agentEnrollmentResultCopy.textContent = options.copy;
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
packageEndpoint = options.endpoint;
|
||||
packageFilename = options.filename;
|
||||
}
|
||||
|
||||
async function createCredentialTransition(agent) {
|
||||
const confirmed = window.confirm(
|
||||
"Create a one-time credential upgrade for " + agent.agent_id +
|
||||
"? Its Agent identity, Site, and Zone will remain unchanged."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const payload = await api("/api/agent-credential-transitions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({agent_id: agent.agent_id})
|
||||
});
|
||||
const transition = payload.data;
|
||||
showOneTimeToken({
|
||||
token: transition.transition_token,
|
||||
expiresAt: transition.expires_at,
|
||||
title: "Existing Agent credential upgrade created",
|
||||
copy: "This token is bound to the existing Agent identity " +
|
||||
transition.target_agent_id + ". It cannot enroll a new " +
|
||||
"Agent or transfer its Zone.",
|
||||
endpoint: "/api/agent-credential-transitions/windows-package",
|
||||
filename: "MinerOpsAgent-Credential-Upgrade-Windows.zip"
|
||||
});
|
||||
el.agentCreateStatus.textContent =
|
||||
"Credential upgrade created for " + transition.target_agent_id + ".";
|
||||
await refreshData(false);
|
||||
} catch (error) {
|
||||
el.agentCreateStatus.textContent =
|
||||
error.message || "Credential upgrade could not be created.";
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWindowsPackage() {
|
||||
const token = el.agentEnrollmentToken.textContent;
|
||||
|
||||
@@ -375,7 +451,7 @@
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/agent-enrollments/windows-package",
|
||||
packageEndpoint,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
@@ -408,7 +484,7 @@
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = downloadUrl;
|
||||
link.download = "MinerOpsAgent-Windows.zip";
|
||||
link.download = packageFilename;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -700,6 +700,7 @@ def required_page_permission(path: str) -> str:
|
||||
def machine_agent_request(path: str) -> bool:
|
||||
exact = {
|
||||
"/api/agent/enroll",
|
||||
"/api/agent/credential-transition",
|
||||
"/api/agent/checkin",
|
||||
"/api/agent/config",
|
||||
"/api/agent/known-miners",
|
||||
@@ -707,6 +708,8 @@ def machine_agent_request(path: str) -> bool:
|
||||
"/api/agent/pool-snapshots",
|
||||
"/api/agent/commands/pending",
|
||||
"/api/agent/commands/result",
|
||||
"/api/agent/commands/pending-disabled-old",
|
||||
"/api/agent/commands/result-disabled-old",
|
||||
"/api/agent/discoveries",
|
||||
"/api/agent/miner-enrichment",
|
||||
"/api/agent/miner-enrichment-v2",
|
||||
@@ -759,6 +762,19 @@ def component_request(path: str) -> bool:
|
||||
|
||||
def required_api_permission(method: str, path: str) -> str | None:
|
||||
parts = path.split("/")
|
||||
if method == "POST" and path == "/api/dashboard/m30-command-test-lab/run":
|
||||
return "miners.control"
|
||||
if method == "POST" and path == "/api/tools/whatsminer-read-test":
|
||||
return "miners.control"
|
||||
if method == "POST" and path in {
|
||||
"/api/dashboard/scheduled-bulk-actions",
|
||||
"/api/dashboard/scheduled-bulk-actions/process-due",
|
||||
}:
|
||||
return "miners.control"
|
||||
if (method == "POST" and len(parts) == 6
|
||||
and parts[:4] == ["", "api", "dashboard", "scheduled-bulk-actions"]
|
||||
and parts[5] in {"cancel", "delete"}):
|
||||
return "miners.control"
|
||||
if (
|
||||
len(parts) >= 5
|
||||
and parts[:3] == ["", "api", "miners"]
|
||||
@@ -787,6 +803,10 @@ def required_api_permission(method: str, path: str) -> str | None:
|
||||
return "agents.view" if method == "GET" else "agents.manage"
|
||||
if path == "/api/agent-enrollments/windows-package":
|
||||
return "agents.manage"
|
||||
if path == "/api/agent-credential-transitions":
|
||||
return "agents.manage"
|
||||
if path == "/api/agent-credential-transitions/windows-package":
|
||||
return "agents.manage"
|
||||
if repair_parts_request(path):
|
||||
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
||||
if path.startswith("/api/admin/"):
|
||||
|
||||
+167
-1616
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,6 @@
|
||||
"""Business rules for typed, Agent-routed miner controls."""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID
|
||||
|
||||
from app.miner_control.exceptions import (
|
||||
ControlledMinerNotFound,
|
||||
@@ -16,6 +15,7 @@ from app.miner_control.models import (
|
||||
MinerControlProjection,
|
||||
)
|
||||
from app.miner_control.repository import MinerControlRepository
|
||||
from app.agent_commands.policy import PHYSICAL_CONTROL_DISABLED
|
||||
|
||||
|
||||
KNOWN_MODES = {"low", "normal", "high"}
|
||||
@@ -29,33 +29,8 @@ class MinerControlService:
|
||||
|
||||
@staticmethod
|
||||
def _capability(context: dict) -> ControlCapability:
|
||||
if not context.get("agent_zone_id"):
|
||||
return ControlCapability(state="unavailable", reason="agent_zone_unassigned")
|
||||
if context.get("agent_zone_exists") is not True:
|
||||
return ControlCapability(state="unavailable", reason="agent_zone_missing")
|
||||
if context.get("agent_zone_enabled") is not True:
|
||||
return ControlCapability(state="unavailable", reason="agent_zone_disabled")
|
||||
if not context.get("agent_id"):
|
||||
return ControlCapability(state="unavailable", reason="agent_unavailable")
|
||||
if not context.get("current_ip"):
|
||||
return ControlCapability(state="unavailable", reason="current_ip_missing")
|
||||
if context.get("api3_supported") is False:
|
||||
return ControlCapability(state="unavailable", reason="api3_unsupported")
|
||||
if context.get("api3_supported") is not True:
|
||||
return ControlCapability(state="unknown", reason="api3_support_unknown")
|
||||
|
||||
payload = context.get("last_payload") or {}
|
||||
if payload.get("control_available") is False:
|
||||
reason = payload.get("control_unavailable_reason")
|
||||
return ControlCapability(
|
||||
state="unavailable",
|
||||
reason=reason if reason == "credentials_missing" else "agent_unavailable",
|
||||
)
|
||||
if payload.get("control_available") is not True:
|
||||
return ControlCapability(
|
||||
state="unknown", reason="agent_capability_unknown"
|
||||
)
|
||||
return ControlCapability(state="supported")
|
||||
# Credentials and Zone routing are not physical identity authorization.
|
||||
return ControlCapability(state="unavailable", reason=PHYSICAL_CONTROL_DISABLED)
|
||||
|
||||
@staticmethod
|
||||
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
|
||||
@@ -120,50 +95,4 @@ class MinerControlService:
|
||||
) -> MinerControlAccepted:
|
||||
if not actor_user_id:
|
||||
raise MinerControlActorRequired("Authenticated actor UUID is required")
|
||||
context = self.repository.get_control_context(miner_id)
|
||||
if not context:
|
||||
raise ControlledMinerNotFound("Miner not found")
|
||||
capability = self._capability(context)
|
||||
if capability.state != "supported":
|
||||
raise MinerControlUnavailable(
|
||||
capability.reason or "control_capability_not_supported"
|
||||
)
|
||||
|
||||
runtime = str(context.get("runtime_state") or "").strip().lower()
|
||||
if action == "suspend" and runtime not in RUNNING_STATES:
|
||||
raise MinerControlUnavailable("miner_not_factually_running")
|
||||
if action == "resume" and runtime not in STOPPED_STATES:
|
||||
raise MinerControlUnavailable("miner_not_factually_stopped")
|
||||
if action == "performance_mode" and requested_value not in KNOWN_MODES:
|
||||
raise MinerControlUnavailable("invalid_performance_mode")
|
||||
|
||||
operation_id = str(uuid4())
|
||||
payload: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"miner_id": str(miner_id),
|
||||
"action": action,
|
||||
"requested_value": requested_value,
|
||||
"ip_address": context["current_ip"],
|
||||
}
|
||||
operation = self.repository.enqueue_with_audit(
|
||||
operation_id=operation_id,
|
||||
miner_id=miner_id,
|
||||
agent_id=context["agent_id"],
|
||||
actor_user_id=actor_user_id,
|
||||
actor_username=actor_username,
|
||||
request_payload=payload,
|
||||
audit_payload={
|
||||
"operation_id": operation_id,
|
||||
"miner_id": str(miner_id),
|
||||
"action": action,
|
||||
"requested_value": requested_value,
|
||||
},
|
||||
)
|
||||
return MinerControlAccepted(
|
||||
operation_id=operation["operation_id"],
|
||||
miner_id=miner_id,
|
||||
action=action,
|
||||
requested_value=requested_value,
|
||||
status=operation["status"],
|
||||
created_at=operation["created_at"],
|
||||
)
|
||||
raise MinerControlUnavailable(PHYSICAL_CONTROL_DISABLED)
|
||||
@@ -49,6 +49,14 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
"legacy-agent",
|
||||
)
|
||||
|
||||
def test_secret_only_legacy_agent_maps_to_configured_identity(self):
|
||||
self.assertEqual(
|
||||
self.service().authenticate(None, "legacy-secret"),
|
||||
"legacy-agent",
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate(None, "wrong-secret")
|
||||
|
||||
def test_legacy_agent_with_null_credential_still_authenticates(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
@@ -116,13 +124,83 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
|
||||
self.service(rows).authenticate("agent-new", secret)
|
||||
|
||||
def test_revoked_registry_row_never_falls_back_even_without_hash(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentCredentialRevoked
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||
|
||||
def test_missing_identity_never_authenticates_enrolled_legacy_agent(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
for secret in ("unique-secret", "legacy-secret"):
|
||||
with self.subTest(secret=secret), self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service(rows).authenticate(None, secret)
|
||||
|
||||
def test_missing_identity_reports_revoked_legacy_credential(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": NOW,
|
||||
}
|
||||
}
|
||||
with self.assertRaises(AgentCredentialRevoked):
|
||||
self.service(rows).authenticate(None, "legacy-secret")
|
||||
|
||||
def test_durable_retirement_rejects_fallback_after_credentials_are_cleared(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
"legacy_auth_disabled_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||
|
||||
def test_identity_retirement_rejects_fallback_without_current_agent_row(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
"legacy_auth_disabled_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||
|
||||
def test_unknown_identity_does_not_use_shared_secret(self):
|
||||
with self.assertRaises(AgentIdentityUnauthorized):
|
||||
self.service().authenticate("unknown-agent", "legacy-secret")
|
||||
|
||||
def test_missing_identity_or_secret_is_rejected(self):
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate(None, "legacy-secret")
|
||||
def test_missing_secret_is_rejected(self):
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate("legacy-agent", None)
|
||||
|
||||
@@ -131,6 +209,11 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||
self.service().authenticate("legacy-agent", "anything")
|
||||
|
||||
def test_secret_only_path_requires_configured_legacy_identity(self):
|
||||
settings.minerops_agent_id = ""
|
||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||
self.service().authenticate(None, "legacy-secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -16,7 +16,7 @@ from app.agent_commands.exceptions import (
|
||||
AgentCommandConflict,
|
||||
AgentResultValidationError,
|
||||
)
|
||||
from app.agent_commands.models import AgentCommandResultRequest
|
||||
from app.agent_commands.models import AgentCommandResultRequest, MinerControlResultPayload
|
||||
from app.agent_commands.repository import CLAIM_COMMANDS_SQL
|
||||
from app.agent_commands.router import router
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
@@ -38,14 +38,14 @@ class FakeRepository:
|
||||
def __init__(self, row=None):
|
||||
self.row = row or {
|
||||
"id": "command-1", "agent_id": "test-agent",
|
||||
"command_type": "miner_control", "command_status": "claimed",
|
||||
"command_type": "poll_known_miners_now", "command_status": "claimed",
|
||||
"request_payload": {"action": "suspend"},
|
||||
}
|
||||
self.finished = []
|
||||
|
||||
def claim_pending(self, agent_id, limit=5):
|
||||
return [{
|
||||
"command_id": "command-1", "command_type": "miner_control",
|
||||
"command_id": "command-1", "command_type": "poll_known_miners_now",
|
||||
"request_payload": {"action": "suspend"}, "created_at": NOW,
|
||||
}]
|
||||
|
||||
@@ -54,6 +54,8 @@ class FakeRepository:
|
||||
|
||||
def finish_command(self, *args):
|
||||
self.finished.append(args)
|
||||
if args[2] == "cancelled":
|
||||
raise AgentCommandConflict(args[4])
|
||||
|
||||
|
||||
def typed_result(**overrides):
|
||||
@@ -84,7 +86,7 @@ class AgentCommandServiceTests(unittest.TestCase):
|
||||
)
|
||||
AgentCommandService(repository).submit_result("test-agent", request)
|
||||
finished = repository.finished[0]
|
||||
self.assertIsNone(finished[4])
|
||||
self.assertIn("[address]", finished[4])
|
||||
self.assertNotIn("192.0.2.10", str(finished))
|
||||
|
||||
def test_wrong_agent_pending_and_terminal_results_are_rejected(self):
|
||||
@@ -111,8 +113,8 @@ class AgentCommandServiceTests(unittest.TestCase):
|
||||
command_id="command-1", status="completed",
|
||||
result_payload={**typed_result(), forbidden: "sensitive"},
|
||||
)
|
||||
with self.assertRaises(AgentResultValidationError):
|
||||
AgentCommandService(repository).submit_result("test-agent", request)
|
||||
with self.assertRaises(ValueError):
|
||||
MinerControlResultPayload.model_validate(request.result_payload)
|
||||
|
||||
def test_suspend_resume_cannot_claim_confirmation(self):
|
||||
for status in ("confirmed", "verification_failed"):
|
||||
@@ -123,8 +125,10 @@ class AgentCommandServiceTests(unittest.TestCase):
|
||||
verification_attempted=True, verification_status=status
|
||||
),
|
||||
)
|
||||
with self.assertRaises(AgentResultValidationError):
|
||||
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
|
||||
repository = FakeRepository()
|
||||
repository.row["command_type"] = "miner_control"
|
||||
with self.assertRaisesRegex(AgentCommandConflict, "physical_control_disabled_stage_a"):
|
||||
AgentCommandService(repository).submit_result("test-agent", request)
|
||||
|
||||
|
||||
class FakeAgentAuthRepository:
|
||||
@@ -160,17 +164,26 @@ class AgentCommandRouterTests(unittest.TestCase):
|
||||
"X-MinerOps-Agent-Secret": "test-secret",
|
||||
}
|
||||
|
||||
def test_poll_requires_header_identity_and_ignores_query_spoofing(self):
|
||||
def test_poll_accepts_secret_only_legacy_agent_and_ignores_query_spoofing(self):
|
||||
before_claim_calls = []
|
||||
self.app.state.agent_command_before_claim = lambda: before_claim_calls.append(True)
|
||||
self.assertEqual(self.client.get("/api/agent/commands/pending").status_code, 401)
|
||||
self.assertEqual(
|
||||
self.client.get(
|
||||
"/api/agent/commands/pending",
|
||||
headers={"X-MinerOps-Agent-Secret": "test-secret"},
|
||||
).status_code,
|
||||
200,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get(
|
||||
"/api/agent/commands/pending?agent_id=other", headers=self.headers()
|
||||
).status_code,
|
||||
200,
|
||||
)
|
||||
self.assertEqual(before_claim_calls, [True])
|
||||
# Agent polling may claim already queued commands, but must never invoke
|
||||
# a legacy scheduler/materializer as a side effect.
|
||||
self.assertEqual(before_claim_calls, [])
|
||||
self.assertEqual(
|
||||
self.client.get(
|
||||
"/api/agent/commands/pending", headers=self.headers("other")
|
||||
@@ -214,9 +227,9 @@ class AgentSourceContractTests(unittest.TestCase):
|
||||
self.assertIn('.agent_zone_id', service)
|
||||
self.assertIn('"agent_zone_id"', (root / "config.example.json").read_text())
|
||||
self.assertNotIn("?agent_id=", service)
|
||||
self.assertIn('"get.miner.setting"', control)
|
||||
self.assertIn("$attempt -le 3", control)
|
||||
self.assertIn("$Payload.performance_mode", control)
|
||||
self.assertIn("physical_control_disabled_stage_a", control)
|
||||
self.assertNotIn('"set.miner.', control)
|
||||
self.assertNotIn("$Payload.performance_mode", control)
|
||||
self.assertNotIn("api3_account =", control)
|
||||
self.assertNotIn("api3_password =", control)
|
||||
for forbidden in ("raw_response", "raw_command", "raw_exception"):
|
||||
@@ -366,3 +379,18 @@ class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
||||
self.assertIn("minerops_require_agent_secret(request)", route)
|
||||
self.assertIn('"site_name": "Plexus"', route)
|
||||
self.assertNotIn("Depends(require_agent_identity)", route)
|
||||
|
||||
def test_disabled_old_command_routes_are_fixed_retirement_responses(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
for decorator in (
|
||||
'@app.get("/api/agent/commands/pending-disabled-old")',
|
||||
'@app.post("/api/agent/commands/result-disabled-old")',
|
||||
):
|
||||
with self.subTest(decorator=decorator):
|
||||
start = main.index(decorator)
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
self.assertIn("status_code=410", route)
|
||||
self.assertNotIn("minerops_require_agent_secret", route)
|
||||
self.assertNotIn("request.json", route)
|
||||
self.assertNotIn("minerops_agent_command_queue", route)
|
||||
@@ -0,0 +1,584 @@
|
||||
"""PostgreSQL security contract for existing-Agent credential transition."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import ValidationError
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.repository import (
|
||||
AgentEnrollmentRepository,
|
||||
default_connection_factory,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_registry.repository import AgentRegistryRepository
|
||||
from app.config import settings
|
||||
|
||||
|
||||
PRODUCTION_AGENT_ID = "plexus-windows-agent"
|
||||
PRODUCTION_SITE_ID = UUID("00000000-0000-4000-8000-000000002001")
|
||||
PRODUCTION_ZONE_ID = UUID("00000000-0000-4000-8000-000000002002")
|
||||
PRODUCTION_MINER_ID = UUID("00000000-0000-4000-8000-000000002003")
|
||||
|
||||
|
||||
def connect():
|
||||
return default_connection_factory()
|
||||
|
||||
|
||||
class AgentCredentialTransitionPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"credential transition requires its disposable PostgreSQL runner"
|
||||
)
|
||||
if settings.minerops_agent_id != PRODUCTION_AGENT_ID:
|
||||
raise AssertionError(
|
||||
"runner must bind the production-shaped legacy Agent identity"
|
||||
)
|
||||
cls.service = AgentEnrollmentService(AgentEnrollmentRepository(connect))
|
||||
cls.auth = AgentAuthService(AgentAuthRepository(connect))
|
||||
|
||||
def create_legacy_agent(
|
||||
self,
|
||||
agent_id,
|
||||
*,
|
||||
site_id=None,
|
||||
zone_id=None,
|
||||
enabled=True,
|
||||
secret_hash=None,
|
||||
revoked=False,
|
||||
):
|
||||
site_id = site_id or uuid4()
|
||||
zone_id = zone_id or uuid4()
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO sites (id, name) VALUES (%s, %s)",
|
||||
(site_id, f"Transition Site {site_id.hex[:8]}"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(zone_id, site_id, "Default Zone", enabled),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, '10.220.0.0/24'::cidr)
|
||||
""",
|
||||
(zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name,
|
||||
display_name, agent_secret_hash,
|
||||
credential_created_at, credential_revoked_at
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, %s, %s, %s,
|
||||
CASE WHEN %s::boolean THEN now() ELSE NULL END,
|
||||
CASE WHEN %s::boolean THEN now() ELSE NULL END
|
||||
)
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
site_id,
|
||||
zone_id,
|
||||
f"Transition Site {site_id.hex[:8]}",
|
||||
f"Agent {agent_id}"[:120],
|
||||
secret_hash,
|
||||
secret_hash is not None,
|
||||
revoked,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
return site_id, zone_id
|
||||
|
||||
def create_transition(self, agent_id):
|
||||
return self.service.create_credential_transition(
|
||||
AgentCredentialTransitionCreateRequest(agent_id=agent_id),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
def redeem_transition(self, token, secret):
|
||||
return AgentEnrollmentService(
|
||||
AgentEnrollmentRepository(connect)
|
||||
).redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=token,
|
||||
agent_secret_hash=hash_agent_secret(secret),
|
||||
)
|
||||
)
|
||||
|
||||
def agent_row(self, agent_id):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, site_id, agent_zone_id, agent_secret_hash,
|
||||
credential_created_at, credential_revoked_at,
|
||||
legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_01_production_identity_transitions_without_routing_change(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES (%s, 'Plexus Mining Site')
|
||||
""",
|
||||
(PRODUCTION_SITE_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, 'Default Zone', true)
|
||||
""",
|
||||
(PRODUCTION_ZONE_ID, PRODUCTION_SITE_ID),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, '192.0.2.0/24'::cidr)
|
||||
""",
|
||||
(PRODUCTION_ZONE_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name, display_name
|
||||
)
|
||||
VALUES (%s, %s, %s, 'Plexus', 'Plexus Windows Agent')
|
||||
""",
|
||||
(
|
||||
PRODUCTION_AGENT_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miners (
|
||||
id, site_id, agent_zone_id, asset_tag,
|
||||
current_ip, current_mac, status
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, 'TRANSITION-PRODUCTION-MINER',
|
||||
'192.0.2.20', '02:00:00:00:20:03', 'online'
|
||||
)
|
||||
""",
|
||||
(
|
||||
PRODUCTION_MINER_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
legacy_secret = settings.minerops_agent_shared_secret
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(None, legacy_secret),
|
||||
PRODUCTION_AGENT_ID,
|
||||
)
|
||||
|
||||
transition = self.create_transition(PRODUCTION_AGENT_ID)
|
||||
token = transition["transition_token"]
|
||||
self.assertEqual(transition["target_agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertEqual(transition["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(transition["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT token_hash, target_agent_id, used_at,
|
||||
enrolled_agent_id, site_id, agent_zone_id
|
||||
FROM agent_enrollments WHERE id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
authorization = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(authorization["token_hash"], hash_agent_secret(token))
|
||||
self.assertNotEqual(authorization["token_hash"], token)
|
||||
self.assertEqual(authorization["target_agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertIsNone(authorization["used_at"])
|
||||
|
||||
new_secret = "production-shaped-per-agent-secret"
|
||||
result = self.redeem_transition(token, new_secret)
|
||||
self.assertEqual(result["agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertNotIn("agent_secret", result)
|
||||
self.assertEqual(result["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(result["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
|
||||
# An ambiguous response is safely retryable only with the same locally
|
||||
# generated credential; no second transition or audit event is created.
|
||||
retried = self.redeem_transition(token, new_secret)
|
||||
self.assertEqual(retried, result)
|
||||
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||
self.redeem_transition(token, "different-secret-cannot-rotate")
|
||||
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(PRODUCTION_AGENT_ID, new_secret),
|
||||
PRODUCTION_AGENT_ID,
|
||||
)
|
||||
for supplied_id in (None, PRODUCTION_AGENT_ID):
|
||||
with self.subTest(supplied_id=supplied_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(supplied_id, legacy_secret)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT site_id, agent_zone_id, agent_secret_hash,
|
||||
credential_created_at, legacy_auth_disabled_at
|
||||
FROM minerops_site_agents WHERE agent_id = %s
|
||||
""",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT site_id, agent_zone_id, current_mac::text,
|
||||
host(current_ip) AS current_ip
|
||||
FROM miners WHERE id = %s
|
||||
""",
|
||||
(PRODUCTION_MINER_ID,),
|
||||
)
|
||||
miner = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS retirement_count
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
retirement_count = cur.fetchone()["retirement_count"]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS redeemed_audits
|
||||
FROM audit_logs
|
||||
WHERE action = 'agent.credential_transition.redeemed'
|
||||
AND entity_id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
redeemed_audits = cur.fetchone()["redeemed_audits"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(agent["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(agent["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
self.assertEqual(agent["agent_secret_hash"], hash_agent_secret(new_secret))
|
||||
self.assertIsNotNone(agent["credential_created_at"])
|
||||
self.assertIsNotNone(agent["legacy_auth_disabled_at"])
|
||||
self.assertEqual(miner["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(miner["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
self.assertEqual(miner["current_mac"], "02:00:00:00:20:03")
|
||||
self.assertEqual(miner["current_ip"], "192.0.2.20")
|
||||
self.assertEqual(retirement_count, 1)
|
||||
self.assertEqual(redeemed_audits, 1)
|
||||
|
||||
# Remove deletable enrollment/current state to prove that the separate
|
||||
# append-only retirement identity still defeats fallback on recreation.
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM agent_enrollments WHERE id = %s",
|
||||
(transition["id"],),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name
|
||||
) VALUES (%s, %s, %s, 'Recreated Plexus')
|
||||
""",
|
||||
(
|
||||
PRODUCTION_AGENT_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
recreated = self.agent_row(PRODUCTION_AGENT_ID)
|
||||
self.assertIsNone(recreated["agent_secret_hash"])
|
||||
self.assertIsNotNone(recreated["legacy_auth_disabled_at"])
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.auth.authenticate(PRODUCTION_AGENT_ID, legacy_secret)
|
||||
|
||||
def test_02_concurrent_redemption_has_one_credential_winner(self):
|
||||
agent_id = "transition-concurrent-agent"
|
||||
self.create_legacy_agent(agent_id)
|
||||
token = self.create_transition(agent_id)["transition_token"]
|
||||
secrets = ("concurrent-secret-a", "concurrent-secret-b")
|
||||
|
||||
def redeem(secret):
|
||||
try:
|
||||
return ("success", secret, self.redeem_transition(token, secret))
|
||||
except AgentEnrollmentTokenInvalid:
|
||||
return ("invalid", secret, None)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(redeem, secrets))
|
||||
|
||||
winners = [item for item in results if item[0] == "success"]
|
||||
losers = [item for item in results if item[0] == "invalid"]
|
||||
self.assertEqual(len(winners), 1)
|
||||
self.assertEqual(len(losers), 1)
|
||||
row = self.agent_row(agent_id)
|
||||
self.assertEqual(row["agent_secret_hash"], hash_agent_secret(winners[0][1]))
|
||||
|
||||
def test_03_authorization_cannot_substitute_identity_or_steal_zone(self):
|
||||
agent_a = "transition-bound-agent-a"
|
||||
agent_b = "transition-bound-agent-b"
|
||||
site_a, zone_a = self.create_legacy_agent(agent_a)
|
||||
site_b, zone_b = self.create_legacy_agent(agent_b)
|
||||
token = self.create_transition(agent_a)["transition_token"]
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
AgentCredentialTransitionRedeemRequest.model_validate(
|
||||
{
|
||||
"token": token,
|
||||
"agent_secret_hash": hash_agent_secret("bound-secret"),
|
||||
"agent_id": agent_b,
|
||||
}
|
||||
)
|
||||
|
||||
result = self.redeem_transition(token, "bound-secret")
|
||||
self.assertEqual(result["agent_id"], agent_a)
|
||||
self.assertEqual((result["site_id"], result["agent_zone_id"]), (site_a, zone_a))
|
||||
other = self.agent_row(agent_b)
|
||||
self.assertEqual((other["site_id"], other["agent_zone_id"]), (site_b, zone_b))
|
||||
self.assertIsNone(other["agent_secret_hash"])
|
||||
|
||||
def test_04_revoked_retired_and_disabled_targets_fail_closed(self):
|
||||
revoked = "transition-revoked-agent"
|
||||
self.create_legacy_agent(
|
||||
revoked,
|
||||
secret_hash=hash_agent_secret("revoked-secret"),
|
||||
revoked=True,
|
||||
)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(revoked)
|
||||
|
||||
retired = "transition-retired-agent"
|
||||
self.create_legacy_agent(retired)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO agent_legacy_auth_retirements (agent_id) VALUES (%s)",
|
||||
(retired,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(retired)
|
||||
|
||||
disabled = "transition-disabled-agent"
|
||||
self.create_legacy_agent(disabled, enabled=False)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(disabled)
|
||||
|
||||
changed = "transition-zone-disabled-after-authorization"
|
||||
_, changed_zone = self.create_legacy_agent(changed)
|
||||
authorization = self.create_transition(changed)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE agent_zones SET is_enabled = false WHERE id = %s",
|
||||
(changed_zone,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.redeem_transition(
|
||||
authorization["transition_token"], "disabled-zone-secret"
|
||||
)
|
||||
self.assertIsNone(self.agent_row(changed)["agent_secret_hash"])
|
||||
|
||||
def test_05_credential_collision_rolls_back_retirement_and_token_use(self):
|
||||
collision_hash = hash_agent_secret("already-issued-secret")
|
||||
self.create_legacy_agent(
|
||||
"transition-existing-credential-holder",
|
||||
secret_hash=collision_hash,
|
||||
)
|
||||
target = "transition-rollback-target"
|
||||
self.create_legacy_agent(target)
|
||||
transition = self.create_transition(target)
|
||||
|
||||
with self.assertRaises(AgentEnrollmentCredentialCollision):
|
||||
self.service.redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=transition["transition_token"],
|
||||
agent_secret_hash=collision_hash,
|
||||
)
|
||||
)
|
||||
|
||||
row = self.agent_row(target)
|
||||
self.assertIsNone(row["agent_secret_hash"])
|
||||
self.assertIsNone(row["credential_created_at"])
|
||||
self.assertIsNone(row["legacy_auth_disabled_at"])
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(target,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], 0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, enrolled_agent_id
|
||||
FROM agent_enrollments WHERE id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
self.assertEqual(cur.fetchone(), (None, None))
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_06_normal_enrollment_still_works_and_occupied_zone_is_protected(self):
|
||||
site_id, occupied_zone = self.create_legacy_agent(
|
||||
"transition-ordinary-occupied"
|
||||
)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=site_id,
|
||||
agent_zone_id=occupied_zone,
|
||||
display_name="Must Not Steal Occupied Zone",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
free_zone = uuid4()
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, 'Free Enrollment Zone', true)
|
||||
""",
|
||||
(free_zone, site_id),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
enrollment = self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=site_id,
|
||||
agent_zone_id=free_zone,
|
||||
display_name="Ordinary New Agent",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
enrolled = self.service.redeem(
|
||||
AgentEnrollRequest(token=enrollment["enrollment_token"])
|
||||
)
|
||||
self.assertRegex(enrolled["agent_id"], r"^agent-[0-9a-f]{32}$")
|
||||
self.assertEqual(enrolled["agent_zone_id"], free_zone)
|
||||
|
||||
def test_07_registry_history_and_audit_never_expose_plaintext(self):
|
||||
agent_id = "transition-privacy-agent"
|
||||
self.create_legacy_agent(agent_id)
|
||||
transition = self.create_transition(agent_id)
|
||||
token = transition["transition_token"]
|
||||
|
||||
listed = self.service.list_enrollments()
|
||||
item = next(row for row in listed if row["id"] == transition["id"])
|
||||
self.assertEqual(item["target_agent_id"], agent_id)
|
||||
self.assertEqual(item["purpose"], "existing_agent_credential_transition")
|
||||
serialized_item = repr(item)
|
||||
self.assertNotIn(token, serialized_item)
|
||||
for forbidden in (
|
||||
"token_hash",
|
||||
"transition_token",
|
||||
"agent_secret",
|
||||
"agent_secret_hash",
|
||||
):
|
||||
self.assertNotIn(forbidden, item)
|
||||
|
||||
registry = AgentRegistryRepository().list_registry()
|
||||
registry_agent = next(
|
||||
row for row in registry["agents"] if row["agent_id"] == agent_id
|
||||
)
|
||||
for forbidden in ("agent_secret", "agent_secret_hash", "token_hash"):
|
||||
self.assertNotIn(forbidden, registry_agent)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT new_value_json::text
|
||||
FROM audit_logs
|
||||
WHERE entity_id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
audit_text = "\n".join(row[0] for row in cur.fetchall())
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertNotIn(token, audit_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,13 +5,24 @@ import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.identity import machine_agent_request, required_api_permission
|
||||
from app.agent_enrollment.router import router
|
||||
from app.identity import (
|
||||
get_current_user,
|
||||
machine_agent_request,
|
||||
required_api_permission,
|
||||
)
|
||||
|
||||
|
||||
SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
|
||||
@@ -25,6 +36,8 @@ class FakeAgentEnrollmentRepository:
|
||||
def __init__(self):
|
||||
self.created = None
|
||||
self.redeemed = None
|
||||
self.transition_created = None
|
||||
self.transition_redeemed = None
|
||||
|
||||
def create_enrollment(self, **kwargs):
|
||||
self.created = kwargs
|
||||
@@ -42,6 +55,20 @@ class FakeAgentEnrollmentRepository:
|
||||
def list_enrollments(self, **kwargs):
|
||||
return []
|
||||
|
||||
def create_credential_transition(self, **kwargs):
|
||||
self.transition_created = kwargs
|
||||
return {
|
||||
"id": ENROLLMENT_ID,
|
||||
"target_agent_id": kwargs["target_agent_id"],
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": "Existing Agent",
|
||||
"created_at": NOW,
|
||||
"expires_at": NOW + timedelta(minutes=30),
|
||||
}
|
||||
|
||||
def redeem_enrollment(self, **kwargs):
|
||||
self.redeemed = kwargs
|
||||
return {
|
||||
@@ -54,6 +81,18 @@ class FakeAgentEnrollmentRepository:
|
||||
"ip_ranges": ["10.30.0.0/22"],
|
||||
}
|
||||
|
||||
def redeem_credential_transition(self, **kwargs):
|
||||
self.transition_redeemed = kwargs
|
||||
return {
|
||||
"agent_id": "existing-agent",
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": "Existing Agent",
|
||||
"ip_ranges": ["10.30.0.0/22"],
|
||||
}
|
||||
|
||||
|
||||
class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self):
|
||||
@@ -123,6 +162,45 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
serialized = repr(repository.redeemed)
|
||||
self.assertNotIn(secret, serialized)
|
||||
|
||||
def test_existing_transition_is_server_bound_and_accepts_only_secret_hash(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
created = service.create_credential_transition(
|
||||
AgentCredentialTransitionCreateRequest(
|
||||
agent_id=" existing-agent "
|
||||
),
|
||||
actor_user_id=USER_ID,
|
||||
)
|
||||
token = created["transition_token"]
|
||||
|
||||
self.assertEqual(
|
||||
repository.transition_created["target_agent_id"],
|
||||
"existing-agent",
|
||||
)
|
||||
self.assertEqual(
|
||||
repository.transition_created["token_hash"],
|
||||
hash_agent_secret(token),
|
||||
)
|
||||
self.assertNotIn(token, repr(repository.transition_created))
|
||||
|
||||
credential_hash = hash_agent_secret("locally-generated-secret")
|
||||
result = service.redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=token,
|
||||
agent_secret_hash=credential_hash,
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["agent_id"], "existing-agent")
|
||||
self.assertEqual(
|
||||
repository.transition_redeemed,
|
||||
{
|
||||
"token_hash": hash_agent_secret(token),
|
||||
"agent_secret_hash": credential_hash,
|
||||
},
|
||||
)
|
||||
self.assertNotIn("agent_secret", result)
|
||||
|
||||
def test_enrollment_admin_permission_mapping_is_explicit(self):
|
||||
self.assertEqual(
|
||||
required_api_permission("GET", "/api/agent-enrollments"),
|
||||
@@ -132,9 +210,18 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
required_api_permission("POST", "/api/agent-enrollments"),
|
||||
"agents.manage",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"POST", "/api/agent-credential-transitions"
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self):
|
||||
self.assertTrue(machine_agent_request("/api/agent/enroll"))
|
||||
self.assertTrue(
|
||||
machine_agent_request("/api/agent/credential-transition")
|
||||
)
|
||||
self.assertFalse(
|
||||
machine_agent_request("/api/agent-enrollments")
|
||||
)
|
||||
@@ -153,5 +240,66 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class AgentCredentialTransitionRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeAgentEnrollmentRepository()
|
||||
self.service = AgentEnrollmentService(self.repository)
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.app.dependency_overrides[get_agent_enrollment_service] = (
|
||||
lambda: self.service
|
||||
)
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(USER_ID),
|
||||
"role": "Test",
|
||||
"permissions": ("agents.view", "agents.manage"),
|
||||
}
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def test_admin_creation_is_distinct_no_store_and_identity_only(self):
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions",
|
||||
json={"agent_id": "existing-agent"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["target_agent_id"], "existing-agent")
|
||||
self.assertIn("transition_token", payload)
|
||||
self.assertNotIn("site_id", self.repository.transition_created)
|
||||
self.assertNotIn("agent_zone_id", self.repository.transition_created)
|
||||
|
||||
def test_machine_redemption_returns_no_permanent_plaintext_secret(self):
|
||||
response = self.client.post(
|
||||
"/api/agent/credential-transition",
|
||||
json={
|
||||
"token": "T" * 43,
|
||||
"agent_secret_hash": hash_agent_secret(
|
||||
"locally-generated-router-secret"
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["agent_id"], "existing-agent")
|
||||
self.assertNotIn("agent_secret", payload)
|
||||
|
||||
def test_view_only_admin_cannot_create_transition(self):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(USER_ID),
|
||||
"role": "Test",
|
||||
"permissions": ("agents.view",),
|
||||
}
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions",
|
||||
json={"agent_id": "existing-agent"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
from psycopg.errors import ForeignKeyViolation
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||
@@ -11,6 +12,7 @@ from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
@@ -26,6 +28,7 @@ from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_zones.models import AgentCheckinRequest
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
@@ -322,6 +325,7 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
legacy_auth_disabled_at,
|
||||
last_payload::text AS last_payload
|
||||
FROM minerops_site_agents
|
||||
WHERE site_id = %s
|
||||
@@ -345,6 +349,16 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
)
|
||||
enrollment_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT retired_at
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
retirement_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT new_value_json::text AS payload
|
||||
@@ -369,6 +383,15 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIsNotNone(agent["credential_created_at"])
|
||||
self.assertIsNone(agent["credential_revoked_at"])
|
||||
self.assertEqual(
|
||||
agent["legacy_auth_disabled_at"],
|
||||
agent["credential_created_at"],
|
||||
)
|
||||
self.assertIsNotNone(retirement_row)
|
||||
self.assertEqual(
|
||||
retirement_row["retired_at"],
|
||||
agent["credential_created_at"],
|
||||
)
|
||||
|
||||
self.assertIsNotNone(enrollment_row["used_at"])
|
||||
self.assertIsNone(enrollment_row["revoked_at"])
|
||||
@@ -409,6 +432,17 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
"definitely-not-the-issued-secret",
|
||||
)
|
||||
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
original_legacy_secret = settings.minerops_agent_shared_secret
|
||||
settings.minerops_agent_id = agent_id
|
||||
settings.minerops_agent_shared_secret = "global-secret-must-not-work"
|
||||
try:
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(None, "global-secret-must-not-work")
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
settings.minerops_agent_shared_secret = original_legacy_secret
|
||||
|
||||
# Normal check-in must never mutate the credential.
|
||||
secret_hash_before = agent["agent_secret_hash"]
|
||||
|
||||
@@ -433,7 +467,8 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
SELECT
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
credential_revoked_at,
|
||||
legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
@@ -453,6 +488,150 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
self.assertIsNone(
|
||||
after_checkin["credential_revoked_at"]
|
||||
)
|
||||
self.assertEqual(
|
||||
after_checkin["legacy_auth_disabled_at"],
|
||||
agent["legacy_auth_disabled_at"],
|
||||
)
|
||||
|
||||
# 0017 enrollment integrity remains RESTRICT: the current Agent row
|
||||
# cannot be deleted while its enrollment relationship exists.
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with self.assertRaises(ForeignKeyViolation):
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(agent_id,),
|
||||
)
|
||||
connection.rollback()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# After an operator deliberately removes the enrollment history, the
|
||||
# independent retirement ledger must outlive deletion and recreation
|
||||
# of the current Agent row with the identical identity.
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM agent_enrollments WHERE id = %s",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
original_legacy_secret = settings.minerops_agent_shared_secret
|
||||
settings.minerops_agent_id = agent_id
|
||||
settings.minerops_agent_shared_secret = "recreation-global-secret"
|
||||
try:
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(None, "recreation-global-secret")
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING legacy_auth_disabled_at
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
self.site_id,
|
||||
self.zone_id,
|
||||
self.site_name,
|
||||
),
|
||||
)
|
||||
recreated = cur.fetchone()
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(
|
||||
recreated["legacy_auth_disabled_at"],
|
||||
retirement_row["retired_at"],
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(agent_id, "recreation-global-secret")
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
settings.minerops_agent_shared_secret = original_legacy_secret
|
||||
|
||||
def test_failed_credential_issuance_rolls_back_new_retirement(self):
|
||||
enrollment = self.create_enrollment("Collision Agent")
|
||||
collision_id = f"agent-collision-{uuid4().hex}"
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Existing uncredentialed identity')
|
||||
""",
|
||||
(collision_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
try:
|
||||
with self.assertRaises(AgentEnrollmentCredentialCollision):
|
||||
AgentEnrollmentRepository().redeem_enrollment(
|
||||
token_hash=hash_agent_secret(enrollment["enrollment_token"]),
|
||||
agent_id=collision_id,
|
||||
agent_secret_hash=hash_agent_secret("never-issued-secret"),
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(collision_id,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()["count"], 0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
enrollment_row = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(enrollment_row["used_at"])
|
||||
self.assertIsNone(enrollment_row["enrolled_agent_id"])
|
||||
finally:
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(collision_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_expired_token_is_rejected_without_side_effects(self):
|
||||
enrollment = self.create_enrollment()
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Disposable PostgreSQL contract for durable legacy-auth retirement."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from psycopg.errors import CheckViolation
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentCredentialRevoked,
|
||||
)
|
||||
from app.agent_auth.repository import AgentAuthRepository, default_connection_factory
|
||||
from app.agent_auth.service import AgentAuthService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class AgentLegacyAuthRetirementPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_RETIREMENT_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"legacy-auth retirement requires its disposable PostgreSQL runner"
|
||||
)
|
||||
cls.case = os.environ["MINEROPS_LEGACY_RETIREMENT_CASE"]
|
||||
cls.agent_id = settings.minerops_agent_id
|
||||
cls.auth = AgentAuthService(AgentAuthRepository())
|
||||
|
||||
def row(self):
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_secret_hash, credential_created_at,
|
||||
credential_revoked_at, legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def retirement_row(self, agent_id=None):
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, retired_at
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id or self.agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_01_migration_backfill_is_selective(self):
|
||||
row = self.row()
|
||||
self.assertIsNotNone(row)
|
||||
if self.case == "legacy":
|
||||
self.assertEqual(row, (None, None, None, None))
|
||||
self.assertIsNone(self.retirement_row())
|
||||
else:
|
||||
self.assertIsNotNone(row[3])
|
||||
retirement = self.retirement_row()
|
||||
self.assertIsNotNone(retirement)
|
||||
self.assertEqual(retirement[1], row[3])
|
||||
|
||||
def test_02_real_repository_authentication_obeys_retirement(self):
|
||||
secret = settings.minerops_agent_shared_secret
|
||||
if self.case == "legacy":
|
||||
self.assertEqual(self.auth.authenticate(None, secret), self.agent_id)
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(self.agent_id, secret), self.agent_id
|
||||
)
|
||||
return
|
||||
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentCredentialRevoked
|
||||
):
|
||||
self.auth.authenticate(agent_id, secret)
|
||||
|
||||
def test_03_clearing_credentials_cannot_restore_global_fallback(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case must remain eligible")
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET agent_secret_hash = NULL,
|
||||
credential_created_at = NULL,
|
||||
credential_revoked_at = NULL
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
row = self.row()
|
||||
self.assertEqual(row[:3], (None, None, None))
|
||||
self.assertIsNotNone(row[3])
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
def test_04_deletion_and_recreation_cannot_restore_fallback(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case has no retirement history")
|
||||
|
||||
retirement = self.retirement_row()
|
||||
self.assertIsNotNone(retirement)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(self.row())
|
||||
self.assertEqual(self.retirement_row(), retirement)
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(state="deleted", agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Recreated')
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
recreated = self.row()
|
||||
self.assertEqual(recreated[:3], (None, None, None))
|
||||
self.assertEqual(recreated[3], retirement[1])
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(state="recreated", agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
def test_05_retirement_ledger_and_projection_are_one_way(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case has no retirement history")
|
||||
|
||||
statements = (
|
||||
"""
|
||||
UPDATE agent_legacy_auth_retirements
|
||||
SET retired_at = retired_at + interval '1 second'
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
"""
|
||||
DELETE FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
"TRUNCATE agent_legacy_auth_retirements",
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET legacy_auth_disabled_at = NULL
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
)
|
||||
for statement in statements:
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with self.subTest(statement=" ".join(statement.split())):
|
||||
with self.assertRaises(CheckViolation):
|
||||
with connection.cursor() as cur:
|
||||
if "%s" in statement:
|
||||
cur.execute(statement, (self.agent_id,))
|
||||
else:
|
||||
cur.execute(statement)
|
||||
connection.rollback()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNotNone(self.retirement_row())
|
||||
self.assertIsNotNone(self.row()[3])
|
||||
|
||||
def test_06_other_legacy_identity_is_not_accidentally_retired(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("cross-identity check runs in the retired case")
|
||||
|
||||
other_id = self.agent_id + "-untouched"
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Untouched')
|
||||
""",
|
||||
(other_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(self.retirement_row(other_id))
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
settings.minerops_agent_id = other_id
|
||||
try:
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(
|
||||
other_id, settings.minerops_agent_shared_secret
|
||||
),
|
||||
other_id,
|
||||
)
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -62,6 +62,7 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
self.assertIn('id="agentSite"', managed)
|
||||
self.assertIn('id="agentZone"', managed)
|
||||
self.assertIn('id="agentDisplayName"', managed)
|
||||
self.assertIn('data-can-manage-agents="true"', managed)
|
||||
self.assertIn("Linux x64 — coming later", managed)
|
||||
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
|
||||
|
||||
@@ -100,6 +101,11 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
'api("/api/agent-enrollments", {',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'api("/api/agent-credential-transitions", {',
|
||||
source,
|
||||
)
|
||||
self.assertIn("Upgrade credential", source)
|
||||
|
||||
for unsafe in (
|
||||
"innerHTML",
|
||||
@@ -153,9 +159,10 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'link.download = "MinerOpsAgent-Windows.zip"',
|
||||
'packageFilename = "MinerOpsAgent-Windows.zip"',
|
||||
source,
|
||||
)
|
||||
self.assertIn("link.download = packageFilename", source)
|
||||
self.assertIn(
|
||||
"window.URL.revokeObjectURL(downloadUrl)",
|
||||
source,
|
||||
|
||||
@@ -73,7 +73,8 @@ class AgentRegistryPostgresTests(unittest.TestCase):
|
||||
version,
|
||||
ip_address,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
@@ -85,6 +86,7 @@ class AgentRegistryPostgresTests(unittest.TestCase):
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
""",
|
||||
|
||||
@@ -65,6 +65,7 @@ class WindowsPackageBuilderTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(enrollment["token"], TOKEN)
|
||||
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||
self.assertEqual(
|
||||
enrollment["hub_url"],
|
||||
"https://minerops.example.test",
|
||||
@@ -77,6 +78,50 @@ class WindowsPackageBuilderTests(unittest.TestCase):
|
||||
self.assertNotIn("api3_account", serialized)
|
||||
self.assertNotIn("agent-config.json", "\n".join(names))
|
||||
|
||||
def test_existing_agent_transition_package_is_generic_and_distinct(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.source_tree(root)
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=TOKEN,
|
||||
hub_url="https://minerops.example.test",
|
||||
workflow="existing_agent_credential_transition",
|
||||
source_root=root,
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(package)) as archive:
|
||||
enrollment = json.loads(
|
||||
archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
|
||||
)
|
||||
)
|
||||
instructions = archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
|
||||
).decode("utf-8")
|
||||
names = set(archive.namelist())
|
||||
|
||||
self.assertEqual(
|
||||
enrollment,
|
||||
{
|
||||
"hub_url": "https://minerops.example.test",
|
||||
"token": TOKEN,
|
||||
"workflow": "existing_agent_credential_transition",
|
||||
},
|
||||
)
|
||||
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
|
||||
self.assertIn(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/Upgrade-MinerOpsAgentCredential.ps1",
|
||||
names,
|
||||
)
|
||||
serialized = json.dumps(enrollment)
|
||||
for forbidden in (
|
||||
"plexus-windows-agent",
|
||||
"site_id",
|
||||
"agent_zone_id",
|
||||
"agent_secret",
|
||||
):
|
||||
self.assertNotIn(forbidden, serialized)
|
||||
|
||||
def test_missing_canonical_source_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
@@ -134,6 +179,13 @@ class WindowsPackageRouteTests(unittest.TestCase):
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"POST",
|
||||
"/api/agent-credential-transitions/windows-package",
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_view_only_user_cannot_download_package(self):
|
||||
self.authenticate(("agents.view",))
|
||||
@@ -180,6 +232,35 @@ class WindowsPackageRouteTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
self.assertEqual(enrollment["token"], TOKEN)
|
||||
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||
|
||||
def test_manager_downloads_distinct_existing_agent_upgrade_package(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions/windows-package",
|
||||
headers={"Origin": "http://testserver"},
|
||||
json={"token": TOKEN},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
self.assertIn(
|
||||
"MinerOpsAgent-Credential-Upgrade-Windows.zip",
|
||||
response.headers["content-disposition"],
|
||||
)
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
enrollment = json.loads(
|
||||
archive.read(f"{WINDOWS_PACKAGE_ROOT}/enrollment.json")
|
||||
)
|
||||
instructions = archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
|
||||
).decode("utf-8")
|
||||
self.assertEqual(
|
||||
enrollment["workflow"],
|
||||
"existing_agent_credential_transition",
|
||||
)
|
||||
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
|
||||
|
||||
def test_package_requires_browser_origin(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
@@ -90,6 +90,23 @@ class FakeRepository:
|
||||
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||
}
|
||||
|
||||
def get_agent_configuration(self, agent_id):
|
||||
assignment = self.get_agent_assignment(agent_id)
|
||||
if not assignment:
|
||||
return None
|
||||
zone_row = self.zones.get(assignment.get("agent_zone_id"))
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"site_id": assignment.get("site_id"),
|
||||
"agent_zone_id": assignment.get("agent_zone_id"),
|
||||
"site_name": assignment.get("site_name"),
|
||||
"agent_zone_name": zone_row["name"] if zone_row else None,
|
||||
"agent_zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||
"networks": [
|
||||
item["network"] for item in (zone_row or {}).get("networks", [])
|
||||
],
|
||||
}
|
||||
|
||||
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
|
||||
self.assigned = (list(miner_ids), agent_zone_id)
|
||||
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
|
||||
@@ -218,6 +235,25 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||
self.assertIsNone(result.site_id)
|
||||
|
||||
def test_configuration_uses_only_authenticated_agent_zone_networks(self):
|
||||
self.repository.zones[ZONE_A]["networks"] = [
|
||||
{"id": uuid4(), "network": "10.30.0.0/24"}
|
||||
]
|
||||
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
|
||||
self.repository.zones[ZONE_B]["networks"] = [
|
||||
{"id": uuid4(), "network": "10.80.0.0/24"}
|
||||
]
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
|
||||
configuration = self.service.configuration("agent-a")
|
||||
|
||||
self.assertEqual(configuration["agent_zone_id"], ZONE_A)
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in configuration["ip_ranges"]],
|
||||
["10.30.0.0/24"],
|
||||
)
|
||||
self.assertNotIn("10.80.0.0/24", str(configuration))
|
||||
|
||||
|
||||
class AgentZoneRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
from unittest.mock import patch
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||
@@ -10,6 +11,8 @@ from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.config import settings
|
||||
from app.miner_control.repository import MinerControlRepository
|
||||
from app.miner_control.service import MinerControlService
|
||||
from app.miner_control.exceptions import MinerControlUnavailable
|
||||
from app.agent_commands.policy import PHYSICAL_CONTROL_DISABLED
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001630")
|
||||
@@ -34,6 +37,29 @@ def connect():
|
||||
|
||||
|
||||
class AgentZonePostgresTests(unittest.TestCase):
|
||||
def assert_stage_a_unavailable(self, expected_zone, expected_agent):
|
||||
repository = MinerControlRepository(connect)
|
||||
context = repository.get_control_context(MINER_UNASSIGNED)
|
||||
# Check actual routing facts independently of the unconditional Stage A
|
||||
# reason. A changed assertion must not hide a Site fallback regression.
|
||||
self.assertEqual(context["agent_zone_id"], expected_zone)
|
||||
self.assertEqual(context["agent_id"], expected_agent)
|
||||
projection = MinerControlService(repository).get_controls(MINER_UNASSIGNED)
|
||||
for capability in (projection.service_control, projection.performance_mode_control):
|
||||
self.assertEqual(capability.state, "unavailable")
|
||||
self.assertEqual(capability.reason, PHYSICAL_CONTROL_DISABLED)
|
||||
self.assertIsNone(projection.available_service_action)
|
||||
with patch.object(repository, "enqueue_with_audit") as enqueue:
|
||||
for action, value in (("suspend", None), ("resume", None), ("performance_mode", "high")):
|
||||
with self.assertRaisesRegex(MinerControlUnavailable, PHYSICAL_CONTROL_DISABLED):
|
||||
MinerControlService(repository).queue_control(
|
||||
MINER_UNASSIGNED, action, requested_value=value,
|
||||
actor_user_id=UUID("00000000-0000-4000-8000-000000001639"),
|
||||
actor_username="zone-containment-test",
|
||||
)
|
||||
enqueue.assert_not_called()
|
||||
return context
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
connection = connect()
|
||||
@@ -203,10 +229,8 @@ class AgentZonePostgresTests(unittest.TestCase):
|
||||
finally:
|
||||
connection.close()
|
||||
try:
|
||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||
MINER_UNASSIGNED
|
||||
)
|
||||
self.assertEqual(projection.service_control.reason, "agent_zone_disabled")
|
||||
context = self.assert_stage_a_unavailable(ZONE_A, AGENT_A)
|
||||
self.assertFalse(context["agent_zone_enabled"])
|
||||
finally:
|
||||
connection = connect()
|
||||
try:
|
||||
@@ -228,16 +252,10 @@ class AgentZonePostgresTests(unittest.TestCase):
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||
MINER_UNASSIGNED
|
||||
)
|
||||
self.assertEqual(projection.service_control.reason, "agent_unavailable")
|
||||
self.assert_stage_a_unavailable(ZONE_C, None)
|
||||
|
||||
def test_null_zone_does_not_fall_back_to_site_agent(self):
|
||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||
MINER_UNASSIGNED
|
||||
)
|
||||
self.assertEqual(projection.service_control.reason, "agent_zone_unassigned")
|
||||
self.assert_stage_a_unavailable(None, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Full HTTP/FastAPI plus PostgreSQL contract for the unchanged legacy Agent."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001a01")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001a02")
|
||||
ZONE_C = UUID("00000000-0000-4000-8000-000000001a03")
|
||||
MINER_A = UUID("00000000-0000-4000-8000-000000001a11")
|
||||
MINER_B = UUID("00000000-0000-4000-8000-000000001a12")
|
||||
LEGACY_AGENT = "legacy-plexus-http"
|
||||
ENROLLED_AGENT = "enrolled-http-agent"
|
||||
REVOKED_AGENT = "revoked-http-agent"
|
||||
ENROLLED_SECRET = "enrolled-http-agent-secret"
|
||||
REVOKED_SECRET = "revoked-http-agent-secret"
|
||||
ZONE_A_NETWORK = "10.31.0.0/24"
|
||||
ZONE_B_NETWORK = "10.81.0.0/24"
|
||||
ZONE_C_NETWORK = "10.82.0.0/24"
|
||||
|
||||
|
||||
def connect():
|
||||
from app.agent_auth.repository import default_connection_factory
|
||||
|
||||
return default_connection_factory()
|
||||
|
||||
|
||||
class LegacyAgentHttpPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"full-stack legacy contract requires its disposable runner"
|
||||
)
|
||||
if settings.minerops_agent_id != LEGACY_AGENT:
|
||||
raise AssertionError("runner did not bind the configured legacy identity")
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_zone_id
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
cls.zone_a = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM agent_zones
|
||||
WHERE site_id = %s AND name = 'Default Zone'
|
||||
""",
|
||||
(SITE_B,),
|
||||
)
|
||||
cls.zone_b = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES
|
||||
(%s, %s::cidr),
|
||||
(%s, %s::cidr)
|
||||
""",
|
||||
(cls.zone_a, ZONE_A_NETWORK, cls.zone_b, ZONE_B_NETWORK),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name)
|
||||
VALUES (%s, %s, 'Revoked Contract Zone')
|
||||
""",
|
||||
(ZONE_C, SITE_B),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, %s::cidr)
|
||||
""",
|
||||
(ZONE_C, ZONE_C_NETWORK),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name, display_name,
|
||||
agent_secret_hash, credential_created_at,
|
||||
credential_revoked_at, legacy_auth_disabled_at
|
||||
) VALUES
|
||||
(%s, %s, %s, 'Contract Site B', 'Enrolled Agent',
|
||||
%s, now(), NULL, now()),
|
||||
(%s, %s, %s, 'Contract Site B', 'Revoked Agent',
|
||||
%s, now() - interval '1 hour', now(),
|
||||
now() - interval '1 hour')
|
||||
""",
|
||||
(
|
||||
ENROLLED_AGENT,
|
||||
SITE_B,
|
||||
cls.zone_b,
|
||||
hash_agent_secret(ENROLLED_SECRET),
|
||||
REVOKED_AGENT,
|
||||
SITE_B,
|
||||
ZONE_C,
|
||||
hash_agent_secret(REVOKED_SECRET),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# Deliberately do not enter TestClient as a context manager. That keeps
|
||||
# unrelated application startup jobs out of this focused HTTP contract
|
||||
# while exercising the real ASGI middleware, dependencies, and routes.
|
||||
cls.client = TestClient(app)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client.close()
|
||||
|
||||
@staticmethod
|
||||
def legacy_headers():
|
||||
return {"X-MinerOps-Agent-Secret": settings.minerops_agent_shared_secret}
|
||||
|
||||
@staticmethod
|
||||
def agent_headers(agent_id, secret):
|
||||
return {
|
||||
"X-MinerOps-Agent-ID": agent_id,
|
||||
"X-MinerOps-Agent-Secret": secret,
|
||||
}
|
||||
|
||||
def assert_no_credentials(self, response):
|
||||
lowered = response.text.lower()
|
||||
for forbidden in (
|
||||
"agent_secret_hash", "credential_created_at", "token_hash",
|
||||
settings.minerops_agent_shared_secret.lower(),
|
||||
ENROLLED_SECRET.lower(), REVOKED_SECRET.lower(),
|
||||
):
|
||||
self.assertNotIn(forbidden, lowered)
|
||||
|
||||
def test_10_secret_only_config_is_legacy_identity_and_zone_bound(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/config?agent_id=" + ENROLLED_AGENT
|
||||
+ "&agent_zone_id=" + str(self.zone_b),
|
||||
headers=self.legacy_headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["agent_id"], LEGACY_AGENT)
|
||||
self.assertEqual(payload["agent_zone_id"], str(self.zone_a))
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in payload["ip_ranges"]],
|
||||
[ZONE_A_NETWORK],
|
||||
)
|
||||
self.assertNotIn(ZONE_B_NETWORK, response.text)
|
||||
self.assertNotIn(ZONE_C_NETWORK, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_20_known_miners_returns_only_migrated_legacy_zone(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/known-miners", headers=self.legacy_headers()
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(
|
||||
[item["miner_id"] for item in response.json()["miners"]],
|
||||
[str(MINER_A)],
|
||||
)
|
||||
self.assertNotIn(str(MINER_B), response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_30_exact_old_checkin_shape_succeeds_and_spoofing_fails(self):
|
||||
old_body = {
|
||||
"agent_id": LEGACY_AGENT,
|
||||
"site_name": "Plexus",
|
||||
"hostname": "legacy-contract-host",
|
||||
"version": "0.4-command-queue",
|
||||
"ip_address": "",
|
||||
"local_time": "2026-08-31T00:00:00Z",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/api/agent/checkin", headers=self.legacy_headers(), json=old_body
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.json()["agent_id"], LEGACY_AGENT)
|
||||
self.assertEqual(response.json()["agent_zone_id"], str(self.zone_a))
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
spoofed = self.client.post(
|
||||
"/api/agent/checkin?agent_id=" + ENROLLED_AGENT,
|
||||
headers=self.legacy_headers(),
|
||||
json={**old_body, "agent_id": "body-spoof-agent"},
|
||||
)
|
||||
self.assertEqual(spoofed.status_code, 422, spoofed.text)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM minerops_site_agents WHERE agent_id = %s",
|
||||
("body-spoof-agent",),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], 0)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_40_global_secret_rejects_unknown_and_enrolled_explicit_ids(self):
|
||||
for agent_id, expected_status in (
|
||||
("unknown-agent", 403),
|
||||
(ENROLLED_AGENT, 401),
|
||||
):
|
||||
with self.subTest(agent_id=agent_id):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, expected_status, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_50_enrolled_agent_uses_per_agent_secret_and_own_zone(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(ENROLLED_AGENT, ENROLLED_SECRET),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["agent_id"], ENROLLED_AGENT)
|
||||
self.assertEqual(payload["agent_zone_id"], str(self.zone_b))
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in payload["ip_ranges"]],
|
||||
[ZONE_B_NETWORK],
|
||||
)
|
||||
self.assertNotIn(ZONE_A_NETWORK, response.text)
|
||||
self.assertNotIn(ZONE_C_NETWORK, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_60_revoked_credential_cannot_use_per_agent_or_global_secret(self):
|
||||
for secret in (REVOKED_SECRET, settings.minerops_agent_shared_secret):
|
||||
with self.subTest(secret_kind="global" if secret != REVOKED_SECRET else "agent"):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(REVOKED_AGENT, secret),
|
||||
)
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_90_durably_retired_legacy_identity_loses_secret_only_fallback(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET legacy_auth_disabled_at = now()
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
response = self.client.get(
|
||||
"/api/agent/config", headers=self.legacy_headers()
|
||||
)
|
||||
self.assertEqual(response.status_code, 401, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Focused source-level contracts for legacy Agent migrations."""
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
VERSIONS = Path(__file__).parents[1] / "alembic" / "versions"
|
||||
|
||||
|
||||
def load_migration(filename):
|
||||
path = VERSIONS / filename
|
||||
spec = importlib.util.spec_from_file_location(filename.removesuffix(".py"), path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class EmptyResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
|
||||
class RecordingConnection:
|
||||
def __init__(self):
|
||||
self.statements = []
|
||||
|
||||
def execute(self, statement, parameters=None):
|
||||
del parameters
|
||||
self.statements.append(str(statement))
|
||||
return EmptyResult()
|
||||
|
||||
|
||||
class LegacyRoutingMigrationTests(unittest.TestCase):
|
||||
def test_0018_locks_every_preflight_and_update_table_before_reads(self):
|
||||
migration = load_migration("0018_legacy_agent_routing.py")
|
||||
connection = RecordingConnection()
|
||||
with patch.object(migration.op, "get_bind", return_value=connection):
|
||||
migration.upgrade()
|
||||
|
||||
first = " ".join(connection.statements[0].split()).lower()
|
||||
self.assertTrue(first.startswith("lock table"))
|
||||
self.assertIn("in share row exclusive mode", first)
|
||||
for table in (
|
||||
"agent_zones", "minerops_agent_discoveries",
|
||||
"minerops_agent_snapshots", "minerops_site_agents", "sites",
|
||||
):
|
||||
with self.subTest(table=table):
|
||||
self.assertIn(table, first)
|
||||
|
||||
first_update = next(
|
||||
index for index, statement in enumerate(connection.statements)
|
||||
if "UPDATE " in statement
|
||||
)
|
||||
self.assertEqual(first_update, 4)
|
||||
|
||||
def test_0019_is_the_direct_forward_only_successor(self):
|
||||
migration = load_migration("0019_agent_legacy_auth_retire.py")
|
||||
self.assertEqual(migration.revision, "0019_agent_legacy_auth_retire")
|
||||
self.assertEqual(migration.down_revision, "0018_legacy_agent_routing")
|
||||
|
||||
def test_0020_adds_only_existing_identity_transition_binding(self):
|
||||
migration = load_migration(
|
||||
"0020_existing_agent_credential_transition.py"
|
||||
)
|
||||
self.assertEqual(migration.revision, "0020_existing_agent_transition")
|
||||
self.assertEqual(
|
||||
migration.down_revision,
|
||||
"0019_agent_legacy_auth_retire",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Fresh-chain production compatibility contract for the legacy Plexus Agent."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.config import settings
|
||||
from app.main import (
|
||||
minerops_agent_known_miners,
|
||||
minerops_agent_miner_snapshots,
|
||||
)
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001901")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001902")
|
||||
MINER_A = UUID("00000000-0000-4000-8000-000000001911")
|
||||
MINER_B = UUID("00000000-0000-4000-8000-000000001912")
|
||||
LEGACY_AGENT = "legacy-plexus-production"
|
||||
NEW_AGENT = "enrolled-agent-production-contract"
|
||||
NEW_SECRET = "new-agent-production-contract-secret"
|
||||
|
||||
|
||||
def connect():
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
def json_request(payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
delivered = False
|
||||
|
||||
async def receive():
|
||||
nonlocal delivered
|
||||
if delivered:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
delivered = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "http",
|
||||
"path": "/api/agent/miner-snapshots",
|
||||
"raw_path": b"/api/agent/miner-snapshots",
|
||||
"query_string": b"",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("testclient", 12345),
|
||||
"server": ("testserver", 80),
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
class LegacyAgentProductionPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_PRODUCTION_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"Legacy production contract requires its disposable PostgreSQL runner"
|
||||
)
|
||||
if settings.minerops_agent_id != LEGACY_AGENT:
|
||||
raise AssertionError("Runner did not bind the configured legacy identity")
|
||||
|
||||
cls.auth = AgentAuthService(AgentAuthRepository(connect))
|
||||
cls.zones = AgentZoneService(AgentZoneRepository(connect))
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_zone_id
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
cls.zone_a = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM agent_zones
|
||||
WHERE site_id = %s AND name = 'Default Zone'
|
||||
""",
|
||||
(SITE_B,),
|
||||
)
|
||||
cls.zone_b = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name,
|
||||
agent_secret_hash, credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, 'Enrolled Contract Agent', %s, now(), now()
|
||||
)
|
||||
""",
|
||||
(
|
||||
NEW_AGENT,
|
||||
SITE_B,
|
||||
cls.zone_b,
|
||||
hash_agent_secret(NEW_SECRET),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_secret_only_auth_is_bounded_to_legacy_identity(self):
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(None, settings.minerops_agent_shared_secret),
|
||||
LEGACY_AGENT,
|
||||
)
|
||||
with self.assertRaises(AgentIdentityUnauthorized):
|
||||
self.auth.authenticate(
|
||||
"unknown-agent", settings.minerops_agent_shared_secret
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.auth.authenticate(
|
||||
NEW_AGENT, settings.minerops_agent_shared_secret
|
||||
)
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(NEW_AGENT, NEW_SECRET),
|
||||
NEW_AGENT,
|
||||
)
|
||||
|
||||
def test_migrated_legacy_agent_has_correct_site_and_zone(self):
|
||||
routing = self.zones.routing_context(LEGACY_AGENT)
|
||||
self.assertIsNone(routing.routing_reason)
|
||||
self.assertEqual(routing.site_id, SITE_A)
|
||||
self.assertEqual(routing.agent_zone_id, self.zone_a)
|
||||
|
||||
def test_known_miners_and_telemetry_remain_zone_scoped(self):
|
||||
known = asyncio.run(
|
||||
minerops_agent_known_miners(LEGACY_AGENT, self.zones)
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["miner_id"] for row in known["miners"]],
|
||||
[str(MINER_A)],
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
minerops_agent_miner_snapshots(
|
||||
json_request(
|
||||
{
|
||||
"agent_id": LEGACY_AGENT,
|
||||
"site_name": "Plexus",
|
||||
"miners": [
|
||||
{
|
||||
"miner_id": str(MINER_A),
|
||||
"ip_address": "192.0.2.191",
|
||||
"online": False,
|
||||
},
|
||||
{
|
||||
"miner_id": str(MINER_B),
|
||||
"ip_address": "192.0.2.192",
|
||||
"online": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
LEGACY_AGENT,
|
||||
self.zones,
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["agent_zone_id"], str(self.zone_a))
|
||||
self.assertEqual(result["apply_result"]["applied"], 1)
|
||||
self.assertIn(
|
||||
{"miner_id": str(MINER_B), "error": "agent_zone_conflict"},
|
||||
result["apply_result"]["errors"],
|
||||
)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status FROM miners WHERE id = %s",
|
||||
(MINER_A,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], "offline")
|
||||
cur.execute(
|
||||
"SELECT status FROM miners WHERE id = %s",
|
||||
(MINER_B,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], "online")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM minerops_agent_snapshots
|
||||
WHERE agent_id = %s AND agent_zone_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT, self.zone_a),
|
||||
)
|
||||
self.assertGreaterEqual(cur.fetchone()[0], 1)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Focused regression tests for retired legacy miner-write surfaces."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main
|
||||
from app.identity import get_current_user, require_permission, required_api_permission
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||
|
||||
|
||||
class LegacyControlRetirementTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def assert_retired(callable_, *args, **kwargs):
|
||||
try:
|
||||
result = callable_(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
asyncio.run(result)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 410:
|
||||
raise AssertionError(f"Expected HTTP 410, got {exc.status_code}") from exc
|
||||
return
|
||||
raise AssertionError("Expected a fixed HTTP 410 retirement response")
|
||||
|
||||
def test_all_legacy_http_write_routes_are_fixed_inert_responses(self):
|
||||
routes = (
|
||||
(main.run_dashboard_m30_command_test_lab, {"role": "Admin"}),
|
||||
(main.retired_direct_reboot_miner_command, "miner-1", {"role": "Admin"}),
|
||||
(main.real_power_off_miner_command, "miner-1", {"role": "Admin"}),
|
||||
(main.real_power_on_miner_command, "miner-1", {"role": "Admin"}),
|
||||
(main.real_suspend_miner_command, "miner-1", {"role": "Admin"}),
|
||||
(main.real_resume_miner_command, "miner-1", {"role": "Admin"}),
|
||||
(main.retired_direct_api3_password_change, "miner-1", {"role": "Admin"}),
|
||||
(main.retired_legacy_agent_miner_control, "miner-1", {"role": "Admin"}),
|
||||
(main.process_due_dashboard_scheduled_bulk_actions, {"role": "Admin"}),
|
||||
(main.create_dashboard_scheduled_bulk_action, {"role": "Admin"}),
|
||||
(main.whatsminer_read_test, {"role": "Admin"}),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(main, "get_db_connection") as sync_database,
|
||||
patch.object(main, "minerops_command_db_connect") as async_database,
|
||||
patch.object(main, "insert_miner_command_event") as audit_write,
|
||||
patch.object(main, "minerops_send_whatsminer_reboot") as reboot_write,
|
||||
patch.object(main, "minerops_power_command") as power_write,
|
||||
patch.object(main, "minerops_api3_change_password") as password_write,
|
||||
):
|
||||
for route in routes:
|
||||
with self.subTest(route=route[0].__name__):
|
||||
self.assert_retired(route[0], *route[1:])
|
||||
|
||||
for forbidden_call in (
|
||||
sync_database,
|
||||
async_database,
|
||||
audit_write,
|
||||
reboot_write,
|
||||
power_write,
|
||||
password_write,
|
||||
):
|
||||
self.assertFalse(forbidden_call.called)
|
||||
|
||||
def test_m30_requires_control_permission_before_inert_response(self):
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"POST", "/api/dashboard/m30-command-test-lab/run"
|
||||
),
|
||||
"miners.control",
|
||||
)
|
||||
dependency = require_permission("miners.control")
|
||||
with self.assertRaises(HTTPException) as denied:
|
||||
dependency({"role": "Read Only", "permissions": []})
|
||||
self.assertEqual(denied.exception.status_code, 403)
|
||||
|
||||
route_source = inspect.getsource(main.run_dashboard_m30_command_test_lab)
|
||||
self.assertIn('Depends(require_permission("miners.control"))', route_source)
|
||||
self.assertIn("status_code=410", route_source)
|
||||
self.assertNotIn("minerops_agent_command_queue", route_source)
|
||||
self.assertNotIn("plexus-windows-agent", route_source)
|
||||
self.assertNotIn("dry_run", route_source)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_api_route(
|
||||
"/api/dashboard/m30-command-test-lab/run",
|
||||
main.run_dashboard_m30_command_test_lab,
|
||||
methods=["POST"],
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
app.dependency_overrides[get_current_user] = lambda: {
|
||||
"role": "Read Only",
|
||||
"permissions": [],
|
||||
}
|
||||
self.assertEqual(
|
||||
client.post("/api/dashboard/m30-command-test-lab/run").status_code,
|
||||
403,
|
||||
)
|
||||
app.dependency_overrides[get_current_user] = lambda: {
|
||||
"role": "Custom",
|
||||
"permissions": ["miners.control"],
|
||||
}
|
||||
self.assertEqual(
|
||||
client.post("/api/dashboard/m30-command-test-lab/run").status_code,
|
||||
410,
|
||||
)
|
||||
|
||||
def test_registered_retired_routes_have_no_direct_write_implementation(self):
|
||||
retired_paths = {
|
||||
"/api/dashboard/m30-command-test-lab/run",
|
||||
"/api/dashboard/scheduled-bulk-actions/process-due",
|
||||
"/api/dashboard/scheduled-bulk-actions",
|
||||
"/api/tools/whatsminer-read-test",
|
||||
"/api/miners/{miner_id}/commands/reboot",
|
||||
"/api/miners/{miner_id}/commands/power-off",
|
||||
"/api/miners/{miner_id}/commands/power-on",
|
||||
"/api/miners/{miner_id}/commands/suspend",
|
||||
"/api/miners/{miner_id}/commands/resume",
|
||||
"/api/miners/{miner_id}/api3/change-password",
|
||||
"/api/miners/{miner_id}/agent-control",
|
||||
}
|
||||
registered = [
|
||||
(route.path, route.endpoint)
|
||||
for route in main.app.routes
|
||||
if getattr(route, "path", None) in retired_paths
|
||||
and "POST" in getattr(route, "methods", set())
|
||||
]
|
||||
self.assertEqual({path for path, _ in registered}, retired_paths)
|
||||
self.assertEqual(len(registered), len(retired_paths))
|
||||
|
||||
forbidden = (
|
||||
"socket",
|
||||
"minerops_send_",
|
||||
"minerops_power_command",
|
||||
"minerops_api3_change_password",
|
||||
"insert_miner_command_event",
|
||||
"minerops_agent_command_queue",
|
||||
"minerops_command_db_connect",
|
||||
"get_db_connection",
|
||||
"request.json",
|
||||
)
|
||||
for path, endpoint in registered:
|
||||
source = inspect.getsource(endpoint)
|
||||
with self.subTest(path=path):
|
||||
self.assertIn("status_code=410", source)
|
||||
for unsafe_fragment in forbidden:
|
||||
self.assertNotIn(unsafe_fragment, source)
|
||||
|
||||
def test_legacy_scheduler_is_noop_and_not_registered_before_claim(self):
|
||||
with patch.object(main, "get_db_connection") as database:
|
||||
self.assertEqual(main.minerops_process_due_scheduled_bulk_actions(), [])
|
||||
self.assertFalse(database.called)
|
||||
self.assertFalse(hasattr(main.app.state, "agent_command_before_claim"))
|
||||
source = inspect.getsource(main.minerops_process_due_scheduled_bulk_actions)
|
||||
self.assertNotIn("minerops_agent_command_queue", source)
|
||||
self.assertNotIn("get_db_connection", source)
|
||||
|
||||
def test_every_retired_http_route_denies_unprivileged_users_and_never_writes(self):
|
||||
paths = {
|
||||
"/api/dashboard/m30-command-test-lab/run",
|
||||
"/api/dashboard/scheduled-bulk-actions",
|
||||
"/api/dashboard/scheduled-bulk-actions/process-due",
|
||||
"/api/tools/whatsminer-read-test",
|
||||
"/api/miners/{miner_id}/commands/reboot",
|
||||
"/api/miners/{miner_id}/commands/power-off",
|
||||
"/api/miners/{miner_id}/commands/power-on",
|
||||
"/api/miners/{miner_id}/commands/suspend",
|
||||
"/api/miners/{miner_id}/commands/resume",
|
||||
"/api/miners/{miner_id}/api3/change-password",
|
||||
"/api/miners/{miner_id}/agent-control",
|
||||
}
|
||||
app = FastAPI()
|
||||
for route in main.app.routes:
|
||||
if getattr(route, "path", None) in paths and "POST" in route.methods:
|
||||
app.add_api_route(route.path, route.endpoint, methods=["POST"])
|
||||
with TestClient(app) as client, patch.object(main.socket, "create_connection") as transport, patch.object(main, "get_db_connection") as database:
|
||||
for permissions, expected in (([], 403), (["miners.control", "credentials.manage"], 410)):
|
||||
app.dependency_overrides[get_current_user] = lambda: {
|
||||
"role": "Custom", "permissions": permissions,
|
||||
}
|
||||
for path in paths:
|
||||
concrete = path.replace("{miner_id}", "miner-1")
|
||||
self.assertEqual(client.post(concrete, json={"dry_run": False}).status_code, expected, concrete)
|
||||
transport.assert_not_called()
|
||||
database.assert_not_called()
|
||||
|
||||
def test_diagnostic_write_disguises_overrides_and_api3_are_retired(self):
|
||||
app = FastAPI()
|
||||
app.add_api_route("/api/tools/whatsminer-read-test", main.whatsminer_read_test, methods=["POST"])
|
||||
app.dependency_overrides[get_current_user] = lambda: {"role": "Custom", "permissions": ["miners.control"]}
|
||||
attacks = (
|
||||
{"command": "reboot"}, {"command": "read_then_reboot"},
|
||||
{"command": "summary", "params": {"command": "reboot"}},
|
||||
{"command": "summary", "params": {"cmd": "set.miner.service", "param": "stop"}},
|
||||
{"port": 4433, "command": "set.miner.power_mode", "params": {"param": "high"}},
|
||||
{"command": "summary\n{\"command\":\"reboot\"}"},
|
||||
{"command": "summary"},
|
||||
)
|
||||
with TestClient(app) as client, patch.object(main.socket, "create_connection") as transport, patch.object(main, "send_whatsminer_read_command") as sender:
|
||||
for attack in attacks:
|
||||
self.assertEqual(client.post("/api/tools/whatsminer-read-test", json={"ip_address": "192.0.2.1", **attack}).status_code, 410)
|
||||
transport.assert_not_called()
|
||||
sender.assert_not_called()
|
||||
|
||||
def test_internal_discovery_helper_cannot_become_a_generic_command_gateway(self):
|
||||
with patch.object(main.socket, "create_connection") as transport:
|
||||
for command, port, params in (
|
||||
("reboot", 4028, None), ("read_reboot", 4028, None),
|
||||
("summary", 4433, None), ("set.miner.service", 4433, {"param": "stop"}),
|
||||
("summary", 4028, {"command": "reboot"}),
|
||||
("summary", 4028, {"cmd": "set.miner.service"}),
|
||||
("get_miner_info", 4028, {"info": "arbitrary"}),
|
||||
):
|
||||
self.assert_retired(main.send_whatsminer_read_command, "192.0.2.1", port, command, 500, params)
|
||||
transport.assert_not_called()
|
||||
|
||||
def test_old_agent_aliases_are_inert_even_without_authentication(self):
|
||||
app = FastAPI()
|
||||
app.add_api_route("/api/agent/commands/pending-disabled-old", main.minerops_agent_commands_pending_retired, methods=["GET"])
|
||||
app.add_api_route("/api/agent/commands/result-disabled-old", main.minerops_agent_commands_result_retired, methods=["POST"])
|
||||
with TestClient(app) as client, patch.object(main, "minerops_command_db_connect") as database:
|
||||
self.assertEqual(client.get("/api/agent/commands/pending-disabled-old").status_code, 410)
|
||||
self.assertEqual(client.post("/api/agent/commands/result-disabled-old", json={"status": "completed"}).status_code, 410)
|
||||
database.assert_not_called()
|
||||
|
||||
def test_canonical_control_still_derives_agent_from_miner_zone(self):
|
||||
repository_source = (
|
||||
APP_ROOT / "miner_control" / "repository.py"
|
||||
).read_text()
|
||||
self.assertIn("miners.agent_zone_id", repository_source)
|
||||
self.assertIn(
|
||||
"agents.agent_zone_id = miners.agent_zone_id",
|
||||
repository_source,
|
||||
)
|
||||
self.assertNotIn("agents.site_id = miners.site_id", repository_source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -71,64 +71,47 @@ class FakeRepository:
|
||||
|
||||
|
||||
class MinerControlServiceTests(unittest.TestCase):
|
||||
def test_supported_projection_is_factual_and_bounded(self):
|
||||
def test_disabled_projection_preserves_facts_and_bounded_history(self):
|
||||
repository = FakeRepository()
|
||||
result = MinerControlService(repository).get_controls(MINER_ID)
|
||||
self.assertEqual(result.service_control.state, "supported")
|
||||
self.assertEqual(result.service_control.state, "unavailable")
|
||||
self.assertEqual(result.service_control.reason, "physical_control_disabled_stage_a")
|
||||
self.assertEqual(result.performance_mode, "normal")
|
||||
self.assertEqual(result.available_service_action, "suspend")
|
||||
self.assertIsNone(result.available_service_action)
|
||||
self.assertEqual(repository.limits, [5])
|
||||
|
||||
def test_capability_missing_facts_is_not_optimistic(self):
|
||||
cases = (
|
||||
({"agent_zone_id": None}, "unavailable", "agent_zone_unassigned"),
|
||||
({"agent_zone_exists": False}, "unavailable", "agent_zone_missing"),
|
||||
({"agent_zone_enabled": False}, "unavailable", "agent_zone_disabled"),
|
||||
({"agent_id": None}, "unavailable", "agent_unavailable"),
|
||||
({"current_ip": None}, "unavailable", "current_ip_missing"),
|
||||
({"api3_supported": False}, "unavailable", "api3_unsupported"),
|
||||
({"api3_supported": None}, "unknown", "api3_support_unknown"),
|
||||
({"last_payload": {}}, "unknown", "agent_capability_unknown"),
|
||||
(
|
||||
{"last_payload": {"control_available": False,
|
||||
"control_unavailable_reason": "credentials_missing"}},
|
||||
"unavailable", "credentials_missing",
|
||||
),
|
||||
)
|
||||
for changes, state, reason in cases:
|
||||
with self.subTest(reason=reason):
|
||||
result = MinerControlService(FakeRepository(context(**changes))).get_controls(MINER_ID)
|
||||
self.assertEqual(result.service_control.state, state)
|
||||
self.assertEqual(result.service_control.reason, reason)
|
||||
def test_capability_cannot_be_enabled_by_agent_claims(self):
|
||||
for facts in ({}, {"control_available": True}, {"control_available": False}):
|
||||
repository = FakeRepository(context(last_payload=facts))
|
||||
result = MinerControlService(repository).get_controls(MINER_ID)
|
||||
self.assertEqual(result.service_control.state, "unavailable")
|
||||
self.assertEqual(result.service_control.reason, "physical_control_disabled_stage_a")
|
||||
|
||||
def test_resume_requires_known_stopped_state(self):
|
||||
service = MinerControlService(FakeRepository(context(runtime_state="offline")))
|
||||
with self.assertRaisesRegex(MinerControlUnavailable, "factually_stopped"):
|
||||
service.queue_control(
|
||||
MINER_ID, "resume", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="operator",
|
||||
)
|
||||
def test_resume_is_disabled_even_for_known_stopped_state(self):
|
||||
service = MinerControlService(FakeRepository(context(runtime_state="stopped")))
|
||||
with self.assertRaisesRegex(MinerControlUnavailable, "physical_control_disabled_stage_a"):
|
||||
service.queue_control(MINER_ID, "resume", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="operator")
|
||||
|
||||
def test_queue_contains_routing_but_audit_and_response_do_not(self):
|
||||
def test_all_current_actions_are_rejected_before_routing_or_queue_write(self):
|
||||
for action in ("suspend", "resume", "performance_mode"):
|
||||
repository = FakeRepository()
|
||||
with self.assertRaisesRegex(MinerControlUnavailable, "physical_control_disabled_stage_a"):
|
||||
MinerControlService(repository).queue_control(
|
||||
MINER_ID, action, requested_value="high",
|
||||
actor_user_id=USER_ID, actor_username="operator")
|
||||
self.assertEqual(repository.enqueue_calls, [])
|
||||
self.assertFalse(hasattr(repository, "requested_miner"))
|
||||
|
||||
def test_rejected_request_leaves_context_unchanged(self):
|
||||
repository = FakeRepository()
|
||||
result = MinerControlService(repository).queue_control(
|
||||
MINER_ID, "performance_mode", requested_value="high",
|
||||
actor_user_id=USER_ID, actor_username="operator",
|
||||
)
|
||||
call = repository.enqueue_calls[0]
|
||||
self.assertEqual(call["request_payload"]["ip_address"], "192.0.2.10")
|
||||
self.assertNotIn("ip_address", call["audit_payload"])
|
||||
self.assertNotIn("agent_id", call["audit_payload"])
|
||||
self.assertNotIn("password", str(call).lower())
|
||||
self.assertNotIn("192.0.2.10", result.model_dump_json())
|
||||
|
||||
def test_no_projection_changes_when_work_is_queued(self):
|
||||
repository = FakeRepository()
|
||||
MinerControlService(repository).queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="operator",
|
||||
)
|
||||
self.assertFalse(any("update" in key for key in repository.enqueue_calls[0]))
|
||||
before = repository.miner_context.copy()
|
||||
with self.assertRaises(MinerControlUnavailable):
|
||||
MinerControlService(repository).queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="operator")
|
||||
self.assertEqual(repository.miner_context, before)
|
||||
self.assertEqual(repository.enqueue_calls, [])
|
||||
|
||||
|
||||
class MinerControlRouterTests(unittest.TestCase):
|
||||
@@ -159,18 +142,16 @@ class MinerControlRouterTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["miner_id"], str(MINER_ID))
|
||||
|
||||
def test_posts_require_control_and_return_202_bounded_envelope(self):
|
||||
path = f"/api/miners/{MINER_ID}/controls/suspend"
|
||||
self.authenticate(("miners.view",))
|
||||
self.assertEqual(self.client.post(path, json={}).status_code, 403)
|
||||
self.authenticate(("miners.control",))
|
||||
response = self.client.post(path, json={})
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(set(response.json()["data"]), {
|
||||
"operation_id", "miner_id", "action", "requested_value", "status", "created_at"
|
||||
})
|
||||
self.assertNotIn("ip", response.text.lower())
|
||||
self.assertNotIn("agent", response.text.lower())
|
||||
def test_posts_require_control_and_then_fail_closed(self):
|
||||
for action, body in (("suspend", {}), ("resume", {}), ("performance-mode", {"mode": "high"})):
|
||||
path = f"/api/miners/{MINER_ID}/controls/{action}"
|
||||
self.authenticate(("miners.view",))
|
||||
self.assertEqual(self.client.post(path, json=body).status_code, 403)
|
||||
self.authenticate(("miners.control",))
|
||||
response = self.client.post(path, json=body)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertIn("physical_control_disabled_stage_a", response.text)
|
||||
self.assertEqual(self.repository.enqueue_calls, [])
|
||||
|
||||
def test_strict_bodies_reject_routing_commands_and_credentials(self):
|
||||
self.authenticate(("miners.control",))
|
||||
@@ -195,7 +176,7 @@ class MinerControlRouterTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.post(path, json={}, headers={"Origin": "http://testserver"}).status_code,
|
||||
202,
|
||||
409,
|
||||
)
|
||||
|
||||
def test_identity_permission_mapping_is_specific(self):
|
||||
|
||||
@@ -137,7 +137,7 @@ class MinerControlPostgresTests(unittest.TestCase):
|
||||
"""
|
||||
INSERT INTO minerops_agent_command_queue (
|
||||
id, agent_id, command_type, command_status, request_payload, created_at
|
||||
) VALUES (%s, %s, 'miner_control', %s, %s, COALESCE(%s, now()))
|
||||
) VALUES (%s, %s, 'poll_known_miners_now', %s, %s, COALESCE(%s, now()))
|
||||
""",
|
||||
(command_id, AGENT_ID, status,
|
||||
Jsonb({"schema_version": 1, "miner_id": str(miner_id),
|
||||
@@ -199,13 +199,22 @@ class MinerControlPostgresTests(unittest.TestCase):
|
||||
service.submit_result(AGENT_ID, pending)
|
||||
|
||||
def test_atomic_queue_audit_duplicate_live_and_completed_history(self):
|
||||
service = MinerControlService(MinerControlRepository(connect))
|
||||
first = service.queue_control(
|
||||
repository = MinerControlRepository(connect)
|
||||
def queue_control(miner_id, action, *, requested_value, actor_user_id, actor_username):
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
return SimpleNamespace(**repository.enqueue_with_audit(
|
||||
operation_id=str(uuid4()), miner_id=miner_id, agent_id=AGENT_ID,
|
||||
actor_user_id=actor_user_id, actor_username=actor_username,
|
||||
request_payload={"schema_version": 1, "miner_id": str(miner_id),
|
||||
"action": action, "requested_value": requested_value},
|
||||
audit_payload={"miner_id": str(miner_id), "action": action}))
|
||||
first = queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||
)
|
||||
with self.assertRaises(MinerControlConflict):
|
||||
service.queue_control(
|
||||
queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||
)
|
||||
@@ -223,17 +232,26 @@ class MinerControlPostgresTests(unittest.TestCase):
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
second = service.queue_control(
|
||||
second = queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||
)
|
||||
self.assertNotEqual(first.operation_id, second.operation_id)
|
||||
|
||||
def test_audit_failure_rolls_back_queue_row(self):
|
||||
service = MinerControlService(MinerControlRepository(connect))
|
||||
repository = MinerControlRepository(connect)
|
||||
def queue_control(miner_id, action, *, requested_value, actor_user_id, actor_username):
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
return SimpleNamespace(**repository.enqueue_with_audit(
|
||||
operation_id=str(uuid4()), miner_id=miner_id, agent_id=AGENT_ID,
|
||||
actor_user_id=actor_user_id, actor_username=actor_username,
|
||||
request_payload={"schema_version": 1, "miner_id": str(miner_id),
|
||||
"action": action, "requested_value": requested_value},
|
||||
audit_payload={"miner_id": str(miner_id), "action": action}))
|
||||
operation_count_before = self._operation_count()
|
||||
with self.assertRaises(Exception):
|
||||
service.queue_control(
|
||||
queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=UUID("00000000-0000-4000-8000-000000009999"),
|
||||
actor_username="missing-user",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Defense-in-depth unit tests; no physical miner or external service is used."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app import main
|
||||
from app.agent_commands.exceptions import AgentCommandConflict
|
||||
from app.agent_commands.models import AgentCommandResultRequest
|
||||
from app.agent_commands.policy import rejection_reason
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
|
||||
|
||||
class StageAContainmentTests(unittest.TestCase):
|
||||
def test_service_dispatch_rejects_physical_and_unknown_rows_even_if_repository_regresses(self):
|
||||
repository = Mock()
|
||||
repository.claim_pending.return_value = [
|
||||
{"command_id": str(index), "command_type": kind, "request_payload": payload,
|
||||
"created_at": datetime.now(timezone.utc)}
|
||||
for index, (kind, payload) in enumerate((
|
||||
("miner_control", {"schema_version": 1, "action": "suspend"}),
|
||||
("miner_control", {"schema_version": 999}),
|
||||
("miner_control_v2", {}), ("raw_api3", {}),
|
||||
("poll_known_miners_now", {}),
|
||||
))
|
||||
]
|
||||
result = AgentCommandService(repository).claim("simulated-agent")
|
||||
self.assertEqual([row.command_type for row in result], ["poll_known_miners_now"])
|
||||
|
||||
def test_service_result_rejection_does_not_depend_on_payload_validation(self):
|
||||
for kind in ("miner_control", "raw_api3", "miner_control_v2"):
|
||||
repository = Mock()
|
||||
repository.get_command.return_value = {"id": "old-command", "agent_id": "simulated-agent",
|
||||
"command_type": kind, "command_status": "claimed", "request_payload": {}}
|
||||
with self.assertRaises(AgentCommandConflict):
|
||||
AgentCommandService(repository).submit_result("simulated-agent",
|
||||
AgentCommandResultRequest(command_id="old-command", status="completed",
|
||||
result_payload={"schema_version": 999, "command_accepted": True}))
|
||||
args = repository.finish_command.call_args.args
|
||||
self.assertEqual(args[2:4], ("cancelled", {}))
|
||||
self.assertEqual(args[4], rejection_reason(kind))
|
||||
|
||||
def test_dormant_legacy_helpers_cannot_transmit_or_queue_if_called_internally(self):
|
||||
names = (
|
||||
"dry_run_reboot_miner_command", "change_miner_api3_password",
|
||||
"minerops_queue_agent_miner_control", "minerops_power_command",
|
||||
"minerops_send_whatsminer_reboot_sync", "minerops_send_whatsminer_reboot",
|
||||
"minerops_send_whatsminer_command_sync", "minerops_api3_send_payload_sync",
|
||||
"minerops_api3_service_sync", "minerops_api3_service",
|
||||
"minerops_send_control_command_item", "minerops_send_whatsminer_command_sequence",
|
||||
"minerops_send_whatsminer_command", "minerops_api3_service_sync_with_credentials",
|
||||
"minerops_api3_service_with_credentials", "minerops_api3_change_password_sync",
|
||||
"minerops_api3_change_password",
|
||||
)
|
||||
with patch.object(main.socket, "create_connection") as transport, patch.object(main, "get_db_connection") as database, patch.object(main, "minerops_command_db_connect") as async_database:
|
||||
for name in names:
|
||||
function = getattr(main, name)
|
||||
required = ["simulated" for parameter in inspect.signature(function).parameters.values()
|
||||
if parameter.default is inspect.Parameter.empty]
|
||||
with self.subTest(name=name), self.assertRaises(HTTPException) as rejected:
|
||||
result = function(*required)
|
||||
if inspect.isawaitable(result):
|
||||
asyncio.run(result)
|
||||
self.assertEqual(rejected.exception.status_code, 410)
|
||||
transport.assert_not_called()
|
||||
database.assert_not_called()
|
||||
async_database.assert_not_called()
|
||||
|
||||
def test_fixed_internal_discovery_queries_still_use_only_allowlisted_selection(self):
|
||||
parameters = {"get_miner_info": {"info": "ip,proto,netmask,gateway,dns,hostname,mac,ledstat,minersn,powersn"},
|
||||
"get_version": {}, "summary": {}, "edevs": {}, "pools": {}}
|
||||
for command, params in parameters.items():
|
||||
with patch.object(main.socket, "create_connection") as transport:
|
||||
socket = transport.return_value.__enter__.return_value
|
||||
socket.recv.side_effect = [b'{"STATUS":[]}', b'']
|
||||
result = main.send_whatsminer_read_command("192.0.2.1", 4028, command, 500, params)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(socket.sendall.call_count, 1)
|
||||
import json
|
||||
transmitted = json.loads(socket.sendall.call_args.args[0])
|
||||
self.assertEqual(transmitted, {"command": command, **params})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Stage A queue containment; requires an explicitly disposable PostgreSQL DB."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_commands.exceptions import AgentCommandConflict
|
||||
from app.agent_commands.models import AgentCommandResultRequest
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
AGENT = "stage-a-simulated-agent"
|
||||
OTHER_AGENT = "stage-a-other-agent"
|
||||
|
||||
|
||||
def connect():
|
||||
return psycopg.connect(host=settings.postgres_host, port=settings.postgres_port,
|
||||
dbname=settings.postgres_db, user=settings.postgres_user,
|
||||
password=settings.postgres_password, connect_timeout=5)
|
||||
|
||||
|
||||
class StageAQueueContainmentTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_STAGE_A_DISPOSABLE") != "1" or not settings.postgres_db.startswith("minerops_stage_a_"):
|
||||
raise RuntimeError("Refusing queue tests outside an explicitly disposable Stage A database")
|
||||
|
||||
def setUp(self):
|
||||
self.transport_patch = patch("socket.create_connection", side_effect=AssertionError("No miner transport allowed"))
|
||||
self.transport = self.transport_patch.start()
|
||||
self.addCleanup(self.transport_patch.stop)
|
||||
self.addCleanup(self.transport.assert_not_called)
|
||||
with connect() as connection, connection.cursor() as cur:
|
||||
cur.execute("DELETE FROM audit_logs WHERE entity_type = 'agent_command' AND old_value_json ->> 'agent_id' IN (%s, %s)", (AGENT, OTHER_AGENT))
|
||||
cur.execute("DELETE FROM minerops_agent_command_queue WHERE agent_id IN (%s, %s)", (AGENT, OTHER_AGENT))
|
||||
self.repository = AgentCommandRepository(connect)
|
||||
self.service = AgentCommandService(self.repository)
|
||||
|
||||
@staticmethod
|
||||
def seed(command_id, status="pending", command_type="miner_control", payload=None, agent=AGENT):
|
||||
if payload is None:
|
||||
payload = {"action": "legacy_4028", "ip_address": "192.0.2.1", "legacy_4028_command": "reboot", "dry_run": False}
|
||||
with connect() as connection, connection.cursor() as cur:
|
||||
cur.execute("""INSERT INTO minerops_agent_command_queue
|
||||
(id, agent_id, command_type, command_status, request_payload,
|
||||
result_payload, error_message, claimed_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'prior evidence',
|
||||
CASE WHEN %s = 'claimed' THEN now() ELSE NULL END)""",
|
||||
(command_id, agent, command_type, status, Jsonb(payload),
|
||||
Jsonb({"historical_evidence": True}), status))
|
||||
|
||||
@staticmethod
|
||||
def row(command_id):
|
||||
with connect() as connection, connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("SELECT * FROM minerops_agent_command_queue WHERE id = %s", (command_id,))
|
||||
return dict(cur.fetchone())
|
||||
|
||||
@staticmethod
|
||||
def audits(command_id):
|
||||
with connect() as connection, connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("SELECT * FROM audit_logs WHERE action = 'agent.command.rejected' AND old_value_json ->> 'command_id' = %s", (command_id,))
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
def assert_cancelled_preserving_history(self, command_id, before, outcome):
|
||||
after = self.row(command_id)
|
||||
self.assertEqual(after["command_status"], "cancelled")
|
||||
for field in ("id", "agent_id", "command_type", "request_payload", "result_payload", "created_at", "claimed_at"):
|
||||
self.assertEqual(after[field], before[field], field)
|
||||
self.assertTrue(after["error_message"].startswith("prior evidence\n"))
|
||||
self.assertIsNotNone(after["completed_at"])
|
||||
audits = self.audits(command_id)
|
||||
self.assertEqual(len(audits), 1)
|
||||
self.assertEqual(audits[0]["old_value_json"]["status"], before["command_status"])
|
||||
self.assertEqual(audits[0]["new_value_json"]["execution_outcome"], outcome)
|
||||
self.assertNotIn("192.0.2.1", str(audits))
|
||||
|
||||
def test_seeded_legacy_pending_never_dispatched(self):
|
||||
self.seed("stage-a-pending")
|
||||
before = self.row("stage-a-pending")
|
||||
self.assertEqual(self.service.claim(AGENT), [])
|
||||
self.assert_cancelled_preserving_history("stage-a-pending", before, "unknown")
|
||||
|
||||
def test_seeded_legacy_claimed_cancelled_with_unknown_prior_execution(self):
|
||||
self.seed("stage-a-claimed", status="claimed")
|
||||
before = self.row("stage-a-claimed")
|
||||
self.assertEqual(self.service.claim(AGENT), [])
|
||||
self.assert_cancelled_preserving_history("stage-a-claimed", before, "unknown")
|
||||
|
||||
def test_all_physical_schemas_and_unknown_types_are_rejected(self):
|
||||
payloads = ({}, {"schema_version": 1, "action": "suspend"},
|
||||
{"schema_version": 2, "action": "resume"},
|
||||
{"schema_version": "1", "action": "performance_mode"},
|
||||
{"schema_version": 999, "action": "reboot"})
|
||||
for index, payload in enumerate(payloads):
|
||||
self.seed(f"stage-a-schema-{index}", payload=payload)
|
||||
self.seed("stage-a-unknown", command_type="raw_api3")
|
||||
self.assertEqual(self.service.claim(AGENT), [])
|
||||
for index in range(len(payloads)):
|
||||
self.assertIn("physical_control_disabled_stage_a", self.row(f"stage-a-schema-{index}")["error_message"])
|
||||
self.assertIn("unsupported_command_schema_stage_a", self.row("stage-a-unknown")["error_message"])
|
||||
|
||||
def test_late_results_and_replay_cannot_promote_old_claimed_commands(self):
|
||||
self.seed("stage-a-late", status="claimed")
|
||||
before = self.row("stage-a-late")
|
||||
for status in ("completed", "failed", "completed"):
|
||||
request = AgentCommandResultRequest(command_id="stage-a-late", status=status,
|
||||
result_payload={"command_accepted": True})
|
||||
with self.assertRaisesRegex(AgentCommandConflict, "physical_control_disabled_stage_a"):
|
||||
self.service.submit_result(AGENT, request)
|
||||
self.assert_cancelled_preserving_history("stage-a-late", before, "unknown")
|
||||
|
||||
def test_repository_cannot_be_bypassed_by_direct_finish_call(self):
|
||||
self.seed("stage-a-direct", status="pending")
|
||||
before = self.row("stage-a-direct")
|
||||
with self.assertRaises(AgentCommandConflict):
|
||||
self.repository.finish_command("stage-a-direct", AGENT, "completed", {"accepted": True}, None)
|
||||
self.assert_cancelled_preserving_history("stage-a-direct", before, "unknown")
|
||||
|
||||
def test_wrong_agent_cannot_cancel_or_promote_another_agents_entry(self):
|
||||
self.seed("stage-a-foreign", status="claimed", agent=OTHER_AGENT)
|
||||
before = self.row("stage-a-foreign")
|
||||
self.assertEqual(self.service.claim(AGENT), [])
|
||||
with self.assertRaises(AgentCommandConflict):
|
||||
self.service.submit_result(AGENT, AgentCommandResultRequest(command_id="stage-a-foreign", status="completed"))
|
||||
with self.assertRaises(AgentCommandConflict):
|
||||
self.repository.finish_command("stage-a-foreign", AGENT, "completed", {}, None)
|
||||
self.assertEqual(self.row("stage-a-foreign"), before)
|
||||
self.assertEqual(self.audits("stage-a-foreign"), [])
|
||||
|
||||
def test_terminal_history_is_immutable_and_not_reaudited(self):
|
||||
for status in ("completed", "failed", "cancelled"):
|
||||
command_id = f"stage-a-terminal-{status}"
|
||||
self.seed(command_id, status=status)
|
||||
before = self.row(command_id)
|
||||
self.assertEqual(self.service.claim(AGENT), [])
|
||||
with self.assertRaises(AgentCommandConflict):
|
||||
self.repository.finish_command(command_id, AGENT, "completed", {}, None)
|
||||
self.assertEqual(self.row(command_id), before)
|
||||
self.assertEqual(self.audits(command_id), [])
|
||||
|
||||
def test_read_only_commands_still_claim_and_complete(self):
|
||||
for kind in ("poll_known_miners_now", "discover_new_miners", "enrich_match_discovery"):
|
||||
self.seed(f"stage-a-read-{kind}", command_type=kind, payload={"ip_address": "192.0.2.1"})
|
||||
commands = self.service.claim(AGENT)
|
||||
self.assertEqual(len(commands), 3)
|
||||
for command in commands:
|
||||
self.service.submit_result(AGENT, AgentCommandResultRequest(command_id=command.command_id, status="completed", result_payload={"simulated": True}))
|
||||
self.assertEqual(self.row(command.command_id)["command_status"], "completed")
|
||||
|
||||
def test_concurrent_pollers_cancel_once_and_never_deliver_physical_commands(self):
|
||||
self.seed("stage-a-concurrent")
|
||||
before = self.row("stage-a-concurrent")
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda _: AgentCommandRepository(connect).claim_pending(AGENT), range(2)))
|
||||
self.assertEqual(results, [[], []])
|
||||
self.assert_cancelled_preserving_history("stage-a-concurrent", before, "unknown")
|
||||
|
||||
def test_locked_legacy_entry_cannot_leak_into_claim_query(self):
|
||||
self.seed("stage-a-locked")
|
||||
locker = connect()
|
||||
try:
|
||||
with locker.cursor() as cur:
|
||||
cur.execute("SELECT id FROM minerops_agent_command_queue WHERE id = 'stage-a-locked' FOR UPDATE")
|
||||
self.assertEqual(self.service.claim(AGENT), [])
|
||||
finally:
|
||||
locker.rollback()
|
||||
locker.close()
|
||||
self.assertEqual(self.service.claim(AGENT), [])
|
||||
self.assertEqual(self.row("stage-a-locked")["command_status"], "cancelled")
|
||||
|
||||
def test_audit_failure_rolls_back_cancellation_and_prevents_dispatch(self):
|
||||
self.seed("stage-a-audit-failure")
|
||||
before = self.row("stage-a-audit-failure")
|
||||
def fail_audit(cur, row, **kwargs):
|
||||
cur.execute("SELECT 1 / 0")
|
||||
with patch.object(AgentCommandRepository, "_audit_rejection", side_effect=fail_audit):
|
||||
with self.assertRaises(psycopg.errors.DivisionByZero):
|
||||
self.service.claim(AGENT)
|
||||
self.assertEqual(self.row("stage-a-audit-failure"), before)
|
||||
self.assertEqual(self.audits("stage-a-audit-failure"), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Conservative delivery outcome decisions, independent of SQL row shape."""
|
||||
import unittest
|
||||
from app.agent_commands.policy import rejection_execution_outcome
|
||||
|
||||
|
||||
class DeliveryOutcomeTests(unittest.TestCase):
|
||||
def test_only_explicit_clean_pending_evidence_allows_not_dispatched(self):
|
||||
self.assertEqual(rejection_execution_outcome({"previous_status": "pending", "no_delivery_evidence": True}), "not_dispatched")
|
||||
|
||||
def test_missing_ambiguous_or_reported_delivery_is_unknown(self):
|
||||
for row in ({}, {"previous_status": "pending"},
|
||||
{"previous_status": "pending", "no_delivery_evidence": False},
|
||||
{"previous_status": "pending", "no_delivery_evidence": None},
|
||||
{"previous_status": "pending", "no_delivery_evidence": 1},
|
||||
{"previous_status": "pending", "no_delivery_evidence": "true"},
|
||||
{"previous_status": "claimed", "no_delivery_evidence": True},
|
||||
{"previous_status": "cancelled", "no_delivery_evidence": True},
|
||||
{"previous_status": "pending", "no_delivery_evidence": True, "delivery_reported": True},
|
||||
{"previous_status": "pending", "no_delivery_evidence": True, "delivery_reported": None}):
|
||||
with self.subTest(row=row):
|
||||
self.assertEqual(rejection_execution_outcome(row), "unknown")
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Delivery evidence across every Stage A retirement entry point; no miner IO."""
|
||||
import os
|
||||
import unittest
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from app.agent_commands.exceptions import AgentCommandConflict
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.config import settings
|
||||
from tests.test_stage_a_containment_postgres import connect
|
||||
|
||||
AGENT = "stage-a-final-evidence-agent"
|
||||
ACTOR = "00000000-0000-4000-8000-000000003302"
|
||||
SCHEDULE = "stage-a-final-evidence-schedule"
|
||||
PATHS = ("poll", "result", "cutover", "cancel", "delete")
|
||||
# Verbatim CLAIM_COMMANDS_SQL from committed 2d89e80. Test-only legacy delivery;
|
||||
# never use this query in the Stage A production repository.
|
||||
BASELINE_CLAIM_SQL = """
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE agent_id = %s
|
||||
AND command_status = 'pending'
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT %s
|
||||
)
|
||||
UPDATE minerops_agent_command_queue AS queue
|
||||
SET command_status = 'claimed', claimed_at = now()
|
||||
FROM candidates
|
||||
WHERE queue.id = candidates.id
|
||||
AND queue.command_status = 'pending'
|
||||
RETURNING queue.id AS command_id, queue.command_type,
|
||||
queue.request_payload, queue.created_at
|
||||
"""
|
||||
|
||||
|
||||
class DeliveryEvidencePostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_STAGE_A_DISPOSABLE") != "1" or not settings.postgres_db.startswith("minerops_stage_a_"):
|
||||
raise RuntimeError("Delivery evidence tests require a disposable Stage A database")
|
||||
|
||||
def setUp(self):
|
||||
self.repo = AgentCommandRepository(connect)
|
||||
self.clean()
|
||||
self.addCleanup(self.clean)
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("""INSERT INTO users (id,username,display_name,password_hash,role_id)
|
||||
SELECT %s,'stage-a-final-evidence','Delivery Evidence','unused',id
|
||||
FROM roles WHERE name='Admin' ON CONFLICT (id) DO NOTHING""", (ACTOR,))
|
||||
self.no_transport = patch("socket.create_connection", side_effect=AssertionError("No miner IO"))
|
||||
mocked = self.no_transport.start()
|
||||
self.addCleanup(mocked.assert_not_called)
|
||||
self.addCleanup(self.no_transport.stop)
|
||||
|
||||
def clean(self):
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM audit_logs WHERE actor_user_id=%s OR old_value_json->>'agent_id'=%s", (ACTOR, AGENT))
|
||||
cur.execute("DELETE FROM minerops_agent_command_results WHERE agent_id=%s", (AGENT,))
|
||||
cur.execute("DELETE FROM minerops_agent_command_queue WHERE agent_id=%s", (AGENT,))
|
||||
cur.execute("DELETE FROM minerops_scheduled_bulk_actions WHERE id=%s", (SCHEDULE,))
|
||||
|
||||
def seed(self, command_id="stage-a-final-evidence-command", kind="miner_control"):
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("""INSERT INTO minerops_scheduled_bulk_actions
|
||||
(id,agent_id,target_type,status,scheduled_for) VALUES (%s,%s,'all','running',now())
|
||||
ON CONFLICT (id) DO UPDATE SET status='running'""", (SCHEDULE,AGENT))
|
||||
cur.execute("""INSERT INTO minerops_agent_command_queue (id,agent_id,command_type,request_payload)
|
||||
VALUES (%s,%s,%s,%s)""",
|
||||
(command_id,AGENT,kind,Jsonb({"scheduled_action_id": SCHEDULE,"action":"suspend","ip_address":"127.0.0.1"})))
|
||||
return command_id
|
||||
|
||||
def row(self, cid):
|
||||
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("SELECT * FROM minerops_agent_command_queue WHERE id=%s", (cid,))
|
||||
return dict(cur.fetchone())
|
||||
|
||||
def audits(self, cid):
|
||||
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("""SELECT * FROM audit_logs WHERE action='agent.command.rejected'
|
||||
AND old_value_json->>'command_id'=%s""", (cid,))
|
||||
return list(cur.fetchall())
|
||||
|
||||
def retire(self, path, cid):
|
||||
if path == "poll": self.assertEqual(self.repo.claim_pending(AGENT), [])
|
||||
elif path == "result":
|
||||
with self.assertRaises(AgentCommandConflict): self.repo.finish_command(cid, AGENT, "completed", {}, None)
|
||||
elif path == "cutover": return self.repo.retire_unsupported(AGENT, actor_user_id=ACTOR)
|
||||
else: return self.repo.retire_schedule(SCHEDULE, actor_user_id=ACTOR, operation=path)
|
||||
|
||||
def assert_retired(self, cid, before, outcome):
|
||||
after = self.row(cid)
|
||||
self.assertEqual(after["command_status"], "cancelled")
|
||||
self.assertIsNotNone(after["completed_at"])
|
||||
for key in ("id","agent_id","command_type","claimed_at","created_at","request_payload","result_payload"):
|
||||
self.assertEqual(after[key], before[key], key)
|
||||
if before["error_message"]: self.assertTrue(after["error_message"].startswith(before["error_message"]+"\n"))
|
||||
audits = self.audits(cid)
|
||||
self.assertEqual(len(audits), 1)
|
||||
self.assertEqual(audits[0]["old_value_json"]["status"], before["command_status"])
|
||||
self.assertEqual(audits[0]["new_value_json"]["execution_outcome"], outcome)
|
||||
|
||||
def test_genuine_baseline_claim_then_explicit_repair_requeue_all_paths(self):
|
||||
for path in PATHS:
|
||||
with self.subTest(path=path):
|
||||
self.clean(); cid=self.seed()
|
||||
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(BASELINE_CLAIM_SQL,(AGENT,5)); delivered=list(cur.fetchall())
|
||||
self.assertEqual([r["command_id"] for r in delivered], [cid])
|
||||
claimed=self.row(cid)
|
||||
self.assertEqual(claimed["command_status"],"claimed"); self.assertIsNotNone(claimed["claimed_at"])
|
||||
# Explicitly simulate a legacy repair. No production requeue path
|
||||
# is asserted or added; retain the actual baseline claim evidence.
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("UPDATE minerops_agent_command_queue SET command_status='pending' WHERE id=%s",(cid,))
|
||||
before=self.row(cid); self.assertEqual(before["claimed_at"],claimed["claimed_at"])
|
||||
self.retire(path,cid); self.assert_retired(cid,before,"unknown")
|
||||
audit_before=self.audits(cid); row_before=self.row(cid)
|
||||
for retry in PATHS: self.retire(retry,cid)
|
||||
self.assertEqual(self.audits(cid),audit_before); self.assertEqual(self.row(cid),row_before)
|
||||
|
||||
def test_pristine_pending_all_paths_and_result_itself_is_delivery_evidence(self):
|
||||
for path in PATHS:
|
||||
with self.subTest(path=path):
|
||||
self.clean(); cid=self.seed(); before=self.row(cid)
|
||||
self.retire(path,cid)
|
||||
self.assert_retired(cid,before,"unknown" if path=="result" else "not_dispatched")
|
||||
|
||||
def test_each_durable_evidence_source_is_unknown_on_every_path(self):
|
||||
sources=("claim","completion","result","error","legacy_result","unlinked_legacy_result","audit_old","audit_new","prior_unknown")
|
||||
for source in sources:
|
||||
for path in PATHS:
|
||||
with self.subTest(source=source,path=path):
|
||||
self.clean(); cid=self.seed()
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
if source in ("claim","completion","result","error"):
|
||||
assignments={"claim":"claimed_at=now()", "completion":"completed_at=now()",
|
||||
"result":"result_payload='{}'::jsonb", "error":"error_message='ambiguous legacy error'"}
|
||||
cur.execute("UPDATE minerops_agent_command_queue SET "+assignments[source]+" WHERE id=%s",(cid,))
|
||||
elif source in ("legacy_result", "unlinked_legacy_result"):
|
||||
cur.execute("""INSERT INTO minerops_agent_command_results (agent_id,site_name,command_id,status,payload)
|
||||
VALUES (%s,'simulated',%s,'completed','{}'::jsonb)""",(AGENT,cid if source=="legacy_result" else None))
|
||||
else:
|
||||
# Durable audit evidence with all queue timestamps missing.
|
||||
old={"command_id":cid,"agent_id":AGENT} if source!="audit_new" else {"agent_id":AGENT}
|
||||
new={"operation_id":cid} if source=="audit_new" else {"execution_outcome":"unknown"}
|
||||
action="agent.command.rejected" if source=="prior_unknown" else "legacy.command.delivered"
|
||||
cur.execute("""INSERT INTO audit_logs (actor_user_id,action,entity_type,old_value_json,new_value_json)
|
||||
VALUES (%s,%s,'agent_command',%s,%s)""",(ACTOR,action,Jsonb(old),Jsonb(new)))
|
||||
prior_audits=self.audits(cid); before=self.row(cid); self.retire(path,cid)
|
||||
after=self.row(cid)
|
||||
self.assertEqual(after["command_status"],"cancelled")
|
||||
self.assertEqual(after["claimed_at"],before["claimed_at"])
|
||||
self.assertEqual(after["result_payload"],before["result_payload"])
|
||||
if before["completed_at"]: self.assertEqual(after["completed_at"],before["completed_at"])
|
||||
audits=self.audits(cid); self.assertEqual(len(audits),len(prior_audits)+1)
|
||||
self.assertTrue(all(a["new_value_json"].get("execution_outcome")=="unknown" for a in audits))
|
||||
|
||||
def test_creation_audit_alone_does_not_report_delivery(self):
|
||||
cid=self.seed()
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("""INSERT INTO audit_logs (actor_user_id,action,entity_type,new_value_json)
|
||||
VALUES (%s,'miner.control.queued','miner',%s)""",(ACTOR,Jsonb({"operation_id":cid})))
|
||||
before=self.row(cid); self.retire("cutover",cid); self.assert_retired(cid,before,"not_dispatched")
|
||||
|
||||
def test_currently_claimed_without_timestamp_remains_unknown(self):
|
||||
for path in PATHS:
|
||||
self.clean(); cid=self.seed()
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("UPDATE minerops_agent_command_queue SET command_status='claimed' WHERE id=%s",(cid,))
|
||||
before=self.row(cid); self.retire(path,cid); self.assert_retired(cid,before,"unknown")
|
||||
|
||||
def test_audit_failure_rolls_back_every_cancellation_path(self):
|
||||
for path in PATHS:
|
||||
with self.subTest(path=path):
|
||||
self.clean(); cid=self.seed(); before=self.row(cid)
|
||||
def fail(cur,row,**kwargs): cur.execute("SELECT 1/0")
|
||||
with patch.object(AgentCommandRepository,"_audit_rejection",side_effect=fail):
|
||||
with self.assertRaises(Exception): self.retire(path,cid)
|
||||
self.assertEqual(self.row(cid),before); self.assertEqual(self.audits(cid),[])
|
||||
|
||||
def test_concurrent_baseline_claim_blocks_result_then_unknown(self):
|
||||
cid=self.seed(); locker=connect()
|
||||
try:
|
||||
with locker.cursor() as cur:
|
||||
cur.execute(BASELINE_CLAIM_SQL,(AGENT,1)); self.assertEqual(len(cur.fetchall()),1)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
def cancellation():
|
||||
def named_connection():
|
||||
conn=connect()
|
||||
conn.execute("SET application_name='stage-a-final-result-race'")
|
||||
return conn
|
||||
with self.assertRaises(AgentCommandConflict):
|
||||
AgentCommandRepository(named_connection).finish_command(cid,AGENT,"completed",{},None)
|
||||
future=pool.submit(cancellation)
|
||||
try:
|
||||
# Observe an actual database lock wait before committing the
|
||||
# claim, rather than relying on thread scheduling or sleeps.
|
||||
blocked=False; deadline=time.monotonic()+5
|
||||
while time.monotonic()<deadline:
|
||||
with connect() as observer, observer.cursor() as cur:
|
||||
cur.execute("""SELECT EXISTS (SELECT 1 FROM pg_stat_activity
|
||||
WHERE application_name='stage-a-final-result-race'
|
||||
AND wait_event_type='Lock')""")
|
||||
blocked=cur.fetchone()[0]
|
||||
if blocked: break
|
||||
time.sleep(.01)
|
||||
self.assertTrue(blocked,"Cancellation must wait for the genuine legacy claim lock")
|
||||
finally:
|
||||
locker.commit()
|
||||
future.result(timeout=10)
|
||||
self.assertEqual(self.row(cid)["command_status"],"cancelled")
|
||||
self.assertIsNotNone(self.row(cid)["claimed_at"])
|
||||
self.assertEqual(self.audits(cid)[0]["new_value_json"]["execution_outcome"],"unknown")
|
||||
finally: locker.close()
|
||||
|
||||
def test_mixed_legacy_backlog_classifies_each_row_without_delivering(self):
|
||||
for n in range(12):
|
||||
cid=self.seed("stage-a-final-evidence-"+str(n),"miner_control" if n%2 else "unknown_legacy")
|
||||
if n%3:
|
||||
with connect() as conn,conn.cursor() as cur:
|
||||
cur.execute("UPDATE minerops_agent_command_queue SET claimed_at=now() WHERE id=%s",(cid,))
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
self.assertEqual(sum(pool.map(lambda _: self.repo.retire_unsupported(AGENT,actor_user_id=ACTOR), range(2))),12)
|
||||
for n in range(12):
|
||||
audit=self.audits("stage-a-final-evidence-"+str(n)); self.assertEqual(len(audit),1)
|
||||
self.assertEqual(audit[0]["new_value_json"]["execution_outcome"],"unknown" if n%3 else "not_dispatched")
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Audited schedule retirement on an explicitly disposable PostgreSQL database."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app import main
|
||||
from app.agent_commands.exceptions import AgentCommandConflict
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.agent_commands.policy import PHYSICAL_CONTROL_DISABLED
|
||||
from app.config import settings
|
||||
from app.identity import get_current_user, required_api_permission
|
||||
from tests.test_stage_a_containment_postgres import connect
|
||||
|
||||
|
||||
AGENT = "stage-a-schedule-agent"
|
||||
OTHER_AGENT = "stage-a-schedule-other-agent"
|
||||
ACTOR = UUID("00000000-0000-4000-8000-000000003301")
|
||||
SCHEDULE = "stage-a-schedule-history"
|
||||
|
||||
|
||||
class StageAScheduleCancellationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_STAGE_A_DISPOSABLE") != "1" or not settings.postgres_db.startswith("minerops_stage_a_"):
|
||||
raise RuntimeError("Refusing schedule tests outside an explicitly disposable Stage A database")
|
||||
|
||||
def setUp(self):
|
||||
self.no_transport = patch("socket.create_connection", side_effect=AssertionError("No miner connection"))
|
||||
self.transport = self.no_transport.start()
|
||||
self.addCleanup(self.no_transport.stop)
|
||||
self.addCleanup(self.transport.assert_not_called)
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM audit_logs WHERE actor_user_id = %s OR old_value_json ->> 'agent_id' IN (%s, %s)",
|
||||
(ACTOR, AGENT, OTHER_AGENT))
|
||||
cur.execute("DELETE FROM minerops_agent_command_queue WHERE agent_id IN (%s, %s)", (AGENT, OTHER_AGENT))
|
||||
cur.execute("DELETE FROM minerops_scheduled_bulk_actions WHERE id = %s", (SCHEDULE,))
|
||||
cur.execute("""INSERT INTO users (id, username, display_name, password_hash, role_id)
|
||||
SELECT %s, 'stage-a-retirement-auditor', 'Retirement Auditor', 'unused', id
|
||||
FROM roles WHERE name = 'Admin' ON CONFLICT (id) DO NOTHING""", (ACTOR,))
|
||||
cur.execute("""INSERT INTO minerops_scheduled_bulk_actions
|
||||
(id, agent_id, name, target_type, scheduled_for, status, last_error)
|
||||
VALUES (%s, %s, 'Historical schedule', 'all', now(), 'running', 'prior evidence')""",
|
||||
(SCHEDULE, AGENT))
|
||||
self.repository = AgentCommandRepository(connect)
|
||||
|
||||
def seed(self, command_id, status="pending", agent=AGENT, kind="miner_control", schedule=SCHEDULE):
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("""INSERT INTO minerops_agent_command_queue
|
||||
(id, agent_id, command_type, command_status, request_payload, result_payload,
|
||||
error_message, claimed_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'prior evidence',
|
||||
CASE WHEN %s = 'claimed' THEN now() ELSE NULL END)""",
|
||||
(command_id, agent, kind, status,
|
||||
Jsonb({"scheduled_action_id": schedule, "ip_address": "192.0.2.1", "action": "suspend"}),
|
||||
Jsonb({"historical_result": True}), status))
|
||||
|
||||
@staticmethod
|
||||
def row(command_id):
|
||||
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("SELECT * FROM minerops_agent_command_queue WHERE id = %s", (command_id,))
|
||||
return dict(cur.fetchone())
|
||||
|
||||
@staticmethod
|
||||
def schedule():
|
||||
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("SELECT * FROM minerops_scheduled_bulk_actions WHERE id = %s", (SCHEDULE,))
|
||||
return dict(cur.fetchone())
|
||||
|
||||
@staticmethod
|
||||
def audits():
|
||||
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("""SELECT * FROM audit_logs WHERE actor_user_id = %s
|
||||
OR old_value_json ->> 'agent_id' IN (%s, %s)""", (ACTOR, AGENT, OTHER_AGENT))
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
def retire(self, operation="cancel"):
|
||||
with patch.object(main, "get_db_connection", side_effect=connect):
|
||||
return getattr(main, f"{operation}_dashboard_scheduled_bulk_action")(
|
||||
SCHEDULE, {"id": str(ACTOR), "username": "retirement-auditor"},
|
||||
)
|
||||
|
||||
def assert_retired(self, command_id, before, operation):
|
||||
after = self.row(command_id)
|
||||
self.assertEqual(after["command_status"], "cancelled")
|
||||
for field in ("id", "agent_id", "command_type", "request_payload", "result_payload", "created_at", "claimed_at"):
|
||||
self.assertEqual(after[field], before[field], field)
|
||||
self.assertEqual(after["error_message"], "prior evidence\n" + PHYSICAL_CONTROL_DISABLED)
|
||||
self.assertIsNotNone(after["completed_at"])
|
||||
audits = [r for r in self.audits() if r["action"] == "agent.command.rejected"
|
||||
and r["old_value_json"]["command_id"] == command_id]
|
||||
self.assertEqual(len(audits), 1)
|
||||
audit = audits[0]
|
||||
self.assertEqual(audit["actor_user_id"], ACTOR)
|
||||
self.assertEqual(audit["old_value_json"]["agent_id"], AGENT)
|
||||
self.assertEqual(audit["old_value_json"]["status"], before["command_status"])
|
||||
self.assertEqual(audit["new_value_json"]["source"], "schedule_" + operation)
|
||||
self.assertEqual(audit["new_value_json"]["reason"], PHYSICAL_CONTROL_DISABLED)
|
||||
self.assertEqual(audit["new_value_json"]["execution_outcome"],
|
||||
"unknown")
|
||||
self.assertNotIn("192.0.2.1", str(audit))
|
||||
|
||||
def test_schedule_cancel_retires_pending_and_claimed_with_attributed_audits(self):
|
||||
for status in ("pending", "claimed"):
|
||||
command_id = "stage-a-schedule-cancel-" + status
|
||||
self.seed(command_id, status)
|
||||
before = {s: self.row("stage-a-schedule-cancel-" + s) for s in ("pending", "claimed")}
|
||||
self.retire()
|
||||
for status in before:
|
||||
self.assert_retired("stage-a-schedule-cancel-" + status, before[status], "cancel")
|
||||
self.assertEqual(self.schedule()["status"], "cancelled")
|
||||
self.assertEqual(len(self.audits()), 3)
|
||||
|
||||
def test_schedule_delete_is_audited_retirement_and_preserves_history(self):
|
||||
self.seed("stage-a-schedule-delete", "claimed")
|
||||
before, schedule_before = self.row("stage-a-schedule-delete"), self.schedule()
|
||||
self.retire("delete")
|
||||
self.assert_retired("stage-a-schedule-delete", before, "delete")
|
||||
after = self.schedule()
|
||||
for field in schedule_before:
|
||||
if field not in {"status", "last_error", "updated_at"}:
|
||||
self.assertEqual(after[field], schedule_before[field], field)
|
||||
parent_audits = [r for r in self.audits() if r["action"] == "scheduled_action.retired"]
|
||||
self.assertEqual(len(parent_audits), 1)
|
||||
self.assertEqual(parent_audits[0]["new_value_json"]["operation"], "delete")
|
||||
|
||||
def test_repeated_cancel_and_delete_are_idempotent(self):
|
||||
self.seed("stage-a-schedule-repeat")
|
||||
self.retire()
|
||||
before, schedule_before, audits_before = self.row("stage-a-schedule-repeat"), self.schedule(), self.audits()
|
||||
for operation in ("cancel", "delete", "delete"):
|
||||
self.retire(operation)
|
||||
self.assertEqual(self.row("stage-a-schedule-repeat"), before)
|
||||
self.assertEqual(self.schedule(), schedule_before)
|
||||
self.assertEqual(self.audits(), audits_before)
|
||||
|
||||
def test_terminal_queue_and_schedule_history_remains_immutable(self):
|
||||
for status in ("completed", "failed", "cancelled"):
|
||||
self.seed("stage-a-schedule-terminal-" + status, status)
|
||||
before = {s: self.row("stage-a-schedule-terminal-" + s) for s in ("completed", "failed", "cancelled")}
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("UPDATE minerops_scheduled_bulk_actions SET status = 'completed' WHERE id = %s", (SCHEDULE,))
|
||||
schedule_before = self.schedule()
|
||||
self.retire("delete")
|
||||
self.assertEqual(self.schedule(), schedule_before)
|
||||
for status, row in before.items():
|
||||
self.assertEqual(self.row("stage-a-schedule-terminal-" + status), row)
|
||||
self.assertEqual(self.audits(), [])
|
||||
|
||||
def test_stored_agent_and_schedule_scope_cannot_be_overridden(self):
|
||||
self.seed("stage-a-schedule-own")
|
||||
self.seed("stage-a-schedule-foreign", agent=OTHER_AGENT)
|
||||
self.seed("stage-a-schedule-unrelated", schedule="another-schedule")
|
||||
self.seed("stage-a-schedule-telemetry", kind="poll_known_miners_now")
|
||||
protected = {c: self.row(c) for c in ("stage-a-schedule-foreign", "stage-a-schedule-unrelated", "stage-a-schedule-telemetry")}
|
||||
self.retire()
|
||||
self.assertEqual(self.row("stage-a-schedule-own")["command_status"], "cancelled")
|
||||
for command_id, before in protected.items():
|
||||
self.assertEqual(self.row(command_id), before)
|
||||
|
||||
def test_schedule_unknown_type_is_rejected_with_its_actual_reason(self):
|
||||
self.seed("stage-a-schedule-unknown", kind="raw_api3")
|
||||
self.retire()
|
||||
rejection = next(a for a in self.audits() if a["action"] == "agent.command.rejected")
|
||||
self.assertEqual(rejection["new_value_json"]["reason"], "unsupported_command_schema_stage_a")
|
||||
|
||||
def test_concurrent_cancel_delete_workers_audit_each_transition_once(self):
|
||||
self.seed("stage-a-schedule-concurrent", "claimed")
|
||||
def worker(operation):
|
||||
return AgentCommandRepository(connect).retire_schedule(SCHEDULE, actor_user_id=str(ACTOR), operation=operation)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
counts = list(pool.map(worker, ("cancel", "delete")))
|
||||
self.assertEqual(sorted(counts), [0, 1])
|
||||
self.assertEqual(self.row("stage-a-schedule-concurrent")["command_status"], "cancelled")
|
||||
self.assertEqual(len(self.audits()), 2)
|
||||
|
||||
def test_poll_finish_and_schedule_cancel_race_has_one_queue_audit(self):
|
||||
self.seed("stage-a-schedule-race", "claimed")
|
||||
def worker(i):
|
||||
repo = AgentCommandRepository(connect)
|
||||
try:
|
||||
if i == 0:
|
||||
return repo.retire_schedule(SCHEDULE, actor_user_id=str(ACTOR), operation="cancel")
|
||||
if i == 1:
|
||||
return repo.claim_pending(AGENT)
|
||||
return repo.finish_command("stage-a-schedule-race", AGENT, "completed", {}, None)
|
||||
except AgentCommandConflict:
|
||||
return None
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
list(pool.map(worker, range(3)))
|
||||
rejections = [a for a in self.audits() if a["action"] == "agent.command.rejected"]
|
||||
self.assertEqual(len(rejections), 1)
|
||||
self.assertEqual(rejections[0]["new_value_json"]["execution_outcome"], "unknown")
|
||||
self.assertEqual(self.row("stage-a-schedule-race")["command_status"], "cancelled")
|
||||
|
||||
def test_audit_failure_after_one_insert_rolls_back_all_cancellations(self):
|
||||
for command_id in ("stage-a-schedule-rollback-a", "stage-a-schedule-rollback-b"):
|
||||
self.seed(command_id, "claimed")
|
||||
before = {c: self.row(c) for c in ("stage-a-schedule-rollback-a", "stage-a-schedule-rollback-b")}
|
||||
schedule_before = self.schedule()
|
||||
original = AgentCommandRepository._audit_rejection
|
||||
calls = []
|
||||
def fail_second(cur, row, **kwargs):
|
||||
calls.append(row["id"])
|
||||
if len(calls) == 2:
|
||||
raise RuntimeError("simulated audit failure")
|
||||
original(cur, row, **kwargs)
|
||||
with patch.object(AgentCommandRepository, "_audit_rejection", side_effect=fail_second):
|
||||
with self.assertRaisesRegex(RuntimeError, "simulated audit failure"):
|
||||
self.retire("delete")
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertEqual(self.schedule(), schedule_before)
|
||||
for command_id, row in before.items():
|
||||
self.assertEqual(self.row(command_id), row)
|
||||
self.assertEqual(self.audits(), [])
|
||||
|
||||
def test_schedule_parent_audit_failure_rolls_back_queue_and_parent(self):
|
||||
self.seed("stage-a-schedule-parent-rollback")
|
||||
before, parent_before = self.row("stage-a-schedule-parent-rollback"), self.schedule()
|
||||
# A database constraint failure after child audits exercises the final
|
||||
# schedule/audit portion, rather than only an early application exception.
|
||||
def connection_with_failure():
|
||||
conn = connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""CREATE OR REPLACE FUNCTION pg_temp.reject_schedule_audit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF NEW.action = 'scheduled_action.retired' THEN RAISE EXCEPTION 'simulated parent audit failure'; END IF;
|
||||
RETURN NEW; END $$""")
|
||||
cur.execute("CREATE TRIGGER stage_a_parent_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION pg_temp.reject_schedule_audit()")
|
||||
# Trigger creation is in the very transaction the repository must roll back.
|
||||
return conn
|
||||
with self.assertRaisesRegex(Exception, "simulated parent audit failure"):
|
||||
AgentCommandRepository(connection_with_failure).retire_schedule(
|
||||
SCHEDULE, actor_user_id=str(ACTOR), operation="cancel")
|
||||
self.assertEqual(self.row("stage-a-schedule-parent-rollback"), before)
|
||||
self.assertEqual(self.schedule(), parent_before)
|
||||
self.assertEqual(self.audits(), [])
|
||||
|
||||
def test_unknown_schedule_has_no_queue_or_audit_side_effects(self):
|
||||
self.seed("stage-a-schedule-orphan")
|
||||
before = self.row("stage-a-schedule-orphan")
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM minerops_scheduled_bulk_actions WHERE id = %s", (SCHEDULE,))
|
||||
from fastapi import HTTPException
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
self.retire()
|
||||
self.assertEqual(error.exception.status_code, 404)
|
||||
self.assertEqual(self.row("stage-a-schedule-orphan"), before)
|
||||
self.assertEqual(self.audits(), [])
|
||||
|
||||
def test_http_cancel_delete_require_control_permission(self):
|
||||
app = FastAPI()
|
||||
for operation in ("cancel", "delete"):
|
||||
path = f"/api/dashboard/scheduled-bulk-actions/{{action_id}}/{operation}"
|
||||
app.add_api_route(path, getattr(main, f"{operation}_dashboard_scheduled_bulk_action"), methods=["POST"])
|
||||
self.assertEqual(required_api_permission("POST", path), "miners.control")
|
||||
app.dependency_overrides[get_current_user] = lambda: {"id": str(ACTOR), "role": "Custom", "permissions": ["agents.manage"]}
|
||||
with TestClient(app) as client, patch.object(main, "get_db_connection") as db:
|
||||
for operation in ("cancel", "delete"):
|
||||
self.assertEqual(client.post(f"/api/dashboard/scheduled-bulk-actions/{SCHEDULE}/{operation}").status_code, 403)
|
||||
db.assert_not_called()
|
||||
|
||||
def test_offline_cutover_batch_is_audited_and_never_claims_readers(self):
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute("""INSERT INTO minerops_agent_command_queue
|
||||
(id, agent_id, command_type, command_status, request_payload, claimed_at)
|
||||
SELECT 'stage-a-schedule-backlog-' || n, %s,
|
||||
CASE WHEN n %% 2 = 0 THEN 'miner_control' ELSE 'raw_api3' END,
|
||||
CASE WHEN n %% 3 = 0 THEN 'claimed' ELSE 'pending' END,
|
||||
'{}'::jsonb, CASE WHEN n %% 5 = 0 THEN now() ELSE NULL END
|
||||
FROM generate_series(1,1003) n""", (AGENT,))
|
||||
self.seed("stage-a-schedule-offline-read", kind="poll_known_miners_now")
|
||||
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 1000)
|
||||
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 3)
|
||||
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 0)
|
||||
self.assertEqual(self.row("stage-a-schedule-offline-read")["command_status"], "pending")
|
||||
rejections = self.audits()
|
||||
self.assertEqual(len(rejections), 1003)
|
||||
for audit in rejections:
|
||||
self.assertEqual(audit["actor_user_id"], ACTOR)
|
||||
self.assertEqual(audit["new_value_json"]["source"], "stage_a_cutover")
|
||||
n = int(audit["old_value_json"]["command_id"].rsplit("-", 1)[1])
|
||||
self.assertEqual(audit["new_value_json"]["execution_outcome"],
|
||||
"unknown" if n % 3 == 0 or n % 5 == 0 else "not_dispatched")
|
||||
|
||||
def test_cutover_zero_batch_does_not_claim_a_locked_drain_is_complete(self):
|
||||
self.seed("stage-a-schedule-cutover-locked", "claimed")
|
||||
locker = connect()
|
||||
try:
|
||||
with locker.cursor() as cur:
|
||||
cur.execute("SELECT id FROM minerops_agent_command_queue WHERE id = %s FOR UPDATE", ("stage-a-schedule-cutover-locked",))
|
||||
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 0)
|
||||
self.assertEqual(self.row("stage-a-schedule-cutover-locked")["command_status"], "claimed")
|
||||
finally:
|
||||
locker.rollback()
|
||||
locker.close()
|
||||
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 1)
|
||||
self.assertEqual(self.row("stage-a-schedule-cutover-locked")["command_status"], "cancelled")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,248 @@
|
||||
MinerOps Blocker #3 Stage A: old-Agent containment and upgrade runbook
|
||||
Status: review candidate. This procedure has NOT been executed in production.
|
||||
Scope: contain physical control; no Stages B-E protocol or control re-enablement.
|
||||
|
||||
The independent audit demonstrated that an old Agent can execute a previously
|
||||
delivered command after the Hub marks it cancelled. Cancelling a database row,
|
||||
revoking a Hub credential, advertising control unavailable, or replacing script
|
||||
files does not revoke a cached command or loaded PowerShell functions. Execution
|
||||
of any previously claimed command remains UNKNOWN until separately investigated.
|
||||
|
||||
Production approval requires independent security revalidation, an approved change
|
||||
window, verified network enforcement, and the native Windows checks below. A new
|
||||
database migration or production network infrastructure change requires explicit
|
||||
approval. This runbook authorizes no production actions by itself.
|
||||
|
||||
Ownership and evidence
|
||||
|
||||
Assign an incident/change owner, a network operator, a Hub/database operator, and a
|
||||
Windows operator. Record their identities, UTC timestamps, change ticket, and every
|
||||
gate's evidence in a protected location. Keep enrollment tokens and Agent/miner
|
||||
credentials out of ordinary logs and this checklist. Preserve required backups
|
||||
and recovery state with their existing ACLs; do not overwrite agent-config.json.
|
||||
|
||||
Maintain one ledger containing ALL registered Agents, every distinct queue Agent
|
||||
(including deleted registry entries), offline/unreachable Agents, alternative
|
||||
installations and manually running executors, legacy shared identities, all Hub
|
||||
replicas/workers, scheduled jobs, task hosts, physical networks, and IPv4/IPv6 paths.
|
||||
For each Agent record its Site/Zone assignment, last check-in, host/network identity,
|
||||
network fence evidence, process/task evidence, installed-file hashes, verified
|
||||
artifact identifier, and release decision. Keep unverified entries quarantined.
|
||||
|
||||
Gate 1: restrict physical write access BEFORE stopping/upgrading anything
|
||||
|
||||
1. Arrange an approved maintenance window and identify the actual network control
|
||||
that can keep old Agents from reaching ALL physical miners. Layer-4 rules cannot
|
||||
distinguish a legacy read from a write on the same miner API port. Default to
|
||||
denying Agent-to-miner traffic, including ports 4028 and 4433, alternative miner
|
||||
management interfaces/ports, IPv6, alternate NICs/VPNs, and established sessions.
|
||||
Telemetry may be interrupted. Do not substitute an unreviewed 'read-only' proxy.
|
||||
2. Apply only separately approved network controls through the responsible operator.
|
||||
A stopped process or a host-local rule alone is insufficient for an unreachable
|
||||
machine that can later reboot/reconnect. Use enforcement that already applies
|
||||
to that machine's traffic when it returns. Include unknown/retired installations
|
||||
and aliases; trusting an IP address alone is insufficient when addresses can change.
|
||||
3. Verify deny counters and session termination/conntrack handling. Use approved
|
||||
synthetic targets/canaries, never a real write command to a physical miner.
|
||||
A rule that blocks only NEW connections leaves a pre-existing API session exposed.
|
||||
Check both the intended route and alternate routes. Keep the fence if evidence
|
||||
is unavailable, ambiguous, or an offline Agent cannot be located.
|
||||
|
||||
Exit gate: verified deny enforcement covers every unverified/legacy executor,
|
||||
including future reconnects and existing sessions. Failure means STOP; keep fences.
|
||||
|
||||
Gate 2: stop old executors and automatic restarts
|
||||
|
||||
1. Stop command production and processing on every old Hub/worker version, including
|
||||
old schedule materializers and running requests that could still insert/deliver
|
||||
commands. Hold ingress and scale/restart mechanisms as necessary under the
|
||||
approved change procedure. Inventory the actual versions of every instance.
|
||||
2. On each reachable Windows host disable/hold all relevant Scheduled Task triggers,
|
||||
retry/restart policies, service recovery, watchdogs, startup/logon entries, GUI
|
||||
launch actions, manual jobs, and external orchestration that can launch MinerOps.
|
||||
Record original definitions and intended running/stopped state for recovery.
|
||||
3. Stop MinerOpsAgent and any alternative/manual executor, then verify task instances
|
||||
AND their PowerShell/child process trees have exited. Record command paths,
|
||||
process start times and identities. An in-memory old module is still executable
|
||||
even after its source file changes. Do not gracefully 'drain' by executing writes.
|
||||
4. Keep pool/enrichment tasks held during runtime replacement and verify no alternate
|
||||
old code copy can auto-start. Never run uninstall or erase config/state to stop it.
|
||||
|
||||
Exit gate: every reachable old executor is stopped and restart-held; every
|
||||
unreachable/unverified Agent remains network-fenced. Do not remove a fence to
|
||||
diagnose a failed stop. Escalate failures with evidence and keep maintenance holds.
|
||||
|
||||
Gate 3: update ALL Hub instances with no mixed-version execution
|
||||
|
||||
1. Capture protected queue/audit evidence and the relevant schedule/Agent inventory
|
||||
before changes. Retain original request/result payloads, timestamps and errors.
|
||||
Secure these exports because historical payloads may contain sensitive data.
|
||||
2. Install only the independently reviewed Stage A artifact on every Hub instance
|
||||
while executor fences/holds remain. Stop old replicas and workers fully, including
|
||||
rolling-update stragglers and old connections/requests; do not allow a mixture of
|
||||
active baseline and Stage A schedulers/queue consumers.
|
||||
3. Verify each instance's actual artifact hashes and that physical admission is
|
||||
fixed disabled: legacy write/diagnostic POSTs return 410 after authorization,
|
||||
canonical typed controls return 409 physical_control_disabled_stage_a, and queue
|
||||
SQL dispatches only the fixed read types. Do not use runtime flags to enable writes.
|
||||
4. Run reviewed schema compatibility checks as appropriate. Stage A remediation adds
|
||||
NO migration. Stop and request approval if the deployed schema needs one.
|
||||
|
||||
Exit gate: only reviewed containment Hub code is active; all old producers are
|
||||
stopped. Keep Agents held. If any instance cannot be verified, keep ingress/worker
|
||||
holds and physical fences rather than continuing a partial rolling deployment.
|
||||
|
||||
Gate 4: audited cancellation across ALL Agents, including offline/queue-only IDs
|
||||
|
||||
1. Use the canonical reviewed code and a real accountable operator users.id for
|
||||
manual cutover retirement. Existing authenticated Agent polling/result rejection
|
||||
is system-attributed by source and Agent ID; manual cutover supplies actor_user_id.
|
||||
Do not impersonate another user, create invented execution evidence, or log secrets.
|
||||
2. Inventory IDs using the UNION of registry and queue Agent IDs. Also reconcile the
|
||||
fleet ledger, historical/retired identities, and old shared Agent ID. Do not use
|
||||
only Agents currently checking in. A useful read-only inventory is:
|
||||
|
||||
SELECT agent_id FROM minerops_site_agents
|
||||
UNION SELECT agent_id FROM minerops_agent_command_queue
|
||||
ORDER BY agent_id;
|
||||
|
||||
3. In a separately approved administrative process configured for the intended DB,
|
||||
call the reviewed repository operation for each ID, in committed bounded batches:
|
||||
|
||||
repository = AgentCommandRepository()
|
||||
count = repository.retire_unsupported(agent_id, actor_user_id=operator_user_id)
|
||||
|
||||
Repeat for all IDs while progress is made, under an explicit time/batch limit.
|
||||
This method never claims read work; do NOT substitute claim_pending or an Agent
|
||||
HTTP poll as an administrative drain. Each transition and rejection audit commits
|
||||
atomically. Database/audit failure rolls back and keeps the change window closed.
|
||||
4. A batch is bounded at 1000 and uses SKIP LOCKED. A zero count may mean locked rows,
|
||||
not completion. Resolve/retry approved lock holders with time limits; never
|
||||
silently declare the drain finished. Query ALL Agents to verify a zero live count:
|
||||
|
||||
SELECT agent_id, command_type, command_status, count(*)
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE command_status IN ('pending', 'claimed')
|
||||
AND command_type NOT IN
|
||||
('poll_known_miners_now', 'discover_new_miners', 'enrich_match_discovery')
|
||||
GROUP BY agent_id, command_type, command_status;
|
||||
|
||||
5. Reconcile newly cancelled rows to agent.command.rejected records. Check command ID,
|
||||
Agent ID, prior state, immutable rejection reason, source and operator attribution.
|
||||
Current pending status does not establish that a command was never delivered.
|
||||
Current claimed status, any retained claimed_at, completion/result/error evidence,
|
||||
linked legacy result or non-creation audit history, an unlinked legacy result
|
||||
for that Agent, or an Agent result submission
|
||||
requires execution_outcome=unknown. Missing or ambiguous evidence is unknown.
|
||||
Only a pending row with a complete pre-cancellation check finding no delivery
|
||||
evidence is classified not_dispatched by the queue. Historical claim evidence
|
||||
survives cancellation; an explicitly repaired/requeued pending row is still
|
||||
unknown. No ordinary production requeue mechanism is introduced by Stage A.
|
||||
Neither classification proves absence of out-of-band historical physical writes.
|
||||
Repeated retirement must add no duplicate audit and must not rewrite history.
|
||||
6. Schedule cancel/delete uses the stored schedule's Agent ID and the same rejection
|
||||
audit policy; delete retains the schedule. This is NOT a fleet drain: orphaned or
|
||||
foreign-Agent rows are handled by the per-Agent cutover sweep above. Never delete
|
||||
queue/audit rows, reset claimed commands to pending, or rewrite terminal results.
|
||||
Existing pre-remediation cancelled rows missing an audit must be preserved and
|
||||
documented as a historical evidence gap. Do not invent a prior-state audit.
|
||||
|
||||
Exit gate: the global live-unsupported query returns no rows, locks are accounted
|
||||
for, transition counts/audits reconcile, and no old producer can replenish work.
|
||||
This gate does not revoke already delivered payloads; gates 1, 2 and 5 remain mandatory.
|
||||
|
||||
Gate 5: upgrade Windows code and verify installed bytes while stopped
|
||||
|
||||
1. Use a verified artifact prepared from the exact independently reviewed Stage A
|
||||
boundary. Record its digest and a trusted expected SHA-256 manifest outside the
|
||||
target installation. VERSION is unchanged at 1.0.0-alpha and is not upgrade proof.
|
||||
2. Include all managed runtime scripts, especially MinerOpsAgentService.ps1,
|
||||
MinerOpsControl.ps1, EnrichOneMiner.ps1, MinerOpsApi3EnrichmentPoller.ps1, the pool
|
||||
pollers, task/GUI/tray scripts, and VERSION. Check the package's installer and
|
||||
enrollment/credential-upgrade entry points as well. Include the manifest's origin
|
||||
and review approval; hashes computed only from a suspect host are not a trust root.
|
||||
3. Confirm the main task was positively stopped BEFORE invoking the existing
|
||||
installer. It can auto-restart a task that it found running. Keep the external
|
||||
network fence and restart hold throughout installation and failure recovery.
|
||||
Do not pass filesystem trust-reset switches unless their existing reviewed
|
||||
recovery workflow specifically calls for them. Preserve config/state/logs/backups.
|
||||
4. Compare installed Get-FileHash -Algorithm SHA256 values to the trusted manifest.
|
||||
Review task actions for the expected executable, canonical script path, arguments,
|
||||
principal, and original/reviewed definition; check all aliases/duplicate tasks.
|
||||
Verify ACLs, signer/source provenance where used, absence of old running processes,
|
||||
and the task state consistent with the maintenance hold. Do not overwrite an
|
||||
established task definition merely to force a convenient state.
|
||||
5. Follow the existing credential transition/enrollment recovery workflow only if
|
||||
required. Hub credentials and local miner credentials are different trust domains;
|
||||
rotating a Hub secret cannot recall a cached physical command. Keep credentials
|
||||
local and protected. Never restore an old credential/config to make a test pass.
|
||||
|
||||
Exit gate: installed bytes and process/task state match the approved ledger for
|
||||
every Agent proposed for release. An inaccessible Agent, hash mismatch, duplicate
|
||||
task, unexpected process, or trust-reset blocker remains fenced and stopped.
|
||||
|
||||
Gate 6: native Windows validation and fresh process startup
|
||||
|
||||
Before production release, run on a network-isolated native Windows PowerShell 5.1
|
||||
host with simulated miners only. Linux PowerShell 7 tests are not evidence of SYSTEM
|
||||
task lifecycle correctness. Validate these points and retain results:
|
||||
- Actual SYSTEM/Scheduled Task stop, child cleanup, restart hold and reboot behavior.
|
||||
- Installed package hash verification and fresh module loading after upgrade.
|
||||
- Legacy/malformed/unsupported cached commands rejected with zero physical writes.
|
||||
- Diagnostic write/parameter overrides rejected before any device connection;
|
||||
fixed telemetry/discovery/API3 read selections remain compatible with simulators.
|
||||
- Enrollment, credential-transition interruption/recovery, and running/stopped intent
|
||||
retain existing protections, without credentials appearing in normal output.
|
||||
- A full network fence prevents an old cached command from reaching a live simulator;
|
||||
a separate unfenced positive control proves the simulator and old-executor setup.
|
||||
|
||||
1. Start only a fresh verified Agent process, initially still fenced, after all
|
||||
preceding gates are satisfied. Verify executable/script paths, start time after
|
||||
publication, loaded source provenance, per-Agent authentication, Zone assignment,
|
||||
and unavailable control advertisement. The advertisement alone is not proof.
|
||||
2. Recheck the unsupported queue and audit reconciliation after startup. Do not
|
||||
restore read connectivity for a host with any unverified old executor.
|
||||
3. Release the fence only for individually verified upgraded hosts under the approved
|
||||
network change. Re-enable only reviewed telemetry schedules. Physical control
|
||||
stays disabled on Hub AND Agent. Retain independent monitor/rollback evidence.
|
||||
|
||||
Exit gate: every released Agent has complete positive verification. Fleet-wide
|
||||
containment cannot be claimed while unverified devices have an unfenced route.
|
||||
|
||||
Gate 7: returning offline Agents and rollback/recovery
|
||||
|
||||
Maintain a default quarantine for every offline, unknown, historical or unverified
|
||||
Agent when it reconnects. A timestamp, IP address, same VERSION, successful Hub
|
||||
authentication, or control_available=false claim cannot release it. Repeat stop,
|
||||
hash/task verification, upgrade, fresh-start and relevant gates before restoring
|
||||
miner connectivity. Keep retired Agent credentials retired; do not reopen legacy
|
||||
shared-secret fallback for convenience.
|
||||
|
||||
If ANY gate fails:
|
||||
- Keep or reinstate physical network fencing immediately. Stop/restart-hold Agent
|
||||
executors and prevent old Hub producers from running. Preserve evidence and notify
|
||||
the change owner; do not automatically resume the previous running state.
|
||||
- Database/audit failure: leave rolled-back rows as evidence, resolve the error,
|
||||
retry audited retirement, and re-run the global zero-live check. Never repair
|
||||
audit integrity by deleting history or fabricating execution outcomes.
|
||||
- Installer/recovery failure: preserve trust blockers, recovery tokens and protected
|
||||
backups according to existing recovery instructions. Verify state before retry.
|
||||
- If restoring old Hub or Agent binaries is unavoidable, those binaries stay stopped
|
||||
and all affected miner routes stay fenced. An operational rollback does not restore
|
||||
security containment. Do not reconnect until the reviewed Stage A bytes are again
|
||||
installed and every failed gate is repeated successfully.
|
||||
- If verification cannot be completed within the change window, leave the affected
|
||||
host quarantined with a named owner. Partial success is not authority to un-fence it.
|
||||
|
||||
Disposable simulation available for independent review
|
||||
|
||||
agents/windows/tests/Test-MinerOpsOldAgentFence.ps1 loads AST functions from a
|
||||
separate git archive of 2d89e80 and the audit's cached suspend payload. It requires
|
||||
MINEROPS_STAGE_A_SIMULATION=1 and an explicitly provided simulated target. The
|
||||
remediation artifact directory contains simulate_fence.py and simulated_miner.py
|
||||
which create a temporary Docker INTERNAL network and fake API3 miner, then run the
|
||||
old executor first with --network none (zero simulated writes) and separately on
|
||||
the internal network (one simulated service-stop write). No host ports are exposed.
|
||||
Only those created resources are removed. This demonstrates the coarse fence's
|
||||
effect, not the adequacy of any production firewall/NAC design. Native Windows
|
||||
evidence and actual fleet/network verification remain separate release gates.
|
||||
@@ -78,8 +78,12 @@ migration_head="$(
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"'
|
||||
)"
|
||||
if [[ "${migration_head}" != "0016_agent_zones" ]]; then
|
||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0016_agent_zones." >&2
|
||||
expected_head="$(
|
||||
"${compose[@]}" run --rm --no-deps backend alembic heads 2>/dev/null |
|
||||
awk 'NR == 1 { print $1 }'
|
||||
)"
|
||||
if [[ -z "${expected_head}" || "${migration_head}" != "${expected_head}" ]]; then
|
||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected ${expected_head:-unresolved}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_CREDENTIAL_TRANSITION_PROJECT:-minerops-credential-transition-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
for source_file in \
|
||||
backend/tests/test_agent_credential_transition_postgres.py \
|
||||
agents/windows/Upgrade-MinerOpsAgentCredential.ps1; do
|
||||
permissions="$(stat -c '%a' "${source_file}")"
|
||||
(( (10#${permissions} % 10) & 4 )) || {
|
||||
echo "${source_file} is not readable by the non-root test container" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
"${compose[@]}" build backend
|
||||
expected_head="$("${compose[@]}" run --rm --no-deps backend alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
[[ "${expected_head}" == "0020_existing_agent_transition" ]]
|
||||
|
||||
echo "Testing the additive 0019 -> 0020 transition-binding migration..."
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade 0019_agent_legacy_auth_retire
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||
|
||||
echo "Testing production-shaped transactional credential transition..."
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
--env MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE=1 \
|
||||
--env MINEROPS_AGENT_ID=plexus-windows-agent \
|
||||
--env MINEROPS_AGENT_SHARED_SECRET=credential-transition-legacy-secret \
|
||||
backend python -m unittest -v \
|
||||
tests.test_agent_credential_transition_postgres
|
||||
|
||||
echo "Testing downgrade refusal after transition history exists..."
|
||||
if output="$("${compose[@]}" run --rm --no-deps backend \
|
||||
alembic downgrade 0019_agent_legacy_auth_retire 2>&1)"; then
|
||||
echo "0020 downgrade unexpectedly discarded transition history" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"existing-Agent credential transition history exists"* ]]
|
||||
|
||||
echo "Existing-Agent credential transition checks passed."
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_LEGACY_RETIREMENT_PROJECT:-minerops-legacy-retirement-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
run_db_alembic() {
|
||||
local database="$1"
|
||||
shift
|
||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic "$@"
|
||||
}
|
||||
psql_named() {
|
||||
local database="$1"
|
||||
shift
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'database="$1"; shift; exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$database" "$@"' \
|
||||
sh "${database}" "$@"
|
||||
}
|
||||
create_database() {
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'exec createdb -U "$POSTGRES_USER" "$1"' sh "$1"
|
||||
}
|
||||
run_contract() {
|
||||
local database="$1"
|
||||
local case_name="$2"
|
||||
local agent_id="$3"
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
--env POSTGRES_DB="${database}" \
|
||||
--env MINEROPS_LEGACY_RETIREMENT_DISPOSABLE=1 \
|
||||
--env MINEROPS_LEGACY_RETIREMENT_CASE="${case_name}" \
|
||||
--env MINEROPS_AGENT_ID="${agent_id}" \
|
||||
--env MINEROPS_AGENT_SHARED_SECRET=legacy-retirement-contract-secret \
|
||||
backend python -m unittest -v \
|
||||
tests.test_agent_legacy_auth_retirement_postgres
|
||||
}
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
"${compose[@]}" build backend
|
||||
expected_head="$(run_db_alembic postgres heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
[[ "${expected_head}" == "0020_existing_agent_transition" ]]
|
||||
|
||||
echo "Testing selective 0019 backfill from the 0018 schema..."
|
||||
create_database legacy_retirement_uncredentialed
|
||||
run_db_alembic legacy_retirement_uncredentialed upgrade 0018_legacy_agent_routing
|
||||
psql_named legacy_retirement_uncredentialed -c \
|
||||
"INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('legacy-uncredentialed', 'Legacy')"
|
||||
run_db_alembic legacy_retirement_uncredentialed upgrade head
|
||||
[[ "$(psql_named legacy_retirement_uncredentialed -Atc "SELECT legacy_auth_disabled_at IS NULL FROM minerops_site_agents WHERE agent_id='legacy-uncredentialed'")" == "t" ]]
|
||||
[[ "$(psql_named legacy_retirement_uncredentialed -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='legacy-uncredentialed'")" == "0" ]]
|
||||
|
||||
create_database legacy_retirement_credentialed
|
||||
run_db_alembic legacy_retirement_credentialed upgrade 0018_legacy_agent_routing
|
||||
psql_named legacy_retirement_credentialed <<'SQL'
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_name, agent_secret_hash,
|
||||
credential_created_at, credential_revoked_at
|
||||
) VALUES (
|
||||
'legacy-credentialed', 'Legacy', repeat('a', 64),
|
||||
now() - interval '1 hour', now()
|
||||
);
|
||||
SQL
|
||||
run_db_alembic legacy_retirement_credentialed upgrade head
|
||||
[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT legacy_auth_disabled_at = credential_created_at FROM minerops_site_agents WHERE agent_id='legacy-credentialed'")" == "t" ]]
|
||||
[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT retired_at = (SELECT credential_created_at FROM minerops_site_agents WHERE agent_id='legacy-credentialed') FROM agent_legacy_auth_retirements WHERE agent_id='legacy-credentialed'")" == "t" ]]
|
||||
|
||||
echo "Testing backfill from used enrollment history after credential clearing..."
|
||||
create_database legacy_retirement_enrollment
|
||||
run_db_alembic legacy_retirement_enrollment upgrade 0018_legacy_agent_routing
|
||||
psql_named legacy_retirement_enrollment <<'SQL'
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES ('00000000-0000-4000-8000-000000001901', 'Enrollment Evidence');
|
||||
INSERT INTO agent_zones (id, site_id, name)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000001902',
|
||||
'00000000-0000-4000-8000-000000001901',
|
||||
'Enrollment Evidence Zone'
|
||||
);
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_name, site_id, agent_zone_id
|
||||
) VALUES (
|
||||
'enrollment-evidence-cleared',
|
||||
'Enrollment Evidence',
|
||||
'00000000-0000-4000-8000-000000001901',
|
||||
'00000000-0000-4000-8000-000000001902'
|
||||
);
|
||||
INSERT INTO agent_enrollments (
|
||||
token_hash, site_id, agent_zone_id, display_name,
|
||||
created_at, expires_at, used_at, enrolled_agent_id
|
||||
) VALUES (
|
||||
repeat('c', 64),
|
||||
'00000000-0000-4000-8000-000000001901',
|
||||
'00000000-0000-4000-8000-000000001902',
|
||||
'Enrollment Evidence Agent',
|
||||
now() - interval '2 hours',
|
||||
now() + interval '1 hour',
|
||||
now() - interval '1 hour',
|
||||
'enrollment-evidence-cleared'
|
||||
);
|
||||
SQL
|
||||
run_db_alembic legacy_retirement_enrollment upgrade head
|
||||
[[ "$(psql_named legacy_retirement_enrollment -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='enrollment-evidence-cleared'")" == "1" ]]
|
||||
[[ "$(psql_named legacy_retirement_enrollment -Atc "SELECT legacy_auth_disabled_at IS NOT NULL AND agent_secret_hash IS NULL AND credential_created_at IS NULL AND credential_revoked_at IS NULL FROM minerops_site_agents WHERE agent_id='enrollment-evidence-cleared'")" == "t" ]]
|
||||
|
||||
echo "Testing real PostgreSQL authentication before and after credential clearing..."
|
||||
run_contract legacy_retirement_uncredentialed legacy legacy-uncredentialed
|
||||
run_contract legacy_retirement_credentialed retired legacy-credentialed
|
||||
|
||||
echo "Testing database-enforced atomic retirement and ledger immutability..."
|
||||
psql_named legacy_retirement_credentialed -c \
|
||||
"INSERT INTO minerops_site_agents (agent_id, site_name, agent_secret_hash, credential_created_at) VALUES ('database-issued', 'Issued', repeat('b', 64), now())"
|
||||
[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='database-issued'")" == "1" ]]
|
||||
[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT legacy_auth_disabled_at IS NOT NULL FROM minerops_site_agents WHERE agent_id='database-issued'")" == "t" ]]
|
||||
if psql_named legacy_retirement_credentialed -c \
|
||||
"UPDATE minerops_site_agents SET legacy_auth_disabled_at = NULL WHERE agent_id='legacy-credentialed'"; then
|
||||
echo "Permanent retirement marker was unexpectedly cleared" >&2
|
||||
exit 1
|
||||
fi
|
||||
if psql_named legacy_retirement_credentialed -c \
|
||||
"UPDATE agent_legacy_auth_retirements SET retired_at = now() WHERE agent_id='legacy-credentialed'"; then
|
||||
echo "Retirement history was unexpectedly updated" >&2
|
||||
exit 1
|
||||
fi
|
||||
if psql_named legacy_retirement_credentialed -c \
|
||||
"DELETE FROM agent_legacy_auth_retirements WHERE agent_id='legacy-credentialed'"; then
|
||||
echo "Retirement history was unexpectedly deleted" >&2
|
||||
exit 1
|
||||
fi
|
||||
if psql_named legacy_retirement_credentialed -c \
|
||||
"TRUNCATE agent_legacy_auth_retirements"; then
|
||||
echo "Retirement history was unexpectedly truncated" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Testing downgrade decision points..."
|
||||
run_db_alembic legacy_retirement_uncredentialed downgrade 0018_legacy_agent_routing
|
||||
[[ "$(psql_named legacy_retirement_uncredentialed -Atc 'SELECT version_num FROM alembic_version')" == "0018_legacy_agent_routing" ]]
|
||||
if output="$(run_db_alembic legacy_retirement_credentialed downgrade 0018_legacy_agent_routing 2>&1)"; then
|
||||
echo "0019 downgrade unexpectedly discarded retirement history" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"durable identity-level legacy-auth retirement history exists"* ]]
|
||||
[[ "$(psql_named legacy_retirement_credentialed -Atc 'SELECT version_num FROM alembic_version')" == "${expected_head}" ]]
|
||||
|
||||
echo "Durable legacy Agent authentication retirement checks passed."
|
||||
@@ -179,8 +179,9 @@ run_alembic upgrade 0016_agent_zones
|
||||
echo "Testing empty database through head..."
|
||||
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_agent_zones_empty'
|
||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_agent_zones_empty backend alembic upgrade head
|
||||
expected_head="$(run_alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -U "$POSTGRES_USER" -d minerops_agent_zones_empty -Atc "SELECT version_num FROM alembic_version"')"
|
||||
[[ "${empty_revision}" == "0016_agent_zones" ]]
|
||||
[[ -n "${expected_head}" && "${empty_revision}" == "${expected_head}" ]]
|
||||
|
||||
echo "Agent Zones migration lifecycle checks passed."
|
||||
@@ -132,7 +132,9 @@ health_json="$(cat "${health_file}")"
|
||||
|
||||
echo "Checking migrations and required schema..."
|
||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0017_agent_enrollment"* ]]
|
||||
expected_revision="$("${compose[@]}" exec -T backend alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
[[ -n "${expected_revision}" ]]
|
||||
[[ "${current_revision}" == *"${expected_revision}"* ]]
|
||||
|
||||
missing_tables="$(
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_LEGACY_HTTP_PROJECT:-minerops-legacy-http-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
"${compose[@]}" build backend
|
||||
|
||||
echo "Building a production-shaped 0014 database..."
|
||||
"${compose[@]}" run --rm --no-deps backend \
|
||||
alembic upgrade 0014_repair_parts_tracking
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||
INSERT INTO sites (id, name) VALUES
|
||||
('00000000-0000-4000-8000-000000001a01', 'Plexus'),
|
||||
('00000000-0000-4000-8000-000000001a02', 'Contract Site B');
|
||||
INSERT INTO miners (
|
||||
id, site_id, asset_tag, current_ip, current_mac, status,
|
||||
first_seen_at, last_seen_at
|
||||
) VALUES
|
||||
(
|
||||
'00000000-0000-4000-8000-000000001a11',
|
||||
'00000000-0000-4000-8000-000000001a01',
|
||||
'LEGACY-HTTP-A', '192.0.2.201', '02:00:00:00:1a:11',
|
||||
'online', NULL, NULL
|
||||
),
|
||||
(
|
||||
'00000000-0000-4000-8000-000000001a12',
|
||||
'00000000-0000-4000-8000-000000001a02',
|
||||
'LEGACY-HTTP-B', '192.0.2.202', '02:00:00:00:1a:12',
|
||||
'online', NULL, NULL
|
||||
);
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES ('legacy-plexus-http', ' plexus ');
|
||||
SQL
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||
|
||||
echo "Running real ASGI authentication and PostgreSQL routing checks..."
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
--env MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE=1 \
|
||||
--env MINEROPS_AGENT_ID=legacy-plexus-http \
|
||||
--env MINEROPS_AGENT_SHARED_SECRET=legacy-http-contract-secret \
|
||||
backend python -m unittest -v \
|
||||
tests.test_legacy_agent_http_postgres
|
||||
|
||||
echo "Full-stack legacy Agent HTTP/PostgreSQL contract passed."
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_LEGACY_PRODUCTION_PROJECT:-minerops-legacy-production-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
"${compose[@]}" build backend
|
||||
|
||||
echo "Building the production-shaped database from the 0014 baseline..."
|
||||
"${compose[@]}" run --rm --no-deps backend \
|
||||
alembic upgrade 0014_repair_parts_tracking
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||
INSERT INTO sites (id, name) VALUES
|
||||
('00000000-0000-4000-8000-000000001901', 'Plexus'),
|
||||
('00000000-0000-4000-8000-000000001902', 'Contract Site B');
|
||||
INSERT INTO miners (
|
||||
id, site_id, asset_tag, current_ip, current_mac, status,
|
||||
first_seen_at, last_seen_at
|
||||
) VALUES
|
||||
(
|
||||
'00000000-0000-4000-8000-000000001911',
|
||||
'00000000-0000-4000-8000-000000001901',
|
||||
'LEGACY-CONTRACT-A', '192.0.2.191', '02:00:00:00:19:11',
|
||||
'online', NULL, NULL
|
||||
),
|
||||
(
|
||||
'00000000-0000-4000-8000-000000001912',
|
||||
'00000000-0000-4000-8000-000000001902',
|
||||
'LEGACY-CONTRACT-B', '192.0.2.192', '02:00:00:00:19:12',
|
||||
'online', NULL, NULL
|
||||
);
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES ('legacy-plexus-production', ' plexus ');
|
||||
SQL
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||
|
||||
echo "Running legacy authentication, routing, and inert telemetry contract..."
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
--env MINEROPS_LEGACY_PRODUCTION_DISPOSABLE=1 \
|
||||
--env MINEROPS_AGENT_ID=legacy-plexus-production \
|
||||
--env MINEROPS_AGENT_SHARED_SECRET=legacy-production-contract-secret \
|
||||
backend python -m unittest -v \
|
||||
tests.test_legacy_agent_production_postgres
|
||||
|
||||
echo "Legacy production compatibility contract passed."
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_LEGACY_ROUTING_PROJECT:-minerops-legacy-routing-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
run_db_alembic() {
|
||||
local database="$1"
|
||||
shift
|
||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic "$@"
|
||||
}
|
||||
psql_named() {
|
||||
local database="$1"
|
||||
shift
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'database="$1"; shift; exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$database" "$@"' \
|
||||
sh "${database}" "$@"
|
||||
}
|
||||
create_database() {
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'exec createdb -U "$POSTGRES_USER" "$1"' sh "$1"
|
||||
}
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
"${compose[@]}" build backend
|
||||
expected_revision="0018_legacy_agent_routing"
|
||||
|
||||
echo "Testing the real single-Site production shape from 0014 -> 0018..."
|
||||
create_database legacy_routing_happy
|
||||
run_db_alembic legacy_routing_happy upgrade 0014_repair_parts_tracking
|
||||
psql_named legacy_routing_happy <<'SQL'
|
||||
DELETE FROM sites;
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES (
|
||||
'191e6d8f-ce40-4488-a8f1-ad6ce1790266',
|
||||
'Plexus Mining Site'
|
||||
);
|
||||
INSERT INTO miners (id, site_id, asset_tag, first_seen_at, last_seen_at, current_mac)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000001811',
|
||||
'191e6d8f-ce40-4488-a8f1-ad6ce1790266',
|
||||
'LEGACY-ROUTING-MINER', NULL, NULL, '02:00:00:00:18:11'
|
||||
);
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES ('plexus-windows-agent', 'Plexus');
|
||||
INSERT INTO minerops_agent_snapshots (agent_id, site_name, miner_count, payload)
|
||||
VALUES ('plexus-windows-agent', 'Plexus', 1, '{}');
|
||||
INSERT INTO minerops_agent_discoveries (
|
||||
id, agent_id, site_name, ip_address, raw_payload, discovery_status
|
||||
) VALUES (
|
||||
'legacy-routing-discovery', 'plexus-windows-agent', 'Plexus',
|
||||
'192.0.2.181', '{}', 'new'
|
||||
);
|
||||
SQL
|
||||
run_db_alembic legacy_routing_happy upgrade "${expected_revision}"
|
||||
[[ "$(psql_named legacy_routing_happy -Atc 'SELECT version_num FROM alembic_version')" == "${expected_revision}" ]]
|
||||
psql_named legacy_routing_happy <<'SQL'
|
||||
DO $$
|
||||
DECLARE
|
||||
expected_site uuid := '191e6d8f-ce40-4488-a8f1-ad6ce1790266';
|
||||
expected_zone uuid;
|
||||
BEGIN
|
||||
SELECT id INTO expected_zone FROM agent_zones
|
||||
WHERE site_id = expected_site AND lower(name) = 'default zone' AND is_enabled;
|
||||
IF expected_zone IS NULL THEN
|
||||
RAISE EXCEPTION 'Expected enabled Default Zone is missing';
|
||||
END IF;
|
||||
IF (SELECT site_id FROM minerops_site_agents
|
||||
WHERE agent_id = 'plexus-windows-agent')
|
||||
IS DISTINCT FROM expected_site THEN
|
||||
RAISE EXCEPTION 'Sole-Site legacy Agent resolution failed';
|
||||
END IF;
|
||||
IF (SELECT agent_zone_id FROM minerops_site_agents
|
||||
WHERE agent_id = 'plexus-windows-agent')
|
||||
IS DISTINCT FROM expected_zone THEN
|
||||
RAISE EXCEPTION 'Legacy Agent Zone routing failed';
|
||||
END IF;
|
||||
IF (SELECT agent_zone_id FROM miners
|
||||
WHERE id = '00000000-0000-4000-8000-000000001811')
|
||||
IS DISTINCT FROM expected_zone THEN
|
||||
RAISE EXCEPTION 'Existing miner ownership was not preserved in its Site default Zone';
|
||||
END IF;
|
||||
IF (SELECT current_mac FROM miners
|
||||
WHERE id = '00000000-0000-4000-8000-000000001811')
|
||||
IS DISTINCT FROM '02:00:00:00:18:11' THEN
|
||||
RAISE EXCEPTION 'Miner identity/history projection was rewritten';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM minerops_agent_snapshots
|
||||
WHERE agent_id = 'plexus-windows-agent'
|
||||
AND agent_zone_id IS DISTINCT FROM expected_zone
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Legacy snapshots were not attributed to the Agent Zone';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM minerops_agent_discoveries
|
||||
WHERE agent_id = 'plexus-windows-agent'
|
||||
AND agent_zone_id IS DISTINCT FROM expected_zone
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Legacy discoveries were not attributed to the Agent Zone';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
echo "Testing unmatched multi-Site data fails closed before routing writes..."
|
||||
create_database legacy_routing_missing
|
||||
run_db_alembic legacy_routing_missing upgrade 0014_repair_parts_tracking
|
||||
psql_named legacy_routing_missing <<'SQL'
|
||||
DELETE FROM sites;
|
||||
INSERT INTO sites (id, name) VALUES
|
||||
('00000000-0000-4000-8000-000000001801', 'Candidate Site A'),
|
||||
('00000000-0000-4000-8000-000000001802', 'Candidate Site B');
|
||||
SQL
|
||||
psql_named legacy_routing_missing -c \
|
||||
"INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('missing-agent', 'No Such Site')"
|
||||
run_db_alembic legacy_routing_missing upgrade 0017_agent_enrollment
|
||||
if output="$(run_db_alembic legacy_routing_missing upgrade "${expected_revision}" 2>&1)"; then
|
||||
echo "Missing Site match unexpectedly passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"unmatched multi-Site installations fail closed"* ]]
|
||||
[[ "${output}" == *"missing-agent"* && "${output}" == *"/'0'/'2'"* ]]
|
||||
[[ "$(psql_named legacy_routing_missing -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]]
|
||||
[[ "$(psql_named legacy_routing_missing -Atc "SELECT site_id IS NULL FROM minerops_site_agents WHERE agent_id='missing-agent'")" == "t" ]]
|
||||
[[ "$(psql_named legacy_routing_missing -Atc "SELECT agent_zone_id IS NULL FROM minerops_site_agents WHERE agent_id='missing-agent'")" == "t" ]]
|
||||
|
||||
echo "Testing ambiguous normalized Site match fails closed..."
|
||||
create_database legacy_routing_ambiguous
|
||||
run_db_alembic legacy_routing_ambiguous upgrade 0014_repair_parts_tracking
|
||||
psql_named legacy_routing_ambiguous <<'SQL'
|
||||
DELETE FROM sites;
|
||||
INSERT INTO sites (id, name) VALUES
|
||||
('00000000-0000-4000-8000-000000001821', 'Ambiguous Site'),
|
||||
('00000000-0000-4000-8000-000000001822', 'ambiguous site');
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES ('ambiguous-agent', ' AMBIGUOUS SITE ');
|
||||
SQL
|
||||
run_db_alembic legacy_routing_ambiguous upgrade 0017_agent_enrollment
|
||||
if output="$(run_db_alembic legacy_routing_ambiguous upgrade "${expected_revision}" 2>&1)"; then
|
||||
echo "Ambiguous Site match unexpectedly passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"ambiguous-agent"* && "${output}" == *"/'2'"* ]]
|
||||
[[ "$(psql_named legacy_routing_ambiguous -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]]
|
||||
[[ "$(psql_named legacy_routing_ambiguous -Atc "SELECT site_id IS NULL AND agent_zone_id IS NULL FROM minerops_site_agents WHERE agent_id='ambiguous-agent'")" == "t" ]]
|
||||
|
||||
echo "Testing an exact normalized match wins in a multi-Site database..."
|
||||
create_database legacy_routing_exact_multi
|
||||
run_db_alembic legacy_routing_exact_multi upgrade 0014_repair_parts_tracking
|
||||
psql_named legacy_routing_exact_multi <<'SQL'
|
||||
DELETE FROM sites;
|
||||
INSERT INTO sites (id, name) VALUES
|
||||
('00000000-0000-4000-8000-000000001825', 'Exact Site'),
|
||||
('00000000-0000-4000-8000-000000001826', 'Unrelated Site');
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES ('exact-multi-agent', ' eXaCt SiTe ');
|
||||
SQL
|
||||
run_db_alembic legacy_routing_exact_multi upgrade 0017_agent_enrollment
|
||||
run_db_alembic legacy_routing_exact_multi upgrade "${expected_revision}"
|
||||
[[ "$(psql_named legacy_routing_exact_multi -Atc "SELECT site_id FROM minerops_site_agents WHERE agent_id='exact-multi-agent'")" == "00000000-0000-4000-8000-000000001825" ]]
|
||||
[[ "$(psql_named legacy_routing_exact_multi -Atc "SELECT agent.agent_zone_id = zone.id FROM minerops_site_agents AS agent JOIN agent_zones AS zone ON zone.site_id=agent.site_id AND zone.name='Default Zone' WHERE agent.agent_id='exact-multi-agent'")" == "t" ]]
|
||||
|
||||
echo "Testing two legacy Agents targeting one Default Zone fails closed..."
|
||||
create_database legacy_routing_conflict
|
||||
run_db_alembic legacy_routing_conflict upgrade 0014_repair_parts_tracking
|
||||
psql_named legacy_routing_conflict <<'SQL'
|
||||
DELETE FROM sites;
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES ('00000000-0000-4000-8000-000000001831', 'Sole Durable Site');
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name) VALUES
|
||||
('conflict-agent-a', 'Legacy Display A'),
|
||||
('conflict-agent-b', 'Legacy Display B');
|
||||
SQL
|
||||
run_db_alembic legacy_routing_conflict upgrade 0017_agent_enrollment
|
||||
if output="$(run_db_alembic legacy_routing_conflict upgrade "${expected_revision}" 2>&1)"; then
|
||||
echo "One-Agent-per-Zone conflict unexpectedly passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"one-Agent-per-Zone would be violated"* ]]
|
||||
[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]]
|
||||
[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT count(*) FROM minerops_site_agents WHERE site_id IS NOT NULL')" == "0" ]]
|
||||
[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT count(*) FROM minerops_site_agents WHERE agent_zone_id IS NOT NULL')" == "0" ]]
|
||||
|
||||
echo "Testing explicit Site and Zone assignments win..."
|
||||
create_database legacy_routing_explicit
|
||||
run_db_alembic legacy_routing_explicit upgrade 0017_agent_enrollment
|
||||
psql_named legacy_routing_explicit <<'SQL'
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES ('00000000-0000-4000-8000-000000001841', 'Explicit Site');
|
||||
INSERT INTO agent_zones (
|
||||
id, site_id, name
|
||||
) VALUES (
|
||||
'00000000-0000-4000-8000-000000001842',
|
||||
'00000000-0000-4000-8000-000000001841',
|
||||
'Explicit Zone'
|
||||
);
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name
|
||||
) VALUES (
|
||||
'explicit-agent',
|
||||
'00000000-0000-4000-8000-000000001841',
|
||||
'00000000-0000-4000-8000-000000001842',
|
||||
'A Different Display Name'
|
||||
);
|
||||
SQL
|
||||
run_db_alembic legacy_routing_explicit upgrade "${expected_revision}"
|
||||
[[ "$(psql_named legacy_routing_explicit -Atc "SELECT site_id || '/' || agent_zone_id FROM minerops_site_agents WHERE agent_id='explicit-agent'")" == "00000000-0000-4000-8000-000000001841/00000000-0000-4000-8000-000000001842" ]]
|
||||
|
||||
echo "Testing conservative downgrade refusal..."
|
||||
if output="$(run_db_alembic legacy_routing_happy downgrade 0017_agent_enrollment 2>&1)"; then
|
||||
echo "0018 downgrade unexpectedly discarded routing attribution" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"Restore the pre-upgrade database backup"* ]]
|
||||
[[ "$(psql_named legacy_routing_happy -Atc 'SELECT version_num FROM alembic_version')" == "${expected_revision}" ]]
|
||||
|
||||
echo "Legacy Agent routing migration lifecycle checks passed."
|
||||
@@ -130,8 +130,9 @@ run_alembic upgrade 0015_miner_control_foundation
|
||||
echo "Testing empty database through head..."
|
||||
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty'
|
||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head
|
||||
expected_head="$(run_alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')"
|
||||
[[ "${empty_revision}" == "0015_miner_control_foundation" ]]
|
||||
[[ -n "${expected_head}" && "${empty_revision}" == "${expected_head}" ]]
|
||||
|
||||
echo "Miner Control Foundation migration tests passed."
|
||||
@@ -16,7 +16,7 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
for source_file in backend/tests/test_agent_auth.py backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py; do
|
||||
for source_file in backend/tests/test_agent_auth.py backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py backend/tests/test_legacy_control_retirement.py; do
|
||||
permissions="$(stat -c '%a' "${source_file}")"
|
||||
(( (10#${permissions} % 10) & 4 )) || {
|
||||
echo "${source_file} is not readable by the non-root test container" >&2
|
||||
@@ -47,6 +47,7 @@ done
|
||||
tests.test_agent_auth \
|
||||
tests.test_miner_control \
|
||||
tests.test_agent_commands \
|
||||
tests.test_legacy_control_retirement \
|
||||
tests.test_miner_control_postgres
|
||||
|
||||
echo "Miner Control Foundation focused tests passed."
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run inside an isolated test environment with a freshly migrated disposable DB.
|
||||
# No service creation, cleanup, miner commands, or production defaults.
|
||||
set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}/backend"
|
||||
[[ "${MINEROPS_STAGE_A_DISPOSABLE:-}" == 1 ]] || {
|
||||
echo "Require MINEROPS_STAGE_A_DISPOSABLE=1 and an isolated test database." >&2
|
||||
exit 1
|
||||
}
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
export MINEROPS_ENABLE_REAL_REBOOT=false
|
||||
export MINEROPS_ENABLE_REAL_POWER_CONTROL=false
|
||||
python -c 'from app.config import settings; assert settings.postgres_db.startswith("minerops_stage_a_"), "Refusing non-Stage-A database"'
|
||||
python -m unittest -v \
|
||||
tests.test_legacy_control_retirement \
|
||||
tests.test_stage_a_containment \
|
||||
tests.test_stage_a_containment_postgres \
|
||||
tests.test_stage_a_delivery_evidence \
|
||||
tests.test_stage_a_delivery_evidence_postgres \
|
||||
tests.test_stage_a_schedule_cancellation_postgres \
|
||||
tests.test_agent_zones_postgres \
|
||||
tests.test_miner_control \
|
||||
tests.test_agent_commands.AgentCommandServiceTests \
|
||||
tests.test_agent_commands.AgentCommandRouterTests
|
||||
# Separately run agents/windows/tests/Test-MinerOpsControl.ps1 in a network-free
|
||||
# PowerShell environment, then Test-MinerOpsDiagnosticTransport.ps1. Both load
|
||||
# functions only. Diagnostic compatibility listeners bind loopback exclusively.
|
||||
@@ -37,6 +37,7 @@ echo "=== VERIFY CANONICAL SOURCE IS IN IMAGE ==="
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
backend sh -ec '
|
||||
test -f /app/agents/windows/Enroll-MinerOpsAgent.ps1
|
||||
test -f /app/agents/windows/Upgrade-MinerOpsAgentCredential.ps1
|
||||
test -f /app/agents/windows/install.ps1
|
||||
test -f /app/agents/windows/MinerOpsAgentService.ps1
|
||||
test -f /app/agents/windows/VERSION
|
||||
@@ -52,7 +53,9 @@ docker run --rm \
|
||||
|
||||
foreach ($script in @(
|
||||
"/work/Enroll-MinerOpsAgent.ps1",
|
||||
"/work/install.ps1"
|
||||
"/work/Upgrade-MinerOpsAgentCredential.ps1",
|
||||
"/work/install.ps1",
|
||||
"/work/MinerOpsAgentGUI.ps1"
|
||||
)) {
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
@@ -70,6 +73,8 @@ docker run --rm \
|
||||
}
|
||||
|
||||
& /work/tests/Test-MinerOpsEnrollment.ps1
|
||||
& /work/tests/Test-MinerOpsBootEventParser.ps1
|
||||
& /work/tests/Test-MinerOpsCredentialTransition.ps1
|
||||
& /work/tests/Test-MinerOpsInstaller.ps1
|
||||
'
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ set -euo pipefail
|
||||
service="agents/windows/MinerOpsAgentService.ps1"
|
||||
control="agents/windows/MinerOpsControl.ps1"
|
||||
installer="agents/windows/install.ps1"
|
||||
transition="agents/windows/Upgrade-MinerOpsAgentCredential.ps1"
|
||||
gui="agents/windows/MinerOpsAgentGUI.ps1"
|
||||
rg -q 'X-MinerOps-Agent-ID' "${service}"
|
||||
rg -q 'X-MinerOps-Agent-ID' "${gui}"
|
||||
rg -Fq '$config.agent_id' "${gui}"
|
||||
rg -q 'X-MinerOps-Agent-Secret' "${gui}"
|
||||
rg -Fq '$config.agent_zone_id' "${service}"
|
||||
rg -Fq '$body["agent_zone_id"]' "${service}"
|
||||
rg -q '"agent_zone_id"' agents/windows/config.example.json
|
||||
@@ -28,9 +33,39 @@ rg -q 'could not restart restored MinerOpsAgent' "${installer}"
|
||||
! rg -q 'Register-ScheduledTask.*-Force' "${installer}"
|
||||
! rg -q 'agent_secret|api3_password|api3_account' "${installer}"
|
||||
|
||||
rg -q 'existing_agent_credential_transition' "${transition}"
|
||||
rg -q '/api/agent/credential-transition' "${transition}"
|
||||
rg -q 'RandomNumberGenerator' "${transition}"
|
||||
rg -q 'agent-credential-transition.recovery.json' "${transition}"
|
||||
rg -q 'minerops-credential-transition\.blocked' "${transition}"
|
||||
rg -q 'Assert-MinerOpsTrustedAncestorNamespace' "${transition}"
|
||||
rg -q 'Open-MinerOpsDirectoryLock' "${transition}"
|
||||
rg -q 'Invoke-MinerOpsRecoverableConfigInstall' "${transition}"
|
||||
rg -q 'active-moved-to-prior' "${transition}"
|
||||
rg -q 'pending-moved-to-active' "${transition}"
|
||||
! rg -q '\[IO\.File\]::Replace|Invoke-MinerOpsAtomicConfigReplace' "${transition}"
|
||||
rg -q 'minerops-filesystem-trust\.blocked-' "${installer}"
|
||||
rg -q 'ApproveFilesystemTrustResetAfterReboot' "${installer}"
|
||||
rg -q 'windows-full-boot-event-v1:' "${installer}"
|
||||
rg -q 'windows-full-boot-event-v1:' "${transition}"
|
||||
! rg -q 'LastBootUpTime' "${installer}" "${transition}" \
|
||||
agents/windows/Enroll-MinerOpsAgent.ps1
|
||||
! rg -q 'Remove-MinerOpsInstallerTrustBlocker' "${installer}"
|
||||
! rg -q 'Remove-MinerOpsEnrollmentTrustBlocker' \
|
||||
agents/windows/Enroll-MinerOpsAgent.ps1
|
||||
rg -q 'Remove-MinerOpsFilesystemTrustBlockerAfterConvergence' "${transition}"
|
||||
rg -q 'GetNumberOfLinks' "${installer}"
|
||||
rg -q 'Assert-MinerOpsInstallerTrustedAncestors' "${installer}"
|
||||
! rg -q 'plexus-windows-agent|Plexus Mining Site' "${transition}"
|
||||
rg -q 'Get-MinerOpsVerifiedScheduledTask' "${transition}"
|
||||
! rg -q 'Get-ScheduledTask[^[:cntrl:]]*SilentlyContinue' "${transition}"
|
||||
|
||||
if command -v pwsh >/dev/null 2>&1; then
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsBootEventParser.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsCredentialTransition.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsNativeHarness.ps1
|
||||
else
|
||||
echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed."
|
||||
fi
|
||||
|
||||
Reference in new issue
Block a user