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/.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/README.md b/README.md index 031b832..c9ce72a 100644 --- a/README.md +++ b/README.md @@ -114,17 +114,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 diff --git a/cmd/agentbbs/admin.go b/cmd/agentbbs/admin.go index 79f8422..c0ef853 100644 --- a/cmd/agentbbs/admin.go +++ b/cmd/agentbbs/admin.go @@ -77,11 +77,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 b97fb87..4442689 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -8,7 +8,10 @@ // 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 admin@host the operator admin console ($AGENTBBS_ADMINS only) +// 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; +// 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 // @@ -28,6 +31,7 @@ import ( "context" "crypto/rand" "encoding/binary" + "encoding/hex" "errors" "fmt" "io" @@ -57,11 +61,11 @@ import ( "github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/files" "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,6 +77,8 @@ import ( "github.com/profullstack/agentbbs/plugins/about" "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" ) @@ -94,22 +100,25 @@ 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) - files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0) - 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) + files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0) + 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 @@ -152,28 +161,34 @@ 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, 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{}, hello.Plugin{}} // Files (SFTP): per-user workspaces + a shared public area, reached over the // :22 listener via `sftp files@` (docs/files.md). Disable with @@ -346,6 +361,8 @@ func (a *app) router() wish.Middleware { a.handleMail(s) case auth.IsFilesAdminName(user): filesAdminHandler(s) + case auth.IsMsgName(user): + a.handleMsg(s) case isVideo: a.handleVideo(s, code) case user == "agent": @@ -373,7 +390,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. @@ -417,12 +438,22 @@ 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") 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 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) @@ -492,19 +523,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 @@ -619,7 +653,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) @@ -628,22 +662,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) } @@ -793,7 +846,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 } @@ -803,10 +856,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 @@ -823,33 +877,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@). @@ -866,9 +912,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", @@ -968,7 +1014,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+"."))) } @@ -978,7 +1024,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 } @@ -990,6 +1036,40 @@ 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) + } + } +} + +// gitWelcomeEmailBody is the plain-text email sent by the notify-creds ops +// command when a member's AgentGit (Forgejo) account is created or reset: web +// login link, username, and the one-time password to change on first sign-in. +func gitWelcomeEmailBody(name, password, loginURL string) string { + return "Hi " + name + ",\n\n" + + "Your git account is ready. Sign in to the web interface here:\n\n" + + " " + loginURL + "\n\n" + + " username: " + name + "\n" + + " password: " + password + "\n\n" + + "You'll be asked to set a new password the first time you sign in.\n" + + "After that, click the \"+\" (top right) → \"New Repository\" to create repos.\n\n" + + "Pushing over git uses your registered SSH key — no password needed.\n\n" + + "If you didn't request this, you can ignore this email.\n" +} + +// 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. @@ -1289,29 +1369,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 @@ -1335,11 +1481,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) }() @@ -1449,6 +1602,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/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/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 a194ad7..f119bb9 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} @@ -112,6 +113,13 @@ func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] } // management TUI (operator-gated). func IsFilesAdminName(u string) bool { return FilesAdminNames[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. @@ -129,7 +137,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] || - MailNames[n] || FilesAdminNames[n] || systemReserved[n] { + MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || systemReserved[n] { return true } return strings.HasPrefix(n, "video-") // video- call routes diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 7a70ed5..fc8af5f 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/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/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/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) 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/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 } 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/plugin/plugin.go b/internal/plugin/plugin.go index 4f32b24..05efeb6 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -22,6 +22,14 @@ 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 + // 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/internal/pods/pods.go b/internal/pods/pods.go index 0b10acb..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" @@ -87,6 +90,69 @@ 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) +} + +// 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 } @@ -102,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 @@ -131,11 +200,17 @@ 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")) || + (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 { _ = exec.Command(m.engine, "start", name).Run() // no-op if running - m.tuneApt(name) return name, nil } } @@ -154,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 @@ -178,28 +256,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 { @@ -212,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/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 diff --git a/internal/store/store.go b/internal/store/store.go index 09946dc..7dfb75e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5,6 +5,8 @@ package store import ( "database/sql" "errors" + "fmt" + "strings" "time" _ "modernc.org/sqlite" @@ -41,10 +43,22 @@ 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 } +// 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 +114,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). @@ -506,6 +535,15 @@ CREATE TABLE IF NOT EXISTS files_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' ); +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) { @@ -516,8 +554,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 } @@ -721,6 +763,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/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..ad26ea3 100644 --- a/plugins/arcade/arcade.go +++ b/plugins/arcade/arcade.go @@ -1,37 +1,67 @@ // 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, hangman) 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) 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) } +// 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,44 +75,89 @@ 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: "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() }, }, @@ -116,24 +191,93 @@ 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 +} + +// 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.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) + cmd.Env = m.gameEnv(work) 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...) + 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} + }) +} + +type gameDoneMsg struct { + name string + err error +} func (m *menu) Init() tea.Cmd { return nil } @@ -151,9 +295,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 +324,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..702fb3c 100644 --- a/plugins/arcade/board.go +++ b/plugins/arcade/board.go @@ -4,24 +4,27 @@ 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). +// 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,17 +45,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 — "+b.game) + "\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/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)) + } +} 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.") +} 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)) + } +} 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/pods/Containerfile b/pods/Containerfile new file mode 100644 index 0000000..e679998 --- /dev/null +++ b/pods/Containerfile @@ -0,0 +1,57 @@ +# 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 + +# 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/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 41ba5df..485fb81 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 @@ -52,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; } @@ -119,8 +121,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" \ @@ -166,15 +171,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" @@ -191,8 +292,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). @@ -210,6 +313,27 @@ 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" + 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 +fi + # ---- 6. environment file --------------------------------------------------- ENV_DIR=/etc/agentbbs install -d -m 0750 "$ENV_DIR" @@ -244,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 @@ -261,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 @@ -333,6 +470,14 @@ 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). 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:-}}" @@ -839,8 +984,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 @@ -851,7 +1003,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] @@ -897,6 +1052,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. @@ -907,7 +1065,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}" @@ -921,18 +1079,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).