Skip to content

Commit 54da317

Browse files
ralyodioclaude
andauthored
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>
1 parent f2bcb7e commit 54da317

11 files changed

Lines changed: 625 additions & 3 deletions

File tree

cmd/agentbbs/main.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
1111
// ssh <name>@host (from another account) prints a finger card for that member
1212
// ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin
13+
// ssh passwd@host reset ONE password across git + mail + chat (forgot-password;
14+
// key-gated, password@ is an alias). See docs/credentials.md
1315
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only;
1416
// sysop@/root@ are aliases)
1517
// ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated,
@@ -65,6 +67,7 @@ import (
6567
"github.com/profullstack/agentbbs/internal/forgejo"
6668
"github.com/profullstack/agentbbs/internal/games"
6769
"github.com/profullstack/agentbbs/internal/hub"
70+
"github.com/profullstack/agentbbs/internal/ircpass"
6871
"github.com/profullstack/agentbbs/internal/mail"
6972
"github.com/profullstack/agentbbs/internal/mailbox"
7073
"github.com/profullstack/agentbbs/internal/mailu"
@@ -114,6 +117,7 @@ type app struct {
114117
mailHost string // mail server host (IMAP/SMTP), e.g. mail.profullstack.com
115118
webmailURL string // webmail (Roundcube) URL shown to members
116119
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
120+
irc ircpass.Config // chat/IRC password reset bridge (privileged helper)
117121
live *liveReg // in-memory live-session registry (admin console)
118122
files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0)
119123
gamesReg *games.Registry // AgentGames catalog
@@ -190,6 +194,7 @@ func main() {
190194
mailHost: mailHost,
191195
webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost),
192196
forgejo: forgejo.ConfigFromEnv(),
197+
irc: ircpass.ConfigFromEnv(),
193198
live: newLiveReg(),
194199
dataDir: dataDir,
195200
assets: env("AGENTBBS_ASSETS", "./assets"),
@@ -384,6 +389,8 @@ func (a *app) router() wish.Middleware {
384389
filesAdminHandler(s)
385390
case auth.IsMsgName(user):
386391
a.handleMsg(s)
392+
case auth.IsPasswdName(user):
393+
a.handlePasswd(s)
387394
case isVideo:
388395
a.handleVideo(s, code)
389396
case user == "agent":
@@ -631,6 +638,36 @@ func readLine(s ssh.Session, in *bufio.Reader) (string, error) {
631638
}
632639
}
633640

641+
// readSecret reads a line like readLine but echoes '*' for each character instead
642+
// of the character itself, so a password isn't shown on screen. Same raw-PTY
643+
// handling (accept '\r' or '\n', handle backspace, abort on Ctrl-C/Ctrl-D).
644+
func readSecret(s ssh.Session, in *bufio.Reader) (string, error) {
645+
var b []byte
646+
for {
647+
c, err := in.ReadByte()
648+
if err != nil {
649+
return "", err
650+
}
651+
switch c {
652+
case '\r', '\n':
653+
wish.Print(s, "\r\n")
654+
return string(b), nil
655+
case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
656+
return "", io.EOF
657+
case 0x7f, '\b':
658+
if len(b) > 0 {
659+
b = b[:len(b)-1]
660+
wish.Print(s, "\b \b")
661+
}
662+
default:
663+
if c >= 0x20 {
664+
b = append(b, c)
665+
wish.Print(s, "*")
666+
}
667+
}
668+
}
669+
}
670+
634671
// handleJoin runs onboarding interactively in one SSH session: register the
635672
// visitor's key, confirm their email with a code we email them, then offer the
636673
// $99 Founding Lifetime membership (CoinPay). It then disconnects.
@@ -1738,6 +1775,194 @@ func (a *app) handleMsg(s ssh.Session) {
17381775
_ = s.Exit(0)
17391776
}
17401777

1778+
// handlePasswd is the self-service "reset my password everywhere" route. It is
1779+
// gated by the caller's registered SSH key (so it doubles as the forgot-password
1780+
// path — no old password needed) and sets ONE new password across every service
1781+
// that has its own credential: git (Forgejo), mail (Mailu webmail), and chat
1782+
// (IRC + The Lounge). Git push and BBS/SSH access are unaffected — those use the
1783+
// member's key, not a password.
1784+
//
1785+
// ssh passwd@host interactive: type a new password (twice), applied everywhere
1786+
// ssh passwd@host < file non-interactive: read the new password from stdin
1787+
// echo | ssh passwd@host empty stdin / no PTY: a strong password is generated for you
1788+
func (a *app) handlePasswd(s ssh.Session) {
1789+
fp := auth.Fingerprint(s.PublicKey())
1790+
if fp == "" {
1791+
wish.Println(s, "passwd@ needs your registered SSH key. New here? ssh join@"+a.host)
1792+
_ = s.Exit(1)
1793+
return
1794+
}
1795+
u, found, err := a.st.UserByFingerprint(fp)
1796+
if err != nil || !found {
1797+
wish.Println(s, "key not registered — run: ssh join@"+a.host)
1798+
_ = s.Exit(1)
1799+
return
1800+
}
1801+
if u.Banned {
1802+
wish.Println(s, "this account is suspended.")
1803+
_ = s.Exit(1)
1804+
return
1805+
}
1806+
1807+
pw, generated, err := a.readNewPassword(s)
1808+
if err != nil {
1809+
wish.Println(s, "password reset cancelled.")
1810+
_ = s.Exit(1)
1811+
return
1812+
}
1813+
1814+
wish.Println(s, "")
1815+
wish.Println(s, "Setting your password across services…")
1816+
1817+
type result struct{ label, detail string }
1818+
var ok, failed []result
1819+
1820+
// git (Forgejo): make sure the account exists, then set the chosen password.
1821+
if a.forgejo.Configured() {
1822+
if _, _, e := a.forgejo.EnsureUser(u.Name, u.Email); e != nil {
1823+
failed = append(failed, result{"git ", e.Error()})
1824+
} else if e := a.forgejo.SetPassword(u.Name, pw); e != nil {
1825+
failed = append(failed, result{"git ", e.Error()})
1826+
} else {
1827+
ok = append(ok, result{"git ", a.forgejo.LoginURL() + " (username: " + u.Name + ")"})
1828+
}
1829+
}
1830+
1831+
// mail (Mailu webmail): ensure the mailbox exists, then set its password.
1832+
if a.mailEnabled() {
1833+
if e := a.ensureMailbox(u); e != nil {
1834+
failed = append(failed, result{"mail", e.Error()})
1835+
} else {
1836+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
1837+
e := a.mailu.SetPassword(ctx, u.Name, a.mailDomain, pw)
1838+
cancel()
1839+
if e != nil {
1840+
failed = append(failed, result{"mail", e.Error()})
1841+
} else {
1842+
ok = append(ok, result{"mail", a.webmailURL + " (" + a.mailAddress(u.Name) + ")"})
1843+
}
1844+
}
1845+
}
1846+
1847+
// chat (IRC + The Lounge): set via the privileged helper.
1848+
if a.irc.Configured() {
1849+
if e := a.irc.SetPassword(u.Name, pw); e != nil {
1850+
failed = append(failed, result{"chat", e.Error()})
1851+
} else {
1852+
ok = append(ok, result{"chat", "SASL on irc." + rootDomain(a.host) + " / web — account: " + u.Name})
1853+
}
1854+
}
1855+
1856+
wish.Println(s, "")
1857+
if generated {
1858+
wish.Println(s, "Your new password (save it now — it isn't shown again):")
1859+
wish.Println(s, " "+pw)
1860+
wish.Println(s, "")
1861+
}
1862+
for _, r := range ok {
1863+
wish.Println(s, " ✓ "+r.label+" "+r.detail)
1864+
}
1865+
for _, r := range failed {
1866+
wish.Println(s, " ✗ "+r.label+" "+r.detail)
1867+
}
1868+
if len(ok) == 0 && len(failed) == 0 {
1869+
wish.Println(s, " (no password-backed services are configured on this server)")
1870+
}
1871+
1872+
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "passwd")
1873+
1874+
// Best-effort confirmation email (never contains the password). Skipped when
1875+
// the member has no verified address or SMTP isn't configured.
1876+
if u.EmailVerified && u.Email != "" && a.mail.Configured() {
1877+
_ = a.mail.Send(u.Email, "Your "+rootDomain(a.host)+" password was changed",
1878+
passwdChangedEmailBody(u.Name, len(ok), remoteIP(s)))
1879+
}
1880+
1881+
if len(failed) > 0 {
1882+
_ = s.Exit(1)
1883+
return
1884+
}
1885+
_ = s.Exit(0)
1886+
}
1887+
1888+
// readNewPassword obtains the member's new password. With a PTY it prompts twice
1889+
// (masked) and requires the two entries to match and meet a minimum length. With
1890+
// no PTY it reads the password from stdin; if stdin is empty it generates a strong
1891+
// one and returns generated=true so the caller shows it to the member.
1892+
func (a *app) readNewPassword(s ssh.Session) (pw string, generated bool, err error) {
1893+
const minLen = 8
1894+
_, _, isPTY := s.Pty()
1895+
if !isPTY {
1896+
b, _ := io.ReadAll(io.LimitReader(s, 4096))
1897+
piped := strings.TrimSpace(string(b))
1898+
if piped == "" {
1899+
gen, e := randPassword()
1900+
return gen, true, e
1901+
}
1902+
if len(piped) < minLen {
1903+
return "", false, fmt.Errorf("password too short")
1904+
}
1905+
return piped, false, nil
1906+
}
1907+
1908+
in := bufio.NewReader(s)
1909+
for {
1910+
wish.Print(s, "New password (min "+fmt.Sprint(minLen)+" chars, blank to generate one): ")
1911+
first, e := readSecret(s, in)
1912+
if e != nil {
1913+
return "", false, e
1914+
}
1915+
if first == "" {
1916+
gen, e := randPassword()
1917+
return gen, true, e
1918+
}
1919+
if len(first) < minLen {
1920+
wish.Println(s, " too short — try again.")
1921+
continue
1922+
}
1923+
wish.Print(s, "Confirm new password: ")
1924+
second, e := readSecret(s, in)
1925+
if e != nil {
1926+
return "", false, e
1927+
}
1928+
if first != second {
1929+
wish.Println(s, " passwords didn't match — try again.")
1930+
continue
1931+
}
1932+
return first, false, nil
1933+
}
1934+
}
1935+
1936+
// randPassword returns a strong URL-safe-ish random password (24 hex chars).
1937+
func randPassword() (string, error) {
1938+
var b [12]byte
1939+
if _, err := rand.Read(b[:]); err != nil {
1940+
return "", err
1941+
}
1942+
return hex.EncodeToString(b[:]), nil
1943+
}
1944+
1945+
// rootDomain strips the first label off a host (bbs.profullstack.com →
1946+
// profullstack.com) so user-facing copy can name the shared apex.
1947+
func rootDomain(host string) string {
1948+
if i := strings.IndexByte(host, '.'); i >= 0 && strings.Contains(host[i+1:], ".") {
1949+
return host[i+1:]
1950+
}
1951+
return host
1952+
}
1953+
1954+
// passwdChangedEmailBody is the security-notice email sent after a successful
1955+
// reset. It deliberately never includes the password.
1956+
func passwdChangedEmailBody(name string, services int, ip string) string {
1957+
return "Hi " + name + ",\n\n" +
1958+
"Your password was just changed across your account services (git, mail, chat)" +
1959+
" via ssh passwd@.\n\n" +
1960+
fmt.Sprintf(" services updated: %d\n", services) +
1961+
" request IP: " + ip + "\n\n" +
1962+
"If this wasn't you, your SSH key may be compromised — rotate it and contact the operator.\n\n" +
1963+
"Note: git push and BBS/SSH login use your SSH key, not this password.\n"
1964+
}
1965+
17411966
// handleFinger prints a classic finger card when someone ssh's to an
17421967
// existing account name that isn't their own (e.g. ssh anthony@host).
17431968
// Returns false when the route should fall through to the hub.

docs/credentials.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,40 @@ verification, and it's a no-op when Forgejo is unconfigured. It runs on email
2626
verification (`join@` and the web `/verify` link) and again, asynchronously, on
2727
each BBS login so an existing member's key is kept in sync.
2828

29+
## `passwd@` — self-service "reset my password everywhere"
30+
31+
A member who forgot their password (or just wants to rotate it) runs:
32+
33+
```bash
34+
ssh passwd@bbs.profullstack.com # interactive: type a new password twice
35+
ssh passwd@bbs.profullstack.com < pw # non-interactive: read it from stdin
36+
echo | ssh passwd@bbs.profullstack.com # empty/no PTY: a strong one is generated for you
37+
```
38+
39+
The route is **gated by the caller's registered SSH key**, so it doubles as the
40+
forgot-password path — no old password is required (the key *is* the proof of
41+
identity). `password@` is an alias. Whatever the member enters is applied as **one
42+
password across every service that has its own credential**:
43+
44+
| Service | How it's set | Notes |
45+
|---|---|---|
46+
| **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 |
47+
| **mail** (Mailu webmail) | admin API — ensure the mailbox, then `mailu.SetPassword` | the mailbox/IMAP/webmail login |
48+
| **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) |
49+
50+
BBS/SSH login itself is unaffected — that's always the member's key.
51+
52+
**Why chat needs a helper.** The BBS process runs as the unprivileged `agentbbs`
53+
service user, but the Ergo password store (`/var/lib/ergo/irc-passwd`, `ergo:ergo
54+
0600`) and The Lounge user files are root-owned. `setup.sh` installs
55+
`scripts/set-irc-password.sh` to `/usr/local/sbin/agentbbs-set-irc-password` and a
56+
`/etc/sudoers.d/agentbbs-ircpass` rule letting **only** that one command run as
57+
root. The new password travels on **stdin** (the `set-irc-password.sh <member> -`
58+
form), so it never appears in the process table or sudo's command log. Each leg is
59+
independent: if one service is unconfigured or fails, the others still apply and
60+
the member sees a per-service ✓/✗ summary. A confirmation email (which never
61+
contains the password) is sent on success.
62+
2963
## `notify-creds` — backfill / re-send (ops)
3064

3165
The git- and mailbox-credential emails were added after some accounts already
@@ -77,6 +111,8 @@ on any failure.
77111
| `AGENTBBS_FORWARDEMAIL_API_KEY` | unset | mail — forwardemail.net API key |
78112
| `AGENTBBS_FORWARDEMAIL_DOMAIN` | `AGENTBBS_MAIL_DOMAIN` | mail — alias domain (falls back to the mail domain, default `mail.profullstack.com`) |
79113
| `AGENTBBS_WEBMAIL_URL` | unset | mail — webmail link put in the email (optional) |
114+
| `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 |
115+
| `AGENTBBS_SET_IRC_SUDO` | `1` | chat — invoke the helper via `sudo` (set `0` if the BBS already runs as root, e.g. in tests) |
80116
| `AGENTBBS_SMTP_HOST` / `_FROM` | unset | **sending** all of the above emails (required to actually send) |
81117
| `AGENTBBS_SMTP_PORT` / `_USER` / `_PASS` | `587` / unset / unset | SMTP submission (STARTTLS) |
82118

docs/irc.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ one). The helper also updates the member's The Lounge `saslPassword` so the web
6969
client keeps working with no member action. Members connecting from a desktop client
7070
(irssi/HexChat/WeeChat) use this password as their SASL password.
7171

72+
Members set their own IRC password (alongside git + mail) self-service via
73+
`ssh passwd@<host>` — see [`credentials.md`](credentials.md#passwd--self-service-reset-my-password-everywhere).
74+
That flow calls this same helper as `set-irc-password.sh <member> -` (password on
75+
stdin) through a narrow `sudo` rule installed by `setup.sh`, since the BBS process
76+
itself is unprivileged.
77+
7278
> The SASL requirement has **no IP exemption** — web/agent clients reach Ergo
7379
> through Caddy from `127.0.0.1`, so exempting localhost would let every
7480
> WebSocket client bypass the member check.

internal/auth/auth.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
113113
// management TUI (operator-gated).
114114
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
115115

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

internal/auth/auth_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,25 @@ func TestIsAdminName(t *testing.T) {
1515
}
1616
}
1717

18+
func TestIsPasswdName(t *testing.T) {
19+
for _, name := range []string{"passwd", "PASSWD", "password", "Password"} {
20+
if !IsPasswdName(name) {
21+
t.Errorf("IsPasswdName(%q) = false, want true", name)
22+
}
23+
}
24+
for _, name := range []string{"pass", "pw", "anthony", ""} {
25+
if IsPasswdName(name) {
26+
t.Errorf("IsPasswdName(%q) = true, want false", name)
27+
}
28+
}
29+
// The route names must not be claimable as account names.
30+
for _, name := range []string{"passwd", "password"} {
31+
if _, ok := SanitizeUsername(name); ok {
32+
t.Errorf("SanitizeUsername(%q) should be reserved", name)
33+
}
34+
}
35+
}
36+
1837
func TestAdminsAllowlist(t *testing.T) {
1938
t.Setenv("AGENTBBS_ADMINS", "anthony, Root ops")
2039
admins := Admins()

0 commit comments

Comments
 (0)