| param( |
| [Parameter(Position = 0, Mandatory = $true)][ValidateSet('hub', 'poll', 'send')][string]$Mode, |
| [Alias('Via')][string]$Hub, |
| [Alias('From')][string]$Me, |
| [string]$To, |
| [string]$Body, |
| [string]$BodyFile, |
| [ValidateSet('task', 'report', 'chat')][string]$Type = 'task', |
| [int]$Port = 0, |
| [string]$Key, |
| [string]$KeyFile = ".\.agent-comm.key", |
| [string]$Store = (Join-Path ([System.IO.Path]::GetTempPath()) "agent-comm"), |
| [int]$TimeoutMinutes = 40320, |
| [int]$PollSeconds = 3, |
| [int]$MaxAgeMinutes = 15, |
| [int]$RetrySeconds = 60, |
| [int]$RejectLimit = 5, |
| [int]$BanThreshold = 10, |
| [int]$BanMinutes = 10, |
| [int]$MinKeyLength = 24, |
| [int]$MaxLineChars = 200000, |
| [int]$PortMin = 20000, |
| [int]$PortMax = 60000, |
| [switch]$Lan |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| if (-not $Key) { |
| if (Test-Path -LiteralPath $KeyFile) { $Key = ([System.IO.File]::ReadAllText((Resolve-Path -LiteralPath $KeyFile).Path)).Trim() } |
| } |
| if (-not $Key -or $Key.Length -lt $MinKeyLength) { |
| Write-Output "ERROR bad-config: no/weak key. Pass -Key or create $KeyFile (min $MinKeyLength chars). Generate: openssl rand -base64 32" |
| exit 3 |
| } |
| $hmacObj = New-Object System.Security.Cryptography.HMACSHA256 |
| $hmacObj.Key = [System.Text.Encoding]::UTF8.GetBytes($Key) |
| function Sign([string]$payload) { |
| return -join ($hmacObj.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($payload)) | ForEach-Object { $_.ToString('x2') }) |
| } |
| function FreshEnough($created) { |
| if ($MaxAgeMinutes -le 0) { return $true } |
| try { |
| $t = [datetime]::Parse("$created", [System.Globalization.CultureInfo]::InvariantCulture) |
| return ([math]::Abs(((Get-Date) - $t).TotalMinutes) -le $MaxAgeMinutes) |
| } catch { return $false } |
| } |
|
|
| |
| $targetHost = $Hub |
| if ($Hub -and $Hub.Contains(":")) { |
| $parts = $Hub.Split(":") |
| $targetHost = $parts[0] |
| $Port = [int]$parts[1] |
| } |
| function Connect-Hub { |
| $c = New-Object System.Net.Sockets.TcpClient |
| $async = $c.BeginConnect($targetHost, $Port, $null, $null) |
| if (-not $async.AsyncWaitHandle.WaitOne(2000)) { $c.Close(); throw "connect timeout" } |
| $c.EndConnect($async) |
| $c.ReceiveTimeout = 8000 |
| $c.SendTimeout = 5000 |
| return $c |
| } |
| function Get-LanIP { |
| try { |
| foreach ($a in [System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName())) { |
| if ($a.AddressFamily -eq 'InterNetwork' -and $a.ToString() -ne '127.0.0.1') { return $a.ToString() } |
| } |
| } catch {} |
| return $null |
| } |
|
|
| |
| if ($Mode -eq 'hub') { |
| New-Item -ItemType Directory -Force $Store | Out-Null |
| $bindAddr = if ($Lan) { [System.Net.IPAddress]::Any } else { [System.Net.IPAddress]::Loopback } |
|
|
| $listener = $null |
| if ($Port -gt 0) { |
| $listener = New-Object System.Net.Sockets.TcpListener($bindAddr, $Port) |
| try { $listener.Start() } catch { Write-Output "ERROR bad-config: cannot listen on tcp/${Port}: $($_.Exception.Message)"; exit 3 } |
| } else { |
| for ($i = 0; ($i -lt 60) -and (-not $listener); $i++) { |
| $p = Get-Random -Minimum $PortMin -Maximum $PortMax |
| $cand = New-Object System.Net.Sockets.TcpListener($bindAddr, $p) |
| try { $cand.Start(); $listener = $cand; $Port = $p } catch { } |
| } |
| if (-not $listener) { Write-Output "ERROR bad-config: no free port found in $PortMin-$PortMax"; exit 3 } |
| } |
|
|
| $advHost = if ($Lan) { $ip = Get-LanIP; if ($ip) { $ip } else { "<this-lan-ip>" } } else { "localhost" } |
| Write-Output "hub ready host=$advHost port=$Port bind=$($bindAddr.ToString())$(if(-not $Lan){' (loopback only; peers connect through an SSH tunnel)'})" |
| Write-Output "handoff (fill each peer's own name; the key is an execution credential): AGENTCOMM host=$advHost port=$Port me=<PEER> key=<KEY>" |
|
|
| $seen = @{} |
| $fails = @{} |
| $bans = @{} |
| function Note-Fail([string]$ip) { |
| if ([string]::IsNullOrEmpty($ip) -or $ip -eq '?') { return } |
| if ($bans.ContainsKey($ip)) { return } |
| $now = Get-Date |
| if ($fails.ContainsKey($ip) -and ($now - $fails[$ip].first).TotalMinutes -le $BanMinutes) { $fails[$ip].n = $fails[$ip].n + 1 } |
| else { $fails[$ip] = @{ n = 1; first = $now } } |
| if ($fails[$ip].n -ge $BanThreshold) { $bans[$ip] = $now.AddMinutes($BanMinutes); $fails.Remove($ip) } |
| } |
|
|
| $deadline = (Get-Date).AddMinutes($TimeoutMinutes) |
| while ((Get-Date) -lt $deadline) { |
| if (-not $listener.Pending()) { Start-Sleep -Milliseconds 300; continue } |
| $client = $listener.AcceptTcpClient() |
| $ip = '?' |
| try { $ip = ([System.Net.IPEndPoint]$client.Client.RemoteEndPoint).Address.ToString() } catch {} |
|
|
| $now = Get-Date |
| foreach ($k in @($seen.Keys)) { if ($seen[$k] -le $now) { $seen.Remove($k) } } |
| foreach ($k in @($bans.Keys)) { if ($bans[$k] -le $now) { $bans.Remove($k) } } |
| foreach ($k in @($fails.Keys)) { if (($now - $fails[$k].first).TotalMinutes -gt $BanMinutes) { $fails.Remove($k) } } |
| if ($bans.ContainsKey($ip)) { $client.Close(); continue } |
|
|
| try { |
| $client.ReceiveTimeout = 10000 |
| $client.SendTimeout = 10000 |
| $stream = $client.GetStream() |
| $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) |
| $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::UTF8) |
| $writer.AutoFlush = $true |
|
|
| $line = $null |
| try { $line = $reader.ReadLine() } catch {} |
| if ($null -eq $line -or $line.Length -gt $MaxLineChars) { Note-Fail $ip; continue } |
| $req = $null |
| try { $req = $line | ConvertFrom-Json } catch {} |
| if ($null -eq $req -or $null -eq $req.op -or $null -eq $req.id -or $null -eq $req.hmac) { Note-Fail $ip; continue } |
|
|
| |
| $expected = $null |
| if ($req.op -eq 'put') { $expected = Sign "$($req.id)|$($req.from)|$($req.to)|$($req.type)|$($req.created)|$($req.body)" } |
| elseif ($req.op -eq 'get') { $expected = Sign "$($req.id)|get|$($req.for)|$($req.created)" } |
| if ($null -eq $expected -or $expected -ne $req.hmac) { Note-Fail $ip; continue } |
|
|
| if ($req.op -eq 'put') { |
| if (-not (FreshEnough $req.created) -or |
| "$($req.to)" -notmatch '^[A-Za-z0-9_-]{1,32}$' -or |
| "$($req.id)" -notmatch '^[A-Za-z0-9]{1,64}$') { $writer.WriteLine("REJECT"); continue } |
| if ($seen.ContainsKey($req.id)) { $writer.WriteLine("OK $($req.id)"); continue } |
| $box = Join-Path $Store $req.to |
| New-Item -ItemType Directory -Force $box | Out-Null |
| $tmp = Join-Path $box "$($req.id).tmp" |
| [System.IO.File]::WriteAllText($tmp, $line) |
| Rename-Item -LiteralPath $tmp -NewName "$($req.id).json" |
| $exp = (Get-Date).AddMinutes($MaxAgeMinutes) |
| try { $exp = [datetime]::Parse("$($req.created)", [System.Globalization.CultureInfo]::InvariantCulture).AddMinutes($MaxAgeMinutes) } catch {} |
| $seen[$req.id] = $exp |
| $writer.WriteLine("OK $($req.id)") |
| } |
| elseif ($req.op -eq 'get') { |
| if (-not (FreshEnough $req.created) -or "$($req.for)" -notmatch '^[A-Za-z0-9_-]{1,32}$') { $writer.WriteLine("REJECT"); continue } |
| $box = Join-Path $Store $req.for |
| $files = @() |
| if (Test-Path -LiteralPath $box) { $files = @(Get-ChildItem -LiteralPath $box -Filter *.json | Sort-Object LastWriteTime) } |
| foreach ($f in $files) { $writer.WriteLine(([System.IO.File]::ReadAllText($f.FullName)).Trim()) } |
| $writer.WriteLine("END") |
| if ($files.Count -gt 0) { |
| $ack = $null |
| try { $ack = $reader.ReadLine() } catch {} |
| if ($ack -eq 'ACK') { $files | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } } |
| } |
| } |
| } catch { |
| } finally { |
| $client.Close() |
| } |
| } |
| $listener.Stop() |
| Write-Output "hub lifetime of $TimeoutMinutes min elapsed; re-arm to keep the bridge alive (queued mail stays in $Store)" |
| exit 2 |
| } |
|
|
| |
| if ($Mode -eq 'poll') { |
| if (-not $targetHost -or $Port -le 0 -or -not $Me) { Write-Output "ERROR bad-config: poll needs -Hub <host:port> and -Me <name>"; exit 3 } |
| $deadline = (Get-Date).AddMinutes($TimeoutMinutes) |
| $blank = 0 |
| while ((Get-Date) -lt $deadline) { |
| $client = $null; $lines = $null; $connected = $false; $gotEnd = $false |
| try { |
| $client = Connect-Hub; $connected = $true |
| $stream = $client.GetStream() |
| $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::UTF8); $writer.AutoFlush = $true |
| $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) |
| $gid = [guid]::NewGuid().ToString('N'); $created = (Get-Date).ToString('s') |
| $req = [ordered]@{ op = 'get'; id = $gid; for = $Me; created = $created; hmac = (Sign "$gid|get|$Me|$created") } | ConvertTo-Json -Compress |
| $writer.WriteLine($req) |
| $lines = New-Object System.Collections.Generic.List[string] |
| while ($true) { |
| $l = $reader.ReadLine() |
| if ($null -eq $l) { throw "no valid reply" } |
| if ($l -eq 'END') { $gotEnd = $true; break } |
| if ($l -eq 'REJECT') { throw "refused" } |
| $lines.Add($l) |
| } |
| if ($lines.Count -gt 0) { $writer.WriteLine("ACK") } |
| } catch { $lines = $null } finally { if ($null -ne $client) { $client.Close() } } |
|
|
| if ($gotEnd) { |
| $blank = 0 |
| if ($null -ne $lines -and $lines.Count -gt 0) { |
| $shown = 0 |
| foreach ($l in $lines) { |
| $msg = $null; try { $msg = $l | ConvertFrom-Json } catch {} |
| if ($null -eq $msg -or $null -eq $msg.id -or $null -eq $msg.hmac) { continue } |
| |
| |
| $expected = Sign "$($msg.id)|$($msg.from)|$($msg.to)|$($msg.type)|$($msg.created)|$($msg.body)" |
| if ($expected -ne $msg.hmac -or $msg.to -ne $Me) { Write-Output "(dropped a message that failed verification: id=$($msg.id))"; continue } |
| Write-Output "=== MESSAGE from $($msg.from) [$($msg.type)] id=$($msg.id) ===" |
| Write-Output $msg.body |
| $shown++ |
| } |
| if ($shown -gt 0) { exit 0 } |
| } |
| } elseif ($connected) { |
| $blank++ |
| if ($blank -ge $RejectLimit) { Write-Output "ERROR bad-config: reached ${targetHost}:$Port but got no valid reply $blank times; check the key/name on both ends (a wrong key looks exactly like this), or the port may hit another service"; exit 3 } |
| } |
| Start-Sleep -Seconds $PollSeconds |
| } |
| Write-Output "poll idle timeout after $TimeoutMinutes min, no mail for '$Me'; re-arm to keep listening" |
| exit 2 |
| } |
|
|
| |
| if ($Mode -eq 'send') { |
| if (-not $targetHost -or $Port -le 0 -or -not $Me -or -not $To) { Write-Output "ERROR bad-config: send needs -Hub <host:port>, -Me <name>, -To <peer>"; exit 3 } |
| if ($BodyFile) { |
| if (-not (Test-Path -LiteralPath $BodyFile)) { Write-Output "ERROR bad-config: body file missing: $BodyFile"; exit 3 } |
| $Body = [System.IO.File]::ReadAllText((Resolve-Path -LiteralPath $BodyFile).Path) |
| } |
| if ([string]::IsNullOrWhiteSpace($Body)) { Write-Output "ERROR bad-config: empty body (use -Body or -BodyFile)"; exit 3 } |
| $id = [guid]::NewGuid().ToString('N') |
| $created = (Get-Date).ToString('s') |
| $json = [ordered]@{ |
| op = 'put'; id = $id; from = $Me; to = $To; type = $Type; body = $Body; created = $created |
| hmac = (Sign "$id|$Me|$To|$Type|$created|$Body") |
| } | ConvertTo-Json -Compress |
| $deadline = (Get-Date).AddSeconds($RetrySeconds) |
| while ($true) { |
| $client = $null |
| try { |
| $client = Connect-Hub |
| $stream = $client.GetStream() |
| $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::UTF8); $writer.AutoFlush = $true |
| $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) |
| $writer.WriteLine($json) |
| $response = $null |
| try { $response = $reader.ReadLine() } catch {} |
| if ($null -eq $response) { throw "no reply" } |
| if ($response -like "OK*") { Write-Output "queued $Type $id for '$To' at ${targetHost}:$Port"; exit 0 } |
| Write-Output "REJECTED by hub ${targetHost}:$Port (authenticated but refused: bad peer name, stale clock, or briefly banned)" |
| exit 4 |
| } catch { |
| if ((Get-Date) -ge $deadline) { |
| Write-Output "ERROR unreachable: no reply from ${targetHost}:$Port for ${RetrySeconds}s (hub not armed, box off, wrong port, firewall, or WRONG KEY -- a bad key gets silence, not a rejection)" |
| exit 5 |
| } |
| Start-Sleep -Seconds 2 |
| } finally { |
| if ($null -ne $client) { $client.Close() } |
| } |
| } |
| } |