diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index de57789..18122a7 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -10,6 +10,8 @@ // ssh domain@host point your own domain at your homepage (Premium; add/rm/list) // ssh @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. diff --git a/docs/credentials.md b/docs/credentials.md index 03601f0..b7b7bf8 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -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 -` +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) | diff --git a/docs/irc.md b/docs/irc.md index f729ef3..47ceb06 100644 --- a/docs/irc.md +++ b/docs/irc.md @@ -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@` — see [`credentials.md`](credentials.md#passwd--self-service-reset-my-password-everywhere). +That flow calls this same helper as `set-irc-password.sh -` (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. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 544384e..dbc89e2 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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 ` 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- call routes diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index fc8af5f..33c5233 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -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() diff --git a/internal/forgejo/forgejo.go b/internal/forgejo/forgejo.go index 1b6aa99..b551cac 100644 --- a/internal/forgejo/forgejo.go +++ b/internal/forgejo/forgejo.go @@ -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 diff --git a/internal/forgejo/forgejo_test.go b/internal/forgejo/forgejo_test.go index b8a80ec..4e6b910 100644 --- a/internal/forgejo/forgejo_test.go +++ b/internal/forgejo/forgejo_test.go @@ -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) { diff --git a/internal/ircpass/ircpass.go b/internal/ircpass/ircpass.go new file mode 100644 index 0000000..17ca494 --- /dev/null +++ b/internal/ircpass/ircpass.go @@ -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 -` (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 +} diff --git a/internal/ircpass/ircpass_test.go b/internal/ircpass/ircpass_test.go new file mode 100644 index 0000000..6af7ce4 --- /dev/null +++ b/internal/ircpass/ircpass_test.go @@ -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") + } +} diff --git a/scripts/set-irc-password.sh b/scripts/set-irc-password.sh index e3e0d26..8964c4c 100644 --- a/scripts/set-irc-password.sh +++ b/scripts/set-irc-password.sh @@ -8,8 +8,12 @@ # # Usage: # set-irc-password.sh [password] # password generated if omitted +# set-irc-password.sh - # 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 diff --git a/setup.sh b/setup.sh index 36af55d..9c2eb55 100755 --- a/setup.sh +++ b/setup.sh @@ -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 </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}) --------------