From 6cd876b4b3ce5637ce4e7a568639d66e2dba56ac Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 24 Aug 2026 20:40:20 +0000 Subject: [PATCH 1/3] chore: add Windows agent source baseline --- agents/windows/.gitattributes | 2 + agents/windows/.gitignore | 8 + agents/windows/BASELINE_SHA256SUMS | 8 + agents/windows/EnrichOneMiner.ps1 | 249 +++ agents/windows/MinerOpsAgentGUI.ps1 | 226 +++ agents/windows/MinerOpsAgentService.ps1 | 1467 +++++++++++++++++ agents/windows/MinerOpsAgentTray.ps1 | 108 ++ .../windows/MinerOpsApi3EnrichmentPoller.ps1 | 246 +++ agents/windows/MinerOpsPoolPoller.ps1 | 146 ++ agents/windows/MinerOpsPoolPollerTask.ps1 | 28 + agents/windows/README.md | 65 + agents/windows/config.example.json | 21 + 12 files changed, 2574 insertions(+) create mode 100644 agents/windows/.gitattributes create mode 100644 agents/windows/.gitignore create mode 100644 agents/windows/BASELINE_SHA256SUMS create mode 100644 agents/windows/EnrichOneMiner.ps1 create mode 100644 agents/windows/MinerOpsAgentGUI.ps1 create mode 100644 agents/windows/MinerOpsAgentService.ps1 create mode 100644 agents/windows/MinerOpsAgentTray.ps1 create mode 100644 agents/windows/MinerOpsApi3EnrichmentPoller.ps1 create mode 100644 agents/windows/MinerOpsPoolPoller.ps1 create mode 100644 agents/windows/MinerOpsPoolPollerTask.ps1 create mode 100644 agents/windows/README.md create mode 100644 agents/windows/config.example.json diff --git a/agents/windows/.gitattributes b/agents/windows/.gitattributes new file mode 100644 index 0000000..9f88a27 --- /dev/null +++ b/agents/windows/.gitattributes @@ -0,0 +1,2 @@ +*.ps1 -text + diff --git a/agents/windows/.gitignore b/agents/windows/.gitignore new file mode 100644 index 0000000..ec81ed5 --- /dev/null +++ b/agents/windows/.gitignore @@ -0,0 +1,8 @@ +agent-config.json +agent-state.json +logs/ +*.bak +*.bak.* +*-probe-*.json +rollback/ + diff --git a/agents/windows/BASELINE_SHA256SUMS b/agents/windows/BASELINE_SHA256SUMS new file mode 100644 index 0000000..584e0b8 --- /dev/null +++ b/agents/windows/BASELINE_SHA256SUMS @@ -0,0 +1,8 @@ +d10c355772381fb0a3953bb8cf3af247be21659a7eee4c489438d30f8bdd2da8 EnrichOneMiner.ps1 +ef56e1eca40aa838ff1e3826cdba33dc948064b8d685c6d1d4c365b049984d4e MinerOpsAgentGUI.ps1 +51fbcec81331ae9b171783b55f589f7c3d4d0e872ae66611b918703ef2b8725d MinerOpsAgentService.ps1 +bee0f9925e5ace27d8d7207d12af0ab7e5cab6b2a655654fca0ecf7dd71d7380 MinerOpsAgentTray.ps1 +c68ad90566be4ff5b18142048cdc5186d6c71c900f45e5d15d07ef4ecfc25317 MinerOpsApi3EnrichmentPoller.ps1 +90e4bbe7b1fc6467fc37da7c4e67adc01e74f332e9a2d14d1961f1488a984894 MinerOpsPoolPoller.ps1 +18b36f45b966d1ff0c0cd008bb25567204e1cb90839b825e80671f1c2071d3e5 MinerOpsPoolPollerTask.ps1 + diff --git a/agents/windows/EnrichOneMiner.ps1 b/agents/windows/EnrichOneMiner.ps1 new file mode 100644 index 0000000..749eb0e --- /dev/null +++ b/agents/windows/EnrichOneMiner.ps1 @@ -0,0 +1,249 @@ +param( + [Parameter(Mandatory=$true)] + [string]$IpAddress, + + [string]$MinerId = "" +) + +$ConfigPath = "C:\MinerOpsAgent\agent-config.json" + +function Read-AgentConfig { + if (!(Test-Path $ConfigPath)) { + throw "Agent config not found: $ConfigPath" + } + + return Get-Content $ConfigPath -Raw | ConvertFrom-Json +} + +function Invoke-MinerCommandRaw { + param( + [string]$IpAddress, + [string]$Command, + [int]$Port = 4028, + [int]$TimeoutMs = 2500 + ) + + $client = $null + + try { + $client = New-Object System.Net.Sockets.TcpClient + $async = $client.BeginConnect($IpAddress, $Port, $null, $null) + + if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { + return "" + } + + $client.EndConnect($async) + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + + $stream = $client.GetStream() + $payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n") + $stream.Write($payload, 0, $payload.Length) + + try { + $client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send) + } catch { + } + + $buffer = New-Object byte[] 262144 + $chunks = New-Object System.Collections.ArrayList + $started = Get-Date + + while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) { + if (-not $stream.DataAvailable) { + Start-Sleep -Milliseconds 20 + continue + } + + $read = $stream.Read($buffer, 0, $buffer.Length) + + if ($read -le 0) { + break + } + + for ($i = 0; $i -lt $read; $i++) { + [void]$chunks.Add([byte]$buffer[$i]) + } + + if ($chunks.Count -gt 2097152) { + break + } + } + + return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim() + } catch { + return "" + } finally { + if ($client -ne $null) { + $client.Close() + } + } +} + +function Try-ParseJson { + param([string]$Raw) + + if (!$Raw -or $Raw.Trim().Length -eq 0) { + return $null + } + + try { + return $Raw | ConvertFrom-Json + } catch { + return $null + } +} + +function Find-FirstValue { + param( + [object]$Object, + [string[]]$Names + ) + + if ($null -eq $Object) { + return "" + } + + $json = $Object | ConvertTo-Json -Depth 80 -Compress + + foreach ($name in $Names) { + $escaped = [regex]::Escape($name) + + $pattern = '"' + $escaped + '"\s*:\s*"([^"]+)"' + $m = [regex]::Match($json, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($m.Success) { + return $m.Groups[1].Value + } + + $pattern2 = '"' + $escaped + '"\s*:\s*([0-9A-Za-z\.\-_:]+)' + $m2 = [regex]::Match($json, $pattern2, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($m2.Success) { + return $m2.Groups[1].Value + } + } + + return "" +} + +function Find-MacAddress { + param([string]$Text) + + if (!$Text) { + return "" + } + + $m = [regex]::Match($Text, '([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}') + if ($m.Success) { + return $m.Value.Replace("-", ":").ToUpper() + } + + return "" +} + +$config = Read-AgentConfig +$hubUrl = ([string]$config.hub_url).TrimEnd("/") +$secret = [string]$config.agent_secret + +$commands = @( + "summary", + "devs", + "edevs", + "stats", + "pools", + "devdetails", + "version", + "get_version", + "get_miner_info" +) + +$raws = [ordered]@{} +$parsed = [ordered]@{} + +foreach ($cmd in $commands) { + Write-Host "Querying $IpAddress command: $cmd" + $raw = Invoke-MinerCommandRaw -IpAddress $IpAddress -Command $cmd -TimeoutMs 2500 + $raws[$cmd] = $raw + $parsed[$cmd] = Try-ParseJson -Raw $raw + Write-Host " bytes: $($raw.Length)" +} + +$allText = ($raws.GetEnumerator() | ForEach-Object { $_.Value }) -join "`n" +$allParsed = [ordered]@{ + summary = $parsed["summary"] + devs = $parsed["devs"] + edevs = $parsed["edevs"] + stats = $parsed["stats"] + pools = $parsed["pools"] + devdetails = $parsed["devdetails"] + version = $parsed["version"] + get_version = $parsed["get_version"] + get_miner_info = $parsed["get_miner_info"] +} + +$mac = Find-FirstValue -Object $allParsed -Names @("MAC", "Mac", "mac", "macaddr", "mac_address", "Net MAC", "ethaddr", "Ethernet MAC") +if (!$mac) { + $mac = Find-MacAddress -Text $allText +} + +$hostname = Find-FirstValue -Object $allParsed -Names @("Hostname", "hostname", "host", "Host") +$minerSn = Find-FirstValue -Object $allParsed -Names @("Miner SN", "miner_sn", "miner sn", "Serial", "serial", "SN", "sn", "M_Serial") +$firmware = Find-FirstValue -Object $allParsed -Names @("Firmware", "firmware", "firmware_version", "Soft Version", "Version", "CompileTime") +$apiVersion = Find-FirstValue -Object $allParsed -Names @("API", "api", "api_version", "Api Version") +$platform = Find-FirstValue -Object $allParsed -Names @("Platform", "platform", "Machine", "machine") +$model = Find-FirstValue -Object $allParsed -Names @("Model", "model", "Miner Type", "miner_type", "Type", "type") + +if (!$model) { + $model = "WhatsMiner" +} + +$payload = [ordered]@{ + ip_address = $IpAddress + miner_id = $MinerId + mac_address = $mac + hostname = $hostname + miner_sn = $minerSn + model = $model + miner_type = $model + api_version = $apiVersion + firmware_version = $firmware + platform = $platform + raw_version = @{ + version = $raws["version"] + get_version = $raws["get_version"] + } + raw_miner_info = @{ + devs = $raws["devs"] + edevs = $raws["edevs"] + stats = $raws["stats"] + pools = $raws["pools"] + devdetails = $raws["devdetails"] + get_miner_info = $raws["get_miner_info"] + } +} + +Write-Host "" +Write-Host "Detected:" +Write-Host " MAC: $mac" +Write-Host " Hostname: $hostname" +Write-Host " SN: $minerSn" +Write-Host " Model: $model" +Write-Host " Firmware: $firmware" +Write-Host " API: $apiVersion" +Write-Host " Platform: $platform" +Write-Host "" + +$response = Invoke-RestMethod ` + -Method Post ` + -Uri "$hubUrl/api/agent/miner-enrichment-v3" ` + -Headers @{ + "X-MinerOps-Agent-Secret" = $secret + } ` + -ContentType "application/json" ` + -Body ($payload | ConvertTo-Json -Depth 80) + +$response | ConvertTo-Json -Depth 20 + + + + diff --git a/agents/windows/MinerOpsAgentGUI.ps1 b/agents/windows/MinerOpsAgentGUI.ps1 new file mode 100644 index 0000000..7feb4ba --- /dev/null +++ b/agents/windows/MinerOpsAgentGUI.ps1 @@ -0,0 +1,226 @@ +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$BaseDir = "C:\MinerOpsAgent" +$ConfigPath = Join-Path $BaseDir "agent-config.json" +$StatePath = Join-Path $BaseDir "agent-state.json" +$LogPath = Join-Path $BaseDir "logs\agent.log" +$TaskName = "MinerOpsAgent" + +function Read-Config { + if (Test-Path $ConfigPath) { + return Get-Content $ConfigPath -Raw | ConvertFrom-Json + } + return $null +} + +function Read-ConfigText { + if (Test-Path $ConfigPath) { + return Get-Content $ConfigPath -Raw + } + return "" +} + +function Save-ConfigObject { + param([object]$Config) + $Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8 +} + +function Save-ConfigText { + param([string]$Text) + $Text | Set-Content -Path $ConfigPath -Encoding UTF8 +} + +function Get-AgentTaskText { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (!$task) { return "Scheduled task not installed" } + + $info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue + return "$($task.State) | Last result: $($info.LastTaskResult) | Last run: $($info.LastRunTime)" +} + +$form = New-Object System.Windows.Forms.Form +$form.Text = "MinerOps Agent" +$form.Size = New-Object System.Drawing.Size(950, 780) +$form.StartPosition = "CenterScreen" + +$statusLabel = New-Object System.Windows.Forms.Label +$statusLabel.Location = New-Object System.Drawing.Point(15, 15) +$statusLabel.Size = New-Object System.Drawing.Size(900, 24) +$statusLabel.Text = "Task: " + (Get-AgentTaskText) +$form.Controls.Add($statusLabel) + +$startButton = New-Object System.Windows.Forms.Button +$startButton.Text = "Start" +$startButton.Location = New-Object System.Drawing.Point(15, 45) +$startButton.Size = New-Object System.Drawing.Size(100, 32) +$form.Controls.Add($startButton) + +$stopButton = New-Object System.Windows.Forms.Button +$stopButton.Text = "Stop" +$stopButton.Location = New-Object System.Drawing.Point(125, 45) +$stopButton.Size = New-Object System.Drawing.Size(100, 32) +$form.Controls.Add($stopButton) + +$restartButton = New-Object System.Windows.Forms.Button +$restartButton.Text = "Restart" +$restartButton.Location = New-Object System.Drawing.Point(235, 45) +$restartButton.Size = New-Object System.Drawing.Size(100, 32) +$form.Controls.Add($restartButton) + +$testButton = New-Object System.Windows.Forms.Button +$testButton.Text = "Test Hub" +$testButton.Location = New-Object System.Drawing.Point(345, 45) +$testButton.Size = New-Object System.Drawing.Size(100, 32) +$form.Controls.Add($testButton) + +$saveButton = New-Object System.Windows.Forms.Button +$saveButton.Text = "Save Config" +$saveButton.Location = New-Object System.Drawing.Point(455, 45) +$saveButton.Size = New-Object System.Drawing.Size(120, 32) +$form.Controls.Add($saveButton) + +$secretLabel = New-Object System.Windows.Forms.Label +$secretLabel.Text = "Agent Secret:" +$secretLabel.Location = New-Object System.Drawing.Point(15, 90) +$secretLabel.Size = New-Object System.Drawing.Size(100, 22) +$form.Controls.Add($secretLabel) + +$secretBox = New-Object System.Windows.Forms.TextBox +$secretBox.Location = New-Object System.Drawing.Point(115, 87) +$secretBox.Size = New-Object System.Drawing.Size(570, 24) +$secretBox.UseSystemPasswordChar = $true +$form.Controls.Add($secretBox) + +$showSecretCheck = New-Object System.Windows.Forms.CheckBox +$showSecretCheck.Text = "Show" +$showSecretCheck.Location = New-Object System.Drawing.Point(695, 88) +$showSecretCheck.Size = New-Object System.Drawing.Size(70, 24) +$form.Controls.Add($showSecretCheck) + +$saveSecretButton = New-Object System.Windows.Forms.Button +$saveSecretButton.Text = "Save Secret" +$saveSecretButton.Location = New-Object System.Drawing.Point(775, 84) +$saveSecretButton.Size = New-Object System.Drawing.Size(120, 30) +$form.Controls.Add($saveSecretButton) + +$configBox = New-Object System.Windows.Forms.TextBox +$configBox.Location = New-Object System.Drawing.Point(15, 125) +$configBox.Size = New-Object System.Drawing.Size(900, 350) +$configBox.Multiline = $true +$configBox.ScrollBars = "Both" +$configBox.Font = New-Object System.Drawing.Font("Consolas", 10) +$configBox.Text = Read-ConfigText +$form.Controls.Add($configBox) + +$stateBox = New-Object System.Windows.Forms.TextBox +$stateBox.Location = New-Object System.Drawing.Point(15, 490) +$stateBox.Size = New-Object System.Drawing.Size(900, 85) +$stateBox.Multiline = $true +$stateBox.ScrollBars = "Vertical" +$stateBox.Font = New-Object System.Drawing.Font("Consolas", 9) +$form.Controls.Add($stateBox) + +$logBox = New-Object System.Windows.Forms.TextBox +$logBox.Location = New-Object System.Drawing.Point(15, 590) +$logBox.Size = New-Object System.Drawing.Size(900, 130) +$logBox.Multiline = $true +$logBox.ScrollBars = "Vertical" +$logBox.Font = New-Object System.Drawing.Font("Consolas", 9) +$form.Controls.Add($logBox) + +function Refresh-Display { + $statusLabel.Text = "Task: " + (Get-AgentTaskText) + + $config = Read-Config + if ($config -and $secretBox.Text.Length -eq 0) { + $secretBox.Text = [string]$config.agent_secret + } + + if (Test-Path $StatePath) { + $stateBox.Text = Get-Content $StatePath -Raw + } else { + $stateBox.Text = "No state file yet." + } + + if (Test-Path $LogPath) { + $logBox.Text = (Get-Content $LogPath -Tail 30) -join [Environment]::NewLine + } else { + $logBox.Text = "No log file yet." + } +} + +$showSecretCheck.Add_CheckedChanged({ + $secretBox.UseSystemPasswordChar = -not $showSecretCheck.Checked +}) + +$saveSecretButton.Add_Click({ + try { + $config = $configBox.Text | ConvertFrom-Json + $config.agent_secret = $secretBox.Text + $config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8 + $configBox.Text = Get-Content $ConfigPath -Raw + [System.Windows.Forms.MessageBox]::Show("Secret saved. Restart the agent to apply changes.") + } catch { + [System.Windows.Forms.MessageBox]::Show("Could not save secret: " + $_.Exception.Message) + } +}) + +$startButton.Add_Click({ + Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + Refresh-Display +}) + +$stopButton.Add_Click({ + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + Refresh-Display +}) + +$restartButton.Add_Click({ + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + Refresh-Display +}) + +$saveButton.Add_Click({ + try { + $null = $configBox.Text | ConvertFrom-Json + Save-ConfigText -Text $configBox.Text + [System.Windows.Forms.MessageBox]::Show("Config saved. Restart the agent to apply changes.") + } catch { + [System.Windows.Forms.MessageBox]::Show("Invalid JSON: " + $_.Exception.Message) + } +}) + +$testButton.Add_Click({ + try { + $config = $configBox.Text | ConvertFrom-Json + if ($secretBox.Text.Length -gt 0) { + $config.agent_secret = $secretBox.Text + } + + $hub = [string]$config.hub_url + $secret = [string]$config.agent_secret + + $headers = @{ + "X-MinerOps-Agent-Secret" = $secret + } + + $result = Invoke-RestMethod -Uri ($hub.TrimEnd("/") + "/api/agent/config") -Headers $headers -TimeoutSec 20 + [System.Windows.Forms.MessageBox]::Show("Hub test OK.`nSite: " + $result.site_name) + } catch { + [System.Windows.Forms.MessageBox]::Show("Hub test failed: " + $_.Exception.Message) + } +}) + +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 3000 +$timer.Add_Tick({ Refresh-Display }) +$timer.Start() + +Refresh-Display +[void]$form.ShowDialog() diff --git a/agents/windows/MinerOpsAgentService.ps1 b/agents/windows/MinerOpsAgentService.ps1 new file mode 100644 index 0000000..57a812f --- /dev/null +++ b/agents/windows/MinerOpsAgentService.ps1 @@ -0,0 +1,1467 @@ +$ErrorActionPreference = "Continue" + +$BaseDir = "C:\MinerOpsAgent" +$ConfigPath = Join-Path $BaseDir "agent-config.json" +$LogDir = Join-Path $BaseDir "logs" +$LogPath = Join-Path $LogDir "agent.log" +$StatePath = Join-Path $BaseDir "agent-state.json" + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + +function Write-AgentLog { + param([string]$Message) + $line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message + Add-Content -Path $LogPath -Value $line -Encoding UTF8 +} + +function Read-AgentConfig { + if (!(Test-Path $ConfigPath)) { + throw "Missing config file: $ConfigPath" + } + + return Get-Content $ConfigPath -Raw | ConvertFrom-Json +} + +function Save-AgentState { + param( + [string]$Status, + [string]$Message, + [object]$Extra = $null + ) + + $state = [ordered]@{ + last_run = (Get-Date).ToString("o") + status = $Status + message = $Message + computer_name = $env:COMPUTERNAME + extra = $Extra + } + + $state | ConvertTo-Json -Depth 30 | Set-Content -Path $StatePath -Encoding UTF8 +} + +function Invoke-AgentApi { + param( + [string]$Method, + [string]$Path, + [object]$Body = $null + ) + + $config = Read-AgentConfig + $hub = [string]$config.hub_url + $secret = [string]$config.agent_secret + + $uri = $hub.TrimEnd("/") + $Path + + $headers = @{ + "X-MinerOps-Agent-Secret" = $secret + } + + if ($Body -ne $null) { + $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 -TimeoutSec 120 +} + +function Send-AgentCheckin { + $config = Read-AgentConfig + + $body = [ordered]@{ + agent_id = [string]$config.agent_id + site_name = [string]$config.site_name + hostname = $env:COMPUTERNAME + version = "0.4-command-queue" + ip_address = "" + local_time = (Get-Date).ToString("o") + } + + return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body +} + +function Get-KnownMinersFromHub { + $result = Invoke-AgentApi -Method "GET" -Path "/api/agent/known-miners" + + if ($result.miners) { + return @($result.miners) + } + + return @() +} + +function Get-PendingAgentCommands { + $config = Read-AgentConfig + $agentId = [string]$config.agent_id + + $result = Invoke-AgentApi -Method "GET" -Path "/api/agent/commands/pending?agent_id=$agentId" + + if ($result.commands) { + return @($result.commands) + } + + return @() +} + +function Send-AgentCommandResult { + param( + [string]$CommandId, + [string]$Status, + [object]$ResultPayload = $null, + [string]$ErrorMessage = $null + ) + + $body = [ordered]@{ + command_id = $CommandId + status = $Status + result_payload = $ResultPayload + error_message = $ErrorMessage + } + + return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body +} + +function Poll-KnownMiners { + param([object[]]$KnownMiners) + + $maxWorkers = 25 + $jobs = New-Object System.Collections.ArrayList + $results = New-Object System.Collections.ArrayList + + foreach ($miner in $KnownMiners) { + while (($jobs | Where-Object { $_.State -eq "Running" }).Count -ge $maxWorkers) { + $done = @($jobs | Where-Object { $_.State -ne "Running" }) + + foreach ($job in $done) { + try { + $jsonResult = Receive-Job $job -ErrorAction SilentlyContinue + if ($jsonResult) { + foreach ($line in @($jsonResult)) { + if ($line) { + [void]$results.Add(($line | ConvertFrom-Json)) + } + } + } + } catch { + } + + Remove-Job $job -Force -ErrorAction SilentlyContinue + [void]$jobs.Remove($job) + } + + Start-Sleep -Milliseconds 200 + } + + $minerJson = $miner | ConvertTo-Json -Depth 20 -Compress + + $job = Start-Job -ScriptBlock { + param($minerJson) + + $Miner = $minerJson | ConvertFrom-Json + + function Invoke-WhatsMinerSummary { + param( + [string]$IpAddress, + [int]$Port = 4028, + [int]$TimeoutMs = 2000 + ) + + $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" + } + + $client.EndConnect($async) + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + + $stream = $client.GetStream() + $payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"summary`"}`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 + + while ($true) { + try { + $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 + } + } catch { + break + } + } + + $raw = [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim() + + return @{ + ok = $true + raw = $raw + error = $null + } + } catch { + return @{ + ok = $false + raw = "" + error = $_.Exception.Message + } + } finally { + if ($client -ne $null) { + $client.Close() + } + } + } + + $started = Get-Date + $summary = Invoke-WhatsMinerSummary -IpAddress ([string]$Miner.ip_address) + $finished = Get-Date + + $online = $false + if ($summary.ok -and $summary.raw.Length -gt 0) { + $online = $true + } + + $result = [ordered]@{ + miner_id = [string]$Miner.miner_id + ip_address = [string]$Miner.ip_address + mac_address = [string]$Miner.mac_address + room_name = [string]$Miner.room_name + model = [string]$Miner.model + previous_status = [string]$Miner.status + online = $online + started_at = $started.ToString("o") + finished_at = $finished.ToString("o") + duration_ms = [int](New-TimeSpan -Start $started -End $finished).TotalMilliseconds + responses = @{ + summary = @{ + ok = $summary.ok + raw = $summary.raw + error = $summary.error + } + } + } + + $result | ConvertTo-Json -Depth 50 -Compress + } -ArgumentList $minerJson + + [void]$jobs.Add($job) + } + + while ($jobs.Count -gt 0) { + $done = @($jobs | Where-Object { $_.State -ne "Running" }) + + foreach ($job in $done) { + try { + $jsonResult = Receive-Job $job -ErrorAction SilentlyContinue + if ($jsonResult) { + foreach ($line in @($jsonResult)) { + if ($line) { + [void]$results.Add(($line | ConvertFrom-Json)) + } + } + } + } catch { + } + + Remove-Job $job -Force -ErrorAction SilentlyContinue + [void]$jobs.Remove($job) + } + + Start-Sleep -Milliseconds 200 + } + + return @($results) +} + +function Send-MinerSnapshot { + param([object[]]$Miners) + + $config = Read-AgentConfig + + $body = [ordered]@{ + agent_id = [string]$config.agent_id + site_name = [string]$config.site_name + hostname = $env:COMPUTERNAME + created_at = (Get-Date).ToString("o") + miner_count = $Miners.Count + miners = @($Miners) + } + + return Invoke-AgentApi -Method "POST" -Path "/api/agent/miner-snapshots" -Body $body +} +function Convert-CidrToIpList { + param([string]$Cidr) + + $Cidr = ([string]$Cidr).Trim() + + if ($Cidr -notmatch '^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})$') { + throw "Invalid CIDR: $Cidr" + } + + $a = [int]$Matches[1] + $b = [int]$Matches[2] + $c = [int]$Matches[3] + $d = [int]$Matches[4] + $prefix = [int]$Matches[5] + + if ($prefix -ne 22) { + throw "This discovery scanner currently supports /22 ranges only. Got: $Cidr" + } + + $list = New-Object System.Collections.ArrayList + + # /22 = 4 x /24 networks, 1024 addresses, 1022 usable. + # Example: 10.30.0.0/22 means 10.30.0.1 through 10.30.3.254. + for ($third = $c; $third -le ($c + 3); $third++) { + for ($fourth = 1; $fourth -le 254; $fourth++) { + [void]$list.Add("$a.$b.$third.$fourth") + } + } + + return @($list) +} +function Test-MinerPort { + param( + [string]$IpAddress, + [int]$Port = 4028, + [int]$TimeoutMs = 600 + ) + + $client = $null + + try { + $client = New-Object System.Net.Sockets.TcpClient + $async = $client.BeginConnect($IpAddress, $Port, $null, $null) + + if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { + return $false + } + + $client.EndConnect($async) + return $true + } catch { + return $false + } finally { + if ($client -ne $null) { + $client.Close() + } + } +} + +function Get-MinerSummaryRaw { + param( + [string]$IpAddress, + [int]$Port = 4028, + [int]$TimeoutMs = 1500 + ) + + $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" + } + + $client.EndConnect($async) + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + + $stream = $client.GetStream() + $payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"summary`"}`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 + + while ($true) { + try { + $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 + } + } catch { + break + } + } + + return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim() + } catch { + return "" + } finally { + if ($client -ne $null) { + $client.Close() + } + } +} + +function Ensure-FastScannerLoaded { + if ("MinerOpsFastScanner" -as [type]) { + return + } + + $code = @" +using System; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +public class MinerOpsScanTarget { + public string IpAddress; + public string RoomName; + public int Port; +} + +public class MinerOpsScanResult { + public string IpAddress; + public string RoomName; + public bool Online; + public string RawSummary; + public string Error; + public int DurationMs; +} + +public static class MinerOpsFastScanner { + public static MinerOpsScanResult[] Scan(MinerOpsScanTarget[] targets, int maxConcurrency, int timeoutMs) { + var results = new List(); + var locker = new object(); + var semaphore = new SemaphoreSlim(maxConcurrency); + var tasks = new List(); + + foreach (var target in targets) { + semaphore.Wait(); + + var localTarget = target; + + tasks.Add(Task.Run(async delegate { + try { + var result = await ScanOne(localTarget, timeoutMs); + lock (locker) { + results.Add(result); + } + } finally { + semaphore.Release(); + } + })); + } + + Task.WaitAll(tasks.ToArray()); + return results.ToArray(); + } + + private static async Task ScanOne(MinerOpsScanTarget target, int timeoutMs) { + var started = DateTime.UtcNow; + + var result = new MinerOpsScanResult(); + result.IpAddress = target.IpAddress; + result.RoomName = target.RoomName; + result.Online = false; + result.RawSummary = ""; + result.Error = ""; + result.DurationMs = 0; + + TcpClient client = null; + + try { + client = new TcpClient(); + + var connectTask = client.ConnectAsync(target.IpAddress, target.Port); + var timeoutTask = Task.Delay(timeoutMs); + + var winner = await Task.WhenAny(connectTask, timeoutTask); + + if (winner != connectTask) { + result.Error = "Connect timeout"; + return Finish(result, started); + } + + await connectTask; + + client.ReceiveTimeout = timeoutMs; + client.SendTimeout = timeoutMs; + + using (var stream = client.GetStream()) { + var payload = Encoding.ASCII.GetBytes("{\"cmd\":\"summary\"}\n"); + await stream.WriteAsync(payload, 0, payload.Length); + + try { + client.Client.Shutdown(SocketShutdown.Send); + } catch { + } + + var buffer = new byte[65536]; + var chunks = new List(); + var readStarted = DateTime.UtcNow; + + while (true) { + if ((DateTime.UtcNow - readStarted).TotalMilliseconds > timeoutMs) { + break; + } + + if (!stream.DataAvailable) { + await Task.Delay(10); + continue; + } + + int read = await stream.ReadAsync(buffer, 0, buffer.Length); + + if (read <= 0) { + break; + } + + for (int i = 0; i < read; i++) { + chunks.Add(buffer[i]); + } + + if (chunks.Count > 1048576) { + break; + } + } + + if (chunks.Count > 0) { + result.RawSummary = Encoding.UTF8.GetString(chunks.ToArray()).Trim(); + result.Online = result.RawSummary.Length > 0; + } + } + } catch (Exception ex) { + result.Error = ex.Message; + } finally { + if (client != null) { + try { client.Close(); } catch {} + } + } + + return Finish(result, started); + } + + private static MinerOpsScanResult Finish(MinerOpsScanResult result, DateTime started) { + result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds; + return result; + } +} +"@ + + Add-Type -TypeDefinition $code -Language CSharp +} + +function Discover-MinersInRanges { + $config = Read-AgentConfig + $ranges = @($config.ip_ranges | Where-Object { $_.enabled -eq $true }) + + Ensure-FastScannerLoaded + + $targets = New-Object System.Collections.ArrayList + + foreach ($range in $ranges) { + Write-AgentLog "Fast discovery range enabled: $($range.name) $($range.cidr)" + + $ips = Convert-CidrToIpList -Cidr ([string]$range.cidr) + + foreach ($ip in $ips) { + $target = New-Object MinerOpsScanTarget + $target.IpAddress = $ip + $target.RoomName = [string]$range.name + $target.Port = 4028 + [void]$targets.Add($target) + } + } + + Write-AgentLog "Fast discovery targets built: $($targets.Count)" + + Ensure-FastScannerLoaded + + $maxConcurrency = 220 + $timeoutMs = 900 + + Write-AgentLog "Fast discovery scan starting. Concurrency: $maxConcurrency TimeoutMs: $timeoutMs" + + $started = Get-Date + $scanResults = [MinerOpsFastScanner]::Scan([MinerOpsScanTarget[]]$targets.ToArray([MinerOpsScanTarget]), $maxConcurrency, $timeoutMs) + $finished = Get-Date + + $discoveries = New-Object System.Collections.ArrayList + + foreach ($result in $scanResults) { + if ($result.Online -and $result.RawSummary -and $result.RawSummary.Length -gt 0) { + $model = "WhatsMiner" + + $item = [ordered]@{ + ip_address = [string]$result.IpAddress + room_name = [string]$result.RoomName + mac_address = "" + model = $model + hostname = "" + miner_sn = "" + raw_summary = [string]$result.RawSummary + scan_duration_ms = [int]$result.DurationMs + discovered_at = (Get-Date).ToString("o") + } + + [void]$discoveries.Add($item) + } + } + + Write-AgentLog "Fast discovery scan complete. Responders: $($discoveries.Count). Duration seconds: $([math]::Round((New-TimeSpan -Start $started -End $finished).TotalSeconds, 2))" + + return @($discoveries) +} +function Send-AgentDiscoveries { + param([object[]]$Discoveries) + + $config = Read-AgentConfig + + $body = [ordered]@{ + agent_id = [string]$config.agent_id + site_name = [string]$config.site_name + hostname = $env:COMPUTERNAME + created_at = (Get-Date).ToString("o") + discovery_count = $Discoveries.Count + discoveries = @($Discoveries) + } + + return Invoke-AgentApi -Method "POST" -Path "/api/agent/discoveries" -Body $body +} + +function Run-NewMinerDiscovery { + Write-AgentLog "Starting new-miner discovery scan." + $started = Get-Date + + $discoveries = Discover-MinersInRanges + + $finished = Get-Date + + Write-AgentLog "Discovery scan complete. Found responders: $($discoveries.Count). Sending to hub." + + $result = Send-AgentDiscoveries -Discoveries $discoveries + + $extra = @{ + discovered = $discoveries.Count + duration_seconds = [math]::Round((New-TimeSpan -Start $started -End $finished).TotalSeconds, 2) + hub_result = $result + } + + Save-AgentState -Status "ok" -Message "New-miner discovery scan sent." -Extra $extra + + return $extra +} + + +function Run-KnownMinerPoll { + param([string]$Reason = "scheduled") + + Write-AgentLog "Checking in with hub." + $checkin = Send-AgentCheckin + + Write-AgentLog "Getting known miners from hub." + $knownMiners = Get-KnownMinersFromHub + + Write-AgentLog "Known miners received: $($knownMiners.Count). Polling summary. Reason: $Reason" + $pollStarted = Get-Date + $pollResults = Poll-KnownMiners -KnownMiners $knownMiners + $pollFinished = Get-Date + + $online = @($pollResults | Where-Object { $_.online }).Count + $offline = @($pollResults | Where-Object { -not $_.online }).Count + + Write-AgentLog "Polling complete. Online: $online Offline: $offline Total: $($pollResults.Count). Sending snapshot." + + $snapshot = Send-MinerSnapshot -Miners $pollResults + + $extra = @{ + reason = $Reason + known_miners = $knownMiners.Count + polled = $pollResults.Count + online = $online + offline = $offline + poll_duration_seconds = [math]::Round((New-TimeSpan -Start $pollStarted -End $pollFinished).TotalSeconds, 2) + snapshot = $snapshot + checkin = $checkin + } + + Save-AgentState -Status "ok" -Message "Real miner summary snapshot sent." -Extra $extra + + return $extra +} + + +function Invoke-MinerPlainCommand { + param( + [Parameter(Mandatory=$true)] + [string]$IpAddress, + + [Parameter(Mandatory=$true)] + [string]$Command, + + [int]$Port = 4028, + + [int]$TimeoutMs = 2500 + ) + + $client = $null + + try { + $client = New-Object System.Net.Sockets.TcpClient + $async = $client.BeginConnect($IpAddress, $Port, $null, $null) + + if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { + throw "Connect timeout to $IpAddress`:$Port" + } + + $client.EndConnect($async) + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + + $stream = $client.GetStream() + $payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n") + $stream.Write($payload, 0, $payload.Length) + + try { + $client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send) + } catch { + } + + $buffer = New-Object byte[] 65536 + $chunks = New-Object System.Collections.ArrayList + $started = Get-Date + + while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) { + if (-not $stream.DataAvailable) { + Start-Sleep -Milliseconds 20 + continue + } + + $read = $stream.Read($buffer, 0, $buffer.Length) + + if ($read -le 0) { + break + } + + for ($i = 0; $i -lt $read; $i++) { + [void]$chunks.Add([byte]$buffer[$i]) + } + + if ($chunks.Count -gt 1048576) { + break + } + } + + return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim() + } finally { + if ($client -ne $null) { + $client.Close() + } + } +} + +function Get-MinerOpsApi3Credential { + param( + [string]$Name, + [string]$DefaultValue + ) + + $config = Read-AgentConfig + + if ($config.PSObject.Properties.Name -contains $Name) { + $value = [string]$config.$Name + + if ($value -and $value.Trim().Length -gt 0) { + return $value.Trim() + } + } + + return $DefaultValue +} + +function Get-MinerOpsApi3Token { + param( + [Parameter(Mandatory=$true)] + [string]$Command, + + [Parameter(Mandatory=$true)] + [string]$Password, + + [Parameter(Mandatory=$true)] + [string]$Salt, + + [Parameter(Mandatory=$true)] + [int64]$Timestamp + ) + + $source = "$Command$Password$Salt$Timestamp" + $bytes = [System.Text.Encoding]::UTF8.GetBytes($source) + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $digest = $sha.ComputeHash($bytes) + return ([Convert]::ToBase64String($digest)).Substring(0, 8) + } finally { + $sha.Dispose() + } +} + +function Read-MinerOpsExactBytes { + param( + [Parameter(Mandatory=$true)] + [System.IO.Stream]$Stream, + + [Parameter(Mandatory=$true)] + [int]$Length, + + [int]$TimeoutMs = 10000 + ) + + $buffer = New-Object byte[] $Length + $offset = 0 + $started = Get-Date + + while ($offset -lt $Length) { + if (((Get-Date) - $started).TotalMilliseconds -gt $TimeoutMs) { + throw "Timed out reading $Length bytes from API3 stream." + } + + try { + $read = $Stream.Read($buffer, $offset, $Length - $offset) + } catch { + throw "API3 stream read failed: $($_.Exception.Message)" + } + + if ($read -le 0) { + throw "API3 stream ended early. Read $offset of $Length bytes." + } + + $offset += $read + } + + return $buffer +} + +function Invoke-MinerOpsApi3Payload { + param( + [Parameter(Mandatory=$true)] + [string]$IpAddress, + + [Parameter(Mandatory=$true)] + [hashtable]$Payload, + + [int]$Port = 4433, + + [int]$TimeoutMs = 10000 + ) + + $client = $null + $started = Get-Date + + try { + $client = New-Object System.Net.Sockets.TcpClient + + $async = $client.BeginConnect($IpAddress, $Port, $null, $null) + + if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { + throw "API3 connect timeout to $IpAddress`:$Port" + } + + $client.EndConnect($async) + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + + $json = $Payload | ConvertTo-Json -Depth 20 -Compress + $requestBytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $lengthBytes = [System.BitConverter]::GetBytes([UInt32]$requestBytes.Length) + + $stream = $client.GetStream() + $stream.Write($lengthBytes, 0, $lengthBytes.Length) + $stream.Write($requestBytes, 0, $requestBytes.Length) + + $responseLengthBytes = Read-MinerOpsExactBytes -Stream $stream -Length 4 -TimeoutMs $TimeoutMs + $responseLength = [System.BitConverter]::ToUInt32($responseLengthBytes, 0) + + if ($responseLength -le 0 -or $responseLength -gt 1048576) { + throw "API3 response length looked invalid: $responseLength" + } + + $responseBytes = Read-MinerOpsExactBytes -Stream $stream -Length ([int]$responseLength) -TimeoutMs $TimeoutMs + $responseText = [System.Text.Encoding]::UTF8.GetString($responseBytes) + $responseJson = $responseText | ConvertFrom-Json + + return @{ + sent = $true + protocol = "whatsminer_api3" + target_ip = $IpAddress + target_port = $Port + duration_seconds = [math]::Round(((Get-Date) - $started).TotalSeconds, 3) + request = @{ + cmd = $Payload.cmd + param = $Payload.param + account = $Payload.account + has_token = [bool]$Payload.token + } + response = $responseJson + } + } finally { + if ($client -ne $null) { + $client.Close() + } + } +} + +function Get-MinerOpsApi3Salt { + param( + [Parameter(Mandatory=$true)] + [string]$IpAddress, + + [int]$Port = 4433 + ) + + $response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $Port -Payload @{ + cmd = "get.device.info" + param = $null + } + + if ([int]$response.response.code -ne 0) { + throw "API3 get.device.info failed: $($response.response | ConvertTo-Json -Compress)" + } + + $salt = [string]$response.response.msg.salt + + if (!$salt) { + throw "API3 get.device.info did not return salt." + } + + return @{ + salt = $salt + salt_response = $response + } +} + +function Invoke-MinerOpsApi3Service { + param( + [Parameter(Mandatory=$true)] + [string]$IpAddress, + + [Parameter(Mandatory=$true)] + [ValidateSet("start", "stop", "restart")] + [string]$ServiceAction + ) + + $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "super" + $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "super" + $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 +} + + +function Normalize-MinerOpsIpAddress { + param( + [string]$IpAddress + ) + + $value = ([string]$IpAddress).Trim() + + if ($value.Contains("/")) { + $value = $value.Split("/")[0] + } + + return $value +} + + +function Invoke-MinerOpsApi3AuthenticatedCommand { + param( + [Parameter(Mandatory=$true)] + [string]$IpAddress, + + [Parameter(Mandatory=$true)] + [string]$Command, + + [object]$Param = $null + ) + + $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "super" + $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "super" + $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 { + param( + [Parameter(Mandatory=$true)] $Payload, + [Parameter(Mandatory=$true)] [string] $Name + ) + + if ($null -eq $Payload) { + return $null + } + + if ($Payload -is [hashtable]) { + if ($Payload.ContainsKey($Name)) { + return $Payload[$Name] + } + + return $null + } + + $property = $Payload.PSObject.Properties[$Name] + + if ($property) { + return $property.Value + } + + return $null +} + +function Invoke-MinerOpsLegacy4028Command { + param( + [Parameter(Mandatory=$true)] [string] $IpAddress, + [Parameter(Mandatory=$true)] [string] $CommandJson, + [int] $Port = 4028, + [int] $TimeoutMs = 7000 + ) + + $normalizedIp = Normalize-MinerOpsIpAddress -IpAddress $IpAddress + + $client = New-Object System.Net.Sockets.TcpClient + $connectResult = $client.BeginConnect($normalizedIp, $Port, $null, $null) + + if (-not $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { + try { $client.Close() } catch {} + throw "Legacy 4028 connection timed out to $normalizedIp`:$Port" + } + + $client.EndConnect($connectResult) + + try { + $stream = $client.GetStream() + $stream.ReadTimeout = $TimeoutMs + $stream.WriteTimeout = $TimeoutMs + + $bytes = [System.Text.Encoding]::ASCII.GetBytes($CommandJson) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() + + Start-Sleep -Milliseconds 500 + + $buffer = New-Object byte[] 65535 + $builder = New-Object System.Text.StringBuilder + + do { + $read = $stream.Read($buffer, 0, $buffer.Length) + + if ($read -gt 0) { + [void]$builder.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)) + } + } while ($stream.DataAvailable) + + $rawResponse = $builder.ToString().Trim([char]0).Trim() + + $parsedResponse = $null + + try { + if ($rawResponse) { + $parsedResponse = $rawResponse | ConvertFrom-Json + } + } catch { + $parsedResponse = $null + } + + return @{ + success = $true + control_method = "legacy_4028" + port = $Port + request_json = $CommandJson + raw_response = $rawResponse + parsed_response = $parsedResponse + } + } + finally { + try { $stream.Close() } catch {} + try { $client.Close() } catch {} + } +} + +function Invoke-MinerOpsM30Legacy4028Test { + param( + [Parameter(Mandatory=$true)] $Payload + ) + + if ($Payload -is [string]) { + $Payload = $Payload | ConvertFrom-Json + } + + $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "ip_address" + + if (-not $ipAddress) { + $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "current_ip" + } + + if (-not $ipAddress) { + throw "Legacy 4028 test failed: no IP address in payload." + } + + $legacyJson = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_json" + $legacyCommand = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_command" + + if (-not $legacyJson) { + if (-not $legacyCommand) { + throw "Legacy 4028 test failed: no legacy command in payload." + } + + $legacyJson = '{"command":"' + $legacyCommand + '"}' + } + + Write-AgentLog "Running legacy 4028 M30 test against $ipAddress command=$legacyCommand" + + $result = Invoke-MinerOpsLegacy4028Command -IpAddress $ipAddress -CommandJson $legacyJson -Port 4028 + + return @{ + success = $true + action = "legacy_4028" + test_lab = "m30" + ip_address = $ipAddress + legacy_4028_command = $legacyCommand + legacy_4028_request = $legacyJson + legacy_4028_response = $result.raw_response + legacy_4028_parsed = $result.parsed_response + control_method = "legacy_4028" + } +} + +function Run-MinerControlCommand { + + param( + [Parameter(Mandatory=$true)] + [object]$Payload + ) + # MinerOps M30 legacy 4028 test path + if ($Payload -is [string]) { + try { + $Payload = $Payload | ConvertFrom-Json + } catch { + } + } + + if ($Payload -and (($Payload.control_method -eq "legacy_4028") -or ($Payload.action -eq "legacy_4028"))) { + return Invoke-MinerOpsM30Legacy4028Test -Payload $Payload + } + + + if ($Payload -is [string]) { + $Payload = $Payload | ConvertFrom-Json + } + + if ($null -eq $Payload) { + throw "Miner control payload was empty." + } + + $action = ([string]$Payload.action).ToLower() + $minerId = [string]$Payload.miner_id + $macAddress = [string]$Payload.mac_address + $dryRun = [bool]$Payload.dry_run + + $ipAddress = ([string]$Payload.ip_address).Trim() + + if ($ipAddress.Contains("/")) { + $ipAddress = $ipAddress.Split("/")[0] + } + + if ([string]::IsNullOrWhiteSpace($ipAddress)) { + throw "Miner control payload did not include ip_address." + } + + $serviceAction = "" + $api3Command = "" + $api3Param = $null + $performanceMode = "" + + if ($action -eq "reboot") { + $serviceAction = "restart" + $api3Command = "set.miner.service" + } elseif ($action -eq "suspend") { + $serviceAction = "stop" + $api3Command = "set.miner.service" + } elseif ($action -eq "resume") { + $serviceAction = "start" + $api3Command = "set.miner.service" + } elseif ($action -eq "performance_mode") { + $performanceMode = ([string]$Payload.performance_mode).ToLower() + + if ($performanceMode -in @("low", "normal", "high")) { + $api3Command = "set.miner.power_mode" + $api3Param = $performanceMode + } else { + throw "Invalid performance_mode: $performanceMode" + } + } else { + throw "Unsupported miner control action: $action" + } + + Write-AgentLog "Miner control requested. Action=$action API3Command=$api3Command API3Service=$serviceAction PerformanceMode=$performanceMode IP=$ipAddress MAC=$macAddress DryRun=$dryRun" + + if ($dryRun) { + return @{ + ok = $true + dry_run = $true + action = $action + api3_command = $api3Command + api3_service_action = $serviceAction + performance_mode = $performanceMode + ip_address = $ipAddress + miner_id = $minerId + mac_address = $macAddress + message = "Dry run only. No miner command sent." + } + } + + if ($action -eq "performance_mode") { + $api3Result = Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress $ipAddress -Command $api3Command -Param $api3Param + } else { + $api3Result = Invoke-MinerOpsApi3Service -IpAddress $ipAddress -ServiceAction $serviceAction + } + + return @{ + ok = $true + dry_run = $false + action = $action + api3_command = $api3Command + api3_service_action = $serviceAction + performance_mode = $performanceMode + ip_address = $ipAddress + miner_id = $minerId + mac_address = $macAddress + message = "API3 miner control command sent by Windows Agent." + api3_result = $api3Result + } +} + + +function Process-PendingCommands { + $commands = @(Get-PendingAgentCommands) + + if ($commands.Count -le 0) { + return 0 + } + + Write-AgentLog "Pending commands received: $($commands.Count)" + + foreach ($cmd in $commands) { + $commandId = [string]$cmd.command_id + $commandType = [string]$cmd.command_type + + try { + Write-AgentLog "Running command $commandId type $commandType" + + if ($commandType -eq "poll_known_miners_now") { + $result = Run-KnownMinerPoll -Reason "manual_command" + Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload $result | Out-Null + Write-AgentLog "Command completed: $commandId" + } elseif ($commandType -eq "miner_control") { + $result = Run-MinerControlCommand -Payload $cmd.request_payload + Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload $result | Out-Null + Write-AgentLog "Miner control command completed: $commandId" + + } elseif ($commandType -eq "enrich_match_discovery") { + $payloadObj = $cmd.request_payload + + if ($payloadObj -is [string]) { + try { + $payloadObj = $payloadObj | ConvertFrom-Json + } catch { + $payloadObj = $null + } + } + + $ipAddress = "" + + if ($payloadObj -ne $null -and $payloadObj.PSObject.Properties.Name -contains "ip_address") { + $ipAddress = [string]$payloadObj.ip_address + } + + if (!$ipAddress) { + throw "enrich_match_discovery command missing ip_address." + } + + 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 + + if ($LASTEXITCODE -ne 0) { + throw "EnrichOneMiner failed for $ipAddress. Output: $out" + } + + Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload @{ + ip_address = $ipAddress + output = $out + } | Out-Null + + Write-AgentLog "Enrich / Match command completed: $commandId" + + } elseif ($commandType -eq "discover_new_miners") { + $result = Run-NewMinerDiscovery + Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload $result | Out-Null + Write-AgentLog "Discovery command completed: $commandId" + } else { + Send-AgentCommandResult -CommandId $commandId -Status "failed" -ResultPayload @{} -ErrorMessage "Unknown command type: $commandType" | Out-Null + Write-AgentLog "Unknown command type failed: $commandType" + } + } catch { + $msg = $_.Exception.Message + Send-AgentCommandResult -CommandId $commandId -Status "failed" -ResultPayload @{} -ErrorMessage $msg | Out-Null + Write-AgentLog "Command failed: $commandId ERROR: $msg" + } + } + + return $commands.Count +} + +Write-AgentLog "MinerOps Agent service script started." +Save-AgentState -Status "starting" -Message "Agent starting." + +while ($true) { + try { + $config = Read-AgentConfig + $interval = [int]$config.poll_interval_seconds + + if ($interval -lt 60) { + $interval = 60 + } + + $commandsProcessed = Process-PendingCommands + + if ($commandsProcessed -eq 0) { + $null = Run-KnownMinerPoll -Reason "scheduled" + } + + Write-AgentLog "Sleeping up to $interval seconds. Checking for web commands every 15 seconds." + + $slept = 0 + + while ($slept -lt $interval) { + Start-Sleep -Seconds 15 + $slept += 15 + + Process-PendingCommands | Out-Null + } + } catch { + $msg = $_.Exception.Message + Write-AgentLog "ERROR: $msg" + Save-AgentState -Status "error" -Message $msg + Start-Sleep -Seconds 30 + } +} + + + + + + + + + + + + + + diff --git a/agents/windows/MinerOpsAgentTray.ps1 b/agents/windows/MinerOpsAgentTray.ps1 new file mode 100644 index 0000000..69b0ce1 --- /dev/null +++ b/agents/windows/MinerOpsAgentTray.ps1 @@ -0,0 +1,108 @@ +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$TaskName = "MinerOpsAgent" +$GuiPath = "C:\MinerOpsAgent\MinerOpsAgentGUI.ps1" +$LogPath = "C:\MinerOpsAgent\logs\agent.log" +$DashboardUrl = "https://plexus.lussier.cc/dashboard" + +function Start-Agent { + Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +} + +function Stop-Agent { + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +} + +function Restart-Agent { + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +} + +function Open-Gui { + Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$GuiPath`"" +} + +function Open-Dashboard { + Start-Process $DashboardUrl +} + +function Open-Log { + if (Test-Path $LogPath) { + Start-Process notepad.exe $LogPath + } +} + +function Get-AgentStatusText { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + + if (!$task) { + return "MinerOps Agent: task not installed" + } + + return "MinerOps Agent: $($task.State)" +} + +$notify = New-Object System.Windows.Forms.NotifyIcon +$notify.Icon = [System.Drawing.SystemIcons]::Application +$notify.Text = Get-AgentStatusText +$notify.Visible = $true + +$menu = New-Object System.Windows.Forms.ContextMenuStrip + +$itemOpen = $menu.Items.Add("Open Agent GUI") +$itemOpen.Add_Click({ Open-Gui }) + +$itemDashboard = $menu.Items.Add("Open Plexus Dashboard") +$itemDashboard.Add_Click({ Open-Dashboard }) + +$menu.Items.Add("-") | Out-Null + +$itemStart = $menu.Items.Add("Start Agent") +$itemStart.Add_Click({ + Start-Agent + Start-Sleep -Seconds 1 + $notify.Text = Get-AgentStatusText +}) + +$itemStop = $menu.Items.Add("Stop Agent") +$itemStop.Add_Click({ + Stop-Agent + Start-Sleep -Seconds 1 + $notify.Text = Get-AgentStatusText +}) + +$itemRestart = $menu.Items.Add("Restart Agent") +$itemRestart.Add_Click({ + Restart-Agent + Start-Sleep -Seconds 1 + $notify.Text = Get-AgentStatusText +}) + +$menu.Items.Add("-") | Out-Null + +$itemLog = $menu.Items.Add("Open Log") +$itemLog.Add_Click({ Open-Log }) + +$itemExit = $menu.Items.Add("Exit Tray Icon") +$itemExit.Add_Click({ + $notify.Visible = $false + $notify.Dispose() + [System.Windows.Forms.Application]::Exit() +}) + +$notify.ContextMenuStrip = $menu + +$notify.Add_DoubleClick({ + Open-Gui +}) + +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 5000 +$timer.Add_Tick({ + $notify.Text = Get-AgentStatusText +}) +$timer.Start() + +[System.Windows.Forms.Application]::Run() diff --git a/agents/windows/MinerOpsApi3EnrichmentPoller.ps1 b/agents/windows/MinerOpsApi3EnrichmentPoller.ps1 new file mode 100644 index 0000000..1ca18e6 --- /dev/null +++ b/agents/windows/MinerOpsApi3EnrichmentPoller.ps1 @@ -0,0 +1,246 @@ +param( + [int] $Limit = 25, + [int] $TimeoutMs = 2500 +) + +$ErrorActionPreference = "Stop" + +$ConfigPath = "C:\MinerOpsAgent\agent-config.json" + +if (-not (Test-Path $ConfigPath)) { + throw "Missing config file: $ConfigPath" +} + +$Config = Get-Content $ConfigPath -Raw | ConvertFrom-Json +$HubUrl = [string]$Config.hub_url +$HubUrl = $HubUrl.TrimEnd("/") +$AgentId = [string]$Config.agent_id +$SiteName = [string]$Config.site_name +$Secret = [string]$Config.agent_secret + +$Headers = @{ + "X-MinerOps-Agent-Secret" = $Secret +} + +function Invoke-WhatsMinerApi3Read { + param( + [Parameter(Mandatory=$true)] [string] $Ip, + [Parameter(Mandatory=$true)] [hashtable] $Request, + [int] $TimeoutMs = 2500 + ) + + $client = $null + $stream = $null + + try { + $json = ($Request | ConvertTo-Json -Depth 20 -Compress) + $payload = [System.Text.Encoding]::ASCII.GetBytes($json) + $length = [BitConverter]::GetBytes([Int32]$payload.Length) + + $client = New-Object System.Net.Sockets.TcpClient + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + + $connect = $client.BeginConnect($Ip, 4433, $null, $null) + + if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { + try { $client.Close() } catch {} + throw "Timed out connecting to $Ip`:4433" + } + + $client.EndConnect($connect) + + $stream = $client.GetStream() + $stream.ReadTimeout = $TimeoutMs + $stream.WriteTimeout = $TimeoutMs + + $stream.Write($length, 0, 4) + $stream.Write($payload, 0, $payload.Length) + $stream.Flush() + + $lenBuf = New-Object byte[] 4 + $offset = 0 + + while ($offset -lt 4) { + $read = $stream.Read($lenBuf, $offset, 4 - $offset) + + if ($read -le 0) { + throw "No response length received." + } + + $offset += $read + } + + $responseLength = [BitConverter]::ToInt32($lenBuf, 0) + + if ($responseLength -le 0 -or $responseLength -gt 1048576) { + throw "Invalid response length: $responseLength" + } + + $buffer = New-Object byte[] $responseLength + $offset = 0 + + while ($offset -lt $responseLength) { + $read = $stream.Read($buffer, $offset, $responseLength - $offset) + + if ($read -le 0) { + throw "Response ended early at $offset of $responseLength bytes." + } + + $offset += $read + } + + $raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $responseLength) + $parsed = $raw | ConvertFrom-Json + + return [pscustomobject]@{ + ok = $true + parsed = $parsed + error = $null + } + } + catch { + return [pscustomobject]@{ + ok = $false + parsed = $null + error = $_.Exception.Message + } + } + finally { + if ($stream) { try { $stream.Close() } catch {} } + if ($client) { try { $client.Close() } catch {} } + } +} + +function Invoke-Api3OkCommand { + param( + [string] $Ip, + [hashtable] $Request + ) + + $result = Invoke-WhatsMinerApi3Read -Ip $Ip -Request $Request -TimeoutMs $TimeoutMs + + if (-not $result.ok) { + return $null + } + + if ($result.parsed.code -ne 0) { + return $null + } + + return $result.parsed +} + +Write-Host "Fetching known miners from $HubUrl" + +$known = Invoke-RestMethod ` + -Uri "$HubUrl/api/agent/known-miners" ` + -Headers $Headers ` + -Method Get + +$miners = @($known.miners) + +if ($Limit -gt 0) { + $miners = @($miners | Select-Object -First $Limit) +} + +Write-Host "API3 enrichment check count: $($miners.Count)" + +$items = New-Object System.Collections.Generic.List[object] +$checked = 0 +$api3Ok = 0 +$api3Failed = 0 + +foreach ($miner in $miners) { + $checked += 1 + $ip = [string]$miner.ip_address + $mac = [string]$miner.mac_address + + if (-not $ip) { + continue + } + + Write-Host "[$checked/$($miners.Count)] $ip $mac" + + $deviceInfoResult = Invoke-WhatsMinerApi3Read ` + -Ip $ip ` + -Request @{ cmd = "get.device.info" } ` + -TimeoutMs $TimeoutMs + + if (-not $deviceInfoResult.ok -or $deviceInfoResult.parsed.code -ne 0) { + $api3Failed += 1 + + $err = $deviceInfoResult.error + + if ($deviceInfoResult.ok) { + $err = "API3 code $($deviceInfoResult.parsed.code): $($deviceInfoResult.parsed.msg)" + } + + $items.Add([pscustomobject]@{ + ip = $ip + mac = $mac + api3_supported = $false + api3_error = $err + device_info = $null + miner_setting = $null + miner_status = $null + fan_setting = $null + custom_data = $null + }) + + continue + } + + $api3Ok += 1 + + $minerSetting = Invoke-Api3OkCommand ` + -Ip $ip ` + -Request @{ cmd = "get.miner.setting" } + + $minerStatus = Invoke-Api3OkCommand ` + -Ip $ip ` + -Request @{ cmd = "get.miner.status"; param = "summary+pools+edevs" } + + $fanSetting = Invoke-Api3OkCommand ` + -Ip $ip ` + -Request @{ cmd = "get.fan.setting" } + + $customData = Invoke-Api3OkCommand ` + -Ip $ip ` + -Request @{ cmd = "get.device.custom_data" } + + $items.Add([pscustomobject]@{ + ip = $ip + mac = $mac + api3_supported = $true + api3_error = $null + device_info = $deviceInfoResult.parsed + miner_setting = $minerSetting + miner_status = $minerStatus + fan_setting = $fanSetting + custom_data = $customData + }) +} + +$payload = @{ + agent_id = $AgentId + site_name = $SiteName + items = @($items.ToArray()) +} + +$json = $payload | ConvertTo-Json -Depth 80 + +Write-Host "Posting API3 enrichment results to hub..." +$result = Invoke-RestMethod ` + -Uri "$HubUrl/api/agent/api3-enrichment" ` + -Method Post ` + -Headers $Headers ` + -ContentType "application/json" ` + -Body $json + +Write-Host "" +Write-Host "Local checked: $checked" +Write-Host "API3 OK: $api3Ok" +Write-Host "API3 failed: $api3Failed" +Write-Host "" +$result | ConvertTo-Json -Depth 20 diff --git a/agents/windows/MinerOpsPoolPoller.ps1 b/agents/windows/MinerOpsPoolPoller.ps1 new file mode 100644 index 0000000..503d420 --- /dev/null +++ b/agents/windows/MinerOpsPoolPoller.ps1 @@ -0,0 +1,146 @@ +$ErrorActionPreference = "Stop" + +$ConfigPath = "C:\MinerOpsAgent\agent-config.json" +$Config = Get-Content -Raw $ConfigPath | ConvertFrom-Json + +$HubUrl = ($Config.hub_url + "").TrimEnd("/") +$AgentId = $Config.agent_id +$AgentSecret = $Config.agent_secret + +$Headers = @{ + "X-MinerOps-Agent-Secret" = $AgentSecret +} + +function Normalize-MinerOpsIp { + param([string] $Ip) + + if (-not $Ip) { + return "" + } + + return ($Ip -replace "/32$", "").Trim() +} + +function Invoke-MinerOps4028PoolsSimple { + param( + [Parameter(Mandatory=$true)] [string] $Ip, + [int] $Port = 4028, + [int] $TimeoutMs = 5000 + ) + + $client = $null + $stream = $null + + try { + $json = '{"command":"pools"}' + + $client = New-Object System.Net.Sockets.TcpClient + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + $client.Connect($Ip, $Port) + + $stream = $client.GetStream() + $stream.ReadTimeout = $TimeoutMs + $stream.WriteTimeout = $TimeoutMs + + $bytes = [System.Text.Encoding]::ASCII.GetBytes($json) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() + + Start-Sleep -Milliseconds 500 + + $buffer = New-Object byte[] 131072 + $read = $stream.Read($buffer, 0, $buffer.Length) + + if ($read -le 0) { + return $null + } + + $raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read).Trim([char]0).Trim() + + if (-not $raw) { + return $null + } + + $parsed = $raw | ConvertFrom-Json + + if (-not $parsed.POOLS) { + return $null + } + + return @($parsed.POOLS) + } + catch { + return $null + } + finally { + if ($stream) { + try { $stream.Close() } catch {} + } + + if ($client) { + try { $client.Close() } catch {} + } + } +} + +$response = Invoke-RestMethod -Method Get -Uri "$HubUrl/api/agent/known-miners" -Headers $Headers -TimeoutSec 60 + +$knownMiners = @() + +if ($response.miners) { + $knownMiners = @($response.miners) +} elseif ($response.data -and $response.data.miners) { + $knownMiners = @($response.data.miners) +} elseif ($response.data -and $response.data.known_miners) { + $knownMiners = @($response.data.known_miners) +} + +Write-Host "Known miners: $($knownMiners.Count)" + +$poolSnapshots = New-Object System.Collections.Generic.List[object] +$count = 0 + +foreach ($miner in $knownMiners) { + $count++ + + $ip = Normalize-MinerOpsIp -Ip $miner.ip_address + + if (-not $ip) { + continue + } + + if (($count % 25) -eq 0) { + Write-Host "Checked $count / $($knownMiners.Count). Collected $($poolSnapshots.Count)." + } + + $pools = @(Invoke-MinerOps4028PoolsSimple -Ip $ip) + + if ($pools.Count -gt 0) { + $poolSnapshots.Add([pscustomobject]@{ + miner_id = $miner.miner_id + ip_address = $ip + mac_address = $miner.mac_address + model = $miner.model + pools = @($pools) + }) + } +} + +Write-Host "Pool snapshots collected: $($poolSnapshots.Count)" + +$body = @{ + agent_id = $AgentId + pool_snapshots = $poolSnapshots +} | ConvertTo-Json -Depth 20 + +$result = Invoke-RestMethod ` + -Method Post ` + -Uri "$HubUrl/api/agent/pool-snapshots" ` + -Headers $Headers ` + -Body $body ` + -ContentType "application/json" ` + -TimeoutSec 180 + +$result | ConvertTo-Json -Depth 10 + diff --git a/agents/windows/MinerOpsPoolPollerTask.ps1 b/agents/windows/MinerOpsPoolPollerTask.ps1 new file mode 100644 index 0000000..81101db --- /dev/null +++ b/agents/windows/MinerOpsPoolPollerTask.ps1 @@ -0,0 +1,28 @@ +$LogDir = "C:\MinerOpsAgent\logs" +$LogFile = Join-Path $LogDir "pool-poller.log" + +if (-not (Test-Path $LogDir)) { + New-Item -ItemType Directory -Path $LogDir -Force | Out-Null +} + +$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + +try { + Add-Content -Path $LogFile -Value "$stamp Starting MinerOps pool poller." + + $output = powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\MinerOpsAgent\MinerOpsPoolPoller.ps1" 2>&1 + + foreach ($line in $output) { + Add-Content -Path $LogFile -Value "$line" + } + + $stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Add-Content -Path $LogFile -Value "$stamp Pool poller finished." + Add-Content -Path $LogFile -Value "" +} +catch { + $stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Add-Content -Path $LogFile -Value "$stamp Pool poller failed: $($_.Exception.Message)" + Add-Content -Path $LogFile -Value "" + exit 1 +} diff --git a/agents/windows/README.md b/agents/windows/README.md new file mode 100644 index 0000000..3b59509 --- /dev/null +++ b/agents/windows/README.md @@ -0,0 +1,65 @@ +# MinerOps Windows Agent + +The Windows Agent runs at a mining site and performs local discovery, polling, +API3 enrichment, pool polling, and approved queued miner-control operations. +MinerOps does not send API3 credentials through the hub command queue; those +credentials remain in the Agent's local `agent-config.json`. + +## Recovered Baseline + +The seven runtime and support scripts in this directory were recovered from the +live `C:\MinerOpsAgent` installation. `BASELINE_SHA256SUMS` records their exact +pre-change hashes. Historical backups, live configuration, state, logs, and +probe output are intentionally excluded. + +## Local Files + +Create `agent-config.json` from `config.example.json` on the Agent host. Never +commit that file. `site_id` is the durable MinerOps site UUID; `site_name` is +retained only for transitional display compatibility. + +The Agent writes runtime state to `agent-state.json` and logs under `logs/`. +Installation and upgrades preserve all three paths. + +## Security + +API3 account and password values are local secrets. Missing credentials make +miner control unavailable. Passwords, API tokens, salts, and the Agent secret +must not be written to normal logs or returned to MinerOps command history. + +The Internal Alpha supports one Agent identity protected by the deployment +shared secret. A second active Agent requires per-Agent credentials before it +may be enabled. + +## Legacy Compatibility + +The older direct Hub API3, `/agent-control`, password-change, bulk-control, and +scheduling paths remain available for compatibility and are deprecated for the +Internal Alpha. The new Miner Detail controls use only the typed control API and +Agent queue; the modular control backend does not connect directly to miners. + +## Deployment + +Deploy Agent compatibility before enabling Hub enforcement of the +`X-MinerOps-Agent-ID` header: + +1. Copy this directory to a temporary folder on the Windows Agent host. +2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass + secrets as installer or scheduled-task arguments. +3. Run `install.ps1` from an elevated Windows PowerShell session. +4. Confirm the four scheduled tasks are registered and that Agent check-in + reports the expected non-secret `VERSION`. +5. Enable the matching Agent ID and shared secret on the Hub. + +The installer copies runtime code to `C:\MinerOpsAgent`, preserves +`agent-config.json`, `agent-state.json`, and `logs\`, and registers or +updates `MinerOpsAgent`, `MinerOpsAgentTray`, +`MinerOpsApi3Enrichment`, and `MinerOpsPoolPoller`. + +Before replacing existing runtime files, the installer copies them to a +timestamped folder under `C:\MinerOpsAgent\backups`. To roll back, stop the +Agent tasks, copy the selected backup's runtime files back into +`C:\MinerOpsAgent`, and restart the tasks. `uninstall.ps1` unregisters the +four tasks and removes runtime code while preserving local configuration, +state, logs, and backups. + diff --git a/agents/windows/config.example.json b/agents/windows/config.example.json new file mode 100644 index 0000000..ead5687 --- /dev/null +++ b/agents/windows/config.example.json @@ -0,0 +1,21 @@ +{ + "hub_url": "https://minerops.example.invalid", + "agent_id": "replace-with-agent-id", + "site_id": "00000000-0000-0000-0000-000000000000", + "site_name": "Example Site", + "agent_secret": "replace-with-agent-secret", + "poll_interval_seconds": 120, + "ip_ranges": [ + { + "name": "Example Range", + "cidr": "192.0.2.0/24", + "enabled": false, + "api_port": 4028, + "api3_port": 4433 + } + ], + "api3_account": "replace-with-api3-account", + "api3_password": "replace-with-api3-password", + "api3_port": 4433 +} + -- 2.47.3 From f5965af9144d96898433d0d5e7505de44e245c27 Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 24 Aug 2026 20:43:21 +0000 Subject: [PATCH 2/3] feat: add safe miner control foundation --- agents/windows/.gitattributes | 2 + agents/windows/MinerOpsAgentService.ps1 | 637 +++++++++--------- agents/windows/MinerOpsControl.ps1 | 201 ++++++ agents/windows/VERSION | 1 + agents/windows/install.ps1 | 62 ++ agents/windows/tests/Test-MinerOpsControl.ps1 | 84 +++ agents/windows/uninstall.ps1 | 21 + .../versions/0015_miner_control_foundation.py | 137 ++++ backend/app/agent_commands/__init__.py | 1 + backend/app/agent_commands/dependencies.py | 37 + backend/app/agent_commands/exceptions.py | 17 + backend/app/agent_commands/models.py | 57 ++ backend/app/agent_commands/repository.py | 130 ++++ backend/app/agent_commands/router.py | 57 ++ backend/app/agent_commands/service.py | 87 +++ backend/app/config.py | 3 + backend/app/frontend/miner_detail_view.py | 32 + backend/app/frontend/static/app.css | 49 ++ backend/app/frontend/static/miner-detail.js | 146 +++- backend/app/frontend/views.py | 1 + backend/app/identity.py | 11 + backend/app/main.py | 185 +---- backend/app/miner_control/__init__.py | 1 + backend/app/miner_control/dependencies.py | 8 + backend/app/miner_control/exceptions.py | 21 + backend/app/miner_control/models.py | 85 +++ backend/app/miner_control/repository.py | 135 ++++ backend/app/miner_control/router.py | 129 ++++ backend/app/miner_control/service.py | 165 +++++ backend/tests/test_agent_commands.py | 235 +++++++ backend/tests/test_miner_control.py | 227 +++++++ backend/tests/test_miner_control_postgres.py | 247 +++++++ backend/tests/test_miner_detail.py | 15 +- backend/tests/test_system_health.py | 2 +- scripts/seed-development-demo.sh | 4 +- scripts/test-dev-baseline.sh | 2 +- scripts/test-miner-control-migration.sh | 137 ++++ scripts/test-miner-control.sh | 51 ++ scripts/test-windows-agent-source.sh | 21 + 39 files changed, 2952 insertions(+), 491 deletions(-) create mode 100644 agents/windows/MinerOpsControl.ps1 create mode 100644 agents/windows/VERSION create mode 100644 agents/windows/install.ps1 create mode 100644 agents/windows/tests/Test-MinerOpsControl.ps1 create mode 100644 agents/windows/uninstall.ps1 create mode 100644 backend/alembic/versions/0015_miner_control_foundation.py create mode 100644 backend/app/agent_commands/__init__.py create mode 100644 backend/app/agent_commands/dependencies.py create mode 100644 backend/app/agent_commands/exceptions.py create mode 100644 backend/app/agent_commands/models.py create mode 100644 backend/app/agent_commands/repository.py create mode 100644 backend/app/agent_commands/router.py create mode 100644 backend/app/agent_commands/service.py create mode 100644 backend/app/miner_control/__init__.py create mode 100644 backend/app/miner_control/dependencies.py create mode 100644 backend/app/miner_control/exceptions.py create mode 100644 backend/app/miner_control/models.py create mode 100644 backend/app/miner_control/repository.py create mode 100644 backend/app/miner_control/router.py create mode 100644 backend/app/miner_control/service.py create mode 100644 backend/tests/test_agent_commands.py create mode 100644 backend/tests/test_miner_control.py create mode 100644 backend/tests/test_miner_control_postgres.py create mode 100755 scripts/test-miner-control-migration.sh create mode 100755 scripts/test-miner-control.sh create mode 100755 scripts/test-windows-agent-source.sh diff --git a/agents/windows/.gitattributes b/agents/windows/.gitattributes index 9f88a27..9a3e5d8 100644 --- a/agents/windows/.gitattributes +++ b/agents/windows/.gitattributes @@ -1,2 +1,4 @@ *.ps1 -text + +*.ps1 whitespace=cr-at-eol diff --git a/agents/windows/MinerOpsAgentService.ps1 b/agents/windows/MinerOpsAgentService.ps1 index 57a812f..8d9e97f 100644 --- a/agents/windows/MinerOpsAgentService.ps1 +++ b/agents/windows/MinerOpsAgentService.ps1 @@ -55,6 +55,7 @@ function Invoke-AgentApi { $headers = @{ "X-MinerOps-Agent-Secret" = $secret + "X-MinerOps-Agent-ID" = [string]$config.agent_id } if ($Body -ne $null) { @@ -94,7 +95,7 @@ function Get-PendingAgentCommands { $config = Read-AgentConfig $agentId = [string]$config.agent_id - $result = Invoke-AgentApi -Method "GET" -Path "/api/agent/commands/pending?agent_id=$agentId" + $result = Invoke-AgentApi -Method "GET" -Path "/api/agent/commands/pending" if ($result.commands) { return @($result.commands) @@ -981,8 +982,8 @@ function Invoke-MinerOpsApi3Service { [string]$ServiceAction ) - $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "super" - $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "super" + $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "" + $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "" $portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433" try { @@ -1019,322 +1020,322 @@ function Invoke-MinerOpsApi3Service { return $result } - -function Normalize-MinerOpsIpAddress { - param( - [string]$IpAddress - ) - - $value = ([string]$IpAddress).Trim() - - if ($value.Contains("/")) { - $value = $value.Split("/")[0] - } - - return $value -} - - -function Invoke-MinerOpsApi3AuthenticatedCommand { - param( - [Parameter(Mandatory=$true)] - [string]$IpAddress, - - [Parameter(Mandatory=$true)] - [string]$Command, - - [object]$Param = $null - ) - - $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "super" - $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "super" - $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 { - param( - [Parameter(Mandatory=$true)] $Payload, - [Parameter(Mandatory=$true)] [string] $Name - ) - - if ($null -eq $Payload) { - return $null - } - - if ($Payload -is [hashtable]) { - if ($Payload.ContainsKey($Name)) { - return $Payload[$Name] - } - - return $null - } - - $property = $Payload.PSObject.Properties[$Name] - - if ($property) { - return $property.Value - } - - return $null -} - -function Invoke-MinerOpsLegacy4028Command { - param( - [Parameter(Mandatory=$true)] [string] $IpAddress, - [Parameter(Mandatory=$true)] [string] $CommandJson, - [int] $Port = 4028, - [int] $TimeoutMs = 7000 - ) - - $normalizedIp = Normalize-MinerOpsIpAddress -IpAddress $IpAddress - - $client = New-Object System.Net.Sockets.TcpClient - $connectResult = $client.BeginConnect($normalizedIp, $Port, $null, $null) - - if (-not $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { - try { $client.Close() } catch {} - throw "Legacy 4028 connection timed out to $normalizedIp`:$Port" - } - - $client.EndConnect($connectResult) - - try { - $stream = $client.GetStream() - $stream.ReadTimeout = $TimeoutMs - $stream.WriteTimeout = $TimeoutMs - - $bytes = [System.Text.Encoding]::ASCII.GetBytes($CommandJson) - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush() - - Start-Sleep -Milliseconds 500 - - $buffer = New-Object byte[] 65535 - $builder = New-Object System.Text.StringBuilder - - do { - $read = $stream.Read($buffer, 0, $buffer.Length) - - if ($read -gt 0) { - [void]$builder.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)) - } - } while ($stream.DataAvailable) - - $rawResponse = $builder.ToString().Trim([char]0).Trim() - - $parsedResponse = $null - - try { - if ($rawResponse) { - $parsedResponse = $rawResponse | ConvertFrom-Json - } - } catch { - $parsedResponse = $null - } - - return @{ - success = $true - control_method = "legacy_4028" - port = $Port - request_json = $CommandJson - raw_response = $rawResponse - parsed_response = $parsedResponse - } - } - finally { - try { $stream.Close() } catch {} - try { $client.Close() } catch {} - } -} - -function Invoke-MinerOpsM30Legacy4028Test { - param( - [Parameter(Mandatory=$true)] $Payload - ) - - if ($Payload -is [string]) { - $Payload = $Payload | ConvertFrom-Json - } - - $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "ip_address" - - if (-not $ipAddress) { - $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "current_ip" - } - - if (-not $ipAddress) { - throw "Legacy 4028 test failed: no IP address in payload." - } - - $legacyJson = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_json" - $legacyCommand = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_command" - - if (-not $legacyJson) { - if (-not $legacyCommand) { - throw "Legacy 4028 test failed: no legacy command in payload." - } - - $legacyJson = '{"command":"' + $legacyCommand + '"}' - } - - Write-AgentLog "Running legacy 4028 M30 test against $ipAddress command=$legacyCommand" - - $result = Invoke-MinerOpsLegacy4028Command -IpAddress $ipAddress -CommandJson $legacyJson -Port 4028 - - return @{ - success = $true - action = "legacy_4028" - test_lab = "m30" - ip_address = $ipAddress - legacy_4028_command = $legacyCommand - legacy_4028_request = $legacyJson - legacy_4028_response = $result.raw_response - legacy_4028_parsed = $result.parsed_response - control_method = "legacy_4028" - } -} + +function Normalize-MinerOpsIpAddress { + param( + [string]$IpAddress + ) + + $value = ([string]$IpAddress).Trim() + + if ($value.Contains("/")) { + $value = $value.Split("/")[0] + } + + return $value +} + + +function Invoke-MinerOpsApi3AuthenticatedCommand { + param( + [Parameter(Mandatory=$true)] + [string]$IpAddress, + + [Parameter(Mandatory=$true)] + [string]$Command, + + [object]$Param = $null + ) + + $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "" + $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "" + $portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433" + + try { + $port = [int]$portRaw + } catch { + $port = 4433 + } + + $saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $port + $salt = [string]$saltInfo.salt + $timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $token = Get-MinerOpsApi3Token -Command $Command -Password $password -Salt $salt -Timestamp $timestamp + + $payload = @{ + cmd = $Command + ts = $timestamp + token = $token + account = $account + } + + if ($null -ne $Param) { + $payload["param"] = $Param + } + + $result = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $port -Payload $payload + + $result["salt_response"] = $saltInfo.salt_response + $result["api3_command"] = $Command + $result["api3_param"] = $Param + $result["api3_account"] = $account + + if ([int]$result.response.code -ne 0) { + throw "API3 $Command failed: $($result.response | ConvertTo-Json -Compress)" + } + + return $result +} + +function Get-MinerOpsPayloadValue { + param( + [Parameter(Mandatory=$true)] $Payload, + [Parameter(Mandatory=$true)] [string] $Name + ) + + if ($null -eq $Payload) { + return $null + } + + if ($Payload -is [hashtable]) { + if ($Payload.ContainsKey($Name)) { + return $Payload[$Name] + } + + return $null + } + + $property = $Payload.PSObject.Properties[$Name] + + if ($property) { + return $property.Value + } + + return $null +} + +function Invoke-MinerOpsLegacy4028Command { + param( + [Parameter(Mandatory=$true)] [string] $IpAddress, + [Parameter(Mandatory=$true)] [string] $CommandJson, + [int] $Port = 4028, + [int] $TimeoutMs = 7000 + ) + + $normalizedIp = Normalize-MinerOpsIpAddress -IpAddress $IpAddress + + $client = New-Object System.Net.Sockets.TcpClient + $connectResult = $client.BeginConnect($normalizedIp, $Port, $null, $null) + + if (-not $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { + try { $client.Close() } catch {} + throw "Legacy 4028 connection timed out to $normalizedIp`:$Port" + } + + $client.EndConnect($connectResult) + + try { + $stream = $client.GetStream() + $stream.ReadTimeout = $TimeoutMs + $stream.WriteTimeout = $TimeoutMs + + $bytes = [System.Text.Encoding]::ASCII.GetBytes($CommandJson) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() + + Start-Sleep -Milliseconds 500 + + $buffer = New-Object byte[] 65535 + $builder = New-Object System.Text.StringBuilder + + do { + $read = $stream.Read($buffer, 0, $buffer.Length) + + if ($read -gt 0) { + [void]$builder.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)) + } + } while ($stream.DataAvailable) + + $rawResponse = $builder.ToString().Trim([char]0).Trim() + + $parsedResponse = $null + + try { + if ($rawResponse) { + $parsedResponse = $rawResponse | ConvertFrom-Json + } + } catch { + $parsedResponse = $null + } + + return @{ + success = $true + control_method = "legacy_4028" + port = $Port + request_json = $CommandJson + raw_response = $rawResponse + parsed_response = $parsedResponse + } + } + finally { + try { $stream.Close() } catch {} + try { $client.Close() } catch {} + } +} + +function Invoke-MinerOpsM30Legacy4028Test { + param( + [Parameter(Mandatory=$true)] $Payload + ) + + if ($Payload -is [string]) { + $Payload = $Payload | ConvertFrom-Json + } + + $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "ip_address" + + if (-not $ipAddress) { + $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "current_ip" + } + + if (-not $ipAddress) { + throw "Legacy 4028 test failed: no IP address in payload." + } + + $legacyJson = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_json" + $legacyCommand = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_command" + + if (-not $legacyJson) { + if (-not $legacyCommand) { + throw "Legacy 4028 test failed: no legacy command in payload." + } + + $legacyJson = '{"command":"' + $legacyCommand + '"}' + } + + Write-AgentLog "Running legacy 4028 M30 test against $ipAddress command=$legacyCommand" + + $result = Invoke-MinerOpsLegacy4028Command -IpAddress $ipAddress -CommandJson $legacyJson -Port 4028 + + return @{ + success = $true + action = "legacy_4028" + test_lab = "m30" + ip_address = $ipAddress + legacy_4028_command = $legacyCommand + legacy_4028_request = $legacyJson + legacy_4028_response = $result.raw_response + legacy_4028_parsed = $result.parsed_response + control_method = "legacy_4028" + } +} function Run-MinerControlCommand { - - param( - [Parameter(Mandatory=$true)] - [object]$Payload + + param( + [Parameter(Mandatory=$true)] + [object]$Payload ) - # MinerOps M30 legacy 4028 test path - if ($Payload -is [string]) { - try { - $Payload = $Payload | ConvertFrom-Json - } catch { - } - } - - if ($Payload -and (($Payload.control_method -eq "legacy_4028") -or ($Payload.action -eq "legacy_4028"))) { - return Invoke-MinerOpsM30Legacy4028Test -Payload $Payload - } - - - if ($Payload -is [string]) { - $Payload = $Payload | ConvertFrom-Json - } - - if ($null -eq $Payload) { - throw "Miner control payload was empty." - } - - $action = ([string]$Payload.action).ToLower() - $minerId = [string]$Payload.miner_id - $macAddress = [string]$Payload.mac_address - $dryRun = [bool]$Payload.dry_run - - $ipAddress = ([string]$Payload.ip_address).Trim() - - if ($ipAddress.Contains("/")) { - $ipAddress = $ipAddress.Split("/")[0] - } - - if ([string]::IsNullOrWhiteSpace($ipAddress)) { - throw "Miner control payload did not include ip_address." - } - - $serviceAction = "" - $api3Command = "" - $api3Param = $null - $performanceMode = "" - - if ($action -eq "reboot") { - $serviceAction = "restart" - $api3Command = "set.miner.service" - } elseif ($action -eq "suspend") { - $serviceAction = "stop" - $api3Command = "set.miner.service" - } elseif ($action -eq "resume") { - $serviceAction = "start" - $api3Command = "set.miner.service" - } elseif ($action -eq "performance_mode") { - $performanceMode = ([string]$Payload.performance_mode).ToLower() - - if ($performanceMode -in @("low", "normal", "high")) { - $api3Command = "set.miner.power_mode" - $api3Param = $performanceMode - } else { - throw "Invalid performance_mode: $performanceMode" - } - } else { - throw "Unsupported miner control action: $action" - } - - Write-AgentLog "Miner control requested. Action=$action API3Command=$api3Command API3Service=$serviceAction PerformanceMode=$performanceMode IP=$ipAddress MAC=$macAddress DryRun=$dryRun" - - if ($dryRun) { - return @{ - ok = $true - dry_run = $true - action = $action - api3_command = $api3Command - api3_service_action = $serviceAction - performance_mode = $performanceMode - ip_address = $ipAddress - miner_id = $minerId - mac_address = $macAddress - message = "Dry run only. No miner command sent." - } - } - - if ($action -eq "performance_mode") { - $api3Result = Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress $ipAddress -Command $api3Command -Param $api3Param - } else { - $api3Result = Invoke-MinerOpsApi3Service -IpAddress $ipAddress -ServiceAction $serviceAction - } - - return @{ - ok = $true - dry_run = $false - action = $action - api3_command = $api3Command - api3_service_action = $serviceAction - performance_mode = $performanceMode - ip_address = $ipAddress - miner_id = $minerId - mac_address = $macAddress - message = "API3 miner control command sent by Windows Agent." - api3_result = $api3Result - } -} + # MinerOps M30 legacy 4028 test path + if ($Payload -is [string]) { + try { + $Payload = $Payload | ConvertFrom-Json + } catch { + } + } + + if ($Payload -and (($Payload.control_method -eq "legacy_4028") -or ($Payload.action -eq "legacy_4028"))) { + return Invoke-MinerOpsM30Legacy4028Test -Payload $Payload + } + + + if ($Payload -is [string]) { + $Payload = $Payload | ConvertFrom-Json + } + + if ($null -eq $Payload) { + throw "Miner control payload was empty." + } + + $action = ([string]$Payload.action).ToLower() + $minerId = [string]$Payload.miner_id + $macAddress = [string]$Payload.mac_address + $dryRun = [bool]$Payload.dry_run + + $ipAddress = ([string]$Payload.ip_address).Trim() + + if ($ipAddress.Contains("/")) { + $ipAddress = $ipAddress.Split("/")[0] + } + + if ([string]::IsNullOrWhiteSpace($ipAddress)) { + throw "Miner control payload did not include ip_address." + } + + $serviceAction = "" + $api3Command = "" + $api3Param = $null + $performanceMode = "" + + if ($action -eq "reboot") { + $serviceAction = "restart" + $api3Command = "set.miner.service" + } elseif ($action -eq "suspend") { + $serviceAction = "stop" + $api3Command = "set.miner.service" + } elseif ($action -eq "resume") { + $serviceAction = "start" + $api3Command = "set.miner.service" + } elseif ($action -eq "performance_mode") { + $performanceMode = ([string]$Payload.performance_mode).ToLower() + + if ($performanceMode -in @("low", "normal", "high")) { + $api3Command = "set.miner.power_mode" + $api3Param = $performanceMode + } else { + throw "Invalid performance_mode: $performanceMode" + } + } else { + throw "Unsupported miner control action: $action" + } + + Write-AgentLog "Miner control requested. Action=$action API3Command=$api3Command API3Service=$serviceAction PerformanceMode=$performanceMode IP=$ipAddress MAC=$macAddress DryRun=$dryRun" + + if ($dryRun) { + return @{ + ok = $true + dry_run = $true + action = $action + api3_command = $api3Command + api3_service_action = $serviceAction + performance_mode = $performanceMode + ip_address = $ipAddress + miner_id = $minerId + mac_address = $macAddress + message = "Dry run only. No miner command sent." + } + } + + if ($action -eq "performance_mode") { + $api3Result = Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress $ipAddress -Command $api3Command -Param $api3Param + } else { + $api3Result = Invoke-MinerOpsApi3Service -IpAddress $ipAddress -ServiceAction $serviceAction + } + + return @{ + ok = $true + dry_run = $false + action = $action + api3_command = $api3Command + api3_service_action = $serviceAction + performance_mode = $performanceMode + ip_address = $ipAddress + miner_id = $minerId + mac_address = $macAddress + message = "API3 miner control command sent by Windows Agent." + api3_result = $api3Result + } +} function Process-PendingCommands { @@ -1416,6 +1417,10 @@ function Process-PendingCommands { return $commands.Count } +$controlModule = Join-Path $PSScriptRoot "MinerOpsControl.ps1" +if (!(Test-Path $controlModule)) { throw "Missing typed control module: $controlModule" } +. $controlModule + Write-AgentLog "MinerOps Agent service script started." Save-AgentState -Status "starting" -Message "Agent starting." diff --git a/agents/windows/MinerOpsControl.ps1 b/agents/windows/MinerOpsControl.ps1 new file mode 100644 index 0000000..55387d7 --- /dev/null +++ b/agents/windows/MinerOpsControl.ps1 @@ -0,0 +1,201 @@ +# Typed MinerOps miner-control execution. Loaded by MinerOpsAgentService.ps1. + +function Get-MinerOpsAgentVersion { + $versionPath = Join-Path $PSScriptRoot "VERSION" + if (Test-Path $versionPath) { + return ([string](Get-Content $versionPath -Raw)).Trim() + } + return "unknown" +} + +function Get-MinerOpsControlCredentials { + $config = Read-AgentConfig + $account = ([string]$config.api3_account).Trim() + $password = [string]$config.api3_password + $port = 4433 + if ($config.api3_port) { + try { $port = [int]$config.api3_port } catch { $port = 4433 } + } + return @{ + available = (-not [string]::IsNullOrWhiteSpace($account)) -and + (-not [string]::IsNullOrWhiteSpace($password)) + account = $account + password = $password + port = $port + } +} + +function Invoke-MinerOpsControlApi3Command { + param( + [Parameter(Mandatory=$true)][string]$IpAddress, + [Parameter(Mandatory=$true)][string]$Command, + [object]$Param = $null + ) + $credentials = Get-MinerOpsControlCredentials + if (-not $credentials.available) { throw "credentials_missing" } + + $saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $credentials.port + $timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $token = Get-MinerOpsApi3Token -Command $Command -Password $credentials.password ` + -Salt ([string]$saltInfo.salt) -Timestamp $timestamp + $payload = @{ + cmd = $Command + ts = $timestamp + token = $token + account = $credentials.account + } + if ($null -ne $Param) { $payload["param"] = $Param } + $response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress ` + -Port $credentials.port -Payload $payload + if ([int]$response.response.code -ne 0) { throw "command_rejected" } + return $response +} + +function New-MinerOpsControlResult { + param( + [bool]$Accepted, + [bool]$VerificationAttempted, + [string]$VerificationStatus, + [string]$ObservedMode = "", + [string]$FailureCode = "", + [string]$QueueStatus = "completed" + ) + return [ordered]@{ + schema_version = 1 + command_accepted = $Accepted + verification_attempted = $VerificationAttempted + verification_status = $VerificationStatus + observed_service_state = $null + observed_performance_mode = if ($ObservedMode) { $ObservedMode } else { $null } + failure_code = if ($FailureCode) { $FailureCode } else { $null } + _queue_status = $QueueStatus + } +} + +function Run-MinerControlCommand { + param([Parameter(Mandatory=$true)][object]$Payload) + + try { + if ($Payload -is [string]) { $Payload = $Payload | ConvertFrom-Json } + $action = ([string]$Payload.action).Trim().ToLower() + $ipAddress = ([string]$Payload.ip_address).Trim() + $requested = ([string]$Payload.requested_value).Trim().ToLower() + if (-not $requested -and $Payload.performance_mode) { + $requested = ([string]$Payload.performance_mode).Trim().ToLower() + } + if ($action -notin @("suspend", "resume", "performance_mode")) { + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode "invalid_action" -QueueStatus "failed" + } + if ([string]::IsNullOrWhiteSpace($ipAddress)) { + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode "target_missing" -QueueStatus "failed" + } + $credentials = Get-MinerOpsControlCredentials + if (-not $credentials.available) { + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode "credentials_missing" -QueueStatus "failed" + } + + if ($action -eq "performance_mode") { + if ($requested -notin @("low", "normal", "high")) { + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode "invalid_mode" -QueueStatus "failed" + } + Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress ` + -Command "set.miner.power_mode" -Param $requested | Out-Null + + $observed = "" + $readUnavailable = $false + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + $read = Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress ` + -Command "get.miner.setting" + $candidate = ([string]$read.response.msg.'power-mode').Trim().ToLower() + if ($candidate -in @("low", "normal", "high")) { + $observed = $candidate + if ($observed -eq $requested) { + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $true -VerificationStatus "confirmed" ` + -ObservedMode $observed + } + } + } catch { + if ($_.Exception.Message -match "unsupported|unknown_command") { + $readUnavailable = $true + break + } + } + if ($attempt -lt 3) { Start-Sleep -Milliseconds 750 } + } + if ($readUnavailable) { + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $true -VerificationStatus "unavailable" + } + if ($observed) { + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $true ` + -VerificationStatus "verification_failed" -ObservedMode $observed + } + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $true -VerificationStatus "unconfirmed" + } + + $serviceAction = if ($action -eq "suspend") { "stop" } else { "start" } + Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress ` + -Command "set.miner.service" -Param $serviceAction | Out-Null + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $false -VerificationStatus "unconfirmed" + } catch { + $failure = if ($_.Exception.Message -eq "credentials_missing") { + "credentials_missing" + } else { "write_rejected" } + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode $failure -QueueStatus "failed" + } +} + +function Send-AgentCommandResult { + param( + [string]$CommandId, + [string]$Status, + [object]$ResultPayload = $null, + [string]$ErrorMessage = $null + ) + if ($ResultPayload -and $ResultPayload._queue_status) { + $Status = [string]$ResultPayload._queue_status + $ResultPayload.Remove("_queue_status") + $ErrorMessage = if ($ResultPayload.failure_code) { + [string]$ResultPayload.failure_code + } else { $null } + } + $body = [ordered]@{ + command_id = $CommandId + status = $Status + result_payload = $ResultPayload + error_message = $ErrorMessage + } + return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body +} + +function Send-AgentCheckin { + $config = Read-AgentConfig + $credentials = Get-MinerOpsControlCredentials + $body = [ordered]@{ + agent_id = [string]$config.agent_id + site_id = [string]$config.site_id + site_name = [string]$config.site_name + hostname = $env:COMPUTERNAME + version = Get-MinerOpsAgentVersion + ip_address = "" + control_available = [bool]$credentials.available + control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" } + local_time = (Get-Date).ToString("o") + } + return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body +} diff --git a/agents/windows/VERSION b/agents/windows/VERSION new file mode 100644 index 0000000..dadcca1 --- /dev/null +++ b/agents/windows/VERSION @@ -0,0 +1 @@ +1.0.0-alpha diff --git a/agents/windows/install.ps1 b/agents/windows/install.ps1 new file mode 100644 index 0000000..a0abec4 --- /dev/null +++ b/agents/windows/install.ps1 @@ -0,0 +1,62 @@ +#Requires -RunAsAdministrator +$ErrorActionPreference = "Stop" + +$Target = "C:\MinerOpsAgent" +$BackupRoot = Join-Path $Target "backups" +$Backup = Join-Path $BackupRoot (Get-Date -Format "yyyyMMdd-HHmmss") +$RuntimeFiles = @( + "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", + "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", + "MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1", + "VERSION" +) + +New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null +$existing = @($RuntimeFiles | Where-Object { Test-Path (Join-Path $Target $_) }) +if ($existing.Count -gt 0) { + New-Item -ItemType Directory -Force -Path $Backup | Out-Null + foreach ($name in $existing) { + Copy-Item (Join-Path $Target $name) (Join-Path $Backup $name) -Force + } +} + +try { + foreach ($name in $RuntimeFiles) { + $source = Join-Path $PSScriptRoot $name + if (!(Test-Path $source)) { throw "Deployment source is missing $name" } + Copy-Item $source (Join-Path $Target $name) -Force + } + + $powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + $system = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + $startup = New-ScheduledTaskTrigger -AtStartup + $fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 5) + + $definitions = @( + @{ Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"; Trigger = $startup; Principal = $system }, + @{ Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"; Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null }, + @{ Name = "MinerOpsApi3Enrichment"; Script = "MinerOpsApi3EnrichmentPoller.ps1"; Trigger = $fiveMinutes; Principal = $system }, + @{ Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"; Trigger = $fiveMinutes; Principal = $system } + ) + foreach ($definition in $definitions) { + $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f ` + (Join-Path $Target $definition.Script) + $action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments + if ($definition.Principal) { + Register-ScheduledTask -TaskName $definition.Name -Action $action ` + -Trigger $definition.Trigger -Principal $definition.Principal -Force | Out-Null + } else { + Register-ScheduledTask -TaskName $definition.Name -Action $action ` + -Trigger $definition.Trigger -Force | Out-Null + } + } + Write-Host "MinerOps Windows Agent installed. Existing config, state, and logs were preserved." +} catch { + if (Test-Path $Backup) { + foreach ($name in $existing) { + Copy-Item (Join-Path $Backup $name) (Join-Path $Target $name) -Force + } + } + throw +} diff --git a/agents/windows/tests/Test-MinerOpsControl.ps1 b/agents/windows/tests/Test-MinerOpsControl.ps1 new file mode 100644 index 0000000..c1cf328 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsControl.ps1 @@ -0,0 +1,84 @@ +$ErrorActionPreference = "Stop" +$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 } +$script:calls = @() +$script:outcomes = @() +$script:outcomeIndex = 0 + +function Read-AgentConfig { return $script:config } +. (Join-Path (Split-Path $PSScriptRoot -Parent) "MinerOpsControl.ps1") + +function Invoke-MinerOpsControlApi3Command { + param([string]$IpAddress, [string]$Command, [object]$Param = $null) + $script:calls += [pscustomobject]@{ command = $Command; param = $Param } + $outcome = $script:outcomes[$script:outcomeIndex] + $script:outcomeIndex += 1 + if ($outcome -is [System.Exception]) { throw $outcome } + return $outcome +} + +function Assert-Equal($Actual, $Expected, [string]$Message) { + if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." } +} +function Reset-Harness([object[]]$Outcomes) { + $script:calls = @() + $script:outcomes = @($Outcomes) + $script:outcomeIndex = 0 +} +function Response([string]$Mode = "") { + return @{ response = @{ code = 0; msg = @{ "power-mode" = $Mode } } } +} + +$payload = @{ action = "performance_mode"; requested_value = "normal"; ip_address = "192.0.2.1" } + +$script:config = [pscustomobject]@{ api3_account = ""; api3_password = ""; api3_port = 4433 } +Reset-Harness @() +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.failure_code "credentials_missing" "Missing credentials code" +Assert-Equal $script:calls.Count 0 "Missing credentials must not contact API3" + +$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 } +Reset-Harness @((Response), (Response "normal")) +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.verification_status "confirmed" "Performance confirmation" +Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "Exactly one write" + +Reset-Harness @((Response), (Response "low"), (Response "low"), (Response "low")) +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.verification_status "verification_failed" "Contradictory mode" +Assert-Equal $script:calls.Count 4 "One write and three bounded reads" + +Reset-Harness @((Response), (Response), (Response), (Response)) +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.verification_status "unconfirmed" "Ambiguous reads" + +Reset-Harness @((Response), [System.Exception]::new("unknown_command")) +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.verification_status "unavailable" "Unavailable read path" + +foreach ($action in @("suspend", "resume")) { + Reset-Harness @((Response)) + $result = Run-MinerControlCommand -Payload @{ + action = $action; requested_value = $null; ip_address = "192.0.2.1" + } + Assert-Equal $result.command_accepted $true "$action accepted" + Assert-Equal $result.verification_status "unconfirmed" "$action remains unconfirmed" + Assert-Equal $result.observed_service_state $null "$action has no invented state" + Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "$action one write" +} + +Reset-Harness @([System.Exception]::new("vendor response included secret")) +$result = Run-MinerControlCommand -Payload @{ + action = "suspend"; requested_value = $null; ip_address = "192.0.2.1" +} +Assert-Equal $result.failure_code "write_rejected" "Write rejection is sanitized" +$json = $result | ConvertTo-Json -Compress +if ($json -match "vendor|secret|192\\.0\\.2\\.1") { throw "Typed result leaked raw data" } + +Reset-Harness @() +$result = Run-MinerControlCommand -Payload @{ + action = "performance_mode"; requested_value = "turbo"; ip_address = "192.0.2.1" +} +Assert-Equal $result.failure_code "invalid_mode" "Invalid mode code" +Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3" + +Write-Host "MinerOps typed Agent control tests passed." diff --git a/agents/windows/uninstall.ps1 b/agents/windows/uninstall.ps1 new file mode 100644 index 0000000..0de7e6d --- /dev/null +++ b/agents/windows/uninstall.ps1 @@ -0,0 +1,21 @@ +#Requires -RunAsAdministrator +$ErrorActionPreference = "Stop" + +$Target = "C:\MinerOpsAgent" +$Tasks = @( + "MinerOpsAgent", "MinerOpsAgentTray", "MinerOpsApi3Enrichment", "MinerOpsPoolPoller" +) +foreach ($name in $Tasks) { + Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue +} + +$RuntimeFiles = @( + "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", + "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", + "MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1", + "VERSION" +) +foreach ($name in $RuntimeFiles) { + Remove-Item (Join-Path $Target $name) -Force -ErrorAction SilentlyContinue +} +Write-Host "MinerOps Agent tasks and code removed. Config, state, logs, and backups were preserved." diff --git a/backend/alembic/versions/0015_miner_control_foundation.py b/backend/alembic/versions/0015_miner_control_foundation.py new file mode 100644 index 0000000..583e9c1 --- /dev/null +++ b/backend/alembic/versions/0015_miner_control_foundation.py @@ -0,0 +1,137 @@ +"""add miner control foundation + +Revision ID: 0015_miner_control_foundation +Revises: 0014_repair_parts_tracking +Create Date: 2026-08-24 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0015_miner_control_foundation" +down_revision: Union[str, None] = "0014_repair_parts_tracking" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +QUEUE_STATUSES = ("pending", "claimed", "completed", "failed", "cancelled") + + +def _format_values(values: object) -> str: + return ", ".join(str(value) for value in (values or [])) + + +def upgrade() -> None: + connection = op.get_bind() + unknown_statuses = connection.execute( + sa.text( + """ + SELECT array_agg(DISTINCT command_status ORDER BY command_status) + FROM minerops_agent_command_queue + WHERE command_status NOT IN ('pending', 'claimed', 'completed', 'failed', 'cancelled') + """ + ), + ).scalar() + if unknown_statuses: + raise RuntimeError( + "Cannot upgrade to 0015: minerops_agent_command_queue contains " + f"unsupported command_status values: {_format_values(unknown_statuses)}. " + "Correct those rows to pending, claimed, completed, failed, or cancelled " + "before retrying the migration." + ) + + duplicate_live_miners = connection.execute( + sa.text( + """ + SELECT request_payload ->> 'miner_id' AS miner_id + FROM minerops_agent_command_queue + WHERE command_type = 'miner_control' + AND command_status IN ('pending', 'claimed') + AND nullif(trim(request_payload ->> 'miner_id'), '') IS NOT NULL + GROUP BY request_payload ->> 'miner_id' + HAVING count(*) > 1 + ORDER BY request_payload ->> 'miner_id' + """ + ) + ).scalars().all() + if duplicate_live_miners: + raise RuntimeError( + "Cannot upgrade to 0015: more than one pending or claimed miner_control " + "command exists for miner UUID(s): " + f"{_format_values(duplicate_live_miners)}. Resolve or cancel the duplicate " + "live commands before retrying the migration; no queue history was changed." + ) + + op.add_column( + "minerops_site_agents", + sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_minerops_site_agents_site_id", + "minerops_site_agents", + "sites", + ["site_id"], + ["id"], + ondelete="RESTRICT", + ) + op.create_index( + "uq_minerops_site_agents_site_id", + "minerops_site_agents", + ["site_id"], + unique=True, + postgresql_where=sa.text("site_id IS NOT NULL"), + ) + op.create_check_constraint( + "ck_minerops_agent_command_queue_status", + "minerops_agent_command_queue", + "command_status IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')", + ) + op.create_index( + "uq_agent_queue_live_miner_control", + "minerops_agent_command_queue", + [sa.text("(request_payload ->> 'miner_id')")], + unique=True, + postgresql_where=sa.text( + "command_type = 'miner_control' " + "AND command_status IN ('pending', 'claimed')" + ), + ) + op.create_index( + "idx_agent_queue_miner_control_history", + "minerops_agent_command_queue", + [ + sa.text("(request_payload ->> 'miner_id')"), + sa.text("created_at DESC"), + sa.text("id DESC"), + ], + postgresql_where=sa.text("command_type = 'miner_control'"), + ) + + +def downgrade() -> None: + op.drop_index( + "idx_agent_queue_miner_control_history", + table_name="minerops_agent_command_queue", + ) + op.drop_index( + "uq_agent_queue_live_miner_control", + table_name="minerops_agent_command_queue", + ) + op.drop_constraint( + "ck_minerops_agent_command_queue_status", + "minerops_agent_command_queue", + type_="check", + ) + op.drop_index( + "uq_minerops_site_agents_site_id", + table_name="minerops_site_agents", + ) + op.drop_constraint( + "fk_minerops_site_agents_site_id", + "minerops_site_agents", + type_="foreignkey", + ) + op.drop_column("minerops_site_agents", "site_id") diff --git a/backend/app/agent_commands/__init__.py b/backend/app/agent_commands/__init__.py new file mode 100644 index 0000000..30c60af --- /dev/null +++ b/backend/app/agent_commands/__init__.py @@ -0,0 +1 @@ +"""Authenticated Agent command transport for MinerOps.""" diff --git a/backend/app/agent_commands/dependencies.py b/backend/app/agent_commands/dependencies.py new file mode 100644 index 0000000..94c1bd7 --- /dev/null +++ b/backend/app/agent_commands/dependencies.py @@ -0,0 +1,37 @@ +"""Dependencies for the authenticated Agent queue protocol.""" + +import hmac +from typing import Annotated + +from fastapi import Header, HTTPException + +from app.agent_commands.repository import AgentCommandRepository +from app.agent_commands.service import AgentCommandService +from app.config import settings + + +def get_agent_command_service() -> AgentCommandService: + return AgentCommandService(AgentCommandRepository()) + + +def require_agent_identity( + agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None, + provided_secret: Annotated[ + str | None, Header(alias="X-MinerOps-Agent-Secret") + ] = None, +) -> str: + expected_id = settings.minerops_agent_id.strip() + expected_secret = settings.minerops_agent_shared_secret.strip() + if not expected_id or not expected_secret: + raise HTTPException(status_code=503, detail="Agent authentication is not configured") + + secret = (provided_secret or "").strip() + if not secret or not hmac.compare_digest(secret, expected_secret): + raise HTTPException(status_code=401, detail="Invalid or missing Agent secret") + + identity = (agent_id or "").strip() + if not identity: + raise HTTPException(status_code=401, detail="Missing Agent identity") + if not hmac.compare_digest(identity, expected_id): + raise HTTPException(status_code=403, detail="Agent identity is not authorized") + return identity diff --git a/backend/app/agent_commands/exceptions.py b/backend/app/agent_commands/exceptions.py new file mode 100644 index 0000000..3b7aa2d --- /dev/null +++ b/backend/app/agent_commands/exceptions.py @@ -0,0 +1,17 @@ +"""Domain-neutral Agent command protocol errors.""" + + +class AgentCommandError(Exception): + """Base Agent command protocol error.""" + + +class AgentCommandNotFound(AgentCommandError): + """The submitted command identifier is unknown.""" + + +class AgentCommandConflict(AgentCommandError): + """The queue row cannot accept the requested transition.""" + + +class AgentResultValidationError(AgentCommandError): + """The Agent result does not match its command contract.""" diff --git a/backend/app/agent_commands/models.py b/backend/app/agent_commands/models.py new file mode 100644 index 0000000..dd8585f --- /dev/null +++ b/backend/app/agent_commands/models.py @@ -0,0 +1,57 @@ +"""Typed contracts for the Agent queue protocol.""" + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class AgentCommand(BaseModel): + command_id: str + command_type: str + request_payload: dict[str, Any] + created_at: datetime + + +class AgentCommandListResponse(BaseModel): + ok: Literal[True] = True + commands: list[AgentCommand] + + +class MinerControlResultPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] + command_accepted: bool + verification_attempted: bool + verification_status: Literal[ + "confirmed", "verification_failed", "unconfirmed", "unavailable" + ] + observed_service_state: None = None + observed_performance_mode: Literal["low", "normal", "high"] | None = None + failure_code: str | None = Field( + default=None, max_length=80, pattern=r"^[a-z0-9_]+$" + ) + + @model_validator(mode="after") + def validate_truthfulness(self) -> "MinerControlResultPayload": + if self.verification_status == "confirmed" and not self.verification_attempted: + raise ValueError("confirmed results require verification_attempted") + if not self.command_accepted and not self.failure_code: + raise ValueError("rejected commands require a failure_code") + return self + + +class AgentCommandResultRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + command_id: str = Field(min_length=1, max_length=128) + status: Literal["completed", "failed"] + result_payload: dict[str, Any] = Field(default_factory=dict) + error_message: str | None = Field(default=None, max_length=255) + + +class AgentCommandResultResponse(BaseModel): + ok: Literal[True] = True + command_id: str + status: Literal["completed", "failed"] diff --git a/backend/app/agent_commands/repository.py b/backend/app/agent_commands/repository.py new file mode 100644 index 0000000..1909f54 --- /dev/null +++ b/backend/app/agent_commands/repository.py @@ -0,0 +1,130 @@ +"""PostgreSQL persistence for Agent command transport.""" + +from collections.abc import Callable +from typing import Any + +import psycopg +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound +from app.config import settings + + +ConnectionFactory = Callable[[], psycopg.Connection] + +CLAIM_COMMANDS_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 +""" + + +def default_connection_factory() -> psycopg.Connection: + 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 AgentCommandRepository: + """Own queue locking and immutable terminal transitions.""" + + def __init__(self, connection_factory: ConnectionFactory | None = None): + self._connection_factory = connection_factory or default_connection_factory + + def claim_pending(self, agent_id: str, limit: int = 5) -> list[dict]: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit)) + rows = [dict(row) for row in cur.fetchall()] + connection.commit() + return rows + except Exception: + connection.rollback() + raise + finally: + connection.close() + + def get_command(self, command_id: str) -> dict | None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id, agent_id, command_type, command_status, request_payload + FROM minerops_agent_command_queue WHERE id = %s + """, + (command_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + connection.close() + + def finish_command( + self, + command_id: str, + agent_id: str, + status: str, + result_payload: dict[str, Any], + error_message: str | None, + ) -> None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + UPDATE minerops_agent_command_queue + SET command_status = %s, result_payload = %s, + error_message = %s, completed_at = now() + WHERE id = %s AND agent_id = %s AND command_status = 'claimed' + RETURNING id + """, + (status, Jsonb(result_payload), error_message, command_id, agent_id), + ) + if cur.fetchone(): + connection.commit() + return + + cur.execute( + """ + SELECT agent_id, command_status + FROM minerops_agent_command_queue WHERE id = %s + """, + (command_id,), + ) + row = cur.fetchone() + connection.rollback() + if not row: + raise AgentCommandNotFound("Agent command not found") + if row["agent_id"] != agent_id: + raise AgentCommandConflict("Agent command belongs to another Agent") + raise AgentCommandConflict( + f"Agent command is {row['command_status']}, not claimed" + ) + except (AgentCommandNotFound, AgentCommandConflict): + raise + except Exception: + connection.rollback() + raise + finally: + connection.close() diff --git a/backend/app/agent_commands/router.py b/backend/app/agent_commands/router.py new file mode 100644 index 0000000..8c27086 --- /dev/null +++ b/backend/app/agent_commands/router.py @@ -0,0 +1,57 @@ +"""HTTP boundary for authenticated Agent command transport.""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.agent_commands.dependencies import get_agent_command_service, require_agent_identity +from app.agent_commands.exceptions import ( + AgentCommandConflict, + AgentCommandNotFound, + AgentResultValidationError, +) +from app.agent_commands.models import ( + AgentCommandListResponse, + AgentCommandResultRequest, + AgentCommandResultResponse, +) +from app.agent_commands.service import AgentCommandService + + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"]) +AgentIdentity = Annotated[str, Depends(require_agent_identity)] +AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)] + + +@router.get("/pending", response_model=AgentCommandListResponse) +def claim_pending_commands( + request: Request, agent_id: AgentIdentity, service: AgentService +) -> AgentCommandListResponse: + before_claim = getattr( + request.app.state, "agent_command_before_claim", None + ) + if callable(before_claim): + try: + before_claim() + except Exception: + logger.exception("Scheduled command materialization failed before Agent claim") + return AgentCommandListResponse(commands=service.claim(agent_id)) + + +@router.post("/result", response_model=AgentCommandResultResponse) +def submit_command_result( + request: AgentCommandResultRequest, + agent_id: AgentIdentity, + service: AgentService, +) -> AgentCommandResultResponse: + try: + service.submit_result(agent_id, request) + except AgentCommandNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except AgentCommandConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except AgentResultValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return AgentCommandResultResponse(command_id=request.command_id, status=request.status) diff --git a/backend/app/agent_commands/service.py b/backend/app/agent_commands/service.py new file mode 100644 index 0000000..8148478 --- /dev/null +++ b/backend/app/agent_commands/service.py @@ -0,0 +1,87 @@ +"""Agent command transport coordination and result validation.""" + +import re + +from pydantic import ValidationError + +from app.agent_commands.exceptions import ( + AgentCommandConflict, + AgentCommandNotFound, + AgentResultValidationError, +) +from app.agent_commands.models import ( + AgentCommand, + AgentCommandResultRequest, + MinerControlResultPayload, +) +from app.agent_commands.repository import AgentCommandRepository + + +class AgentCommandService: + def __init__(self, repository: AgentCommandRepository): + self.repository = repository + + def claim(self, agent_id: str) -> list[AgentCommand]: + return [ + AgentCommand.model_validate(row) + for row in self.repository.claim_pending(agent_id) + ] + + @staticmethod + def _legacy_error(value: str | None) -> str | None: + if not value: + return None + sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[address]", value) + return sanitized[:255] + + def submit_result( + self, agent_id: str, request: AgentCommandResultRequest + ) -> None: + command = self.repository.get_command(request.command_id) + if not command: + raise AgentCommandNotFound("Agent command not found") + if command["agent_id"] != agent_id: + raise AgentCommandConflict("Agent command belongs to another Agent") + if command["command_status"] != "claimed": + raise AgentCommandConflict( + f"Agent command is {command['command_status']}, not claimed" + ) + + result_payload = request.result_payload + error_message = self._legacy_error(request.error_message) + if command["command_type"] == "miner_control": + try: + result = MinerControlResultPayload.model_validate(request.result_payload) + except ValidationError as exc: + raise AgentResultValidationError( + "Invalid typed miner-control result" + ) from exc + if request.status == "completed" and not result.command_accepted: + raise AgentResultValidationError( + "Completed miner-control results must be accepted" + ) + if request.status == "failed" and result.command_accepted: + raise AgentResultValidationError( + "Failed miner-control results cannot be accepted" + ) + action = (command.get("request_payload") or {}).get("action") + if ( + action in {"suspend", "resume"} + and result.command_accepted + and result.verification_status != "unconfirmed" + ): + raise AgentResultValidationError( + "Accepted suspend and resume results must remain unconfirmed" + ) + result_payload = result.model_dump(mode="json") + error_message = ( + result.failure_code.replace("_", " ") if result.failure_code else None + ) + + self.repository.finish_command( + request.command_id, + agent_id, + request.status, + result_payload, + error_message, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 32348d6..84c56e4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -16,6 +16,9 @@ class Settings(BaseSettings): redis_url: str = "redis://redis:6379/0" + minerops_agent_id: str = "plexus-windows-agent" + minerops_agent_shared_secret: str = "" + jwt_secret_key: str jwt_access_token_minutes: int = 30 jwt_refresh_token_days: int = 14 diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py index c06723d..65b985b 100644 --- a/backend/app/frontend/miner_detail_view.py +++ b/backend/app/frontend/miner_detail_view.py @@ -48,6 +48,38 @@ def miner_detail_content() -> str:
+
+

Agent-routed actions

+

Controls

+ +
+

+ Loading factual capability and recent operations. +

+
+ + + +
+ + +
ActionRequestedStatusVerificationCreated
+
+

Physical context

Location

diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index 01055f5..d405bb4 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -1722,3 +1722,52 @@ button.map-position:hover { @media (prefers-reduced-motion: reduce) { .miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; } } + +.control-capability-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 14px 0; +} + +.control-capability-grid > div { + padding: 10px; + border: 1px solid var(--line); + background: #f7f8fa; +} + +.miner-control-actions, +.performance-control { + display: flex; + align-items: end; + gap: 10px; +} + +.miner-control-actions { + margin: 12px 0; +} + +.performance-control label { + display: block; + font-size: 12px; + font-weight: 700; +} + +.status-callout { + margin: 12px 0; + padding: 10px; + border-left: 4px solid var(--warn); + background: #fff8e6; +} + +@media (max-width: 720px) { + .control-capability-grid { + grid-template-columns: 1fr; + } + + .miner-control-actions, + .performance-control { + align-items: stretch; + flex-direction: column; + } +} diff --git a/backend/app/frontend/static/miner-detail.js b/backend/app/frontend/static/miner-detail.js index f128d8f..af1756b 100644 --- a/backend/app/frontend/static/miner-detail.js +++ b/backend/app/frontend/static/miner-detail.js @@ -5,6 +5,7 @@ const minerId = document.body.dataset.minerId; const returnTo = document.body.dataset.returnTo || ""; + const canControl = document.body.dataset.canControlMiners === "true"; const el = { loading: document.getElementById("minerDetailLoading"), error: document.getElementById("minerDetailError"), @@ -32,7 +33,17 @@ identityHistory: document.getElementById("minerIdentityHistory"), identityHistoryBody: document.getElementById("minerIdentityHistoryBody"), observations: document.getElementById("minerObservations"), - observationsBody: document.getElementById("minerObservationsBody") + observationsBody: document.getElementById("minerObservationsBody"), + controlsRefresh: document.getElementById("minerControlsRefresh"), + controlsSummary: document.getElementById("minerControlsSummary"), + controlCapabilities: document.getElementById("minerControlCapabilities"), + controlActions: document.getElementById("minerControlActions"), + controlPermission: document.getElementById("minerControlPermission"), + controlResult: document.getElementById("minerControlResult"), + serviceControl: document.getElementById("minerServiceControl"), + performanceForm: document.getElementById("minerPerformanceControl"), + performanceMode: document.getElementById("minerPerformanceMode"), + controlHistory: document.getElementById("minerControlHistory") }; function create(tag, className, text) { @@ -121,6 +132,123 @@ return payload ? payload.data : null; } + async function mutate(path, body) { + const response = await fetch(path, { + method: "POST", + credentials: "same-origin", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + if (response.status === 401) { + window.location.assign("/login?next=" + encodeURIComponent( + window.location.pathname + window.location.search + )); + throw new Error("Login required"); + } + let payload = null; + try { payload = await response.json(); } catch (_error) { payload = null; } + if (!response.ok) { + throw new Error(payload && payload.detail + ? String(payload.detail) : "Request failed (" + response.status + ")"); + } + return payload ? payload.data : null; + } + + function capabilityText(capability) { + if (!capability) return "Unknown"; + const reason = capability.reason ? " / " + words(capability.reason) : ""; + return words(capability.state) + reason; + } + + function operationStatus(operation) { + if (operation.status === "pending") return "Pending"; + if (operation.status === "claimed") return "Running"; + if (operation.status === "failed") return "Failed"; + if (operation.status === "cancelled") return "Cancelled"; + if (operation.verification_status === "confirmed") return "Confirmed"; + if (operation.verification_status === "unconfirmed") return "Unconfirmed"; + if (operation.verification_status === "verification_failed") return "Verification failed"; + if (operation.verification_status === "unavailable") return "Verification unavailable"; + return "Completed"; + } + + function renderControls(data) { + el.controlsSummary.textContent = "Runtime: " + words(data.runtime_state) + + " / Known mode: " + words(data.performance_mode); + el.controlCapabilities.replaceChildren(); + [["Agent", data.agent], ["Service control", data.service_control], + ["Performance mode", data.performance_mode_control]].forEach(function (item) { + const block = create("div"); + block.append(create("span", "secondary", item[0]), + create("strong", "", capabilityText(item[1]))); + el.controlCapabilities.appendChild(block); + }); + + el.controlPermission.hidden = canControl; + el.controlActions.hidden = !canControl; + el.serviceControl.hidden = true; + el.performanceForm.hidden = true; + if (canControl && data.available_service_action) { + el.serviceControl.dataset.action = data.available_service_action; + el.serviceControl.textContent = data.available_service_action === "suspend" + ? "Suspend miner" : "Resume miner"; + el.serviceControl.hidden = false; + } + if (canControl && data.performance_mode_control.state === "supported") { + if (data.performance_mode) el.performanceMode.value = data.performance_mode; + el.performanceForm.hidden = false; + } + + el.controlHistory.replaceChildren(); + (data.recent_operations || []).forEach(function (operation) { + const row = create("tr"); + [words(operation.action), display(operation.requested_value, "None"), + operationStatus(operation), words(operation.verification_status), + dateTime(operation.created_at)].forEach(function (value) { + row.appendChild(create("td", "", value)); + }); + el.controlHistory.appendChild(row); + }); + if (!(data.recent_operations || []).length) { + const row = create("tr"); + const cell = create("td", "empty-copy", "No control operations recorded."); + cell.colSpan = 5; + row.appendChild(cell); + el.controlHistory.appendChild(row); + } + } + + async function loadControls() { + el.controlsSummary.textContent = "Loading control capability and history."; + try { + const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/controls"); + renderControls(data); + } catch (error) { + el.controlsSummary.textContent = "Controls unavailable: " + error.message; + } + } + + async function queueControl(action, requestedValue) { + const description = action === "performance_mode" + ? "set performance mode to " + requestedValue : action + " this miner"; + if (!window.confirm("Confirm: " + description + "?")) return; + const pathAction = action === "performance_mode" ? "performance-mode" : action; + const body = action === "performance_mode" ? { mode: requestedValue } : {}; + el.controlResult.hidden = false; + el.controlResult.textContent = "Queueing control operation."; + try { + const operation = await mutate( + "/api/miners/" + encodeURIComponent(minerId) + "/controls/" + pathAction, + body + ); + el.controlResult.textContent = "Pending: " + words(operation.action) + + " operation " + operation.operation_id + "."; + await loadControls(); + } catch (error) { + el.controlResult.textContent = "Control was not queued: " + error.message; + } + } + function table(headers, rows) { const wrap = create("div", "table-wrap compact-history-table"); const tableNode = create("table"); @@ -413,8 +541,21 @@ } } + function refreshAll() { + loadOverview(); + loadControls(); + } + el.retry.addEventListener("click", loadOverview); - el.refresh.addEventListener("click", loadOverview); + el.refresh.addEventListener("click", refreshAll); + el.controlsRefresh.addEventListener("click", loadControls); + el.serviceControl.addEventListener("click", function () { + queueControl(el.serviceControl.dataset.action, null); + }); + el.performanceForm.addEventListener("submit", function (event) { + event.preventDefault(); + queueControl("performance_mode", el.performanceMode.value); + }); el.identityHistory.addEventListener("toggle", function () { if (el.identityHistory.open) loadIdentityHistory(); }); @@ -422,4 +563,5 @@ if (el.observations.open) loadObservations(); }); loadOverview(); + loadControls(); })(); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index 87fb8e3..c5908bc 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -521,6 +521,7 @@ def render_app_page(
diff --git a/backend/app/identity.py b/backend/app/identity.py index 15d8dae..c6c8323 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -757,6 +757,17 @@ def component_request(path: str) -> bool: def required_api_permission(method: str, path: str) -> str | None: parts = path.split("/") + if ( + len(parts) >= 5 + and parts[:3] == ["", "api", "miners"] + and parts[4] == "controls" + ): + if method == "GET" and len(parts) == 5: + return "miners.view" + if method == "POST" and len(parts) == 6 and parts[5] in { + "suspend", "resume", "performance-mode" + }: + return "miners.control" if ( method == "GET" and len(parts) == 5 diff --git a/backend/app/main.py b/backend/app/main.py index 239462a..8c5ab70 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,8 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings +from app.agent_commands.router import router as agent_commands_router +from app.miner_control.router import router as miner_control_router from app.dashboard.router import router as dashboard_router from app.identity import ( COOKIE_TOKEN_HINT, @@ -63,6 +65,8 @@ app = FastAPI(title=settings.app_name) app.include_router(identity_router) app.include_router(frontend_router) app.include_router(inventory_router) +app.include_router(miner_control_router) +app.include_router(agent_commands_router) app.include_router(components_router) app.include_router(locations_router) app.include_router(dashboard_router) @@ -4010,6 +4014,8 @@ def minerops_process_due_scheduled_bulk_actions(): conn.close() +app.state.agent_command_before_claim = minerops_process_due_scheduled_bulk_actions + def minerops_reconcile_scheduled_bulk_action_statuses(): minerops_ensure_scheduled_bulk_actions_table() @@ -24985,8 +24991,7 @@ async def minerops_hub_mode_js(): # ----------------------------- def minerops_agent_secret_expected() -> str: - import os - return str(os.environ.get("MINEROPS_AGENT_SHARED_SECRET", "") or "").strip() + return settings.minerops_agent_shared_secret.strip() def minerops_require_agent_secret(request: Request): @@ -25089,6 +25094,7 @@ async def minerops_agent_checkin(request: Request): body = {} agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip() + site_id = str(body.get("site_id") or "").strip() site_name = str(body.get("site_name") or "Plexus").strip() hostname = str(body.get("hostname") or "").strip() version = str(body.get("version") or "").strip() @@ -25101,6 +25107,7 @@ async def minerops_agent_checkin(request: Request): """ INSERT INTO minerops_site_agents ( agent_id, + site_id, site_name, hostname, version, @@ -25108,9 +25115,10 @@ async def minerops_agent_checkin(request: Request): last_checkin_at, last_payload ) - VALUES ($1, $2, $3, $4, $5, now(), $6::jsonb) + VALUES ($1, NULLIF($2, '')::uuid, $3, $4, $5, $6, now(), $7::jsonb) ON CONFLICT (agent_id) DO UPDATE SET + site_id = COALESCE(EXCLUDED.site_id, minerops_site_agents.site_id), site_name = EXCLUDED.site_name, hostname = EXCLUDED.hostname, version = EXCLUDED.version, @@ -25119,6 +25127,7 @@ async def minerops_agent_checkin(request: Request): last_payload = EXCLUDED.last_payload """, agent_id, + site_id, site_name, hostname, version, @@ -25301,12 +25310,11 @@ async def minerops_agent_setup_info(current_user: dict = Depends(get_current_use return { "ok": True, "hub_url": "https://plexus.lussier.cc", - "agent_id": "plexus-windows-agent", + "agent_id": settings.minerops_agent_id, "site_name": "Plexus", - "agent_secret": secret, - "secret_length": len(secret), + "secret_configured": bool(secret), "config_path": "C:\\MinerOpsAgent\\agent-config.json", - "message": "Keep this secret private. It allows the Windows Agent to push updates to the hub.", + "message": "Agent credentials are provisioned locally and are never returned to browsers.", } @@ -25353,7 +25361,7 @@ async def minerops_agent_setup_page():

Agent Connection Info

- Keep this secret private. Anyone with the secret can submit agent data to this hub. + Agent credentials must be provisioned directly on the Windows host.
@@ -25365,20 +25373,7 @@ async def minerops_agent_setup_page(): - - - -

- - - -

-
- -
-

Windows Agent Config JSON

-

Paste this into C:\\MinerOpsAgent\\agent-config.json if you need to recreate the agent config.

- +

@@ -25427,42 +25422,14 @@ async function loadSetup() { document.getElementById("hubUrl").value = data.hub_url || ""; document.getElementById("agentId").value = data.agent_id || ""; document.getElementById("siteName").value = data.site_name || ""; - document.getElementById("agentSecret").value = data.agent_secret || ""; - - const config = { - agent_id: data.agent_id, - site_name: data.site_name, - hub_url: data.hub_url, - agent_secret: data.agent_secret, - poll_interval_seconds: 120, - ip_ranges: [ - { name: "Unit 4", cidr: "10.30.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 }, - { name: "Unit 6", cidr: "10.80.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 }, - { name: "Unit 12", cidr: "10.90.0.0/22", enabled: false, api_port: 4028, api3_port: 4433 } - ] - }; - - document.getElementById("configJson").value = JSON.stringify(config, null, 2); + document.getElementById("secretStatus").textContent = data.secret_configured + ? "A shared Agent secret is configured on the Hub. Its value is not exposed here." + : "The Hub does not currently have an Agent secret configured."; status.className = "ok"; status.textContent = "Agent setup info loaded."; } -function toggleSecret() { - const input = document.getElementById("agentSecret"); - input.type = input.type === "password" ? "text" : "password"; -} - -async function copySecret() { - await navigator.clipboard.writeText(document.getElementById("agentSecret").value); - alert("Secret copied."); -} - -async function copyConfig() { - await navigator.clipboard.writeText(document.getElementById("configJson").value); - alert("Config JSON copied."); -} - document.addEventListener("DOMContentLoaded", loadSetup); @@ -25939,118 +25906,6 @@ async def minerops_agent_command_status(current_user: dict = Depends(get_current } -@app.get("/api/agent/commands/pending") -async def minerops_agent_commands_pending_v2(request: Request): - # __SCHEDULE_DUE_HOOK_V2__ - try: - minerops_process_due_scheduled_bulk_actions() - except NameError: - pass - except Exception as exc: - print(f"Scheduled bulk action due processing error: {exc}") - - minerops_require_agent_secret(request) - - await minerops_ensure_agent_command_queue_table() - - agent_id = request.query_params.get("agent_id") or "plexus-windows-agent" - - conn = await minerops_command_db_connect() - - try: - rows = await conn.fetch( - """ - UPDATE minerops_agent_command_queue - SET - command_status = 'claimed', - claimed_at = now() - WHERE id IN ( - SELECT id - FROM minerops_agent_command_queue - WHERE agent_id = $1 - AND command_status = 'pending' - ORDER BY created_at ASC - LIMIT 5 - ) - RETURNING - id, - command_type, - request_payload, - created_at - """, - agent_id, - ) - finally: - await conn.close() - - commands = [] - - for row in rows: - commands.append( - { - "command_id": row["id"], - "command_type": row["command_type"], - "request_payload": row["request_payload"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - } - ) - - return { - "ok": True, - "commands": commands, - } - - -@app.post("/api/agent/commands/result") -async def minerops_agent_commands_result_v2(request: Request): - import json - - minerops_require_agent_secret(request) - - body = await request.json() - - command_id = str(body.get("command_id") or "").strip() - status = str(body.get("status") or "").strip().lower() - result_payload = body.get("result_payload") or {} - error_message = body.get("error_message") - - if not command_id: - raise HTTPException(status_code=400, detail="Missing command_id.") - - if status not in {"completed", "failed"}: - status = "completed" - - await minerops_ensure_agent_command_queue_table() - - conn = await minerops_command_db_connect() - - try: - await conn.execute( - """ - UPDATE minerops_agent_command_queue - SET - command_status = $2, - result_payload = $3::jsonb, - error_message = $4, - completed_at = now() - WHERE id = $1 - """, - command_id, - status, - json.dumps(result_payload), - error_message, - ) - finally: - await conn.close() - - return { - "ok": True, - "command_id": command_id, - "status": status, - "message": "Agent command result received.", - } - - # ----------------------------- # Phase 5K.5: Agent control web page diff --git a/backend/app/miner_control/__init__.py b/backend/app/miner_control/__init__.py new file mode 100644 index 0000000..d261d88 --- /dev/null +++ b/backend/app/miner_control/__init__.py @@ -0,0 +1 @@ +"""Typed miner control domain.""" diff --git a/backend/app/miner_control/dependencies.py b/backend/app/miner_control/dependencies.py new file mode 100644 index 0000000..0003016 --- /dev/null +++ b/backend/app/miner_control/dependencies.py @@ -0,0 +1,8 @@ +"""Dependency construction for miner control.""" + +from app.miner_control.repository import MinerControlRepository +from app.miner_control.service import MinerControlService + + +def get_miner_control_service() -> MinerControlService: + return MinerControlService(MinerControlRepository()) diff --git a/backend/app/miner_control/exceptions.py b/backend/app/miner_control/exceptions.py new file mode 100644 index 0000000..24281dd --- /dev/null +++ b/backend/app/miner_control/exceptions.py @@ -0,0 +1,21 @@ +"""Miner control domain exceptions.""" + + +class MinerControlError(Exception): + """Base miner control error.""" + + +class ControlledMinerNotFound(MinerControlError): + """The durable miner UUID is unknown.""" + + +class MinerControlUnavailable(MinerControlError): + """The requested action cannot be safely queued.""" + + +class MinerControlConflict(MinerControlError): + """Another live control operation already exists.""" + + +class MinerControlActorRequired(MinerControlError): + """An authenticated actor UUID is required.""" diff --git a/backend/app/miner_control/models.py b/backend/app/miner_control/models.py new file mode 100644 index 0000000..3abf6e3 --- /dev/null +++ b/backend/app/miner_control/models.py @@ -0,0 +1,85 @@ +"""Typed contracts for miner control capabilities and operations.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field + + +ControlAction = Literal["suspend", "resume", "performance_mode"] +CapabilityState = Literal["supported", "unavailable", "unknown"] +QueueStatus = Literal["pending", "claimed", "completed", "failed", "cancelled"] +VerificationStatus = Literal[ + "confirmed", "verification_failed", "unconfirmed", "unavailable" +] +PerformanceMode = Literal["low", "normal", "high"] + + +class EmptyControlRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class PerformanceModeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: PerformanceMode + + +class ControlCapability(BaseModel): + state: CapabilityState + reason: str | None = None + + +class ControlAgentAvailability(BaseModel): + state: CapabilityState + reason: str | None = None + last_checkin_at: AwareDatetime | None = None + version: str | None = None + + +class MinerControlOperation(BaseModel): + operation_id: str + miner_id: UUID + action: ControlAction + requested_value: str | None = None + status: QueueStatus + verification_status: VerificationStatus | None = None + command_accepted: bool | None = None + observed_performance_mode: PerformanceMode | None = None + failure_code: str | None = None + created_at: AwareDatetime + claimed_at: AwareDatetime | None = None + completed_at: AwareDatetime | None = None + + +class MinerControlProjection(BaseModel): + miner_id: UUID + runtime_state: str | None = None + performance_mode: PerformanceMode | None = None + agent: ControlAgentAvailability + service_control: ControlCapability + performance_mode_control: ControlCapability + available_service_action: Literal["suspend", "resume"] | None = None + recent_operations: list[MinerControlOperation] + + +class MinerControlResponse(BaseModel): + data: MinerControlProjection + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class MinerControlAccepted(BaseModel): + operation_id: str + miner_id: UUID + action: ControlAction + requested_value: str | None = None + status: Literal["pending"] + created_at: datetime + + +class MinerControlAcceptedResponse(BaseModel): + data: MinerControlAccepted + meta: dict[str, object] = Field(default_factory=dict) + error: None = None diff --git a/backend/app/miner_control/repository.py b/backend/app/miner_control/repository.py new file mode 100644 index 0000000..ce9ca1d --- /dev/null +++ b/backend/app/miner_control/repository.py @@ -0,0 +1,135 @@ +"""PostgreSQL persistence for typed miner control operations.""" + +from collections.abc import Callable +from typing import Any +from uuid import UUID + +import psycopg +from psycopg.errors import UniqueViolation +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.config import settings +from app.miner_control.exceptions import MinerControlConflict + + +ConnectionFactory = Callable[[], psycopg.Connection] +LIVE_CONTROL_CONSTRAINT = "uq_agent_queue_live_miner_control" + + +def default_connection_factory() -> psycopg.Connection: + 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 MinerControlRepository: + """Resolve control facts and persist queue/audit rows atomically.""" + + def __init__(self, connection_factory: ConnectionFactory | None = None): + self._connection_factory = connection_factory or default_connection_factory + + def get_control_context(self, miner_id: UUID) -> dict | None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip, + miners.status AS runtime_state, + miners.configured_performance_mode, + miners.api3_supported, miners.api3_api_switch, + agents.agent_id, agents.last_checkin_at, + agents.version AS agent_version, agents.last_payload + FROM miners + LEFT JOIN minerops_site_agents AS agents + ON agents.site_id = miners.site_id + WHERE miners.id = %s + """, + (miner_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + connection.close() + + def list_recent(self, miner_id: UUID, limit: int = 5) -> list[dict]: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id AS operation_id, command_status AS status, + request_payload, result_payload, created_at, + claimed_at, completed_at + FROM minerops_agent_command_queue + WHERE command_type = 'miner_control' + AND request_payload ->> 'miner_id' = %s + AND request_payload ->> 'schema_version' = '1' + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (str(miner_id), limit), + ) + return [dict(row) for row in cur.fetchall()] + finally: + connection.close() + + def enqueue_with_audit( + self, + *, + operation_id: str, + miner_id: UUID, + agent_id: str, + actor_user_id: UUID, + actor_username: str, + request_payload: dict[str, Any], + audit_payload: dict[str, Any], + ) -> dict: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO minerops_agent_command_queue ( + id, agent_id, command_type, command_status, + requested_by_user_id, requested_by_username, request_payload + ) + VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s) + RETURNING id AS operation_id, command_status AS status, created_at + """, + ( + operation_id, agent_id, str(actor_user_id), actor_username, + Jsonb(request_payload), + ), + ) + operation = dict(cur.fetchone()) + cur.execute( + """ + INSERT INTO audit_logs ( + actor_user_id, action, entity_type, entity_id, + new_value_json, related_miner_asset_id + ) + VALUES (%s, 'miner.control.queued', 'miner', %s, %s, %s) + """, + (actor_user_id, miner_id, Jsonb(audit_payload), miner_id), + ) + connection.commit() + return operation + except UniqueViolation as exc: + connection.rollback() + if exc.diag.constraint_name == LIVE_CONTROL_CONSTRAINT: + raise MinerControlConflict( + "A pending or running control operation already exists for this miner" + ) from exc + raise + except Exception: + connection.rollback() + raise + finally: + connection.close() diff --git a/backend/app/miner_control/router.py b/backend/app/miner_control/router.py new file mode 100644 index 0000000..777f850 --- /dev/null +++ b/backend/app/miner_control/router.py @@ -0,0 +1,129 @@ +"""HTTP mapping for typed miner controls.""" + +from typing import Annotated +from urllib.parse import urlsplit +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from app.identity import require_permission +from app.miner_control.dependencies import get_miner_control_service +from app.miner_control.exceptions import ( + ControlledMinerNotFound, + MinerControlConflict, + MinerControlUnavailable, +) +from app.miner_control.models import ( + EmptyControlRequest, + MinerControlAcceptedResponse, + MinerControlResponse, + PerformanceModeRequest, +) +from app.miner_control.service import MinerControlService + + +router = APIRouter(prefix="/api/miners", tags=["miner-controls"]) +ControlService = Annotated[MinerControlService, Depends(get_miner_control_service)] +ControlViewer = Annotated[dict, Depends(require_permission("miners.view"))] +ControlUser = Annotated[dict, Depends(require_permission("miners.control"))] + + +def require_same_origin(request: Request) -> None: + if request.headers.get("sec-fetch-site", "").lower() == "cross-site": + raise HTTPException(status_code=403, detail="Cross-site control request rejected") + origin = request.headers.get("origin") + if origin: + parsed = urlsplit(origin) + if parsed.scheme not in {"http", "https"} or parsed.netloc != request.url.netloc: + raise HTTPException(status_code=403, detail="Cross-site control request rejected") + + +def _actor(current_user: dict) -> tuple[UUID, str]: + return UUID(str(current_user["id"])), str( + current_user.get("username") or current_user.get("email") or "unknown" + ) + + +def _queue( + service: MinerControlService, + miner_id: UUID, + action: str, + requested_value: str | None, + current_user: dict, +) -> MinerControlAcceptedResponse: + actor_id, username = _actor(current_user) + try: + operation = service.queue_control( + miner_id, + action, + requested_value=requested_value, + actor_user_id=actor_id, + actor_username=username, + ) + except ControlledMinerNotFound as exc: + raise HTTPException(status_code=404, detail="Miner not found") from exc + except MinerControlUnavailable as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except MinerControlConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return MinerControlAcceptedResponse(data=operation) + + +@router.get("/{miner_id}/controls", response_model=MinerControlResponse) +def get_miner_controls( + miner_id: UUID, + current_user: ControlViewer, + service: ControlService, +) -> MinerControlResponse: + del current_user + try: + return MinerControlResponse(data=service.get_controls(miner_id)) + except ControlledMinerNotFound as exc: + raise HTTPException(status_code=404, detail="Miner not found") from exc + + +@router.post( + "/{miner_id}/controls/suspend", + response_model=MinerControlAcceptedResponse, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_same_origin)], +) +def suspend_miner( + miner_id: UUID, + payload: EmptyControlRequest, + current_user: ControlUser, + service: ControlService, +) -> MinerControlAcceptedResponse: + del payload + return _queue(service, miner_id, "suspend", None, current_user) + + +@router.post( + "/{miner_id}/controls/resume", + response_model=MinerControlAcceptedResponse, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_same_origin)], +) +def resume_miner( + miner_id: UUID, + payload: EmptyControlRequest, + current_user: ControlUser, + service: ControlService, +) -> MinerControlAcceptedResponse: + del payload + return _queue(service, miner_id, "resume", None, current_user) + + +@router.post( + "/{miner_id}/controls/performance-mode", + response_model=MinerControlAcceptedResponse, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_same_origin)], +) +def set_performance_mode( + miner_id: UUID, + payload: PerformanceModeRequest, + current_user: ControlUser, + service: ControlService, +) -> MinerControlAcceptedResponse: + return _queue(service, miner_id, "performance_mode", payload.mode, current_user) diff --git a/backend/app/miner_control/service.py b/backend/app/miner_control/service.py new file mode 100644 index 0000000..3f8a602 --- /dev/null +++ b/backend/app/miner_control/service.py @@ -0,0 +1,165 @@ +"""Business rules for typed, Agent-routed miner controls.""" + +from typing import Any +from uuid import UUID, uuid4 + +from app.miner_control.exceptions import ( + ControlledMinerNotFound, + MinerControlActorRequired, + MinerControlUnavailable, +) +from app.miner_control.models import ( + ControlAgentAvailability, + ControlCapability, + MinerControlAccepted, + MinerControlOperation, + MinerControlProjection, +) +from app.miner_control.repository import MinerControlRepository + + +KNOWN_MODES = {"low", "normal", "high"} +RUNNING_STATES = {"online", "running"} +STOPPED_STATES = {"suspended", "stopped"} + + +class MinerControlService: + def __init__(self, repository: MinerControlRepository): + self.repository = repository + + @staticmethod + def _capability(context: dict) -> ControlCapability: + if not context.get("site_id"): + return ControlCapability(state="unavailable", reason="site_unassigned") + if not context.get("agent_id"): + return ControlCapability(state="unavailable", reason="agent_unassigned") + 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 + def _operation(miner_id: UUID, row: dict) -> MinerControlOperation: + request = row.get("request_payload") or {} + result = row.get("result_payload") or {} + mode = result.get("observed_performance_mode") + return MinerControlOperation( + operation_id=row["operation_id"], + miner_id=miner_id, + action=request.get("action"), + requested_value=request.get("requested_value"), + status=row["status"], + verification_status=result.get("verification_status"), + command_accepted=result.get("command_accepted"), + observed_performance_mode=mode if mode in KNOWN_MODES else None, + failure_code=result.get("failure_code"), + created_at=row["created_at"], + claimed_at=row.get("claimed_at"), + completed_at=row.get("completed_at"), + ) + + def get_controls(self, miner_id: UUID) -> MinerControlProjection: + context = self.repository.get_control_context(miner_id) + if not context: + raise ControlledMinerNotFound("Miner not found") + capability = self._capability(context) + runtime = str(context.get("runtime_state") or "").strip().lower() or None + service_action = None + if capability.state == "supported": + if runtime in RUNNING_STATES: + service_action = "suspend" + elif runtime in STOPPED_STATES: + service_action = "resume" + configured_mode = str(context.get("configured_performance_mode") or "").lower() + return MinerControlProjection( + miner_id=miner_id, + runtime_state=runtime, + performance_mode=(configured_mode if configured_mode in KNOWN_MODES else None), + agent=ControlAgentAvailability( + state=capability.state, + reason=capability.reason, + last_checkin_at=context.get("last_checkin_at"), + version=context.get("agent_version"), + ), + service_control=capability, + performance_mode_control=capability, + available_service_action=service_action, + recent_operations=[ + self._operation(miner_id, row) + for row in self.repository.list_recent(miner_id, limit=5) + ], + ) + + def queue_control( + self, + miner_id: UUID, + action: str, + *, + requested_value: str | None, + actor_user_id: UUID, + actor_username: str, + ) -> MinerControlAccepted: + if not actor_user_id: + raise MinerControlActorRequired("Authenticated actor UUID is required") + context = self.repository.get_control_context(miner_id) + if not context: + raise ControlledMinerNotFound("Miner not found") + capability = self._capability(context) + if capability.state != "supported": + raise MinerControlUnavailable( + capability.reason or "control_capability_not_supported" + ) + + runtime = str(context.get("runtime_state") or "").strip().lower() + if action == "suspend" and runtime not in RUNNING_STATES: + raise MinerControlUnavailable("miner_not_factually_running") + if action == "resume" and runtime not in STOPPED_STATES: + raise MinerControlUnavailable("miner_not_factually_stopped") + if action == "performance_mode" and requested_value not in KNOWN_MODES: + raise MinerControlUnavailable("invalid_performance_mode") + + operation_id = str(uuid4()) + payload: dict[str, Any] = { + "schema_version": 1, + "miner_id": str(miner_id), + "action": action, + "requested_value": requested_value, + "ip_address": context["current_ip"], + } + operation = self.repository.enqueue_with_audit( + operation_id=operation_id, + miner_id=miner_id, + agent_id=context["agent_id"], + actor_user_id=actor_user_id, + actor_username=actor_username, + request_payload=payload, + audit_payload={ + "operation_id": operation_id, + "miner_id": str(miner_id), + "action": action, + "requested_value": requested_value, + }, + ) + return MinerControlAccepted( + operation_id=operation["operation_id"], + miner_id=miner_id, + action=action, + requested_value=requested_value, + status=operation["status"], + created_at=operation["created_at"], + ) diff --git a/backend/tests/test_agent_commands.py b/backend/tests/test_agent_commands.py new file mode 100644 index 0000000..f569033 --- /dev/null +++ b/backend/tests/test_agent_commands.py @@ -0,0 +1,235 @@ +"""Focused tests for authenticated Agent queue transport.""" + +import unittest +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.agent_commands.dependencies import get_agent_command_service +from app.agent_commands.exceptions import ( + AgentCommandConflict, + AgentResultValidationError, +) +from app.agent_commands.models import AgentCommandResultRequest +from app.agent_commands.repository import CLAIM_COMMANDS_SQL +from app.agent_commands.router import router +from app.agent_commands.service import AgentCommandService +from app.config import settings + + +NOW = datetime(2026, 8, 24, tzinfo=timezone.utc) + + +def agent_source_root() -> Path | None: + container_root = Path("/agents/windows") + if container_root.is_dir(): + return container_root + repository_root = Path(__file__).parents[2] / "agents" / "windows" + return repository_root if repository_root.is_dir() else None + + +class FakeRepository: + def __init__(self, row=None): + self.row = row or { + "id": "command-1", "agent_id": "test-agent", + "command_type": "miner_control", "command_status": "claimed", + "request_payload": {"action": "suspend"}, + } + self.finished = [] + + def claim_pending(self, agent_id, limit=5): + return [{ + "command_id": "command-1", "command_type": "miner_control", + "request_payload": {"action": "suspend"}, "created_at": NOW, + }] + + def get_command(self, command_id): + return self.row if command_id == self.row.get("id") else None + + def finish_command(self, *args): + self.finished.append(args) + + +def typed_result(**overrides): + result = { + "schema_version": 1, + "command_accepted": True, + "verification_attempted": False, + "verification_status": "unconfirmed", + "observed_service_state": None, + "observed_performance_mode": None, + "failure_code": None, + } + result.update(overrides) + return result + + +class AgentCommandServiceTests(unittest.TestCase): + def test_claim_query_uses_skip_locked_and_deterministic_order(self): + self.assertIn("FOR UPDATE SKIP LOCKED", CLAIM_COMMANDS_SQL) + self.assertIn("ORDER BY created_at, id", CLAIM_COMMANDS_SQL) + self.assertIn("queue.command_status = 'pending'", CLAIM_COMMANDS_SQL) + + def test_typed_result_is_strict_and_sanitized(self): + repository = FakeRepository() + request = AgentCommandResultRequest( + command_id="command-1", status="completed", + result_payload=typed_result(), error_message="raw 192.0.2.10 vendor exception", + ) + AgentCommandService(repository).submit_result("test-agent", request) + finished = repository.finished[0] + self.assertIsNone(finished[4]) + self.assertNotIn("192.0.2.10", str(finished)) + + def test_wrong_agent_pending_and_terminal_results_are_rejected(self): + request = AgentCommandResultRequest( + command_id="command-1", status="completed", result_payload=typed_result() + ) + with self.assertRaises(AgentCommandConflict): + AgentCommandService(FakeRepository()).submit_result("wrong-agent", request) + for queue_status in ("pending", "completed", "failed", "cancelled"): + with self.subTest(status=queue_status), self.assertRaises(AgentCommandConflict): + row = FakeRepository().row.copy() + row["command_status"] = queue_status + AgentCommandService(FakeRepository(row)).submit_result("test-agent", request) + + def test_invalid_status_and_secret_fields_are_rejected(self): + with self.assertRaises(Exception): + AgentCommandResultRequest( + command_id="command-1", status="unknown", result_payload={} + ) + for forbidden in ("password", "account", "token", "raw_response"): + with self.subTest(field=forbidden): + repository = FakeRepository() + request = AgentCommandResultRequest( + command_id="command-1", status="completed", + result_payload={**typed_result(), forbidden: "sensitive"}, + ) + with self.assertRaises(AgentResultValidationError): + AgentCommandService(repository).submit_result("test-agent", request) + + def test_suspend_resume_cannot_claim_confirmation(self): + for status in ("confirmed", "verification_failed"): + with self.subTest(status=status): + request = AgentCommandResultRequest( + command_id="command-1", status="completed", + result_payload=typed_result( + verification_attempted=True, verification_status=status + ), + ) + with self.assertRaises(AgentResultValidationError): + AgentCommandService(FakeRepository()).submit_result("test-agent", request) + + +class AgentCommandRouterTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(router) + self.repository = FakeRepository() + self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService( + self.repository + ) + self.client = TestClient(self.app) + self.old_id = settings.minerops_agent_id + self.old_secret = settings.minerops_agent_shared_secret + settings.minerops_agent_id = "test-agent" + settings.minerops_agent_shared_secret = "test-secret" + + def tearDown(self): + settings.minerops_agent_id = self.old_id + settings.minerops_agent_shared_secret = self.old_secret + self.client.close() + + def headers(self, agent="test-agent"): + return { + "X-MinerOps-Agent-ID": agent, + "X-MinerOps-Agent-Secret": "test-secret", + } + + def test_poll_requires_header_identity_and_ignores_query_spoofing(self): + before_claim_calls = [] + self.app.state.agent_command_before_claim = lambda: before_claim_calls.append(True) + self.assertEqual(self.client.get("/api/agent/commands/pending").status_code, 401) + self.assertEqual( + self.client.get( + "/api/agent/commands/pending?agent_id=other", headers=self.headers() + ).status_code, + 200, + ) + self.assertEqual(before_claim_calls, [True]) + self.assertEqual( + self.client.get( + "/api/agent/commands/pending", headers=self.headers("other") + ).status_code, + 403, + ) + + def test_result_requires_strict_claimed_contract(self): + response = self.client.post( + "/api/agent/commands/result", + headers=self.headers(), + json={ + "command_id": "command-1", "status": "completed", + "result_payload": typed_result(), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "completed") + self.assertEqual( + self.client.post( + "/api/agent/commands/result", headers=self.headers(), + json={"command_id": "command-1", "status": "other", + "result_payload": {}}, + ).status_code, + 422, + ) + + +class AgentSourceContractTests(unittest.TestCase): + def test_agent_typed_path_has_locked_commands_and_no_default_credentials(self): + root = agent_source_root() + if root is None: + self.skipTest("Windows Agent source is not mounted") + service = (root / "MinerOpsAgentService.ps1").read_text() + control = (root / "MinerOpsControl.ps1").read_text() + main = (Path(__file__).parents[1] / "app" / "main.py").read_text() + powershell = "\n".join(path.read_text() for path in root.glob("*.ps1")) + self.assertNotIn('DefaultValue "super"', service) + self.assertNotIn('DefaultValue "super"', powershell) + self.assertIn('"X-MinerOps-Agent-ID"', service) + self.assertNotIn("?agent_id=", service) + self.assertIn('"get.miner.setting"', control) + self.assertIn("$attempt -le 3", control) + self.assertIn("$Payload.performance_mode", control) + self.assertNotIn("api3_account =", control) + self.assertNotIn("api3_password =", control) + for forbidden in ("raw_response", "raw_command", "raw_exception"): + with self.subTest(field=forbidden): + self.assertNotIn(forbidden, control.lower()) + self.assertNotIn('"agent_secret": secret', main) + self.assertNotIn('"secret_length":', main) + self.assertIn('"secret_configured": bool(secret)', main) + + def test_deployment_preserves_local_state_and_registers_established_tasks(self): + root = agent_source_root() + if root is None: + self.skipTest("Windows Agent source is not mounted") + install = (root / "install.ps1").read_text() + uninstall = (root / "uninstall.ps1").read_text() + for task in ( + "MinerOpsAgent", "MinerOpsAgentTray", + "MinerOpsApi3Enrichment", "MinerOpsPoolPoller", + ): + self.assertIn(task, install) + self.assertIn(task, uninstall) + for local_path in ("agent-config.json", "agent-state.json", "logs"): + self.assertNotIn(f'"{local_path}"', install) + self.assertNotIn(f'"{local_path}"', uninstall) + self.assertNotIn("agent_secret", install.lower()) + self.assertNotIn("api3_password", install.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_miner_control.py b/backend/tests/test_miner_control.py new file mode 100644 index 0000000..5f092e9 --- /dev/null +++ b/backend/tests/test_miner_control.py @@ -0,0 +1,227 @@ +"""Focused service, HTTP, frontend, and security tests for miner control v1.""" + +import unittest +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID, uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.identity import get_current_user, required_api_permission +from app.miner_control.dependencies import get_miner_control_service +from app.miner_control.exceptions import MinerControlConflict, MinerControlUnavailable +from app.miner_control.models import MinerControlAccepted +from app.miner_control.router import router +from app.miner_control.service import MinerControlService + + +NOW = datetime(2026, 8, 24, tzinfo=timezone.utc) +MINER_ID = UUID("00000000-0000-4000-8000-000000001501") +USER_ID = UUID("00000000-0000-4000-8000-000000001502") +APP_ROOT = Path(__file__).parents[1] / "app" + + +def context(**overrides): + result = { + "id": MINER_ID, + "site_id": uuid4(), + "current_ip": "192.0.2.10", + "runtime_state": "online", + "configured_performance_mode": "normal", + "api3_supported": True, + "api3_api_switch": "on", + "agent_id": "test-agent", + "last_checkin_at": NOW, + "agent_version": "1.0.0-alpha", + "last_payload": {"control_available": True}, + } + result.update(overrides) + return result + + +class FakeRepository: + def __init__(self, miner_context=None, history=None, conflict=False): + self.miner_context = context() if miner_context is None else miner_context + self.history = history or [] + self.conflict = conflict + self.enqueue_calls = [] + self.limits = [] + + def get_control_context(self, miner_id): + self.requested_miner = miner_id + return self.miner_context + + def list_recent(self, miner_id, limit=5): + self.limits.append(limit) + return self.history + + def enqueue_with_audit(self, **kwargs): + self.enqueue_calls.append(kwargs) + if self.conflict: + raise MinerControlConflict("live command exists") + return { + "operation_id": kwargs["operation_id"], + "status": "pending", + "created_at": NOW, + } + + +class MinerControlServiceTests(unittest.TestCase): + def test_supported_projection_is_factual_and_bounded(self): + repository = FakeRepository() + result = MinerControlService(repository).get_controls(MINER_ID) + self.assertEqual(result.service_control.state, "supported") + self.assertEqual(result.performance_mode, "normal") + self.assertEqual(result.available_service_action, "suspend") + self.assertEqual(repository.limits, [5]) + + def test_capability_missing_facts_is_not_optimistic(self): + cases = ( + ({"site_id": None}, "unavailable", "site_unassigned"), + ({"agent_id": None}, "unavailable", "agent_unassigned"), + ({"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): + service = MinerControlService(FakeRepository(context(runtime_state="offline"))) + with self.assertRaisesRegex(MinerControlUnavailable, "factually_stopped"): + service.queue_control( + MINER_ID, "resume", requested_value=None, + actor_user_id=USER_ID, actor_username="operator", + ) + + def test_queue_contains_routing_but_audit_and_response_do_not(self): + repository = FakeRepository() + result = MinerControlService(repository).queue_control( + MINER_ID, "performance_mode", requested_value="high", + actor_user_id=USER_ID, actor_username="operator", + ) + call = repository.enqueue_calls[0] + self.assertEqual(call["request_payload"]["ip_address"], "192.0.2.10") + self.assertNotIn("ip_address", call["audit_payload"]) + self.assertNotIn("agent_id", call["audit_payload"]) + self.assertNotIn("password", str(call).lower()) + self.assertNotIn("192.0.2.10", result.model_dump_json()) + + def test_no_projection_changes_when_work_is_queued(self): + repository = FakeRepository() + MinerControlService(repository).queue_control( + MINER_ID, "suspend", requested_value=None, + actor_user_id=USER_ID, actor_username="operator", + ) + self.assertFalse(any("update" in key for key in repository.enqueue_calls[0])) + + +class MinerControlRouterTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(router) + self.repository = FakeRepository() + self.service = MinerControlService(self.repository) + self.app.dependency_overrides[get_miner_control_service] = lambda: self.service + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(USER_ID), "username": "operator", "role": "Custom", + "permissions": list(permissions), + } + + def test_get_requires_authentication_and_miners_view(self): + path = f"/api/miners/{MINER_ID}/controls" + self.assertEqual(self.client.get(path).status_code, 401) + self.authenticate(("miners.control",)) + self.assertEqual(self.client.get(path).status_code, 403) + self.authenticate(("miners.view",)) + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["miner_id"], str(MINER_ID)) + + def test_posts_require_control_and_return_202_bounded_envelope(self): + path = f"/api/miners/{MINER_ID}/controls/suspend" + self.authenticate(("miners.view",)) + self.assertEqual(self.client.post(path, json={}).status_code, 403) + self.authenticate(("miners.control",)) + response = self.client.post(path, json={}) + self.assertEqual(response.status_code, 202) + self.assertEqual(set(response.json()["data"]), { + "operation_id", "miner_id", "action", "requested_value", "status", "created_at" + }) + self.assertNotIn("ip", response.text.lower()) + self.assertNotIn("agent", response.text.lower()) + + def test_strict_bodies_reject_routing_commands_and_credentials(self): + self.authenticate(("miners.control",)) + base = f"/api/miners/{MINER_ID}/controls" + for body in ( + {"ip": "192.0.2.1"}, {"agent_id": "other"}, {"dry_run": True}, + {"raw_command": "reboot"}, {"api_password": "secret"}, + ): + with self.subTest(body=body): + self.assertEqual(self.client.post(base + "/suspend", json=body).status_code, 422) + self.assertEqual( + self.client.post(base + "/performance-mode", json={"mode": "turbo"}).status_code, + 422, + ) + + def test_cross_site_rejected_and_same_origin_allowed(self): + self.authenticate(("miners.control",)) + path = f"/api/miners/{MINER_ID}/controls/suspend" + self.assertEqual( + self.client.post(path, json={}, headers={"Sec-Fetch-Site": "cross-site"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(path, json={}, headers={"Origin": "http://testserver"}).status_code, + 202, + ) + + def test_identity_permission_mapping_is_specific(self): + base = f"/api/miners/{MINER_ID}/controls" + self.assertEqual(required_api_permission("GET", base), "miners.view") + for action in ("suspend", "resume", "performance-mode"): + self.assertEqual( + required_api_permission("POST", f"{base}/{action}"), + "miners.control", + ) + self.assertNotEqual( + required_api_permission("POST", base), "miners.control" + ) + + +class MinerControlFrontendTests(unittest.TestCase): + def test_controls_are_safe_confirmation_gated_and_non_polling(self): + source = (APP_ROOT / "frontend" / "static" / "miner-detail.js").read_text() + view = (APP_ROOT / "frontend" / "miner_detail_view.py").read_text() + self.assertIn('window.confirm("Confirm: "', source) + self.assertIn("await loadControls();", source) + self.assertNotIn("setInterval", source) + self.assertNotIn("innerHTML", source) + self.assertIn("minerControlsTitle", view) + self.assertIn("Your account can inspect control capability", view) + + def test_new_ui_does_not_use_legacy_control_routes(self): + source = (APP_ROOT / "frontend" / "static" / "miner-detail.js").read_text() + for legacy in ("agent-control", "/commands/", "api3", "change-password", "reboot"): + self.assertNotIn(legacy, source) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_miner_control_postgres.py b/backend/tests/test_miner_control_postgres.py new file mode 100644 index 0000000..87651a2 --- /dev/null +++ b/backend/tests/test_miner_control_postgres.py @@ -0,0 +1,247 @@ +"""Real PostgreSQL concurrency and transaction regressions for miner control.""" + +import unittest +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from uuid import UUID + +import psycopg +from psycopg.types.json import Jsonb + +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 +from app.miner_control.exceptions import MinerControlConflict +from app.miner_control.repository import MinerControlRepository +from app.miner_control.service import MinerControlService + + +SITE_ID = UUID("00000000-0000-4000-8000-000000001510") +MINER_ID = UUID("00000000-0000-4000-8000-000000001511") +USER_ID = UUID("00000000-0000-4000-8000-000000001512") +OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513") +AGENT_ID = "control-test-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, + ) + + +def result_payload(accepted=True): + return { + "schema_version": 1, + "command_accepted": accepted, + "verification_attempted": False, + "verification_status": "unconfirmed" if accepted else "unavailable", + "observed_service_state": None, + "observed_performance_mode": None, + "failure_code": None if accepted else "write_rejected", + } + + +class MinerControlPostgresTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') " + "ON CONFLICT (id) DO NOTHING", (SITE_ID,) + ) + cur.execute( + """ + INSERT INTO users (id, username, display_name, password_hash, role_id) + SELECT %s, 'control-test-user', 'Control Test User', 'unused', id + FROM roles WHERE name = 'Admin' ON CONFLICT (id) DO NOTHING + """, (USER_ID,) + ) + cur.execute( + """ + INSERT INTO miners ( + id, site_id, asset_tag, current_ip, status, api3_supported, + configured_performance_mode, first_seen_at, last_seen_at + ) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true, + 'normal', NULL, NULL) + ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id, + current_ip = EXCLUDED.current_ip, status = EXCLUDED.status, + api3_supported = true + """, (MINER_ID, SITE_ID) + ) + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, site_id, site_name, last_payload + ) VALUES (%s, %s, 'Control Test Site', %s) + ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id, + last_payload = EXCLUDED.last_payload + """, (AGENT_ID, SITE_ID, Jsonb({"control_available": True})) + ) + connection.commit() + finally: + connection.close() + + @classmethod + def tearDownClass(cls): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute("DELETE FROM audit_logs WHERE entity_id = %s", (MINER_ID,)) + cur.execute( + "DELETE FROM minerops_agent_command_queue " + "WHERE request_payload ->> 'miner_id' IN (%s, %s)", + (str(MINER_ID), str(OTHER_MINER_ID)), + ) + cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,)) + cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,)) + cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,)) + cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,)) + connection.commit() + finally: + connection.close() + + def setUp(self): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute("DELETE FROM audit_logs WHERE entity_id = %s", (MINER_ID,)) + cur.execute( + "DELETE FROM minerops_agent_command_queue " + "WHERE request_payload ->> 'miner_id' IN (%s, %s)", + (str(MINER_ID), str(OTHER_MINER_ID)), + ) + connection.commit() + finally: + connection.close() + + def insert_command(self, command_id, miner_id, status="pending", created=None): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO minerops_agent_command_queue ( + id, agent_id, command_type, command_status, request_payload, created_at + ) VALUES (%s, %s, 'miner_control', %s, %s, COALESCE(%s, now())) + """, + (command_id, AGENT_ID, status, + Jsonb({"schema_version": 1, "miner_id": str(miner_id), + "action": "suspend", "requested_value": None}), created), + ) + connection.commit() + finally: + connection.close() + + def test_two_concurrent_claimers_never_receive_same_command(self): + self.insert_command("concurrent-one", MINER_ID) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map( + lambda _value: AgentCommandRepository(connect).claim_pending(AGENT_ID), + range(2), + )) + claimed = [row["command_id"] for result in results for row in result] + self.assertEqual(claimed, ["concurrent-one"]) + + def test_skip_locked_claims_next_available_row(self): + self.insert_command( + "a-locked", MINER_ID, + created=datetime(2026, 8, 24, 10, tzinfo=timezone.utc), + ) + self.insert_command( + "b-available", OTHER_MINER_ID, + created=datetime(2026, 8, 24, 11, tzinfo=timezone.utc), + ) + locker = connect() + try: + with locker.cursor() as cur: + cur.execute( + "SELECT id FROM minerops_agent_command_queue " + "WHERE id = 'a-locked' FOR UPDATE" + ) + claimed = AgentCommandRepository(connect).claim_pending(AGENT_ID) + self.assertEqual([row["command_id"] for row in claimed], ["b-available"]) + finally: + locker.rollback() + locker.close() + + def test_result_binding_claim_requirement_and_terminal_immutability(self): + self.insert_command("result-one", MINER_ID, status="claimed") + request = AgentCommandResultRequest( + command_id="result-one", status="completed", result_payload=result_payload() + ) + service = AgentCommandService(AgentCommandRepository(connect)) + with self.assertRaises(Exception): + service.submit_result("wrong-agent", request) + service.submit_result(AGENT_ID, request) + with self.assertRaises(Exception): + service.submit_result(AGENT_ID, request) + + self.insert_command("pending-result", OTHER_MINER_ID, status="pending") + pending = AgentCommandResultRequest( + command_id="pending-result", status="failed", result_payload=result_payload(False) + ) + with self.assertRaises(Exception): + service.submit_result(AGENT_ID, pending) + + def test_atomic_queue_audit_duplicate_live_and_completed_history(self): + service = MinerControlService(MinerControlRepository(connect)) + first = service.queue_control( + MINER_ID, "suspend", requested_value=None, + actor_user_id=USER_ID, actor_username="control-test-user", + ) + with self.assertRaises(MinerControlConflict): + service.queue_control( + MINER_ID, "suspend", requested_value=None, + actor_user_id=USER_ID, actor_username="control-test-user", + ) + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "SELECT count(*) FROM audit_logs WHERE entity_id = %s", (MINER_ID,) + ) + self.assertEqual(cur.fetchone()[0], 1) + cur.execute( + "UPDATE minerops_agent_command_queue SET command_status = 'completed', " + "completed_at = now() WHERE id = %s", (first.operation_id,) + ) + connection.commit() + finally: + connection.close() + second = service.queue_control( + MINER_ID, "suspend", requested_value=None, + actor_user_id=USER_ID, actor_username="control-test-user", + ) + self.assertNotEqual(first.operation_id, second.operation_id) + + def test_audit_failure_rolls_back_queue_row(self): + service = MinerControlService(MinerControlRepository(connect)) + operation_count_before = self._operation_count() + with self.assertRaises(Exception): + service.queue_control( + MINER_ID, "suspend", requested_value=None, + actor_user_id=UUID("00000000-0000-4000-8000-000000009999"), + actor_username="missing-user", + ) + self.assertEqual(self._operation_count(), operation_count_before) + + def _operation_count(self): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "SELECT count(*) FROM minerops_agent_command_queue " + "WHERE request_payload ->> 'miner_id' = %s", (str(MINER_ID),) + ) + return cur.fetchone()[0] + finally: + connection.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_miner_detail.py b/backend/tests/test_miner_detail.py index d6679d3..029691f 100644 --- a/backend/tests/test_miner_detail.py +++ b/backend/tests/test_miner_detail.py @@ -498,10 +498,12 @@ class MinerDetailFrontendTests(unittest.TestCase): r'type="button">Refresh', ) source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8") - self.assertIn( - 'el.refresh.addEventListener("click", loadOverview);', + self.assertIn('el.refresh.addEventListener("click", refreshAll);', source) + refresh = re.search( + r"function refreshAll\(\) \{\s*loadOverview\(\);\s*loadControls\(\);\s*\}", source, ) + self.assertIsNotNone(refresh) css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") self.assertIn(".miner-detail-actions .quiet-button { color: var(--ink); }", css) @@ -562,9 +564,14 @@ class MinerDetailFrontendTests(unittest.TestCase): self.assertIn("/events", source) self.assertIn("/parts", source) self.assertNotIn("setInterval", source) + self.assertEqual(source.count('method: "POST"'), 1) + self.assertIn('"/controls/" + pathAction', source) + self.assertIn( + 'action === "performance_mode" ? "performance-mode" : action', source + ) for mutation in ( - 'method: "POST"', 'method: "PATCH"', 'method: "PUT"', - 'method: "DELETE"', "reboot", "change-mac", "manual-details", + 'method: "PATCH"', 'method: "PUT"', 'method: "DELETE"', + "reboot", "change-mac", "manual-details", ): self.assertNotIn(mutation, source) for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"): diff --git a/backend/tests/test_system_health.py b/backend/tests/test_system_health.py index 8221afc..93a9669 100644 --- a/backend/tests/test_system_health.py +++ b/backend/tests/test_system_health.py @@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService APP_ROOT = Path(__file__).parents[1] / "app" -EXPECTED_HEAD = "0014_repair_parts_tracking" +EXPECTED_HEAD = "0015_miner_control_foundation" def database_payload(revision=EXPECTED_HEAD): diff --git a/scripts/seed-development-demo.sh b/scripts/seed-development-demo.sh index c0f1e1a..9cad861 100755 --- a/scripts/seed-development-demo.sh +++ b/scripts/seed-development-demo.sh @@ -78,8 +78,8 @@ migration_head="$( "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"' )" -if [[ "${migration_head}" != "0014_repair_parts_tracking" ]]; then - echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0014_repair_parts_tracking." >&2 +if [[ "${migration_head}" != "0015_miner_control_foundation" ]]; then + echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0015_miner_control_foundation." >&2 exit 1 fi diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 531ec18..e8313a5 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0014_repair_parts_tracking"* ]] +[[ "${current_revision}" == *"0015_miner_control_foundation"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ diff --git a/scripts/test-miner-control-migration.sh b/scripts/test-miner-control-migration.sh new file mode 100755 index 0000000..14f5d42 --- /dev/null +++ b/scripts/test-miner-control-migration.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_CONTROL_MIGRATION_PROJECT:-minerops-control-migration-$$}" +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 120 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT +run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; } +psql_db() { + "${compose[@]}" exec -T postgres sh -ec \ + 'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@" +} + +[[ -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 "Testing 0015 preflight failures from the previous head..." +run_alembic upgrade 0014_repair_parts_tracking +psql_db -c "INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status) VALUES ('unknown-status', 'test-agent', 'test', 'mystery')" +if output="$(run_alembic upgrade 0015_miner_control_foundation 2>&1)"; then + echo "Unknown queue status unexpectedly passed migration preflight" >&2 + exit 1 +fi +[[ "${output}" == *"unsupported command_status values: mystery"* ]] +psql_db -c "DELETE FROM minerops_agent_command_queue WHERE id = 'unknown-status'" + +duplicate_miner="00000000-0000-4000-8000-000000001599" +psql_db <&2 + exit 1 +fi +[[ "${output}" == *"more than one pending or claimed miner_control"* ]] +[[ "${output}" == *"${duplicate_miner}"* ]] +psql_db -c "DELETE FROM minerops_agent_command_queue WHERE id IN ('duplicate-one', 'duplicate-two')" + +psql_db <<'SQL' +INSERT INTO minerops_site_agents (agent_id, site_name) +VALUES ('legacy-unassigned-agent', 'Legacy Display Name'); +INSERT INTO minerops_agent_command_queue ( + id, agent_id, command_type, command_status, request_payload +) VALUES ( + 'preserved-history', 'legacy-unassigned-agent', 'miner_control', 'completed', + '{"miner_id":"00000000-0000-4000-8000-000000001598","action":"suspend"}' +); +SQL +run_alembic upgrade 0015_miner_control_foundation +[[ "$(run_alembic current 2>/dev/null)" == *"0015_miner_control_foundation"* ]] + +echo "Checking assignment, status, uniqueness, history, and preservation..." +psql_db <<'SQL' +DO $$ +BEGIN + IF (SELECT site_id IS NOT NULL FROM minerops_site_agents + WHERE agent_id = 'legacy-unassigned-agent') THEN + RAISE EXCEPTION 'Legacy site_name was silently converted to site_id'; + END IF; + IF NOT EXISTS (SELECT 1 FROM minerops_agent_command_queue + WHERE id = 'preserved-history' AND command_status = 'completed') THEN + RAISE EXCEPTION 'Historical queue row was not preserved'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'ck_minerops_agent_command_queue_status') THEN + RAISE EXCEPTION 'Queue status constraint missing'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_indexes + WHERE indexname = 'uq_agent_queue_live_miner_control') + OR NOT EXISTS (SELECT 1 FROM pg_indexes + WHERE indexname = 'idx_agent_queue_miner_control_history') THEN + RAISE EXCEPTION 'Miner control queue indexes missing'; + END IF; +END $$; + +INSERT INTO sites (id, name) +VALUES ('00000000-0000-4000-8000-000000001590', 'Control Migration Site'); +UPDATE minerops_site_agents SET site_id = '00000000-0000-4000-8000-000000001590' +WHERE agent_id = 'legacy-unassigned-agent'; + +DO $$ +BEGIN + BEGIN + INSERT INTO minerops_site_agents (agent_id, site_id, site_name) + VALUES ('duplicate-site-agent', '00000000-0000-4000-8000-000000001590', 'Duplicate'); + RAISE EXCEPTION 'Duplicate durable site assignment was accepted'; + EXCEPTION WHEN unique_violation THEN NULL; + END; + BEGIN + DELETE FROM sites WHERE id = '00000000-0000-4000-8000-000000001590'; + RAISE EXCEPTION 'Assigned site deletion was accepted'; + EXCEPTION WHEN foreign_key_violation THEN NULL; + END; + BEGIN + INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status) + VALUES ('bad-status-after', 'legacy-unassigned-agent', 'test', 'mystery'); + RAISE EXCEPTION 'Unknown status passed CHECK'; + EXCEPTION WHEN check_violation THEN NULL; + END; +END $$; +SQL + +echo "Testing downgrade, historical preservation, and re-upgrade..." +run_alembic downgrade 0014_repair_parts_tracking +[[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_agent_command_queue WHERE id = 'preserved-history'")" == "1" ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM information_schema.columns WHERE table_name='minerops_site_agents' AND column_name='site_id'")" == "0" ]] +run_alembic upgrade 0015_miner_control_foundation + +echo "Testing empty database through head..." +"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty' +"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head +empty_revision="$("${compose[@]}" exec -T postgres sh -ec \ + 'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')" +[[ "${empty_revision}" == "0015_miner_control_foundation" ]] + +echo "Miner Control Foundation migration tests passed." diff --git a/scripts/test-miner-control.sh b/scripts/test-miner-control.sh new file mode 100755 index 0000000..c425874 --- /dev/null +++ b/scripts/test-miner-control.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_CONTROL_TEST_PROJECT:-minerops-control-test-$$}" +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 120 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_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py; do + permissions="$(stat -c '%a' "${source_file}")" + (( (10#${permissions} % 10) & 4 )) || { + echo "${source_file} is not readable by the non-root test container" >&2 + exit 1 + } +done +for source_directory in agents agents/windows agents/windows/tests; do + permissions="$(stat -c '%a' "${source_directory}")" + (( (10#${permissions} % 10) & 1 )) || { + echo "${source_directory} is not traversable 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 +"${compose[@]}" run --rm --no-deps backend alembic upgrade head +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + -v "${repo_root}/agents:/agents:ro" \ + backend python -m unittest -v \ + tests.test_miner_control \ + tests.test_agent_commands \ + tests.test_miner_control_postgres + +echo "Miner Control Foundation focused tests passed." diff --git a/scripts/test-windows-agent-source.sh b/scripts/test-windows-agent-source.sh new file mode 100755 index 0000000..3181024 --- /dev/null +++ b/scripts/test-windows-agent-source.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +service="agents/windows/MinerOpsAgentService.ps1" +control="agents/windows/MinerOpsControl.ps1" +rg -q 'X-MinerOps-Agent-ID' "${service}" +! rg -q 'commands/pending\?agent_id=' "${service}" +! rg -q 'DefaultValue "super"' "${service}" +rg -q 'get\.miner\.setting' "${control}" +rg -q '\$attempt -le 3' "${control}" +rg -q 'VerificationStatus "unconfirmed"' "${control}" +for secret_field in 'api3_password =' 'api3_account =' 'raw_vendor' 'salt_response'; do + ! rg -q "${secret_field}" "${control}" +done + +if command -v pwsh >/dev/null 2>&1; then + pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1 +else + echo "pwsh is not installed; deterministic PowerShell harness retained but not executed." +fi +echo "Windows Agent source contract checks passed." -- 2.47.3 From d8cd7ad058e440e7fa0ee2581a5a10097aefb3cc Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 25 Aug 2026 15:56:35 +0000 Subject: [PATCH 3/3] fix: preserve Windows agent tasks during upgrades --- agents/windows/README.md | 25 ++- agents/windows/install.ps1 | 190 +++++++++++++++--- .../windows/tests/Test-MinerOpsInstaller.ps1 | 60 ++++++ scripts/test-windows-agent-source.sh | 14 +- 4 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 agents/windows/tests/Test-MinerOpsInstaller.ps1 diff --git a/agents/windows/README.md b/agents/windows/README.md index 3b59509..5bbc9d5 100644 --- a/agents/windows/README.md +++ b/agents/windows/README.md @@ -51,15 +51,24 @@ Deploy Agent compatibility before enabling Hub enforcement of the reports the expected non-secret `VERSION`. 5. Enable the matching Agent ID and shared secret on the Hub. -The installer copies runtime code to `C:\MinerOpsAgent`, preserves -`agent-config.json`, `agent-state.json`, and `logs\`, and registers or -updates `MinerOpsAgent`, `MinerOpsAgentTray`, -`MinerOpsApi3Enrichment`, and `MinerOpsPoolPoller`. +The installer copies only managed runtime code to `C:\MinerOpsAgent` and +preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe +output, and other runtime data. During an upgrade, existing definitions for +`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and +`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are +created for a fresh or incomplete installation without hard-coded user names. + +If `MinerOpsAgent` was running before an upgrade, the installer stops only that +Scheduled Task, waits for it to stop, replaces the managed files, and starts it +again. A previously stopped Agent remains stopped. The tray and periodic tasks +are not unnecessarily restarted. Before replacing existing runtime files, the installer copies them to a -timestamped folder under `C:\MinerOpsAgent\backups`. To roll back, stop the -Agent tasks, copy the selected backup's runtime files back into -`C:\MinerOpsAgent`, and restart the tasks. `uninstall.ps1` unregisters the -four tasks and removes runtime code while preserving local configuration, +timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment +automatically restores those managed files, removes tasks created by that +failed attempt, and restores the main Agent's prior running state. For a manual +rollback, stop `MinerOpsAgent`, restore the selected backup's runtime files, +and restart it only if it was previously running. `uninstall.ps1` unregisters +the four tasks and removes runtime code while preserving local configuration, state, logs, and backups. diff --git a/agents/windows/install.ps1 b/agents/windows/install.ps1 index a0abec4..4025bdc 100644 --- a/agents/windows/install.ps1 +++ b/agents/windows/install.ps1 @@ -3,7 +3,9 @@ $ErrorActionPreference = "Stop" $Target = "C:\MinerOpsAgent" $BackupRoot = Join-Path $Target "backups" -$Backup = Join-Path $BackupRoot (Get-Date -Format "yyyyMMdd-HHmmss") +$BackupTimestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$BackupSuffix = [guid]::NewGuid().ToString("N").Substring(0, 8) +$Backup = Join-Path $BackupRoot "$BackupTimestamp-$BackupSuffix" $RuntimeFiles = @( "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", @@ -11,52 +13,190 @@ $RuntimeFiles = @( "VERSION" ) -New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null -$existing = @($RuntimeFiles | Where-Object { Test-Path (Join-Path $Target $_) }) -if ($existing.Count -gt 0) { - New-Item -ItemType Directory -Force -Path $Backup | Out-Null - foreach ($name in $existing) { - Copy-Item (Join-Path $Target $name) (Join-Path $Backup $name) -Force +function Get-MinerOpsScheduledTask { + param([Parameter(Mandatory)][string]$Name) + + return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue +} + +function Stop-MinerOpsScheduledTaskForUpgrade { + param( + [Parameter(Mandatory)][string]$Name, + [TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30) + ) + + Stop-ScheduledTask -TaskName $Name -ErrorAction Stop + $deadline = [DateTime]::UtcNow.Add($Timeout) + do { + Start-Sleep -Milliseconds 500 + $task = Get-MinerOpsScheduledTask -Name $Name + if ($null -eq $task) { + throw "Scheduled Task $Name disappeared while stopping it." + } + if ([string]$task.State -ne "Running") { + return + } + } while ([DateTime]::UtcNow -lt $deadline) + + throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds." +} + +function Start-MinerOpsScheduledTask { + param([Parameter(Mandatory)][string]$Name) + + Start-ScheduledTask -TaskName $Name -ErrorAction Stop +} + +# Validate the complete source before creating directories, stopping tasks, or +# replacing any target file. +foreach ($name in $RuntimeFiles) { + $source = Join-Path $PSScriptRoot $name + if (!(Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Deployment source is missing $name" } } +$mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent" +$mainTaskExisted = $null -ne $mainTask +$mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running" +$mainTaskStoppedForUpgrade = $false +$deploymentStarted = $false +$existing = @() +$createdTaskNames = [System.Collections.Generic.List[string]]::new() + try { + New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null + $existing = @($RuntimeFiles | Where-Object { + Test-Path -LiteralPath (Join-Path $Target $_) -PathType Leaf + }) + if ($existing.Count -gt 0) { + New-Item -ItemType Directory -Force -Path $Backup | Out-Null + foreach ($name in $existing) { + Copy-Item -LiteralPath (Join-Path $Target $name) ` + -Destination (Join-Path $Backup $name) -Force + } + } + + if ($mainTaskWasRunning) { + $mainTaskStoppedForUpgrade = $true + Stop-MinerOpsScheduledTaskForUpgrade -Name "MinerOpsAgent" + } + + $deploymentStarted = $true foreach ($name in $RuntimeFiles) { - $source = Join-Path $PSScriptRoot $name - if (!(Test-Path $source)) { throw "Deployment source is missing $name" } - Copy-Item $source (Join-Path $Target $name) -Force + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) ` + -Destination (Join-Path $Target $name) -Force } $powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" - $system = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + $system = New-ScheduledTaskPrincipal -UserId "SYSTEM" ` + -LogonType ServiceAccount -RunLevel Highest $startup = New-ScheduledTaskTrigger -AtStartup $fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` -RepetitionInterval (New-TimeSpan -Minutes 5) + $commonSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew ` + -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Hours 72) + $agentSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew ` + -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Hours 72) -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) $definitions = @( - @{ Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"; Trigger = $startup; Principal = $system }, - @{ Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"; Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null }, - @{ Name = "MinerOpsApi3Enrichment"; Script = "MinerOpsApi3EnrichmentPoller.ps1"; Trigger = $fiveMinutes; Principal = $system }, - @{ Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"; Trigger = $fiveMinutes; Principal = $system } + @{ + Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1" + Trigger = $startup; Principal = $system; Settings = $agentSettings + ExtraArguments = "" + }, + @{ + Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1" + Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null + Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden" + }, + @{ + Name = "MinerOpsApi3Enrichment" + Script = "MinerOpsApi3EnrichmentPoller.ps1" + Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings + ExtraArguments = "-Limit 0 -TimeoutMs 8000" + }, + @{ + Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1" + Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings + ExtraArguments = "" + } ) foreach ($definition in $definitions) { + if ($null -ne (Get-MinerOpsScheduledTask -Name $definition.Name)) { + Write-Host "Preserving existing Scheduled Task $($definition.Name)." + continue + } + $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f ` (Join-Path $Target $definition.Script) + if ($definition.ExtraArguments) { + $arguments = "$arguments $($definition.ExtraArguments)" + } $action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments + $registration = @{ + TaskName = $definition.Name + Action = $action + Trigger = $definition.Trigger + Settings = $definition.Settings + } if ($definition.Principal) { - Register-ScheduledTask -TaskName $definition.Name -Action $action ` - -Trigger $definition.Trigger -Principal $definition.Principal -Force | Out-Null - } else { - Register-ScheduledTask -TaskName $definition.Name -Action $action ` - -Trigger $definition.Trigger -Force | Out-Null + $registration.Principal = $definition.Principal } + Register-ScheduledTask @registration | Out-Null + $createdTaskNames.Add($definition.Name) } - Write-Host "MinerOps Windows Agent installed. Existing config, state, and logs were preserved." + + if ($mainTaskWasRunning) { + Start-MinerOpsScheduledTask -Name "MinerOpsAgent" + $mainTaskStoppedForUpgrade = $false + } + + Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved." } catch { - if (Test-Path $Backup) { - foreach ($name in $existing) { - Copy-Item (Join-Path $Backup $name) (Join-Path $Target $name) -Force + $deploymentError = $_ + $rollbackErrors = [System.Collections.Generic.List[string]]::new() + + foreach ($taskName in $createdTaskNames) { + try { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false ` + -ErrorAction Stop + } catch { + $rollbackErrors.Add("could not remove new task ${taskName}: $($_.Exception.Message)") } } - throw + + if ($deploymentStarted) { + foreach ($name in $RuntimeFiles) { + try { + $targetPath = Join-Path $Target $name + if ($existing -contains $name) { + Copy-Item -LiteralPath (Join-Path $Backup $name) ` + -Destination $targetPath -Force + } elseif (Test-Path -LiteralPath $targetPath) { + Remove-Item -LiteralPath $targetPath -Force + } + } catch { + $rollbackErrors.Add("could not restore ${name}: $($_.Exception.Message)") + } + } + } + + if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade) { + try { + Start-MinerOpsScheduledTask -Name "MinerOpsAgent" + $mainTaskStoppedForUpgrade = $false + } catch { + $rollbackErrors.Add("could not restart restored MinerOpsAgent: $($_.Exception.Message)") + } + } + + if ($rollbackErrors.Count -gt 0) { + $rollbackSummary = $rollbackErrors -join "; " + throw "Deployment failed: $($deploymentError.Exception.Message). Rollback errors: $rollbackSummary" + } + throw $deploymentError } diff --git a/agents/windows/tests/Test-MinerOpsInstaller.ps1 b/agents/windows/tests/Test-MinerOpsInstaller.ps1 new file mode 100644 index 0000000..b57b433 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsInstaller.ps1 @@ -0,0 +1,60 @@ +$ErrorActionPreference = "Stop" +$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1" +$tokens = $null +$parseErrors = $null +[System.Management.Automation.Language.Parser]::ParseFile( + $installer, [ref]$tokens, [ref]$parseErrors +) | Out-Null +if ($parseErrors.Count -gt 0) { + throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')" +} + +$source = Get-Content -LiteralPath $installer -Raw +function Assert-SourceContains { + param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message) + if ($source -notmatch $Pattern) { throw $Message } +} + +$runtimeMatch = [regex]::Match( + $source, + '(?s)\$RuntimeFiles\s*=\s*@\((?.*?)\)' +) +if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." } +$runtimeFiles = $runtimeMatch.Groups["files"].Value +foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) { + if ($runtimeFiles -match [regex]::Escape($forbidden)) { + throw "$forbidden must not be a managed deployment target." + } +} + +$preflight = $source.IndexOf('# Validate the complete source') +$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory') +if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) { + throw "Source preflight must happen before target changes." +} + +Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' ` + "Existing task preservation guard is missing." +Assert-SourceContains 'Preserving existing Scheduled Task' ` + "Existing task preservation is not explicit." +Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' ` + "Fresh enrichment task arguments are missing." +Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' ` + "Bounded main Agent stop behavior is missing." +Assert-SourceContains '\$mainTaskWasRunning' ` + "Main Agent prior running state is not represented." +Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' ` + "Rollback does not restore managed files from backup." +Assert-SourceContains 'Unregister-ScheduledTask' ` + "Rollback does not remove tasks created by a failed install." +Assert-SourceContains 'could not restart restored MinerOpsAgent' ` + "Rollback restart failure is not surfaced." + +if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') { + throw "Installer must not force-overwrite Scheduled Tasks." +} +if ($source -match '(?i)agent_secret|api3_password|api3_account') { + throw "Installer must not inspect or print credentials." +} + +Write-Host "MinerOps installer source safety tests passed." diff --git a/scripts/test-windows-agent-source.sh b/scripts/test-windows-agent-source.sh index 3181024..6506221 100755 --- a/scripts/test-windows-agent-source.sh +++ b/scripts/test-windows-agent-source.sh @@ -3,6 +3,7 @@ set -euo pipefail service="agents/windows/MinerOpsAgentService.ps1" control="agents/windows/MinerOpsControl.ps1" +installer="agents/windows/install.ps1" rg -q 'X-MinerOps-Agent-ID' "${service}" ! rg -q 'commands/pending\?agent_id=' "${service}" ! rg -q 'DefaultValue "super"' "${service}" @@ -13,9 +14,20 @@ for secret_field in 'api3_password =' 'api3_account =' 'raw_vendor' 'salt_respon ! rg -q "${secret_field}" "${control}" done +rg -q 'Get-MinerOpsScheduledTask' "${installer}" +rg -q 'Stop-MinerOpsScheduledTaskForUpgrade' "${installer}" +rg -q 'mainTaskWasRunning' "${installer}" +rg -q 'Preserving existing Scheduled Task' "${installer}" +rg -q -- '-Limit 0 -TimeoutMs 8000' "${installer}" +rg -q 'Unregister-ScheduledTask' "${installer}" +rg -q 'could not restart restored MinerOpsAgent' "${installer}" +! rg -q 'Register-ScheduledTask.*-Force' "${installer}" +! rg -q 'agent_secret|api3_password|api3_account' "${installer}" + if command -v pwsh >/dev/null 2>&1; then pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1 + pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.ps1 else - echo "pwsh is not installed; deterministic PowerShell harness retained but not executed." + echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed." fi echo "Windows Agent source contract checks passed." -- 2.47.3