feat(passwd): self-service password reset across git, mail & chat (#59)

Add a key-gated `ssh passwd@host` route (alias `password@`) that sets ONE
member-chosen password across every service with its own credential:

  - git  (Forgejo)        new forgejo.SetPassword (PATCH /admin/users, clears
                          must_change; EnsureUser first so the account exists)
  - mail (Mailu webmail)  existing mailu.SetPassword
  - chat (IRC/Ergo + The Lounge)  new internal/ircpass package

Because the route authenticates by the member's registered SSH key, it also
serves as the forgot-password path — no old password required.

The BBS runs as a non-root service user, but the Ergo password store and The
Lounge user files are root-owned. internal/ircpass bridges this by shelling out
to scripts/set-irc-password.sh through a narrow sudoers rule (installed by
setup.sh). The new password travels on stdin (a new `set-irc-password.sh
<member> -` form), so it never appears in the process table or sudo's log.

UX: masked entry typed twice (readSecret); no-PTY reads stdin; empty input
generates a strong password and shows it once. Each service leg is independent
and best-effort with a per-service ✓/✗ summary, plus a confirmation email that
never contains the password.

Tests: ircpass (stdin contract + member/password rejection), forgejo.SetPassword,
auth IsPasswdName + reservation. Docs: credentials.md (passwd@ section) + irc.md.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-27 03:46:23 -07:00 committed by GitHub
parent f2bcb7e063
commit 54da317f4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 625 additions and 3 deletions

View file

@ -10,6 +10,8 @@
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
// ssh <name>@host (from another account) prints a finger card for that member
// ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin
// ssh passwd@host reset ONE password across git + mail + chat (forgot-password;
// key-gated, password@ is an alias). See docs/credentials.md
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only;
// sysop@/root@ are aliases)
// ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated,
@ -65,6 +67,7 @@ import (
"github.com/profullstack/agentbbs/internal/forgejo"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/ircpass"
"github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/mailbox"
"github.com/profullstack/agentbbs/internal/mailu"
@ -114,6 +117,7 @@ type app struct {
mailHost string // mail server host (IMAP/SMTP), e.g. mail.profullstack.com
webmailURL string // webmail (Roundcube) URL shown to members
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
irc ircpass.Config // chat/IRC password reset bridge (privileged helper)
live *liveReg // in-memory live-session registry (admin console)
files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0)
gamesReg *games.Registry // AgentGames catalog
@ -190,6 +194,7 @@ func main() {
mailHost: mailHost,
webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost),
forgejo: forgejo.ConfigFromEnv(),
irc: ircpass.ConfigFromEnv(),
live: newLiveReg(),
dataDir: dataDir,
assets: env("AGENTBBS_ASSETS", "./assets"),
@ -384,6 +389,8 @@ func (a *app) router() wish.Middleware {
filesAdminHandler(s)
case auth.IsMsgName(user):
a.handleMsg(s)
case auth.IsPasswdName(user):
a.handlePasswd(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":
@ -631,6 +638,36 @@ func readLine(s ssh.Session, in *bufio.Reader) (string, error) {
}
}
// readSecret reads a line like readLine but echoes '*' for each character instead
// of the character itself, so a password isn't shown on screen. Same raw-PTY
// handling (accept '\r' or '\n', handle backspace, abort on Ctrl-C/Ctrl-D).
func readSecret(s ssh.Session, in *bufio.Reader) (string, error) {
var b []byte
for {
c, err := in.ReadByte()
if err != nil {
return "", err
}
switch c {
case '\r', '\n':
wish.Print(s, "\r\n")
return string(b), nil
case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
return "", io.EOF
case 0x7f, '\b':
if len(b) > 0 {
b = b[:len(b)-1]
wish.Print(s, "\b \b")
}
default:
if c >= 0x20 {
b = append(b, c)
wish.Print(s, "*")
}
}
}
}
// handleJoin runs onboarding interactively in one SSH session: register the
// visitor's key, confirm their email with a code we email them, then offer the
// $99 Founding Lifetime membership (CoinPay). It then disconnects.
@ -1738,6 +1775,194 @@ func (a *app) handleMsg(s ssh.Session) {
_ = s.Exit(0)
}
// handlePasswd is the self-service "reset my password everywhere" route. It is
// gated by the caller's registered SSH key (so it doubles as the forgot-password
// path — no old password needed) and sets ONE new password across every service
// that has its own credential: git (Forgejo), mail (Mailu webmail), and chat
// (IRC + The Lounge). Git push and BBS/SSH access are unaffected — those use the
// member's key, not a password.
//
// ssh passwd@host interactive: type a new password (twice), applied everywhere
// ssh passwd@host < file non-interactive: read the new password from stdin
// echo | ssh passwd@host empty stdin / no PTY: a strong password is generated for you
func (a *app) handlePasswd(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "passwd@ needs your registered SSH key. New here? ssh join@"+a.host)
_ = s.Exit(1)
return
}
u, found, err := a.st.UserByFingerprint(fp)
if err != nil || !found {
wish.Println(s, "key not registered — run: ssh join@"+a.host)
_ = s.Exit(1)
return
}
if u.Banned {
wish.Println(s, "this account is suspended.")
_ = s.Exit(1)
return
}
pw, generated, err := a.readNewPassword(s)
if err != nil {
wish.Println(s, "password reset cancelled.")
_ = s.Exit(1)
return
}
wish.Println(s, "")
wish.Println(s, "Setting your password across services…")
type result struct{ label, detail string }
var ok, failed []result
// git (Forgejo): make sure the account exists, then set the chosen password.
if a.forgejo.Configured() {
if _, _, e := a.forgejo.EnsureUser(u.Name, u.Email); e != nil {
failed = append(failed, result{"git ", e.Error()})
} else if e := a.forgejo.SetPassword(u.Name, pw); e != nil {
failed = append(failed, result{"git ", e.Error()})
} else {
ok = append(ok, result{"git ", a.forgejo.LoginURL() + " (username: " + u.Name + ")"})
}
}
// mail (Mailu webmail): ensure the mailbox exists, then set its password.
if a.mailEnabled() {
if e := a.ensureMailbox(u); e != nil {
failed = append(failed, result{"mail", e.Error()})
} else {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
e := a.mailu.SetPassword(ctx, u.Name, a.mailDomain, pw)
cancel()
if e != nil {
failed = append(failed, result{"mail", e.Error()})
} else {
ok = append(ok, result{"mail", a.webmailURL + " (" + a.mailAddress(u.Name) + ")"})
}
}
}
// chat (IRC + The Lounge): set via the privileged helper.
if a.irc.Configured() {
if e := a.irc.SetPassword(u.Name, pw); e != nil {
failed = append(failed, result{"chat", e.Error()})
} else {
ok = append(ok, result{"chat", "SASL on irc." + rootDomain(a.host) + " / web — account: " + u.Name})
}
}
wish.Println(s, "")
if generated {
wish.Println(s, "Your new password (save it now — it isn't shown again):")
wish.Println(s, " "+pw)
wish.Println(s, "")
}
for _, r := range ok {
wish.Println(s, " ✓ "+r.label+" "+r.detail)
}
for _, r := range failed {
wish.Println(s, " ✗ "+r.label+" "+r.detail)
}
if len(ok) == 0 && len(failed) == 0 {
wish.Println(s, " (no password-backed services are configured on this server)")
}
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "passwd")
// Best-effort confirmation email (never contains the password). Skipped when
// the member has no verified address or SMTP isn't configured.
if u.EmailVerified && u.Email != "" && a.mail.Configured() {
_ = a.mail.Send(u.Email, "Your "+rootDomain(a.host)+" password was changed",
passwdChangedEmailBody(u.Name, len(ok), remoteIP(s)))
}
if len(failed) > 0 {
_ = s.Exit(1)
return
}
_ = s.Exit(0)
}
// readNewPassword obtains the member's new password. With a PTY it prompts twice
// (masked) and requires the two entries to match and meet a minimum length. With
// no PTY it reads the password from stdin; if stdin is empty it generates a strong
// one and returns generated=true so the caller shows it to the member.
func (a *app) readNewPassword(s ssh.Session) (pw string, generated bool, err error) {
const minLen = 8
_, _, isPTY := s.Pty()
if !isPTY {
b, _ := io.ReadAll(io.LimitReader(s, 4096))
piped := strings.TrimSpace(string(b))
if piped == "" {
gen, e := randPassword()
return gen, true, e
}
if len(piped) < minLen {
return "", false, fmt.Errorf("password too short")
}
return piped, false, nil
}
in := bufio.NewReader(s)
for {
wish.Print(s, "New password (min "+fmt.Sprint(minLen)+" chars, blank to generate one): ")
first, e := readSecret(s, in)
if e != nil {
return "", false, e
}
if first == "" {
gen, e := randPassword()
return gen, true, e
}
if len(first) < minLen {
wish.Println(s, " too short — try again.")
continue
}
wish.Print(s, "Confirm new password: ")
second, e := readSecret(s, in)
if e != nil {
return "", false, e
}
if first != second {
wish.Println(s, " passwords didn't match — try again.")
continue
}
return first, false, nil
}
}
// randPassword returns a strong URL-safe-ish random password (24 hex chars).
func randPassword() (string, error) {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// rootDomain strips the first label off a host (bbs.profullstack.com →
// profullstack.com) so user-facing copy can name the shared apex.
func rootDomain(host string) string {
if i := strings.IndexByte(host, '.'); i >= 0 && strings.Contains(host[i+1:], ".") {
return host[i+1:]
}
return host
}
// passwdChangedEmailBody is the security-notice email sent after a successful
// reset. It deliberately never includes the password.
func passwdChangedEmailBody(name string, services int, ip string) string {
return "Hi " + name + ",\n\n" +
"Your password was just changed across your account services (git, mail, chat)" +
" via ssh passwd@.\n\n" +
fmt.Sprintf(" services updated: %d\n", services) +
" request IP: " + ip + "\n\n" +
"If this wasn't you, your SSH key may be compromised — rotate it and contact the operator.\n\n" +
"Note: git push and BBS/SSH login use your SSH key, not this password.\n"
}
// handleFinger prints a classic finger card when someone ssh's to an
// existing account name that isn't their own (e.g. ssh anthony@host).
// Returns false when the route should fall through to the hub.

View file

@ -26,6 +26,40 @@ verification, and it's a no-op when Forgejo is unconfigured. It runs on email
verification (`join@` and the web `/verify` link) and again, asynchronously, on
each BBS login so an existing member's key is kept in sync.
## `passwd@` — self-service "reset my password everywhere"
A member who forgot their password (or just wants to rotate it) runs:
```bash
ssh passwd@bbs.profullstack.com # interactive: type a new password twice
ssh passwd@bbs.profullstack.com < pw # non-interactive: read it from stdin
echo | ssh passwd@bbs.profullstack.com # empty/no PTY: a strong one is generated for you
```
The route is **gated by the caller's registered SSH key**, so it doubles as the
forgot-password path — no old password is required (the key *is* the proof of
identity). `password@` is an alias. Whatever the member enters is applied as **one
password across every service that has its own credential**:
| Service | How it's set | Notes |
|---|---|---|
| **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) |
BBS/SSH login itself is unaffected — that's always the member's key.
**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
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.
## `notify-creds` — backfill / re-send (ops)
The git- and mailbox-credential emails were added after some accounts already
@ -77,6 +111,8 @@ on any failure.
| `AGENTBBS_FORWARDEMAIL_API_KEY` | unset | mail — forwardemail.net API key |
| `AGENTBBS_FORWARDEMAIL_DOMAIN` | `AGENTBBS_MAIL_DOMAIN` | mail — alias domain (falls back to the mail domain, default `mail.profullstack.com`) |
| `AGENTBBS_WEBMAIL_URL` | unset | mail — webmail link put in the email (optional) |
| `AGENTBBS_SET_IRC_PASSWD` | unset (set by `setup.sh` when IRC is on) | chat — path to the privileged `set-irc-password.sh` helper for `passwd@`; empty disables the chat leg |
| `AGENTBBS_SET_IRC_SUDO` | `1` | chat — invoke the helper via `sudo` (set `0` if the BBS already runs as root, e.g. in tests) |
| `AGENTBBS_SMTP_HOST` / `_FROM` | unset | **sending** all of the above emails (required to actually send) |
| `AGENTBBS_SMTP_PORT` / `_USER` / `_PASS` | `587` / unset / unset | SMTP submission (STARTTLS) |

View file

@ -69,6 +69,12 @@ 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.
Members set their own IRC password (alongside git + mail) self-service via
`ssh passwd@<host>` — see [`credentials.md`](credentials.md#passwd--self-service-reset-my-password-everywhere).
That flow calls this same helper as `set-irc-password.sh <member> -` (password on
stdin) through a narrow `sudo` rule installed by `setup.sh`, since the BBS process
itself is unprivileged.
> 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
> WebSocket client bypass the member check.

View file

@ -113,6 +113,16 @@ func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
// management TUI (operator-gated).
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
// PasswdNames route a member into the self-service password reset: a key-gated
// flow that sets ONE new password across every downstream service that has its
// own credential — git (Forgejo), mail (Mailu webmail), and chat (IRC/The Lounge).
// Because the member is authenticated by their registered SSH key, this doubles
// as the "forgot password" path: no old password is required.
var PasswdNames = map[string]bool{"passwd": true, "password": true}
// IsPasswdName reports whether the SSH username requests the password reset flow.
func IsPasswdName(u string) bool { return PasswdNames[strings.ToLower(u)] }
// MsgNames route a member-to-member message: `ssh msg@host <user>` leaves a
// note in the recipient's BBS inbox (store-and-forward, see the Members plugin).
var MsgNames = map[string]bool{"msg": true, "message": true}
@ -137,7 +147,8 @@ func IsReservedName(name string) bool {
n := strings.ToLower(name)
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || systemReserved[n] {
MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] ||
PasswdNames[n] || systemReserved[n] {
return true
}
return strings.HasPrefix(n, "video-") // video-<code> call routes

View file

@ -15,6 +15,25 @@ func TestIsAdminName(t *testing.T) {
}
}
func TestIsPasswdName(t *testing.T) {
for _, name := range []string{"passwd", "PASSWD", "password", "Password"} {
if !IsPasswdName(name) {
t.Errorf("IsPasswdName(%q) = false, want true", name)
}
}
for _, name := range []string{"pass", "pw", "anthony", ""} {
if IsPasswdName(name) {
t.Errorf("IsPasswdName(%q) = true, want false", name)
}
}
// The route names must not be claimable as account names.
for _, name := range []string{"passwd", "password"} {
if _, ok := SanitizeUsername(name); ok {
t.Errorf("SanitizeUsername(%q) should be reserved", name)
}
}
}
func TestAdminsAllowlist(t *testing.T) {
t.Setenv("AGENTBBS_ADMINS", "anthony, Root ops")
admins := Admins()

View file

@ -142,6 +142,38 @@ func (c Config) EnsureUserReset(username, email string) (created bool, password
return false, pw, nil
}
// SetPassword sets an existing account's password to the member-chosen value and
// clears must_change_password (they picked it, so don't force another change on
// next sign-in). Unlike EnsureUserReset it never generates a password and never
// creates the account: the caller is a member resetting their own credential
// across services, and the Forgejo account is expected to already exist (it is
// created at email-verification time). A missing account is reported as an error
// so the caller can surface "no git account yet" rather than silently succeeding.
func (c Config) SetPassword(username, password string) error {
if !c.Configured() {
return fmt.Errorf("forgejo not configured")
}
exists, err := c.userExists(username)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("forgejo user %q does not exist", username)
}
body, _ := json.Marshal(map[string]any{
"password": password,
"must_change_password": false,
})
status, resp, err := c.do(http.MethodPatch, "/admin/users/"+username, body)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("forgejo set password %q: %d: %s", username, status, truncate(resp, 200))
}
return nil
}
// EnsureKey registers an SSH public key on the member's Forgejo account so the
// key they use for the BBS is also their git push key ("BBS membership is the
// git account"). It is idempotent: added is false when the same key material is

View file

@ -148,6 +148,60 @@ func TestEnsureUserResetPatchesWhenExists(t *testing.T) {
}
}
func TestSetPasswordPatchesChosenPassword(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/alice":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1}`))
case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/admin/users/alice":
_ = json.NewDecoder(r.Body).Decode(&body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusTeapot)
}
}))
defer srv.Close()
c := Config{BaseURL: srv.URL, Token: "secret"}
if err := c.SetPassword("alice", "member-chosen-pw"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if body["password"] != "member-chosen-pw" {
t.Errorf("sent password %v, want member-chosen-pw", body["password"])
}
// They chose it, so don't force another change on next sign-in.
if body["must_change_password"] != false {
t.Errorf("expected must_change_password=false, got %v", body["must_change_password"])
}
}
func TestSetPasswordErrorsWhenMissing(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusNotFound)
return
}
t.Errorf("must not PATCH a non-existent user (%s %s)", r.Method, r.URL.Path)
w.WriteHeader(http.StatusTeapot)
}))
defer srv.Close()
c := Config{BaseURL: srv.URL, Token: "secret"}
if err := c.SetPassword("ghost", "pw"); err == nil {
t.Fatal("expected an error when the account does not exist")
}
}
func TestSetPasswordUnconfigured(t *testing.T) {
if err := (Config{}).SetPassword("alice", "pw"); err == nil {
t.Fatal("expected an error when Forgejo is not configured")
}
}
func TestEnsureUserNoOpWhenExists(t *testing.T) {
created := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

128
internal/ircpass/ircpass.go Normal file
View file

@ -0,0 +1,128 @@
// Package ircpass sets a member's chat/IRC password from the (non-root) BBS
// process. The Ergo password store (/var/lib/ergo/irc-passwd, ergo:ergo 0600)
// and each member's The Lounge user file are root-owned, so the BBS — which runs
// as an unprivileged service user — cannot write them directly. Instead it shells
// out to scripts/set-irc-password.sh through a narrow sudo rule (installed by
// setup.sh) that lets only that one command run as root for a single member.
//
// This is the chat leg of the unified "reset my password everywhere" flow
// (passwd@): git (Forgejo) and mail (Mailu) are set in-process via their admin
// APIs; chat is set here. The script writes the Ergo pbkdf2 hash AND syncs the
// member's The Lounge saslPassword, so native IRC clients and the web client both
// keep working with the same secret.
//
// Config (env):
//
// AGENTBBS_SET_IRC_PASSWD path to set-irc-password.sh (enables the chat leg)
// AGENTBBS_SET_IRC_SUDO "1" (default) to invoke it via sudo; "0" to call it
// directly (e.g. when the BBS already runs as root, or
// in tests). When sudo is used the binary is taken from
// AGENTBBS_SUDO_BIN (default "sudo").
package ircpass
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Config locates the privileged helper and how to invoke it.
type Config struct {
Script string // path to set-irc-password.sh; empty disables the chat leg
UseSudo bool // run the script through sudo
SudoBin string // sudo binary (default "sudo")
}
// ConfigFromEnv reads the chat-password settings from the environment.
func ConfigFromEnv() Config {
return Config{
Script: strings.TrimSpace(os.Getenv("AGENTBBS_SET_IRC_PASSWD")),
UseSudo: os.Getenv("AGENTBBS_SET_IRC_SUDO") != "0",
SudoBin: env("AGENTBBS_SUDO_BIN", "sudo"),
}
}
// Configured reports whether the chat password can actually be set (the helper
// script path is set). When false, callers skip the chat leg and say so.
func (c Config) Configured() bool { return c.Script != "" }
// SetPassword sets member's chat/IRC password by running the privileged helper as
// `set-irc-password.sh <member> -` (optionally via sudo), feeding the password on
// STDIN. Passing it on stdin — not argv — keeps it out of the process table (ps)
// and out of sudo's command log. member is the authenticated SSH account name; we
// still reject anything that isn't a plain account token as defence in depth, so
// it can never be read as a flag or path.
func (c Config) SetPassword(member, password string) error {
if !c.Configured() {
return fmt.Errorf("chat password helper not configured")
}
if !validMember(member) {
return fmt.Errorf("invalid member name %q", member)
}
if password == "" || strings.ContainsAny(password, "\r\n") {
return fmt.Errorf("invalid password")
}
// "-" tells the helper to read the password from stdin.
name, args := c.Script, []string{member, "-"}
if c.UseSudo {
name = c.SudoBin
args = []string{"-n", c.Script, member, "-"}
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
// Never let the helper inherit the BBS environment wholesale; pass only the
// store/Lounge paths it reads, so an operator override flows through.
cmd.Env = passthroughEnv()
cmd.Stdin = strings.NewReader(password + "\n")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return fmt.Errorf("set-irc-password: %v: %s", err, strings.TrimSpace(out.String()))
}
return nil
}
// validMember accepts the same charset the BBS allows for account names
// ([a-z0-9-], the output of auth.SanitizeUsername) so a member string can never
// smuggle a flag or path separator into the helper's argv.
func validMember(m string) bool {
if m == "" || len(m) > 32 || strings.HasPrefix(m, "-") {
return false
}
for _, r := range m {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
default:
return false
}
}
return true
}
// passthroughEnv builds a minimal environment for the helper: PATH plus the few
// AGENTBBS_/ERGO_ knobs that select the password store and Lounge user dir.
func passthroughEnv() []string {
keep := []string{"PATH", "ERGO_IRC_PASSWD", "AGENTBBS_LOUNGE_USERS"}
var env []string
for _, k := range keep {
if v, ok := os.LookupEnv(k); ok {
env = append(env, k+"="+v)
}
}
return env
}
func env(k, def string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return def
}

View file

@ -0,0 +1,75 @@
package ircpass
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestConfiguredRequiresScript(t *testing.T) {
if (Config{}).Configured() {
t.Fatal("empty config should not be Configured")
}
if !(Config{Script: "/x/set-irc-password.sh"}).Configured() {
t.Fatal("config with a script path should be Configured")
}
}
func TestSetPasswordRunsHelperWithArgs(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "args.txt")
script := filepath.Join(dir, "set-irc-password.sh")
// A fake helper mirroring the real contract: member in $1, "-" in $2, and the
// password on stdin. Records member + stdin so the test can assert both.
body := "#!/bin/sh\nread pw\nprintf '%s\\n%s\\n%s\\n' \"$1\" \"$2\" \"$pw\" > " + out + "\n"
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
c := Config{Script: script, UseSudo: false}
if err := c.SetPassword("alice", "s3cret-pw"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
// member as argv[0], "-" sentinel as argv[1], password only on stdin.
want := "alice\n-\ns3cret-pw\n"
if string(got) != want {
t.Fatalf("helper got %q, want %q", got, want)
}
}
func TestSetPasswordRejectsBadMember(t *testing.T) {
c := Config{Script: "/bin/true", UseSudo: false}
for _, bad := range []string{"", "-rf", "a b", "alice;rm", "../etc", "Alice"} {
if err := c.SetPassword(bad, "pw"); err == nil {
t.Fatalf("expected error for member %q", bad)
}
}
}
func TestSetPasswordRejectsBadPassword(t *testing.T) {
for _, bad := range []string{"", "with\nnewline", "carriage\rreturn"} {
if err := (Config{Script: "/bin/true"}).SetPassword("alice", bad); err == nil {
t.Fatalf("expected error for password %q", bad)
}
}
}
func TestSetPasswordUnconfigured(t *testing.T) {
if err := (Config{}).SetPassword("alice", "pw"); err == nil ||
!strings.Contains(err.Error(), "not configured") {
t.Fatalf("want not-configured error, got %v", err)
}
}
func TestSetPasswordSurfacesHelperFailure(t *testing.T) {
c := Config{Script: "/bin/false", UseSudo: false}
if err := c.SetPassword("alice", "pw"); err == nil {
t.Fatal("expected error when helper exits non-zero")
}
}

View file

@ -8,8 +8,12 @@
#
# Usage:
# set-irc-password.sh <member> [password] # password generated if omitted
# set-irc-password.sh <member> - # read the password from stdin
# set-irc-password.sh --all # provision any member missing one
#
# The "-" form is how the (non-root) BBS passwd@ flow calls this under sudo: the
# password arrives on stdin so it never lands in the process table or sudo's log.
#
# 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
@ -98,10 +102,22 @@ def main(argv):
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)
from_stdin = len(argv) > 1 and argv[1] == "-"
if from_stdin:
pw = sys.stdin.readline().rstrip("\n")
if not pw:
print("empty password on stdin", file=sys.stderr)
return 2
elif len(argv) > 1:
pw = argv[1]
else:
pw = secrets.token_urlsafe(9)
set_one(member, pw, store)
write_store(store)
print(f"{member}\t{pw}")
# When the password came from stdin (BBS passwd@ flow) the caller already
# knows it — don't echo it back into their captured output. Otherwise print
# it so an operator running this by hand sees the generated/set value.
print(member if from_stdin else f"{member}\t{pw}")
return 0

View file

@ -965,8 +965,28 @@ UNIT
sleep 1
systemctl is-active --quiet ergo \
|| warn "ergo failed to start — check: journalctl -u ergo -n50"
# ---- chat password bridge for the BBS passwd@ flow ----------------------
# The Ergo password store + The Lounge user files are root-owned, but the BBS
# runs as ${SVC_USER}. Install set-irc-password.sh to a stable path and grant
# ${SVC_USER} a narrow NOPASSWD sudo rule for exactly that command, so a member
# running `ssh passwd@` can set their chat password alongside git + mail. The
# password travels on stdin (the "-" form), so it never appears in sudo's log.
install -m 0755 -o root -g root \
"${SRC_DIR}/scripts/set-irc-password.sh" /usr/local/sbin/agentbbs-set-irc-password
cat > /etc/sudoers.d/agentbbs-ircpass <<SUDO
${SVC_USER} ALL=(root) NOPASSWD: /usr/local/sbin/agentbbs-set-irc-password
SUDO
chmod 0440 /etc/sudoers.d/agentbbs-ircpass
if visudo -cf /etc/sudoers.d/agentbbs-ircpass >/dev/null 2>&1; then
upsert_env AGENTBBS_SET_IRC_PASSWD /usr/local/sbin/agentbbs-set-irc-password
else
warn "sudoers rule for the chat password bridge is invalid — removing it (passwd@ will skip chat)"
rm -f /etc/sudoers.d/agentbbs-ircpass
fi
else
systemctl disable --now ergo ergo-certs.timer ergo-motd.timer >/dev/null 2>&1 || true
rm -f /etc/sudoers.d/agentbbs-ircpass /usr/local/sbin/agentbbs-set-irc-password
fi
# ---- 9c. News (Usenet/NNTP) server (co-located news.${DOMAIN}) --------------