mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
parent
f2bcb7e063
commit
54da317f4e
11 changed files with 625 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
128
internal/ircpass/ircpass.go
Normal 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
|
||||
}
|
||||
75
internal/ircpass/ircpass_test.go
Normal file
75
internal/ircpass/ircpass_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
20
setup.sh
20
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 <<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}) --------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue