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

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")
}
}