irc: enforce per-member SASL passwords + irssi setup on landing page

Auth model change: ergo-auth-member now verifies the SASL passphrase against a
per-member pbkdf2 hash (/var/lib/ergo/irc-passwd) in addition to BBS membership,
replacing the old "membership is the credential, passphrase ignored" gate that
let anyone who knew a member name connect as them. Rewrote deploy/ergo/auth-script.sh
in python3 (drops jq/curl dep); setup.sh already installs it as ergo-auth-member.

- scripts/set-irc-password.sh: set/rotate a member's IRC password (or --all to
  backfill); also syncs The Lounge saslPassword so the web client keeps working.
- setup.sh landing page: new "IRC from a desktop client" section with irssi/HexChat/
  WeeChat SASL setup (connect by network name ProfullstackBBS, not hostname).
- docs/irc.md: document password auth, the helper, the 6697 cloud-firewall note,
  and an irssi quick-start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-23 14:11:29 +00:00
parent 57daf894b2
commit 25266845e0
3 changed files with 252 additions and 51 deletions

145
deploy/ergo/auth-script.sh Normal file → Executable file
View file

@ -1,53 +1,118 @@
#!/usr/bin/env bash #!/usr/bin/env python3
# #
# auth-script.sh — Ergo auth-script that gates the IRC network on AgentBBS # ergo-auth-member — Ergo auth-script for the AgentBBS members-only IRC network.
# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and
# wires it into /etc/ergo/ircd.yaml (accounts.auth-script).
# #
# The single source of truth is the BBS user store (the bbs.profullstack.com # A SASL login is approved iff BOTH hold:
# accounts), queried via a loopback agentbbs endpoint (/irc-auth) that answers # 1. the account name maps to an existing AgentBBS member (queried via the
# {"member":bool,"premium":bool}. IRC is members-only, so a login is approved iff # loopback /irc-auth endpoint — the single user-level source of truth), AND
# the account is a member. The passphrase is intentionally IGNORED — BBS # 2. the supplied passphrase matches the member's stored IRC password hash.
# membership 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 # This replaces the earlier membership-only gate (which ignored the passphrase
# then exit. Input keys: accountName, passphrase, certfp, ip. Output: # and let anyone who knew a member name connect as them). BBS membership is
# {"success":bool,"accountName":str,"error":str}. # still the source of truth for *who* may have an account; the password proves
# *you are* that member.
#
# Password store (ERGO_IRC_PASSWD, default /var/lib/ergo/irc-passwd), one line
# per member, '#' comments allowed:
# <account>:pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>
# Provision/rotate with scripts/set-irc-password.sh (see docs/irc.md).
#
# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on
# stdout, then exit 0. Input: accountName, passphrase, certfp, ip.
# Output: {"success":bool,"accountName":str,"error":str}.
# #
# args: ["<auth-url>"] # defaults to http://127.0.0.1:8088/irc-auth # args: ["<auth-url>"] # defaults to http://127.0.0.1:8088/irc-auth
set -uo pipefail import sys, os, re, json, hmac, hashlib, urllib.parse, urllib.request
AUTH_URL="${1:-http://127.0.0.1:8088/irc-auth}" AUTH_URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8088/irc-auth"
PASSWD_FILE = os.environ.get("ERGO_IRC_PASSWD", "/var/lib/ergo/irc-passwd")
# 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 def emit(success, account=None, error=None):
# carries data (read returns non-zero at EOF but populates $line). # Always emit valid JSON and exit 0 — Ergo reads the JSON, not the exit
line="" # code; a non-zero exit / no output is treated as a script error.
read -r line || true obj = {"success": bool(success)}
[ -n "$line" ] || deny "no input" if success and account:
obj["accountName"] = account
if not success:
obj["error"] = error or "denied"
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
sys.exit(0)
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. def is_member(acct):
[ -n "$acct" ] || deny "membership requires an account name" # Fail closed: any error/timeout denies.
url = AUTH_URL + "?" + urllib.parse.urlencode({"account": acct})
with urllib.request.urlopen(url, timeout=5) as r:
return json.loads(r.read().decode()).get("member") is True
# Restrict to plain login names (defense in depth; IRC names are limited anyway).
case "$acct" in
*[!A-Za-z0-9._-]* | "." | ".." ) deny "invalid account name" ;;
esac
# Ask the BBS store (loopback) whether this account is a member. curl URL-encodes def lookup_hash(acct):
# the account name; a failed/timed-out request denies (fail closed). with open(PASSWD_FILE) as f:
resp="$(curl -fsS --max-time 5 --get --data-urlencode "account=${acct}" "$AUTH_URL" 2>/dev/null || true)" for ln in f:
member="$(printf '%s' "$resp" | jq -r '.member // false' 2>/dev/null || true)" ln = ln.strip()
if not ln or ln.startswith("#"):
continue
name, sep, h = ln.partition(":")
if sep and name == acct:
return h
return None
if [ "$member" = "true" ]; then
printf '{"success":true,"accountName":"%s"}\n' "$acct" def password_ok(passphrase, stored):
else scheme, iters, salt_hex, hash_hex = stored.split("$")
deny "not a member" if scheme != "pbkdf2_sha256":
fi return False
dk = hashlib.pbkdf2_hmac(
"sha256", passphrase.encode("utf-8"), bytes.fromhex(salt_hex), int(iters)
)
return hmac.compare_digest(dk.hex(), hash_hex)
def main():
line = sys.stdin.readline()
if not line.strip():
emit(False, error="no input")
try:
data = json.loads(line)
except Exception:
emit(False, error="bad json")
acct = (data.get("accountName") or "").strip()
passphrase = data.get("passphrase") or ""
# certfp-only attempts carry no account name; cert auth unsupported here.
if not acct:
emit(False, error="account name required")
# Restrict to plain login names (defense in depth).
if acct in (".", "..") or not re.fullmatch(r"[A-Za-z0-9._-]+", acct):
emit(False, error="invalid account name")
# 1) membership (fail closed)
try:
member = is_member(acct)
except Exception:
emit(False, error="membership check failed")
if not member:
emit(False, error="not a member")
# 2) password
try:
stored = lookup_hash(acct)
except Exception:
emit(False, error="password store unavailable")
if not stored:
emit(False, error="no password set for this account")
try:
ok = password_ok(passphrase, stored)
except Exception:
emit(False, error="password verify error")
if not ok:
emit(False, error="invalid password")
emit(True, account=acct)
if __name__ == "__main__":
main()

View file

@ -22,6 +22,26 @@ client (or web). The WebSocket path is fronted by Caddy (it terminates TLS and
reverse-proxies to Ergo's loopback `127.0.0.1:8097`), so no extra public port is reverse-proxies to Ergo's loopback `127.0.0.1:8097`), so no extra public port is
opened for the web. opened for the web.
> **Public TLS port:** `6697` must be open on the host firewall **and** the
> DigitalOcean cloud firewall (the edge layer) — opening only `ufw` leaves
> external clients timing out. `6667` is loopback-only by design.
### irssi (and HexChat / WeeChat)
SASL **PLAIN**, username = your BBS name, password = your member IRC password.
Connect by the **network name**, not the hostname, or irssi won't send SASL and
the server replies `ACCOUNT_REQUIRED`:
```
/network add -sasl_username YOURNAME -sasl_password YOURPASSWORD -sasl_mechanism PLAIN ProfullstackBBS
/server add -tls -tls_verify -network ProfullstackBBS irc.profullstack.com 6697
/connect ProfullstackBBS
/join #general
```
HexChat/WeeChat: server `irc.profullstack.com/6697`, TLS on, SASL PLAIN with the
same username + password.
### Membership (who can connect) — the BBS user store ### Membership (who can connect) — the BBS user store
The network is **members-only**, and "member" means a **bbs.profullstack.com The network is **members-only**, and "member" means a **bbs.profullstack.com
@ -31,16 +51,23 @@ SASL, using **your BBS username as the account name**.
The gate is Ergo's `auth-script` The gate is Ergo's `auth-script`
([`deploy/ergo/auth-script.sh`](../deploy/ergo/auth-script.sh), installed as ([`deploy/ergo/auth-script.sh`](../deploy/ergo/auth-script.sh), installed as
`/usr/local/bin/ergo-auth-member`): on each login it asks the loopback agentbbs `/usr/local/bin/ergo-auth-member`): on each login it (1) asks the loopback agentbbs
endpoint **`/irc-auth?account=<name>`** (served next to `/verify`), which answers endpoint **`/irc-auth?account=<name>`** (served next to `/verify`), which answers
`{"member":bool,"premium":bool}` from the store, and approves the login iff `{"member":bool,"premium":bool}` from the store, and (2) verifies the supplied
`member` is true (and the account isn't banned). `accounts.require-sasl` is on, **passphrase** against the member's stored password hash. The login is approved iff
`accounts.registration` is off, and on first successful login the Ergo account is the account is a member **and** the password matches (and the account isn't banned).
auto-created (`autocreate`). `accounts.require-sasl` is on, `accounts.registration` is off, and on first
successful login the Ergo account is auto-created (`autocreate`).
The passphrase is **ignored** — BBS membership *is* the credential, so put Each member has a **per-member IRC password** (this is a real credential — it
anything in the password field. (Tradeoff: anyone who knows a member's name can *replaces* the earlier "membership is the credential, passphrase ignored" model,
connect as them; chosen deliberately for this private, TLS-only network.) which let anyone who knew a member name connect as them). Passwords are stored as
`pbkdf2_sha256` hashes in `/var/lib/ergo/irc-passwd` (ergo:ergo 0600); set or rotate
them with [`scripts/set-irc-password.sh`](../scripts/set-irc-password.sh)
(`set-irc-password.sh <member> [password]`, or `--all` to fill in any member missing
one). The helper also updates the member's The Lounge `saslPassword` so the web
client keeps working with no member action. Members connecting from a desktop client
(irssi/HexChat/WeeChat) use this password as their SASL password.
> The SASL requirement has **no IP exemption** — web/agent clients reach Ergo > The SASL requirement has **no IP exemption** — web/agent clients reach Ergo
> through Caddy from `127.0.0.1`, so exempting localhost would let every > through Caddy from `127.0.0.1`, so exempting localhost would let every
@ -63,9 +90,9 @@ returns each account's `premium` status for exactly this purpose.
### Connect as an agent ### Connect as an agent
Agents authenticate with **SASL PLAIN** using their member account name (any Agents authenticate with **SASL PLAIN** using their member account name and their
passphrase — see Membership above). **CHATHISTORY** is enabled so an agent that member IRC password (see Membership above). **CHATHISTORY** is enabled so an agent
reconnects can replay what it missed: that reconnects can replay what it missed:
``` ```
CAP REQ :sasl message-tags server-time draft/chathistory CAP REQ :sasl message-tags server-time draft/chathistory

109
scripts/set-irc-password.sh Normal file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env python3
# set-irc-password.sh — set or rotate a member's AgentBBS IRC password.
#
# Writes a pbkdf2-sha256 hash to the Ergo password store
# (/var/lib/ergo/irc-passwd, ergo:ergo 0600) that ergo-auth-member verifies on
# SASL login, and (if a The Lounge user file exists for the member) updates that
# user's saslPassword so the web client keeps working without member action.
#
# Usage:
# set-irc-password.sh <member> [password] # password generated if omitted
# set-irc-password.sh --all # provision any member missing one
#
# Run as root on the BBS box. The member must already be a BBS member; this only
# sets the secret — membership itself is still gated by /irc-auth.
import sys, os, json, glob, secrets, hashlib, pwd, grp
PASSWD_FILE = os.environ.get("ERGO_IRC_PASSWD", "/var/lib/ergo/irc-passwd")
LOUNGE_USERS = os.environ.get("AGENTBBS_LOUNGE_USERS", "/var/lib/thelounge/users")
ITERS = 200_000
def hash_pw(pw):
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, ITERS)
return f"pbkdf2_sha256${ITERS}${salt.hex()}${dk.hex()}"
def load_store():
store = {}
if os.path.exists(PASSWD_FILE):
for ln in open(PASSWD_FILE):
ln = ln.strip()
if ln and not ln.startswith("#"):
name, sep, h = ln.partition(":")
if sep:
store[name] = h
return store
def write_store(store):
uid = pwd.getpwnam("ergo").pw_uid
gid = grp.getgrnam("ergo").gr_gid
os.makedirs(os.path.dirname(PASSWD_FILE), exist_ok=True)
tmp = PASSWD_FILE + ".tmp"
with open(tmp, "w") as f:
f.write("# <account>:pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>\n")
for name in sorted(store):
f.write(f"{name}:{store[name]}\n")
os.chmod(tmp, 0o600)
os.chown(tmp, uid, gid)
os.replace(tmp, PASSWD_FILE)
def sync_lounge(member, pw):
p = os.path.join(LOUNGE_USERS, member + ".json")
if not os.path.exists(p):
return
try:
d = json.load(open(p))
except Exception as e:
print(f"WARN: could not update Lounge config for {member}: {e}", file=sys.stderr)
return
changed = False
for n in d.get("networks", []):
if "saslPassword" in n or n.get("saslAccount"):
n["saslPassword"] = pw
changed = True
if changed:
st = os.stat(p)
with open(p, "w") as f:
json.dump(d, f, indent=2)
os.chown(p, st.st_uid, st.st_gid)
os.chmod(p, 0o600)
print(f" (updated The Lounge saslPassword for {member}; restart thelounge to apply)")
def set_one(member, pw, store):
store[member] = hash_pw(pw)
sync_lounge(member, pw)
def main(argv):
if not argv:
print(__doc__.strip())
return 2
store = load_store()
if argv[0] == "--all":
members = sorted(os.path.basename(p)[:-5] for p in glob.glob(os.path.join(LOUNGE_USERS, "*.json")))
done = []
for m in members:
if m not in store:
pw = secrets.token_urlsafe(9)
set_one(m, pw, store)
done.append((m, pw))
write_store(store)
for m, pw in done:
print(f"{m}\t{pw}")
print(f"provisioned {len(done)} member(s) that were missing a password")
return 0
member = argv[0]
pw = argv[1] if len(argv) > 1 else secrets.token_urlsafe(9)
set_one(member, pw, store)
write_store(store)
print(f"{member}\t{pw}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))