Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b0d32cd6b | ||
|
|
e898f283ab | ||
|
|
2d89e80410 | ||
|
|
97a0139e3d | ||
|
|
3cfb0d1abe |
No files matched your search
@@ -2,7 +2,10 @@
|
|||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$IpAddress,
|
[string]$IpAddress,
|
||||||
|
|
||||||
[string]$MinerId = ""
|
[string]$MinerId = "",
|
||||||
|
[string]$DiscoveryId = "",
|
||||||
|
[string]$ObservationVersion = "",
|
||||||
|
[string]$CommandId = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||||
@@ -23,6 +26,24 @@ function Invoke-MinerCommandRaw {
|
|||||||
[int]$TimeoutMs = 2500
|
[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
|
$client = $null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -38,7 +59,7 @@ function Invoke-MinerCommandRaw {
|
|||||||
$client.SendTimeout = $TimeoutMs
|
$client.SendTimeout = $TimeoutMs
|
||||||
|
|
||||||
$stream = $client.GetStream()
|
$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)
|
$stream.Write($payload, 0, $payload.Length)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -146,6 +167,14 @@ $hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
|||||||
$secret = [string]$config.agent_secret
|
$secret = [string]$config.agent_secret
|
||||||
$agentId = [string]$config.agent_id
|
$agentId = [string]$config.agent_id
|
||||||
|
|
||||||
|
# Revalidate the claimed command against current Hub authority before miner sockets.
|
||||||
|
if (!$DiscoveryId -or !$ObservationVersion -or !$CommandId) { throw "discovery_observation_required" }
|
||||||
|
$target = Invoke-RestMethod -Method Post -Uri "$hubUrl/api/agent/discovery-target" `
|
||||||
|
-Headers @{ "X-MinerOps-Agent-ID" = $agentId; "X-MinerOps-Agent-Secret" = $secret } `
|
||||||
|
-ContentType "application/json" -Body (@{ discovery_id=$DiscoveryId; version=$ObservationVersion; command_id=$CommandId } | ConvertTo-Json)
|
||||||
|
$IpAddress = [string]$target.ip_address
|
||||||
|
if (!$target.ok -or !$IpAddress) { throw "discovery_target_denied" }
|
||||||
|
|
||||||
$commands = @(
|
$commands = @(
|
||||||
"summary",
|
"summary",
|
||||||
"devs",
|
"devs",
|
||||||
@@ -201,6 +230,9 @@ if (!$model) {
|
|||||||
$payload = [ordered]@{
|
$payload = [ordered]@{
|
||||||
ip_address = $IpAddress
|
ip_address = $IpAddress
|
||||||
miner_id = $MinerId
|
miner_id = $MinerId
|
||||||
|
discovery_id = $DiscoveryId
|
||||||
|
version = $ObservationVersion
|
||||||
|
command_id = $CommandId
|
||||||
mac_address = $mac
|
mac_address = $mac
|
||||||
hostname = $hostname
|
hostname = $hostname
|
||||||
miner_sn = $minerSn
|
miner_sn = $minerSn
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -204,9 +204,11 @@ $testButton.Add_Click({
|
|||||||
}
|
}
|
||||||
|
|
||||||
$hub = [string]$config.hub_url
|
$hub = [string]$config.hub_url
|
||||||
|
$agentId = [string]$config.agent_id
|
||||||
$secret = [string]$config.agent_secret
|
$secret = [string]$config.agent_secret
|
||||||
|
|
||||||
$headers = @{
|
$headers = @{
|
||||||
|
"X-MinerOps-Agent-ID" = $agentId
|
||||||
"X-MinerOps-Agent-Secret" = $secret
|
"X-MinerOps-Agent-Secret" = $secret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ function Invoke-AgentApi {
|
|||||||
"X-MinerOps-Agent-ID" = [string]$config.agent_id
|
"X-MinerOps-Agent-ID" = [string]$config.agent_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$batchField = $null
|
||||||
|
if ($Path -eq "/api/agent/miner-snapshots") { $batchField = "miners" }
|
||||||
|
elseif ($Path -eq "/api/agent/discoveries") { $batchField = "discoveries" }
|
||||||
|
if ($batchField -and $Body -ne $null -and @($Body[$batchField]).Count -gt 128) {
|
||||||
|
$items = @($Body[$batchField])
|
||||||
|
$results = @()
|
||||||
|
for ($offset = 0; $offset -lt $items.Count; $offset += 128) {
|
||||||
|
$batch = @{}
|
||||||
|
foreach ($key in $Body.Keys) { $batch[$key] = $Body[$key] }
|
||||||
|
$last = [Math]::Min($offset + 127, $items.Count - 1)
|
||||||
|
$batch[$batchField] = @($items[$offset..$last])
|
||||||
|
$results += Invoke-AgentApi -Method $Method -Path $Path -Body $batch
|
||||||
|
}
|
||||||
|
return @{ ok = $true; batches = $results; processed = $items.Count }
|
||||||
|
}
|
||||||
if ($Body -ne $null) {
|
if ($Body -ne $null) {
|
||||||
$json = $Body | ConvertTo-Json -Depth 100 -Compress
|
$json = $Body | ConvertTo-Json -Depth 100 -Compress
|
||||||
return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -ContentType "application/json" -Body $json -TimeoutSec 180
|
return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -ContentType "application/json" -Body $json -TimeoutSec 180
|
||||||
@@ -252,6 +267,7 @@ function Poll-KnownMiners {
|
|||||||
|
|
||||||
$result = [ordered]@{
|
$result = [ordered]@{
|
||||||
miner_id = [string]$Miner.miner_id
|
miner_id = [string]$Miner.miner_id
|
||||||
|
miner_version = [string]$Miner.miner_version
|
||||||
ip_address = [string]$Miner.ip_address
|
ip_address = [string]$Miner.ip_address
|
||||||
mac_address = [string]$Miner.mac_address
|
mac_address = [string]$Miner.mac_address
|
||||||
room_name = [string]$Miner.room_name
|
room_name = [string]$Miner.room_name
|
||||||
@@ -592,7 +608,7 @@ public static class MinerOpsFastScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Discover-MinersInRanges {
|
function Discover-MinersInRanges {
|
||||||
$config = Read-AgentConfig
|
$config = Invoke-AgentApi -Method "GET" -Path "/api/agent/config"
|
||||||
$ranges = @($config.ip_ranges | Where-Object { $_.enabled -eq $true })
|
$ranges = @($config.ip_ranges | Where-Object { $_.enabled -eq $true })
|
||||||
|
|
||||||
Ensure-FastScannerLoaded
|
Ensure-FastScannerLoaded
|
||||||
@@ -743,61 +759,7 @@ function Invoke-MinerPlainCommand {
|
|||||||
|
|
||||||
[int]$TimeoutMs = 2500
|
[int]$TimeoutMs = 2500
|
||||||
)
|
)
|
||||||
|
throw "physical_control_disabled_stage_a"
|
||||||
$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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-MinerOpsApi3Credential {
|
function Get-MinerOpsApi3Credential {
|
||||||
@@ -894,61 +856,7 @@ function Invoke-MinerOpsApi3Payload {
|
|||||||
|
|
||||||
[int]$TimeoutMs = 10000
|
[int]$TimeoutMs = 10000
|
||||||
)
|
)
|
||||||
|
throw "physical_control_disabled_stage_a"
|
||||||
$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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-MinerOpsApi3Salt {
|
function Get-MinerOpsApi3Salt {
|
||||||
@@ -989,43 +897,7 @@ function Invoke-MinerOpsApi3Service {
|
|||||||
[ValidateSet("start", "stop", "restart")]
|
[ValidateSet("start", "stop", "restart")]
|
||||||
[string]$ServiceAction
|
[string]$ServiceAction
|
||||||
)
|
)
|
||||||
|
throw "physical_control_disabled_stage_a"
|
||||||
$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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1054,45 +926,7 @@ function Invoke-MinerOpsApi3AuthenticatedCommand {
|
|||||||
|
|
||||||
[object]$Param = $null
|
[object]$Param = $null
|
||||||
)
|
)
|
||||||
|
throw "physical_control_disabled_stage_a"
|
||||||
$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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-MinerOpsPayloadValue {
|
function Get-MinerOpsPayloadValue {
|
||||||
@@ -1129,113 +963,14 @@ function Invoke-MinerOpsLegacy4028Command {
|
|||||||
[int] $Port = 4028,
|
[int] $Port = 4028,
|
||||||
[int] $TimeoutMs = 7000
|
[int] $TimeoutMs = 7000
|
||||||
)
|
)
|
||||||
|
throw "physical_control_disabled_stage_a"
|
||||||
$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 {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-MinerOpsM30Legacy4028Test {
|
function Invoke-MinerOpsM30Legacy4028Test {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)] $Payload
|
[Parameter(Mandatory=$true)] $Payload
|
||||||
)
|
)
|
||||||
|
throw "physical_control_disabled_stage_a"
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Run-MinerControlCommand {
|
function Run-MinerControlCommand {
|
||||||
@@ -1244,105 +979,7 @@ function Run-MinerControlCommand {
|
|||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[object]$Payload
|
[object]$Payload
|
||||||
)
|
)
|
||||||
# MinerOps M30 legacy 4028 test path
|
throw "physical_control_disabled_stage_a"
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1368,8 +1005,8 @@ function Process-PendingCommands {
|
|||||||
Write-AgentLog "Command completed: $commandId"
|
Write-AgentLog "Command completed: $commandId"
|
||||||
} elseif ($commandType -eq "miner_control") {
|
} elseif ($commandType -eq "miner_control") {
|
||||||
$result = Run-MinerControlCommand -Payload $cmd.request_payload
|
$result = Run-MinerControlCommand -Payload $cmd.request_payload
|
||||||
Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload $result | Out-Null
|
Send-AgentCommandResult -CommandId $commandId -Status "failed" -ResultPayload $result | Out-Null
|
||||||
Write-AgentLog "Miner control command completed: $commandId"
|
Write-AgentLog "Miner control rejected by Stage A containment: $commandId"
|
||||||
|
|
||||||
} elseif ($commandType -eq "enrich_match_discovery") {
|
} elseif ($commandType -eq "enrich_match_discovery") {
|
||||||
$payloadObj = $cmd.request_payload
|
$payloadObj = $cmd.request_payload
|
||||||
@@ -1394,7 +1031,12 @@ function Process-PendingCommands {
|
|||||||
|
|
||||||
Write-AgentLog "Running Enrich / Match for discovery IP $ipAddress"
|
Write-AgentLog "Running Enrich / Match for discovery IP $ipAddress"
|
||||||
|
|
||||||
$out = powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\MinerOpsAgent\EnrichOneMiner.ps1 -IpAddress $ipAddress 2>&1 | Out-String
|
$hubConfig = Invoke-AgentApi -Method "GET" -Path "/api/agent/config"
|
||||||
|
if (-not [string]::Equals([string]$payloadObj.agent_zone_id, [string]$hubConfig.agent_zone_id, [StringComparison]::Ordinal)) {
|
||||||
|
throw "discovery_command_stale_authority"
|
||||||
|
}
|
||||||
|
if (-not $payloadObj.discovery_id -or -not $payloadObj.version) { throw "discovery_observation_required" }
|
||||||
|
$out = powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\MinerOpsAgent\EnrichOneMiner.ps1 -IpAddress $ipAddress -DiscoveryId ([string]$payloadObj.discovery_id) -ObservationVersion ([string]$payloadObj.version) -CommandId $commandId 2>&1 | Out-String
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "EnrichOneMiner failed for $ipAddress. Output: $out"
|
throw "EnrichOneMiner failed for $ipAddress. Output: $out"
|
||||||
|
|||||||
@@ -30,11 +30,57 @@ function Invoke-WhatsMinerApi3Read {
|
|||||||
[int] $TimeoutMs = 2500
|
[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
|
$client = $null
|
||||||
$stream = $null
|
$stream = $null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$json = ($Request | ConvertTo-Json -Depth 20 -Compress)
|
$json = ($wireRequest | ConvertTo-Json -Depth 20 -Compress)
|
||||||
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
|
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
|
||||||
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
|
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
|
||||||
|
|
||||||
@@ -178,6 +224,8 @@ foreach ($miner in $miners) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$items.Add([pscustomobject]@{
|
$items.Add([pscustomobject]@{
|
||||||
|
miner_id = [string]$miner.miner_id
|
||||||
|
miner_version = [string]$miner.miner_version
|
||||||
ip = $ip
|
ip = $ip
|
||||||
mac = $mac
|
mac = $mac
|
||||||
api3_supported = $false
|
api3_supported = $false
|
||||||
@@ -211,6 +259,8 @@ foreach ($miner in $miners) {
|
|||||||
-Request @{ cmd = "get.device.custom_data" }
|
-Request @{ cmd = "get.device.custom_data" }
|
||||||
|
|
||||||
$items.Add([pscustomobject]@{
|
$items.Add([pscustomobject]@{
|
||||||
|
miner_id = [string]$miner.miner_id
|
||||||
|
miner_version = [string]$miner.miner_version
|
||||||
ip = $ip
|
ip = $ip
|
||||||
mac = $mac
|
mac = $mac
|
||||||
api3_supported = $true
|
api3_supported = $true
|
||||||
@@ -229,15 +279,14 @@ $payload = @{
|
|||||||
items = @($items.ToArray())
|
items = @($items.ToArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
$json = $payload | ConvertTo-Json -Depth 80
|
$allItems = @($items.ToArray())
|
||||||
|
$result = @()
|
||||||
Write-Host "Posting API3 enrichment results to hub..."
|
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
|
||||||
$result = Invoke-RestMethod `
|
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
|
||||||
-Uri "$HubUrl/api/agent/api3-enrichment" `
|
$payload.items = @($allItems[$offset..$last])
|
||||||
-Method Post `
|
$result += Invoke-RestMethod -Uri "$HubUrl/api/agent/api3-enrichment" -Method Post `
|
||||||
-Headers $Headers `
|
-Headers $Headers -ContentType "application/json" -Body ($payload | ConvertTo-Json -Depth 80)
|
||||||
-ContentType "application/json" `
|
}
|
||||||
-Body $json
|
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Local checked: $checked"
|
Write-Host "Local checked: $checked"
|
||||||
|
|||||||
@@ -26,29 +26,8 @@ function Get-MinerOpsControlCredentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-MinerOpsControlApi3Command {
|
function Invoke-MinerOpsControlApi3Command {
|
||||||
param(
|
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
|
||||||
[Parameter(Mandatory=$true)][string]$IpAddress,
|
throw "physical_control_disabled_stage_a"
|
||||||
[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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function New-MinerOpsControlResult {
|
function New-MinerOpsControlResult {
|
||||||
@@ -74,90 +53,11 @@ function New-MinerOpsControlResult {
|
|||||||
|
|
||||||
function Run-MinerControlCommand {
|
function Run-MinerControlCommand {
|
||||||
param([Parameter(Mandatory=$true)][object]$Payload)
|
param([Parameter(Mandatory=$true)][object]$Payload)
|
||||||
|
# No current request schema binds authorization to physical identity.
|
||||||
try {
|
# Do not parse, normalize, reinterpret, or transmit an old queued payload.
|
||||||
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 `
|
return New-MinerOpsControlResult -Accepted $false `
|
||||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||||
-FailureCode "invalid_action" -QueueStatus "failed"
|
-FailureCode "physical_control_disabled_stage_a" -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Send-AgentCommandResult {
|
function Send-AgentCommandResult {
|
||||||
@@ -185,7 +85,6 @@ function Send-AgentCommandResult {
|
|||||||
|
|
||||||
function Send-AgentCheckin {
|
function Send-AgentCheckin {
|
||||||
$config = Read-AgentConfig
|
$config = Read-AgentConfig
|
||||||
$credentials = Get-MinerOpsControlCredentials
|
|
||||||
$body = [ordered]@{
|
$body = [ordered]@{
|
||||||
agent_id = [string]$config.agent_id
|
agent_id = [string]$config.agent_id
|
||||||
site_id = [string]$config.site_id
|
site_id = [string]$config.site_id
|
||||||
@@ -193,8 +92,8 @@ function Send-AgentCheckin {
|
|||||||
hostname = $env:COMPUTERNAME
|
hostname = $env:COMPUTERNAME
|
||||||
version = Get-MinerOpsAgentVersion
|
version = Get-MinerOpsAgentVersion
|
||||||
ip_address = ""
|
ip_address = ""
|
||||||
control_available = [bool]$credentials.available
|
control_available = $false
|
||||||
control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" }
|
control_unavailable_reason = "physical_control_disabled_stage_a"
|
||||||
local_time = (Get-Date).ToString("o")
|
local_time = (Get-Date).ToString("o")
|
||||||
}
|
}
|
||||||
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
|
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ foreach ($miner in $knownMiners) {
|
|||||||
if ($pools.Count -gt 0) {
|
if ($pools.Count -gt 0) {
|
||||||
$poolSnapshots.Add([pscustomobject]@{
|
$poolSnapshots.Add([pscustomobject]@{
|
||||||
miner_id = $miner.miner_id
|
miner_id = $miner.miner_id
|
||||||
|
miner_version = $miner.miner_version
|
||||||
ip_address = $ip
|
ip_address = $ip
|
||||||
mac_address = $miner.mac_address
|
mac_address = $miner.mac_address
|
||||||
model = $miner.model
|
model = $miner.model
|
||||||
@@ -130,18 +131,14 @@ foreach ($miner in $knownMiners) {
|
|||||||
|
|
||||||
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
||||||
|
|
||||||
$body = @{
|
$allItems = @($poolSnapshots.ToArray())
|
||||||
agent_id = $AgentId
|
$result = @()
|
||||||
pool_snapshots = $poolSnapshots
|
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
|
||||||
} | ConvertTo-Json -Depth 20
|
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
|
||||||
|
$body = @{agent_id=$AgentId; pool_snapshots=@($allItems[$offset..$last])} | ConvertTo-Json -Depth 80
|
||||||
$result = Invoke-RestMethod `
|
$result += Invoke-RestMethod -Method Post -Uri "$HubUrl/api/agent/pool-snapshots" `
|
||||||
-Method Post `
|
-Headers $Headers -Body $body -ContentType "application/json" -TimeoutSec 180
|
||||||
-Uri "$HubUrl/api/agent/pool-snapshots" `
|
}
|
||||||
-Headers $Headers `
|
|
||||||
-Body $body `
|
|
||||||
-ContentType "application/json" `
|
|
||||||
-TimeoutSec 180
|
|
||||||
|
|
||||||
$result | ConvertTo-Json -Depth 10
|
$result | ConvertTo-Json -Depth 10
|
||||||
|
|
||||||
+176
-4
@@ -1,7 +1,44 @@
|
|||||||
# MinerOps Windows Agent
|
# 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,
|
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
|
MinerOps does not send API3 credentials through the hub command queue; those
|
||||||
credentials remain in the Agent's local `agent-config.json`.
|
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
|
### Existing Agent upgrade
|
||||||
|
|
||||||
For an already enrolled/configured Agent, do not run the enrollment
|
An existing legacy Agent must receive a per-Agent credential before the global
|
||||||
bootstrapper. Copy the new canonical Agent files to a temporary directory and
|
shared-secret compatibility path can be retired for its identity. In Agent
|
||||||
run install.ps1 from an elevated Windows PowerShell session.
|
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
|
The installer copies only managed runtime code to C:\MinerOpsAgent and
|
||||||
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
|
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
|
and are not rewritten. Missing tasks are created for a fresh or incomplete
|
||||||
installation without hard-coded user names.
|
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
|
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
|
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
|
||||||
again. A previously stopped Agent remains stopped. Before replacing runtime
|
again. A previously stopped Agent remains stopped. Before replacing runtime
|
||||||
files, the installer copies them to a timestamped backup under
|
files, the installer copies them to a timestamped backup under
|
||||||
C:\MinerOpsAgent\backups.
|
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
|
A failed deployment restores managed files, removes tasks created by that
|
||||||
failed attempt, and restores the main Agent's prior running state.
|
failed attempt, and restores the main Agent's prior running state.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+1062
-11
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"
|
$ErrorActionPreference = "Stop"
|
||||||
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
|
$script:assertions = 0
|
||||||
$script:calls = @()
|
$script:physicalCalls = 0
|
||||||
$script:outcomes = @()
|
$script:readCalls = 0
|
||||||
$script:outcomeIndex = 0
|
$script:apiCalls = @()
|
||||||
|
$script:commands = @()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function Assert-Equal($Actual, $Expected, [string]$Message) {
|
function Assert-Equal($Actual, $Expected, [string]$Message) {
|
||||||
|
$script:assertions++
|
||||||
if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
|
if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
|
||||||
}
|
}
|
||||||
function Reset-Harness([object[]]$Outcomes) {
|
function Assert-Throws($Block, [string]$Message) {
|
||||||
$script:calls = @()
|
$caught = $false
|
||||||
$script:outcomes = @($Outcomes)
|
try { & $Block | Out-Null } catch {
|
||||||
$script:outcomeIndex = 0
|
$caught = $true
|
||||||
}
|
Assert-Equal $_.Exception.Message "physical_control_disabled_stage_a" $Message
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
Assert-Equal $result.command_accepted $true "$action accepted"
|
Assert-Equal $caught $true "$Message must reject"
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Reset-Harness @([System.Exception]::new("vendor response included secret"))
|
# Define functions from the real service AST, without executing its service loop,
|
||||||
$result = Run-MinerControlCommand -Payload @{
|
# config loading, startup, or any scheduled tasks.
|
||||||
action = "suspend"; requested_value = $null; ip_address = "192.0.2.1"
|
$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 @()
|
$script:productionApi3Payload = (Get-Command Invoke-MinerOpsApi3Payload).ScriptBlock
|
||||||
$result = Run-MinerControlCommand -Payload @{
|
$script:productionPlainCommand = (Get-Command Invoke-MinerPlainCommand).ScriptBlock
|
||||||
action = "performance_mode"; requested_value = "turbo"; ip_address = "192.0.2.1"
|
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"
|
function Get-PendingAgentCommands { return $script:commands }
|
||||||
Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3"
|
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."
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
param([string]$AgentRoot = (Join-Path $PSScriptRoot '..'))
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$checks=0
|
||||||
|
foreach ($file in @('MinerOpsAgentService.ps1','EnrichOneMiner.ps1','MinerOpsApi3EnrichmentPoller.ps1','MinerOpsPoolPoller.ps1')) {
|
||||||
|
$tokens=$null; $errors=$null
|
||||||
|
$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $AgentRoot $file),[ref]$tokens,[ref]$errors)
|
||||||
|
if ($errors.Count) { throw "Parse errors in ${file}: $errors" }
|
||||||
|
$checks++
|
||||||
|
if ($file -eq 'MinerOpsAgentService.ps1') {
|
||||||
|
$function=$ast.Find({param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Invoke-AgentApi'},$true)
|
||||||
|
Invoke-Expression $function.Extent.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Read-AgentConfig { return @{hub_url='https://hub.invalid';agent_id='simulated-agent';agent_secret='disposable-only'} }
|
||||||
|
$script:requests=@()
|
||||||
|
function Invoke-RestMethod {
|
||||||
|
param($Method,$Uri,$Headers,$ContentType,$Body,$TimeoutSec)
|
||||||
|
$parsed=$Body | ConvertFrom-Json
|
||||||
|
$script:requests+=@{body=$parsed;headers=$Headers}
|
||||||
|
return @{ok=$true}
|
||||||
|
}
|
||||||
|
foreach ($selection in @(@{path='/api/agent/discoveries';field='discoveries'},@{path='/api/agent/miner-snapshots';field='miners'})) {
|
||||||
|
$script:requests=@();$body=@{agent_id='simulated-agent'}
|
||||||
|
$body[$selection.field]=@(1..257 | ForEach-Object { @{sample=$_;miner_version='signed-version'} })
|
||||||
|
$result=Invoke-AgentApi -Method POST -Path $selection.path -Body $body
|
||||||
|
$sizes=@($script:requests | ForEach-Object { @($_.body.($selection.field)).Count })
|
||||||
|
if (($sizes -join ',') -ne '128,128,1') { throw "Unsafe batching: $sizes" }
|
||||||
|
foreach ($request in $script:requests) {
|
||||||
|
if ($request.headers['X-MinerOps-Agent-ID'] -ne 'simulated-agent') { throw 'Identity lost' }
|
||||||
|
}
|
||||||
|
if ($result.processed -ne 257) { throw 'Samples lost' }
|
||||||
|
$checks+=3
|
||||||
|
}
|
||||||
|
# Execute the actual target-validation prefix only; transport functions/loops are never loaded.
|
||||||
|
$source=Get-Content -LiteralPath (Join-Path $AgentRoot 'EnrichOneMiner.ps1') -Raw
|
||||||
|
$start=$source.IndexOf('$config = Read-AgentConfig')
|
||||||
|
$finish=$source.IndexOf('$commands = @(',$start)
|
||||||
|
$prefix=$source.Substring($start,$finish-$start)
|
||||||
|
$script:targetRequests=0
|
||||||
|
function Invoke-RestMethod {
|
||||||
|
param($Method,$Uri,$Headers,$ContentType,$Body)
|
||||||
|
$script:targetRequests++
|
||||||
|
if ($Uri -notlike '*/api/agent/discovery-target') { throw 'Unexpected request' }
|
||||||
|
return @{ok=$true;ip_address='198.51.100.10'}
|
||||||
|
}
|
||||||
|
$DiscoveryId='';$ObservationVersion='';$CommandId='';$IpAddress='203.0.113.99'
|
||||||
|
$denied=$false
|
||||||
|
try { Invoke-Expression $prefix } catch { $denied=$_.Exception.Message -eq 'discovery_observation_required' }
|
||||||
|
if (!$denied -or $script:targetRequests -ne 0) { throw 'Missing identity was not rejected before I/O' }
|
||||||
|
$checks++
|
||||||
|
$DiscoveryId='00000000-0000-4000-8000-000000000001';$ObservationVersion='version';$CommandId='command'
|
||||||
|
Invoke-Expression $prefix
|
||||||
|
if ($IpAddress -ne '198.51.100.10' -or $script:targetRequests -ne 1) { throw 'Unvalidated target retained' }
|
||||||
|
$checks++
|
||||||
|
Write-Output "Discovery PowerShell checks passed: $checks; mocked Hub only; zero miner sockets."
|
||||||
@@ -32,6 +32,9 @@ function Assert-SourceContains {
|
|||||||
Assert-SourceContains 'enrollment\.json' `
|
Assert-SourceContains 'enrollment\.json' `
|
||||||
"Bootstrap enrollment file is not explicit."
|
"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' `
|
Assert-SourceContains '/api/agent/enroll' `
|
||||||
"Bootstrap does not use the secure enrollment endpoint."
|
"Bootstrap does not use the secure enrollment endpoint."
|
||||||
|
|
||||||
@@ -44,8 +47,20 @@ Assert-SourceContains 'agent-config\.json\.pending' `
|
|||||||
Assert-SourceContains 'agent-config\.recovery\.json' `
|
Assert-SourceContains 'agent-config\.recovery\.json' `
|
||||||
"Post-redemption credential recovery path is missing."
|
"Post-redemption credential recovery path is missing."
|
||||||
|
|
||||||
Assert-SourceContains 'Protect-MinerOpsCredentialFile' `
|
Assert-SourceContains 'Open-MinerOpsEnrollmentCredentialFileCreateNew' `
|
||||||
"Credential file ACL protection is missing."
|
"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' `
|
Assert-SourceContains 'S-1-5-18' `
|
||||||
"SYSTEM ACL entry is missing."
|
"SYSTEM ACL entry is missing."
|
||||||
@@ -53,12 +68,73 @@ Assert-SourceContains 'S-1-5-18' `
|
|||||||
Assert-SourceContains 'S-1-5-32-544' `
|
Assert-SourceContains 'S-1-5-32-544' `
|
||||||
"Administrators ACL entry is missing."
|
"Administrators ACL entry is missing."
|
||||||
|
|
||||||
Assert-SourceContains 'WindowsIdentity\]::GetCurrent\(\)\.User' `
|
Assert-SourceContains 'SetOwner\(\$administrators\)' `
|
||||||
"Installing user ACL entry is missing."
|
"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."
|
"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' `
|
Assert-SourceContains '& \$Installer' `
|
||||||
"Bootstrap does not hand off to the safe 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' `
|
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
|
||||||
"Consumed extracted enrollment token is not removed."
|
"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(
|
$existingConfigGuard = $source.IndexOf(
|
||||||
'Test-Path -LiteralPath $ConfigPath'
|
'Test-Path -LiteralPath $ConfigPath'
|
||||||
)
|
)
|
||||||
@@ -85,7 +233,7 @@ if (
|
|||||||
}
|
}
|
||||||
|
|
||||||
$configMove = $source.IndexOf(
|
$configMove = $source.IndexOf(
|
||||||
'-Destination $ConfigPath'
|
'[IO.File]::Move($PendingConfigPath, $ConfigPath)'
|
||||||
)
|
)
|
||||||
|
|
||||||
$installerCall = $source.IndexOf(
|
$installerCall = $source.IndexOf(
|
||||||
|
|||||||
@@ -33,10 +33,14 @@ if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
|||||||
throw "Source preflight must happen before target changes."
|
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."
|
"Existing task preservation guard is missing."
|
||||||
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
||||||
"Existing task preservation is not explicit."
|
"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"' `
|
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
|
||||||
"Fresh enrichment task arguments are missing."
|
"Fresh enrichment task arguments are missing."
|
||||||
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
||||||
@@ -49,6 +53,228 @@ Assert-SourceContains 'Unregister-ScheduledTask' `
|
|||||||
"Rollback does not remove tasks created by a failed install."
|
"Rollback does not remove tasks created by a failed install."
|
||||||
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
||||||
"Rollback restart failure is not surfaced."
|
"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') {
|
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
|
||||||
throw "Installer must not force-overwrite Scheduled Tasks."
|
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:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT agent_id,
|
SELECT identity.agent_id,
|
||||||
agent_secret_hash,
|
agent.agent_secret_hash,
|
||||||
credential_created_at,
|
agent.credential_created_at,
|
||||||
credential_revoked_at
|
agent.credential_revoked_at,
|
||||||
FROM minerops_site_agents
|
retirement.retired_at AS legacy_auth_disabled_at
|
||||||
WHERE agent_id = %s
|
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,),
|
(agent_id,),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,19 +25,50 @@ class AgentAuthService:
|
|||||||
identity = (agent_id or "").strip()
|
identity = (agent_id or "").strip()
|
||||||
secret = (provided_secret or "").strip()
|
secret = (provided_secret or "").strip()
|
||||||
|
|
||||||
if not identity:
|
|
||||||
raise AgentAuthenticationFailed("Missing Agent identity")
|
|
||||||
if not secret:
|
if not secret:
|
||||||
raise AgentAuthenticationFailed("Invalid or missing Agent 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)
|
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
|
# Any Agent with a per-Agent credential is permanently on the new
|
||||||
# authentication path. The legacy shared secret must never authenticate
|
# authentication path. The legacy shared secret must never authenticate
|
||||||
# that identity, even if the Agent later becomes revoked.
|
# that identity, even if the Agent later becomes revoked.
|
||||||
if credential and credential.get("agent_secret_hash"):
|
if credential and credential.get("agent_secret_hash"):
|
||||||
if credential.get("credential_revoked_at") is not None:
|
# Once the configured legacy identity has ever received a
|
||||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
# 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)
|
supplied_hash = hash_agent_secret(secret)
|
||||||
if not hmac.compare_digest(
|
if not hmac.compare_digest(
|
||||||
@@ -48,10 +79,15 @@ class AgentAuthService:
|
|||||||
|
|
||||||
return identity
|
return identity
|
||||||
|
|
||||||
# Temporary compatibility for the single pre-enrollment Agent.
|
# The durable identity retirement survives Agent-row deletion,
|
||||||
legacy_id = settings.minerops_agent_id.strip()
|
# credential-field corruption, administrative mistakes, and future
|
||||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
# 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:
|
if not legacy_id or not legacy_secret:
|
||||||
raise AgentAuthenticationUnavailable(
|
raise AgentAuthenticationUnavailable(
|
||||||
"Legacy Agent authentication is not configured"
|
"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,7 +8,10 @@ from psycopg.rows import dict_row
|
|||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
|
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
|
from app.config import settings
|
||||||
|
from app.discovery.repository import DiscoveryRepository
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
@@ -19,6 +22,7 @@ WITH candidates AS (
|
|||||||
FROM minerops_agent_command_queue
|
FROM minerops_agent_command_queue
|
||||||
WHERE agent_id = %s
|
WHERE agent_id = %s
|
||||||
AND command_status = 'pending'
|
AND command_status = 'pending'
|
||||||
|
AND command_type = ANY(%s)
|
||||||
ORDER BY created_at, id
|
ORDER BY created_at, id
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
@@ -32,6 +36,70 @@ RETURNING queue.id AS command_id, queue.command_type,
|
|||||||
queue.request_payload, queue.created_at
|
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:
|
def default_connection_factory() -> psycopg.Connection:
|
||||||
return psycopg.connect(
|
return psycopg.connect(
|
||||||
@@ -54,8 +122,29 @@ class AgentCommandRepository:
|
|||||||
connection = self._connection_factory()
|
connection = self._connection_factory()
|
||||||
try:
|
try:
|
||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit))
|
DiscoveryRepository.lock(cur)
|
||||||
|
self._retire_unsupported(cur, agent_id)
|
||||||
|
discovery = DiscoveryRepository(self._connection_factory)
|
||||||
|
cur.execute(f"""SELECT q.*, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||||
|
FROM minerops_agent_command_queue q WHERE agent_id=%s
|
||||||
|
AND command_status='pending' AND command_type=ANY(%s)
|
||||||
|
ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT 1000""",
|
||||||
|
(agent_id,list(READ_ONLY_COMMAND_TYPES)))
|
||||||
|
pending=list(cur.fetchall())
|
||||||
|
for command in pending:
|
||||||
|
try:
|
||||||
|
discovery.validate_command(cur,agent_id,command)
|
||||||
|
except HTTPException as exc:
|
||||||
|
cur.execute("""UPDATE minerops_agent_command_queue SET command_status='cancelled',
|
||||||
|
completed_at=COALESCE(completed_at,now()), error_message=concat_ws(chr(10),NULLIF(error_message,''),%s::text)
|
||||||
|
WHERE id=%s""",(exc.detail,command['id']))
|
||||||
|
DiscoveryRepository.audit(cur,'discovery.command_rejected',None,
|
||||||
|
{'command_id':command['id'],'agent_id':agent_id,'reason':exc.detail,
|
||||||
|
'execution_outcome':rejection_execution_outcome({**command,'previous_status':'pending'})})
|
||||||
|
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
|
||||||
rows = [dict(row) for row in cur.fetchall()]
|
rows = [dict(row) for row in cur.fetchall()]
|
||||||
|
for row in rows:
|
||||||
|
discovery.validate_command(cur,agent_id,row)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return rows
|
return rows
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -64,6 +153,105 @@ class AgentCommandRepository:
|
|||||||
finally:
|
finally:
|
||||||
connection.close()
|
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:
|
def get_command(self, command_id: str) -> dict | None:
|
||||||
connection = self._connection_factory()
|
connection = self._connection_factory()
|
||||||
try:
|
try:
|
||||||
@@ -91,6 +279,32 @@ class AgentCommandRepository:
|
|||||||
connection = self._connection_factory()
|
connection = self._connection_factory()
|
||||||
try:
|
try:
|
||||||
with connection.cursor(row_factory=dict_row) as cur:
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE minerops_agent_command_queue
|
UPDATE minerops_agent_command_queue
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
"""HTTP boundary for authenticated Agent command transport."""
|
"""HTTP boundary for authenticated Agent command transport."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -18,8 +16,6 @@ from app.agent_commands.models import (
|
|||||||
)
|
)
|
||||||
from app.agent_commands.service import AgentCommandService
|
from app.agent_commands.service import AgentCommandService
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
|
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
|
||||||
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||||
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
|
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
|
||||||
@@ -29,14 +25,7 @@ AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)
|
|||||||
def claim_pending_commands(
|
def claim_pending_commands(
|
||||||
request: Request, agent_id: AgentIdentity, service: AgentService
|
request: Request, agent_id: AgentIdentity, service: AgentService
|
||||||
) -> AgentCommandListResponse:
|
) -> AgentCommandListResponse:
|
||||||
before_claim = getattr(
|
del request
|
||||||
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")
|
|
||||||
return AgentCommandListResponse(commands=service.claim(agent_id))
|
return AgentCommandListResponse(commands=service.claim(agent_id))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,16 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from app.agent_commands.exceptions import (
|
from app.agent_commands.exceptions import (
|
||||||
AgentCommandConflict,
|
AgentCommandConflict,
|
||||||
AgentCommandNotFound,
|
AgentCommandNotFound,
|
||||||
AgentResultValidationError,
|
|
||||||
)
|
)
|
||||||
from app.agent_commands.models import (
|
from app.agent_commands.models import (
|
||||||
AgentCommand,
|
AgentCommand,
|
||||||
AgentCommandResultRequest,
|
AgentCommandResultRequest,
|
||||||
MinerControlResultPayload,
|
|
||||||
)
|
)
|
||||||
from app.agent_commands.repository import AgentCommandRepository
|
from app.agent_commands.repository import AgentCommandRepository
|
||||||
|
from app.agent_commands.policy import rejection_reason
|
||||||
|
|
||||||
|
|
||||||
class AgentCommandService:
|
class AgentCommandService:
|
||||||
@@ -25,6 +22,7 @@ class AgentCommandService:
|
|||||||
return [
|
return [
|
||||||
AgentCommand.model_validate(row)
|
AgentCommand.model_validate(row)
|
||||||
for row in self.repository.claim_pending(agent_id)
|
for row in self.repository.claim_pending(agent_id)
|
||||||
|
if rejection_reason(row["command_type"]) is None
|
||||||
]
|
]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -42,6 +40,14 @@ class AgentCommandService:
|
|||||||
raise AgentCommandNotFound("Agent command not found")
|
raise AgentCommandNotFound("Agent command not found")
|
||||||
if command["agent_id"] != agent_id:
|
if command["agent_id"] != agent_id:
|
||||||
raise AgentCommandConflict("Agent command belongs to another Agent")
|
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":
|
if command["command_status"] != "claimed":
|
||||||
raise AgentCommandConflict(
|
raise AgentCommandConflict(
|
||||||
f"Agent command is {command['command_status']}, not claimed"
|
f"Agent command is {command['command_status']}, not claimed"
|
||||||
@@ -49,34 +55,6 @@ class AgentCommandService:
|
|||||||
|
|
||||||
result_payload = request.result_payload
|
result_payload = request.result_payload
|
||||||
error_message = self._legacy_error(request.error_message)
|
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(
|
self.repository.finish_command(
|
||||||
request.command_id,
|
request.command_id,
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ class AgentEnrollmentSummary(BaseModel):
|
|||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
used_at: datetime | None = None
|
used_at: datetime | None = None
|
||||||
revoked_at: datetime | None = None
|
revoked_at: datetime | None = None
|
||||||
|
target_agent_id: str | None = None
|
||||||
enrolled_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"]
|
status: Literal["waiting", "used", "revoked", "expired"]
|
||||||
|
|
||||||
|
|
||||||
@@ -96,3 +98,56 @@ class AgentEnrollResponse(BaseModel):
|
|||||||
|
|
||||||
class AgentWindowsPackageRequest(AgentEnrollRequest):
|
class AgentWindowsPackageRequest(AgentEnrollRequest):
|
||||||
"""Short-lived token used to construct a Windows bootstrap package."""
|
"""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."""
|
"""PostgreSQL persistence for secure Agent enrollment."""
|
||||||
|
|
||||||
|
import hmac
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ ConnectionFactory = Callable[[], psycopg.Connection]
|
|||||||
|
|
||||||
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
|
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
|
||||||
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
|
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_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
|
||||||
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
|
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
|
||||||
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
|
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
|
||||||
@@ -214,6 +216,184 @@ class AgentEnrollmentRepository:
|
|||||||
finally:
|
finally:
|
||||||
connection.close()
|
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(
|
def list_enrollments(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -238,7 +418,13 @@ class AgentEnrollmentRepository:
|
|||||||
enrollment.expires_at,
|
enrollment.expires_at,
|
||||||
enrollment.used_at,
|
enrollment.used_at,
|
||||||
enrollment.revoked_at,
|
enrollment.revoked_at,
|
||||||
|
enrollment.target_agent_id,
|
||||||
enrollment.enrolled_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
|
CASE
|
||||||
WHEN enrollment.used_at IS NOT NULL
|
WHEN enrollment.used_at IS NOT NULL
|
||||||
THEN 'used'
|
THEN 'used'
|
||||||
@@ -292,7 +478,8 @@ class AgentEnrollmentRepository:
|
|||||||
display_name,
|
display_name,
|
||||||
expires_at,
|
expires_at,
|
||||||
used_at,
|
used_at,
|
||||||
revoked_at
|
revoked_at,
|
||||||
|
target_agent_id
|
||||||
FROM agent_enrollments
|
FROM agent_enrollments
|
||||||
WHERE token_hash = %s
|
WHERE token_hash = %s
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -309,6 +496,7 @@ class AgentEnrollmentRepository:
|
|||||||
if (
|
if (
|
||||||
enrollment["used_at"] is not None
|
enrollment["used_at"] is not None
|
||||||
or enrollment["revoked_at"] is not None
|
or enrollment["revoked_at"] is not None
|
||||||
|
or enrollment["target_agent_id"] is not None
|
||||||
):
|
):
|
||||||
raise AgentEnrollmentTokenInvalid(
|
raise AgentEnrollmentTokenInvalid(
|
||||||
"Invalid or expired enrollment token"
|
"Invalid or expired enrollment token"
|
||||||
@@ -371,6 +559,22 @@ class AgentEnrollmentRepository:
|
|||||||
"Invalid or expired enrollment token"
|
"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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO minerops_site_agents (
|
INSERT INTO minerops_site_agents (
|
||||||
@@ -380,7 +584,8 @@ class AgentEnrollmentRepository:
|
|||||||
site_name,
|
site_name,
|
||||||
display_name,
|
display_name,
|
||||||
agent_secret_hash,
|
agent_secret_hash,
|
||||||
credential_created_at
|
credential_created_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
%s,
|
%s,
|
||||||
@@ -389,6 +594,7 @@ class AgentEnrollmentRepository:
|
|||||||
%s,
|
%s,
|
||||||
%s,
|
%s,
|
||||||
%s,
|
%s,
|
||||||
|
%s,
|
||||||
%s
|
%s
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
@@ -400,6 +606,7 @@ class AgentEnrollmentRepository:
|
|||||||
enrollment["display_name"],
|
enrollment["display_name"],
|
||||||
agent_secret_hash,
|
agent_secret_hash,
|
||||||
operation_now,
|
operation_now,
|
||||||
|
operation_now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -504,3 +711,258 @@ class AgentEnrollmentRepository:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
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.dependencies import get_agent_enrollment_service
|
||||||
from app.agent_enrollment.exceptions import (
|
from app.agent_enrollment.exceptions import (
|
||||||
AgentEnrollmentConflict,
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
AgentEnrollmentNotFound,
|
AgentEnrollmentNotFound,
|
||||||
AgentEnrollmentTokenInvalid,
|
AgentEnrollmentTokenInvalid,
|
||||||
)
|
)
|
||||||
from app.agent_enrollment.models import (
|
from app.agent_enrollment.models import (
|
||||||
|
AgentCredentialTransitionCreateRequest,
|
||||||
|
AgentCredentialTransitionCreateResponse,
|
||||||
|
AgentCredentialTransitionRedeemRequest,
|
||||||
|
AgentCredentialTransitionResponse,
|
||||||
AgentEnrollmentCreateRequest,
|
AgentEnrollmentCreateRequest,
|
||||||
AgentEnrollmentCreateResponse,
|
AgentEnrollmentCreateResponse,
|
||||||
AgentEnrollmentListResponse,
|
AgentEnrollmentListResponse,
|
||||||
@@ -70,6 +75,31 @@ def create_agent_enrollment(
|
|||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
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(
|
@router.get(
|
||||||
"/api/agent-enrollments",
|
"/api/agent-enrollments",
|
||||||
response_model=AgentEnrollmentListResponse,
|
response_model=AgentEnrollmentListResponse,
|
||||||
@@ -118,6 +148,7 @@ def download_windows_agent_package(
|
|||||||
package = build_windows_agent_package(
|
package = build_windows_agent_package(
|
||||||
enrollment_token=package_request.token,
|
enrollment_token=package_request.token,
|
||||||
hub_url=hub_url,
|
hub_url=hub_url,
|
||||||
|
workflow="new_agent",
|
||||||
)
|
)
|
||||||
except AgentWindowsPackageSourceError as exc:
|
except AgentWindowsPackageSourceError as exc:
|
||||||
raise HTTPException(status_code=503, detail=str(exc)) from 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(
|
@router.post(
|
||||||
"/api/agent/enroll",
|
"/api/agent/enroll",
|
||||||
response_model=AgentEnrollResponse,
|
response_model=AgentEnrollResponse,
|
||||||
@@ -157,3 +232,27 @@ def enroll_agent(
|
|||||||
) from exc
|
) from exc
|
||||||
except AgentEnrollmentConflict as exc:
|
except AgentEnrollmentConflict as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from 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,
|
AgentEnrollmentCredentialCollision,
|
||||||
)
|
)
|
||||||
from app.agent_enrollment.models import (
|
from app.agent_enrollment.models import (
|
||||||
|
AgentCredentialTransitionCreateRequest,
|
||||||
|
AgentCredentialTransitionRedeemRequest,
|
||||||
AgentEnrollmentCreateRequest,
|
AgentEnrollmentCreateRequest,
|
||||||
AgentEnrollRequest,
|
AgentEnrollRequest,
|
||||||
)
|
)
|
||||||
@@ -73,6 +75,31 @@ class AgentEnrollmentService:
|
|||||||
limit=limit,
|
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:
|
def redeem(self, request: AgentEnrollRequest) -> dict:
|
||||||
token_hash = hash_agent_secret(request.token)
|
token_hash = hash_agent_secret(request.token)
|
||||||
|
|
||||||
@@ -96,3 +123,12 @@ class AgentEnrollmentService:
|
|||||||
raise AgentEnrollmentConflict(
|
raise AgentEnrollmentConflict(
|
||||||
"Unable to generate unique Agent credentials"
|
"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 = (
|
WINDOWS_PACKAGE_FILES = (
|
||||||
"Enroll-MinerOpsAgent.ps1",
|
"Enroll-MinerOpsAgent.ps1",
|
||||||
|
"Upgrade-MinerOpsAgentCredential.ps1",
|
||||||
"install.ps1",
|
"install.ps1",
|
||||||
"uninstall.ps1",
|
"uninstall.ps1",
|
||||||
"MinerOpsAgentService.ps1",
|
"MinerOpsAgentService.ps1",
|
||||||
@@ -80,6 +81,7 @@ def build_windows_agent_package(
|
|||||||
*,
|
*,
|
||||||
enrollment_token: str,
|
enrollment_token: str,
|
||||||
hub_url: str,
|
hub_url: str,
|
||||||
|
workflow: str = "new_agent",
|
||||||
source_root: Path | None = None,
|
source_root: Path | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
token = enrollment_token.strip()
|
token = enrollment_token.strip()
|
||||||
@@ -91,6 +93,12 @@ def build_windows_agent_package(
|
|||||||
|
|
||||||
normalized_hub_url = normalize_hub_url(hub_url)
|
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 = (
|
root = (
|
||||||
Path(source_root)
|
Path(source_root)
|
||||||
if source_root is not None
|
if source_root is not None
|
||||||
@@ -117,18 +125,27 @@ def build_windows_agent_package(
|
|||||||
enrollment = {
|
enrollment = {
|
||||||
"hub_url": normalized_hub_url,
|
"hub_url": normalized_hub_url,
|
||||||
"token": token,
|
"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.
|
1. Extract this ZIP on the target Windows machine.
|
||||||
2. Open Windows PowerShell as Administrator.
|
2. Open Windows PowerShell as Administrator.
|
||||||
3. Change to the extracted MinerOpsAgent-Windows folder.
|
3. Change to the extracted MinerOpsAgent-Windows folder.
|
||||||
4. Run:
|
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.
|
Do not post enrollment.json in chat, tickets, screenshots, or logs.
|
||||||
Delete the ZIP and extracted setup folder after successful installation.
|
Delete the ZIP and extracted setup folder after successful installation.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
CredentialState = Literal["legacy", "active", "revoked"]
|
CredentialState = Literal["legacy", "active", "revoked", "retired"]
|
||||||
RoutingState = Literal["assigned", "unassigned", "disabled"]
|
RoutingState = Literal["assigned", "unassigned", "disabled"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,9 +103,13 @@ class AgentRegistryRepository:
|
|||||||
THEN 'revoked'
|
THEN 'revoked'
|
||||||
WHEN agent.agent_secret_hash IS NOT NULL
|
WHEN agent.agent_secret_hash IS NOT NULL
|
||||||
THEN 'active'
|
THEN 'active'
|
||||||
|
WHEN retirement.retired_at IS NOT NULL
|
||||||
|
THEN 'retired'
|
||||||
ELSE 'legacy'
|
ELSE 'legacy'
|
||||||
END AS credential_state
|
END AS credential_state
|
||||||
FROM minerops_site_agents AS agent
|
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
|
LEFT JOIN sites AS site
|
||||||
ON site.id = agent.site_id
|
ON site.id = agent.site_id
|
||||||
LEFT JOIN agent_zones AS zone
|
LEFT JOIN agent_zones AS zone
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ class AgentZoneRoutingContext(BaseModel):
|
|||||||
routing_reason: str | None = None
|
routing_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDiscoveryMinerMatch(AgentZoneRoutingContext):
|
||||||
|
miner_id: UUID | None = None
|
||||||
|
matched_by: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MinerZoneAssociationResult(BaseModel):
|
class MinerZoneAssociationResult(BaseModel):
|
||||||
agent_zone_id: UUID | None = None
|
agent_zone_id: UUID | None = None
|
||||||
routing_reason: str | None = None
|
routing_reason: str | None = None
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ class AgentZoneRepository:
|
|||||||
ip_address = EXCLUDED.ip_address,
|
ip_address = EXCLUDED.ip_address,
|
||||||
last_checkin_at = now(),
|
last_checkin_at = now(),
|
||||||
last_payload = EXCLUDED.last_payload
|
last_payload = EXCLUDED.last_payload
|
||||||
|
WHERE minerops_site_agents.site_id IS NOT DISTINCT FROM EXCLUDED.site_id
|
||||||
|
AND minerops_site_agents.agent_zone_id IS NOT DISTINCT FROM EXCLUDED.agent_zone_id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
agent_id,
|
agent_id,
|
||||||
@@ -216,6 +218,8 @@ class AgentZoneRepository:
|
|||||||
Jsonb(payload),
|
Jsonb(payload),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
raise AgentZoneConflict("Agent assignment changed concurrently")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
except UniqueViolation as exc:
|
except UniqueViolation as exc:
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
@@ -250,6 +254,64 @@ class AgentZoneRepository:
|
|||||||
finally:
|
finally:
|
||||||
connection.close()
|
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 find_miner_in_zone(self, *, agent_id: str, agent_zone_id: UUID,
|
||||||
|
mac_address: str | None, ip_address: str | None) -> dict | None:
|
||||||
|
"""Retained helper uses the same atomic authority/collision boundary as HTTP."""
|
||||||
|
from app.discovery.repository import DiscoveryRepository, reject
|
||||||
|
repository = DiscoveryRepository(self._connection_factory)
|
||||||
|
with repository.transaction() as cur:
|
||||||
|
context = repository.context(cur, agent_id)
|
||||||
|
if context['agent_zone_id'] != agent_zone_id:
|
||||||
|
reject('discovery_stale_authority')
|
||||||
|
if not mac_address and not ip_address:
|
||||||
|
return None
|
||||||
|
row = repository.match(cur, context, {'mac_address': mac_address, 'ip_address': ip_address})
|
||||||
|
return {'id': row['id'], 'matched_by': 'mac' if mac_address else 'ip'} if row else None
|
||||||
|
|
||||||
def assign_miners_to_zone(
|
def assign_miners_to_zone(
|
||||||
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||||
) -> dict[UUID, str]:
|
) -> dict[UUID, str]:
|
||||||
@@ -282,15 +344,7 @@ class AgentZoneRepository:
|
|||||||
elif miner["site_id"] not in {None, zone["site_id"]}:
|
elif miner["site_id"] not in {None, zone["site_id"]}:
|
||||||
outcomes[miner_id] = "site_conflict"
|
outcomes[miner_id] = "site_conflict"
|
||||||
else:
|
else:
|
||||||
cur.execute(
|
outcomes[miner_id] = "miner_zone_unassigned"
|
||||||
"""
|
|
||||||
UPDATE miners
|
|
||||||
SET site_id = %s, agent_zone_id = %s, updated_at = now()
|
|
||||||
WHERE id = %s AND agent_zone_id IS NULL
|
|
||||||
""",
|
|
||||||
(zone["site_id"], agent_zone_id, miner_id),
|
|
||||||
)
|
|
||||||
outcomes[miner_id] = "assigned"
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return outcomes
|
return outcomes
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.agent_zones.exceptions import (
|
|||||||
from app.agent_zones.models import (
|
from app.agent_zones.models import (
|
||||||
AgentCheckinRequest,
|
AgentCheckinRequest,
|
||||||
AgentCheckinResponse,
|
AgentCheckinResponse,
|
||||||
|
AgentDiscoveryMinerMatch,
|
||||||
AgentZoneRoutingContext,
|
AgentZoneRoutingContext,
|
||||||
AgentZoneCreateRequest,
|
AgentZoneCreateRequest,
|
||||||
AgentZoneSummary,
|
AgentZoneSummary,
|
||||||
@@ -81,6 +82,8 @@ class AgentZoneService:
|
|||||||
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
||||||
|
|
||||||
existing = self.repository.get_agent(authenticated_agent_id)
|
existing = self.repository.get_agent(authenticated_agent_id)
|
||||||
|
if not existing or existing.get("agent_zone_id") is None:
|
||||||
|
raise AgentZoneValidationError("Agent requires an administrator-established Zone assignment")
|
||||||
requested_site = request.site_id
|
requested_site = request.site_id
|
||||||
requested_zone = request.agent_zone_id
|
requested_zone = request.agent_zone_id
|
||||||
final_site = requested_site or (existing or {}).get("site_id")
|
final_site = requested_site or (existing or {}).get("site_id")
|
||||||
@@ -177,6 +180,52 @@ class AgentZoneService:
|
|||||||
site_id=assignment.get("site_id"), agent_zone_id=zone_id
|
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 match_discovered_miner(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
*,
|
||||||
|
mac_address: str | None,
|
||||||
|
ip_address: str | None,
|
||||||
|
recorded_zone_id: UUID | None = None,
|
||||||
|
) -> AgentDiscoveryMinerMatch:
|
||||||
|
routing = self.routing_context(agent_id, recorded_zone_id)
|
||||||
|
if routing.routing_reason or routing.agent_zone_id is None:
|
||||||
|
return AgentDiscoveryMinerMatch(**routing.model_dump())
|
||||||
|
|
||||||
|
match = self.repository.find_miner_in_zone(
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_zone_id=routing.agent_zone_id,
|
||||||
|
mac_address=mac_address,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
return AgentDiscoveryMinerMatch(
|
||||||
|
**routing.model_dump(),
|
||||||
|
miner_id=match["id"] if match else None,
|
||||||
|
matched_by=match["matched_by"] if match else None,
|
||||||
|
)
|
||||||
|
|
||||||
def associate_miners_from_agent(
|
def associate_miners_from_agent(
|
||||||
self, agent_id: str, miner_ids: list[UUID]
|
self, agent_id: str, miner_ids: list[UUID]
|
||||||
) -> MinerZoneAssociationResult:
|
) -> MinerZoneAssociationResult:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Agent discovery isolation; physical miner writes are never authorized here."""
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Exact administrative CSV selection, inside the caller's locked transaction.
|
||||||
|
|
||||||
|
CSV metadata never establishes ownership or replaces board identity. Every supplied
|
||||||
|
identity is a constraint; a failed strong identifier never falls back to an IP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.discovery.repository import address, mac
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_inventory_row(cur, row, row_number, selected_zone=None):
|
||||||
|
def fail(message, status=409):
|
||||||
|
raise HTTPException(status_code=status, detail=f"CSV row {row_number}: {message}")
|
||||||
|
|
||||||
|
def field(names, normalize=lambda v: v):
|
||||||
|
values = []
|
||||||
|
for name in names:
|
||||||
|
value = row.get(name)
|
||||||
|
if value is not None and str(value).strip():
|
||||||
|
try:
|
||||||
|
values.append(normalize(str(value).strip()))
|
||||||
|
except (ValueError, HTTPException):
|
||||||
|
fail(f"invalid {names[0]}", 422)
|
||||||
|
if len(set(values)) > 1:
|
||||||
|
fail(f"conflicting {names[0]} columns")
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
def uuid(value):
|
||||||
|
return str(UUID(value))
|
||||||
|
|
||||||
|
mid = field(["Miner ID", "Miner UUID", "miner_id", "id"], uuid)
|
||||||
|
zone = field(["Agent Zone ID", "Zone ID", "agent_zone_id", "zone_id"], uuid)
|
||||||
|
site = field(["Site ID", "site_id"], uuid)
|
||||||
|
ip = field(["IP", "IP Address", "ip", "ip_address"], address)
|
||||||
|
current_mac = field(["MAC Addr", "MAC", "Mac", "mac", "mac_address"], mac)
|
||||||
|
serial = field(["Serial", "Serial Number", "SN", "Miner SN", "miner_sn", "serial_number"])
|
||||||
|
if selected_zone:
|
||||||
|
try:
|
||||||
|
selected_zone = uuid(selected_zone)
|
||||||
|
except ValueError:
|
||||||
|
fail("invalid selected Agent Zone ID", 422)
|
||||||
|
if zone and zone != selected_zone:
|
||||||
|
fail("row Zone disagrees with selected Zone")
|
||||||
|
zone = selected_zone
|
||||||
|
if zone:
|
||||||
|
cur.execute("SELECT site_id::text FROM agent_zones WHERE id=%s", (zone,))
|
||||||
|
zone_row = cur.fetchone()
|
||||||
|
if not zone_row:
|
||||||
|
fail("selected Zone does not exist", 422)
|
||||||
|
if site and site != zone_row["site_id"]:
|
||||||
|
fail("Site and Zone disagree")
|
||||||
|
if not (mid or current_mac or serial or (ip and zone)):
|
||||||
|
fail("provide Miner ID, current MAC, serial, or IP plus Agent Zone ID", 422)
|
||||||
|
|
||||||
|
selectors = []
|
||||||
|
if mid:
|
||||||
|
selectors.append(("id=%s::uuid", mid))
|
||||||
|
if current_mac:
|
||||||
|
selectors.append(("regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')=%s", current_mac.replace(":", "").lower()))
|
||||||
|
if serial:
|
||||||
|
selectors.append(("miner_sn=%s", serial))
|
||||||
|
# IP is only a namespace-scoped selector when no stronger identity is supplied.
|
||||||
|
if not selectors:
|
||||||
|
selectors.append(("current_ip=%s::inet AND agent_zone_id=%s::uuid", (ip, zone)))
|
||||||
|
|
||||||
|
candidates = {}
|
||||||
|
for predicate, value in selectors:
|
||||||
|
params = value if isinstance(value, tuple) else (value,)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id::text, site_id::text, agent_zone_id::text, "
|
||||||
|
"host(current_ip) AS current_ip, current_mac, miner_sn, model, miner_type, firmware_version "
|
||||||
|
"FROM miners WHERE " + predicate + " ORDER BY id LIMIT 2 FOR UPDATE",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
matches = cur.fetchall()
|
||||||
|
if len(matches) > 1:
|
||||||
|
fail("ambiguous inventory identity; supply exact Miner ID and consistent identifiers")
|
||||||
|
candidates.update({m["id"]: m for m in matches})
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
if len(candidates) != 1:
|
||||||
|
fail("identifiers select different inventory records")
|
||||||
|
miner = next(iter(candidates.values()))
|
||||||
|
if (
|
||||||
|
(mid and mid != miner["id"])
|
||||||
|
or (zone and zone != miner["agent_zone_id"])
|
||||||
|
or (site and site != miner["site_id"])
|
||||||
|
or (ip and ip != miner["current_ip"])
|
||||||
|
or (serial and serial != miner["miner_sn"])
|
||||||
|
or (current_mac and current_mac != mac(miner["current_mac"]))
|
||||||
|
):
|
||||||
|
fail("identifiers disagree with current inventory; refresh selection before importing")
|
||||||
|
return miner
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Bounded HTTP adapter; authentication remains on the registered routes."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
import psycopg
|
||||||
|
from app.discovery.repository import DiscoveryRepository
|
||||||
|
|
||||||
|
MAX_BODY = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch(method, request=None, **kwargs):
|
||||||
|
if request is not None:
|
||||||
|
chunks = []
|
||||||
|
size = 0
|
||||||
|
async for chunk in request.stream():
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_BODY:
|
||||||
|
raise HTTPException(413, "discovery_payload_too_large")
|
||||||
|
chunks.append(chunk)
|
||||||
|
try:
|
||||||
|
|
||||||
|
def invalid_constant(_):
|
||||||
|
raise ValueError("Non-finite JSON number")
|
||||||
|
|
||||||
|
kwargs["body"] = json.loads(
|
||||||
|
b"".join(chunks), parse_constant=invalid_constant
|
||||||
|
)
|
||||||
|
except (ValueError, UnicodeDecodeError, RecursionError):
|
||||||
|
raise HTTPException(422, "discovery_invalid_json")
|
||||||
|
try:
|
||||||
|
return await run_in_threadpool(getattr(DiscoveryRepository(), method), **kwargs)
|
||||||
|
except (
|
||||||
|
psycopg.errors.LockNotAvailable,
|
||||||
|
psycopg.errors.QueryCanceled,
|
||||||
|
psycopg.errors.DeadlockDetected,
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "discovery_concurrent_change_retry")
|
||||||
|
except psycopg.DataError:
|
||||||
|
raise HTTPException(422, "discovery_invalid_value")
|
||||||
|
except psycopg.IntegrityError:
|
||||||
|
raise HTTPException(409, "discovery_identity_conflict")
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -74,7 +74,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p id="agentEnrollmentResultCopy">
|
||||||
This token is displayed only for this enrollment step.
|
This token is displayed only for this enrollment step.
|
||||||
Keep it private. MinerOps does not store the plaintext token.
|
Keep it private. MinerOps does not store the plaintext token.
|
||||||
</p>
|
</p>
|
||||||
@@ -152,7 +152,9 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<section class="agent-summary-grid" aria-label="Agent Management summary">
|
||||||
<div class="panel agent-summary-card">
|
<div class="panel agent-summary-card">
|
||||||
<span>Registered Agents</span>
|
<span>Registered Agents</span>
|
||||||
@@ -197,6 +199,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
|||||||
<th>Last check-in</th>
|
<th>Last check-in</th>
|
||||||
<th>Credential</th>
|
<th>Credential</th>
|
||||||
<th>Routing</th>
|
<th>Routing</th>
|
||||||
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="agentRows"></tbody>
|
<tbody id="agentRows"></tbody>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
"agentCreateSubmit",
|
"agentCreateSubmit",
|
||||||
"agentCreateStatus",
|
"agentCreateStatus",
|
||||||
"agentEnrollmentResult",
|
"agentEnrollmentResult",
|
||||||
|
"agentEnrollmentResultTitle",
|
||||||
|
"agentEnrollmentResultCopy",
|
||||||
"agentEnrollmentToken",
|
"agentEnrollmentToken",
|
||||||
"agentEnrollmentExpires",
|
"agentEnrollmentExpires",
|
||||||
"agentEnrollmentDownload",
|
"agentEnrollmentDownload",
|
||||||
@@ -48,6 +50,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let enrollments = [];
|
let enrollments = [];
|
||||||
|
let packageEndpoint = "/api/agent-enrollments/windows-package";
|
||||||
|
let packageFilename = "MinerOpsAgent-Windows.zip";
|
||||||
|
|
||||||
function text(value, fallback) {
|
function text(value, fallback) {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
@@ -143,6 +147,27 @@
|
|||||||
cell(words(agent.routing_state))
|
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);
|
el.agentRows.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +213,10 @@
|
|||||||
cell(words(enrollment.status)),
|
cell(words(enrollment.status)),
|
||||||
cell(dateTime(enrollment.created_at)),
|
cell(dateTime(enrollment.created_at)),
|
||||||
cell(dateTime(enrollment.expires_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);
|
el.agentEnrollmentRows.appendChild(row);
|
||||||
@@ -338,15 +366,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const enrollment = payload.data;
|
const enrollment = payload.data;
|
||||||
|
showOneTimeToken({
|
||||||
el.agentEnrollmentToken.textContent =
|
token: enrollment.enrollment_token,
|
||||||
enrollment.enrollment_token;
|
expiresAt: enrollment.expires_at,
|
||||||
|
title: "Enrollment created",
|
||||||
el.agentEnrollmentExpires.textContent =
|
copy: "This token creates a new Agent identity. Keep it private. " +
|
||||||
"Expires " + dateTime(enrollment.expires_at);
|
"MinerOps does not store the plaintext token.",
|
||||||
|
endpoint: "/api/agent-enrollments/windows-package",
|
||||||
el.agentEnrollmentCopyStatus.textContent = "";
|
filename: "MinerOpsAgent-Windows.zip"
|
||||||
el.agentEnrollmentResult.hidden = false;
|
});
|
||||||
el.agentCreateStatus.textContent = "Enrollment created.";
|
el.agentCreateStatus.textContent = "Enrollment created.";
|
||||||
|
|
||||||
el.agentDisplayName.value = "";
|
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() {
|
async function downloadWindowsPackage() {
|
||||||
const token = el.agentEnrollmentToken.textContent;
|
const token = el.agentEnrollmentToken.textContent;
|
||||||
|
|
||||||
@@ -375,7 +451,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"/api/agent-enrollments/windows-package",
|
packageEndpoint,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@@ -408,7 +484,7 @@
|
|||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|
||||||
link.href = downloadUrl;
|
link.href = downloadUrl;
|
||||||
link.download = "MinerOpsAgent-Windows.zip";
|
link.download = packageFilename;
|
||||||
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|||||||
@@ -700,6 +700,7 @@ def required_page_permission(path: str) -> str:
|
|||||||
def machine_agent_request(path: str) -> bool:
|
def machine_agent_request(path: str) -> bool:
|
||||||
exact = {
|
exact = {
|
||||||
"/api/agent/enroll",
|
"/api/agent/enroll",
|
||||||
|
"/api/agent/credential-transition",
|
||||||
"/api/agent/checkin",
|
"/api/agent/checkin",
|
||||||
"/api/agent/config",
|
"/api/agent/config",
|
||||||
"/api/agent/known-miners",
|
"/api/agent/known-miners",
|
||||||
@@ -707,7 +708,10 @@ def machine_agent_request(path: str) -> bool:
|
|||||||
"/api/agent/pool-snapshots",
|
"/api/agent/pool-snapshots",
|
||||||
"/api/agent/commands/pending",
|
"/api/agent/commands/pending",
|
||||||
"/api/agent/commands/result",
|
"/api/agent/commands/result",
|
||||||
|
"/api/agent/commands/pending-disabled-old",
|
||||||
|
"/api/agent/commands/result-disabled-old",
|
||||||
"/api/agent/discoveries",
|
"/api/agent/discoveries",
|
||||||
|
"/api/agent/discovery-target",
|
||||||
"/api/agent/miner-enrichment",
|
"/api/agent/miner-enrichment",
|
||||||
"/api/agent/miner-enrichment-v2",
|
"/api/agent/miner-enrichment-v2",
|
||||||
"/api/agent/miner-enrichment-v3",
|
"/api/agent/miner-enrichment-v3",
|
||||||
@@ -759,6 +763,19 @@ def component_request(path: str) -> bool:
|
|||||||
|
|
||||||
def required_api_permission(method: str, path: str) -> str | None:
|
def required_api_permission(method: str, path: str) -> str | None:
|
||||||
parts = path.split("/")
|
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 (
|
if (
|
||||||
len(parts) >= 5
|
len(parts) >= 5
|
||||||
and parts[:3] == ["", "api", "miners"]
|
and parts[:3] == ["", "api", "miners"]
|
||||||
@@ -787,6 +804,10 @@ def required_api_permission(method: str, path: str) -> str | None:
|
|||||||
return "agents.view" if method == "GET" else "agents.manage"
|
return "agents.view" if method == "GET" else "agents.manage"
|
||||||
if path == "/api/agent-enrollments/windows-package":
|
if path == "/api/agent-enrollments/windows-package":
|
||||||
return "agents.manage"
|
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):
|
if repair_parts_request(path):
|
||||||
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
||||||
if path.startswith("/api/admin/"):
|
if path.startswith("/api/admin/"):
|
||||||
@@ -806,6 +827,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
|||||||
return "repairs.complete"
|
return "repairs.complete"
|
||||||
if method == "GET" and repair_timeline_request(path):
|
if method == "GET" and repair_timeline_request(path):
|
||||||
return "repairs.read"
|
return "repairs.read"
|
||||||
|
if method == "GET" and path in {"/api/agent/discoveries/summary", "/api/agent/status-summary"}:
|
||||||
|
return "agents.view"
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
if path.startswith("/api/system/"):
|
if path.startswith("/api/system/"):
|
||||||
return "system_health.view"
|
return "system_health.view"
|
||||||
|
|||||||
+363
-5555
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,6 @@
|
|||||||
"""Business rules for typed, Agent-routed miner controls."""
|
"""Business rules for typed, Agent-routed miner controls."""
|
||||||
|
|
||||||
from typing import Any
|
from uuid import UUID
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from app.miner_control.exceptions import (
|
from app.miner_control.exceptions import (
|
||||||
ControlledMinerNotFound,
|
ControlledMinerNotFound,
|
||||||
@@ -16,6 +15,7 @@ from app.miner_control.models import (
|
|||||||
MinerControlProjection,
|
MinerControlProjection,
|
||||||
)
|
)
|
||||||
from app.miner_control.repository import MinerControlRepository
|
from app.miner_control.repository import MinerControlRepository
|
||||||
|
from app.agent_commands.policy import PHYSICAL_CONTROL_DISABLED
|
||||||
|
|
||||||
|
|
||||||
KNOWN_MODES = {"low", "normal", "high"}
|
KNOWN_MODES = {"low", "normal", "high"}
|
||||||
@@ -29,33 +29,8 @@ class MinerControlService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _capability(context: dict) -> ControlCapability:
|
def _capability(context: dict) -> ControlCapability:
|
||||||
if not context.get("agent_zone_id"):
|
# Credentials and Zone routing are not physical identity authorization.
|
||||||
return ControlCapability(state="unavailable", reason="agent_zone_unassigned")
|
return ControlCapability(state="unavailable", reason=PHYSICAL_CONTROL_DISABLED)
|
||||||
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")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
|
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
|
||||||
@@ -120,50 +95,4 @@ class MinerControlService:
|
|||||||
) -> MinerControlAccepted:
|
) -> MinerControlAccepted:
|
||||||
if not actor_user_id:
|
if not actor_user_id:
|
||||||
raise MinerControlActorRequired("Authenticated actor UUID is required")
|
raise MinerControlActorRequired("Authenticated actor UUID is required")
|
||||||
context = self.repository.get_control_context(miner_id)
|
raise MinerControlUnavailable(PHYSICAL_CONTROL_DISABLED)
|
||||||
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"],
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Actual Chromium; every HTTP request is fulfilled or aborted, with disposable data only.
|
||||||
|
const {chromium}=require('playwright');
|
||||||
|
const fs=require('fs');const assert=require('node:assert/strict');
|
||||||
|
(async()=>{
|
||||||
|
const browser=await chromium.launch({headless:true,args:['--no-sandbox','--disable-background-networking']});
|
||||||
|
try {
|
||||||
|
const context=await browser.newContext({serviceWorkers:'block'});
|
||||||
|
const page=await context.newPage();
|
||||||
|
const marker='<img src="data:," onerror="window.__discoveryAudit=1">';
|
||||||
|
const commands=[];const unexpected=[];
|
||||||
|
const observations=[0,1].map(n=>({id:'00000000-0000-4000-8000-00000000000'+(n+1),agent_id:'Agent '+n,agent_zone_id:'Zone '+n,version:'2026-01-01T00:00:00.00000'+n+'+00:00',created_at:'2026-01-01T00:00:00Z',ip_address:'198.51.100.10',room_name:marker,model:marker,mac_address:marker,discovery_status:'new'}));
|
||||||
|
await page.route('**/*',async route=>{
|
||||||
|
const url=new URL(route.request().url());
|
||||||
|
if(url.origin!=='http://minerops.invalid'){unexpected.push(url.href);return route.abort();}
|
||||||
|
const path=url.pathname;
|
||||||
|
if(path==='/agent/control')return route.fulfill({contentType:'text/html',body:fs.readFileSync(process.argv[2],'utf8')});
|
||||||
|
if(path==='/api/agent/status-summary')return route.fulfill({json:{live_database_counts:{total:2,online:2},latest_snapshot:{},agent:{agent_id:marker,hostname:marker,version:marker},recent_commands:[{command_type:marker,command_status:'failed',requested_by_username:marker,error_message:marker}]}});
|
||||||
|
if(path==='/api/agent/discoveries')return route.fulfill({json:{discoveries:observations}});
|
||||||
|
if(path==='/api/agent/discoveries/summary')return route.fulfill({json:{total:2,counts:{new:2}}});
|
||||||
|
if(path==='/api/agent/discoveries/review'||path==='/api/agent/discoveries/enrich-match'){
|
||||||
|
commands.push({path,body:route.request().postDataJSON()});return route.fulfill({json:{ok:true,message:'Saved'}});
|
||||||
|
}
|
||||||
|
return route.abort();
|
||||||
|
});
|
||||||
|
await context.addInitScript(()=>localStorage.setItem('minerops_token','disposable-browser-token'));
|
||||||
|
page.on('dialog',dialog=>dialog.accept());
|
||||||
|
await page.goto('http://minerops.invalid/agent/control');
|
||||||
|
await page.waitForSelector('#discoveriesBody tr button');
|
||||||
|
if(process.argv[3]==='baseline'){
|
||||||
|
await page.waitForFunction(()=>window.__discoveryAudit===1);
|
||||||
|
console.log('Baseline positive control: harmless stored event handler executed.');
|
||||||
|
} else {
|
||||||
|
assert.equal(await page.evaluate(()=>window.__discoveryAudit),undefined);
|
||||||
|
assert.equal(await page.locator('#discoveriesBody img,#commandsBody img,[onerror]').count(),0);
|
||||||
|
assert.ok((await page.locator('#discoveriesBody').innerText()).includes(marker));
|
||||||
|
await page.locator('#discoveriesBody tr').nth(0).getByRole('button',{name:'Add as New',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.getElementById('statusBox').textContent==='Agent status loaded.');
|
||||||
|
await page.locator('#discoveriesBody tr').nth(1).getByRole('button',{name:'Enrich / Match',exact:true}).click();
|
||||||
|
assert.equal(commands.length,2);
|
||||||
|
assert.equal(commands[0].body.discovery_id,observations[0].id);
|
||||||
|
assert.equal(commands[0].body.version,observations[0].version);
|
||||||
|
assert.equal(commands[1].body.discovery_id,observations[1].id);
|
||||||
|
assert.equal(commands[1].body.version,observations[1].version);
|
||||||
|
assert.ok(commands.every(c=>!Object.hasOwn(c.body,'ip_address')));
|
||||||
|
assert.deepEqual(unexpected,[]);
|
||||||
|
console.log('Chromium passed: stored markup inert; Agent/status/error data escaped; same-IP actions retain exact observation IDs/versions; no external requests.');
|
||||||
|
}
|
||||||
|
} finally {await browser.close();}
|
||||||
|
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Export the literal production page without importing/startup or network access."""
|
||||||
|
|
||||||
|
import ast, json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(sys.argv[1])
|
||||||
|
tree = ast.parse((root / "backend/app/main.py").read_text())
|
||||||
|
node = next(
|
||||||
|
n
|
||||||
|
for n in tree.body
|
||||||
|
if isinstance(n, ast.AsyncFunctionDef) and n.name == "minerops_agent_control_page"
|
||||||
|
)
|
||||||
|
html = next(
|
||||||
|
n.value
|
||||||
|
for n in ast.walk(node)
|
||||||
|
if isinstance(n, ast.Constant)
|
||||||
|
and isinstance(n.value, str)
|
||||||
|
and "<!doctype html>" in n.value
|
||||||
|
)
|
||||||
|
Path(sys.argv[2]).write_text(html)
|
||||||
@@ -49,6 +49,14 @@ class AgentAuthServiceTests(unittest.TestCase):
|
|||||||
"legacy-agent",
|
"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):
|
def test_legacy_agent_with_null_credential_still_authenticates(self):
|
||||||
rows = {
|
rows = {
|
||||||
"legacy-agent": {
|
"legacy-agent": {
|
||||||
@@ -116,13 +124,83 @@ class AgentAuthServiceTests(unittest.TestCase):
|
|||||||
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
|
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
|
||||||
self.service(rows).authenticate("agent-new", secret)
|
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):
|
def test_unknown_identity_does_not_use_shared_secret(self):
|
||||||
with self.assertRaises(AgentIdentityUnauthorized):
|
with self.assertRaises(AgentIdentityUnauthorized):
|
||||||
self.service().authenticate("unknown-agent", "legacy-secret")
|
self.service().authenticate("unknown-agent", "legacy-secret")
|
||||||
|
|
||||||
def test_missing_identity_or_secret_is_rejected(self):
|
def test_missing_secret_is_rejected(self):
|
||||||
with self.assertRaises(AgentAuthenticationFailed):
|
|
||||||
self.service().authenticate(None, "legacy-secret")
|
|
||||||
with self.assertRaises(AgentAuthenticationFailed):
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
self.service().authenticate("legacy-agent", None)
|
self.service().authenticate("legacy-agent", None)
|
||||||
|
|
||||||
@@ -131,6 +209,11 @@ class AgentAuthServiceTests(unittest.TestCase):
|
|||||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||||
self.service().authenticate("legacy-agent", "anything")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -16,7 +16,7 @@ from app.agent_commands.exceptions import (
|
|||||||
AgentCommandConflict,
|
AgentCommandConflict,
|
||||||
AgentResultValidationError,
|
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.repository import CLAIM_COMMANDS_SQL
|
||||||
from app.agent_commands.router import router
|
from app.agent_commands.router import router
|
||||||
from app.agent_commands.service import AgentCommandService
|
from app.agent_commands.service import AgentCommandService
|
||||||
@@ -38,14 +38,14 @@ class FakeRepository:
|
|||||||
def __init__(self, row=None):
|
def __init__(self, row=None):
|
||||||
self.row = row or {
|
self.row = row or {
|
||||||
"id": "command-1", "agent_id": "test-agent",
|
"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"},
|
"request_payload": {"action": "suspend"},
|
||||||
}
|
}
|
||||||
self.finished = []
|
self.finished = []
|
||||||
|
|
||||||
def claim_pending(self, agent_id, limit=5):
|
def claim_pending(self, agent_id, limit=5):
|
||||||
return [{
|
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,
|
"request_payload": {"action": "suspend"}, "created_at": NOW,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
@@ -54,6 +54,8 @@ class FakeRepository:
|
|||||||
|
|
||||||
def finish_command(self, *args):
|
def finish_command(self, *args):
|
||||||
self.finished.append(args)
|
self.finished.append(args)
|
||||||
|
if args[2] == "cancelled":
|
||||||
|
raise AgentCommandConflict(args[4])
|
||||||
|
|
||||||
|
|
||||||
def typed_result(**overrides):
|
def typed_result(**overrides):
|
||||||
@@ -84,7 +86,7 @@ class AgentCommandServiceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
AgentCommandService(repository).submit_result("test-agent", request)
|
AgentCommandService(repository).submit_result("test-agent", request)
|
||||||
finished = repository.finished[0]
|
finished = repository.finished[0]
|
||||||
self.assertIsNone(finished[4])
|
self.assertIn("[address]", finished[4])
|
||||||
self.assertNotIn("192.0.2.10", str(finished))
|
self.assertNotIn("192.0.2.10", str(finished))
|
||||||
|
|
||||||
def test_wrong_agent_pending_and_terminal_results_are_rejected(self):
|
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",
|
command_id="command-1", status="completed",
|
||||||
result_payload={**typed_result(), forbidden: "sensitive"},
|
result_payload={**typed_result(), forbidden: "sensitive"},
|
||||||
)
|
)
|
||||||
with self.assertRaises(AgentResultValidationError):
|
with self.assertRaises(ValueError):
|
||||||
AgentCommandService(repository).submit_result("test-agent", request)
|
MinerControlResultPayload.model_validate(request.result_payload)
|
||||||
|
|
||||||
def test_suspend_resume_cannot_claim_confirmation(self):
|
def test_suspend_resume_cannot_claim_confirmation(self):
|
||||||
for status in ("confirmed", "verification_failed"):
|
for status in ("confirmed", "verification_failed"):
|
||||||
@@ -123,8 +125,10 @@ class AgentCommandServiceTests(unittest.TestCase):
|
|||||||
verification_attempted=True, verification_status=status
|
verification_attempted=True, verification_status=status
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
with self.assertRaises(AgentResultValidationError):
|
repository = FakeRepository()
|
||||||
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
|
repository.row["command_type"] = "miner_control"
|
||||||
|
with self.assertRaisesRegex(AgentCommandConflict, "physical_control_disabled_stage_a"):
|
||||||
|
AgentCommandService(repository).submit_result("test-agent", request)
|
||||||
|
|
||||||
|
|
||||||
class FakeAgentAuthRepository:
|
class FakeAgentAuthRepository:
|
||||||
@@ -160,17 +164,26 @@ class AgentCommandRouterTests(unittest.TestCase):
|
|||||||
"X-MinerOps-Agent-Secret": "test-secret",
|
"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 = []
|
before_claim_calls = []
|
||||||
self.app.state.agent_command_before_claim = lambda: before_claim_calls.append(True)
|
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").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.assertEqual(
|
||||||
self.client.get(
|
self.client.get(
|
||||||
"/api/agent/commands/pending?agent_id=other", headers=self.headers()
|
"/api/agent/commands/pending?agent_id=other", headers=self.headers()
|
||||||
).status_code,
|
).status_code,
|
||||||
200,
|
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.assertEqual(
|
||||||
self.client.get(
|
self.client.get(
|
||||||
"/api/agent/commands/pending", headers=self.headers("other")
|
"/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', service)
|
||||||
self.assertIn('"agent_zone_id"', (root / "config.example.json").read_text())
|
self.assertIn('"agent_zone_id"', (root / "config.example.json").read_text())
|
||||||
self.assertNotIn("?agent_id=", service)
|
self.assertNotIn("?agent_id=", service)
|
||||||
self.assertIn('"get.miner.setting"', control)
|
self.assertIn("physical_control_disabled_stage_a", control)
|
||||||
self.assertIn("$attempt -le 3", control)
|
self.assertNotIn('"set.miner.', control)
|
||||||
self.assertIn("$Payload.performance_mode", control)
|
self.assertNotIn("$Payload.performance_mode", control)
|
||||||
self.assertNotIn("api3_account =", control)
|
self.assertNotIn("api3_account =", control)
|
||||||
self.assertNotIn("api3_password =", control)
|
self.assertNotIn("api3_password =", control)
|
||||||
for forbidden in ("raw_response", "raw_command", "raw_exception"):
|
for forbidden in ("raw_response", "raw_command", "raw_exception"):
|
||||||
@@ -251,30 +264,13 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
class AgentTransportIsolationSourceTests(unittest.TestCase):
|
class AgentTransportIsolationSourceTests(unittest.TestCase):
|
||||||
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
|
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
|
||||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
from app.main import minerops_agent_known_miners, agent_pool_snapshots
|
||||||
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
|
from inspect import getsource
|
||||||
|
for fn,method in [(minerops_agent_known_miners,"known"),(agent_pool_snapshots,"snapshots")]:
|
||||||
known_start = main.index('@app.get("/api/agent/known-miners")')
|
source=getsource(fn)
|
||||||
known_end = main.index("# Phase 5J:", known_start)
|
self.assertIn("Depends(require_agent_identity)",source)
|
||||||
known = main[known_start:known_end]
|
self.assertIn("discovery_dispatch",source)
|
||||||
|
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||||
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
|
|
||||||
pool_end = main.index("\n@app.", pool_start + 1)
|
|
||||||
pool = main[pool_start:pool_end]
|
|
||||||
|
|
||||||
self.assertIn('"/api/agent/known-miners"', identity)
|
|
||||||
|
|
||||||
self.assertIn("Depends(require_agent_identity)", known)
|
|
||||||
self.assertIn("zone_service.routing_context", known)
|
|
||||||
self.assertIn("miners.agent_zone_id = $1::uuid", known)
|
|
||||||
self.assertNotIn("minerops_require_agent_secret", known)
|
|
||||||
|
|
||||||
self.assertIn("Depends(require_agent_identity)", pool)
|
|
||||||
self.assertIn("zone_service.routing_context", pool)
|
|
||||||
self.assertIn("agent_id = authenticated_agent_id", pool)
|
|
||||||
self.assertIn("agent_zone_id = %s", pool)
|
|
||||||
self.assertNotIn('body.get("agent_id")', pool)
|
|
||||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
|
|
||||||
|
|
||||||
def test_windows_machine_pollers_send_agent_identity(self):
|
def test_windows_machine_pollers_send_agent_identity(self):
|
||||||
root = agent_source_root()
|
root = agent_source_root()
|
||||||
@@ -294,67 +290,21 @@ class AgentTransportIsolationSourceTests(unittest.TestCase):
|
|||||||
|
|
||||||
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
||||||
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
|
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
|
||||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
from app import main
|
||||||
|
from inspect import getsource
|
||||||
decorators = (
|
for fn in [main.minerops_agent_miner_enrichment,main.minerops_agent_miner_enrichment_v2,main.minerops_agent_miner_enrichment_v3]:
|
||||||
'@app.post("/api/agent/miner-enrichment")',
|
source=getsource(fn)
|
||||||
'@app.post("/api/agent/miner-enrichment-v2")',
|
self.assertIn("Depends(require_agent_identity)",source)
|
||||||
'@app.post("/api/agent/miner-enrichment-v3")',
|
self.assertIn("discovery_dispatch",source)
|
||||||
)
|
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||||
|
|
||||||
for decorator in decorators:
|
|
||||||
with self.subTest(decorator=decorator):
|
|
||||||
start = main.index(decorator)
|
|
||||||
end = main.index("\n@app.", start + 1)
|
|
||||||
route = main[start:end]
|
|
||||||
|
|
||||||
self.assertIn("Depends(require_agent_identity)", route)
|
|
||||||
self.assertIn("zone_service.routing_context", route)
|
|
||||||
self.assertIn("agent_zone_id", route)
|
|
||||||
self.assertNotIn("minerops_require_agent_secret", route)
|
|
||||||
|
|
||||||
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
|
|
||||||
v2_end = main.index("\n@app.", v2_start + 1)
|
|
||||||
v2 = main[v2_start:v2_end]
|
|
||||||
self.assertIn(
|
|
||||||
"AND agent_zone_id = $2::uuid",
|
|
||||||
v2,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"AND agent_zone_id = $3::uuid",
|
|
||||||
v2,
|
|
||||||
)
|
|
||||||
|
|
||||||
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
|
|
||||||
v3_end = main.index("\n@app.", v3_start + 1)
|
|
||||||
v3 = main[v3_start:v3_end]
|
|
||||||
self.assertIn(
|
|
||||||
"AND agent_zone_id = $8::uuid",
|
|
||||||
v3,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"AND agent_zone_id = $7::uuid",
|
|
||||||
v3,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
|
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
|
||||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
from app.main import minerops_agent_api3_enrichment
|
||||||
|
from inspect import getsource
|
||||||
start = main.index('@app.post("/api/agent/api3-enrichment")')
|
source=getsource(minerops_agent_api3_enrichment)
|
||||||
end = main.index("\n@app.", start + 1)
|
self.assertIn("Depends(require_agent_identity)",source)
|
||||||
route = main[start:end]
|
self.assertIn("discovery_dispatch",source)
|
||||||
|
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||||
self.assertIn("Depends(require_agent_identity)", route)
|
|
||||||
self.assertIn("zone_service.routing_context", route)
|
|
||||||
self.assertIn("agent_id = authenticated_agent_id", route)
|
|
||||||
self.assertIn("agent_zone_id = %s", route)
|
|
||||||
self.assertIn("if miner_id is None:", route)
|
|
||||||
self.assertIn("continue", route)
|
|
||||||
|
|
||||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
|
|
||||||
self.assertNotIn('body.get("agent_id")', route)
|
|
||||||
self.assertNotIn('body.get("site_name")', route)
|
|
||||||
self.assertNotIn('request.headers.get("x-agent-secret")', route)
|
|
||||||
|
|
||||||
def test_legacy_config_remains_explicitly_legacy_only(self):
|
def test_legacy_config_remains_explicitly_legacy_only(self):
|
||||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||||
@@ -366,3 +316,18 @@ class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
|||||||
self.assertIn("minerops_require_agent_secret(request)", route)
|
self.assertIn("minerops_require_agent_secret(request)", route)
|
||||||
self.assertIn('"site_name": "Plexus"', route)
|
self.assertIn('"site_name": "Plexus"', route)
|
||||||
self.assertNotIn("Depends(require_agent_identity)", 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()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Reused discovery isolation scenarios exercised through real HTTP authority."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from tests.test_discovery_security_postgres import DiscoveryFixtures
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDiscoveryIsolationPostgresTests(DiscoveryFixtures, unittest.TestCase):
|
||||||
|
def test_cross_zone_mac_and_ip_are_unmatched_without_id_disclosure(self):
|
||||||
|
mid, mac = self.miner(1)
|
||||||
|
before = self.durable()
|
||||||
|
row = self.observe(mac=mac)
|
||||||
|
self.assertEqual(row["discovery_status"], "zone_conflict")
|
||||||
|
self.assertIsNone(row["matched_miner_id"])
|
||||||
|
self.assertNotIn(str(mid), str(row))
|
||||||
|
self.assertEqual(before["miners"], self.durable()["miners"])
|
||||||
|
|
||||||
|
def test_same_zone_mac_and_ip_matches_are_preserved(self):
|
||||||
|
mid, mac = self.miner()
|
||||||
|
row = self.observe(mac=mac)
|
||||||
|
self.assertEqual(row["matched_miner_id"], str(mid))
|
||||||
|
self.assertEqual(row["discovery_status"], "known")
|
||||||
|
|
||||||
|
def test_unassigned_miner_is_not_claimed_by_discovery_matching(self):
|
||||||
|
mid, mac = self.miner()
|
||||||
|
with __import__(
|
||||||
|
"app.agent_zones.repository", fromlist=["default_connection_factory"]
|
||||||
|
).default_connection_factory() as conn:
|
||||||
|
conn.execute("UPDATE miners SET agent_zone_id=NULL WHERE id=%s", (mid,))
|
||||||
|
row = self.observe(mac=mac)
|
||||||
|
self.assertIsNone(row["matched_miner_id"])
|
||||||
|
self.assertIsNone(
|
||||||
|
self.scalar("SELECT agent_zone_id FROM miners WHERE id=%s", (mid,))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_claimed_agent_identity_cannot_override_authentication(self):
|
||||||
|
response = self.post(
|
||||||
|
"/api/agent/discoveries", {"agent_id": self.agents[1], "discoveries": []}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
@@ -5,13 +5,24 @@ import unittest
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID
|
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_auth.service import hash_agent_secret
|
||||||
from app.agent_enrollment.models import (
|
from app.agent_enrollment.models import (
|
||||||
|
AgentCredentialTransitionCreateRequest,
|
||||||
|
AgentCredentialTransitionRedeemRequest,
|
||||||
AgentEnrollmentCreateRequest,
|
AgentEnrollmentCreateRequest,
|
||||||
AgentEnrollRequest,
|
AgentEnrollRequest,
|
||||||
)
|
)
|
||||||
from app.agent_enrollment.service import AgentEnrollmentService
|
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")
|
SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
|
||||||
@@ -25,6 +36,8 @@ class FakeAgentEnrollmentRepository:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.created = None
|
self.created = None
|
||||||
self.redeemed = None
|
self.redeemed = None
|
||||||
|
self.transition_created = None
|
||||||
|
self.transition_redeemed = None
|
||||||
|
|
||||||
def create_enrollment(self, **kwargs):
|
def create_enrollment(self, **kwargs):
|
||||||
self.created = kwargs
|
self.created = kwargs
|
||||||
@@ -42,6 +55,20 @@ class FakeAgentEnrollmentRepository:
|
|||||||
def list_enrollments(self, **kwargs):
|
def list_enrollments(self, **kwargs):
|
||||||
return []
|
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):
|
def redeem_enrollment(self, **kwargs):
|
||||||
self.redeemed = kwargs
|
self.redeemed = kwargs
|
||||||
return {
|
return {
|
||||||
@@ -54,6 +81,18 @@ class FakeAgentEnrollmentRepository:
|
|||||||
"ip_ranges": ["10.30.0.0/22"],
|
"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):
|
class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||||
def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self):
|
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)
|
serialized = repr(repository.redeemed)
|
||||||
self.assertNotIn(secret, serialized)
|
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):
|
def test_enrollment_admin_permission_mapping_is_explicit(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
required_api_permission("GET", "/api/agent-enrollments"),
|
required_api_permission("GET", "/api/agent-enrollments"),
|
||||||
@@ -132,9 +210,18 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
|||||||
required_api_permission("POST", "/api/agent-enrollments"),
|
required_api_permission("POST", "/api/agent-enrollments"),
|
||||||
"agents.manage",
|
"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):
|
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/enroll"))
|
||||||
|
self.assertTrue(
|
||||||
|
machine_agent_request("/api/agent/credential-transition")
|
||||||
|
)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
machine_agent_request("/api/agent-enrollments")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -4,6 +4,7 @@ import unittest
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from psycopg.errors import ForeignKeyViolation
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
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_auth.service import AgentAuthService, hash_agent_secret
|
||||||
from app.agent_enrollment.exceptions import (
|
from app.agent_enrollment.exceptions import (
|
||||||
AgentEnrollmentConflict,
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
AgentEnrollmentNotFound,
|
AgentEnrollmentNotFound,
|
||||||
AgentEnrollmentTokenInvalid,
|
AgentEnrollmentTokenInvalid,
|
||||||
)
|
)
|
||||||
@@ -26,6 +28,7 @@ from app.agent_enrollment.service import AgentEnrollmentService
|
|||||||
from app.agent_zones.models import AgentCheckinRequest
|
from app.agent_zones.models import AgentCheckinRequest
|
||||||
from app.agent_zones.repository import AgentZoneRepository
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
from app.agent_zones.service import AgentZoneService
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
class AgentEnrollmentPostgresTests(unittest.TestCase):
|
class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||||
@@ -322,6 +325,7 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
|||||||
agent_secret_hash,
|
agent_secret_hash,
|
||||||
credential_created_at,
|
credential_created_at,
|
||||||
credential_revoked_at,
|
credential_revoked_at,
|
||||||
|
legacy_auth_disabled_at,
|
||||||
last_payload::text AS last_payload
|
last_payload::text AS last_payload
|
||||||
FROM minerops_site_agents
|
FROM minerops_site_agents
|
||||||
WHERE site_id = %s
|
WHERE site_id = %s
|
||||||
@@ -345,6 +349,16 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
enrollment_row = cur.fetchone()
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT new_value_json::text AS payload
|
SELECT new_value_json::text AS payload
|
||||||
@@ -369,6 +383,15 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIsNotNone(agent["credential_created_at"])
|
self.assertIsNotNone(agent["credential_created_at"])
|
||||||
self.assertIsNone(agent["credential_revoked_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.assertIsNotNone(enrollment_row["used_at"])
|
||||||
self.assertIsNone(enrollment_row["revoked_at"])
|
self.assertIsNone(enrollment_row["revoked_at"])
|
||||||
@@ -409,6 +432,17 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
|||||||
"definitely-not-the-issued-secret",
|
"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.
|
# Normal check-in must never mutate the credential.
|
||||||
secret_hash_before = agent["agent_secret_hash"]
|
secret_hash_before = agent["agent_secret_hash"]
|
||||||
|
|
||||||
@@ -433,7 +467,8 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
|||||||
SELECT
|
SELECT
|
||||||
agent_secret_hash,
|
agent_secret_hash,
|
||||||
credential_created_at,
|
credential_created_at,
|
||||||
credential_revoked_at
|
credential_revoked_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
FROM minerops_site_agents
|
FROM minerops_site_agents
|
||||||
WHERE agent_id = %s
|
WHERE agent_id = %s
|
||||||
""",
|
""",
|
||||||
@@ -453,6 +488,150 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
|||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
after_checkin["credential_revoked_at"]
|
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):
|
def test_expired_token_is_rejected_without_side_effects(self):
|
||||||
enrollment = self.create_enrollment()
|
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="agentSite"', managed)
|
||||||
self.assertIn('id="agentZone"', managed)
|
self.assertIn('id="agentZone"', managed)
|
||||||
self.assertIn('id="agentDisplayName"', managed)
|
self.assertIn('id="agentDisplayName"', managed)
|
||||||
|
self.assertIn('data-can-manage-agents="true"', managed)
|
||||||
self.assertIn("Linux x64 — coming later", managed)
|
self.assertIn("Linux x64 — coming later", managed)
|
||||||
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
|
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
|
||||||
|
|
||||||
@@ -100,6 +101,11 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
|||||||
'api("/api/agent-enrollments", {',
|
'api("/api/agent-enrollments", {',
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'api("/api/agent-credential-transitions", {',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn("Upgrade credential", source)
|
||||||
|
|
||||||
for unsafe in (
|
for unsafe in (
|
||||||
"innerHTML",
|
"innerHTML",
|
||||||
@@ -153,9 +159,10 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
|||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'link.download = "MinerOpsAgent-Windows.zip"',
|
'packageFilename = "MinerOpsAgent-Windows.zip"',
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
self.assertIn("link.download = packageFilename", source)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"window.URL.revokeObjectURL(downloadUrl)",
|
"window.URL.revokeObjectURL(downloadUrl)",
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ class AgentRegistryPostgresTests(unittest.TestCase):
|
|||||||
version,
|
version,
|
||||||
ip_address,
|
ip_address,
|
||||||
agent_secret_hash,
|
agent_secret_hash,
|
||||||
credential_created_at
|
credential_created_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
%s,
|
%s,
|
||||||
@@ -85,6 +86,7 @@ class AgentRegistryPostgresTests(unittest.TestCase):
|
|||||||
%s,
|
%s,
|
||||||
%s,
|
%s,
|
||||||
%s,
|
%s,
|
||||||
|
now(),
|
||||||
now()
|
now()
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class WindowsPackageBuilderTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(enrollment["token"], TOKEN)
|
self.assertEqual(enrollment["token"], TOKEN)
|
||||||
|
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
enrollment["hub_url"],
|
enrollment["hub_url"],
|
||||||
"https://minerops.example.test",
|
"https://minerops.example.test",
|
||||||
@@ -77,6 +78,50 @@ class WindowsPackageBuilderTests(unittest.TestCase):
|
|||||||
self.assertNotIn("api3_account", serialized)
|
self.assertNotIn("api3_account", serialized)
|
||||||
self.assertNotIn("agent-config.json", "\n".join(names))
|
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):
|
def test_missing_canonical_source_fails_closed(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
@@ -134,6 +179,13 @@ class WindowsPackageRouteTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
"agents.manage",
|
"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):
|
def test_view_only_user_cannot_download_package(self):
|
||||||
self.authenticate(("agents.view",))
|
self.authenticate(("agents.view",))
|
||||||
@@ -180,6 +232,35 @@ class WindowsPackageRouteTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(enrollment["token"], TOKEN)
|
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):
|
def test_package_requires_browser_origin(self):
|
||||||
self.authenticate(("agents.view", "agents.manage"))
|
self.authenticate(("agents.view", "agents.manage"))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class FakeRepository:
|
|||||||
self.agent = None
|
self.agent = None
|
||||||
self.saved = []
|
self.saved = []
|
||||||
self.assign_result = {MINER_A: "assigned"}
|
self.assign_result = {MINER_A: "assigned"}
|
||||||
|
self.discovery_match = None
|
||||||
|
|
||||||
def get_agent(self, agent_id):
|
def get_agent(self, agent_id):
|
||||||
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
|
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
|
||||||
@@ -90,6 +91,28 @@ class FakeRepository:
|
|||||||
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
"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 find_miner_in_zone(self, *, agent_id, agent_zone_id, mac_address, ip_address):
|
||||||
|
self.discovery_match_request = {'agent_id':agent_id,'agent_zone_id':agent_zone_id,
|
||||||
|
'mac_address':mac_address,'ip_address':ip_address}
|
||||||
|
return self.discovery_match
|
||||||
|
|
||||||
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
|
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
|
||||||
self.assigned = (list(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")
|
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
|
||||||
@@ -119,6 +142,7 @@ class AgentZoneModelTests(unittest.TestCase):
|
|||||||
class AgentZoneServiceTests(unittest.TestCase):
|
class AgentZoneServiceTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.repository = FakeRepository()
|
self.repository = FakeRepository()
|
||||||
|
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
|
||||||
self.service = AgentZoneService(self.repository)
|
self.service = AgentZoneService(self.repository)
|
||||||
|
|
||||||
def checkin(self, **kwargs):
|
def checkin(self, **kwargs):
|
||||||
@@ -127,14 +151,14 @@ class AgentZoneServiceTests(unittest.TestCase):
|
|||||||
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
|
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
|
def test_established_assignment_and_same_assignment_are_allowed(self):
|
||||||
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
self.assertTrue(first.routing_available)
|
self.assertTrue(first.routing_available)
|
||||||
self.assertEqual(second.agent_zone_id, ZONE_A)
|
self.assertEqual(second.agent_zone_id, ZONE_A)
|
||||||
|
|
||||||
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
|
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
|
||||||
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
|
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
|
||||||
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
|
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
|
||||||
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
|
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
|
||||||
self.service.checkin(
|
self.service.checkin(
|
||||||
@@ -164,34 +188,20 @@ class AgentZoneServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.agent_zone_id, ZONE_A)
|
self.assertEqual(result.agent_zone_id, ZONE_A)
|
||||||
|
|
||||||
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
|
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
|
||||||
single = self.checkin(site_id=SITE_A)
|
for zones in [{}, {ZONE_A: zone()}, {ZONE_A:zone(),ZONE_B:zone(ZONE_B,SITE_A)}]:
|
||||||
self.assertEqual(single.agent_zone_id, ZONE_A)
|
self.repository.zones=zones
|
||||||
self.repository.agent = None
|
self.repository.agent=None
|
||||||
self.repository.zones = {}
|
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
|
||||||
zero = self.checkin(site_id=SITE_A)
|
self.checkin(site_id=SITE_A)
|
||||||
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
|
|
||||||
self.repository.agent = None
|
|
||||||
self.repository.zones = {
|
|
||||||
ZONE_A: zone(),
|
|
||||||
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
|
|
||||||
}
|
|
||||||
many = self.checkin(site_id=SITE_A)
|
|
||||||
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
|
|
||||||
self.assertIsNone(many.agent_zone_id)
|
|
||||||
|
|
||||||
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
|
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
|
||||||
self.repository.zones[ZONE_A]["is_enabled"] = False
|
self.repository.zones[ZONE_A]["is_enabled"]=False
|
||||||
unassigned = self.checkin(site_id=SITE_A)
|
existing=self.service.checkin("agent-a",AgentCheckinRequest(agent_id="agent-a"))
|
||||||
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
|
self.assertEqual(existing.routing_reason,"agent_zone_disabled")
|
||||||
self.repository.agent = {
|
self.assertEqual(existing.agent_zone_id,ZONE_A)
|
||||||
"agent_id": "agent-a", "site_id": SITE_A,
|
self.repository.agent=None
|
||||||
"agent_zone_id": ZONE_A, "site_name": "North",
|
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
|
||||||
}
|
self.checkin(site_id=SITE_A)
|
||||||
existing = self.service.checkin(
|
|
||||||
"agent-a", AgentCheckinRequest(agent_id="agent-a")
|
|
||||||
)
|
|
||||||
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
|
|
||||||
self.assertEqual(existing.agent_zone_id, ZONE_A)
|
|
||||||
|
|
||||||
def test_payload_is_sanitized_before_persistence(self):
|
def test_payload_is_sanitized_before_persistence(self):
|
||||||
request = AgentCheckinRequest.model_validate({
|
request = AgentCheckinRequest.model_validate({
|
||||||
@@ -218,10 +228,61 @@ class AgentZoneServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||||
self.assertIsNone(result.site_id)
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_match_uses_only_authenticated_agent_zone(self):
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
|
self.repository.discovery_match = {"id": MINER_A, "matched_by": "mac"}
|
||||||
|
|
||||||
|
result = self.service.match_discovered_miner(
|
||||||
|
"agent-a",
|
||||||
|
mac_address="02:00:00:00:16:21",
|
||||||
|
ip_address="192.0.2.21",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.miner_id, MINER_A)
|
||||||
|
self.assertEqual(result.matched_by, "mac")
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.discovery_match_request["agent_zone_id"], ZONE_A
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_discovery_match_rejects_stale_recorded_zone_without_querying(self):
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
|
|
||||||
|
result = self.service.match_discovered_miner(
|
||||||
|
"agent-a",
|
||||||
|
recorded_zone_id=ZONE_B,
|
||||||
|
mac_address="02:00:00:00:16:21",
|
||||||
|
ip_address="192.0.2.21",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(result.miner_id)
|
||||||
|
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||||
|
self.assertFalse(hasattr(self.repository, "discovery_match_request"))
|
||||||
|
|
||||||
|
|
||||||
class AgentZoneRouterTests(unittest.TestCase):
|
class AgentZoneRouterTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.repository = FakeRepository()
|
self.repository = FakeRepository()
|
||||||
|
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
|
||||||
self.service = AgentZoneService(self.repository)
|
self.service = AgentZoneService(self.repository)
|
||||||
self.app = FastAPI()
|
self.app = FastAPI()
|
||||||
self.app.include_router(router)
|
self.app.include_router(router)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||||
@@ -10,6 +11,8 @@ from app.agent_zones.repository import AgentZoneRepository
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.miner_control.repository import MinerControlRepository
|
from app.miner_control.repository import MinerControlRepository
|
||||||
from app.miner_control.service import MinerControlService
|
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")
|
SITE_A = UUID("00000000-0000-4000-8000-000000001630")
|
||||||
@@ -34,6 +37,29 @@ def connect():
|
|||||||
|
|
||||||
|
|
||||||
class AgentZonePostgresTests(unittest.TestCase):
|
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
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
connection = connect()
|
connection = connect()
|
||||||
@@ -170,12 +196,12 @@ class AgentZonePostgresTests(unittest.TestCase):
|
|||||||
connection.rollback()
|
connection.rollback()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
|
def test_unassigned_miner_stays_unassigned_and_existing_other_zone_wins(self):
|
||||||
repository = AgentZoneRepository(connect)
|
repository = AgentZoneRepository(connect)
|
||||||
outcomes = repository.assign_miners_to_zone(
|
outcomes = repository.assign_miners_to_zone(
|
||||||
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
|
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
|
||||||
)
|
)
|
||||||
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
|
self.assertEqual(outcomes[MINER_UNASSIGNED], "miner_zone_unassigned")
|
||||||
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
|
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
|
||||||
connection = connect()
|
connection = connect()
|
||||||
try:
|
try:
|
||||||
@@ -187,7 +213,7 @@ class AgentZonePostgresTests(unittest.TestCase):
|
|||||||
assignments = dict(cur.fetchall())
|
assignments = dict(cur.fetchall())
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
|
self.assertIsNone(assignments[MINER_UNASSIGNED])
|
||||||
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
|
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
|
||||||
|
|
||||||
def test_disabled_zone_blocks_control_without_site_fallback(self):
|
def test_disabled_zone_blocks_control_without_site_fallback(self):
|
||||||
@@ -203,10 +229,8 @@ class AgentZonePostgresTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
try:
|
try:
|
||||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
context = self.assert_stage_a_unavailable(ZONE_A, AGENT_A)
|
||||||
MINER_UNASSIGNED
|
self.assertFalse(context["agent_zone_enabled"])
|
||||||
)
|
|
||||||
self.assertEqual(projection.service_control.reason, "agent_zone_disabled")
|
|
||||||
finally:
|
finally:
|
||||||
connection = connect()
|
connection = connect()
|
||||||
try:
|
try:
|
||||||
@@ -228,16 +252,10 @@ class AgentZonePostgresTests(unittest.TestCase):
|
|||||||
connection.commit()
|
connection.commit()
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
self.assert_stage_a_unavailable(ZONE_C, None)
|
||||||
MINER_UNASSIGNED
|
|
||||||
)
|
|
||||||
self.assertEqual(projection.service_control.reason, "agent_unavailable")
|
|
||||||
|
|
||||||
def test_null_zone_does_not_fall_back_to_site_agent(self):
|
def test_null_zone_does_not_fall_back_to_site_agent(self):
|
||||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
self.assert_stage_a_unavailable(None, None)
|
||||||
MINER_UNASSIGNED
|
|
||||||
)
|
|
||||||
self.assertEqual(projection.service_control.reason, "agent_zone_unassigned")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""Audit F1-F3 reversals: real registered HTTP/transactions, no physical devices."""
|
||||||
|
import ast
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import app.main as main
|
||||||
|
from app.identity import create_access_token
|
||||||
|
from tests.test_discovery_security_postgres import DiscoveryFixtures, connect
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyInventorySecurityTests(DiscoveryFixtures, unittest.TestCase):
|
||||||
|
def upload(self, text, query='', headers=None):
|
||||||
|
return self.client.post(
|
||||||
|
'/api/imports/whatsminer-tool-scan?dry_run=false&update_existing=true' + query,
|
||||||
|
headers=self.admin if headers is None else headers,
|
||||||
|
files={'file': ('simulated.csv', text.encode(), 'text/csv')},
|
||||||
|
)
|
||||||
|
|
||||||
|
def denied(self, response, before, status):
|
||||||
|
self.assertEqual(response.status_code, status, response.text)
|
||||||
|
self.assertEqual(before, self.durable())
|
||||||
|
self.transport.assert_not_called()
|
||||||
|
|
||||||
|
def test_discovery_audit_reproductions_all_rejected_without_reads(self):
|
||||||
|
self.miner(1, ip='198.51.100.111')
|
||||||
|
self.miner(ip='198.51.100.125')
|
||||||
|
self.miner(1, ip='198.51.100.125')
|
||||||
|
for mode in ['true', 'false']:
|
||||||
|
for ip in ['198.51.100.111', '198.51.100.125', '203.0.113.209']:
|
||||||
|
with self.subTest(mode=mode, ip=ip), patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||||
|
before = self.durable()
|
||||||
|
response = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': ip})
|
||||||
|
self.denied(response, before, 410)
|
||||||
|
|
||||||
|
def test_missing_disabled_agent_and_zone_cannot_restore_legacy_discovery(self):
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('UPDATE agent_zones SET is_enabled=false WHERE id=%s', (self.zones[0],))
|
||||||
|
c.execute('UPDATE minerops_site_agents SET credential_revoked_at=now() WHERE agent_id=%s', (self.agents[0],))
|
||||||
|
c.execute('DELETE FROM minerops_site_agents WHERE agent_id=%s', (self.agents[1],))
|
||||||
|
before = self.durable()
|
||||||
|
r = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': '198.51.100.111'})
|
||||||
|
self.denied(r, before, 410)
|
||||||
|
|
||||||
|
def test_inventory_only_permission_cannot_replace_mac(self):
|
||||||
|
role, uid = uuid4(), uuid4()
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('INSERT INTO roles(id,name) VALUES (%s,%s)', (role, str(role)))
|
||||||
|
c.execute("INSERT INTO role_permissions(role_id,permission_id) SELECT %s,id FROM permissions WHERE key='miners.update'", (role,))
|
||||||
|
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) VALUES (%s,%s,'Inventory only','unused',%s)", (uid, str(uid), role))
|
||||||
|
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), str(role))}
|
||||||
|
mid, _ = self.miner(ip='198.51.100.127')
|
||||||
|
before = self.durable()
|
||||||
|
for path, body, status in [
|
||||||
|
('/api/agent/discoveries/review', {'discovery_id': str(uuid4()), 'version': 'x', 'action': 'ignore'}, 403),
|
||||||
|
(f'/api/miners/{mid}/replace-control-board', {}, 403),
|
||||||
|
('/api/miners/discover-one', {'ip_address': '198.51.100.127'}, 410),
|
||||||
|
]:
|
||||||
|
self.denied(self.client.post(path, headers=headers, json=body), before, status)
|
||||||
|
|
||||||
|
def test_range_and_manual_scheduler_retired_in_both_modes(self):
|
||||||
|
for mode in ['true', 'false']:
|
||||||
|
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||||
|
for path in [f'/api/scan-ranges/{uuid4()}/discover-now', '/api/scanner/run-known-now']:
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.client.post(path, headers=self.admin, json={}), before, 410)
|
||||||
|
with patch('app.main.threading.Thread') as thread:
|
||||||
|
main.start_scheduled_scanner()
|
||||||
|
thread.assert_not_called()
|
||||||
|
status = self.client.get('/api/scanner/status', headers=self.admin).json()['data']
|
||||||
|
self.assertFalse(status['enabled'])
|
||||||
|
self.assertTrue(status['scanner_disabled'])
|
||||||
|
|
||||||
|
def test_all_legacy_helper_definitions_and_saved_aliases_are_retired(self):
|
||||||
|
# Execute every original definition, including shadowed functions; decorators
|
||||||
|
# are omitted so this cannot mutate the application's registered routes.
|
||||||
|
tree = ast.parse(Path(main.__file__).read_text())
|
||||||
|
names = {'find_existing_miner', 'discover_one_miner', 'discover_scan_range_now',
|
||||||
|
'call_discover_one_miner_flexible', 'refresh_one_known_miner_for_schedule',
|
||||||
|
'run_scheduled_known_miner_scan', 'scheduled_scanner_loop',
|
||||||
|
'apply_legacy_summary_to_miner', 'legacy_find_miner_id_by_ip'}
|
||||||
|
import inspect
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.FunctionDef) and node.name in names:
|
||||||
|
with self.subTest(helper=node.name, line=node.lineno):
|
||||||
|
node.decorator_list = []
|
||||||
|
namespace = dict(vars(main))
|
||||||
|
exec(compile(ast.Module(body=[node], type_ignores=[]), '<retired-helper>', 'exec'), namespace)
|
||||||
|
fn = namespace[node.name]
|
||||||
|
kwargs = {name: None for name, p in inspect.signature(fn).parameters.items() if p.default is inspect.Parameter.empty}
|
||||||
|
with patch('app.main.get_db_connection', side_effect=AssertionError('No legacy database access')):
|
||||||
|
with self.assertRaises(HTTPException) as exc:
|
||||||
|
fn(**kwargs)
|
||||||
|
self.assertEqual(exc.exception.status_code, 410)
|
||||||
|
with self.assertRaises(HTTPException) as exc:
|
||||||
|
main._minerops_original_discover_one_miner_legacy_summary(None)
|
||||||
|
self.assertEqual(exc.exception.status_code, 410)
|
||||||
|
|
||||||
|
def test_summary_id_ip_disagreement_and_bulk_retired(self):
|
||||||
|
a, _ = self.miner(ip='198.51.100.121')
|
||||||
|
self.miner(1, ip='198.51.100.122')
|
||||||
|
with patch('app.main.read_legacy_whatsminer_summary', side_effect=AssertionError('No summary transport')) as read:
|
||||||
|
for mode in ['true', 'false']:
|
||||||
|
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||||
|
for path, body in [
|
||||||
|
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a), 'ip_address': '198.51.100.122'}),
|
||||||
|
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a)}),
|
||||||
|
('/api/tools/legacy-summary-refresh-missing', {}),
|
||||||
|
]:
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.client.post(path, headers=self.admin, json=body), before, 410)
|
||||||
|
read.assert_not_called()
|
||||||
|
|
||||||
|
def test_summary_cannot_start_inflight_read_or_overwrite_newer_owner(self):
|
||||||
|
mid, _ = self.miner(ip='198.51.100.123')
|
||||||
|
with patch('app.main.read_legacy_whatsminer_summary') as read, ThreadPoolExecutor(2) as pool:
|
||||||
|
future = pool.submit(lambda: self.client.post('/api/tools/legacy-summary-refresh-one', headers=self.admin, json={'miner_id': str(mid)}))
|
||||||
|
with connect() as c:
|
||||||
|
c.execute("UPDATE miners SET site_id=%s,agent_zone_id=%s,current_ip='198.51.100.124',updated_at=clock_timestamp() WHERE id=%s", (self.sites[1], self.zones[1], mid))
|
||||||
|
self.assertEqual(future.result(timeout=8).status_code, 410)
|
||||||
|
before = self.durable()
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
main.apply_legacy_summary_to_miner(str(mid), '198.51.100.123')
|
||||||
|
self.assertEqual(before, self.durable())
|
||||||
|
self.assertEqual(self.scalar('SELECT host(current_ip) FROM miners WHERE id=%s', (mid,)), '198.51.100.124')
|
||||||
|
read.assert_not_called()
|
||||||
|
|
||||||
|
def test_legacy_human_auth_still_required(self):
|
||||||
|
for headers in [{}, self.headers()]:
|
||||||
|
for path in ['/api/miners/discover-one', '/api/tools/legacy-summary-refresh-one']:
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.client.post(path, headers=headers, json={'ip_address': '198.51.100.111'}), before, 401)
|
||||||
|
|
||||||
|
def test_csv_ip_only_requires_zone_even_with_one_global_match(self):
|
||||||
|
self.miner(1, ip='198.51.100.112')
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload('IP,Miner Type\n198.51.100.112,WRONG\n'), before, 422)
|
||||||
|
|
||||||
|
def test_csv_same_ip_requires_namespace_and_exact_zone_updates_one(self):
|
||||||
|
a, _ = self.miner(ip='198.51.100.126')
|
||||||
|
b, _ = self.miner(1, ip='198.51.100.126')
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload('IP,Miner Type\n198.51.100.126,WRONG\n'), before, 422)
|
||||||
|
r = self.upload(f'IP,Agent Zone ID,Miner Type\n198.51.100.126,{self.zones[1]},VALID\n')
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
|
||||||
|
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
|
||||||
|
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
|
||||||
|
|
||||||
|
def test_csv_duplicate_ip_inside_zone_rejected(self):
|
||||||
|
self.miner(ip='198.51.100.130'); self.miner(ip='198.51.100.130')
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload('IP,Miner Type\n198.51.100.130,WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
|
||||||
|
|
||||||
|
def test_csv_exact_id_mac_serial_and_query_zone_legitimate(self):
|
||||||
|
mid, mac = self.miner(ip='198.51.100.131')
|
||||||
|
serial = uuid4().hex
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('UPDATE miners SET miner_sn=%s WHERE id=%s', (serial, mid))
|
||||||
|
for heading, value in [('Miner ID', str(mid)), ('MAC', mac), ('Serial', serial), ('IP', '198.51.100.131')]:
|
||||||
|
r = self.upload(f'{heading},Miner Type\n{value},VALID\n', '&agent_zone_id=' + str(self.zones[0]))
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
self.assertEqual(r.json()['data']['matched_count'], 1)
|
||||||
|
|
||||||
|
def test_csv_conflicting_and_unknown_mac_never_fall_back_to_ip(self):
|
||||||
|
a, _ = self.miner(ip='198.51.100.132')
|
||||||
|
b, bm = self.miner(1, ip='198.51.100.133')
|
||||||
|
for mac in [bm, '02:AA:BB:CC:DD:EF']:
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(f'Miner ID,IP,MAC,Miner Type\n{a},198.51.100.132,{mac},WRONG\n'), before, 409)
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.133,WRONG\n'), before, 409)
|
||||||
|
|
||||||
|
def test_csv_historical_mac_and_contradictory_namespace_serial_denied(self):
|
||||||
|
mid, oldmac = self.miner(ip='198.51.100.134')
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('UPDATE miners SET current_mac=%s,miner_sn=%s WHERE id=%s', ('02:' + ':'.join(f'{v:02X}' for v in uuid4().bytes[:5]), 'serial-' + str(mid), mid))
|
||||||
|
for columns, values in [
|
||||||
|
('MAC', oldmac), ('Serial', 'unknown'), ('Site ID', str(self.sites[1])),
|
||||||
|
('Agent Zone ID', str(self.zones[1])), ('miner_id', str(uuid4())),
|
||||||
|
]:
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(f'Miner ID,{columns},Miner Type\n{mid},{values},WRONG\n'), before, 409)
|
||||||
|
|
||||||
|
def test_csv_invalid_identifiers_and_query_row_disagreement(self):
|
||||||
|
mid, _ = self.miner(ip='198.51.100.135')
|
||||||
|
for header, value in [('MAC', 'bad-mac'), ('IP', '198.51.100.135/24'), ('Miner ID', 'bad-id'), ('Agent Zone ID', 'bad-zone')]:
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(f'{header},Miner Type\n{value},WRONG\n'), before, 422)
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[1]},WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
|
||||||
|
|
||||||
|
def test_csv_unmatched_strong_identity_does_not_select_ip(self):
|
||||||
|
self.miner(ip='198.51.100.136')
|
||||||
|
before = self.durable()
|
||||||
|
r = self.upload('MAC,IP,Miner Type\n02:AA:BB:CC:DD:EF,198.51.100.136,WRONG\n')
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
self.assertEqual(r.json()['data']['unmatched_count'], 1)
|
||||||
|
self.assertEqual(before['miners'], self.durable()['miners'])
|
||||||
|
|
||||||
|
def test_csv_batch_conflict_rolls_back_prior_valid_row(self):
|
||||||
|
a, _ = self.miner(ip='198.51.100.137')
|
||||||
|
b, _ = self.miner(1, ip='198.51.100.138')
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.137,VALID\n{b},198.51.100.137,WRONG\n'), before, 409)
|
||||||
|
|
||||||
|
def test_csv_preview_repeat_and_history_preservation(self):
|
||||||
|
mid, _ = self.miner(ip='198.51.100.139')
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('INSERT INTO miner_status_snapshots(id,miner_id,observed_at,mhs_av) VALUES (%s,%s,now(),123)', (uuid4(), mid))
|
||||||
|
before = self.durable()
|
||||||
|
text = f'Miner ID,Miner Type\n{mid},VALID\n'
|
||||||
|
r = self.client.post('/api/imports/whatsminer-tool-scan?dry_run=true&update_existing=true', headers=self.admin, files={'file': ('simulated.csv', text.encode(), 'text/csv')})
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
self.assertEqual(before, self.durable())
|
||||||
|
for _ in range(2):
|
||||||
|
self.assertEqual(self.upload(text).status_code, 200)
|
||||||
|
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
|
||||||
|
self.assertEqual(before['miner_mac_ownerships'], self.durable()['miner_mac_ownerships'])
|
||||||
|
|
||||||
|
def test_csv_actual_audit_permission_failure_rolls_back(self):
|
||||||
|
mid, _ = self.miner(ip='198.51.100.140')
|
||||||
|
role = 'b1_csv_' + uuid4().hex
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('CREATE ROLE ' + role)
|
||||||
|
c.execute('GRANT USAGE ON SCHEMA public TO ' + role)
|
||||||
|
c.execute('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO ' + role)
|
||||||
|
c.execute('REVOKE INSERT ON audit_logs FROM ' + role)
|
||||||
|
def limited():
|
||||||
|
c = connect(); c.execute('SET ROLE ' + role); c.commit(); return c
|
||||||
|
before = self.durable()
|
||||||
|
try:
|
||||||
|
with patch('app.main.get_db_connection', side_effect=limited):
|
||||||
|
r = self.upload(f'Miner ID,Miner Type\n{mid},ROLLBACK\n')
|
||||||
|
self.denied(r, before, 500)
|
||||||
|
self.assertIn('audit_logs', r.text)
|
||||||
|
finally:
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('DROP OWNED BY ' + role); c.execute('DROP ROLE ' + role)
|
||||||
|
|
||||||
|
def test_csv_concurrent_ownership_move_is_revalidated_after_real_lock_wait(self):
|
||||||
|
mid, _ = self.miner(ip='198.51.100.141')
|
||||||
|
owner = connect()
|
||||||
|
owner.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
|
||||||
|
with ThreadPoolExecutor(1) as pool:
|
||||||
|
future = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},WRONG\n'))
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 4
|
||||||
|
waiting = 0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
|
||||||
|
if waiting: break
|
||||||
|
time.sleep(.02)
|
||||||
|
self.assertGreater(waiting, 0)
|
||||||
|
owner.commit()
|
||||||
|
r = future.result(timeout=8)
|
||||||
|
self.assertEqual(r.status_code, 409, r.text)
|
||||||
|
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
|
||||||
|
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (mid,)))
|
||||||
|
finally:
|
||||||
|
owner.rollback(); owner.close()
|
||||||
|
|
||||||
|
def test_csv_real_update_lock_prevents_mid_transaction_ownership_change(self):
|
||||||
|
import app.discovery.csv_import as module
|
||||||
|
mid, _ = self.miner(ip='198.51.100.142')
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
original = module.resolve_inventory_row
|
||||||
|
def gate(*args, **kwargs):
|
||||||
|
row = original(*args, **kwargs); entered.set()
|
||||||
|
self.assertTrue(release.wait(4)); return row
|
||||||
|
def move():
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
|
||||||
|
with patch.object(module, 'resolve_inventory_row', side_effect=gate), ThreadPoolExecutor(2) as pool:
|
||||||
|
upload = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},VALID\n'))
|
||||||
|
self.assertTrue(entered.wait(4)); moving = pool.submit(move)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
waiting = 0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
|
||||||
|
if waiting: break
|
||||||
|
time.sleep(.02)
|
||||||
|
self.assertGreater(waiting, 0)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
self.assertEqual(upload.result(timeout=8).status_code, 200)
|
||||||
|
moving.result(timeout=8)
|
||||||
|
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
|
||||||
|
|
||||||
|
def test_csv_duplicate_headers_and_conflicting_aliases_rejected(self):
|
||||||
|
mid, _ = self.miner(ip='198.51.100.143')
|
||||||
|
for text, code in [
|
||||||
|
(f'Miner ID,Miner ID,Miner Type\n{mid},{mid},WRONG\n', 422),
|
||||||
|
(f'Miner ID,miner_id,Miner Type\n{mid},{uuid4()},WRONG\n', 409),
|
||||||
|
(f'Miner ID,Site ID,Agent Zone ID,Miner Type\n{mid},{self.sites[1]},{self.zones[0]},WRONG\n', 409),
|
||||||
|
]:
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(text), before, code)
|
||||||
|
|
||||||
|
def test_csv_duplicate_serial_is_ambiguous(self):
|
||||||
|
a, _ = self.miner(ip='198.51.100.144')
|
||||||
|
b, _ = self.miner(1, ip='198.51.100.145')
|
||||||
|
serial = 'duplicate-' + uuid4().hex
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('UPDATE miners SET miner_sn=%s WHERE id IN (%s,%s)', (serial, a, b))
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(f'Serial,Miner Type\n{serial},WRONG\n'), before, 409)
|
||||||
|
|
||||||
|
def test_csv_global_mac_selection_with_overlapping_ip_is_valid(self):
|
||||||
|
a, _ = self.miner(ip='198.51.100.146')
|
||||||
|
b, mac = self.miner(1, ip='198.51.100.146')
|
||||||
|
before = self.durable()
|
||||||
|
r = self.upload(f'MAC,IP,Miner Type\n{mac},198.51.100.146,VALID\n')
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
|
||||||
|
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
|
||||||
|
after = self.durable()
|
||||||
|
for table in ['miner_status_snapshots', 'miner_mac_ownerships', 'miner_network_identities']:
|
||||||
|
self.assertEqual(before[table], after[table])
|
||||||
|
audit = self.scalar("SELECT new_value_json FROM audit_logs WHERE actor_user_id=%s AND action='import.whatsminertool_csv.applied' ORDER BY created_at DESC LIMIT 1", (self.uid,))
|
||||||
|
self.assertEqual(audit['updates'][0]['miner_id'], str(b))
|
||||||
|
self.assertEqual(audit['updates'][0]['agent_zone_id'], str(self.zones[1]))
|
||||||
|
|
||||||
|
def test_csv_read_only_user_cannot_apply_metadata(self):
|
||||||
|
uid = uuid4()
|
||||||
|
with connect() as c:
|
||||||
|
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Read only','unused',id FROM roles WHERE name='Read Only'", (uid, str(uid)))
|
||||||
|
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), 'Read Only')}
|
||||||
|
mid, _ = self.miner(ip='198.51.100.147')
|
||||||
|
before = self.durable()
|
||||||
|
self.denied(self.upload(f'Miner ID,Miner Type\n{mid},WRONG\n', headers=headers), before, 403)
|
||||||
@@ -71,64 +71,47 @@ class FakeRepository:
|
|||||||
|
|
||||||
|
|
||||||
class MinerControlServiceTests(unittest.TestCase):
|
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()
|
repository = FakeRepository()
|
||||||
result = MinerControlService(repository).get_controls(MINER_ID)
|
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.performance_mode, "normal")
|
||||||
self.assertEqual(result.available_service_action, "suspend")
|
self.assertIsNone(result.available_service_action)
|
||||||
self.assertEqual(repository.limits, [5])
|
self.assertEqual(repository.limits, [5])
|
||||||
|
|
||||||
def test_capability_missing_facts_is_not_optimistic(self):
|
def test_capability_cannot_be_enabled_by_agent_claims(self):
|
||||||
cases = (
|
for facts in ({}, {"control_available": True}, {"control_available": False}):
|
||||||
({"agent_zone_id": None}, "unavailable", "agent_zone_unassigned"),
|
repository = FakeRepository(context(last_payload=facts))
|
||||||
({"agent_zone_exists": False}, "unavailable", "agent_zone_missing"),
|
result = MinerControlService(repository).get_controls(MINER_ID)
|
||||||
({"agent_zone_enabled": False}, "unavailable", "agent_zone_disabled"),
|
self.assertEqual(result.service_control.state, "unavailable")
|
||||||
({"agent_id": None}, "unavailable", "agent_unavailable"),
|
self.assertEqual(result.service_control.reason, "physical_control_disabled_stage_a")
|
||||||
({"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_resume_requires_known_stopped_state(self):
|
def test_resume_is_disabled_even_for_known_stopped_state(self):
|
||||||
service = MinerControlService(FakeRepository(context(runtime_state="offline")))
|
service = MinerControlService(FakeRepository(context(runtime_state="stopped")))
|
||||||
with self.assertRaisesRegex(MinerControlUnavailable, "factually_stopped"):
|
with self.assertRaisesRegex(MinerControlUnavailable, "physical_control_disabled_stage_a"):
|
||||||
service.queue_control(
|
service.queue_control(MINER_ID, "resume", requested_value=None,
|
||||||
MINER_ID, "resume", requested_value=None,
|
actor_user_id=USER_ID, actor_username="operator")
|
||||||
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()
|
repository = FakeRepository()
|
||||||
result = MinerControlService(repository).queue_control(
|
with self.assertRaisesRegex(MinerControlUnavailable, "physical_control_disabled_stage_a"):
|
||||||
MINER_ID, "performance_mode", requested_value="high",
|
MinerControlService(repository).queue_control(
|
||||||
actor_user_id=USER_ID, actor_username="operator",
|
MINER_ID, action, requested_value="high",
|
||||||
)
|
actor_user_id=USER_ID, actor_username="operator")
|
||||||
call = repository.enqueue_calls[0]
|
self.assertEqual(repository.enqueue_calls, [])
|
||||||
self.assertEqual(call["request_payload"]["ip_address"], "192.0.2.10")
|
self.assertFalse(hasattr(repository, "requested_miner"))
|
||||||
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):
|
def test_rejected_request_leaves_context_unchanged(self):
|
||||||
repository = FakeRepository()
|
repository = FakeRepository()
|
||||||
|
before = repository.miner_context.copy()
|
||||||
|
with self.assertRaises(MinerControlUnavailable):
|
||||||
MinerControlService(repository).queue_control(
|
MinerControlService(repository).queue_control(
|
||||||
MINER_ID, "suspend", requested_value=None,
|
MINER_ID, "suspend", requested_value=None,
|
||||||
actor_user_id=USER_ID, actor_username="operator",
|
actor_user_id=USER_ID, actor_username="operator")
|
||||||
)
|
self.assertEqual(repository.miner_context, before)
|
||||||
self.assertFalse(any("update" in key for key in repository.enqueue_calls[0]))
|
self.assertEqual(repository.enqueue_calls, [])
|
||||||
|
|
||||||
|
|
||||||
class MinerControlRouterTests(unittest.TestCase):
|
class MinerControlRouterTests(unittest.TestCase):
|
||||||
@@ -159,18 +142,16 @@ class MinerControlRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json()["data"]["miner_id"], str(MINER_ID))
|
self.assertEqual(response.json()["data"]["miner_id"], str(MINER_ID))
|
||||||
|
|
||||||
def test_posts_require_control_and_return_202_bounded_envelope(self):
|
def test_posts_require_control_and_then_fail_closed(self):
|
||||||
path = f"/api/miners/{MINER_ID}/controls/suspend"
|
for action, body in (("suspend", {}), ("resume", {}), ("performance-mode", {"mode": "high"})):
|
||||||
|
path = f"/api/miners/{MINER_ID}/controls/{action}"
|
||||||
self.authenticate(("miners.view",))
|
self.authenticate(("miners.view",))
|
||||||
self.assertEqual(self.client.post(path, json={}).status_code, 403)
|
self.assertEqual(self.client.post(path, json=body).status_code, 403)
|
||||||
self.authenticate(("miners.control",))
|
self.authenticate(("miners.control",))
|
||||||
response = self.client.post(path, json={})
|
response = self.client.post(path, json=body)
|
||||||
self.assertEqual(response.status_code, 202)
|
self.assertEqual(response.status_code, 409)
|
||||||
self.assertEqual(set(response.json()["data"]), {
|
self.assertIn("physical_control_disabled_stage_a", response.text)
|
||||||
"operation_id", "miner_id", "action", "requested_value", "status", "created_at"
|
self.assertEqual(self.repository.enqueue_calls, [])
|
||||||
})
|
|
||||||
self.assertNotIn("ip", response.text.lower())
|
|
||||||
self.assertNotIn("agent", response.text.lower())
|
|
||||||
|
|
||||||
def test_strict_bodies_reject_routing_commands_and_credentials(self):
|
def test_strict_bodies_reject_routing_commands_and_credentials(self):
|
||||||
self.authenticate(("miners.control",))
|
self.authenticate(("miners.control",))
|
||||||
@@ -195,7 +176,7 @@ class MinerControlRouterTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.client.post(path, json={}, headers={"Origin": "http://testserver"}).status_code,
|
self.client.post(path, json={}, headers={"Origin": "http://testserver"}).status_code,
|
||||||
202,
|
409,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_identity_permission_mapping_is_specific(self):
|
def test_identity_permission_mapping_is_specific(self):
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class MinerControlPostgresTests(unittest.TestCase):
|
|||||||
"""
|
"""
|
||||||
INSERT INTO minerops_agent_command_queue (
|
INSERT INTO minerops_agent_command_queue (
|
||||||
id, agent_id, command_type, command_status, request_payload, created_at
|
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,
|
(command_id, AGENT_ID, status,
|
||||||
Jsonb({"schema_version": 1, "miner_id": str(miner_id),
|
Jsonb({"schema_version": 1, "miner_id": str(miner_id),
|
||||||
@@ -199,13 +199,22 @@ class MinerControlPostgresTests(unittest.TestCase):
|
|||||||
service.submit_result(AGENT_ID, pending)
|
service.submit_result(AGENT_ID, pending)
|
||||||
|
|
||||||
def test_atomic_queue_audit_duplicate_live_and_completed_history(self):
|
def test_atomic_queue_audit_duplicate_live_and_completed_history(self):
|
||||||
service = MinerControlService(MinerControlRepository(connect))
|
repository = MinerControlRepository(connect)
|
||||||
first = service.queue_control(
|
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,
|
MINER_ID, "suspend", requested_value=None,
|
||||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||||
)
|
)
|
||||||
with self.assertRaises(MinerControlConflict):
|
with self.assertRaises(MinerControlConflict):
|
||||||
service.queue_control(
|
queue_control(
|
||||||
MINER_ID, "suspend", requested_value=None,
|
MINER_ID, "suspend", requested_value=None,
|
||||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||||
)
|
)
|
||||||
@@ -223,17 +232,26 @@ class MinerControlPostgresTests(unittest.TestCase):
|
|||||||
connection.commit()
|
connection.commit()
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
second = service.queue_control(
|
second = queue_control(
|
||||||
MINER_ID, "suspend", requested_value=None,
|
MINER_ID, "suspend", requested_value=None,
|
||||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||||
)
|
)
|
||||||
self.assertNotEqual(first.operation_id, second.operation_id)
|
self.assertNotEqual(first.operation_id, second.operation_id)
|
||||||
|
|
||||||
def test_audit_failure_rolls_back_queue_row(self):
|
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()
|
operation_count_before = self._operation_count()
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception):
|
||||||
service.queue_control(
|
queue_control(
|
||||||
MINER_ID, "suspend", requested_value=None,
|
MINER_ID, "suspend", requested_value=None,
|
||||||
actor_user_id=UUID("00000000-0000-4000-8000-000000009999"),
|
actor_user_id=UUID("00000000-0000-4000-8000-000000009999"),
|
||||||
actor_username="missing-user",
|
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,206 @@
|
|||||||
|
"""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):
|
||||||
|
from uuid import uuid4
|
||||||
|
from app.discovery.repository import DiscoveryRepository
|
||||||
|
site, zone, actor = uuid4(), uuid4(), uuid4()
|
||||||
|
with connect() as connection, connection.cursor() as cur:
|
||||||
|
cur.execute("INSERT INTO sites(id,name) VALUES (%s,%s)",(site,str(site)))
|
||||||
|
cur.execute("INSERT INTO agent_zones(id,site_id,name) VALUES (%s,%s,'Stage A reader')",(zone,site))
|
||||||
|
cur.execute("INSERT INTO agent_zone_network_ranges(agent_zone_id,network) VALUES (%s,'192.0.2.0/24')",(zone,))
|
||||||
|
cur.execute("""INSERT INTO minerops_site_agents(agent_id,site_id,agent_zone_id,site_name)
|
||||||
|
VALUES (%s,%s,%s,'Stage A reader') ON CONFLICT(agent_id) DO UPDATE SET
|
||||||
|
site_id=EXCLUDED.site_id,agent_zone_id=EXCLUDED.agent_zone_id""",(AGENT,site,zone))
|
||||||
|
cur.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Reader','unused',id FROM roles WHERE name='Admin'",(actor,str(actor)))
|
||||||
|
discovery=DiscoveryRepository(connect)
|
||||||
|
observation=discovery.ingest(AGENT,{'discoveries':[{'ip_address':'192.0.2.1'}]})['discoveries'][0]
|
||||||
|
user={'id':str(actor),'username':'stage-a-reader'}
|
||||||
|
for kind in ('poll_known_miners_now','discover_new_miners'):
|
||||||
|
discovery.queue({'agent_id':AGENT},user,kind)
|
||||||
|
discovery.queue({'discovery_id':observation['id'],'version':observation['version']},user)
|
||||||
|
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,219 @@
|
|||||||
|
Blocker #1 discovery isolation: candidate review and compatibility contract
|
||||||
|
=======================================================================
|
||||||
|
Review candidate only. Blocker #1 is NOT declared PASS. No production cutover is
|
||||||
|
authorized. Base: e898f283ab0fc21bac77b63db1c4a9ad35e21562 (Stage A / draft PR #6).
|
||||||
|
No schema migration, new constraints, backfill, or runtime DDL is introduced.
|
||||||
|
|
||||||
|
Authority and observations
|
||||||
|
-------------------------
|
||||||
|
All machine discovery/telemetry routes retain per-Agent HTTP authentication.
|
||||||
|
Authority comes from the persisted Agent -> Site/Zone/network assignment, never
|
||||||
|
client Site, room, Zone, target-Agent names, or a body-supplied Agent identity.
|
||||||
|
Conflicting body Agent IDs are rejected. Disabled/unassigned/revoked routing fails
|
||||||
|
closed. Check-in cannot establish or change an Agent's initial assignment: use
|
||||||
|
existing authenticated enrollment or the separately reviewed legacy transition.
|
||||||
|
Unassigned inventory is not implicitly claimed by check-in or snapshots.
|
||||||
|
|
||||||
|
Discovery reports are bounded (128 items, 1 MiB body; 1,024 observations/minute per
|
||||||
|
Agent). IP literals must be in the current Zone networks. MACs must be canonical
|
||||||
|
ASCII unicast addresses. Explicit IDs, IP aliases, MAC aliases and supplied serial
|
||||||
|
must agree with current inventory and immutable historical MAC ownership. Global
|
||||||
|
historical MAC/serial collision checks reveal no foreign inventory IDs. Duplicate
|
||||||
|
current IPs within a Zone are conflicts; distinct Zones may use the same IP.
|
||||||
|
|
||||||
|
Each observation has a stable server ID and version. Raw reports and original
|
||||||
|
provenance are retained, with an atomic discovery.observed audit recording the
|
||||||
|
current authority, raw hash, inventory fingerprint and observation version. Exact
|
||||||
|
retries deduplicate; discovery clients must send discovered_at for a fresh scan
|
||||||
|
(the supplied Windows scanner does). Approval requires a current, proven,
|
||||||
|
non-superseded observation less than 15 minutes old. Legacy/null/mislinked rows
|
||||||
|
without this evidence remain visible but cannot authorize approval or enrichment.
|
||||||
|
There is no automatic evidence backfill, deletion, identity merge or ownership
|
||||||
|
transfer. An authorized operator must investigate conflicts and obtain a fresh
|
||||||
|
observation. Discovery does not implement an ownership-transfer workflow.
|
||||||
|
|
||||||
|
Human policy remains global administration: agents.view for discovery display;
|
||||||
|
agents.manage for stable-ID review/enrichment; miners.control for scan/poll
|
||||||
|
requests. The UI displays authoritative Agent/Zone and escapes all untrusted text.
|
||||||
|
IP-only approval is unsupported. Submit discovery_id, version and one of
|
||||||
|
add_to_inventory / ignore / non_miner. All three revalidate current authority and
|
||||||
|
identity under locks; only that observation changes. Adding new inventory also
|
||||||
|
requires a valid MAC. Repeat the same accepted decision/version to receive its
|
||||||
|
recorded response without another audit or insertion. Stale/different decisions
|
||||||
|
conflict; refresh before retrying. Audit actor is the authenticated human.
|
||||||
|
|
||||||
|
Read commands and enrichment
|
||||||
|
----------------------------
|
||||||
|
Enrichment is selected by discovery_id/version, routed to its actual observing
|
||||||
|
Agent, and bound to the current authority and IP. No arbitrary-IP or hardcoded
|
||||||
|
Plexus fallback exists. Scan/poll requests require an explicit registered Agent ID;
|
||||||
|
optional UUID request_id provides explicit retry idempotency. New requests without
|
||||||
|
request_id receive distinct IDs. Queue claim rejects legacy/unscoped/stale reads
|
||||||
|
with atomic cancellation/audit and preserves unknown delivery outcome for any
|
||||||
|
previous claim evidence. Existing Stage A physical rejection remains in force.
|
||||||
|
|
||||||
|
Before enrichment miner I/O, the upgraded Windows child submits command_id,
|
||||||
|
discovery_id and version to authenticated POST /api/agent/discovery-target. Only
|
||||||
|
a current claimed enrichment command returns a canonical IP. Enrichment submission
|
||||||
|
revalidates the same binding and appends a NEW child observation for human review;
|
||||||
|
it never resets the parent's decision or reparents history. V1/V2/V3 share the
|
||||||
|
same boundary. Replaying old V2 identity merge inputs cannot merge inventory.
|
||||||
|
|
||||||
|
Known-miners returns a short-lived signed miner_version bound to Agent authority
|
||||||
|
and the current miner ID/IP/MAC/updated_at fingerprint. Every miner/pool/API3
|
||||||
|
snapshot or known-miner enrichment must carry this token and matching identifiers.
|
||||||
|
All Windows polling clients propagate it and batch at 128. A concurrent update,
|
||||||
|
revocation or reassignment may invalidate in-flight telemetry: discard the stale
|
||||||
|
response, refetch known-miners and poll again. Do not fall back to IP matching.
|
||||||
|
Descriptive enrichment does not change MAC/IP/serial/ownership. Raw diagnostics
|
||||||
|
are retained in append-only audits, not empty status rows that hide measurements.
|
||||||
|
API3 retains raw snapshots, safe descriptive metadata, actual summary/pool/board
|
||||||
|
measurements and compatibility counters. Failed/metadata-only API3 responses do
|
||||||
|
not replace the most recent measurements. Nested identity contradictions fail.
|
||||||
|
|
||||||
|
Transactions and database roles
|
||||||
|
------------------------------
|
||||||
|
All discovery writers/read-command claims take bounded locks in a shared order:
|
||||||
|
advisory transaction lock 743190031, SHARE on Agent/Zone/network tables, SHARE ROW
|
||||||
|
EXCLUSIVE on miners, then row locks. These table locks prevent phantom inserts
|
||||||
|
from legacy writers which do not use the advisory lock. Agent routing/check-in
|
||||||
|
uses guarded updates. No miner network I/O occurs while these transactions run.
|
||||||
|
Audit insertion, inventory mutation and the observation decision commit together;
|
||||||
|
any error rolls back all changes. Audit-denial and real UPDATE-trigger failure,
|
||||||
|
concurrent approvals, cross-Zone MAC races, Zone disable and claim-vs-review are
|
||||||
|
covered with real PostgreSQL connections and registered HTTP authorization routes.
|
||||||
|
Lock timeout is 5 seconds; statement timeout 15 seconds. Controlled contention
|
||||||
|
returns conflict/retry. Security favors correctness over fleet throughput here.
|
||||||
|
|
||||||
|
Required release review: validate privileges/lock behavior for the actual deployed
|
||||||
|
DB role in a disposable replica and measure expected fleet load. Tests use an
|
||||||
|
isolated database owner plus a limited role with audit INSERT denied. They do not
|
||||||
|
certify the production role. No new uniqueness constraint is proposed, so do not
|
||||||
|
remove the transaction/table locks without a separately reviewed replacement.
|
||||||
|
|
||||||
|
Reproduction
|
||||||
|
------------
|
||||||
|
Use a disposable checkout of this candidate with an already available backend
|
||||||
|
image containing backend/requirements.txt. Never mount production credentials or
|
||||||
|
.env files. From the candidate root:
|
||||||
|
|
||||||
|
MINEROPS_REVIEW_BACKEND_IMAGE=<existing-image> scripts/test-discovery-security.sh
|
||||||
|
|
||||||
|
The runner creates a unique internal Docker network, tmpfs PostgreSQL 16, no
|
||||||
|
published ports, applies the unchanged existing Alembic chain and runs the two
|
||||||
|
PostgreSQL discovery suites. A trap destroys only its own resources. Tests use
|
||||||
|
simulated Agents and FastAPI's registered routes, without app startup/schedulers.
|
||||||
|
|
||||||
|
PowerShell (isolated PowerShell 7 host/container with simulated/loopback I/O):
|
||||||
|
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsDiscovery.ps1
|
||||||
|
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
|
||||||
|
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsDiagnosticTransport.ps1
|
||||||
|
|
||||||
|
Browser (Python 3, Node, Playwright and its Chromium/runtime installed externally):
|
||||||
|
python3 backend/tests/browser/export_discovery_page.py . /tmp/discovery-page.html
|
||||||
|
node backend/tests/browser/discovery-security.cjs /tmp/discovery-page.html
|
||||||
|
|
||||||
|
The browser test intercepts all requests, supplies harmless event-marker input,
|
||||||
|
and verifies inert rendering and exact same-IP discovery_id/version selection.
|
||||||
|
Its trailing baseline argument demonstrates the baseline stored-markup defect.
|
||||||
|
|
||||||
|
Other regression suites (explicit disposable DB guards apply):
|
||||||
|
tests.test_stage_a_containment_postgres
|
||||||
|
tests.test_stage_a_delivery_evidence_postgres
|
||||||
|
tests.test_stage_a_schedule_cancellation_postgres
|
||||||
|
tests.test_agent_zones_postgres
|
||||||
|
tests.test_agent_credential_transition_postgres
|
||||||
|
tests.test_agent_enrollment_postgres
|
||||||
|
|
||||||
|
Baseline source-test exceptions retained unchanged
|
||||||
|
-------------------------------------------------
|
||||||
|
backend/tests/test_agent_commands.py:
|
||||||
|
AgentEnrichmentIsolationSourceTests.test_legacy_config_remains_explicitly_legacy_only
|
||||||
|
requires the old minerops_require_agent_secret source string / fixed Plexus config.
|
||||||
|
The authenticated config route already changed before this candidate. Replace the
|
||||||
|
obsolete source assertion with real legacy/authenticated route tests in separate
|
||||||
|
work; do not weaken credential authentication to satisfy the string assertion.
|
||||||
|
|
||||||
|
AgentSourceContractTests.test_deployment_preserves_local_state_and_registers_established_tasks
|
||||||
|
blanket-bans the quoted agent-config.json filename in installer source, although
|
||||||
|
the existing credential-recovery installer legitimately preserves/recovers local
|
||||||
|
configuration. Follow up with behavioral installation/state-preservation tests on
|
||||||
|
Windows. Do not remove the recovery path to satisfy the blanket source assertion.
|
||||||
|
|
||||||
|
Both failures reproduce on unchanged 2d89e80 and unchanged e898f28; both method
|
||||||
|
bodies remain identical. They are not silent skips or weakened assertions here.
|
||||||
|
The external-fixture legacy-retirement PostgreSQL test class also requires its
|
||||||
|
own migration-case setup and is not certified by the discovery test runner.
|
||||||
|
|
||||||
|
Outstanding gates
|
||||||
|
-----------------
|
||||||
|
Independent security review of this exact isolated candidate is required before
|
||||||
|
any Blocker #1 PASS or commit approval. Native Windows PowerShell 5.1/SYSTEM and
|
||||||
|
fleet-scale/production-role validation remain outstanding. Unsupported older
|
||||||
|
Agent payloads fail closed and require coordinated upgrade; this is an intentional
|
||||||
|
protocol compatibility change, not transparent mixed-version support.
|
||||||
|
|
||||||
|
Stage A application containment remains a separate audited boundary. Physical
|
||||||
|
execution is disabled; device identity binding and Blocker #3 Stages B-E remain
|
||||||
|
outstanding. Preserve STAGE_A_OLD_AGENT_CUTOVER.txt: verified network fencing must
|
||||||
|
precede stopping/draining/upgrading old Agents. Cached commands cannot be revoked
|
||||||
|
by server cancellation alone. Offline/unreachable Agents stay blocked; restart,
|
||||||
|
reconnection and rollback must not restore an unsafe executor. This candidate
|
||||||
|
neither performs nor authorizes that cutover or any production deployment.
|
||||||
|
|
||||||
|
Independent-audit remediation addendum (October 16, 2026)
|
||||||
|
-------------------------------------------------------
|
||||||
|
The original candidate (99f515924dfc85fe1e1e98361688f1060329c090) failed independent
|
||||||
|
review for F1-F3 in retained legacy callers. This corrected candidate requires a
|
||||||
|
new independent audit. It does not claim Blocker #1 PASS.
|
||||||
|
|
||||||
|
Retired in Hub AND non-Hub modes, with HTTP 410 after existing authentication and
|
||||||
|
permission checks:
|
||||||
|
POST /api/miners/discover-one
|
||||||
|
POST /api/scan-ranges/{scan_range_id}/discover-now
|
||||||
|
POST /api/scanner/run-known-now
|
||||||
|
POST /api/tools/legacy-summary-refresh-one
|
||||||
|
POST /api/tools/legacy-summary-refresh-missing
|
||||||
|
All definitions/aliases of their identity matcher, discovery writer, scheduler
|
||||||
|
adapters, summary writer and global-IP helper reject before transport/database
|
||||||
|
access. Startup creates no local scanner thread; status reports retired/disabled.
|
||||||
|
Ordinary miners.update therefore cannot use discovery to replace a control board
|
||||||
|
or clear a MAC. The dedicated board workflow and immutable MAC ownership remain.
|
||||||
|
|
||||||
|
Functional implication: legacy local discovery, automatic local refresh, and
|
||||||
|
legacy one/bulk summary backfill are unavailable even outside Hub mode. Operators
|
||||||
|
must use authenticated Agent observation/review and inventory-bound Agent polling.
|
||||||
|
Legacy summary replies lack trustworthy identity; there is no retained unversioned
|
||||||
|
write or in-flight read to revalidate. No denied request mutates inventory, audit,
|
||||||
|
or telemetry. The new shared discovery architecture is preserved.
|
||||||
|
|
||||||
|
CSV remains an explicit global administrative workflow protected by miners.update.
|
||||||
|
Each row requires Miner ID, current MAC, serial, or IP plus Agent Zone ID. Zone may
|
||||||
|
come from the query/UI input or a row column; conflicting selections are rejected.
|
||||||
|
All supplied ID/MAC/serial/IP/Site/Zone evidence must agree with current inventory.
|
||||||
|
A failed MAC/serial/ID never falls back to global IP. Missing inventory is reported
|
||||||
|
unmatched; contradictory or ambiguous selection returns row-specific 409, malformed
|
||||||
|
or insufficient identification 422. A failure rolls back the entire import.
|
||||||
|
IP-only rows need a Zone even when only one global match currently exists. Exact
|
||||||
|
MAC/ID/serial selection remains usable by global administrators across Sites.
|
||||||
|
CSV updates only model/type/firmware, never ownership, IP, MAC or telemetry.
|
||||||
|
Duplicate columns are rejected rather than silently using the last value.
|
||||||
|
|
||||||
|
CSV acquires the existing discovery lock order, including the miners table lock,
|
||||||
|
before resolving any row. It holds the locks through metadata changes and one
|
||||||
|
atomic audit containing every changed inventory ID/namespace and old/new values.
|
||||||
|
Lock contention is bounded by existing discovery timeouts; large fleet throughput
|
||||||
|
and actual deployed-role privileges still require independent release validation.
|
||||||
|
Preview uses identical identification checks and rolls back. Repeated imports do
|
||||||
|
not create inventory or telemetry; each applied request retains its import audit.
|
||||||
|
No migration or containment/control policy changes are part of this remediation.
|
||||||
|
|
||||||
|
Regression entrypoint: scripts/test-discovery-security.sh now includes
|
||||||
|
tests.test_legacy_inventory_security_postgres
|
||||||
|
with registered HTTP requests, actual PostgreSQL locks/concurrent ownership moves,
|
||||||
|
actual audit INSERT denial rollback, retired helper/alias coverage, both modes,
|
||||||
|
permission boundaries, ambiguous/conflicting CSV selection, legitimate imports,
|
||||||
|
preview/replay, and retained historical snapshots. All devices are simulated or
|
||||||
|
transport-patched; the runner uses an internal network and disposable database.
|
||||||
|
Full execution evidence and integrity manifest are retained outside the candidate
|
||||||
|
in the remediation evidence directory referenced by the final report.
|
||||||
@@ -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 \
|
"${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"'
|
'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
|
expected_head="$(
|
||||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0016_agent_zones." >&2
|
"${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
|
exit 1
|
||||||
fi
|
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..."
|
echo "Testing empty database through head..."
|
||||||
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_agent_zones_empty'
|
"${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
|
"${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 \
|
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||||
'psql -U "$POSTGRES_USER" -d minerops_agent_zones_empty -Atc "SELECT version_num FROM alembic_version"')"
|
'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."
|
echo "Agent Zones migration lifecycle checks passed."
|
||||||
@@ -132,7 +132,9 @@ health_json="$(cat "${health_file}")"
|
|||||||
|
|
||||||
echo "Checking migrations and required schema..."
|
echo "Checking migrations and required schema..."
|
||||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
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="$(
|
missing_tables="$(
|
||||||
"${compose[@]}" exec -T postgres sh -c \
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run ONLY from the isolated review candidate. No .env files or published DB ports.
|
||||||
|
set -euo pipefail
|
||||||
|
review_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
: "${MINEROPS_REVIEW_BACKEND_IMAGE:?Set an existing Python backend image with requirements installed}"
|
||||||
|
docker image inspect "$MINEROPS_REVIEW_BACKEND_IMAGE" >/dev/null
|
||||||
|
review_id="minerops-discovery-review-$$-$RANDOM"
|
||||||
|
review_network="$review_id-network"
|
||||||
|
review_db="$review_id-postgres"
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$review_db" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$review_network" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
docker network create --internal "$review_network" >/dev/null
|
||||||
|
docker run -d --name "$review_db" --network "$review_network" --network-alias postgres \
|
||||||
|
--tmpfs /var/lib/postgresql/data \
|
||||||
|
-e POSTGRES_DB=discovery_review -e POSTGRES_USER=reviewer \
|
||||||
|
-e POSTGRES_PASSWORD=disposable-review-only postgres:16-alpine >/dev/null
|
||||||
|
review_ready=0
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
if docker exec "$review_db" pg_isready -U reviewer -d discovery_review >/dev/null 2>&1; then review_ready=1; break; fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
[[ "$review_ready" == 1 ]]
|
||||||
|
run_backend() {
|
||||||
|
docker run --rm --network "$review_network" -v "$review_root:/review:ro" \
|
||||||
|
-w /review/backend -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/review/backend \
|
||||||
|
-e POSTGRES_HOST=postgres -e POSTGRES_DB=discovery_review -e POSTGRES_USER=reviewer \
|
||||||
|
-e POSTGRES_PASSWORD=disposable-review-only -e JWT_SECRET_KEY=disposable-review-only \
|
||||||
|
-e MINEROPS_DISCOVERY_DISPOSABLE=1 -e MINEROPS_HUB_MODE=true \
|
||||||
|
-e SCHEDULED_SCAN_ENABLED=false -e MINEROPS_WINDOWS_AGENT_SOURCE=/review/agents/windows \
|
||||||
|
"$MINEROPS_REVIEW_BACKEND_IMAGE" "$@"
|
||||||
|
}
|
||||||
|
run_backend alembic upgrade head
|
||||||
|
run_backend python -m unittest -v tests.test_discovery_security_postgres \
|
||||||
|
tests.test_agent_discovery_isolation_postgres \
|
||||||
|
tests.test_legacy_inventory_security_postgres
|
||||||
|
# Browser and PowerShell suites use their own intercepted/loopback transports.
|
||||||
|
# See docs/operations/BLOCKER_1_DISCOVERY_REVIEW.txt for exact commands and baseline exceptions.
|
||||||
+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..."
|
echo "Testing empty database through head..."
|
||||||
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty'
|
"${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
|
"${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 \
|
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||||
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')"
|
'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."
|
echo "Miner Control Foundation migration tests passed."
|
||||||
@@ -16,7 +16,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
[[ -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}")"
|
permissions="$(stat -c '%a' "${source_file}")"
|
||||||
(( (10#${permissions} % 10) & 4 )) || {
|
(( (10#${permissions} % 10) & 4 )) || {
|
||||||
echo "${source_file} is not readable by the non-root test container" >&2
|
echo "${source_file} is not readable by the non-root test container" >&2
|
||||||
@@ -47,6 +47,7 @@ done
|
|||||||
tests.test_agent_auth \
|
tests.test_agent_auth \
|
||||||
tests.test_miner_control \
|
tests.test_miner_control \
|
||||||
tests.test_agent_commands \
|
tests.test_agent_commands \
|
||||||
|
tests.test_legacy_control_retirement \
|
||||||
tests.test_miner_control_postgres
|
tests.test_miner_control_postgres
|
||||||
|
|
||||||
echo "Miner Control Foundation focused tests passed."
|
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 \
|
"${compose[@]}" run --rm --no-deps \
|
||||||
backend sh -ec '
|
backend sh -ec '
|
||||||
test -f /app/agents/windows/Enroll-MinerOpsAgent.ps1
|
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/install.ps1
|
||||||
test -f /app/agents/windows/MinerOpsAgentService.ps1
|
test -f /app/agents/windows/MinerOpsAgentService.ps1
|
||||||
test -f /app/agents/windows/VERSION
|
test -f /app/agents/windows/VERSION
|
||||||
@@ -52,7 +53,9 @@ docker run --rm \
|
|||||||
|
|
||||||
foreach ($script in @(
|
foreach ($script in @(
|
||||||
"/work/Enroll-MinerOpsAgent.ps1",
|
"/work/Enroll-MinerOpsAgent.ps1",
|
||||||
"/work/install.ps1"
|
"/work/Upgrade-MinerOpsAgentCredential.ps1",
|
||||||
|
"/work/install.ps1",
|
||||||
|
"/work/MinerOpsAgentGUI.ps1"
|
||||||
)) {
|
)) {
|
||||||
$tokens = $null
|
$tokens = $null
|
||||||
$errors = $null
|
$errors = $null
|
||||||
@@ -70,6 +73,8 @@ docker run --rm \
|
|||||||
}
|
}
|
||||||
|
|
||||||
& /work/tests/Test-MinerOpsEnrollment.ps1
|
& /work/tests/Test-MinerOpsEnrollment.ps1
|
||||||
|
& /work/tests/Test-MinerOpsBootEventParser.ps1
|
||||||
|
& /work/tests/Test-MinerOpsCredentialTransition.ps1
|
||||||
& /work/tests/Test-MinerOpsInstaller.ps1
|
& /work/tests/Test-MinerOpsInstaller.ps1
|
||||||
'
|
'
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ set -euo pipefail
|
|||||||
service="agents/windows/MinerOpsAgentService.ps1"
|
service="agents/windows/MinerOpsAgentService.ps1"
|
||||||
control="agents/windows/MinerOpsControl.ps1"
|
control="agents/windows/MinerOpsControl.ps1"
|
||||||
installer="agents/windows/install.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' "${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 '$config.agent_zone_id' "${service}"
|
||||||
rg -Fq '$body["agent_zone_id"]' "${service}"
|
rg -Fq '$body["agent_zone_id"]' "${service}"
|
||||||
rg -q '"agent_zone_id"' agents/windows/config.example.json
|
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 'Register-ScheduledTask.*-Force' "${installer}"
|
||||||
! rg -q 'agent_secret|api3_password|api3_account' "${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
|
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-MinerOpsControl.ps1
|
||||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.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
|
else
|
||||||
echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed."
|
echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in new issue
Block a user