WIP: Windows Agent baseline, miner control, and task-preserving upgrades #2
No files matched your search
@@ -0,0 +1,2 @@
|
||||
*.ps1 -text
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
agent-config.json
|
||||
agent-state.json
|
||||
logs/
|
||||
*.bak
|
||||
*.bak.*
|
||||
*-probe-*.json
|
||||
rollback/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user