From fade1649b4334a3da81c1f053e956bf03625f6ac Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 15:22:12 +0000 Subject: [PATCH] One login: pod/IRC/news/Tor as hub menu items + single-login onboarding The join@ output and hub previously pushed members to ssh into separate servers (ssh pod@, ssh irc@, ssh news@, ssh tor@). Members now reach everything from one `ssh @bbs.profullstack.com` login: - hub: new SessionApp entries (Pod, IRC, News, Tor shell) run as terminal-takeover features via tea.Exec, then return to the menu. Gated by membership/email-verification/plan; shown locked otherwise. - main: build the session apps in teaHandler; extract runIRC/runNews so the irc@/news@ routes and the hub share one path. The old pod@/irc@/ news@/tor@ routes stay as aliases (handy for bots). - onboarding: rewrite the "You're in" and Founding-Member copy to present ONE login and stop advertising separate servers. - email: member mailboxes move to mail.profullstack.com (apex reserved for corporate mail); webmail shown at https://mail.profullstack.com. - username: harden the default handle to a safe /home/ token. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 202 ++++++++++++++++++++++++++++++++++--------- internal/hub/hub.go | 113 +++++++++++++++++++----- 2 files changed, 252 insertions(+), 63 deletions(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 20d9c88..56e319b 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -150,7 +150,9 @@ func main() { host := env("AGENTBBS_HOST", "bbs.profullstack.com") fe := forwardemail.ConfigFromEnv() if fe.Domain == "" { - fe.Domain = host // personal addresses live on the BBS host by default + // 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") } a := &app{ st: st, @@ -332,7 +334,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { username := strings.ToLower(s.User()) var u auth.User - if auth.IsGuestName(username) || fp == "" { + var su store.User + guest := auth.IsGuestName(username) || fp == "" + if guest { // Keyless or explicitly anonymous → guest. Named accounts require a key. if !auth.IsGuestName(username) { wish.Println(s, "note: member access requires an SSH key; joining as guest.") @@ -341,7 +345,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { } else { // A key maps to exactly one account: if this key is already // registered, that identity wins regardless of the username typed. - su, found, err := a.st.UserByFingerprint(fp) + var found bool + var err error + su, found, err = a.st.UserByFingerprint(fp) if err == nil && !found { su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp) } @@ -377,7 +383,88 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { // URL works the moment a member first signs in. seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host) } - return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()} + return hub.New(u, ctx, a.enabledPlugins(), a.sessionApps(s, su, guest)), []tea.ProgramOption{tea.WithAltScreen()} +} + +// sessionExec adapts a func to tea.ExecCommand so the hub can run a +// terminal-takeover feature (pod shell, IRC, news, mail, Tor) via tea.Exec and +// return to the menu afterwards. The feature reads and writes the ssh.Session +// directly, so the stream hooks are no-ops. +type sessionExec struct{ run func() error } + +func (e sessionExec) Run() error { return e.run() } +func (e sessionExec) SetStdin(io.Reader) {} +func (e sessionExec) SetStdout(io.Writer) {} +func (e sessionExec) SetStderr(io.Writer) {} + +// sessionApps builds the hub's terminal-takeover entries (pod, IRC, news, Tor) +// so a member reaches everything from one `ssh @host` login instead of +// separate `ssh pod@`/`irc@`/`news@`/`tor@` connections (which still work as +// aliases, mainly for bots). Each entry is gated by membership/verification/plan +// and shown locked with a reason when unavailable. +func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.SessionApp { + membersOnly := "members only — register first: ssh join@" + a.host + apps := make([]hub.SessionApp, 0, 4) + + // Pod — free for verified members. + podLock := "" + switch { + case guest: + podLock = membersOnly + case env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !su.EmailVerified: + podLock = "confirm your email first — re-run: ssh join@" + a.host + case a.pods == nil: + podLock = "pods are temporarily unavailable on this host" + } + apps = append(apps, hub.SessionApp{ + Title: "Pod", + Description: "your own Linux pod — a full shell", + Locked: podLock, + Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }}, + }) + + // IRC — free for any registered member. + ircLock := "" + if guest { + ircLock = membersOnly + } + apps = append(apps, hub.SessionApp{ + Title: "IRC", + Description: "members-only chat network (humans + agents)", + Locked: ircLock, + Cmd: sessionExec{run: func() error { return a.runIRC(s, su.Name) }}, + }) + + // News — free for any registered member. + newsLock := "" + if guest { + newsLock = membersOnly + } + apps = append(apps, hub.SessionApp{ + Title: "News", + Description: "members-only Usenet/NNTP discussion", + Locked: newsLock, + Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }}, + }) + + // Tor — a Founding Lifetime Member perk: a torsocks shell in the pod. + torLock := "" + switch { + case guest: + torLock = membersOnly + case !su.Premium: + torLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host + case a.pods == nil: + torLock = "pods are temporarily unavailable on this host" + } + apps = append(apps, hub.SessionApp{ + Title: "Tor shell", + Description: "a torsocks shell in your pod (everything over Tor)", + Locked: torLock, + Cmd: sessionExec{run: func() error { return a.pods.Exec(s, su.Name, tor.Torsocks([]string{"bash", "-l"})) }}, + }) + + return apps } // readLine reads one line of interactive input from an SSH session that is @@ -464,7 +551,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 (ssh pod@) and a /~name homepage. + // member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub. if !u.EmailVerified { if !a.verifyEmailInteractive(s, in, &u) { _ = s.Exit(1) @@ -477,10 +564,15 @@ func (a *app) handleJoin(s ssh.Session) { seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host) wish.Println(s, "\n"+strings.Join([]string{ - " You're in — free membership includes:", - " pod ssh pod@" + a.host + " your own Linux pod", - " hub ssh " + u.Name + "@" + a.host, - " homepage https://" + a.host + "/~" + u.Name, + " 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)", + " • IRC chat + Usenet/news (members-only)", + " • the arcade & games", + " • your homepage https://" + a.host + "/~" + u.Name, }, "\n")) // 2) Founding Lifetime ($99 one-time): personal @host email + custom domains. @@ -515,14 +607,35 @@ func (a *app) acceptTerms(s ssh.Session, in *bufio.Reader) bool { } } +// fpToken derives up to n lowercase alphanumeric characters from an SSH key +// fingerprint, for use in a default username/home-dir. The raw base64 +// fingerprint can contain '+' and '/', which are unsafe as a filesystem token, +// so we keep only [a-z0-9]. +func fpToken(fp string, n int) string { + cleaned := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + return r + default: + return -1 + } + }, strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))) + if len(cleaned) > n { + cleaned = cleaned[:n] + } + return cleaned +} + // registerNewMember asks the visitor to choose a username, then creates their // member account under it. The name is sanitized to the hub/subdomain charset, // rejected if reserved, and must be free; pressing enter accepts a generated -// member- default. Returns the created user. +// member- default. The chosen name is also the member's pod home +// (/home/), so it must be a safe shell/filesystem token — the sanitizer +// (and the default below) keep it to lowercase [a-z0-9-]. Returns the user. func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) { - def := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8] + def := "member-" + fpToken(fp, 8) wish.Println(s, "\n Pick a username — letters, numbers and dashes, 3–20 chars.") - wish.Println(s, " It's your handle for ssh @"+a.host+" and https://"+a.host+"/~.") + wish.Println(s, " It's your handle for ssh @"+a.host+" (your pod home is /home/).") for tries := 0; tries < 5; tries++ { wish.Print(s, "\n Username ["+def+"]: ") @@ -651,23 +764,19 @@ func (a *app) ensurePremium(u *store.User) bool { return true } -// showPremiumWelcome prints a premium member's perks: their personal email, -// where it forwards, the webmail URL, and custom domains. +// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail +// URL, the in-hub Mail/Tor entries, and custom domains. func (a *app) showPremiumWelcome(s ssh.Session, u store.User) { lines := []string{ "", " ★ Founding Lifetime Member — thanks! Your perks:", "", - " email " + a.fe.Address(u.Name), - " forwards " + u.Email, - } - if url := a.fe.WebmailURL(); url != "" { - lines = append(lines, " webmail "+url) - } - lines = append(lines, - " domains ssh domain@"+a.host+" add ", + " 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 ", "", - ) + } wish.Println(s, strings.Join(lines, "\n")) } @@ -691,10 +800,10 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) { "", " Everything in your free membership stays free — founding adds these", " bonus features, forever:", - " • your own email " + a.fe.Address(u.Name) + " (forwards to you, webmail included)", - " • custom domains point yourdomain.com at your homepage", - " • Tor access ssh tor@" + a.host + " — fetch URLs & join IRC over Tor", - " • locked-in price founding rate is yours for life — never renew, never pay again", + " • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")", + " • 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", "", } if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil { @@ -1071,21 +1180,28 @@ func (a *app) handleIRC(s ssh.Session) { sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc") defer func() { _ = a.st.EndSession(sessID) }() + // Premium members may /create channels; free members can still join existing + // ones. ensurePremium also settles any pending charge. + if err := a.runIRC(s, u.Name); err != nil { + wish.Println(s, "irc: "+err.Error()) + _ = s.Exit(1) + } +} + +// runIRC dials the members-only IRC network as name and runs the in-process +// client TUI on the session. Shared by the irc@ route and the hub's IRC entry. +func (a *app) runIRC(s ssh.Session, name string) error { addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR")) if addr == "" { addr = irc.DefaultAddr } - log.Info("irc connect", "user", u.Name, "addr", addr) - c, err := irc.Dial(s.Context(), addr, u.Name) + log.Info("irc connect", "user", name, "addr", addr) + c, err := irc.Dial(s.Context(), addr, name) if err != nil { - wish.Println(s, "irc: "+err.Error()) - _ = s.Exit(1) - return + return err } _ = c.Join(irc.DefaultChannel) - if err := irc.Run(s, c); err != nil { - wish.Println(s, "irc: "+err.Error()) - } + return irc.Run(s, c) } // handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server @@ -1114,15 +1230,21 @@ func (a *app) handleNews(s ssh.Session) { sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news") defer func() { _ = a.st.EndSession(sessID) }() + if err := a.runNews(s, u.Name); err != nil { + wish.Println(s, "news: "+err.Error()) + _ = s.Exit(1) + } +} + +// runNews runs the in-process newsreader TUI for name on the session. Shared by +// the news@ route and the hub's News entry. +func (a *app) runNews(s ssh.Session, name string) error { addr := a.newsAddr if addr == "" { addr = news.DefaultAddr } - log.Info("news connect", "user", u.Name, "addr", addr) - if err := news.RunReader(s, addr, u.Name); err != nil { - wish.Println(s, "news: "+err.Error()) - _ = s.Exit(1) - } + log.Info("news connect", "user", name, "addr", addr) + return news.RunReader(s, addr, name) } // handleTorCmd runs an arbitrary command through Tor (torsocks) inside the diff --git a/internal/hub/hub.go b/internal/hub/hub.go index b23bb20..c06c5bc 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -1,6 +1,13 @@ // Package hub is the Bubble Tea model every BBS session lands in (PRD §4.1): // it lists registered plugins and routes the session to the selection, // reclaiming it when the plugin emits ExitMsg. +// +// Besides in-hub plugins, the hub also lists "session apps" — features that take +// over the whole terminal (a pod shell, the IRC client, the newsreader, the mail +// reader, Tor) rather than rendering inside the hub. Selecting one suspends the +// hub via tea.Exec, runs it, and returns to the menu. This is what lets a member +// reach everything from a single `ssh @host` login instead of separate +// `ssh pod@` / `ssh irc@` / `ssh news@` connections. package hub import ( @@ -21,11 +28,30 @@ var ( frameStyle = lipgloss.NewStyle().Padding(1, 2) ) +// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC +// client, the newsreader, the mail reader, or Tor — instead of running as an +// in-hub model. Selecting it suspends the hub (tea.Exec), runs Cmd, then returns +// to the menu. +type SessionApp struct { + Title string + Description string + // Locked, when non-empty, marks the entry visible-but-unavailable (e.g. a + // paid feature for a free member, or members-only for a guest). The reason + // is shown when the entry is selected; the app cannot be launched. + Locked string + // Cmd is the terminal-takeover command run under tea.Exec. + Cmd tea.ExecCommand +} + +// appExitMsg is delivered when a SessionApp launched via tea.Exec returns. +type appExitMsg struct{ err error } + // Model is the hub menu. type Model struct { user auth.User ctx plugin.Context plugins []plugin.Plugin + apps []SessionApp cursor int active tea.Model @@ -34,19 +60,31 @@ type Model struct { note string } -// New builds a hub for one session. -func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin) Model { - return Model{user: user, ctx: ctx, plugins: plugins} +// New builds a hub for one session. apps are terminal-takeover features listed +// after the in-hub plugins (may be nil). +func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin, apps []SessionApp) Model { + return Model{user: user, ctx: ctx, plugins: plugins, apps: apps} } func (m Model) Init() tea.Cmd { return nil } +// entries is the total number of selectable rows (plugins then apps). +func (m Model) entries() int { return len(m.plugins) + len(m.apps) } + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Window size is shared with whichever model is active. if ws, ok := msg.(tea.WindowSizeMsg); ok { m.width, m.height = ws.Width, ws.Height } + // A returning session app (tea.Exec finished) hands control back here. + if ae, ok := msg.(appExitMsg); ok { + if ae.err != nil { + m.note = ae.err.Error() + } + return m, nil + } + // A plugin owns the session until it emits ExitMsg (PRD §4.3). if m.active != nil { if _, ok := msg.(plugin.ExitMsg); ok { @@ -69,44 +107,64 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor-- } case "down", "j": - if m.cursor < len(m.plugins)-1 { + if m.cursor < m.entries()-1 { m.cursor++ } case "enter": - p := m.plugins[m.cursor] - if p.RequiresAuth() && m.user.Kind == auth.Guest { - m.note = "members only — ssh join@ to register" - return m, nil - } - m.active = p.New(m.user, m.ctx) - cmds := []tea.Cmd{m.active.Init()} - if m.width > 0 { - next, cmd := m.active.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - m.active = next - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) + return m.selectEntry() } } return m, nil } +// selectEntry launches whatever the cursor is on: an in-hub plugin or a session app. +func (m Model) selectEntry() (tea.Model, tea.Cmd) { + if m.cursor < len(m.plugins) { + p := m.plugins[m.cursor] + if p.RequiresAuth() && m.user.Kind == auth.Guest { + m.note = "members only — ssh join@ to register" + return m, nil + } + m.active = p.New(m.user, m.ctx) + cmds := []tea.Cmd{m.active.Init()} + if m.width > 0 { + next, cmd := m.active.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.active = next + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + + app := m.apps[m.cursor-len(m.plugins)] + if app.Locked != "" { + m.note = app.Locked + return m, nil + } + return m, tea.Exec(app.Cmd, func(err error) tea.Msg { return appExitMsg{err} }) +} + func (m Model) View() string { if m.active != nil { return m.active.View() } who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind) s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n" - for i, p := range m.plugins { - cur := " " - if i == m.cursor { - cur = cursorStyle.Render("> ") - } + row := 0 + for _, p := range m.plugins { label := p.Title() if p.RequiresAuth() && m.user.Kind == auth.Guest { label += lockStyle.Render(" [members]") } - s += fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(p.Description())) + s += m.renderRow(row, label, p.Description()) + row++ + } + for _, app := range m.apps { + label := app.Title + if app.Locked != "" { + label += lockStyle.Render(" [locked]") + } + s += m.renderRow(row, label, app.Description) + row++ } s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit") if m.note != "" { @@ -114,3 +172,12 @@ func (m Model) View() string { } return frameStyle.Render(s) } + +// renderRow renders one menu line with the cursor and dimmed description. +func (m Model) renderRow(i int, label, desc string) string { + cur := " " + if i == m.cursor { + cur = cursorStyle.Render("> ") + } + return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc)) +}