diff --git a/docs/credentials.md b/docs/credentials.md index b7b7bf8..bd641e1 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -45,17 +45,32 @@ password across every service that has its own credential**: |---|---|---| | **git** (Forgejo) | admin API — ensure the account, then `SetPassword` (clears `must_change_password`) | git **push** uses the SSH key, not this password; this is for the web UI | | **mail** (Mailu webmail) | admin API — ensure the mailbox, then `mailu.SetPassword` | the mailbox/IMAP/webmail login | -| **chat** (IRC + The Lounge) | the privileged helper `set-irc-password.sh` via a narrow `sudo` rule | SASL password for native IRC clients **and** the web client; see [`irc.md`](irc.md) | +| **chat** (IRC + The Lounge) | the privileged helper `set-irc-password.sh` via a narrow `sudo` rule | sets all THREE chat credentials to the new password (see below); see [`irc.md`](irc.md) | BBS/SSH login itself is unaffected — that's always the member's key. +**Chat has three credentials, all set to the new password.** "Chat" spans Ergo +(the IRC server) and The Lounge (the web client at `chat.`), which between +them keep *three* secrets — `set-irc-password.sh` sets all three so one password +works everywhere: + +1. **Ergo SASL** — the pbkdf2 hash in `/var/lib/ergo/irc-passwd` that native IRC + clients (irssi/HexChat) authenticate with. +2. **The Lounge `saslPassword`** — how the *web* client logs in to Ergo on the + member's behalf (in the user's JSON `networks[]`). +3. **The Lounge web-login password** — the bcrypt field used to sign in to + `chat.` *itself*, set via `thelounge reset ` + (`AGENTBBS_LOUNGE_RESET_CMD`). Missing this was the "I reset my password but + chat.profullstack.com says auth failed" bug: a member could reach IRC but not + the web client. + **Why chat needs a helper.** The BBS process runs as the unprivileged `agentbbs` -service user, but the Ergo password store (`/var/lib/ergo/irc-passwd`, `ergo:ergo -0600`) and The Lounge user files are root-owned. `setup.sh` installs -`scripts/set-irc-password.sh` to `/usr/local/sbin/agentbbs-set-irc-password` and a -`/etc/sudoers.d/agentbbs-ircpass` rule letting **only** that one command run as -root. The new password travels on **stdin** (the `set-irc-password.sh -` -form), so it never appears in the process table or sudo's command log. Each leg is +service user, but the Ergo password store (`ergo:ergo 0600`) and The Lounge user +files are root-owned. `setup.sh` installs `scripts/set-irc-password.sh` to +`/usr/local/sbin/agentbbs-set-irc-password` and a `/etc/sudoers.d/agentbbs-ircpass` +rule letting **only** that one command run as root. The new password travels on +**stdin** (the `set-irc-password.sh -` form, and likewise piped to +`thelounge reset`), so it never appears in the process table or sudo's command log. Each leg is independent: if one service is unconfigured or fails, the others still apply and the member sees a per-service ✓/✗ summary. A confirmation email (which never contains the password) is sent on success. diff --git a/internal/ircpass/ircpass.go b/internal/ircpass/ircpass.go index 17ca494..ce4e422 100644 --- a/internal/ircpass/ircpass.go +++ b/internal/ircpass/ircpass.go @@ -74,7 +74,9 @@ func (c Config) SetPassword(member, password string) error { args = []string{"-n", c.Script, member, "-"} } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + // Generous: the helper also resets The Lounge web-login password via a + // `docker exec thelounge ...` which can take a few seconds on a busy box. + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, name, args...) // Never let the helper inherit the BBS environment wholesale; pass only the diff --git a/scripts/set-irc-password.sh b/scripts/set-irc-password.sh index 8964c4c..79e1d15 100644 --- a/scripts/set-irc-password.sh +++ b/scripts/set-irc-password.sh @@ -3,8 +3,13 @@ # # 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. +# SASL login. When a The Lounge user file exists for the member it ALSO sets two +# distinct Lounge credentials to the same password: +# - the IRC network saslPassword (so the web client authenticates to Ergo), and +# - the Lounge WEB LOGIN password (the bcrypt field used to sign in to +# chat. itself), via `thelounge reset` (AGENTBBS_LOUNGE_RESET_CMD). +# Without the web-login sync a member who reset via passwd@ could connect to IRC +# but not log into the web client — the bug this guards against. # # Usage: # set-irc-password.sh [password] # password generated if omitted @@ -16,10 +21,19 @@ # # 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 +import sys, os, json, glob, secrets, hashlib, pwd, grp, subprocess 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") +# Command that resets a member's The Lounge *web login* password — distinct from +# the IRC saslPassword. We feed the new password to it on STDIN (so it never +# lands on a command line) and append the member name. Default targets the +# dockerized The Lounge (`thelounge reset ` reads the password from +# stdin). Set AGENTBBS_LOUNGE_RESET_CMD="" to skip the web-login sync (e.g. when +# The Lounge isn't deployed), or override it for a native/other install. +LOUNGE_RESET_CMD = os.environ.get( + "AGENTBBS_LOUNGE_RESET_CMD", "docker exec -i thelounge thelounge reset" +) ITERS = 200_000 @@ -55,10 +69,40 @@ def write_store(store): os.replace(tmp, PASSWD_FILE) +def set_lounge_web_password(member, pw): + """Set The Lounge WEB LOGIN password (its own bcrypt field), distinct from the + IRC saslPassword. Pipes the new password to `thelounge reset ` on + stdin so it never appears on a command line. Best-effort: warns but never + fails the run (the Ergo SASL credential is the primary IRC secret).""" + cmd = LOUNGE_RESET_CMD.strip() + if not cmd: + return + try: + r = subprocess.run( + cmd.split() + [member], + input=(pw + "\n").encode(), + capture_output=True, + timeout=30, + ) + if r.returncode != 0: + print( + f"WARN: Lounge web-login reset for {member} failed (rc={r.returncode}): " + f"{r.stderr.decode(errors='replace').strip()[:200]}", + file=sys.stderr, + ) + else: + print(f" (updated The Lounge web-login password for {member})") + except Exception as e: + print(f"WARN: Lounge web-login reset for {member}: {e}", file=sys.stderr) + + def sync_lounge(member, pw): p = os.path.join(LOUNGE_USERS, member + ".json") if not os.path.exists(p): return + # Web login password first (rewrites the user file via the Lounge CLI), then + # the saslPassword edit below reads that fresh file and preserves it. + set_lounge_web_password(member, pw) try: d = json.load(open(p)) except Exception as e: