<# bus.ps1 - Windows client for the JYM message bus. Mirrors bus.sh exactly. .\bus.ps1 health liveness + expiry date .\bus.ps1 send "text" send to the other party .\bus.ps1 send - read the message body from stdin .\bus.ps1 poll print messages newer than the saved cursor, advance cursor .\bus.ps1 wait [seconds] poll every 15s until a message arrives (default 90) Targets Windows PowerShell 5.1, which is what ships on Windows. It does not use python3 - ConvertTo-Json and ConvertFrom-Json are built in. The secret is read from a file, never passed as an argument, so it cannot land in shell history or a process listing. NOTE: Windows has no chmod. File protection here is an NTFS ACL applied at setup time, and this script warns rather than refuses, because it cannot verify the ACL as cheaply as bus.sh checks mode 600. #> [CmdletBinding()] param( [Parameter(Position = 0)][string]$Command = "", [Parameter(Position = 1, ValueFromRemainingArguments = $true)][string[]]$Rest ) $ErrorActionPreference = "Stop" # Windows PowerShell 5.1 can still default to TLS 1.0, which Cloudflare rejects. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 function Die($msg) { Write-Error "bus: $msg"; exit 1 } $Cfg = Join-Path $HOME ".config\jym-bus" if (-not (Test-Path $Cfg)) { Die "no config dir $Cfg" } function ReadCfg($name) { $p = Join-Path $Cfg $name if (-not (Test-Path $p)) { Die "missing $p" } return (Get-Content $p -Raw).Trim() } $Url = ReadCfg "url" $Me = ReadCfg "me" $Secret = ReadCfg "secret" switch ($Me) { "a" { $Them = "b" } "b" { $Them = "a" } default { Die "me must be a or b, got '$Me'" } } # Warn if the secret file is readable by anyone beyond the owner and SYSTEM. $secretPath = Join-Path $Cfg "secret" try { $acl = Get-Acl $secretPath $who = $acl.Access | ForEach-Object { $_.IdentityReference.Value } $unexpected = $who | Where-Object { $_ -notmatch [regex]::Escape($env:USERNAME) -and $_ -notmatch 'SYSTEM' } if ($unexpected) { Write-Warning "bus: $secretPath is readable by: $($unexpected -join ', '). Re-run the icacls step in the run sheet." } } catch { Write-Warning "bus: could not read the ACL on $secretPath - check it by hand." } $CursorFile = Join-Path $Cfg "cursor" function Get-Cursor { if (Test-Path $CursorFile) { return (Get-Content $CursorFile -Raw).Trim() } return "0" } # Call the bus. Returns the parsed body on 200 and turns the designed failure codes # into plain-language errors rather than raw exception text. function Invoke-Bus($Method, $Path, $Body) { $headers = @{ Authorization = "Bearer $Secret" } $uri = "$Url$Path" try { if ($Body) { return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers ` -ContentType "application/json" -Body $Body -TimeoutSec 30 } return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -TimeoutSec 30 } catch { $status = $null if ($_.Exception.Response) { $status = $_.Exception.Response.StatusCode.value__ } switch ($status) { 401 { Die "secret rejected (401). It has been rotated. Get the current secret from JY and update $secretPath. Do not retry with this one." } 410 { Die "the bus has expired (410) and is dead by design. Nothing to retry. Stop the loop." } 429 { Die "rate limited (429). Wait a minute before the next call." } default { if ($status) { Die "unexpected HTTP $status from $uri" } Die "network error reaching $Url (offline, DNS, or the project was deleted)" } } } } function Do-Poll { $since = Get-Cursor $msgs = Invoke-Bus "GET" "/msg?for=$Me&since=$since" $null # An empty JSON array can come back as $null or an empty collection. if ($null -eq $msgs -or @($msgs).Count -eq 0) { return "no new messages" } $lines = @() foreach ($m in @($msgs)) { $t = [DateTimeOffset]::FromUnixTimeMilliseconds([int64]$m.ts).LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss") $lines += "--- msg $($m.id) from $($m.sender) at $t ---" $lines += $m.body } # Cursor advances only after the messages were rendered. If this write is ever # lost the same messages replay on the next poll - the reader must tolerate # seeing a message twice, never miss one. $last = @($msgs)[-1].id Set-Content -Path $CursorFile -Value "$last" -NoNewline return ($lines -join "`n") } switch ($Command) { "health" { Invoke-Bus "GET" "/health" $null | ConvertTo-Json -Compress } "send" { if (-not $Rest -or $Rest.Count -lt 1) { Die 'usage: .\bus.ps1 send "message text" (or: .\bus.ps1 send - to read stdin)' } if ($Rest[0] -eq "-") { $text = [Console]::In.ReadToEnd() } else { $text = ($Rest -join " ") } if ([string]::IsNullOrWhiteSpace($text)) { Die "refusing to send an empty message" } $payload = @{ to = $Them; from = $Me; body = $text } | ConvertTo-Json -Compress Invoke-Bus "POST" "/msg" $payload | ConvertTo-Json -Compress } "poll" { Do-Poll } "wait" { $secs = 90 if ($Rest -and $Rest.Count -ge 1) { $secs = [int]$Rest[0] } $end = (Get-Date).AddSeconds($secs) while ($true) { $out = Do-Poll if ($out -ne "no new messages") { $out; exit 0 } if ((Get-Date) -ge $end) { "no new messages after ${secs}s"; exit 0 } Start-Sleep -Seconds 15 } } default { Die 'usage: .\bus.ps1 health | send "text" | send - | poll | wait [seconds]' } }