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 +} +