Nekochu's picture
Add Skill: P2P agent, check-work (from grok CLI), reddit-fetch to search, session-history-search look back in time.
c217fa9 verified
Raw
History Blame Contribute Delete
16.1 kB
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 { "<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 = @{} # 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 <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" } # 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 <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" } # 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() }
}
}
}