feat(irc): members-only Ergo IRC network co-located on the BBS

Provision a self-hosted Ergo IRC network (irc.${DOMAIN}) in setup.sh §9b:
single Go binary on its own ports/user, reusing Caddy's Let's Encrypt cert
for 6697 TLS (refreshed by a timer; self-signed fallback on first boot),
loopback 6667 + a loopback WebSocket fronted by Caddy at wss://${DOMAIN}/irc.

Access is MEMBERS-ONLY: every client must authenticate with SASL, self-service
registration is off, and an auth-script (deploy/ergo/auth-script.sh, installed
as /usr/local/bin/ergo-auth-member) approves a login only if the account name
maps to an existing AgentBBS member home dir under <data>/users/. Passphrase is
ignored — membership (the filesystem dir) is the credential. require-sasl has
no IP exemption so WebSocket clients (which reach Ergo via Caddy from 127.0.0.1)
can't bypass the gate; accounts are auto-created on first successful auth.

Public attack surface is TLS-only (ufw opens 6697; 6667 is loopback). Toggle
with IRC=0. See docs/irc.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 11:21:39 +00:00
parent 4be87440d5
commit 8adafaf515
6 changed files with 1570 additions and 2 deletions

View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# auth-script.sh — Ergo auth-script that gates the IRC network on AgentBBS
# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and
# wires it into /etc/ergo/ircd.yaml (accounts.auth-script).
#
# "Member" == a user with a home dir under the AgentBBS users dir (created when
# someone registers via `ssh join@`). IRC is members-only, so a login is
# approved iff the requested account name maps to such a dir. The passphrase is
# intentionally IGNORED — membership (a filesystem dir) IS the credential, by
# design (see docs/irc.md). Anyone who knows a member's name can connect as
# them; that tradeoff was chosen deliberately for this private, TLS-only network.
#
# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on stdout
# then exit. Input keys: accountName, passphrase, certfp, ip. Output:
# {"success":bool,"accountName":str,"error":str}.
#
# args: ["<users-dir>"] # defaults to /var/lib/agentbbs/users
set -uo pipefail
USERS_DIR="${1:-/var/lib/agentbbs/users}"
# Always emit valid JSON and exit 0 — Ergo reads the JSON, not the exit code;
# a non-zero exit / no output is treated as a script error, not a clean deny.
deny() { printf '{"success":false,"error":"%s"}\n' "${1:-not a member}"; exit 0; }
# Don't gate on read's exit code: a final line without a trailing newline still
# carries data (read returns non-zero at EOF but populates $line).
line=""
read -r line || true
[ -n "$line" ] || deny "no input"
acct="$(printf '%s' "$line" | jq -r '.accountName // ""' 2>/dev/null || true)"
# certfp-only attempts carry no account name; we don't support cert auth here.
[ -n "$acct" ] || deny "membership requires an account name"
# Defense in depth against path traversal. IRC account names are a restricted
# charset anyway, but never let one escape USERS_DIR.
case "$acct" in
*[!A-Za-z0-9._-]* | "." | ".." | *..* | */* ) deny "invalid account name" ;;
esac
if [ -d "$USERS_DIR/$acct" ]; then
printf '{"success":true,"accountName":"%s"}\n' "$acct"
else
deny "not a member"
fi

1189
deploy/ergo/ircd.yaml Normal file

File diff suppressed because it is too large Load diff

42
deploy/ergo/refresh-certs.sh Executable file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# refresh-certs.sh — copy Caddy's Let's Encrypt cert for $DOMAIN into Ergo's
# TLS dir and reload Ergo if it changed. setup.sh installs this to
# /usr/local/bin/ergo-refresh-certs and runs it from the ergo-certs.timer so
# the IRC server's 6697 cert tracks Caddy's auto-renewals.
#
# Ergo and Caddy share the same hostname (${DOMAIN}); Caddy is the only ACME
# client on the box, so we reuse its cert rather than running a second ACME
# client. Exits non-zero (without touching anything) if Caddy hasn't issued the
# cert yet — on first boot that's expected, and setup.sh falls back to a
# self-signed cert until this timer picks up the real one.
set -euo pipefail
DOMAIN="${DOMAIN:?set DOMAIN}"
ERGO_DATA="${ERGO_DATA:-/var/lib/ergo}"
CADDY_DATA="${CADDY_DATA:-/var/lib/caddy/.local/share/caddy}"
# Caddy stores certs under certificates/<acme-dir>/<host>/<host>.{crt,key};
# the ACME directory segment varies (prod vs staging), so glob for it.
crt="$(ls "$CADDY_DATA"/certificates/*/"$DOMAIN"/"$DOMAIN".crt 2>/dev/null | head -1 || true)"
key="$(ls "$CADDY_DATA"/certificates/*/"$DOMAIN"/"$DOMAIN".key 2>/dev/null | head -1 || true)"
if [ -z "$crt" ] || [ -z "$key" ]; then
echo "no Caddy cert for $DOMAIN yet (looked under $CADDY_DATA/certificates)"
exit 1
fi
dst="$ERGO_DATA/tls"
install -d -m 0755 "$dst"
changed=0
if ! cmp -s "$crt" "$dst/fullchain.pem"; then install -m 0644 "$crt" "$dst/fullchain.pem"; changed=1; fi
if ! cmp -s "$key" "$dst/privkey.pem"; then install -m 0640 "$key" "$dst/privkey.pem"; changed=1; fi
chown -R ergo:ergo "$dst" 2>/dev/null || true
if [ "$changed" = 1 ]; then
echo "updated Ergo TLS cert for $DOMAIN"
# Ergo rehashes config + reloads certs on SIGHUP (systemctl reload).
systemctl reload ergo 2>/dev/null || systemctl restart ergo 2>/dev/null || true
else
echo "Ergo TLS cert for $DOMAIN already current"
fi