Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions docs/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |

Expand Down
6 changes: 6 additions & 0 deletions docs/irc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading