param( [Parameter(Position = 0, Mandatory = $true)][ValidateSet('hub', 'poll', 'send')][string]$Mode, [Alias('Via')][string]$Hub, # host:port (poll/send); learned out-of-band from the hub's startup line [Alias('From')][string]$Me, # this session's peer name (poll/send) [string]$To, # recipient peer name (send) [string]$Body, [string]$BodyFile, [ValidateSet('task', 'report', 'chat')][string]$Type = 'task', [int]$Port = 0, # hub: 0 = auto-pick a free random port and report it; or pin one [string]$Key, [string]$KeyFile = ".\.agent-comm.key", # in the CURRENT working dir on purpose: visible, no hidden global fingerprint. Do NOT commit. [string]$Store = (Join-Path ([System.IO.Path]::GetTempPath()) "agent-comm"), [int]$TimeoutMinutes = 40320, # four weeks; a timeout wake-up is pure token cost, keep it rare [int]$PollSeconds = 3, [int]$MaxAgeMinutes = 15, [int]$RetrySeconds = 60, [int]$RejectLimit = 5, # poll: consecutive no-valid-reply cycles before flagging a config/key error [int]$BanThreshold = 10, # hub: bad requests from one IP before a temporary ban [int]$BanMinutes = 10, [int]$MinKeyLength = 24, [int]$MaxLineChars = 200000, [int]$PortMin = 20000, [int]$PortMax = 60000, [switch]$Lan # hub: bind all interfaces for a TRUSTED LAN. Default is loopback-only (tunnel it over the internet). ) # One script, three modes. # hub : the relay. Binds LOOPBACK by default (invisible; reach it via SSH tunnel); -Lan for a trusted LAN. # Auto-picks a free random port unless -Port is pinned, and prints its address. Queues signed # messages per recipient under $Store. Answers ONLY correctly-signed requests; any unsigned or # garbage probe is dropped in SILENCE, so a port scan learns nothing and cannot fingerprint this tool. # poll : a session's ear. Outbound-only; prints the first mail batch and EXITS (the exit is the harness # ping; re-arm after handling). # send : deposits one signed message at the hub. Outbound-only. # Exit codes: 0 mail delivered / send queued, 2 lifetime elapsed with no mail (re-arm), # 3 bad config/key, 4 hub rejected an authenticated send, 5 hub unreachable OR wrong key. # Transport is authenticated (HMAC-SHA256) but NOT encrypted. Over the internet keep the default loopback # bind and tunnel it: that hides the port, encrypts the traffic, and stops offline key-guessing. # Pure .NET sockets: Windows PowerShell 5.1 and pwsh on Linux/mac alike. # ---- key ---- 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 } } # ---- target (poll/send) ---- $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 } # ================================================================ hub 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 { "" } } 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= key=" $seen = @{} # id -> expiry: blocks in-window replays even after delivery $fails = @{} # ip -> @{ n; first } $bans = @{} # ip -> unban time 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 } # banned: silent drop 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 } # silent $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 } # silent # Authenticate BEFORE any reply. No valid signature => silent drop => nothing to fingerprint. $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 } # silent 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 } # idempotent replay/retry guard $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) # store the signed line verbatim; receivers re-verify end-to-end 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 # set only after the file is in place, so a failed write stays retryable $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 } } # at-least-once } } } 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 } # ================================================================ poll if ($Mode -eq 'poll') { if (-not $targetHost -or $Port -le 0 -or -not $Me) { Write-Output "ERROR bad-config: poll needs -Hub and -Me "; 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" } # wrong key => hub is silent => lands here if ($l -eq 'END') { $gotEnd = $true; break } if ($l -eq 'REJECT') { throw "refused" } # authenticated but refused (name/clock) $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 } # End-to-end: the SENDER signed it (timestamp included). No age check: queued mail is # legitimately old if this session was away. $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 } # ================================================================ send 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 , -Me , -To "; 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" } # wrong key => silent hub => retry => exit 5 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() } } } }