From d763d732a39b7e67d7d955d4bce598676eb736f9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 15 Jun 2026 07:09:57 -0700 Subject: [PATCH 01/18] fix(deploy): build Go binaries on the runner, ship them, SKIP_BUILD on box (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy SSHed into the ~458MB droplet and ran `go build` there. The Go linker's peak memory OOM-killed the build — and with it the sshd serving the deploy session — surfacing as "Connection closed by remote host" (exit 255). It was flaky because it tracked momentary memory pressure from the co-resident ergo/forgejo/tor/podman/agentbbs processes (run #25 passed, #26 failed on near-identical code). Build both binaries on the 16GB GitHub runner instead (pure-Go, modernc sqlite, so CGO_ENABLED=0 static cross-build), scp them to the droplet, and run setup.sh with SKIP_BUILD=1 so the box never compiles. Arch is detected from the droplet so amd64/arm64 both work. setup.sh now also skips the Go toolchain download when SKIP_BUILD=1. Co-authored-by: Claude Opus 4.8 --- .github/workflows/deploy.yml | 74 +++++++++++++++++++++++++++++++++--- setup.sh | 5 ++- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a6967ea..bf2b90b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,18 @@ name: deploy # Fully autonomous, idempotent deploy. On every push to main (or manual -# dispatch) this SSHes to the bbs.profullstack.com droplet and re-runs the -# idempotent provisioner (setup.sh), which pulls origin, rebuilds the Go -# binaries, and restarts the agentbbs service that answers -# `ssh join@bbs.profullstack.com`. Re-running is always safe. +# dispatch) this builds the Go binaries ON THE RUNNER (which has plenty of +# RAM), ships them to the bbs.profullstack.com droplet, and re-runs the +# idempotent provisioner (setup.sh) with SKIP_BUILD=1 so the tiny droplet +# never has to compile. setup.sh still pulls origin, refreshes config/assets, +# and restarts the agentbbs service that answers `ssh join@bbs.profullstack.com`. +# Re-running is always safe. +# +# Why build on the runner: the droplet is a ~458MB box also running ergo, +# forgejo, tor, podman and the live agentbbs. The Go linker's peak memory was +# OOM-killing the build — and with it the sshd serving the deploy session, +# surfacing as "Connection closed by remote host" (exit 255). Compiling on the +# 16GB runner removes that failure mode entirely. # # Required repo secrets (Settings -> Secrets and variables -> Actions): # DEPLOY_SSH_KEY private key whose public half is in the droplet admin @@ -30,6 +38,8 @@ jobs: deploy: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - name: Configure SSH env: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} @@ -43,7 +53,55 @@ jobs: chmod 600 ~/.ssh/id_deploy ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null - - name: Provision / redeploy (idempotent) + - name: Detect droplet architecture + id: arch + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} + run: | + uname_m="$(ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + "${DEPLOY_USER}@${DEPLOY_HOST}" 'uname -m')" + case "$uname_m" in + x86_64|amd64) goarch=amd64 ;; + aarch64|arm64) goarch=arm64 ;; + *) echo "::error::unsupported droplet arch '$uname_m'"; exit 1 ;; + esac + echo "goarch=$goarch" >> "$GITHUB_OUTPUT" + echo "::notice::droplet arch $uname_m -> GOARCH=$goarch" + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build binaries (on the runner, not the droplet) + env: + GOOS: linux + GOARCH: ${{ steps.arch.outputs.goarch }} + CGO_ENABLED: '0' # pure-Go (modernc sqlite) — static, portable binary + run: | + mkdir -p dist + go build -trimpath -o dist/agentbbs ./cmd/agentbbs + go build -trimpath -o dist/ascii-live ./cmd/ascii-live + file dist/* || true + + - name: Ship binaries to the droplet + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} + run: | + # scp can only name one remote target; copy each binary explicitly. + scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + dist/agentbbs "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-agentbbs" + scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + dist/ascii-live "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-ascii-live" + + - name: Provision / redeploy (idempotent, SKIP_BUILD=1) env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} @@ -76,7 +134,11 @@ jobs: fi git -C "$SRC" fetch --depth 1 origin "$BRANCH" git -C "$SRC" reset --hard "origin/$BRANCH" - exec env BRANCH="$BRANCH" \ + # Install the runner-built binaries, then tell setup.sh not to compile. + install -m 0755 /tmp/agentbbs-deploy-agentbbs /usr/local/bin/agentbbs + install -m 0755 /tmp/agentbbs-deploy-ascii-live /usr/local/bin/ascii-live + rm -f /tmp/agentbbs-deploy-agentbbs /tmp/agentbbs-deploy-ascii-live + exec env BRANCH="$BRANCH" SKIP_BUILD=1 \ COINPAY_API_KEY="${COINPAY_API_KEY:-}" \ COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \ AGENTBBS_QRYPT_ISSUER_KEY="${AGENTBBS_QRYPT_ISSUER_KEY:-}" \ diff --git a/setup.sh b/setup.sh index a440d0a..59b497c 100755 --- a/setup.sh +++ b/setup.sh @@ -119,8 +119,11 @@ if ! command -v yt-dlp >/dev/null; then fi # ---- 2. Go toolchain (system go is too old; pin GO_VERSION) ----------------- +# Skipped entirely when SKIP_BUILD=1: the CI deploy builds the binaries on the +# runner and ships them, so the droplet needs no Go toolchain at all. GO_ROOT="/usr/local/go" -if [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then +if [ "$SKIP_BUILD" != "1" ] && \ + [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then log "installing Go ${GO_VERSION}" tmp="$(mktemp -d)" curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o "$tmp/go.tgz" \ From 362b47fdde20d6069fd29c0682134fe11f9aaf4f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 15 Jun 2026 07:48:07 -0700 Subject: [PATCH 02/18] Feat/members messaging (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deploy): build Go binaries on the runner, ship them, SKIP_BUILD on box The deploy SSHed into the ~458MB droplet and ran `go build` there. The Go linker's peak memory OOM-killed the build — and with it the sshd serving the deploy session — surfacing as "Connection closed by remote host" (exit 255). It was flaky because it tracked momentary memory pressure from the co-resident ergo/forgejo/tor/podman/agentbbs processes (run #25 passed, #26 failed on near-identical code). Build both binaries on the 16GB GitHub runner instead (pure-Go, modernc sqlite, so CGO_ENABLED=0 static cross-build), scp them to the droplet, and run setup.sh with SKIP_BUILD=1 so the box never compiles. Arch is detected from the droplet so amd64/arm64 both work. setup.sh now also skips the Go toolchain download when SKIP_BUILD=1. Co-Authored-By: Claude Opus 4.8 * feat(members): member directory + store-and-forward messaging A members-only hub plugin (the BBS "who") plus user-to-user messaging: - internal/store: messages table + SendMessage/Inbox/UnreadCount/MarkRead, and OnlineUsers (open sessions) for presence. MarkRead is recipient-scoped so a member can only clear their own mail. - plugins/members: directory with online dots + last-seen, a finger-style profile view, a minimal compose box, and an inbox that marks read on open. - ssh msg@host [text]: scriptable CLI to leave a note (body from args or stdin), mirroring the existing finger route; "msg"/"message" are reserved. - hub: "N unread" badge on login (hubMOTD). plugin.Context gains Host for member homepage URLs. Extends the existing finger@ behavior (ssh @host) rather than replacing it. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- cmd/agentbbs/main.go | 71 ++++- internal/auth/auth.go | 10 +- internal/plugin/plugin.go | 3 + internal/store/store.go | 104 +++++++ internal/store/store_messages_test.go | 82 +++++ plugins/members/members.go | 421 ++++++++++++++++++++++++++ 6 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 internal/store/store_messages_test.go create mode 100644 plugins/members/members.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 6b3506e..3bf9214 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -8,6 +8,8 @@ // emailed code, then offers $99 Founding Lifetime (CoinPay) // ssh pod@host your personal Linux pod — free for verified members // 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 admin@host the operator admin console ($AGENTBBS_ADMINS only) // ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated, // agent-vs-agent (also on wss://host/play). See docs/agentgames.md @@ -72,6 +74,7 @@ import ( "github.com/profullstack/agentbbs/plugins/about" "github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/arcade" + "github.com/profullstack/agentbbs/plugins/members" qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite" ) @@ -171,7 +174,7 @@ func main() { a.mm = games.NewMatchmaker(a.gamesReg, a.st, time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second, time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second) - a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}} + a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}} // Custom domains: maintain the symlink farm Caddy serves and answer its // on-demand-TLS "ask" query so certs are only issued for mapped domains. @@ -318,6 +321,8 @@ func (a *app) router() wish.Middleware { a.handleNews(s) case auth.IsMailName(user): a.handleMail(s) + case auth.IsMsgName(user): + a.handleMsg(s) case isVideo: a.handleVideo(s, code) case user == "agent": @@ -345,7 +350,11 @@ func (a *app) hubMOTD(u auth.User) string { return "You're browsing as a guest.\n" + body + "\nssh join@" + a.host + " to claim a username, a pod & a homepage." } - return "Welcome back, " + u.Name + ".\n" + body + welcome := "Welcome back, " + u.Name + "." + if n, err := a.st.UnreadCount(u.Name); err == nil && n > 0 { + welcome += fmt.Sprintf(" 📬 %d unread — open Members ▸ inbox (i).", n) + } + return welcome + "\n" + body } // teaHandler builds the hub model for guests, members, and agents. @@ -394,7 +403,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub") go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }() - ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets} + ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets, Host: a.host} if u.Kind != auth.Guest { ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name) _ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755) @@ -1421,6 +1430,62 @@ func (a *app) handleChat(s ssh.Session) { } } +// handleMsg is the member-to-member messaging route: `ssh msg@host [text]` +// leaves a note in 's BBS inbox. The body is the remaining args, or stdin +// when none are given (so `echo hi | ssh msg@host bob` works). Members only; +// the recipient reads it in the hub's Members ▸ inbox. +func (a *app) handleMsg(s ssh.Session) { + fp := auth.Fingerprint(s.PublicKey()) + if fp == "" { + wish.Println(s, "msg@ needs your registered SSH key. New here? ssh join@"+a.host) + _ = s.Exit(1) + return + } + from, 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 + } + args := s.Command() + if len(args) == 0 { + wish.Println(s, "usage: ssh msg@"+a.host+" [message] (or pipe the message on stdin)") + _ = s.Exit(1) + return + } + to := strings.ToLower(args[0]) + recipient, ok, err := a.st.UserByName(to) + if err != nil || !ok { + wish.Println(s, "no member named "+to+" — check the spelling (ssh "+to+"@"+a.host+" to finger).") + _ = s.Exit(1) + return + } + if recipient.Name == from.Name { + wish.Println(s, "you can't message yourself.") + _ = s.Exit(1) + return + } + body := strings.TrimSpace(strings.Join(args[1:], " ")) + if body == "" { + // No inline text — read the message from stdin (piped, or typed then ^D). + b, _ := io.ReadAll(io.LimitReader(s, 64*1024)) + body = strings.TrimSpace(string(b)) + } + if body == "" { + wish.Println(s, "empty message — nothing sent.") + _ = s.Exit(1) + return + } + if err := a.st.SendMessage(from.Name, recipient.Name, body); err != nil { + wish.Println(s, "could not send: "+err.Error()) + _ = s.Exit(1) + return + } + _, _ = a.st.RecordSession(from.ID, s.User(), remoteIP(s), "msg") + wish.Println(s, "✓ message left for "+recipient.Name+" — they'll see it in Members ▸ inbox.") + _ = s.Exit(0) +} + // 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/internal/auth/auth.go b/internal/auth/auth.go index ca5c531..026a57b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -103,6 +103,13 @@ func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] } // IsMailName reports whether the SSH username requests the AgentMail client. func IsMailName(u string) bool { return MailNames[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} + +// IsMsgName reports whether the SSH username requests the messaging route. +func IsMsgName(u string) bool { return MsgNames[strings.ToLower(u)] } + // systemReserved are names that don't drive an SSH route but would still // collide with a per-user subdomain (.), the agent route, or common // infra hostnames — so members may not claim them as account names. @@ -119,7 +126,8 @@ var systemReserved = map[string]bool{ 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] || systemReserved[n] { + TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || + MsgNames[n] || systemReserved[n] { return true } return strings.HasPrefix(n, "video-") // video- call routes diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 4f32b24..7bbe300 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -22,6 +22,9 @@ type Context struct { DataDir string // AssetsDir is the read-only platform assets tree (wads, binaries). AssetsDir string + // Host is the BBS hostname (e.g. bbs.profullstack.com), for building + // member homepage URLs (https://Host/~name) and similar links. + Host string } // Plugin is the only integration point between a feature and the hub. diff --git a/internal/store/store.go b/internal/store/store.go index 5cb797b..bf6b482 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5,6 +5,7 @@ package store import ( "database/sql" "errors" + "strings" "time" _ "modernc.org/sqlite" @@ -45,6 +46,16 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) { return u, nil } +// Message is one member-to-member note in the store-and-forward inbox. +type Message struct { + ID int64 + From string + To string + Body string + Read bool + At time.Time +} + // Score is one leaderboard entry. type Score struct { User string @@ -100,6 +111,21 @@ type Store interface { AddChat(userID int64, username, role, text string) error RecentChats(username string, n int) ([]ChatMessage, error) + // Member-to-member messaging (store-and-forward inbox). + + // SendMessage leaves a note from→to in the recipient's inbox. + SendMessage(from, to, body string) error + // Inbox returns up to n messages addressed to username, newest first. + Inbox(username string, n int) ([]Message, error) + // UnreadCount reports how many unread messages username has waiting. + UnreadCount(username string) (int, error) + // MarkRead marks the given message ids read (scoped to username so a member + // can only clear their own mail). Empty ids is a no-op. + MarkRead(username string, ids []int64) error + // OnlineUsers reports the set of usernames with an open session (no + // ended_at), for the members directory presence dots. + OnlineUsers() (map[string]bool, error) + // Custom domains mapped to a member's homepage (public_html). // MapDomain binds domain→username, returning ErrDomainTaken if it is // already claimed by someone else (re-binding to the same owner is a no-op). @@ -471,6 +497,15 @@ CREATE TABLE IF NOT EXISTS news_articles ( ); CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num); CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id); +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + from_user TEXT NOT NULL, + to_user TEXT NOT NULL, + body TEXT NOT NULL, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_user, id DESC); ` func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { @@ -686,6 +721,75 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) return out, rows.Err() } +func (s *sqliteStore) SendMessage(from, to, body string) error { + _, err := s.db.Exec(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`, + from, to, body) + return err +} + +func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) { + if n <= 0 { + n = 50 + } + rows, err := s.db.Query(` + SELECT id, from_user, to_user, body, read, created_at + FROM messages WHERE to_user = ? ORDER BY id DESC LIMIT ?`, username, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Message + for rows.Next() { + var m Message + var read int + var at string + if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Body, &read, &at); err != nil { + return nil, err + } + m.Read = read != 0 + m.At, _ = time.Parse(time.RFC3339, at) + out = append(out, m) + } + return out, rows.Err() +} + +func (s *sqliteStore) UnreadCount(username string) (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE to_user = ? AND read = 0`, username).Scan(&n) + return n, err +} + +func (s *sqliteStore) MarkRead(username string, ids []int64) error { + if len(ids) == 0 { + return nil + } + q := `UPDATE messages SET read = 1 WHERE to_user = ? AND id IN (?` + strings.Repeat(",?", len(ids)-1) + `)` + args := make([]any, 0, len(ids)+1) + args = append(args, username) + for _, id := range ids { + args = append(args, id) + } + _, err := s.db.Exec(q, args...) + return err +} + +func (s *sqliteStore) OnlineUsers() (map[string]bool, error) { + rows, err := s.db.Query(`SELECT DISTINCT username FROM sessions WHERE ended_at IS NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + online := map[string]bool{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + online[strings.ToLower(name)] = true + } + return online, rows.Err() +} + func (s *sqliteStore) MapDomain(domain, username string) error { var owner string err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner) diff --git a/internal/store/store_messages_test.go b/internal/store/store_messages_test.go new file mode 100644 index 0000000..b5acac6 --- /dev/null +++ b/internal/store/store_messages_test.go @@ -0,0 +1,82 @@ +package store + +import "testing" + +func TestMessagingRoundtrip(t *testing.T) { + st := openTest(t) + + _, _ = st.EnsureUser("alice", "member", "SHA256:aaa") + _, _ = st.EnsureUser("bob", "member", "SHA256:bbb") + + if n, err := st.UnreadCount("bob"); err != nil || n != 0 { + t.Fatalf("fresh unread: n=%d err=%v", n, err) + } + + if err := st.SendMessage("alice", "bob", "hey, c4 tonight?"); err != nil { + t.Fatalf("send: %v", err) + } + if err := st.SendMessage("alice", "bob", "second note"); err != nil { + t.Fatalf("send2: %v", err) + } + + n, err := st.UnreadCount("bob") + if err != nil || n != 2 { + t.Fatalf("unread after send: n=%d err=%v", n, err) + } + + inbox, err := st.Inbox("bob", 10) + if err != nil { + t.Fatalf("inbox: %v", err) + } + if len(inbox) != 2 { + t.Fatalf("want 2 messages, got %d", len(inbox)) + } + // Newest first. + if inbox[0].Body != "second note" || inbox[0].From != "alice" || inbox[0].To != "bob" { + t.Fatalf("unexpected newest message: %+v", inbox[0]) + } + + // Mark only the first read; the other stays unread. + if err := st.MarkRead("bob", []int64{inbox[0].ID}); err != nil { + t.Fatalf("markread: %v", err) + } + if n, _ := st.UnreadCount("bob"); n != 1 { + t.Fatalf("want 1 unread after partial read, got %d", n) + } + + // MarkRead is scoped to the recipient: alice can't clear bob's mail. + if err := st.MarkRead("alice", []int64{inbox[1].ID}); err != nil { + t.Fatalf("markread other: %v", err) + } + if n, _ := st.UnreadCount("bob"); n != 1 { + t.Fatalf("cross-user markread leaked: unread=%d", n) + } + + // Empty ids is a no-op. + if err := st.MarkRead("bob", nil); err != nil { + t.Fatalf("markread empty: %v", err) + } +} + +func TestOnlineUsers(t *testing.T) { + st := openTest(t) + + u, _ := st.EnsureUser("carol", "member", "SHA256:ccc") + id, _ := st.RecordSession(u.ID, "carol", "1.2.3.4", "hub") + + online, err := st.OnlineUsers() + if err != nil { + t.Fatalf("online: %v", err) + } + if !online["carol"] { + t.Fatal("carol should be online while her session is open") + } + + if err := st.EndSession(id); err != nil { + t.Fatalf("end: %v", err) + } + online, _ = st.OnlineUsers() + if online["carol"] { + t.Fatal("carol should be offline after her session ends") + } +} diff --git a/plugins/members/members.go b/plugins/members/members.go new file mode 100644 index 0000000..13d77e3 --- /dev/null +++ b/plugins/members/members.go @@ -0,0 +1,421 @@ +// Package members is the member directory + messaging plugin (the BBS "who" and +// store-and-forward inbox). Members browse who else has an account, see who is +// online now, finger a profile, leave a message, and read their own inbox. +// +// It is members-only (RequiresAuth) — guests have no identity to send from or +// receive to. Messaging is store-and-forward via the store's messages table; +// the same inbox is fed by the `ssh msg@host ` CLI route. +package members + +import ( + "fmt" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/store" +) + +type Plugin struct{} + +func (Plugin) ID() string { return "members" } +func (Plugin) Title() string { return "Members" } +func (Plugin) Description() string { return "Who's here · finger a profile · leave a message · inbox" } +func (Plugin) RequiresAuth() bool { return true } + +func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { + return &model{user: user, ctx: ctx, state: stList} +} + +// state is which sub-screen is showing. +type state int + +const ( + stList state = iota + stProfile + stCompose + stInbox +) + +// person is one directory row. +type person struct { + name string + kind string + online bool + lastSeen time.Time + seenOK bool +} + +type model struct { + user auth.User + ctx plugin.Context + state state + + people []person + inbox []store.Message + cursor int // list/inbox cursor + target string // who we're fingering/composing to + + draft string // compose buffer + note string // transient status line + err error + + width, height int +} + +// --- loading --------------------------------------------------------------- + +type loadedMsg struct { + people []person + err error +} + +func (m *model) load() tea.Cmd { + st := m.ctx.Store + me := m.user.Name + return func() tea.Msg { + users, err := st.ListUsers(500) + if err != nil { + return loadedMsg{err: err} + } + online, _ := st.OnlineUsers() + out := make([]person, 0, len(users)) + for _, u := range users { + if u.Name == me { + continue // don't list yourself in the directory + } + p := person{name: u.Name, kind: u.Kind, online: online[strings.ToLower(u.Name)]} + if t, ok, _ := st.LastSeen(u.ID); ok { + p.lastSeen, p.seenOK = t, true + } + out = append(out, p) + } + // Online first, then most-recently-seen, then name. + sort.SliceStable(out, func(i, j int) bool { + if out[i].online != out[j].online { + return out[i].online + } + if out[i].seenOK != out[j].seenOK { + return out[i].seenOK + } + if out[i].seenOK && !out[i].lastSeen.Equal(out[j].lastSeen) { + return out[i].lastSeen.After(out[j].lastSeen) + } + return out[i].name < out[j].name + }) + return loadedMsg{people: out} + } +} + +type inboxMsg struct { + msgs []store.Message + err error +} + +func (m *model) loadInbox() tea.Cmd { + st := m.ctx.Store + me := m.user.Name + return func() tea.Msg { + msgs, err := st.Inbox(me, 100) + if err != nil { + return inboxMsg{err: err} + } + // Opening the inbox marks everything read. + var unread []int64 + for _, mm := range msgs { + if !mm.Read { + unread = append(unread, mm.ID) + } + } + _ = st.MarkRead(me, unread) + return inboxMsg{msgs: msgs} + } +} + +type sentMsg struct{ err error } + +func (m *model) send(to, body string) tea.Cmd { + st := m.ctx.Store + from := m.user.Name + return func() tea.Msg { return sentMsg{err: st.SendMessage(from, to, body)} } +} + +func (m *model) Init() tea.Cmd { return m.load() } + +// --- update ---------------------------------------------------------------- + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + case loadedMsg: + m.people, m.err = msg.people, msg.err + if m.cursor >= len(m.people) { + m.cursor = 0 + } + return m, nil + case inboxMsg: + m.inbox, m.err = msg.msgs, msg.err + return m, nil + case sentMsg: + if msg.err != nil { + m.note = "send failed: " + msg.err.Error() + } else { + m.note = "✓ message sent to " + m.target + m.state = stProfile + } + m.draft = "" + return m, nil + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.state == stCompose { + return m.composeKey(k) + } + m.note = "" + switch m.state { + case stList: + switch k.String() { + case "q", "esc": + return m, plugin.Exit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.people)-1 { + m.cursor++ + } + case "i": + m.state = stInbox + m.cursor = 0 + return m, m.loadInbox() + case "r": + return m, m.load() + case "enter": + if p := m.selected(); p != nil { + m.target = p.name + m.state = stProfile + } + case "m": + if p := m.selected(); p != nil { + m.target = p.name + m.draft = "" + m.state = stCompose + } + } + case stProfile: + switch k.String() { + case "q", "esc", "backspace": + m.state = stList + case "m": + m.draft = "" + m.state = stCompose + } + case stInbox: + switch k.String() { + case "q", "esc", "backspace": + m.state = stList + return m, m.load() // refresh unread badge state + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.inbox)-1 { + m.cursor++ + } + } + } + return m, nil +} + +// composeKey runs the minimal one-line message editor. +func (m *model) composeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.Type { + case tea.KeyEsc: + m.state = stProfile + m.draft = "" + return m, nil + case tea.KeyEnter: + body := strings.TrimSpace(m.draft) + if body == "" { + m.note = "type a message first (esc to cancel)" + return m, nil + } + return m, m.send(m.target, body) + case tea.KeyBackspace, tea.KeyDelete: + if n := len(m.draft); n > 0 { + r := []rune(m.draft) + m.draft = string(r[:len(r)-1]) + } + return m, nil + case tea.KeySpace: + m.draft += " " + return m, nil + case tea.KeyRunes: + m.draft += string(k.Runes) + return m, nil + } + return m, nil +} + +func (m *model) selected() *person { + if m.cursor < 0 || m.cursor >= len(m.people) { + return nil + } + return &m.people[m.cursor] +} + +// --- view ------------------------------------------------------------------ + +var ( + hdr = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + sel = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0")) + on = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + off = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + cur = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + frame = lipgloss.NewStyle().Padding(1, 2) +) + +func (m *model) View() string { + var s string + switch m.state { + case stProfile: + s = m.profileView() + case stCompose: + s = m.composeView() + case stInbox: + s = m.inboxView() + default: + s = m.listView() + } + if m.note != "" { + s += "\n" + warn.Render(m.note) + } + return frame.Render(s) +} + +func (m *model) listView() string { + s := hdr.Render("Members") + dim.Render(" · who's here") + "\n\n" + if m.err != nil { + return s + warn.Render("error: "+m.err.Error()) + } + if len(m.people) == 0 { + return s + dim.Render("no members yet") + } + for i, p := range m.people { + dot := off.Render("○") + if p.online { + dot = on.Render("●") + } + name := p.name + c := " " + if i == m.cursor { + c = cur.Render("❯ ") + name = sel.Render(name) + } + seen := "online" + if !p.online { + seen = "last " + relTime(p.lastSeen, p.seenOK) + } + row := fmt.Sprintf("%s%s %-20s %-8s %s", c, dot, name, p.kind, dim.Render(seen)) + s += row + "\n" + } + s += "\n" + dim.Render("↑/↓ move · enter finger · m message · i inbox · r refresh · q back") + return s +} + +func (m *model) profileView() string { + p := m.find(m.target) + s := hdr.Render("finger "+m.target) + "\n\n" + if p == nil { + return s + dim.Render("unknown member") + } + status := off.Render("offline") + dim.Render(" · last "+relTime(p.lastSeen, p.seenOK)) + if p.online { + status = on.Render("online now") + } + home := "~" + p.name + if m.ctx.Host != "" { + home = "https://" + m.ctx.Host + "/~" + p.name + } + lines := []string{ + " Login: " + sel.Render(p.name) + " Kind: " + p.kind, + " Status: " + status, + " Home: " + dim.Render(home), + } + s += strings.Join(lines, "\n") + s += "\n\n" + dim.Render("m message "+p.name+" · esc back") + return s +} + +func (m *model) composeView() string { + s := hdr.Render("message "+m.target) + "\n\n" + s += dim.Render("from "+m.user.Name+" → "+m.target) + "\n\n" + s += " " + m.draft + cur.Render("▏") + "\n\n" + s += dim.Render("enter send · esc cancel") + return s +} + +func (m *model) inboxView() string { + s := hdr.Render("Inbox") + dim.Render(" · "+m.user.Name) + "\n\n" + if m.err != nil { + return s + warn.Render("error: "+m.err.Error()) + } + if len(m.inbox) == 0 { + return s + dim.Render("no messages — select a member and press m to send one") + + "\n\n" + dim.Render("esc back") + } + for i, msg := range m.inbox { + c := " " + from := msg.From + if i == m.cursor { + c = cur.Render("❯ ") + from = sel.Render(from) + } + s += fmt.Sprintf("%s%-16s %s\n", c, from, dim.Render(relTime(msg.At, true)+" ago")) + s += " " + msg.Body + "\n" + } + s += "\n" + dim.Render("↑/↓ scroll · esc back") + return s +} + +func (m *model) find(name string) *person { + for i := range m.people { + if m.people[i].name == name { + return &m.people[i] + } + } + return nil +} + +// relTime renders a coarse "2h", "3d", "just now" style age. ok=false → "never". +func relTime(t time.Time, ok bool) string { + if !ok || t.IsZero() { + return "never" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} From e0a267e343bf4b7d7c279645c50ded02b8bd04e1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 15 Jun 2026 08:06:32 -0700 Subject: [PATCH 03/18] Fix/pod public html bind mount (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pods/admin): root admin alias, default-caps cleanup, pod rebuild script - auth: add `root` as an admin-console route alias (alongside admin/sysop); still gated by $AGENTBBS_ADMINS — the name confers nothing on its own. - pods: drop the now-redundant tuneApt apt-sandbox hack. Rootless podman keeps its default capability set, so apt/chown/su work without disabling the download sandbox. - scripts/rebuild-pods.sh: recreate all member pods (keeps home volumes) so they pick up the current container profile on next `ssh pod@`. Co-Authored-By: Claude Opus 4.8 * feat(arcade,ui): 80s arcade classics + shared menu theme Arcade games (PRD §5.1), generalizing the sandboxed-PTY DOOM path into an external-game registry: Space Invaders (nInvaders), Pac-Man (pacman4console), Tetris (tint/vitetris), Moon Patrol (moon-buggy). Binaries resolve from assets/bin, PATH, then /usr/games, so a distro install or a hand-built binary lights each game up; missing games are skipped with a discovery hint. Installed on the host via `scripts/fetch-assets.sh --arcade` (apt), wired into setup.sh behind FETCH_ARCADE (default on). UI: new shared ui.Theme.MenuItem widget (accent cursor + badge, description shown only for the focused row) adopted by the hub and arcade menus; the hub groups rows under Features/Sessions headers and the arcade under DOOM/ARCADE/BUILT-IN. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- cmd/agentbbs/main.go | 3 +- internal/admin/admin.go | 17 +-- internal/auth/auth.go | 5 +- internal/auth/auth_test.go | 2 +- internal/hub/hub.go | 64 ++++------ internal/news/tui.go | 15 +-- internal/ui/theme.go | 159 +++++++++++++++++++++++ plugins/about/about.go | 60 +++++++-- plugins/agentgames/agentgames.go | 17 +-- plugins/arcade/arcade.go | 195 ++++++++++++++++++++++------- plugins/arcade/board.go | 12 +- plugins/qryptinvite/qryptinvite.go | 12 +- scripts/fetch-assets.sh | 46 ++++++- scripts/rebuild-pods.sh | 63 ++++++++++ setup.sh | 7 +- 15 files changed, 539 insertions(+), 138 deletions(-) create mode 100644 internal/ui/theme.go create mode 100755 scripts/rebuild-pods.sh diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 3bf9214..583f033 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -10,7 +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 admin@host the operator admin console ($AGENTBBS_ADMINS only) +// 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, // agent-vs-agent (also on wss://host/play). See docs/agentgames.md // diff --git a/internal/admin/admin.go b/internal/admin/admin.go index a67adda..b3bd238 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -17,6 +17,7 @@ import ( "github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/store" + "github.com/profullstack/agentbbs/internal/ui" ) // Live is one connected SSH session, as seen by the registry. @@ -70,13 +71,13 @@ var menuItems = []struct { } var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) - okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) - headStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa")) - frameStyle = lipgloss.NewStyle().Padding(1, 2) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green) + dimStyle = ui.Dim + cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green) + warnStyle = ui.Danger + okStyle = lipgloss.NewStyle().Foreground(ui.Green) + headStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue) + frameStyle = ui.Frame ) // Model is the admin console. @@ -306,7 +307,7 @@ func (m Model) View() string { } header := titleStyle.Render("AgentBBS admin") + dimStyle.Render(" · "+m.admin.Name) - out := header + "\n\n" + body + "\n" + dimStyle.Render(help) + out := header + "\n\n" + body + "\n" + ui.KeyBar(help) if m.note != "" { out += "\n" + m.note } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 026a57b..1679036 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -43,8 +43,9 @@ var DomainNames = map[string]bool{"domain": true, "domains": true} // AdminNames are usernames that route to the privileged admin console (PRD §6). // The route only opens for accounts whose name is in the operator allowlist -// (see IsAdmin); the name itself confers nothing. -var AdminNames = map[string]bool{"admin": true, "sysop": true} +// (see IsAdmin); the name itself confers nothing — so "root" is just a familiar +// alias here, not a backdoor. +var AdminNames = map[string]bool{"admin": true, "sysop": true, "root": true} // TorURLNames route to the one-shot "fetch a URL over Tor" command (premium). var TorURLNames = map[string]bool{"tor-url": true} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index aec7996..9e08477 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -3,7 +3,7 @@ package auth import "testing" func TestIsAdminName(t *testing.T) { - for _, name := range []string{"admin", "ADMIN", "sysop"} { + for _, name := range []string{"admin", "ADMIN", "sysop", "root"} { if !IsAdminName(name) { t.Errorf("IsAdminName(%q) = false, want true", name) } diff --git a/internal/hub/hub.go b/internal/hub/hub.go index d3a6092..cbe4d73 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -19,21 +19,12 @@ import ( "github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/ui" ) var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - selStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0")) - lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + theme = ui.New(ui.Green) bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a")) - motdStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#4ade80")). - Foreground(lipgloss.Color("252")). - Padding(0, 1) - frameStyle = lipgloss.NewStyle().Padding(1, 2) ) // SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC @@ -170,42 +161,37 @@ func (m Model) View() string { b.WriteString(bannerStyle.Render(m.banner) + "\n\n") } who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind) - b.WriteString(titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n") + b.WriteString(theme.Title("AgentBBS") + ui.Dim.Render(" · "+who) + "\n") if m.motd != "" { - b.WriteString("\n" + motdStyle.Render(m.motd) + "\n") + b.WriteString("\n" + theme.Card("", m.motd) + "\n") } b.WriteString("\n") row := 0 - for _, p := range m.plugins { - label := p.Title() - if p.RequiresAuth() && m.user.Kind == auth.Guest { - label += lockStyle.Render(" [members]") + if len(m.plugins) > 0 { + b.WriteString(theme.Section("Features") + "\n") + for _, p := range m.plugins { + badge := "" + if p.RequiresAuth() && m.user.Kind == auth.Guest { + badge = ui.Badge(ui.BadgeMuted, "members") + } + b.WriteString(theme.MenuItem(row == m.cursor, p.Title(), badge, p.Description())) + row++ } - b.WriteString(m.renderRow(row, label, p.Description())) - row++ } - for _, app := range m.apps { - label := app.Title - if app.Locked != "" { - label += lockStyle.Render(" [locked]") + if len(m.apps) > 0 { + b.WriteString("\n" + theme.Section("Sessions") + "\n") + for _, app := range m.apps { + badge := "" + if app.Locked != "" { + badge = ui.Badge(ui.BadgeGold, "locked") + } + b.WriteString(theme.MenuItem(row == m.cursor, app.Title, badge, app.Description)) + row++ } - b.WriteString(m.renderRow(row, label, app.Description)) - row++ } - b.WriteString("\n" + dimStyle.Render("↑/↓ move · enter select · ctrl+c back · q quit")) + b.WriteString("\n" + ui.KeyBar("↑/↓ move · enter select · ctrl+c back · q quit")) if m.note != "" { - b.WriteString("\n" + lockStyle.Render(m.note)) + b.WriteString("\n" + ui.Danger.Render(m.note)) } - return frameStyle.Render(b.String()) -} - -// renderRow renders one menu line with the cursor and dimmed description. The -// selected row's cursor and label are highlighted. -func (m Model) renderRow(i int, label, desc string) string { - cur := " " - if i == m.cursor { - cur = cursorStyle.Render("❯ ") - label = selStyle.Render(label) - } - return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc)) + return ui.Frame.Render(b.String()) } diff --git a/internal/news/tui.go b/internal/news/tui.go index 62f3d7d..6859a68 100644 --- a/internal/news/tui.go +++ b/internal/news/tui.go @@ -8,15 +8,16 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/ssh" "github.com/dustin/go-nntp" + + "github.com/profullstack/agentbbs/internal/ui" ) var ( - nTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc")) - nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(lipgloss.Color("#38bdf8")) - nMeta = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - nFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) - nErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) - nHint = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + theme = ui.New(ui.Purple) + nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(ui.Cyan) + nMeta = ui.Dim + nFrom = lipgloss.NewStyle().Foreground(ui.Green) + nErr = ui.Danger ) // RunReader connects the member to the loopback NNTP server and drives the @@ -311,7 +312,7 @@ func (m *model) frame(header, body, hint string) string { status = "\n" + nMeta.Render(m.status) } return lipgloss.NewStyle().Padding(0, 1).Render( - nTitle.Render(header) + "\n\n" + body + status + "\n\n" + nHint.Render(hint)) + theme.Title(header) + "\n\n" + body + status + "\n\n" + ui.KeyBar(hint)) } func (m *model) viewGroups() string { diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..1324080 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,159 @@ +// Package ui is the shared TUI theme for AgentBBS: one palette and a small set +// of structural widgets (cards, menu rows, status badges, key bars) so every +// screen — the hub and each plugin — looks like part of the same product. +// +// Screens keep their own accent color for identity (the hub is green, the +// arcade amber, the newsreader purple) by constructing a Theme with that +// accent; the layout primitives are shared. +package ui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Palette — the only colors any screen should reach for. +const ( + Green = lipgloss.Color("#4ade80") + Cyan = lipgloss.Color("#38bdf8") + Blue = lipgloss.Color("#60a5fa") + Gold = lipgloss.Color("#fbbf24") + Purple = lipgloss.Color("#c084fc") + Red = lipgloss.Color("#f87171") + + white = lipgloss.Color("#e2e8f0") + text = lipgloss.Color("252") + muted = lipgloss.Color("245") + faint = lipgloss.Color("240") +) + +// Structural styles shared by every screen. +var ( + // Frame is the outer padding every top-level View should wrap itself in. + Frame = lipgloss.NewStyle().Padding(1, 2) + // Dim is for secondary text (descriptions, metadata). + Dim = lipgloss.NewStyle().Foreground(muted) + // Body is primary readable text. + Body = lipgloss.NewStyle().Foreground(text) + // Danger is for errors and warnings. + Danger = lipgloss.NewStyle().Foreground(Red) + + selStyle = lipgloss.NewStyle().Bold(true).Foreground(white) + hintText = lipgloss.NewStyle().Foreground(faint) + keyText = lipgloss.NewStyle().Bold(true).Foreground(muted) +) + +// Theme carries one screen's accent color and renders the shared widgets in it. +type Theme struct{ Accent lipgloss.Color } + +// New returns a theme that tints titles, sections, card borders, cursors, and +// selected rows with accent (use a palette color). +func New(accent lipgloss.Color) Theme { return Theme{Accent: accent} } + +func (t Theme) accentStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(t.Accent) +} + +// Title renders the screen's main heading. +func (t Theme) Title(s string) string { return t.accentStyle().Render(s) } + +// Section renders an upper-cased sub-heading inside a screen. +func (t Theme) Section(s string) string { return t.accentStyle().Render(strings.ToUpper(s)) } + +// Card frames body in a rounded border tinted with the accent. A non-empty +// title is rendered as a section header at the top of the card. +func (t Theme) Card(title, body string) string { + if title != "" { + body = t.Section(title) + "\n\n" + body + } + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Accent). + Padding(1, 2). + Render(body) +} + +// Row renders one selectable menu line: an accent cursor and bold label when +// selected, with a dimmed description on the next line. An empty desc yields a +// single-line row. The returned string ends in a newline. +func (t Theme) Row(selected bool, label, desc string) string { + cur := " " + if selected { + cur = t.accentStyle().Render("❯ ") + label = selStyle.Render(label) + } + row := cur + label + "\n" + if desc != "" { + row += " " + Dim.Render(desc) + "\n" + } + return row +} + +// MenuItem renders one polished menu line shared by the hub and the plugin +// menus (PRD §4.1): an accent cursor and bold label when selected, an optional +// status badge after the label, and — to keep long menus uncluttered — the +// description shown only for the focused row. The result ends in a newline. +func (t Theme) MenuItem(selected bool, label, badge, desc string) string { + name := Body.Render(label) + cur := " " + if selected { + name = selStyle.Render(label) + cur = t.accentStyle().Render("❯ ") + } + if badge != "" { + name += " " + badge + } + out := cur + name + "\n" + if selected && desc != "" { + out += " " + Dim.Render(desc) + "\n" + } + return out +} + +// Badge variants. +const ( + BadgeOK = "ok" + BadgeInfo = "info" + BadgeGold = "gold" + BadgeWarn = "warn" + BadgeMuted = "muted" +) + +// Badge renders a small filled status tag, e.g. Badge(BadgeOK, "guests welcome"). +func Badge(variant, label string) string { + var fg, bg lipgloss.Color + switch variant { + case BadgeOK: + fg, bg = lipgloss.Color("#052e16"), Green + case BadgeInfo: + fg, bg = lipgloss.Color("#082f49"), Cyan + case BadgeGold: + fg, bg = lipgloss.Color("#451a03"), Gold + case BadgeWarn: + fg, bg = lipgloss.Color("#450a0a"), Red + default: + fg, bg = lipgloss.Color("#0b1020"), muted + } + return lipgloss.NewStyle().Bold(true).Foreground(fg).Background(bg).Padding(0, 1).Render(label) +} + +// KeyBar renders a footer hint, emphasizing the key token of each segment. It +// accepts the conventional " · "-separated form ("↑/↓ move · enter select · +// q quit") so call sites read naturally; the first word of each segment is +// brightened as the key. +func KeyBar(s string) string { + segs := strings.Split(s, "·") + for i, seg := range segs { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + if parts := strings.SplitN(seg, " ", 2); len(parts) == 2 { + segs[i] = keyText.Render(parts[0]) + hintText.Render(" "+parts[1]) + } else { + segs[i] = keyText.Render(seg) + } + } + return strings.Join(segs, hintText.Render(" · ")) +} diff --git a/plugins/about/about.go b/plugins/about/about.go index 8461d27..8640530 100644 --- a/plugins/about/about.go +++ b/plugins/about/about.go @@ -3,11 +3,14 @@ package about import ( + "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/ui" ) type Plugin struct{} @@ -26,22 +29,57 @@ type model struct{ user auth.User } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if _, ok := msg.(tea.KeyMsg); ok { - return m, plugin.Exit + if k, ok := msg.(tea.KeyMsg); ok { + switch k.String() { + case "esc", "q", "enter", "ctrl+c", " ": + return m, plugin.Exit + } } return m, nil } var ( - h = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - d = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + theme = ui.New(ui.Green) + taglineStyle = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("245")) + cmdStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Cyan) ) -func (m model) View() string { - return lipgloss.NewStyle().Padding(1, 2).Render( - h.Render("AgentBBS") + " — a modern BBS over SSH for humans and AI agents.\n\n" + - " ssh bbs@profullstack.com this hub (guests welcome)\n" + - " ssh join@profullstack.com register your SSH key\n" + - " ssh pod@profullstack.com your own Linux pod (members, $1/mo via coinpay)\n\n" + - d.Render("Maintained by Profullstack, Inc. · AgentGames spec at logicsrc.com\n\npress any key to return")) +// route is one connection entry point shown in the CONNECT card. +type route struct { + cmd, desc string + badgeVar, tag string +} + +func (m model) View() string { + const cmdW, descW = 28, 22 + + routes := []route{ + {"ssh bbs@profullstack.com", "the public hub", ui.BadgeOK, "guests welcome"}, + {"ssh join@profullstack.com", "register your SSH key", ui.BadgeInfo, "free"}, + {"ssh pod@profullstack.com", "your own Linux pod", ui.BadgeGold, "$1/mo · members"}, + } + + rows := make([]string, 0, len(routes)) + for _, r := range routes { + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, + cmdStyle.Width(cmdW).Render(r.cmd), + ui.Body.Width(descW).Render(r.desc), + ui.Badge(r.badgeVar, r.tag), + )) + } + + footer := ui.Dim.Render("Maintained by Profullstack, Inc.") + "\n" + + ui.Dim.Render("AgentGames spec → logicsrc.com") + + body := lipgloss.JoinVertical(lipgloss.Left, + theme.Title("AgentBBS"), + taglineStyle.Render("a modern BBS over SSH — for humans and AI agents"), + "", + theme.Card("Connect", strings.Join(rows, "\n")), + "", + footer, + "", + ui.KeyBar("esc/q return to menu"), + ) + return ui.Frame.Render(body) } diff --git a/plugins/agentgames/agentgames.go b/plugins/agentgames/agentgames.go index eca6d38..0a7947c 100644 --- a/plugins/agentgames/agentgames.go +++ b/plugins/agentgames/agentgames.go @@ -18,6 +18,7 @@ import ( "github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/store" + "github.com/profullstack/agentbbs/internal/ui" ) // Plugin is the AgentGames hub entry. @@ -52,12 +53,12 @@ const ( ) var ( - title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) - head = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa")) - warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) - frame = lipgloss.NewStyle().Padding(1, 2) + title = lipgloss.NewStyle().Bold(true).Foreground(ui.Green) + dim = ui.Dim + cursor = lipgloss.NewStyle().Bold(true).Foreground(ui.Green) + head = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue) + warn = ui.Danger + frame = ui.Frame ) var actions = []string{"Ladder", "Replays", "Play vs bot"} @@ -302,7 +303,7 @@ func (m *model) View() string { case scPlay: body, help = m.viewPlay() } - out := title.Render("AgentGames") + "\n\n" + body + "\n" + dim.Render(help) + out := title.Render("AgentGames") + "\n\n" + body + "\n" + ui.KeyBar(help) if m.note != "" { out += "\n" + m.note } @@ -393,7 +394,7 @@ func (m *model) viewPlay() (string, string) { func (m *model) row(i int) string { if i == m.cursor { - return cursor.Render("> ") + return cursor.Render("❯ ") } return " " } diff --git a/plugins/arcade/arcade.go b/plugins/arcade/arcade.go index 7fb120c..e5195d6 100644 --- a/plugins/arcade/arcade.go +++ b/plugins/arcade/arcade.go @@ -1,37 +1,65 @@ // Package arcade is the flagship plugin (PRD §5.1): classic terminal games. -// DOOM runs as a sandboxed external binary (doom-ascii + Freedoom); built-in -// TUI games (snake) feed the global leaderboards. +// DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon +// Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games +// (snake) feed the global leaderboards. package arcade import ( - "fmt" "os" + "os/exec" "path/filepath" "strings" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/ui" ) type Plugin struct{} func (Plugin) ID() string { return "arcade" } func (Plugin) Title() string { return "Arcade" } -func (Plugin) Description() string { return "DOOM (ASCII), snake, leaderboards" } +func (Plugin) Description() string { return "DOOM, Space Invaders, Pac-Man, Tetris, snake & leaderboards" } func (Plugin) RequiresAuth() bool { return false } func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { return newMenu(user, ctx) } +// extGame is an 80s arcade classic launched as a sandboxed subprocess on a real +// PTY — the doom-ascii pattern, generalized. The binary is resolved from the +// platform assets dir first, then the host PATH and the well-known distro game +// dirs, so either `scripts/fetch-assets.sh --arcade` (distro install) or a +// hand-built binary dropped in assets/bin makes the game appear in the menu. +type extGame struct { + id string // stable id; also the per-user save subdir under arcade/ + label string // menu label + desc string // one-line menu description + bins []string // candidate binary names (first that resolves wins) + args []string // launch args (most need none) +} + +// extGames is the arcade catalog of external classics, in menu order. +var extGames = []extGame{ + {id: "invaders", label: "Space Invaders", desc: "nInvaders — shoot the descending alien fleet", bins: []string{"ninvaders", "nInvaders"}}, + {id: "pacman", label: "Pac-Man", desc: "pacman4console — clear the maze, dodge the ghosts", bins: []string{"pacman4console"}}, + {id: "tetris", label: "Tetris", desc: "tint — stack the falling tetrominoes", bins: []string{"tint", "vitetris", "tetris"}}, + {id: "moonpatrol", label: "Moon Patrol", desc: "moon-buggy — jump the craters across the lunar surface", bins: []string{"moon-buggy"}}, +} + +// gameDirs are the well-known locations distro packages drop game binaries. +// Debian/Ubuntu put them in /usr/games, which is usually off the daemon's PATH, +// so we probe these explicitly in addition to exec.LookPath. +var gameDirs = []string{"/usr/games", "/usr/local/games", "/usr/local/bin", "/usr/bin"} + // entry is one row in the arcade menu. type entry struct { - label string - desc string - run func(m *menu) (tea.Model, tea.Cmd) + section string + label string + desc string + run func(m *menu) (tea.Model, tea.Cmd) } type menu struct { @@ -45,42 +73,69 @@ type menu struct { child tea.Model // snake / leaderboard take over here } -var ( - tStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#fbbf24")) - dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - cStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24")) - eStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) -) +var theme = ui.New(ui.Gold) func newMenu(user auth.User, ctx plugin.Context) *menu { m := &menu{user: user, ctx: ctx} + + // --- DOOM (per WAD) --- for _, wad := range findWADs(ctx, user) { wad := wad m.entries = append(m.entries, entry{ - label: "DOOM — " + filepath.Base(wad), - desc: "doom-ascii in a sandbox (24-bit color terminal recommended)", - run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(wad) }, + section: "DOOM", + label: "DOOM — " + filepath.Base(wad), + desc: "doom-ascii in a sandbox (24-bit color terminal recommended)", + run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(wad) }, }) } - if len(m.entries) == 0 { + if doomBin(ctx) == "" { m.entries = append(m.entries, entry{ - label: "DOOM — not installed", - desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom", - run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil }, + section: "DOOM", + label: "DOOM — not installed", + desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom", + run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil }, }) } + + // --- arcade classics (external binaries) --- + var arcadeFound bool + for _, g := range extGames { + g := g + if resolveBin(ctx, g.bins) == "" { + continue + } + arcadeFound = true + m.entries = append(m.entries, entry{ + section: "ARCADE", + label: g.label, + desc: g.desc, + run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchExt(g) }, + }) + } + if !arcadeFound { + m.entries = append(m.entries, entry{ + section: "ARCADE", + label: "Arcade classics — not installed", + desc: "run scripts/fetch-assets.sh --arcade on the host (Space Invaders, Pac-Man, Tetris, Moon Patrol)", + run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "arcade binaries missing on host"; return m, nil }, + }) + } + + // --- built-in (leaderboard-backed) --- m.entries = append(m.entries, entry{ - label: "Snake", - desc: "built-in; high scores hit the global leaderboard", + section: "BUILT-IN", + label: "Snake", + desc: "built-in; high scores hit the global leaderboard", run: func(m *menu) (tea.Model, tea.Cmd) { m.child = newSnake(m.user, m.ctx, m.width, m.height) return m, m.child.Init() }, }, entry{ - label: "Leaderboard", - desc: "global top scores", + section: "BUILT-IN", + label: "Leaderboard", + desc: "global top scores", run: func(m *menu) (tea.Model, tea.Cmd) { m.child = newBoard(m.ctx) return m, m.child.Init() @@ -116,24 +171,72 @@ func doomBin(ctx plugin.Context) string { return "" } +// resolveBin finds the first candidate binary that exists: bundled in the +// platform assets dir, on PATH, or in a well-known distro game dir. +func resolveBin(ctx plugin.Context, names []string) string { + for _, n := range names { + if p := filepath.Join(ctx.AssetsDir, "bin", n); isExec(p) { + return p + } + if p, err := exec.LookPath(n); err == nil { + return p + } + for _, d := range gameDirs { + if p := filepath.Join(d, n); isExec(p) { + return p + } + } + } + return "" +} + +func isExec(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0 +} + +// workDir returns the writable per-game save dir: a stable path for members, +// a throwaway temp dir for guests. +func (m *menu) workDir(sub string) string { + if m.ctx.DataDir == "" { + d, _ := os.MkdirTemp("", "agentbbs-guest-"+strings.ReplaceAll(sub, "/", "-")+"-") + return d + } + d := filepath.Join(m.ctx.DataDir, "arcade", sub) + _ = os.MkdirAll(d, 0o755) + return d +} + // launchDoom suspends the TUI and bridges the session to a sandboxed // doom-ascii on a real PTY. Savegames land in the per-user work dir. func (m *menu) launchDoom(wad string) tea.Cmd { bin := doomBin(m.ctx) - work := m.ctx.DataDir - if work == "" { // guests: throwaway saves - work, _ = os.MkdirTemp("", "agentbbs-guest-doom-") - } else { - work = filepath.Join(work, "doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad))) - _ = os.MkdirAll(work, 0o755) - } + work := m.workDir(filepath.Join("doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad)))) cmd := m.ctx.Sandbox.Command(work, bin, "-iwad", wad) return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg { - return doomDoneMsg{err: err} + return gameDoneMsg{name: "DOOM", err: err} }) } -type doomDoneMsg struct{ err error } +// launchExt suspends the TUI and bridges the session to a sandboxed arcade +// classic on a real PTY (the generalized doom path). +func (m *menu) launchExt(g extGame) tea.Cmd { + bin := resolveBin(m.ctx, g.bins) + if bin == "" { // raced with an uninstall; surface rather than exec "" + m.note = g.label + " is no longer installed on the host" + return nil + } + work := m.workDir(g.id) + cmd := m.ctx.Sandbox.Command(work, bin, g.args...) + return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg { + return gameDoneMsg{name: g.label, err: err} + }) +} + +type gameDoneMsg struct { + name string + err error +} func (m *menu) Init() tea.Cmd { return nil } @@ -151,9 +254,9 @@ func (m *menu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } switch msg := msg.(type) { - case doomDoneMsg: + case gameDoneMsg: if msg.err != nil { - m.note = "doom exited: " + msg.err.Error() + m.note = msg.name + " exited: " + msg.err.Error() } return m, nil case tea.KeyMsg: @@ -180,19 +283,23 @@ func (m *menu) View() string { if m.child != nil { return m.child.View() } - s := tStyle.Render("Arcade") + "\n\n" + s := theme.Title("Arcade") + ui.Dim.Render(" · classic terminal games, sandboxed") + "\n\n" + prevSection := "" for i, e := range m.entries { - cur := " " - if i == m.cursor { - cur = cStyle.Render("> ") + if e.section != prevSection { + if prevSection != "" { + s += "\n" + } + s += theme.Section(e.section) + "\n" + prevSection = e.section } - s += fmt.Sprintf("%s%s\n %s\n", cur, e.label, dStyle.Render(e.desc)) + s += theme.MenuItem(i == m.cursor, e.label, "", e.desc) } - s += "\n" + dStyle.Render("↑/↓ move · enter play · q back to hub") + s += "\n" + ui.KeyBar("↑/↓ move · enter play · q back to hub") if m.note != "" { - s += "\n" + eStyle.Render(m.note) + s += "\n" + ui.Danger.Render(m.note) } - return lipgloss.NewStyle().Padding(1, 2).Render(s) + return ui.Frame.Render(s) } // backMsg returns from a child (snake/leaderboard) to the arcade menu. diff --git a/plugins/arcade/board.go b/plugins/arcade/board.go index 3b1d56c..024af2e 100644 --- a/plugins/arcade/board.go +++ b/plugins/arcade/board.go @@ -4,10 +4,10 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/store" + "github.com/profullstack/agentbbs/internal/ui" ) // board renders the global top scores (PRD §5.1 leaderboards). @@ -42,17 +42,17 @@ func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (b *board) View() string { - s := tStyle.Render("Leaderboard — snake") + "\n\n" + s := theme.Title("Leaderboard — snake") + "\n\n" switch { case b.err != nil: - s += eStyle.Render("error: " + b.err.Error()) + s += ui.Danger.Render("error: " + b.err.Error()) case len(b.scores) == 0: - s += dStyle.Render("no scores yet — be the first") + s += ui.Dim.Render("no scores yet — be the first") default: for i, sc := range b.scores { s += fmt.Sprintf("%2d. %-20s %6d\n", i+1, sc.User, sc.Score) } } - s += "\n" + dStyle.Render("any key to return") - return lipgloss.NewStyle().Padding(1, 2).Render(s) + s += "\n" + ui.KeyBar("any-key return to menu") + return ui.Frame.Render(s) } diff --git a/plugins/qryptinvite/qryptinvite.go b/plugins/qryptinvite/qryptinvite.go index 2b35680..eaaf0db 100644 --- a/plugins/qryptinvite/qryptinvite.go +++ b/plugins/qryptinvite/qryptinvite.go @@ -18,6 +18,7 @@ import ( "github.com/profullstack/agentbbs/internal/plugin" qi "github.com/profullstack/agentbbs/internal/qryptinvite" "github.com/profullstack/agentbbs/internal/store" + "github.com/profullstack/agentbbs/internal/ui" ) // Plugin is the hub registration. It admits members only (guests have no @@ -99,13 +100,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { - return lipgloss.NewStyle().Padding(1, 2).Render( - m.body + "\n\n" + dStyle.Render("press any key to return")) + return ui.Frame.Render(m.body + "\n\n" + ui.KeyBar("esc/q return to menu")) } var ( - hStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) - urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")) + hStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green) + dStyle = ui.Dim + errStyle = ui.Danger + urlStyle = lipgloss.NewStyle().Foreground(ui.Blue) ) diff --git a/scripts/fetch-assets.sh b/scripts/fetch-assets.sh index 8db0192..d7999e5 100755 --- a/scripts/fetch-assets.sh +++ b/scripts/fetch-assets.sh @@ -3,10 +3,13 @@ # ./assets — Freedoom by default (PRD §9.1). Run on the host before enabling # the arcade's DOOM entries. # -# scripts/fetch-assets.sh [--shareware] +# scripts/fetch-assets.sh [--shareware] [--arcade] # # --shareware additionally fetches the freely redistributable doom1.wad -# shareware episode. +# shareware episode. +# --arcade installs the 80s arcade classics (Space Invaders, Pac-Man, Tetris, +# Moon Patrol) the arcade plugin launches via the same sandboxed-PTY path as +# DOOM. Needs apt + sudo (Debian/Ubuntu); the menu lists whatever installs. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -14,6 +17,16 @@ ASSETS="$ROOT/assets" BUILD="$ROOT/.build" FREEDOOM_VERSION="${FREEDOOM_VERSION:-0.13.0}" +want_shareware=0 +want_arcade=0 +for arg in "$@"; do + case "$arg" in + --shareware) want_shareware=1 ;; + --arcade) want_arcade=1 ;; + *) echo "!! unknown flag: $arg (use --shareware and/or --arcade)" >&2; exit 2 ;; + esac +done + mkdir -p "$ASSETS/bin" "$ASSETS/wads" "$BUILD" # --- doom-ascii ------------------------------------------------------------- @@ -47,12 +60,39 @@ else fi # --- Doom shareware (optional) ---------------------------------------------- -if [ "${1:-}" = "--shareware" ] && [ ! -f "$ASSETS/wads/doom1.wad" ]; then +if [ "$want_shareware" = 1 ] && [ ! -f "$ASSETS/wads/doom1.wad" ]; then echo ">> fetching Doom shareware episode" curl -fsSL -o "$ASSETS/wads/doom1.wad" \ "https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad" echo ">> installed doom1.wad (shareware)" fi +# --- Arcade classics (optional) --------------------------------------------- +# Tiny, well-packaged ncurses C programs from the distro (Debian/Ubuntu +# universe). They land in /usr/games, which the arcade plugin probes alongside +# assets/bin and PATH. The arcade menu lists whichever of these is present. +ARCADE_PKGS="ninvaders pacman4console moon-buggy tint" +if [ "$want_arcade" = 1 ]; then + if command -v apt-get >/dev/null 2>&1; then + SUDO="" + [ "$(id -u)" -ne 0 ] && SUDO="sudo" + echo ">> installing arcade classics: $ARCADE_PKGS" + $SUDO apt-get update -y + # Install individually so one missing package doesn't abort the rest. + for pkg in $ARCADE_PKGS; do + $SUDO apt-get install -y "$pkg" || echo "!! $pkg not available; skipping" + done + else + echo "!! --arcade needs apt-get (Debian/Ubuntu)." >&2 + echo " On other distros, install equivalents of: $ARCADE_PKGS" >&2 + fi +fi + echo ">> done. WADs:" ls -l "$ASSETS/wads" +echo ">> arcade classics on host:" +for bin in ninvaders pacman4console moon-buggy tint vitetris; do + p="$(command -v "$bin" 2>/dev/null || true)" + [ -z "$p" ] && [ -x "/usr/games/$bin" ] && p="/usr/games/$bin" + [ -n "$p" ] && echo " $bin -> $p" +done diff --git a/scripts/rebuild-pods.sh b/scripts/rebuild-pods.sh new file mode 100755 index 0000000..0cc3fb0 --- /dev/null +++ b/scripts/rebuild-pods.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Rebuild every AgentBBS member pod so it picks up the current container profile +# (e.g. the rootless-podman default capability set added for apt/chown/su/:80). +# +# It removes each pod CONTAINER but keeps that pod's named home volume +# (agentbbs-pod--home) and the host-side public_html, so member data and +# websites are untouched. Pods are recreated automatically — with the new +# profile — the next time each member runs `ssh pod@`. Caddy serves +# public_html from the host, so sites stay up while a pod is briefly down. +# +# Anything a member installed into the pod's system rootfs (apt packages, etc.) +# is lost on rebuild; only /home/dev and public_html persist. +# +# Run this as the user that owns the pods. For rootless podman that's the +# AgentBBS service user (pods are per-user), not necessarily root. +# +# Usage: +# scripts/rebuild-pods.sh # list, then prompt before removing +# scripts/rebuild-pods.sh --yes # non-interactive (for cron/deploy) +# AGENTBBS_POD_ENGINE=docker scripts/rebuild-pods.sh # force engine +set -euo pipefail + +ENGINE="${AGENTBBS_POD_ENGINE:-}" +if [ -z "$ENGINE" ]; then + if command -v podman >/dev/null 2>&1; then + ENGINE=podman + elif command -v docker >/dev/null 2>&1; then + ENGINE=docker + else + echo "rebuild-pods: neither podman nor docker found" >&2 + exit 1 + fi +fi + +mapfile -t pods < <("$ENGINE" ps -a --filter 'name=agentbbs-pod-' --format '{{.Names}}' | sort) + +if [ "${#pods[@]}" -eq 0 ]; then + echo "rebuild-pods: no pods found (engine: $ENGINE)" + exit 0 +fi + +echo "Found ${#pods[@]} pod(s) via $ENGINE:" +printf ' %s\n' "${pods[@]}" + +if [ "${1:-}" != "--yes" ] && [ "${1:-}" != "-y" ]; then + printf 'Remove these containers (home volumes kept)? [y/N] ' + read -r reply + case "$reply" in + y | Y | yes | YES) ;; + *) + echo "aborted" + exit 0 + ;; + esac +fi + +for p in "${pods[@]}"; do + # No -v: named home volumes are preserved, only the container is destroyed. + "$ENGINE" rm -f "$p" >/dev/null && echo "removed $p" +done + +echo +echo "Done. Each pod recreates with the new profile on its owner's next 'ssh pod@'." diff --git a/setup.sh b/setup.sh index 59b497c..9fbd606 100755 --- a/setup.sh +++ b/setup.sh @@ -34,6 +34,7 @@ HTTP_ADDR="${HTTP_ADDR:-127.0.0.1:8088}" # agentbbs /verify endpoint (join@ emai GO_VERSION="${GO_VERSION:-1.26.4}" POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}" FETCH_ASSETS="${FETCH_ASSETS:-1}" # set 0 to skip the DOOM/Freedoom arcade assets +FETCH_ARCADE="${FETCH_ARCADE:-1}" # set 0 to skip the 80s arcade classics (apt: ninvaders, pacman4console, moon-buggy, tint) SKIP_BUILD="${SKIP_BUILD:-0}" # set 1 to use prebuilt /usr/local/bin/{agentbbs,ascii-live} (tiny droplets can't compile) SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set 0 to skip) SELF_UPDATE="${SELF_UPDATE:-1}" # set 0 to skip the autonomous self-update systemd timer @@ -194,8 +195,10 @@ else fi if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/scripts/fetch-assets.sh" ]; then - log "fetching arcade assets (set FETCH_ASSETS=0 to skip)" - ( cd "$SRC_DIR" && ./scripts/fetch-assets.sh ) || warn "asset fetch failed; arcade may be limited" + fetch_flags="" + [ "$FETCH_ARCADE" = "1" ] && fetch_flags="--arcade" + log "fetching arcade assets (set FETCH_ASSETS=0 to skip; FETCH_ARCADE=0 for DOOM only)" + ( cd "$SRC_DIR" && ./scripts/fetch-assets.sh $fetch_flags ) || warn "asset fetch failed; arcade may be limited" fi # Add swap on tiny droplets before the build (and for runtime headroom). From 337011fa0349e7b6aefc367c52a4624674457ba9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 15 Jun 2026 08:23:54 -0700 Subject: [PATCH 04/18] refactor(pods): drop redundant tuneApt apt-sandbox hack (#31) The rootless-podman default capability set (already on main) lets apt drop to the _apt user on its own, so disabling the apt download sandbox via tuneApt is dead code. Removing it; apt now works the proper way. Co-authored-by: Claude Opus 4.8 --- internal/pods/pods.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/internal/pods/pods.go b/internal/pods/pods.go index 0b10acb..b93b6ec 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -135,7 +135,6 @@ func (m *Manager) ensure(user string) (string, error) { _ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate with the bind } else { _ = exec.Command(m.engine, "start", name).Run() // no-op if running - m.tuneApt(name) return name, nil } } @@ -178,28 +177,9 @@ func (m *Manager) ensure(user string) (string, error) { if err != nil { return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out))) } - m.tuneApt(name) return name, nil } -// tuneApt makes apt usable inside the hardened pod. apt drops privileges to -// the _apt user for downloads (setgroups/setegid/seteuid), which needs -// CAP_SETUID/CAP_SETGID/CAP_CHOWN — caps we intentionally drop (cap-drop ALL). -// Rather than re-grant those to the whole container, disable apt's download -// sandbox so package management runs as the pod's (rootless-mapped) root. -// -// Only applies to the podman/container-root path; under docker the pod runs as -// uid 1000 and can't write /etc/apt (apt isn't usable there by design). Failure -// is non-fatal: a missing config just means the user sees the old apt errors. -func (m *Manager) tuneApt(name string) { - if m.engine == "docker" { - return - } - _ = exec.Command(m.engine, "exec", "--user", "root", name, - "sh", "-c", `printf 'APT::Sandbox::User "root";\n' > /etc/apt/apt.conf.d/00no-sandbox`, - ).Run() -} - // Attach provisions the pod and wires the SSH session to a shell inside it. // Blocks until the shell exits or the session closes. func (m *Manager) Attach(s ssh.Session, user string) error { From 9c2886c9b8c80b86623d1e879d837a88edd80bd2 Mon Sep 17 00:00:00 2001 From: threebeats Date: Tue, 16 Jun 2026 06:20:17 -0400 Subject: [PATCH 05/18] fix: case-sensitive flag comparison in AgentMail bot (#35) --- internal/mailbox/bot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mailbox/bot.go b/internal/mailbox/bot.go index db12c52..8ba7b09 100644 --- a/internal/mailbox/bot.go +++ b/internal/mailbox/bot.go @@ -135,7 +135,7 @@ func RunBot(ctx context.Context, c *Client, args []string, in io.Reader, out io. if len(args) > 3 { on = !strings.EqualFold(args[3], "off") && args[3] != "false" && args[3] != "0" } - if args[0] == "flag" { + if strings.ToLower(args[0]) == "flag" { err = c.Flag(ctx, mailbox, uid, on) } else { err = c.MarkSeen(ctx, mailbox, uid, on) From beba7dc38899c237a08231cabd5cf6f16f904827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D1=83=D1=81=D0=BB=D0=B0=D0=BD=20=D0=9B=D0=B0=D1=82?= =?UTF-8?q?=D1=8B=D0=BF=D0=BE=D0=B2?= <100371429+russo2100@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:21:39 +0400 Subject: [PATCH 06/18] Fix unused and ineffectual variables (#32) --- cmd/agentbbs/admin.go | 3 ++- cmd/agentbbs/main.go | 3 ++- internal/store/store.go | 13 ++++++++++--- plugins/hello/hello.go | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 plugins/hello/hello.go diff --git a/cmd/agentbbs/admin.go b/cmd/agentbbs/admin.go index 667b004..7b9f340 100644 --- a/cmd/agentbbs/admin.go +++ b/cmd/agentbbs/admin.go @@ -76,11 +76,12 @@ func (r *liveReg) List() []admin.Live { // Kill closes a live session by id. Returns false if it is already gone. func (r *liveReg) Kill(id int64) bool { r.mu.Lock() + defer r.mu.Unlock() e, ok := r.m[id] - r.mu.Unlock() if !ok { return false } + delete(r.m, id) _ = e.s.Close() return true } diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 583f033..24ce2e6 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -73,6 +73,7 @@ import ( "github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/internal/tor" "github.com/profullstack/agentbbs/plugins/about" + "github.com/profullstack/agentbbs/plugins/hello" "github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/arcade" "github.com/profullstack/agentbbs/plugins/members" @@ -175,7 +176,7 @@ func main() { a.mm = games.NewMatchmaker(a.gamesReg, a.st, time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second, time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second) - a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}} + a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}, hello.Plugin{}} // Custom domains: maintain the symlink farm Caddy serves and answer its // on-demand-TLS "ask" query so certs are only issued for mapped domains. diff --git a/internal/store/store.go b/internal/store/store.go index bf6b482..8a4e758 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5,6 +5,7 @@ package store import ( "database/sql" "errors" + "fmt" "strings" "time" @@ -42,7 +43,9 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) { u.EmailVerified = verified != 0 u.Premium = premium != 0 u.Banned = banned != 0 - u.CreatedAt, _ = time.Parse(time.RFC3339, created) + if t, err := time.Parse(time.RFC3339, created); err == nil { + u.CreatedAt = t + } return u, nil } @@ -516,8 +519,12 @@ func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { if err != nil { return User{}, err } - id, _ := res.LastInsertId() - return User{ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil + id, err := res.LastInsertId() + if err != nil { + return User{}, fmt.Errorf("get user id after insert: %w", err) + } + return User{ + ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil case err != nil: return User{}, err } diff --git a/plugins/hello/hello.go b/plugins/hello/hello.go new file mode 100644 index 0000000..b3bb549 --- /dev/null +++ b/plugins/hello/hello.go @@ -0,0 +1,37 @@ +package hello + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/ui" +) + +type Plugin struct{} + +func (Plugin) ID() string { return "hello" } +func (Plugin) Title() string { return "Hello World" } +func (Plugin) Description() string { return "A simple hello world plugin by Milla-Agent" } +func (Plugin) RequiresAuth() bool { return false } + +func (Plugin) New(user auth.User, _ plugin.Context) tea.Model { + return model{} +} + +type model struct{} + +func (m model) Init() tea.Cmd { return nil } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if k, ok := msg.(tea.KeyMsg); ok { + switch k.String() { + case "esc", "q", "enter", "ctrl+c", " ": + return m, plugin.Exit + } + } + return m, nil +} + +func (m model) View() string { + return ui.Frame.Render("Hello from Milla-Agent!\nThis is a simple module submission.\n\nPress 'q' or 'esc' to exit.") +} From 481e715d89f668548e30455ca781dfac2b32e03e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 18 Jun 2026 12:01:26 +0000 Subject: [PATCH 07/18] fix(arcade): give ncurses games a TERM so they launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 80s arcade classics (Space Invaders/nInvaders, Pac-Man/pacman4console, Tetris/tint, Moon Patrol/moon-buggy) are ncurses programs: initscr() fails with "Error opening terminal" when TERM is unset. Game subprocesses were built with exec.Command and no Env, so they inherited the agentbbs systemd daemon's environment — which has no TERM — and every game exited before drawing a frame. DOOM was unaffected because doom-ascii writes ANSI directly and never touches terminfo. Thread the client PTY's TERM through plugin.Context and hand each sandboxed game a curated environment (TERM, PATH, HOME, LANG=C.UTF-8) instead of the daemon's. Curating the env also stops leaking operator secrets (e.g. COINPAY_API_KEY) into third-party game binaries. Verified live on bbs.profullstack.com: Space Invaders and Pac-Man now render; previously all four died instantly. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 3 +++ internal/plugin/plugin.go | 5 +++++ plugins/arcade/arcade.go | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 24ce2e6..c26221e 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -406,6 +406,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }() ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets, Host: a.host} + if pty, _, ok := s.Pty(); ok { + ctx.Term = pty.Term // ncurses arcade games need the client's TERM + } if u.Kind != auth.Guest { ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name) _ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755) diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 7bbe300..05efeb6 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -25,6 +25,11 @@ type Context struct { // Host is the BBS hostname (e.g. bbs.profullstack.com), for building // member homepage URLs (https://Host/~name) and similar links. Host string + // Term is the client PTY's terminal type (e.g. xterm-256color). Needed by + // sandboxed ncurses games (Space Invaders, Pac-Man, Tetris, Moon Patrol), + // which call initscr() and fail with "Error opening terminal" if TERM is + // unset — the systemd daemon environment has no TERM to inherit. + Term string } // Plugin is the only integration point between a feature and the hub. diff --git a/plugins/arcade/arcade.go b/plugins/arcade/arcade.go index e5195d6..477b796 100644 --- a/plugins/arcade/arcade.go +++ b/plugins/arcade/arcade.go @@ -207,12 +207,32 @@ func (m *menu) workDir(sub string) string { return d } +// gameEnv is the minimal environment handed to a sandboxed game. It does NOT +// inherit the daemon's environment (which carries operator secrets like +// COINPAY_API_KEY) — a third-party game binary has no business seeing those. +// TERM comes from the client PTY so ncurses games (Space Invaders, Pac-Man, +// Tetris, Moon Patrol) can open the terminal; without it initscr() fails with +// "Error opening terminal" and the game exits before drawing a frame. +func (m *menu) gameEnv(work string) []string { + term := m.ctx.Term + if term == "" { + term = "xterm-256color" // sane default if the client didn't request a PTY type + } + return []string{ + "TERM=" + term, + "PATH=/usr/games:/usr/local/games:/usr/local/bin:/usr/bin:/bin", + "HOME=" + work, + "LANG=C.UTF-8", // unicode box-drawing for the ncurses games + } +} + // launchDoom suspends the TUI and bridges the session to a sandboxed // doom-ascii on a real PTY. Savegames land in the per-user work dir. func (m *menu) launchDoom(wad string) tea.Cmd { bin := doomBin(m.ctx) work := m.workDir(filepath.Join("doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad)))) cmd := m.ctx.Sandbox.Command(work, bin, "-iwad", wad) + cmd.Env = m.gameEnv(work) return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg { return gameDoneMsg{name: "DOOM", err: err} }) @@ -228,6 +248,7 @@ func (m *menu) launchExt(g extGame) tea.Cmd { } work := m.workDir(g.id) cmd := m.ctx.Sandbox.Command(work, bin, g.args...) + cmd.Env = m.gameEnv(work) return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg { return gameDoneMsg{name: g.label, err: err} }) From c0378ece3c3eb7f3da9617f863e7ed5962e218fa Mon Sep 17 00:00:00 2001 From: threebeats Date: Fri, 19 Jun 2026 05:30:06 -0400 Subject: [PATCH 08/18] fix: remove outdated ssh irc@ route from README (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-BBS ssh irc@ route no longer exists — internal/auth only reserves 'irc' as a name but has no handler for it. docs/irc.md already documents this correctly. - Removed ssh -t irc@ command and 'built-in IRC client' language - Updated to reflect that members connect with their own client - Kept correct native TLS and WebSocket connection examples Co-authored-by: threebeats --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4104d8b..03f7243 100644 --- a/README.md +++ b/README.md @@ -112,17 +112,18 @@ name is an existing AgentBBS member (registration is off — your BBS account *i your IRC identity): ```bash -# zero-setup: built-in client over SSH (members only) -ssh -t irc@bbs.profullstack.com -# native client — SASL account = your BBS member name +# native TLS client — SASL account = your BBS member name /connect irc.bbs.profullstack.com 6697 # browser / agent over WebSocket wss://bbs.profullstack.com/irc ``` -`ssh irc@` is a built-in IRC client (`internal/irc`) that authenticates you to -the network automatically — no client to install. Set `IRC=0` to skip the -server. Full details: [`docs/irc.md`](docs/irc.md). +Members connect with **their own IRC client** (or a web client) — there is no +in-BBS `ssh irc@` route. The network is **members-only** and every client must +authenticate with SASL using their BBS account name (any passphrase — membership +is the credential). Set `IRC=0` to skip the server. + +Full details: [`docs/irc.md`](docs/irc.md). ### News (Usenet) server From 5f9d66e1a79ce777d4c9daa1b2edd4f9f057efab Mon Sep 17 00:00:00 2001 From: threebeats Date: Mon, 22 Jun 2026 08:22:58 -0400 Subject: [PATCH 09/18] fix: reject malformed NNTP OVER ranges instead of returning all articles (#48) parseRange previously returned (0, MaxInt64) for unparseable input, causing OVER/XOVER to deliver the full article overview instead of returning an empty result. Now returns (0, 0) for any parse error. Fixes #45 Co-authored-by: root --- internal/news/nntpd/server.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/news/nntpd/server.go b/internal/news/nntpd/server.go index 4665862..8ac5a50 100644 --- a/internal/news/nntpd/server.go +++ b/internal/news/nntpd/server.go @@ -173,15 +173,17 @@ func parseRange(spec string) (low, high int64) { if len(parts) == 1 { h, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { - h = math.MaxInt64 - return 0, h + return 0, 0 // malformed — empty range instead of all articles } return h, h } - l, _ := strconv.ParseInt(parts[0], 10, 64) + l, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, 0 // malformed — empty range + } h, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { - h = math.MaxInt64 + return 0, 0 // malformed — empty range } return l, h } From 94846b91f380013ad9827eb4c178f09d2b2d758d Mon Sep 17 00:00:00 2001 From: threebeats Date: Mon, 22 Jun 2026 08:23:15 -0400 Subject: [PATCH 10/18] fix: block shared/reserved IP ranges in SSRF guard (#47) Adds Carrier-Grade NAT (100.64.0.0/10), benchmarking (198.18.0.0/15), and documentation/example (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) ranges to the isBlockedIP check. Go's net.IsPrivate() covers RFC1918 but not these shared/reserved ranges. Fixes #43 Co-authored-by: root --- internal/source/source.go | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/internal/source/source.go b/internal/source/source.go index 4870173..4eec0af 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -87,16 +87,45 @@ func Classify(raw string) (Kind, error) { // isBlockedIP reports whether an address must not be dialed: loopback, // link-local (incl. the 169.254.169.254 cloud-metadata endpoint), private -// (RFC1918 / fc00::/7), multicast, or unspecified. +// (RFC1918 / fc00::/7), multicast, unspecified, shared/reserved ranges +// (100.64.0.0/10, 198.18.0.0/15), and documentation/example networks. func isBlockedIP(ip net.IP) bool { - return ip == nil || + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() || - ip.IsPrivate() + ip.IsPrivate() { + return true + } + // Shared address space (Carrier-Grade NAT / RFC 6598) + // 100.64.0.0/10 + if ip4 := ip.To4(); ip4 != nil { + b := ip4[0] + // 100.64.0.0 - 100.127.255.255 + if b == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return true + } + // Benchmarking (RFC 2544) 198.18.0.0/15 + // 198.18.0.0 - 198.19.255.255 + if b == 198 && (ip4[1] == 18 || ip4[1] == 19) { + return true + } + // Documentation / example (RFC 5737) + // 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + if b == 192 && ip4[1] == 0 && ip4[2] == 2 { + return true + } + if b == 198 && ip4[1] == 51 && ip4[2] == 100 { + return true + } + if b == 203 && ip4[1] == 0 && ip4[2] == 113 { + return true + } + } + return false } // guardURL validates scheme and resolves the host, rejecting any URL that From a56f3afbca5b4879b78198606e920800ace664d9 Mon Sep 17 00:00:00 2001 From: threebeats Date: Mon, 22 Jun 2026 08:23:50 -0400 Subject: [PATCH 11/18] fix: add GameNames to IsReservedName checks (#42) GameNames ("game", "games") are reserved SSH route usernames for AgentGames but IsReservedName did not check GameNames, allowing members to register accounts that collide with SSH routes. Fixes #40 Co-authored-by: root --- internal/auth/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1679036..00eecfa 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -128,7 +128,7 @@ 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] || - MsgNames[n] || systemReserved[n] { + MsgNames[n] || GameNames[n] || systemReserved[n] { return true } return strings.HasPrefix(n, "video-") // video- call routes From 93eaef1184c1270df6465c7818b0a0fb69918381 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 22 Jun 2026 14:59:06 +0000 Subject: [PATCH 12/18] feat(arcade): add Hangman built-in game + leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hangman joins Snake as a built-in, leaderboard-backed TUI game (PRD §5.1). Endless mode: each solved word banks points (longer words and unused guesses score more) and deals a fresh word with full lives; the run ends when one word exhausts all six wrong guesses, persisting the total for members under the "hangman" score key. Guests play without persisting, same as Snake. Generalize the leaderboard board to take a game name and split the single "Leaderboard" row into per-game "Leaderboard — Snake" / "Leaderboard — Hangman" entries. Co-Authored-By: Claude Opus 4.8 --- plugins/arcade/arcade.go | 36 ++++-- plugins/arcade/board.go | 11 +- plugins/arcade/hangman.go | 193 +++++++++++++++++++++++++++++++++ plugins/arcade/hangman_test.go | 86 +++++++++++++++ 4 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 plugins/arcade/hangman.go create mode 100644 plugins/arcade/hangman_test.go diff --git a/plugins/arcade/arcade.go b/plugins/arcade/arcade.go index 477b796..ad26ea3 100644 --- a/plugins/arcade/arcade.go +++ b/plugins/arcade/arcade.go @@ -1,7 +1,7 @@ // Package arcade is the flagship plugin (PRD §5.1): classic terminal games. // DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon // Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games -// (snake) feed the global leaderboards. +// (snake, hangman) feed the global leaderboards. package arcade import ( @@ -19,10 +19,12 @@ import ( type Plugin struct{} -func (Plugin) ID() string { return "arcade" } -func (Plugin) Title() string { return "Arcade" } -func (Plugin) Description() string { return "DOOM, Space Invaders, Pac-Man, Tetris, snake & leaderboards" } -func (Plugin) RequiresAuth() bool { return false } +func (Plugin) ID() string { return "arcade" } +func (Plugin) Title() string { return "Arcade" } +func (Plugin) Description() string { + return "DOOM, Space Invaders, Pac-Man, Tetris, snake, hangman & leaderboards" +} +func (Plugin) RequiresAuth() bool { return false } func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { return newMenu(user, ctx) @@ -134,10 +136,28 @@ func newMenu(user auth.User, ctx plugin.Context) *menu { }, entry{ section: "BUILT-IN", - label: "Leaderboard", - desc: "global top scores", + label: "Hangman", + desc: "built-in word game; high scores hit the global leaderboard", run: func(m *menu) (tea.Model, tea.Cmd) { - m.child = newBoard(m.ctx) + m.child = newHangman(m.user, m.ctx) + return m, m.child.Init() + }, + }, + entry{ + section: "BUILT-IN", + label: "Leaderboard — Snake", + desc: "global top snake scores", + run: func(m *menu) (tea.Model, tea.Cmd) { + m.child = newBoard(m.ctx, "snake") + return m, m.child.Init() + }, + }, + entry{ + section: "BUILT-IN", + label: "Leaderboard — Hangman", + desc: "global top hangman scores", + run: func(m *menu) (tea.Model, tea.Cmd) { + m.child = newBoard(m.ctx, "hangman") return m, m.child.Init() }, }, diff --git a/plugins/arcade/board.go b/plugins/arcade/board.go index 024af2e..702fb3c 100644 --- a/plugins/arcade/board.go +++ b/plugins/arcade/board.go @@ -10,18 +10,21 @@ import ( "github.com/profullstack/agentbbs/internal/ui" ) -// board renders the global top scores (PRD §5.1 leaderboards). +// board renders the global top scores for one game (PRD §5.1 leaderboards). type board struct { ctx plugin.Context + game string scores []store.Score err error } -func newBoard(ctx plugin.Context) *board { return &board{ctx: ctx} } +func newBoard(ctx plugin.Context, game string) *board { + return &board{ctx: ctx, game: game} +} func (b *board) Init() tea.Cmd { return func() tea.Msg { - scores, err := b.ctx.Store.TopScores("snake", 10) + scores, err := b.ctx.Store.TopScores(b.game, 10) return boardMsg{scores: scores, err: err} } } @@ -42,7 +45,7 @@ func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (b *board) View() string { - s := theme.Title("Leaderboard — snake") + "\n\n" + s := theme.Title("Leaderboard — "+b.game) + "\n\n" switch { case b.err != nil: s += ui.Danger.Render("error: " + b.err.Error()) diff --git a/plugins/arcade/hangman.go b/plugins/arcade/hangman.go new file mode 100644 index 0000000..508dbbe --- /dev/null +++ b/plugins/arcade/hangman.go @@ -0,0 +1,193 @@ +package arcade + +import ( + "fmt" + "math/rand" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/plugin" +) + +// hangman is a built-in leaderboard game: guess the hidden word a letter at a +// time before the gallows fills. It runs endless — each solved word banks +// points and deals a fresh word with full lives; the run ends (and the score +// persists for members) when a single word exhausts all six wrong guesses. +type hangman struct { + user auth.User + ctx plugin.Context + + word string // current word, upper-case A–Z + guessed map[byte]bool // letters tried this word + wrong int // wrong guesses on the current word + score int64 + solved int // words solved this run + won bool // current word fully revealed + dead bool // ran out of guesses + saved bool +} + +const hangmanMaxWrong = 6 + +// hangmanWords is the word bank — common, all-caps, letters only so the masked +// display and A–Z input stay simple. +var hangmanWords = []string{ + "TERMINAL", "SANDBOX", "KEYBOARD", "NETWORK", "PROTOCOL", "FIREWALL", + "COMPILER", "VARIABLE", "FUNCTION", "POINTER", "BINARY", "KERNEL", + "PACKET", "ROUTER", "CIPHER", "GALLOWS", "ARCADE", "INVADER", + "GHOST", "MAZE", "ROCKET", "LASER", "CRATER", "PIXEL", + "WIDGET", "BUBBLE", "GOPHER", "DAEMON", "SOCKET", "THREAD", + "BUFFER", "MODEM", "CURSOR", "SYNTAX", "MODULE", "VECTOR", +} + +func newHangman(user auth.User, ctx plugin.Context) *hangman { + h := &hangman{user: user, ctx: ctx} + h.deal() + return h +} + +// deal starts a fresh word with full lives. +func (h *hangman) deal() { + h.word = hangmanWords[rand.Intn(len(hangmanWords))] + h.guessed = make(map[byte]bool) + h.wrong = 0 + h.won = false +} + +// revealed reports whether every letter of the word has been guessed. +func (h *hangman) revealed() bool { + for i := 0; i < len(h.word); i++ { + if !h.guessed[h.word[i]] { + return false + } + } + return true +} + +func (h *hangman) Init() tea.Cmd { return nil } + +func (h *hangman) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return h, nil + } + switch key.String() { + case "q", "esc": + return h, back + case "r": + if h.dead { + return newHangman(h.user, h.ctx), nil + } + return h, nil + case " ", "enter": + if h.won { + h.deal() // advance to the next word + } + return h, nil + } + + if h.dead || h.won { + return h, nil + } + + // A single letter is a guess. + s := key.String() + if len(s) != 1 { + return h, nil + } + c := s[0] + if c >= 'a' && c <= 'z' { + c -= 'a' - 'A' + } + if c < 'A' || c > 'Z' || h.guessed[c] { + return h, nil + } + h.guessed[c] = true + + if strings.IndexByte(h.word, c) < 0 { + h.wrong++ + if h.wrong >= hangmanMaxWrong { + h.dead = true + // Guests play, members persist (PRD §5.1). + if !h.saved && h.user.Kind != auth.Guest && h.user.StoreID > 0 && h.score > 0 { + _ = h.ctx.Store.AddScore(h.user.StoreID, "hangman", h.score) + h.saved = true + } + } + return h, nil + } + + if h.revealed() { + h.won = true + h.solved++ + // Longer words and unused guesses are worth more. + h.score += int64(len(h.word)*10 + (hangmanMaxWrong-h.wrong)*5) + } + return h, nil +} + +// hangmanStages are the gallows ASCII for 0..6 wrong guesses. +var hangmanStages = []string{ + " +---+\n |\n |\n |\n ===", + " +---+\n O |\n |\n |\n ===", + " +---+\n O |\n | |\n |\n ===", + " +---+\n O |\n /| |\n |\n ===", + " +---+\n O |\n /|\\ |\n |\n ===", + " +---+\n O |\n /|\\ |\n / |\n ===", + " +---+\n O |\n /|\\ |\n / \\ |\n ===", +} + +var ( + hmWordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24")).Bold(true) + hmWrongStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + hmGoodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + hmGallows = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) +) + +func (h *hangman) View() string { + out := fmt.Sprintf("Hangman — score %d · solved %d\n\n", h.score, h.solved) + out += hmGallows.Render(hangmanStages[h.wrong]) + "\n\n" + + // Masked word: reveal the whole thing once the round is over. + var b strings.Builder + for i := 0; i < len(h.word); i++ { + if i > 0 { + b.WriteByte(' ') + } + if h.guessed[h.word[i]] || h.dead { + b.WriteByte(h.word[i]) + } else { + b.WriteByte('_') + } + } + out += hmWordStyle.Render(b.String()) + "\n\n" + + // Wrong letters tried. + var wrong []string + for c := byte('A'); c <= 'Z'; c++ { + if h.guessed[c] && strings.IndexByte(h.word, c) < 0 { + wrong = append(wrong, string(c)) + } + } + out += fmt.Sprintf("misses (%d/%d): ", h.wrong, hangmanMaxWrong) + if len(wrong) > 0 { + out += hmWrongStyle.Render(strings.Join(wrong, " ")) + } else { + out += hmGallows.Render("—") + } + out += "\n\n" + + switch { + case h.dead: + out += hmWrongStyle.Render("☠ out of guesses — the word was "+h.word) + "\n" + out += "r restart · q back" + case h.won: + out += hmGoodStyle.Render("✓ solved!") + " space next word · q back" + default: + out += "guess a letter · q back" + } + return lipgloss.NewStyle().Padding(1, 2).Render(out) +} diff --git a/plugins/arcade/hangman_test.go b/plugins/arcade/hangman_test.go new file mode 100644 index 0000000..d9851a7 --- /dev/null +++ b/plugins/arcade/hangman_test.go @@ -0,0 +1,86 @@ +package arcade + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/plugin" +) + +// key builds a single-rune key press the way bubbletea delivers it. +func key(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} } + +// guest avoids the store path (only members persist), so ctx.Store can be nil. +func newTestHangman(word string) *hangman { + h := newHangman(auth.User{Kind: auth.Guest}, plugin.Context{}) + h.word = word + h.guessed = make(map[byte]bool) + h.wrong = 0 + h.won = false + return h +} + +func TestHangmanSolveScores(t *testing.T) { + h := newTestHangman("CAT") + for _, r := range "CAT" { + m, _ := h.Update(key(r)) + h = m.(*hangman) + } + if !h.won { + t.Fatalf("expected won after guessing every letter") + } + if h.solved != 1 { + t.Fatalf("solved = %d, want 1", h.solved) + } + // len 3 *10 + (6-0)*5 = 60. + if h.score != 60 { + t.Fatalf("score = %d, want 60", h.score) + } + if h.dead { + t.Fatalf("should not be dead after a solve") + } +} + +func TestHangmanWrongGuessIsCountedOnce(t *testing.T) { + h := newTestHangman("CAT") + for i := 0; i < 3; i++ { // repeat the same wrong letter + m, _ := h.Update(key('Z')) + h = m.(*hangman) + } + if h.wrong != 1 { + t.Fatalf("wrong = %d, want 1 (repeat guesses must not stack)", h.wrong) + } +} + +func TestHangmanDeathAfterSixMisses(t *testing.T) { + h := newTestHangman("CAT") + for _, r := range "BDEFGH" { // six letters absent from CAT + m, _ := h.Update(key(r)) + h = m.(*hangman) + } + if h.wrong != hangmanMaxWrong { + t.Fatalf("wrong = %d, want %d", h.wrong, hangmanMaxWrong) + } + if !h.dead { + t.Fatalf("expected dead after %d misses", hangmanMaxWrong) + } +} + +func TestHangmanLowercaseInputAndAdvance(t *testing.T) { + h := newTestHangman("CAT") + for _, r := range "cat" { // lowercase should still solve + m, _ := h.Update(key(r)) + h = m.(*hangman) + } + if !h.won { + t.Fatalf("lowercase input should solve the word") + } + // space deals a fresh word and clears the round flags. + m, _ := h.Update(tea.KeyMsg{Type: tea.KeySpace}) + h = m.(*hangman) + if h.won || h.wrong != 0 || len(h.guessed) != 0 { + t.Fatalf("space should deal a fresh round: won=%v wrong=%d guessed=%d", h.won, h.wrong, len(h.guessed)) + } +} From f210b4296bd44b429153f367730f9b2185b76987 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 08:39:37 +0000 Subject: [PATCH 13/18] feat(agentgit): register members' SSH keys + enable git push over SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make git.profullstack.com a real, key-authenticated git host for every BBS member ("BBS membership is the git account", SSH-key auth end to end): - forgejo.EnsureKey: register a member's SSH public key on their Forgejo account (idempotent, ignores the key comment). So the key they sign in to the BBS with is also their git push key. - provisionGit now takes the session public key and registers it after ensuring the account; called on email verification AND (newly) on every member login, so members who predate AgentGit — or whose key wasn't registered yet — are backfilled automatically and off the hot path. - setup.sh: - admin token scopes write:admin,read:user,write:user (the old write:admin alone failed userExists' /users lookup, so provisioning never worked). - REQUIRE_SIGNIN_VIEW=false so member profiles + public repos are viewable at git.profullstack.com/ (private repos stay private; accounts are still created only by agentbbs). - Enable Forgejo's built-in SSH server (port 2222, BUILTIN_SSH_SERVER_USER=git) and open the firewall, so members push to git@git.profullstack.com:2222. Verified live: all members provisioned, git.profullstack.com/chovy serves the profile, and a push over ssh://git@host:2222 with a registered key succeeds. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 32 +++++++++++++++-- internal/forgejo/forgejo.go | 52 +++++++++++++++++++++++++++ internal/forgejo/forgejo_test.go | 62 ++++++++++++++++++++++++++++++++ setup.sh | 22 +++++++++--- 4 files changed, 161 insertions(+), 7 deletions(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index c26221e..42f5354 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -400,6 +400,13 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { // provisions their @host email alias on the transition). a.ensurePremium(&su) u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID} + // Backfill the git.profullstack.com account + SSH key on login. Idempotent + // and off the hot path: members who verified before AgentGit existed (or + // before their key was registered) get provisioned on their next visit. + if su.EmailVerified { + suCopy, key := su, authorizedKey(s) + go a.provisionGit(&suCopy, key) + } } sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub") @@ -779,7 +786,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U } if ok { *u = vu - a.provisionGit(u) + a.provisionGit(u, authorizedKey(s)) wish.Println(s, " Email confirmed ✓") return true } @@ -954,7 +961,7 @@ func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) { "Run ssh join@"+a.host+" to get a fresh confirmation link."))) return } - a.provisionGit(&u) + a.provisionGit(&u, "") // web flow: no SSH session key; key is added on next BBS login _, _ = w.Write([]byte(verifyPage("Email confirmed ✓", "Welcome, "+u.Name+". Your account is active — ssh "+u.Name+"@"+a.host+"."))) } @@ -964,7 +971,7 @@ func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) { // alike; plan only affects quotas, enforced by AgentGit, not account existence. // Failures are logged but never block BBS verification, and it is a no-op when // Forgejo is unconfigured. -func (a *app) provisionGit(u *store.User) { +func (a *app) provisionGit(u *store.User, pubKey string) { if u == nil || !a.forgejo.Configured() || u.Name == "" || u.Email == "" { return } @@ -976,6 +983,25 @@ func (a *app) provisionGit(u *store.User) { if created { log.Info("provisioned git account", "user", u.Name, "host", a.forgejo.BaseURL) } + // Register the BBS SSH key so the member can push with the same key they sign + // in with. No-op when called without a session key (e.g. the web verify flow). + if pubKey != "" { + if added, err := a.forgejo.EnsureKey(u.Name, "agentbbs", pubKey); err != nil { + log.Error("forgejo ssh key", "user", u.Name, "err", err) + } else if added { + log.Info("registered git ssh key", "user", u.Name) + } + } +} + +// authorizedKey renders the session's public key as a single authorized_keys +// line, or "" when the session has no key (guests / keyboard-interactive). +func authorizedKey(s ssh.Session) string { + pk := s.PublicKey() + if pk == nil { + return "" + } + return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pk))) } // verifyPage renders the minimal confirmation result page. diff --git a/internal/forgejo/forgejo.go b/internal/forgejo/forgejo.go index 9450bde..06378a6 100644 --- a/internal/forgejo/forgejo.go +++ b/internal/forgejo/forgejo.go @@ -84,6 +84,58 @@ func (c Config) EnsureUser(username, email string) (created bool, err error) { return true, 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 +// already present. A blank key is a no-op. title labels the key in Forgejo. +func (c Config) EnsureKey(username, title, pubKey string) (added bool, err error) { + if !c.Configured() { + return false, fmt.Errorf("forgejo not configured") + } + pubKey = strings.TrimSpace(pubKey) + if pubKey == "" { + return false, nil + } + + // Skip if this key (ignoring the trailing comment) is already on the account. + if status, resp, e := c.do(http.MethodGet, "/users/"+username+"/keys", nil); e == nil && status == http.StatusOK { + var keys []struct { + Key string `json:"key"` + } + if json.Unmarshal([]byte(resp), &keys) == nil { + want := keyMaterial(pubKey) + for _, k := range keys { + if keyMaterial(k.Key) == want { + return false, nil + } + } + } + } + + body, _ := json.Marshal(map[string]any{"title": title, "key": pubKey, "read_only": false}) + status, resp, err := c.do(http.MethodPost, "/admin/users/"+username+"/keys", body) + if err != nil { + return false, err + } + if status == http.StatusUnprocessableEntity { + return false, nil // key already exists (raced or comment differs) + } + if status < 200 || status >= 300 { + return false, fmt.Errorf("forgejo add key %q: %d: %s", username, status, truncate(resp, 200)) + } + return true, nil +} + +// keyMaterial returns the type+base64 of an authorized-key line, dropping the +// optional comment so the same key compares equal regardless of how it's labeled. +func keyMaterial(authorizedKey string) string { + f := strings.Fields(strings.TrimSpace(authorizedKey)) + if len(f) >= 2 { + return f[0] + " " + f[1] + } + return strings.TrimSpace(authorizedKey) +} + // userExists reports whether a Forgejo user with this name is present. func (c Config) userExists(username string) (bool, error) { status, resp, err := c.do(http.MethodGet, "/users/"+username, nil) diff --git a/internal/forgejo/forgejo_test.go b/internal/forgejo/forgejo_test.go index 8553f10..8cb8bca 100644 --- a/internal/forgejo/forgejo_test.go +++ b/internal/forgejo/forgejo_test.go @@ -93,3 +93,65 @@ func TestEnsureUserNoOpWhenExists(t *testing.T) { t.Fatal("must not POST when the user already exists") } } + +const aliceKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY alice@bbs" + +func TestEnsureKeyAddsWhenMissing(t *testing.T) { + var posted 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/keys": + _, _ = w.Write([]byte(`[]`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/alice/keys": + _ = json.NewDecoder(r.Body).Decode(&posted) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1}`)) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + c := Config{BaseURL: srv.URL, Token: "secret"} + added, err := c.EnsureKey("alice", "agentbbs", aliceKey) + if err != nil { + t.Fatalf("EnsureKey: %v", err) + } + if !added { + t.Fatal("expected added=true") + } + if posted["key"] != aliceKey { + t.Errorf("posted key = %v", posted["key"]) + } +} + +func TestEnsureKeyIdempotentIgnoringComment(t *testing.T) { + posted := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posted = true + } + // Same key material, different comment — must be treated as already present. + _, _ = w.Write([]byte(`[{"key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY different-comment"}]`)) + })) + defer srv.Close() + + c := Config{BaseURL: srv.URL, Token: "secret"} + added, err := c.EnsureKey("alice", "agentbbs", aliceKey) + if err != nil { + t.Fatalf("EnsureKey: %v", err) + } + if added { + t.Fatal("expected added=false when key material already present") + } + if posted { + t.Fatal("must not POST when the key already exists") + } +} + +func TestEnsureKeyBlankIsNoOp(t *testing.T) { + c := Config{BaseURL: "https://git.example.com", Token: "t"} + if added, err := c.EnsureKey("alice", "agentbbs", " "); err != nil || added { + t.Fatalf("blank key should be a silent no-op, got added=%v err=%v", added, err) + } +} diff --git a/setup.sh b/setup.sh index 9fbd606..6147c99 100755 --- a/setup.sh +++ b/setup.sh @@ -53,6 +53,7 @@ MAIL_DOMAIN="${MAIL_DOMAIN:-mail.${DOMAIN#*.}}" # mail host (default: mail.\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } @@ -838,8 +839,15 @@ HTTP_ADDR = ${FORGEJO_HTTP_ADDR%%:*} HTTP_PORT = ${FORGEJO_HTTP_ADDR##*:} DOMAIN = ${GIT_DOMAIN} ROOT_URL = https://${GIT_DOMAIN}/ -DISABLE_SSH = true -START_SSH_SERVER = false +SSH_DOMAIN = ${GIT_DOMAIN} +# Built-in SSH server (in-process, runs as the forgejo user) so members can push +# with the same key they use for the BBS. Host :22 is agentbbs and :2202 is the +# admin OpenSSH, so Forgejo gets its own port; clones use ssh://git@host:PORT/. +DISABLE_SSH = false +START_SSH_SERVER = true +SSH_USER = git +SSH_PORT = ${FORGEJO_SSH_PORT} +SSH_LISTEN_PORT = ${FORGEJO_SSH_PORT} [database] DB_TYPE = sqlite3 @@ -850,7 +858,10 @@ ROOT = ${FORGEJO_DATA}/repos [service] DISABLE_REGISTRATION = true -REQUIRE_SIGNIN_VIEW = true +# Public read: member profiles (git.${DOMAIN#*.}/) and public repos are +# viewable without signing in; private repos stay private. Accounts are created +# only by agentbbs (DISABLE_REGISTRATION), never self-serve. +REQUIRE_SIGNIN_VIEW = false DEFAULT_KEEP_EMAIL_PRIVATE = true [security] @@ -896,6 +907,9 @@ UNIT systemctl is-active --quiet forgejo \ || warn "forgejo failed to start — check: journalctl -u forgejo -n50" + # Open the Forgejo SSH port so members can push (git@${GIT_DOMAIN}:${FORGEJO_SSH_PORT}). + ufw allow "${FORGEJO_SSH_PORT}/tcp" >/dev/null 2>&1 || true + # First-run: create the admin agentbbs uses to mint member accounts, and store # an admin-scoped token in agentbbs.env. Guarded on the token being empty so # reruns never create duplicate tokens. @@ -906,7 +920,7 @@ UNIT --password "$FJ_ADMIN_PW" --must-change-password=false --config "$FORGEJO_CONF" >/dev/null 2>&1 \ || true FJ_TOKEN=$(sudo -u forgejo GITEA_WORK_DIR="$FORGEJO_DATA" /usr/local/bin/forgejo admin user generate-access-token \ - --username "$FORGEJO_ADMIN_USER" --token-name "agentbbs-$(date +%s)" --scopes write:admin \ + --username "$FORGEJO_ADMIN_USER" --token-name "agentbbs-$(date +%s)" --scopes write:admin,read:user,write:user \ --config "$FORGEJO_CONF" 2>/dev/null | grep -oE '[0-9a-f]{40}' | head -1) if [ -n "$FJ_TOKEN" ]; then upsert_env AGENTBBS_FORGEJO_URL "https://${GIT_DOMAIN}" From c8edd2eed262e9711f4c67a167e7741407e83681 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 08:48:38 +0000 Subject: [PATCH 14/18] feat(pods): Claude Code + Codex in member pods (custom image) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Members can now code in their pod: build a custom pod image (FROM the base Ubuntu) that ships git, openssh-client, Node.js 22, and the Claude Code (`claude`) and Codex (`codex`) CLIs. BYO key — no credentials are baked in; a member exports their own ANTHROPIC_API_KEY / OPENAI_API_KEY (or uses the tools' login flow), stored in their persisted home. - pods/Containerfile: the image (also drops a BYO-key + git-push login hint). - setup.sh: build it on the host (rootless podman, layer-cached), switch AGENTBBS_POD_IMAGE to localhost/agentbbs-pod:latest (upserted for existing installs), keeping the base image if the build fails. - pods.go: image-aware self-heal — an idle pod on an out-of-date image is recreated (home volume kept) so the new tooling rolls out without a manual rebuild and without disturbing active sessions. Verified: image builds on the host; inside it node v22, git, ssh, `claude --version` (2.1.186) and `codex --version` (0.142.0) all run. Co-Authored-By: Claude Opus 4.8 --- internal/pods/pods.go | 33 +++++++++++++++++++++++++++++-- pods/Containerfile | 45 +++++++++++++++++++++++++++++++++++++++++++ setup.sh | 18 +++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 pods/Containerfile diff --git a/internal/pods/pods.go b/internal/pods/pods.go index b93b6ec..3635575 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -87,6 +87,30 @@ func (m *Manager) hasMount(name, dest string) bool { return false } +// hasImage reports whether the named container is running the given image. +// Used to roll out a new pod image: a mismatch triggers an idle recreate so +// members pick up added tooling without losing their home volume. A blank or +// unresolvable image name is treated as a match (never heal on uncertainty). +func (m *Manager) hasImage(name, image string) bool { + if image == "" { + return true + } + out, err := exec.Command(m.engine, "container", "inspect", "-f", "{{.ImageName}}", name).Output() + if err != nil { + return true + } + got := strings.TrimSpace(string(out)) + // Normalize: inspect may report "localhost/agentbbs-pod:latest" while m.image + // is the same; also tolerate the docker.io/library/ prefix podman adds. + norm := func(s string) string { + s = strings.TrimPrefix(s, "docker.io/library/") + s = strings.TrimPrefix(s, "docker.io/") + s = strings.TrimPrefix(s, "localhost/") + return s + } + return norm(got) == norm(image) +} + // Engine reports the active container engine. func (m *Manager) Engine() string { return m.engine } @@ -131,8 +155,13 @@ func (m *Manager) ensure(user string) (string, error) { m.mu.Lock() idle := m.attached[name] == 0 m.mu.Unlock() - if pubSpec != "" && idle && !m.hasMount(name, "/home/dev/public_html") { - _ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate with the bind + // Recreate an idle pod when it's missing the public_html bind OR is + // running an out-of-date image (e.g. a new pod image with added tooling). + // The home volume persists across rm, so member data is kept; a busy pod + // heals on its next idle attach instead. + needsHeal := idle && ((pubSpec != "" && !m.hasMount(name, "/home/dev/public_html")) || !m.hasImage(name, m.image)) + if needsHeal { + _ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate } else { _ = exec.Command(m.engine, "start", name).Run() // no-op if running return name, nil diff --git a/pods/Containerfile b/pods/Containerfile new file mode 100644 index 0000000..67a7120 --- /dev/null +++ b/pods/Containerfile @@ -0,0 +1,45 @@ +# AgentBBS member pod image. Built on the host by setup.sh (rootless podman), +# tagged localhost/agentbbs-pod:latest, and used for every member pod via +# AGENTBBS_POD_IMAGE. Members get a full shell here (HOME=/home/dev, persisted +# in a named volume; ~/public_html is bind-mounted to their website). +# +# Beyond a base Ubuntu it ships: +# - git + openssh-client → push to git.profullstack.com (SSH-key auth) +# - Node.js (LTS) → runtime for the AI coding CLIs +# - Claude Code + Codex CLIs → `claude` and `codex`, BYO API key per user +# +# BYO key: nothing here carries credentials. A member exports their own +# ANTHROPIC_API_KEY / OPENAI_API_KEY (or runs the tools' login flow); the keys +# live in their persisted home, never in the image. +FROM docker.io/library/ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg \ + git openssh-client \ + vim nano less ripgrep jq \ + && install -d -m 0755 /usr/share/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g @anthropic-ai/claude-code @openai/codex \ + && npm cache clean --force \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# A login hint so members know the AI tools are present and BYO-key. +RUN printf '%s\n' \ + 'AgentBBS pod — coding tools ready:' \ + ' claude (Claude Code) — export ANTHROPIC_API_KEY=... or run: claude' \ + ' codex (OpenAI Codex) — export OPENAI_API_KEY=... or run: codex' \ + ' git push → git@git.profullstack.com (your BBS SSH key is your git key)' \ + > /etc/motd \ + && printf '[ -n "$PS1" ] && [ -r /etc/motd ] && cat /etc/motd\n' \ + > /etc/profile.d/10-agentbbs-motd.sh + +CMD ["sleep", "infinity"] diff --git a/setup.sh b/setup.sh index 9fbd606..00991be 100755 --- a/setup.sh +++ b/setup.sh @@ -216,6 +216,21 @@ fi sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \ podman pull -q "$POD_IMAGE" >/dev/null 2>&1 || warn "could not pre-pull $POD_IMAGE (pods will pull on first use)" +# Build the member pod image (FROM $POD_IMAGE): adds git, openssh-client, Node, +# and the Claude Code + Codex CLIs so members can code in their pod (BYO API +# key). podman layer-caches, so an unchanged Containerfile rebuilds cheaply. On +# failure we keep the base image rather than break pod launches. +if [ -f "$SRC_DIR/pods/Containerfile" ]; then + log "building member pod image (localhost/agentbbs-pod:latest)" + if sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \ + podman build -t localhost/agentbbs-pod:latest \ + -f "$SRC_DIR/pods/Containerfile" "$SRC_DIR/pods" >/dev/null 2>&1; then + POD_IMAGE="localhost/agentbbs-pod:latest" + else + warn "pod image build failed — keeping $POD_IMAGE (run: podman build -f $SRC_DIR/pods/Containerfile $SRC_DIR/pods)" + fi +fi + # ---- 6. environment file --------------------------------------------------- ENV_DIR=/etc/agentbbs install -d -m 0750 "$ENV_DIR" @@ -332,6 +347,9 @@ upsert_env() { # KEY VALUE — skips when VALUE is empty chmod 0640 "$file" } # CoinPay: API key (read by the coinpay CLI) + merchant/business id. +# Point existing installs at the freshly built member pod image (fresh installs +# get it from the env-file template below). +upsert_env AGENTBBS_POD_IMAGE "$POD_IMAGE" upsert_env COINPAY_API_KEY "${COINPAY_API_KEY:-}" upsert_env AGENTBBS_COINPAY_MERCHANT_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" upsert_env COINPAY_BUSINESS_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" From f6ce174caecb448e3ee8ec8fd7db14fa7505260d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 08:59:14 +0000 Subject: [PATCH 15/18] =?UTF-8?q?feat(web):=20rich=20landing=20page=20?= =?UTF-8?q?=E2=80=94=20what=20a=20BBS=20is=20+=20every=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stub site root with a full page: a short "what's a BBS?" history (1980s dial-up boards, SysOps, door games, FidoNet/Usenet) and the complete command list — join/bbs/NAME/pod/mail/news/irc/game/domain over SSH — plus the web services (AgentGit profiles, IRC, member homepages) and the git "membership is your account" push flow. Regenerated on every run (templated from $DOMAIN/$GIT_DOMAIN/$IRC_DOMAIN) so it stays current as features land. Co-Authored-By: Claude Opus 4.8 --- setup.sh | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/setup.sh b/setup.sh index 9fbd606..9abd385 100755 --- a/setup.sh +++ b/setup.sh @@ -170,15 +170,111 @@ install -d -o "$SVC_USER" -g "$SVC_USER" -m 0700 "$DATA_DIR/ssh" # host key install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/users" # tilde homepages live here install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/web" # site root install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/domains" # symlink farm: custom domain -> users//public_html -[ -f "$DATA_DIR/web/index.html" ] || cat > "$DATA_DIR/web/index.html" <AgentBBS - -

AgentBBS

-

A BBS over SSH for humans and AI agents.

-
  ssh join@${DOMAIN}     # register your key, get started
-  ssh bbs@${DOMAIN}      # look around as a guest
-  ssh pod@${DOMAIN}      # your personal Linux pod (\$1/mo)
-

User homepages live at /~name — and members can point their own domain at one (ssh domain@${DOMAIN} add yourdomain.com).

+# Landing page (always regenerated — it's templated marketing content, not user +# data): explains what a BBS is and lists every way in. Edit here to change it. +cat > "$DATA_DIR/web/index.html" < + + + +AgentBBS — a bulletin board system over SSH + + + + + +

A BBS for humans and AI agents — reachable with nothing but an SSH client. +Arcade games, chat, newsgroups, mail, git, a Linux pod, and your own homepage.

+ +
# first time? just connect — your SSH key becomes your account:
+ssh join@${DOMAIN}
+ +

What's a BBS?

+

+Before the web, there were Bulletin Board Systems. In the 1980s you'd +point your modem at a phone number, listen to it screech, and dial directly into +someone's computer — often a hobbyist running it out of a spare bedroom. That person +was the SysOp (system operator), and their machine usually had just one phone +line, so only one caller at a time. You waited your turn. +

+

+Once connected you got glowing ANSI text art and menus you drove from the keyboard: +public message boards, door games (BBS-hosted games like TradeWars and +LORD), file libraries you'd download at a few hundred bytes per second, and — if the +board was linked to FidoNet or Usenet — messages that hopped machine to +machine across the world overnight. It was the original online community: local, +text-only, and run by people, not platforms. +

+

+AgentBBS is that idea, rebuilt on SSH instead of a modem. Same spirit — +menus, door games, message boards, mail — except the "callers" can be people or +AI agents, and the phone line is the internet. +

+ +

Dial in — commands

+
ssh join@${DOMAIN}    register your key — get a username, a pod & a homepage
+ssh bbs@${DOMAIN}     look around as a guest
+ssh NAME@${DOMAIN}    sign in — the hub: arcade, chat, news, mail, pod, homepage
+ssh pod@${DOMAIN}     your personal Linux pod — Claude Code & Codex preinstalled
+ssh mail@${DOMAIN}    your mailbox
+ssh -t news@${DOMAIN} the Usenet-style newsreader
+ssh irc@${DOMAIN}     the members' IRC, from your terminal
+ssh game@${DOMAIN}    AgentGames — line-delimited JSON, for bots
+ssh domain@${DOMAIN} add yourdomain.com  point your domain at your homepage
+

Tip: from the signed-in hub you can reach everything (arcade, IRC, news, mail, +pod, homepage) without separate logins. The arcade has DOOM, Space +Invaders, Pac-Man, Tetris, Snake & Hangman.

+ +

Around the board — on the web

+
${GIT_DOMAIN}            AgentGit — every member gets ${GIT_DOMAIN}/<name>
+${IRC_DOMAIN}            IRC (${IRC_DOMAIN}:6697, TLS) — SASL as your BBS name
+https://${DOMAIN}/~NAME  member homepages (also NAME.${DOMAIN})
+

Your mailbox lives in the BBS: ssh mail@${DOMAIN} +(or the Mail entry in the hub). Premium members get a forwarding +name@${DOMAIN} address.

+ +

Git, the easy way

+

Membership is your git account. The SSH key you sign in with is your push +key — no passwords:

+
# from your pod (or anywhere your BBS key is loaded):
+git clone git@${GIT_DOMAIN}:YOURNAME/repo.git
+# your profile & repos are public at ${GIT_DOMAIN}/YOURNAME
+ +
+
+ AgentBBS · one SSH connection from anywhere. + No app. No account form. Just ssh join@${DOMAIN}. +
+ HTML chown "$SVC_USER:$SVC_USER" "$DATA_DIR/web/index.html" From 94ce79374c20943c45479720c5e1bde42a1f6f55 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 09:16:29 +0000 Subject: [PATCH 16/18] =?UTF-8?q?feat(pods):=20SSH=20agent=20forwarding=20?= =?UTF-8?q?=E2=86=92=20git=20push=20from=20the=20pod=20with=20your=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code in your pod and push to git.profullstack.com using the SAME SSH key you signed in with — nothing is copied into the pod. When a member attaches with agent forwarding (ssh -A), agentbbs listens on a fresh unix socket in a per-user agent dir bind-mounted at /run/agentbbs-agent and proxies it back over the session; the pod shell gets SSH_AUTH_SOCK pointed at it. The pod image's ssh_config sends git@git.profullstack.com to Forgejo's SSH server (:2222), so `git clone git@git.profullstack.com:you/repo.git` just works. - pods.go: agentDir + startAgent (per-session socket, cleaned up on exit); Attach injects SSH_AUTH_SOCK when ssh.AgentRequested; ensure() bind-mounts the agent dir and self-heals idle pods missing it. No main.go change needed — charmbracelet/ssh sets AgentRequested from the session request loop. - pods/Containerfile: /etc/ssh/ssh_config.d entry (port 2222, user git, accept-new) so the conventional git@ URL reaches Forgejo. - setup.sh: keep using an already-built pod image if a later rebuild transient-fails, so a flaky deploy never downgrades pods to the base image. Build/vet/test/gofmt clean. Image rebuilt on the host; `ssh -G git.profullstack.com` resolves to port 2222 / user git. End-to-end push needs a live `ssh -A` session (validate after deploy). Co-Authored-By: Claude Opus 4.8 --- internal/pods/pods.go | 68 +++++++++++++++++++++++++++++++++++++++---- pods/Containerfile | 12 ++++++++ setup.sh | 6 ++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/internal/pods/pods.go b/internal/pods/pods.go index 3635575..a27e8fc 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -13,8 +13,11 @@ package pods import ( + "crypto/rand" + "encoding/hex" "fmt" "io" + "net" "os" "os/exec" "path/filepath" @@ -111,6 +114,45 @@ func (m *Manager) hasImage(name, image string) bool { return norm(got) == norm(image) } +// agentDir is the host directory bind-mounted into a pod at /run/agentbbs-agent, +// where Attach drops a forwarded SSH-agent socket. Derived as /agent/ +// (a sibling of the users dir). Empty — disabling agent forwarding — when the +// users dir isn't configured or the directory can't be created. +func (m *Manager) agentDir(user string) string { + if m.usersDir == "" { + return "" + } + d := filepath.Join(filepath.Dir(m.usersDir), "agent", unsafeName.ReplaceAllString(strings.ToLower(user), "-")) + if err := os.MkdirAll(d, 0o700); err != nil { + return "" + } + return d +} + +// startAgent forwards the connecting client's SSH agent into the member's pod: +// it listens on a fresh unix socket in the bind-mounted agent dir and proxies +// connections back over the SSH session. Returns the in-pod SSH_AUTH_SOCK path +// and a cleanup func, or "" when forwarding can't be set up (no agent dir / no +// socket). With this, `git push git@git.profullstack.com` inside the pod uses +// the member's own key — nothing is copied into the pod. +func (m *Manager) startAgent(s ssh.Session, user string) (sock string, cleanup func()) { + dir := m.agentDir(user) + if dir == "" { + return "", func() {} + } + var b [8]byte + _, _ = rand.Read(b[:]) + fname := "agent-" + hex.EncodeToString(b[:]) + ".sock" + hostSock := filepath.Join(dir, fname) + _ = os.Remove(hostSock) + l, err := net.Listen("unix", hostSock) + if err != nil { + return "", func() {} + } + go ssh.ForwardAgentConnections(l, s) + return "/run/agentbbs-agent/" + fname, func() { _ = l.Close(); _ = os.Remove(hostSock) } +} + // Engine reports the active container engine. func (m *Manager) Engine() string { return m.engine } @@ -126,6 +168,9 @@ func (m *Manager) ensure(user string) (string, error) { // Bind the host's public_html into the pod so a member's edits at // ~/public_html are exactly what Caddy serves at .. _, pubSpec := m.publicHTMLMount(user) + // Bind a per-user agent dir into the pod; Attach drops a forwarded SSH-agent + // socket here so `git push` uses the member's own key (see startAgent). + agentDir := m.agentDir(user) if m.engine == "docker" { // Under docker the pod runs as uid 1000 (never container root), so the // named home volume — and the bind-mounted public_html — must be owned @@ -159,7 +204,9 @@ func (m *Manager) ensure(user string) (string, error) { // running an out-of-date image (e.g. a new pod image with added tooling). // The home volume persists across rm, so member data is kept; a busy pod // heals on its next idle attach instead. - needsHeal := idle && ((pubSpec != "" && !m.hasMount(name, "/home/dev/public_html")) || !m.hasImage(name, m.image)) + needsHeal := idle && ((pubSpec != "" && !m.hasMount(name, "/home/dev/public_html")) || + (agentDir != "" && !m.hasMount(name, "/run/agentbbs-agent")) || + !m.hasImage(name, m.image)) if needsHeal { _ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate } else { @@ -182,6 +229,9 @@ func (m *Manager) ensure(user string) (string, error) { if pubSpec != "" { args = append(args, "-v", pubSpec) } + if agentDir != "" { + args = append(args, "-v", agentDir+":/run/agentbbs-agent") + } if m.engine == "docker" { // Rootful docker: a breakout is host-root, so refuse to hand out // container root — run as uid 1000 with no caps and no privilege @@ -221,14 +271,22 @@ func (m *Manager) Attach(s ssh.Session, user string) error { return err } + // Forward the client's SSH agent (ssh -A) into the pod so git push uses the + // member's own key. No-op unless the client requested forwarding. + execEnv := []string{"-e", "TERM=" + ptyReq.Term} + if ssh.AgentRequested(s) { + if sock, cleanup := m.startAgent(s, user); sock != "" { + defer cleanup() + execEnv = append(execEnv, "-e", "SSH_AUTH_SOCK="+sock) + } + } + shell := env("AGENTBBS_POD_SHELL", "/bin/bash") - cmd := exec.Command(m.engine, "exec", "-it", - "-e", "TERM="+ptyReq.Term, - name, shell, "-l") + cmd := exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, shell, "-l")...) f, err := pty.Start(cmd) if err != nil { // busybox-ish images may lack bash - cmd = exec.Command(m.engine, "exec", "-it", "-e", "TERM="+ptyReq.Term, name, "/bin/sh", "-l") + cmd = exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, "/bin/sh", "-l")...) f, err = pty.Start(cmd) if err != nil { return fmt.Errorf("pods: attach failed: %w", err) diff --git a/pods/Containerfile b/pods/Containerfile index 67a7120..e679998 100644 --- a/pods/Containerfile +++ b/pods/Containerfile @@ -42,4 +42,16 @@ RUN printf '%s\n' \ && printf '[ -n "$PS1" ] && [ -r /etc/motd ] && cat /etc/motd\n' \ > /etc/profile.d/10-agentbbs-motd.sh +# Make `git@git.profullstack.com:...` reach Forgejo's SSH server (port 2222) and +# trust it on first use, so clones/pushes just work with a forwarded agent key. +RUN install -d -m 0755 /etc/ssh/ssh_config.d \ + && printf '%s\n' \ + 'Host git.profullstack.com' \ + ' Port 2222' \ + ' User git' \ + ' StrictHostKeyChecking accept-new' \ + > /etc/ssh/ssh_config.d/10-agentgit.conf \ + && grep -q 'ssh_config.d/\*.conf' /etc/ssh/ssh_config 2>/dev/null \ + || printf '\nInclude /etc/ssh/ssh_config.d/*.conf\n' >> /etc/ssh/ssh_config + CMD ["sleep", "infinity"] diff --git a/setup.sh b/setup.sh index 572d3d7..492569f 100755 --- a/setup.sh +++ b/setup.sh @@ -323,6 +323,12 @@ if [ -f "$SRC_DIR/pods/Containerfile" ]; then podman build -t localhost/agentbbs-pod:latest \ -f "$SRC_DIR/pods/Containerfile" "$SRC_DIR/pods" >/dev/null 2>&1; then POD_IMAGE="localhost/agentbbs-pod:latest" + elif sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \ + podman image exists localhost/agentbbs-pod:latest >/dev/null 2>&1; then + # A transient build failure (e.g. registry/network hiccup) must not downgrade + # pods back to the base image — keep using the previously built one. + POD_IMAGE="localhost/agentbbs-pod:latest" + warn "pod image rebuild failed — using the existing localhost/agentbbs-pod:latest" else warn "pod image build failed — keeping $POD_IMAGE (run: podman build -f $SRC_DIR/pods/Containerfile $SRC_DIR/pods)" fi From de5517c0006cf3598d3e543556496eff0a61a2a0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 02:31:27 -0700 Subject: [PATCH 17/18] fix(pods): never downgrade AGENTBBS_POD_IMAGE on a transient build failure (#54) The deploy's rootless-podman context intermittently fails (pre-pull/build/even image-exists), which made setup.sh upsert AGENTBBS_POD_IMAGE back to the base ubuntu and silently strip Claude Code/Codex from pods. Only upsert when we actually have localhost/agentbbs-pod:latest; otherwise leave the configured value untouched (the agentbbs daemon uses the local image from its own session). Co-authored-by: Claude Opus 4.8 --- setup.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/setup.sh b/setup.sh index 492569f..7ac7327 100755 --- a/setup.sh +++ b/setup.sh @@ -451,8 +451,13 @@ upsert_env() { # KEY VALUE — skips when VALUE is empty } # CoinPay: API key (read by the coinpay CLI) + merchant/business id. # Point existing installs at the freshly built member pod image (fresh installs -# get it from the env-file template below). -upsert_env AGENTBBS_POD_IMAGE "$POD_IMAGE" +# get it from the env-file template below). Only when we actually have the custom +# image — a transient podman failure in the deploy's rootless context must never +# downgrade a working install back to the base ubuntu (the daemon builds/uses the +# image from its own session regardless). +if [ "$POD_IMAGE" = "localhost/agentbbs-pod:latest" ]; then + upsert_env AGENTBBS_POD_IMAGE "$POD_IMAGE" +fi upsert_env COINPAY_API_KEY "${COINPAY_API_KEY:-}" upsert_env AGENTBBS_COINPAY_MERCHANT_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" upsert_env COINPAY_BUSINESS_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" From 006235ce92b4a2d2a15fc976c8cd492083bc4f86 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 03:38:31 -0700 Subject: [PATCH 18/18] Feat/mail all members (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mail): give every verified member a free @bbs.profullstack.com mailbox Email was built but paid-only (Founding Lifetime gate) and never wired to a running backend. Make it a free benefit of membership and split the address domain from the mail-server host. - internal/mailu: Mailu admin-API client; EnsureUser idempotently provisions a mailbox via the loopback admin REST API (token = mailu.env API_TOKEN). - main.go: auto-provision @ at join@ verification and on first Mail open; un-gate the Mail hub entry + mail@ (membership/email-verified, not Premium); address domain (AGENTBBS_MAIL_ADDR_DOMAIN, default the BBS host) is now distinct from the mail server host (AGENTBBS_MAIL_DOMAIN) and the webmail URL. Drop the forwardemail alias path (Mailu now owns delivery for everyone). - mailbox: gate on membership (a registered handle) instead of Paid; ErrNotPaid -> ErrNotMember. - join@ copy: list email under free membership; premium now pitches custom domains + Tor only. - setup.sh / docs/mail.md / deploy/mailu: address-domain vs server-host split, Mailu API token, MX for the address domain, local-relay SMTP for verify codes. Co-Authored-By: Claude Opus 4.8 * chore(mailu): pin Docker network subnet to match SUBNET; ignore runtime state The base compose declares no network, so Docker assigns the default bridge an arbitrary subnet that won't match mailu.env SUBNET — breaking Mailu's internal service auth/relay. Add a docker-compose.override.yml.example that pins the default network to 192.168.203.0/24, and gitignore the live override + Mailu runtime state (mailu.env, certs/, data/). Co-Authored-By: Claude Opus 4.8 * feat(mail): plaintext loopback IMAP so the gateway bypasses Mailu's front Mailu's front (nginx mail proxy) pre-authenticates against Mailu's user DB before proxying to Dovecot, which rejects the Dovecot master-user login *gateway. The gateway must reach Dovecot directly. The imap container has no TLS cert (only the front does), so the bypass is plaintext over loopback — the master password never leaves the host. - mailbox: IMAPConfig.Plaintext dials with DialInsecure (loopback only). - main.go: mailClientFor sets Plaintext from AGENTBBS_MAIL_IMAP_PLAINTEXT. - override.example: add the unbound resolver (admin needs DNSSEC), webmail image fix (2024.06 uses mailu/webmail), and publish Dovecot 143 on 127.0.0.1:14143. - docs/mail.md: document the front-bypass, the dovecot.conf master passdb (Mailu includes that exact filename), and the 644 master-users perms (640 = temp_fail). Co-Authored-By: Claude Opus 4.8 * deploy(mailu): wire gateway IMAP to the loopback Dovecot path in setup.sh setup.sh §9e set AGENTBBS_MAIL_IMAP_ADDR to the front's :993, which the front's auth proxy rejects for the master-user login (and would clobber the working loopback wiring on every self-update). Point it at 127.0.0.1:14143 + AGENTBBS_MAIL_IMAP_PLAINTEXT=1 instead, matching the override + docs. Co-Authored-By: Claude Opus 4.8 * feat(mail): give free members a webmail password at join@ The gateway opens mailboxes via the Dovecot master user (no member password), but webmail (Roundcube) needs the member to have a password. join@ now sets a fresh, readable webmail password via the Mailu API and shows it with the webmail URL + login, so free members can use webmail at mail.profullstack.com. - mailu: SetPassword (PATCH /user/ raw_password) + test. - main.go: setWebmailPassword + readablePassword; join@ displays url/login/password. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .gitignore | 6 + cmd/agentbbs/main.go | 244 ++++++++++++------ .../mailu/docker-compose.override.yml.example | 36 +++ deploy/mailu/mailu.env.example | 20 +- deploy/mailu/provision-mailbox.sh | 3 +- docs/mail.md | 146 +++++++---- internal/mailbox/client.go | 23 +- internal/mailbox/imap.go | 11 +- internal/mailbox/mailbox_test.go | 16 +- internal/mailbox/types.go | 11 +- internal/mailu/mailu.go | 197 ++++++++++++++ internal/mailu/mailu_test.go | 124 +++++++++ setup.sh | 52 ++-- 13 files changed, 725 insertions(+), 164 deletions(-) create mode 100644 deploy/mailu/docker-compose.override.yml.example create mode 100644 internal/mailu/mailu.go create mode 100644 internal/mailu/mailu_test.go diff --git a/.gitignore b/.gitignore index c4b10e9..ea725d2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,11 @@ *.log .env +# Mailu runtime: secrets, local network override, and state +deploy/mailu/mailu.env +deploy/mailu/docker-compose.override.yml +deploy/mailu/data/ +deploy/mailu/certs/ + # Claude Code local worktrees/state .claude/ diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 42f5354..5da1bf0 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -31,6 +31,7 @@ import ( "context" "crypto/rand" "encoding/binary" + "encoding/hex" "errors" "fmt" "io" @@ -59,11 +60,11 @@ import ( "github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/forgejo" - "github.com/profullstack/agentbbs/internal/forwardemail" "github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mailbox" + "github.com/profullstack/agentbbs/internal/mailu" "github.com/profullstack/agentbbs/internal/news" "github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/plugin" @@ -73,9 +74,9 @@ import ( "github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/internal/tor" "github.com/profullstack/agentbbs/plugins/about" - "github.com/profullstack/agentbbs/plugins/hello" "github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/arcade" + "github.com/profullstack/agentbbs/plugins/hello" "github.com/profullstack/agentbbs/plugins/members" qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite" ) @@ -98,21 +99,24 @@ func envInt(k string, def int) int { } type app struct { - st store.Store - pods *pods.Manager // nil when no container engine on host - sites *sites.Manager - registry []plugin.Plugin - sandbox *sandbox.Runner - mail mail.Config - fe forwardemail.Config // premium @bbs email provisioning - forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning - live *liveReg // in-memory live-session registry (admin console) - gamesReg *games.Registry // AgentGames catalog - mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent) - dataDir string - assets string - host string // public hostname used in user-facing messages - newsAddr string // loopback NNTP address the news@ reader dials + st store.Store + pods *pods.Manager // nil when no container engine on host + sites *sites.Manager + registry []plugin.Plugin + sandbox *sandbox.Runner + mail mail.Config + mailu *mailu.Client // member mailbox provisioning (nil when unconfigured) + mailDomain string // email address domain, e.g. bbs.profullstack.com + 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 + live *liveReg // in-memory live-session registry (admin console) + gamesReg *games.Registry // AgentGames catalog + mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent) + dataDir string + assets string + host string // public hostname used in user-facing messages + newsAddr string // loopback NNTP address the news@ reader dials } // Version is the agentbbs stack release, surfaced via `agentbbs version` and @@ -155,22 +159,28 @@ func main() { } host := env("AGENTBBS_HOST", "bbs.profullstack.com") - fe := forwardemail.ConfigFromEnv() - if fe.Domain == "" { - // Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com), - // not the BBS host and not the apex (which is reserved for corporate mail). - fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") + // Member email addresses are @ (e.g. bbs.profullstack.com). + // The mail server (IMAP/SMTP/webmail) lives on a dedicated host + // (mail.profullstack.com); the apex is reserved for corporate mail. + mailHost := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") + mailDomain := env("AGENTBBS_MAIL_ADDR_DOMAIN", host) + mailuClient := mailu.NewFromEnv() + if !mailuClient.Configured() { + mailuClient = nil } a := &app{ - st: st, - sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))), - mail: mail.ConfigFromEnv(), - fe: fe, - forgejo: forgejo.ConfigFromEnv(), - live: newLiveReg(), - dataDir: dataDir, - assets: env("AGENTBBS_ASSETS", "./assets"), - host: host, + st: st, + sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))), + mail: mail.ConfigFromEnv(), + mailu: mailuClient, + mailDomain: mailDomain, + mailHost: mailHost, + webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost), + forgejo: forgejo.ConfigFromEnv(), + live: newLiveReg(), + dataDir: dataDir, + assets: env("AGENTBBS_ASSETS", "./assets"), + host: host, } a.gamesReg = games.Catalog() a.mm = games.NewMatchmaker(a.gamesReg, a.st, @@ -485,19 +495,22 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }}, }) - // Mail — a Founding Lifetime Member perk: the AgentMail TUI. + // Mail — a free benefit of membership: the AgentMail TUI for your + // @ mailbox. mailLock := "" switch { case guest: mailLock = membersOnly - case !su.Premium: - mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host + case !a.mailEnabled(): + mailLock = "mail is temporarily unavailable on this host" } apps = append(apps, hub.SessionApp{ Title: "Mail", - Description: "your " + a.fe.Domain + " mailbox", + Description: "your " + a.mailAddress(su.Name) + " mailbox", Locked: mailLock, Cmd: sessionExec{run: func() error { + // Make sure the mailbox exists before opening it. + _ = a.ensureMailbox(su) c, err := a.mailClientFor(su) if err != nil { return err @@ -612,7 +625,7 @@ func (a *app) handleJoin(s ssh.Session) { }, "\n")) // 1) email -> emailed code -> enter code. A verified account is a free - // member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub. + // member: it gets a Docker pod, a mailbox, IRC/news, and a /~name homepage. if !u.EmailVerified { if !a.verifyEmailInteractive(s, in, &u) { _ = s.Exit(1) @@ -621,22 +634,41 @@ func (a *app) handleJoin(s ssh.Session) { a.notifySignup(u) } - // Every verified member gets a homepage at https:///~. + // Every verified member gets a homepage at https:///~ and a + // mailbox at @ (best-effort; mail is a bonus, never a gate). seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host) + _ = a.ensureMailbox(u) + // Give them a webmail password so free members can log into webmail. The + // in-BBS reader uses the gateway master user and needs no password, but + // Roundcube does. (Re)set on each join@; they can change it in webmail. + webmailPW := a.setWebmailPassword(u) - wish.Println(s, "\n"+strings.Join([]string{ + includes := []string{ " You're in. One login gets you everything — no other servers to ssh into:", "", " ssh " + u.Name + "@" + a.host, "", " Inside, free membership includes:", " • your own Linux pod (a full shell)", + " • email " + a.mailAddress(u.Name) + " (pick “Mail” in the hub)", " • IRC chat + Usenet/news (members-only)", " • the arcade & games", " • your homepage https://" + a.host + "/~" + u.Name, - }, "\n")) + } + if a.webmailURL != "" && webmailPW != "" { + includes = append(includes, + "", + " Webmail (read your mail in a browser):", + " • url "+a.webmailURL, + " • login "+a.mailAddress(u.Name), + " • password "+webmailPW+" (change it in webmail Settings)", + ) + } else if a.webmailURL != "" { + includes = append(includes, " • webmail "+a.webmailURL) + } + wish.Println(s, "\n"+strings.Join(includes, "\n")) - // 2) Founding Lifetime ($99 one-time): personal @host email + custom domains. + // 2) Founding Lifetime ($99 one-time): custom domains + Tor shell. a.offerPremium(s, &u) _ = s.Exit(0) } @@ -796,10 +828,11 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U return false } -// ensurePremium upgrades *u to premium if its CoinPay charge has settled, -// provisioning the member's @host email alias on the transition. It is silent -// (no session output) so it is safe to call from the hub. Returns the current -// premium state. +// ensurePremium upgrades *u to premium if its CoinPay charge has settled. It is +// silent (no session output) so it is safe to call from the hub. Returns the +// current premium state. Email is no longer a premium perk — every verified +// member gets a mailbox (see ensureMailbox) — so this only unlocks custom +// domains and the Tor shell. func (a *app) ensurePremium(u *store.User) bool { if u.Premium { return true @@ -816,33 +849,25 @@ func (a *app) ensurePremium(u *store.User) bool { return false } u.Premium = true - // Create their @host alias forwarding to the email they verified. - if a.fe.Configured() && u.Email != "" { - if err := a.fe.CreateAlias(u.Name, u.Email); err != nil { - log.Error("forwardemail alias", "err", err, "alias", a.fe.Address(u.Name)) - } - } return true } -// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail -// URL, the in-hub Mail/Tor entries, and custom domains. +// showPremiumWelcome prints a premium member's perks: custom domains and the +// in-hub Tor shell. (Email is free for all members — see the join@ summary.) func (a *app) showPremiumWelcome(s ssh.Session, u store.User) { lines := []string{ "", - " ★ Founding Lifetime Member — thanks! Your perks:", + " ★ Founding Lifetime Member — thanks! Your bonus perks:", "", - " mailbox " + a.fe.Address(u.Name), - " webmail https://" + a.fe.Domain, - " mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host, " domains ssh domain@" + a.host + " add ", + " tor pick “Tor shell” in the hub: ssh " + u.Name + "@" + a.host, "", } wish.Println(s, strings.Join(lines, "\n")) } -// offerPremium pitches the $99 Founding Lifetime membership — a personal @host email and -// custom domains. When CoinPay can mint a charge in-session it shows the exact +// offerPremium pitches the $99 Founding Lifetime membership — custom domains and +// the Tor shell. When CoinPay can mint a charge in-session it shows the exact // amount and deposit address; otherwise it falls back to a pay command. // Non-blocking: the member pays out of band and perks unlock on their next // connect (or re-running join@). @@ -859,9 +884,9 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) { " ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time", " Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.", "", - " Everything in your free membership stays free — founding adds these", - " bonus features, forever:", - " • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")", + " Everything in your free membership stays free — including your", + " " + a.mailAddress(u.Name) + " mailbox. Founding adds these bonus", + " features, forever:", " • custom domains point yourdomain.com at your homepage", " • Tor a “Tor shell” in your pod — everything over Tor", " • locked-in price founding rate is yours for life — never renew, never pay again", @@ -1301,29 +1326,95 @@ func (a *app) runNews(s ssh.Session, name string) error { return news.RunReader(s, addr, name) } -// mailClientFor builds a paid-gated AgentMail client for a member, connecting to -// the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login -// "*") so the BBS gateway can open any member's mailbox with one -// secret; SMTP defaults to the co-located relay (no auth). Returns an error if -// the IMAP connection/login fails. +// mailAddress is a member's email address, e.g. alice@bbs.profullstack.com. +func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain } + +// mailEnabled reports whether member mailboxes can be provisioned (Mailu admin +// API configured). When false the address is still shown but not created. +func (a *app) mailEnabled() bool { return a.mailu.Configured() } + +// ensureMailbox provisions the member's @ mailbox on Mailu if +// it doesn't already exist. Idempotent and best-effort: it logs and returns the +// error but callers treat mail as a bonus that shouldn't block onboarding. A +// no-op when Mailu isn't configured. +func (a *app) ensureMailbox(u store.User) error { + if !a.mailEnabled() || u.Name == "" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := a.mailu.EnsureUser(ctx, u.Name, a.mailDomain); err != nil { + log.Error("provision mailbox", "err", err, "address", a.mailAddress(u.Name)) + return err + } + return nil +} + +// setWebmailPassword sets (and returns) a fresh webmail password for the member +// so free members can log into webmail. Best-effort: returns "" when Mailu isn't +// configured or the API call fails. The in-BBS reader doesn't use this (it goes +// through the gateway master user); only webmail needs a member password. +func (a *app) setWebmailPassword(u store.User) string { + if !a.mailEnabled() || u.Name == "" { + return "" + } + pw := readablePassword() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := a.mailu.SetPassword(ctx, u.Name, a.mailDomain, pw); err != nil { + log.Error("set webmail password", "err", err, "address", a.mailAddress(u.Name)) + return "" + } + return pw +} + +// readablePassword returns a 16-char password from an unambiguous alphabet (no +// 0/O/1/l/I) — easy to read off a terminal once and type into webmail. +func readablePassword() string { + const alphabet = "abcdefghijkmnpqrstuvwxyzACDEFGHJKLMNPQRSTUVWXYZ23456789" + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // Fall back to a hex token; correctness over readability. + var f [12]byte + _, _ = rand.Read(f[:]) + return hex.EncodeToString(f[:]) + } + for i := range b { + b[i] = alphabet[int(b[i])%len(alphabet)] + } + return string(b[:]) +} + +// mailClientFor builds an AgentMail client for a member, connecting to the +// self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login +// "*") so the BBS gateway can open any member's mailbox with one +// secret; SMTP defaults to the co-located relay (no auth). The client stamps +// outgoing mail with the member's @ address. Returns an error +// if the IMAP connection/login fails. func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) { - domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") - login := su.Name + // Mailu keys mailboxes by full address, so the IMAP login (and the master + // login "*") must use the address, not the bare handle. + login := a.mailAddress(su.Name) if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" { - login = su.Name + "*" + master + login = a.mailAddress(su.Name) + "*" + master } cfg := mailbox.IMAPConfig{ - IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", domain+":993"), + IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"), SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"), Username: login, Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"), + // Mailu's front nginx pre-authenticates against its user DB before + // proxying, which rejects the "*master" master login. The gateway + // therefore talks to Dovecot directly over loopback (plaintext, on-host) + // when AGENTBBS_MAIL_IMAP_PLAINTEXT=1. See docs/mail.md. + Plaintext: os.Getenv("AGENTBBS_MAIL_IMAP_PLAINTEXT") == "1", // SMTPUser/SMTPPass left empty: submit via the trusted local relay. } tr, err := mailbox.NewIMAPTransport(cfg) if err != nil { return nil, err } - return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, domain, 50), nil + return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, a.mailDomain, 50), nil } // handleMail routes a Founding Lifetime member into AgentMail: an interactive @@ -1347,11 +1438,18 @@ func (a *app) handleMail(s ssh.Session) { _ = s.Exit(1) return } - if !a.ensurePremium(&u) { - wish.Println(s, " mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@"+a.host) + if !u.EmailVerified { + wish.Println(s, " verify your email first: ssh -t join@"+a.host) _ = s.Exit(1) return } + if !a.mailEnabled() { + wish.Println(s, " mail is temporarily unavailable on this host.") + _ = s.Exit(1) + return + } + // Mail is a free benefit of membership — make sure the mailbox exists. + _ = a.ensureMailbox(u) sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail") defer func() { _ = a.st.EndSession(sessID) }() diff --git a/deploy/mailu/docker-compose.override.yml.example b/deploy/mailu/docker-compose.override.yml.example new file mode 100644 index 0000000..074fd1d --- /dev/null +++ b/deploy/mailu/docker-compose.override.yml.example @@ -0,0 +1,36 @@ +# docker-compose.override.yml — copy to docker-compose.override.yml (gitignored). +# Compose loads this file automatically. It carries three fixes the trimmed base +# compose needs; see docs/mail.md for the full rationale. +networks: + default: + driver: bridge + ipam: + config: + # Mailu trusts SUBNET (mailu.env) as its internal network for + # service-to-service auth/relay; the real network MUST match it. + - subnet: 192.168.203.0/24 +services: + # Mailu requires a DNSSEC-validating resolver or admin won't start. + resolver: + image: ghcr.io/mailu/unbound:2024.06 + env_file: mailu.env + restart: always + networks: + default: + ipv4_address: 192.168.203.254 + front: { dns: [192.168.203.254], depends_on: [resolver] } + admin: { dns: [192.168.203.254], depends_on: [resolver] } + imap: + dns: [192.168.203.254] + depends_on: [resolver] + # Publish Dovecot directly on loopback so the agentbbs gateway can use the + # master-user login (the front's nginx auth proxy rejects "*master"). + # Plaintext is fine: the connection never leaves the host. + ports: ["127.0.0.1:14143:143"] + smtp: { dns: [192.168.203.254], depends_on: [resolver] } + antispam: { dns: [192.168.203.254], depends_on: [resolver] } + # In Mailu 2024.06 the webmail image is "webmail" (not "roundcube:2024.06"). + webmail: + image: ghcr.io/mailu/webmail:2024.06 + dns: [192.168.203.254] + depends_on: [resolver] diff --git a/deploy/mailu/mailu.env.example b/deploy/mailu/mailu.env.example index 273de92..15f4836 100644 --- a/deploy/mailu/mailu.env.example +++ b/deploy/mailu/mailu.env.example @@ -1,15 +1,24 @@ -# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env -# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway). +# Mailu configuration — copy to deploy/mailu/mailu.env and fill the secrets. +# See docs/mail.md for the full setup (DNS, certs, gateway). # # Generate secrets with: openssl rand -hex 16 +# +# NOTE: DOMAIN is the member ADDRESS domain (the @-part); HOSTNAMES is the mail +# SERVER host (TLS/HELO + webmail/admin/API). These deliberately differ: +# members get @bbs.profullstack.com, served from mail.profullstack.com. # --- General ----------------------------------------------------------------- SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16 -DOMAIN=mail.profullstack.com # member addresses are @mail.profullstack.com +DOMAIN=bbs.profullstack.com # member addresses are @bbs.profullstack.com HOSTNAMES=mail.profullstack.com,smtp.profullstack.com POSTMASTER=postmaster # Apex profullstack.com is reserved for corporate mail and is NOT served here. +# Admin REST API: agentbbs auto-provisions member mailboxes through it. Mirror +# this value into the agentbbs service as AGENTBBS_MAIL_API_TOKEN. +API=true +API_TOKEN=CHANGEME_api_token # openssl rand -hex 24 + # TLS_FLAVOR=mail: Mailu does NOT run its own ACME (Caddy owns :80/:443). We feed # it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh). TLS_FLAVOR=mail @@ -32,13 +41,16 @@ MESSAGE_SIZE_LIMIT=52428800 # 50 MB # A Dovecot master user lets the agentbbs gateway open any member's mailbox with # one secret (login "*"). Created by deploy/mailu/provision-mailbox.sh. # Mirror these into the agentbbs service env: +# AGENTBBS_MAIL_ADDR_DOMAIN=bbs.profullstack.com # AGENTBBS_MAIL_DOMAIN=mail.profullstack.com # AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993 # AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25 +# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080 +# AGENTBBS_MAIL_API_TOKEN= # AGENTBBS_MAIL_MASTER_USER=gateway # AGENTBBS_MAIL_MASTER_PASS= # --- Admin bootstrap --------------------------------------------------------- INITIAL_ADMIN_ACCOUNT=admin -INITIAL_ADMIN_DOMAIN=mail.profullstack.com +INITIAL_ADMIN_DOMAIN=bbs.profullstack.com INITIAL_ADMIN_PW=CHANGEME_admin_password diff --git a/deploy/mailu/provision-mailbox.sh b/deploy/mailu/provision-mailbox.sh index 106e3f5..c2ad34d 100755 --- a/deploy/mailu/provision-mailbox.sh +++ b/deploy/mailu/provision-mailbox.sh @@ -14,7 +14,8 @@ set -euo pipefail MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}" -DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}" +# The address domain (the @-part), which may differ from the mail server host. +DOMAIN="${MAIL_ADDR_DOMAIN:-${MAIL_DOMAIN:-bbs.profullstack.com}}" MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}" QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB diff --git a/docs/mail.md b/docs/mail.md index 5920fd6..60cd0b4 100644 --- a/docs/mail.md +++ b/docs/mail.md @@ -1,16 +1,22 @@ -# Mail — self-hosted Mailu at `mail.profullstack.com` +# Mail — self-hosted Mailu -AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at -`@mail.profullstack.com`, reached two ways: +AgentBBS gives **every verified member** (free and paid alike) a real mailbox at +`@bbs.profullstack.com`, reached two ways: -- **Webmail** — `https://mail.profullstack.com` (Roundcube), the only - member-facing mail surface. +- **Webmail** — `https://mail.profullstack.com` (Roundcube). - **AgentMail** — the in-BBS client (`internal/mailbox`): the `Mail` hub entry or `ssh mail@bbs.profullstack.com` (a TUI for humans, a JSON bot mode for agents). It connects to this stack. +Two distinct names are involved — don't conflate them: + +| | value | role | +|---|---|---| +| **Address domain** | `bbs.profullstack.com` | the `@`-part of member addresses (`AGENTBBS_MAIL_ADDR_DOMAIN`) | +| **Mail server host** | `mail.profullstack.com` | where IMAP/SMTP/webmail actually run (`AGENTBBS_MAIL_DOMAIN`) | + The apex `profullstack.com` is **reserved for corporate mail** and is not served -here — member mail lives only on the `mail.` subdomain. +here. ## Architecture @@ -19,97 +25,139 @@ Mailu (Postfix + Dovecot + Roundcube + rspamd) runs as a Docker Compose stack: - Mailu owns the **mail ports** on the host: `25, 465, 587, 993, 995`. - Mailu's HTTP front is bound to **loopback** (`127.0.0.1:8080`); **Caddy** - reverse-proxies `https://mail.profullstack.com` to it (webmail + admin). + reverse-proxies `https://mail.profullstack.com` to it (webmail + admin + API). - **TLS:** `TLS_FLAVOR=mail` — Mailu does *not* run its own ACME (Caddy is the - only ACME client). Caddy obtains the `mail.profullstack.com` cert from its site - block; [`deploy/mailu/refresh-certs.sh`](../deploy/mailu/refresh-certs.sh) - copies it into Mailu and reloads it on renewal — the same pattern as the - Ergo/IRC and NNTP cert refreshers. + only ACME client). Caddy obtains the `mail.profullstack.com` cert; the cert + refresher copies it into Mailu and reloads on renewal. - The **agentbbs gateway** reads/sends on behalf of members: IMAP via a Dovecot **master user** (one secret opens any mailbox), SMTP via the co-located relay on `127.0.0.1:25`. Members therefore never manage an IMAP/SMTP password. +- **Provisioning** is automatic: when a member verifies their email at `join@` + (or opens `Mail`), agentbbs ensures `@bbs.profullstack.com` exists via + Mailu's **admin REST API** (`internal/mailu`, token = `API_TOKEN`). The manual + `deploy/mailu/provision-mailbox.sh` is only for the gateway master user and + backfills. ``` ┌─────────── Caddy (:443) ───────────┐ - webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front, HTTP) + webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front: webmail/admin/API) └───────────────┬─────────────────────┘ │ copies LE cert (refresh-certs.sh) clients → Mailu front (:25 :465 :587 :993 :995) ──→ Postfix / Dovecot / rspamd ▲ agentbbs ──IMAP 993 (master user)──┘ ──SMTP 127.0.0.1:25 (local relay)──▶ + agentbbs ──admin API (token) http://127.0.0.1:8080/api/v1──▶ (auto-provision) ``` ## DNS -`mail.profullstack.com` and `smtp.profullstack.com` A records are added. Also set: +Mail is delivered to the **address domain** (`bbs.profullstack.com`), so its MX +must point at the **server host** (`mail.profullstack.com`): | Type | Host | Value | |---|---|---| | A | `mail.profullstack.com` | host IP | -| A | `smtp.profullstack.com` | host IP | -| MX | `mail.profullstack.com` | `10 mail.profullstack.com.` | -| TXT (SPF) | `mail.profullstack.com` | `v=spf1 mx -all` | -| TXT (DMARC) | `_dmarc.mail.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.profullstack.com` | -| TXT (DKIM) | `dkim._domainkey.mail.profullstack.com` | from `flask mailu config-export` after first boot | +| MX | `bbs.profullstack.com` | `10 mail.profullstack.com.` | +| TXT (SPF) | `bbs.profullstack.com` | `v=spf1 mx -all` | +| TXT (DMARC) | `_dmarc.bbs.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@bbs.profullstack.com` | +| TXT (DKIM) | `dkim._domainkey.bbs.profullstack.com` | from `flask mailu config-export` after first boot | | PTR | host IP | `mail.profullstack.com` (set at your VPS provider) | -> **Port 25 / deliverability:** many cloud providers block outbound `:25` by -> default — request an unblock, set the PTR/rDNS, and warm the IP, or relay -> outbound through a smarthost. Inbound MX and the gateway's local submission -> work regardless. +> **Port 25 / deliverability:** many cloud providers (incl. DigitalOcean) block +> outbound `:25` by default — request an unblock, set the PTR/rDNS, and warm the +> IP, or relay outbound through a smarthost. Inbound MX and the gateway's local +> submission work regardless. ## Install ```bash cd /opt/agentbbs/deploy/mailu -cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, etc. +cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, API_TOKEN, DOMAIN=bbs.profullstack.com, HOSTNAMES=mail.profullstack.com docker compose up -d -# seed the gateway master user + (optionally) backfill member mailboxes: +# add the address domain + the gateway master user: +docker compose exec admin flask mailu domain bbs.profullstack.com AGENTBBS_MAIL_MASTER_USER=gateway ./provision-mailbox.sh --master "$(openssl rand -hex 16)" ``` -Add the Caddy site (setup.sh writes this when `MAIL=1`): - -``` -mail.profullstack.com { - encode zstd gzip - reverse_proxy 127.0.0.1:8080 -} -``` - -Then install the cert refresher on a timer (setup.sh does this too): - -```bash -install -m 0755 deploy/mailu/refresh-certs.sh /usr/local/bin/agentbbs-mailu-certs -# systemd timer runs it every ~12h; first run swaps in the real cert once Caddy issues it. -``` +setup.sh writes the Caddy `mail.profullstack.com` site and the cert-refresh +timer when `MAIL=1`, and brings the stack up once `mailu.env` exists. ## agentbbs gateway env -Set these on the agentbbs service so the `Mail` hub entry / `ssh mail@` work: +Set these on the agentbbs service (setup.sh §9e upserts the non-secret ones): | Var | Value | |---|---| +| `AGENTBBS_MAIL_ADDR_DOMAIN` | `bbs.profullstack.com` | | `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` | -| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` | +| `AGENTBBS_MAIL_IMAP_ADDR` | `127.0.0.1:14143` (Dovecot direct, loopback) | +| `AGENTBBS_MAIL_IMAP_PLAINTEXT` | `1` (the loopback path is plaintext) | | `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` | +| `AGENTBBS_MAIL_ADMIN_URL` | `http://127.0.0.1:8080` | +| `AGENTBBS_MAIL_API_TOKEN` | the Mailu `API_TOKEN` (secret) | | `AGENTBBS_MAIL_MASTER_USER` | `gateway` | -| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above | +| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above (secret) | +| `AGENTBBS_WEBMAIL_URL` | `https://mail.profullstack.com` (default = mail host) | + +Without `AGENTBBS_MAIL_API_TOKEN` auto-provisioning is skipped (the address is +still shown); without `AGENTBBS_MAIL_MASTER_PASS` the gateway can't open +mailboxes. + +### Why the gateway talks to Dovecot directly (plaintext loopback) + +Mailu's **front** (nginx mail proxy) pre-authenticates every IMAP/SMTP login +against Mailu's user DB before proxying to Dovecot — and it rejects the Dovecot +master-user login form `*gateway`. So the gateway must reach **Dovecot +directly**, bypassing the front. The `imap` container has no TLS cert (only the +front does), so the bypass is plaintext over loopback — safe because the +connection (and the master password) never leave the host. Wiring: + +- Publish Dovecot's IMAP on loopback (docker-compose.override.yml): + `imap.ports: ["127.0.0.1:14143:143"]`. +- The Dovecot master user is defined in `data/overrides/dovecot/dovecot.conf` + (Mailu includes exactly that filename — *not* `*.conf`): + + ``` + auth_master_user_separator = * + passdb { driver = passwd-file; master = yes; args = /overrides/master-users } + ``` + + with `data/overrides/dovecot/master-users` holding `gateway:{SHA512-CRYPT}$6$…` + (the hash of `AGENTBBS_MAIL_MASTER_PASS`). The file must be **world-readable + (644)** — Dovecot reads it as a non-root user, and 640 root:root yields a + `temp_fail`. Do **not** add `result_success = continue` (that would also + require the target user's own password); the target mailbox comes from userdb. +- Point the gateway at it: `AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143` + + `AGENTBBS_MAIL_IMAP_PLAINTEXT=1`. + +## Sending mail from the BBS (verify codes + notifications) + +The join@ verification code and signup notifications use `internal/mail` (the +`AGENTBBS_SMTP_*` knobs), separate from the per-member mailbox client. Point +them at the local Mailu relay so codes actually send: + +``` +AGENTBBS_SMTP_HOST=127.0.0.1 +AGENTBBS_SMTP_PORT=25 +AGENTBBS_SMTP_FROM=bbs@bbs.profullstack.com +# user/pass omitted: the co-located relay accepts local submission unauthenticated +``` ## Provisioning member mailboxes -A mailbox must exist before the gateway can open it. Provision when a member -becomes paid (or backfill): +Provisioning is automatic at `join@` verification. To create or backfill by hand: ```bash -deploy/mailu/provision-mailbox.sh alice # creates alice@mail.profullstack.com +deploy/mailu/provision-mailbox.sh alice # creates alice@bbs.profullstack.com ``` -The Dovecot **master user** (`gateway`) then authenticates as any member with -the login form `alice*gateway` + the master password — which is exactly what +(Set `MAIL_DOMAIN=bbs.profullstack.com` for the script, since the address domain +differs from the server host.) + +The Dovecot **master user** (`gateway`) authenticates as any member with the +login form `alice*gateway` + the master password — exactly what `internal/mailbox`'s IMAP adapter sends. See -[`deploy/mailu/README.md`](../deploy/mailu/README.md) for the master-user -override and operational details. +[`deploy/mailu/README.md`](../deploy/mailu/README.md) for details. ## Webmail only for members diff --git a/internal/mailbox/client.go b/internal/mailbox/client.go index aba1928..1f61868 100644 --- a/internal/mailbox/client.go +++ b/internal/mailbox/client.go @@ -7,16 +7,19 @@ import ( "strings" ) -// Identity is the acting member and whether they hold the paid membership. +// Identity is the acting member. AgentMail is a free benefit of membership, so +// having a registered handle is the only requirement; Paid is retained for +// tier-aware features (e.g. quotas) but no longer gates access. type Identity struct { Name string // local-part / handle, e.g. "alice" - Paid bool // Founding Lifetime Member; mail is gated on this + Paid bool // Founding Lifetime Member (informational; does not gate mail) } -// ErrNotPaid is returned to a non-paid member attempting a mail action. -var ErrNotPaid = errors.New("AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com") +// ErrNotMember is returned when a caller without a registered handle attempts a +// mail action. AgentMail is open to every verified member. +var ErrNotMember = errors.New("AgentMail is a member feature — register first: ssh join@bbs.profullstack.com") -// Client is the ergonomic, paid-gated facade the TUI and bot mode use. Every +// Client is the ergonomic, member-gated facade the TUI and bot mode use. Every // method returns plain structs, so the same calls serve humans and agents. type Client struct { t Transport @@ -25,8 +28,8 @@ type Client struct { pageSize int } -// NewClient builds a paid-gated client. domain is the mail domain (e.g. -// mail.profullstack.com); pageSize defaults to 50 when <= 0. +// NewClient builds a member-gated client. domain is the email address domain +// (e.g. bbs.profullstack.com); pageSize defaults to 50 when <= 0. func NewClient(t Transport, id Identity, domain string, pageSize int) *Client { if pageSize <= 0 { pageSize = 50 @@ -34,12 +37,12 @@ func NewClient(t Transport, id Identity, domain string, pageSize int) *Client { return &Client{t: t, id: id, domain: domain, pageSize: pageSize} } -// Address is the member's own mailbox address, e.g. alice@mail.profullstack.com. +// Address is the member's own mailbox address, e.g. alice@bbs.profullstack.com. func (c *Client) Address() string { return c.id.Name + "@" + c.domain } func (c *Client) gate() error { - if c.id.Name == "" || !c.id.Paid { - return ErrNotPaid + if c.id.Name == "" { + return ErrNotMember } return nil } diff --git a/internal/mailbox/imap.go b/internal/mailbox/imap.go index 44ab7f3..36711f0 100644 --- a/internal/mailbox/imap.go +++ b/internal/mailbox/imap.go @@ -24,6 +24,11 @@ type IMAPConfig struct { // SMTPUser/SMTPPass default to Username/Password when empty. SMTPUser string SMTPPass string + // Plaintext dials IMAP without TLS. Used only for a co-located backend over + // loopback (the Mailu gateway hitting Dovecot directly on 127.0.0.1, bypassing + // the front's auth proxy so master-user login works) — the password never + // leaves the host. Never enable it for a remote server. + Plaintext bool } // imapTransport is a Transport backed by a single authenticated IMAP connection @@ -38,7 +43,11 @@ type imapTransport struct { // NewIMAPTransport dials the IMAP server, logs in, and returns a Transport. func NewIMAPTransport(cfg IMAPConfig) (Transport, error) { - c, err := imapclient.DialTLS(cfg.IMAPAddr, nil) + dial := imapclient.DialTLS + if cfg.Plaintext { + dial = imapclient.DialInsecure + } + c, err := dial(cfg.IMAPAddr, nil) if err != nil { return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err) } diff --git a/internal/mailbox/mailbox_test.go b/internal/mailbox/mailbox_test.go index 09da9f1..986dac7 100644 --- a/internal/mailbox/mailbox_test.go +++ b/internal/mailbox/mailbox_test.go @@ -15,7 +15,7 @@ func seeded() *MemoryTransport { } func paidClient(t Transport) *Client { - return NewClient(t, Identity{Name: "alice", Paid: true}, "mail.profullstack.com", 50) + return NewClient(t, Identity{Name: "alice", Paid: true}, "bbs.profullstack.com", 50) } func TestParseFormatAddress(t *testing.T) { @@ -45,9 +45,15 @@ func TestValidEmailAndDraft(t *testing.T) { } func TestGate(t *testing.T) { - c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "mail.profullstack.com", 0) - if _, err := c.Inbox(context.Background(), 0); !errors.Is(err, ErrNotPaid) { - t.Fatalf("expected ErrNotPaid, got %v", err) + // A free member (Paid: false) now has full mail access. + c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "bbs.profullstack.com", 0) + if _, err := c.Inbox(context.Background(), 0); err != nil { + t.Fatalf("free member should have mail access, got %v", err) + } + // Only a caller without a registered handle is rejected. + anon := NewClient(seeded(), Identity{Name: "", Paid: true}, "bbs.profullstack.com", 0) + if _, err := anon.Inbox(context.Background(), 0); !errors.Is(err, ErrNotMember) { + t.Fatalf("expected ErrNotMember, got %v", err) } } @@ -106,7 +112,7 @@ func TestSendAndReply(t *testing.T) { t.Fatalf("send: %v", err) } sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent}) - if len(sent) != 1 || sent[0].From.Address != "alice@mail.profullstack.com" || sent[0].Subject != "Hi" { + if len(sent) != 1 || sent[0].From.Address != "alice@bbs.profullstack.com" || sent[0].Subject != "Hi" { t.Fatalf("sent: %+v", sent) } diff --git a/internal/mailbox/types.go b/internal/mailbox/types.go index a952772..33a706b 100644 --- a/internal/mailbox/types.go +++ b/internal/mailbox/types.go @@ -1,8 +1,9 @@ -// Package mailbox is the BBS-side mail client for Founding Lifetime members: a -// transport-agnostic core (read, search, compose, send, flag, delete) with a -// Bubble Tea TUI for humans and a line-oriented JSON mode for agents/bots. It -// talks to the self-hosted Mailu stack (Dovecot IMAP + Postfix submission) at -// mail.profullstack.com / smtp.profullstack.com. +// Package mailbox is the BBS-side mail client for members (a free benefit of +// membership): a transport-agnostic core (read, search, compose, send, flag, +// delete) with a Bubble Tea TUI for humans and a line-oriented JSON mode for +// agents/bots. Addresses are @bbs.profullstack.com; it talks to the +// self-hosted Mailu stack (Dovecot IMAP + Postfix submission) hosted on +// mail.profullstack.com. // // The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are // deliberately the same so tooling can move between them. diff --git a/internal/mailu/mailu.go b/internal/mailu/mailu.go new file mode 100644 index 0000000..32a066d --- /dev/null +++ b/internal/mailu/mailu.go @@ -0,0 +1,197 @@ +// Package mailu provisions member mailboxes on the self-hosted Mailu stack via +// its admin REST API. Every verified AgentBBS member gets a real mailbox at +// @ (e.g. alice@bbs.profullstack.com); the agentbbs gateway then +// opens it over IMAP with the Dovecot master user, so members never manage an +// IMAP/SMTP password. Mailbox creation is the one thing that must happen up +// front, which is what EnsureUser does (idempotently). +// +// The Mailu admin API listens on the loopback HTTP front (default +// http://127.0.0.1:8080) and authenticates with the token set as API_TOKEN in +// mailu.env. When no token is configured Configured() reports false and callers +// skip provisioning (the address is still shown). +// +// Config (env): +// +// AGENTBBS_MAIL_ADMIN_URL Mailu admin base URL (default http://127.0.0.1:8080) +// AGENTBBS_MAIL_API_TOKEN Mailu API token (from mailu.env API_TOKEN) +// AGENTBBS_MAIL_QUOTA_BYTES per-mailbox quota in bytes (default 1 GiB) +package mailu + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// DefaultQuotaBytes is the per-mailbox storage quota when unset (1 GiB). +const DefaultQuotaBytes = 1 << 30 + +// Config holds the Mailu admin-API endpoint and credentials. +type Config struct { + BaseURL string + Token string + QuotaBytes int64 + HTTP *http.Client +} + +// ConfigFromEnv reads the Mailu admin settings from the environment. +func ConfigFromEnv() Config { + q, _ := strconv.ParseInt(os.Getenv("AGENTBBS_MAIL_QUOTA_BYTES"), 10, 64) + if q <= 0 { + q = DefaultQuotaBytes + } + base := os.Getenv("AGENTBBS_MAIL_ADMIN_URL") + if base == "" { + base = "http://127.0.0.1:8080" + } + return Config{ + BaseURL: strings.TrimRight(base, "/"), + Token: os.Getenv("AGENTBBS_MAIL_API_TOKEN"), + QuotaBytes: q, + HTTP: &http.Client{Timeout: 15 * time.Second}, + } +} + +// Client talks to the Mailu admin REST API. +type Client struct { + cfg Config +} + +// New builds a client. NewFromEnv is the usual entry point. +func New(cfg Config) *Client { + if cfg.HTTP == nil { + cfg.HTTP = &http.Client{Timeout: 15 * time.Second} + } + if cfg.QuotaBytes <= 0 { + cfg.QuotaBytes = DefaultQuotaBytes + } + return &Client{cfg: cfg} +} + +// NewFromEnv builds a client from the environment. +func NewFromEnv() *Client { return New(ConfigFromEnv()) } + +// Configured reports whether mailboxes can actually be provisioned. +func (c *Client) Configured() bool { + return c != nil && c.cfg.Token != "" && c.cfg.BaseURL != "" +} + +func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.cfg.BaseURL+"/api/v1"+path, rdr) + if err != nil { + return nil, err + } + // Mailu authenticates the admin API with the raw token in Authorization. + req.Header.Set("Authorization", c.cfg.Token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return c.cfg.HTTP.Do(req) +} + +// UserExists reports whether email already has a mailbox. +func (c *Client) UserExists(ctx context.Context, email string) (bool, error) { + resp, err := c.do(ctx, http.MethodGet, "/user/"+email, nil) + if err != nil { + return false, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + switch { + case resp.StatusCode == http.StatusOK: + return true, nil + case resp.StatusCode == http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("mailu user lookup %s: %s", email, resp.Status) + } +} + +// EnsureUser creates email@... if it doesn't exist. It is idempotent: an +// existing mailbox (or an "already exists" create response) is success. The +// generated password is unused by members — the gateway master user opens every +// mailbox — but Mailu requires one at creation time. +func (c *Client) EnsureUser(ctx context.Context, localPart, domain string) error { + if !c.Configured() { + return fmt.Errorf("mailu not configured") + } + email := localPart + "@" + domain + exists, err := c.UserExists(ctx, email) + if err != nil { + return err + } + if exists { + return nil + } + pw, err := randomPassword() + if err != nil { + return err + } + payload := map[string]any{ + "email": email, + "raw_password": pw, + "comment": "agentbbs member", + "quota_bytes": c.cfg.QuotaBytes, + "enabled": true, + } + resp, err := c.do(ctx, http.MethodPost, "/user", payload) + if err != nil { + return err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + // A concurrent create / pre-existing mailbox is fine. + if resp.StatusCode == http.StatusConflict || + strings.Contains(strings.ToLower(string(b)), "already exists") { + return nil + } + return fmt.Errorf("mailu create user %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b))) +} + +// SetPassword sets the mailbox password (so the member can log into webmail). +// The gateway opens mailboxes via the Dovecot master user and never needs this, +// but webmail (Roundcube) requires the member to have a known password. +func (c *Client) SetPassword(ctx context.Context, localPart, domain, password string) error { + if !c.Configured() { + return fmt.Errorf("mailu not configured") + } + email := localPart + "@" + domain + resp, err := c.do(ctx, http.MethodPatch, "/user/"+email, map[string]any{"raw_password": password}) + if err != nil { + return err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + return fmt.Errorf("mailu set password %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b))) +} + +func randomPassword() (string, error) { + var b [24]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/mailu/mailu_test.go b/internal/mailu/mailu_test.go new file mode 100644 index 0000000..912119e --- /dev/null +++ b/internal/mailu/mailu_test.go @@ -0,0 +1,124 @@ +package mailu + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestConfigured(t *testing.T) { + if New(Config{BaseURL: "http://x"}).Configured() { + t.Fatal("no token should be unconfigured") + } + if !New(Config{BaseURL: "http://x", Token: "tok"}).Configured() { + t.Fatal("token should be configured") + } + var nilc *Client + if nilc.Configured() { + t.Fatal("nil client must be unconfigured") + } +} + +func TestEnsureUserCreatesWhenMissing(t *testing.T) { + var created map[string]any + var sawToken string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawToken = r.Header.Get("Authorization") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/user/"): + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/user": + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &created) + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL, Token: "secret-tok"}) + if err := c.EnsureUser(context.Background(), "alice", "bbs.profullstack.com"); err != nil { + t.Fatal(err) + } + if sawToken != "secret-tok" { + t.Fatalf("token header = %q", sawToken) + } + if created["email"] != "alice@bbs.profullstack.com" { + t.Fatalf("created email = %v", created["email"]) + } + if created["raw_password"] == nil || created["raw_password"] == "" { + t.Fatal("expected a generated password") + } +} + +func TestEnsureUserIdempotentWhenExists(t *testing.T) { + posted := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + return + } + posted = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL, Token: "t"}) + if err := c.EnsureUser(context.Background(), "bob", "bbs.profullstack.com"); err != nil { + t.Fatal(err) + } + if posted { + t.Fatal("should not POST when the mailbox already exists") + } +} + +func TestEnsureUserConflictIsSuccess(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 + } + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"message":"already exists"}`) + })) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL, Token: "t"}) + if err := c.EnsureUser(context.Background(), "carol", "bbs.profullstack.com"); err != nil { + t.Fatalf("conflict should be treated as success, got %v", err) + } +} + +func TestEnsureUserUnconfigured(t *testing.T) { + if err := New(Config{}).EnsureUser(context.Background(), "x", "y"); err == nil { + t.Fatal("expected error when unconfigured") + } +} + +func TestSetPassword(t *testing.T) { + var method, path string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL, Token: "t"}) + if err := c.SetPassword(context.Background(), "alice", "bbs.profullstack.com", "hunter2"); err != nil { + t.Fatal(err) + } + if method != http.MethodPatch || path != "/api/v1/user/alice@bbs.profullstack.com" { + t.Fatalf("got %s %s", method, path) + } + if body["raw_password"] != "hunter2" { + t.Fatalf("raw_password = %v", body["raw_password"]) + } +} diff --git a/setup.sh b/setup.sh index 7ac7327..ea5847f 100755 --- a/setup.sh +++ b/setup.sh @@ -368,10 +368,11 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # AGENTBBS_SIGNUP_NOTIFY=anthony@profullstack.com # Membership model: -# Free verified members get their own Docker pod (ssh pod@) and a homepage -# at https://${DOMAIN}/~. -# Premium \$10 one-time, lifetime — a personal @${DOMAIN} email -# (forwardemail.net) plus custom domains (ssh domain@). Offered at join@. +# Free verified members get their own Docker pod (ssh pod@), a homepage at +# https://${DOMAIN}/~, AND a real mailbox @${DOMAIN} on the +# self-hosted Mailu stack (read it in the hub's "Mail" or via webmail). +# Premium \$10 one-time, lifetime — custom domains (ssh domain@) + a Tor shell. +# Offered at join@. # Premium payments hit the CoinPay REST API directly (no coinpay CLI needed): # join@ creates a charge and shows the amount + deposit address; a later connect @@ -385,11 +386,23 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # AGENTBBS_PREMIUM_CURRENCY=USD # AGENTBBS_PREMIUM_BLOCKCHAIN=eth -# Premium email aliases (@${DOMAIN}) auto-created on forwardemail.net. -# Without an API key the address is shown but not created (add it manually). -# AGENTBBS_FORWARDEMAIL_API_KEY= -# AGENTBBS_FORWARDEMAIL_DOMAIN=${DOMAIN} -# AGENTBBS_WEBMAIL_URL=https://webmail.${DOMAIN} +# Member email (free for every verified member). Addresses are @${DOMAIN} +# (the address domain), while the Mailu server lives on the mail host below. +# Mailboxes are auto-provisioned at join@ via the Mailu admin REST API: set the +# API token (API_TOKEN in deploy/mailu/mailu.env). Without it the address is +# shown but not created. See docs/mail.md. +# AGENTBBS_MAIL_ADDR_DOMAIN=${DOMAIN} # the @-part of member addresses +# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080 # Mailu admin (loopback) +# AGENTBBS_MAIL_API_TOKEN= +# AGENTBBS_MAIL_QUOTA_BYTES=1073741824 # 1 GiB per mailbox +# AGENTBBS_WEBMAIL_URL=https://${MAIL_DOMAIN} # Roundcube (defaults to mail host) +# The in-BBS mail reader opens mailboxes via a Dovecot master user, reaching +# Dovecot directly over loopback (plaintext, on-host) to bypass Mailu's front +# auth proxy. §9e sets these; the master pass is a secret (see docs/mail.md): +# AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143 +# AGENTBBS_MAIL_IMAP_PLAINTEXT=1 +# AGENTBBS_MAIL_MASTER_USER=gateway +# AGENTBBS_MAIL_MASTER_PASS= # AgentGit (git.profullstack.com): every verified member — free and paid alike — # is provisioned a Forgejo account when they confirm their email. The admin token @@ -1059,18 +1072,25 @@ else systemctl disable --now forgejo >/dev/null 2>&1 || true fi -# ---- 9e. Mailu mail stack (co-located mail.${DOMAIN#*.}) -------------------- +# ---- 9e. Mailu mail stack (server on ${MAIL_DOMAIN}) ------------------------ # Self-hosted Postfix+Dovecot+Roundcube+rspamd via Docker Compose. Mailu owns # the mail ports; Caddy fronts the loopback webmail and supplies the TLS cert -# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of paid members. Full setup, -# DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0. +# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of EVERY verified member +# (free + paid) — addresses are @${DOMAIN}, the server is ${MAIL_DOMAIN}. +# Full setup, DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0. MAILU_DIR="${SRC_DIR}/deploy/mailu" if [ "$MAIL" = "1" ]; then - log "configuring Mailu mail stack (${MAIL_DOMAIN})" - # Tell agentbbs how to reach the mailbox backend (master user/pass are secrets - # the operator sets; see docs/mail.md). + log "configuring Mailu mail stack (server ${MAIL_DOMAIN}, addresses @${DOMAIN})" + # Tell agentbbs how to reach the mailbox backend (master user/pass + the Mailu + # API token are secrets the operator sets; see docs/mail.md). upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}" - upsert_env AGENTBBS_MAIL_IMAP_ADDR "${MAIL_DOMAIN}:993" + upsert_env AGENTBBS_MAIL_ADDR_DOMAIN "${DOMAIN}" + # The gateway reads Dovecot DIRECTLY over loopback (docker-compose.override.yml + # publishes it on 127.0.0.1:14143), bypassing Mailu's front nginx auth proxy so + # the master-user login works. Plaintext is safe — it never leaves the host. + # See docs/mail.md ("Why the gateway talks to Dovecot directly"). + upsert_env AGENTBBS_MAIL_IMAP_ADDR "127.0.0.1:14143" + upsert_env AGENTBBS_MAIL_IMAP_PLAINTEXT "1" upsert_env AGENTBBS_MAIL_SMTP_ADDR "127.0.0.1:25" # Cert refresher: copy Caddy's mail cert into Mailu on renewal (like news/IRC).