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