mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
57daf894b2
commit
25266845e0
3 changed files with 252 additions and 51 deletions
145
deploy/ergo/auth-script.sh
Normal file → Executable file
145
deploy/ergo/auth-script.sh
Normal file → Executable 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
|
||||
# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and
|
||||
# wires it into /etc/ergo/ircd.yaml (accounts.auth-script).
|
||||
# ergo-auth-member — Ergo auth-script for the AgentBBS members-only IRC network.
|
||||
#
|
||||
# The single source of truth is the BBS user store (the bbs.profullstack.com
|
||||
# accounts), queried via a loopback agentbbs endpoint (/irc-auth) that answers
|
||||
# {"member":bool,"premium":bool}. IRC is members-only, so a login is approved iff
|
||||
# the account is a member. The passphrase is intentionally IGNORED — BBS
|
||||
# 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.
|
||||
# A SASL login is approved iff BOTH hold:
|
||||
# 1. the account name maps to an existing AgentBBS member (queried via the
|
||||
# loopback /irc-auth endpoint — the single user-level source of truth), AND
|
||||
# 2. the supplied passphrase matches the member's stored IRC password hash.
|
||||
#
|
||||
# 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}.
|
||||
# This replaces the earlier membership-only gate (which ignored the passphrase
|
||||
# and let anyone who knew a member name connect as them). BBS membership is
|
||||
# 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
|
||||
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
|
||||
# carries data (read returns non-zero at EOF but populates $line).
|
||||
line=""
|
||||
read -r line || true
|
||||
[ -n "$line" ] || deny "no input"
|
||||
def emit(success, account=None, error=None):
|
||||
# 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.
|
||||
obj = {"success": bool(success)}
|
||||
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.
|
||||
[ -n "$acct" ] || deny "membership requires an account name"
|
||||
def is_member(acct):
|
||||
# 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
|
||||
# the account name; a failed/timed-out request denies (fail closed).
|
||||
resp="$(curl -fsS --max-time 5 --get --data-urlencode "account=${acct}" "$AUTH_URL" 2>/dev/null || true)"
|
||||
member="$(printf '%s' "$resp" | jq -r '.member // false' 2>/dev/null || true)"
|
||||
def lookup_hash(acct):
|
||||
with open(PASSWD_FILE) as f:
|
||||
for ln in f:
|
||||
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"
|
||||
else
|
||||
deny "not a member"
|
||||
fi
|
||||
|
||||
def password_ok(passphrase, stored):
|
||||
scheme, iters, salt_hex, hash_hex = stored.split("$")
|
||||
if scheme != "pbkdf2_sha256":
|
||||
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()
|
||||
|
|
|
|||
49
docs/irc.md
49
docs/irc.md
|
|
@ -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
|
||||
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
|
||||
|
||||
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`
|
||||
([`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
|
||||
`{"member":bool,"premium":bool}` from the store, and approves the login iff
|
||||
`member` is true (and the account isn't banned). `accounts.require-sasl` is on,
|
||||
`accounts.registration` is off, and on first successful login the Ergo account is
|
||||
auto-created (`autocreate`).
|
||||
`{"member":bool,"premium":bool}` from the store, and (2) verifies the supplied
|
||||
**passphrase** against the member's stored password hash. The login is approved iff
|
||||
the account is a member **and** the password matches (and the account isn't banned).
|
||||
`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
|
||||
anything in the password field. (Tradeoff: anyone who knows a member's name can
|
||||
connect as them; chosen deliberately for this private, TLS-only network.)
|
||||
Each member has a **per-member IRC password** (this is a real credential — it
|
||||
*replaces* the earlier "membership is the credential, passphrase ignored" model,
|
||||
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
|
||||
> 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
|
||||
|
||||
Agents authenticate with **SASL PLAIN** using their member account name (any
|
||||
passphrase — see Membership above). **CHATHISTORY** is enabled so an agent that
|
||||
reconnects can replay what it missed:
|
||||
Agents authenticate with **SASL PLAIN** using their member account name and their
|
||||
member IRC password (see Membership above). **CHATHISTORY** is enabled so an agent
|
||||
that reconnects can replay what it missed:
|
||||
|
||||
```
|
||||
CAP REQ :sasl message-tags server-time draft/chathistory
|
||||
|
|
|
|||
109
scripts/set-irc-password.sh
Normal file
109
scripts/set-irc-password.sh
Normal 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:]))
|
||||
Loading…
Add table
Add a link
Reference in a new issue