Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions docs/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<domain>`), 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.<domain>` *itself*, set via `thelounge reset <member>`
(`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 <member> -`
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 <member> -` 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.
Expand Down
4 changes: 3 additions & 1 deletion internal/ircpass/ircpass.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 47 additions & 3 deletions scripts/set-irc-password.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.<domain> 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 <member> [password] # password generated if omitted
Expand All @@ -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 <member>` 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


Expand Down Expand Up @@ -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 <member>` 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:
Expand Down
Loading