Files
minerops/agents/windows/MinerOpsAgentService.ps1
T

1468 lines
43 KiB
PowerShell

$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<MinerOpsScanResult>();
var locker = new object();
var semaphore = new SemaphoreSlim(maxConcurrency);
var tasks = new List<Task>();
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<MinerOpsScanResult> 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<byte>();
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
}
}