Merge branch 'feat/files-sftp' into feat/notify-creds

# Conflicts:
#	cmd/agentbbs/main.go
This commit is contained in:
Anthony Ettinger 2026-06-23 10:46:00 +00:00
commit 95d1afb59a
45 changed files with 2924 additions and 389 deletions

View file

@ -1,10 +1,18 @@
name: deploy name: deploy
# Fully autonomous, idempotent deploy. On every push to main (or manual # Fully autonomous, idempotent deploy. On every push to main (or manual
# dispatch) this SSHes to the bbs.profullstack.com droplet and re-runs the # dispatch) this builds the Go binaries ON THE RUNNER (which has plenty of
# idempotent provisioner (setup.sh), which pulls origin, rebuilds the Go # RAM), ships them to the bbs.profullstack.com droplet, and re-runs the
# binaries, and restarts the agentbbs service that answers # idempotent provisioner (setup.sh) with SKIP_BUILD=1 so the tiny droplet
# `ssh join@bbs.profullstack.com`. Re-running is always safe. # 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): # Required repo secrets (Settings -> Secrets and variables -> Actions):
# DEPLOY_SSH_KEY private key whose public half is in the droplet admin # DEPLOY_SSH_KEY private key whose public half is in the droplet admin
@ -30,6 +38,8 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
- name: Configure SSH - name: Configure SSH
env: env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
@ -43,7 +53,55 @@ jobs:
chmod 600 ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy
ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null 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: env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }}
@ -76,7 +134,11 @@ jobs:
fi fi
git -C "$SRC" fetch --depth 1 origin "$BRANCH" git -C "$SRC" fetch --depth 1 origin "$BRANCH"
git -C "$SRC" reset --hard "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_API_KEY="${COINPAY_API_KEY:-}" \
COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \ COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \
AGENTBBS_QRYPT_ISSUER_KEY="${AGENTBBS_QRYPT_ISSUER_KEY:-}" \ AGENTBBS_QRYPT_ISSUER_KEY="${AGENTBBS_QRYPT_ISSUER_KEY:-}" \

6
.gitignore vendored
View file

@ -6,5 +6,11 @@
*.log *.log
.env .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 Code local worktrees/state
.claude/ .claude/

View file

@ -123,17 +123,18 @@ name is an existing AgentBBS member (registration is off — your BBS account *i
your IRC identity): your IRC identity):
```bash ```bash
# zero-setup: built-in client over SSH (members only) # native TLS client — SASL account = your BBS member name
ssh -t irc@bbs.profullstack.com
# native client — SASL account = your BBS member name
/connect irc.bbs.profullstack.com 6697 /connect irc.bbs.profullstack.com 6697
# browser / agent over WebSocket # browser / agent over WebSocket
wss://bbs.profullstack.com/irc wss://bbs.profullstack.com/irc
``` ```
`ssh irc@` is a built-in IRC client (`internal/irc`) that authenticates you to Members connect with **their own IRC client** (or a web client) — there is no
the network automatically — no client to install. Set `IRC=0` to skip the in-BBS `ssh irc@` route. The network is **members-only** and every client must
server. Full details: [`docs/irc.md`](docs/irc.md). 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 ### News (Usenet) server

View file

@ -77,11 +77,12 @@ func (r *liveReg) List() []admin.Live {
// Kill closes a live session by id. Returns false if it is already gone. // Kill closes a live session by id. Returns false if it is already gone.
func (r *liveReg) Kill(id int64) bool { func (r *liveReg) Kill(id int64) bool {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.m[id] e, ok := r.m[id]
r.mu.Unlock()
if !ok { if !ok {
return false return false
} }
delete(r.m, id)
_ = e.s.Close() _ = e.s.Close()
return true return true
} }

View file

@ -8,7 +8,10 @@
// emailed code, then offers $99 Founding Lifetime (CoinPay) // emailed code, then offers $99 Founding Lifetime (CoinPay)
// ssh pod@host your personal Linux pod — free for verified members // 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 domain@host point your own domain at your homepage (Premium; add/rm/list)
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only) // ssh <name>@host (from another account) prints a finger card for that member
// ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin
// ssh 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, // 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 // agent-vs-agent (also on wss://host/play). See docs/agentgames.md
// //
@ -30,6 +33,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/binary" "encoding/binary"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -59,11 +63,11 @@ import (
"github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/files" "github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/forgejo" "github.com/profullstack/agentbbs/internal/forgejo"
"github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/mailbox" "github.com/profullstack/agentbbs/internal/mailbox"
"github.com/profullstack/agentbbs/internal/mailu"
"github.com/profullstack/agentbbs/internal/news" "github.com/profullstack/agentbbs/internal/news"
"github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/payments"
"github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/plugin"
@ -75,6 +79,8 @@ import (
"github.com/profullstack/agentbbs/plugins/about" "github.com/profullstack/agentbbs/plugins/about"
"github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/agentgames"
"github.com/profullstack/agentbbs/plugins/arcade" "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" qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
) )
@ -96,22 +102,25 @@ func envInt(k string, def int) int {
} }
type app struct { type app struct {
st store.Store st store.Store
pods *pods.Manager // nil when no container engine on host pods *pods.Manager // nil when no container engine on host
sites *sites.Manager sites *sites.Manager
registry []plugin.Plugin registry []plugin.Plugin
sandbox *sandbox.Runner sandbox *sandbox.Runner
mail mail.Config mail mail.Config
fe forwardemail.Config // premium @bbs email provisioning mailu *mailu.Client // member mailbox provisioning (nil when unconfigured)
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning mailDomain string // email address domain, e.g. bbs.profullstack.com
live *liveReg // in-memory live-session registry (admin console) mailHost string // mail server host (IMAP/SMTP), e.g. mail.profullstack.com
files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0) webmailURL string // webmail (Roundcube) URL shown to members
gamesReg *games.Registry // AgentGames catalog forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent) live *liveReg // in-memory live-session registry (admin console)
dataDir string files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0)
assets string gamesReg *games.Registry // AgentGames catalog
host string // public hostname used in user-facing messages mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
newsAddr string // loopback NNTP address the news@ reader dials 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 // Version is the agentbbs stack release, surfaced via `agentbbs version` and
@ -158,28 +167,34 @@ func main() {
} }
host := env("AGENTBBS_HOST", "bbs.profullstack.com") host := env("AGENTBBS_HOST", "bbs.profullstack.com")
fe := forwardemail.ConfigFromEnv() // Member email addresses are <name>@<addr-domain> (e.g. bbs.profullstack.com).
if fe.Domain == "" { // The mail server (IMAP/SMTP/webmail) lives on a dedicated host
// Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com), // (mail.profullstack.com); the apex is reserved for corporate mail.
// not the BBS host and not the apex (which is reserved for corporate mail). mailHost := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") mailDomain := env("AGENTBBS_MAIL_ADDR_DOMAIN", host)
mailuClient := mailu.NewFromEnv()
if !mailuClient.Configured() {
mailuClient = nil
} }
a := &app{ a := &app{
st: st, st: st,
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))), sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
mail: mail.ConfigFromEnv(), mail: mail.ConfigFromEnv(),
fe: fe, mailu: mailuClient,
forgejo: forgejo.ConfigFromEnv(), mailDomain: mailDomain,
live: newLiveReg(), mailHost: mailHost,
dataDir: dataDir, webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost),
assets: env("AGENTBBS_ASSETS", "./assets"), forgejo: forgejo.ConfigFromEnv(),
host: host, live: newLiveReg(),
dataDir: dataDir,
assets: env("AGENTBBS_ASSETS", "./assets"),
host: host,
} }
a.gamesReg = games.Catalog() a.gamesReg = games.Catalog()
a.mm = games.NewMatchmaker(a.gamesReg, a.st, a.mm = games.NewMatchmaker(a.gamesReg, a.st,
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second, time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*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 // Files (SFTP): per-user workspaces + a shared public area, reached over the
// :22 listener via `sftp files@<host>` (docs/files.md). Disable with // :22 listener via `sftp files@<host>` (docs/files.md). Disable with
@ -352,6 +367,8 @@ func (a *app) router() wish.Middleware {
a.handleMail(s) a.handleMail(s)
case auth.IsFilesAdminName(user): case auth.IsFilesAdminName(user):
filesAdminHandler(s) filesAdminHandler(s)
case auth.IsMsgName(user):
a.handleMsg(s)
case isVideo: case isVideo:
a.handleVideo(s, code) a.handleVideo(s, code)
case user == "agent": case user == "agent":
@ -379,7 +396,11 @@ func (a *app) hubMOTD(u auth.User) string {
return "You're browsing as a guest.\n" + body + return "You're browsing as a guest.\n" + body +
"\nssh join@" + a.host + " to claim a username, a pod & a homepage." "\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. // teaHandler builds the hub model for guests, members, and agents.
@ -423,12 +444,22 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
// provisions their @host email alias on the transition). // provisions their @host email alias on the transition).
a.ensurePremium(&su) a.ensurePremium(&su)
u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID} 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") sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub")
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }() 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 { if u.Kind != auth.Guest {
ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name) ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name)
_ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755) _ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755)
@ -498,19 +529,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) }}, 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
// <name>@<mailDomain> mailbox.
mailLock := "" mailLock := ""
switch { switch {
case guest: case guest:
mailLock = membersOnly mailLock = membersOnly
case !su.Premium: case !a.mailEnabled():
mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host mailLock = "mail is temporarily unavailable on this host"
} }
apps = append(apps, hub.SessionApp{ apps = append(apps, hub.SessionApp{
Title: "Mail", Title: "Mail",
Description: "your " + a.fe.Domain + " mailbox", Description: "your " + a.mailAddress(su.Name) + " mailbox",
Locked: mailLock, Locked: mailLock,
Cmd: sessionExec{run: func() error { Cmd: sessionExec{run: func() error {
// Make sure the mailbox exists before opening it.
_ = a.ensureMailbox(su)
c, err := a.mailClientFor(su) c, err := a.mailClientFor(su)
if err != nil { if err != nil {
return err return err
@ -625,7 +659,7 @@ func (a *app) handleJoin(s ssh.Session) {
}, "\n")) }, "\n"))
// 1) email -> emailed code -> enter code. A verified account is a free // 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 !u.EmailVerified {
if !a.verifyEmailInteractive(s, in, &u) { if !a.verifyEmailInteractive(s, in, &u) {
_ = s.Exit(1) _ = s.Exit(1)
@ -634,22 +668,41 @@ func (a *app) handleJoin(s ssh.Session) {
a.notifySignup(u) a.notifySignup(u)
} }
// Every verified member gets a homepage at https://<host>/~<name>. // Every verified member gets a homepage at https://<host>/~<name> and a
// mailbox at <name>@<mailDomain> (best-effort; mail is a bonus, never a gate).
seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host) 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:", " You're in. One login gets you everything — no other servers to ssh into:",
"", "",
" ssh " + u.Name + "@" + a.host, " ssh " + u.Name + "@" + a.host,
"", "",
" Inside, free membership includes:", " Inside, free membership includes:",
" • your own Linux pod (a full shell)", " • your own Linux pod (a full shell)",
" • email " + a.mailAddress(u.Name) + " (pick “Mail” in the hub)",
" • IRC chat + Usenet/news (members-only)", " • IRC chat + Usenet/news (members-only)",
" • the arcade & games", " • the arcade & games",
" • your homepage https://" + a.host + "/~" + u.Name, " • 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) a.offerPremium(s, &u)
_ = s.Exit(0) _ = s.Exit(0)
} }
@ -799,7 +852,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
} }
if ok { if ok {
*u = vu *u = vu
a.provisionGit(u) a.provisionGit(u, authorizedKey(s))
wish.Println(s, " Email confirmed ✓") wish.Println(s, " Email confirmed ✓")
return true return true
} }
@ -809,10 +862,11 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
return false return false
} }
// ensurePremium upgrades *u to premium if its CoinPay charge has settled, // ensurePremium upgrades *u to premium if its CoinPay charge has settled. It is
// provisioning the member's @host email alias on the transition. It is silent // silent (no session output) so it is safe to call from the hub. Returns the
// (no session output) so it is safe to call from the hub. Returns the current // current premium state. Email is no longer a premium perk — every verified
// premium state. // member gets a mailbox (see ensureMailbox) — so this only unlocks custom
// domains and the Tor shell.
func (a *app) ensurePremium(u *store.User) bool { func (a *app) ensurePremium(u *store.User) bool {
if u.Premium { if u.Premium {
return true return true
@ -829,23 +883,12 @@ func (a *app) ensurePremium(u *store.User) bool {
return false return false
} }
u.Premium = true u.Premium = true
// Create their <name>@host alias forwarding to the email they verified, then
// email that address their new mailbox details + webmail link.
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))
} else if a.mail.Configured() {
if err := a.mail.Send(u.Email, "Your "+a.fe.Domain+" mailbox is ready",
mailWelcomeEmailBody(u.Name, a.fe.Address(u.Name), a.fe.WebmailURL())); err != nil {
log.Error("mail welcome email", "user", u.Name, "err", err)
}
}
}
return true return true
} }
// mailWelcomeEmailBody is the plain-text email sent when a member's @host // mailWelcomeEmailBody is the plain-text email sent when a member's @host
// mailbox alias is provisioned: their new address and the webmail link. // mailbox alias is provisioned: their new address and the webmail link. Used by
// the `notify-creds` backfill command (see notifycreds.go).
func mailWelcomeEmailBody(name, address, webmail string) string { func mailWelcomeEmailBody(name, address, webmail string) string {
b := "Hi " + name + ",\n\n" + b := "Hi " + name + ",\n\n" +
"Your member mailbox is live:\n\n" + "Your member mailbox is live:\n\n" +
@ -859,24 +902,22 @@ func mailWelcomeEmailBody(name, address, webmail string) string {
return b return b
} }
// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail // showPremiumWelcome prints a premium member's perks: custom domains and the
// URL, the in-hub Mail/Tor entries, and custom domains. // in-hub Tor shell. (Email is free for all members — see the join@ summary.)
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) { func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
lines := []string{ 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 <yourdomain.com>", " domains ssh domain@" + a.host + " add <yourdomain.com>",
" tor pick “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
"", "",
} }
wish.Println(s, strings.Join(lines, "\n")) wish.Println(s, strings.Join(lines, "\n"))
} }
// offerPremium pitches the $99 Founding Lifetime membership — a personal @host email and // offerPremium pitches the $99 Founding Lifetime membership — custom domains and
// custom domains. When CoinPay can mint a charge in-session it shows the exact // 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. // 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 // Non-blocking: the member pays out of band and perks unlock on their next
// connect (or re-running join@). // connect (or re-running join@).
@ -893,9 +934,9 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
" ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time", " ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time",
" Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.", " Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.",
"", "",
" Everything in your free membership stays free — founding adds these", " Everything in your free membership stays free — including your",
" bonus features, forever:", " " + a.mailAddress(u.Name) + " mailbox. Founding adds these bonus",
" • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")", " features, forever:",
" • custom domains point yourdomain.com at your homepage", " • custom domains point yourdomain.com at your homepage",
" • Tor a “Tor shell” in your pod — everything over Tor", " • Tor a “Tor shell” in your pod — everything over Tor",
" • locked-in price founding rate is yours for life — never renew, never pay again", " • locked-in price founding rate is yours for life — never renew, never pay again",
@ -995,7 +1036,7 @@ func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) {
"Run <code>ssh join@"+a.host+"</code> to get a fresh confirmation link."))) "Run <code>ssh join@"+a.host+"</code> to get a fresh confirmation link.")))
return 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 ✓", _, _ = w.Write([]byte(verifyPage("Email confirmed ✓",
"Welcome, "+u.Name+". Your account is active — <code>ssh "+u.Name+"@"+a.host+"</code>."))) "Welcome, "+u.Name+". Your account is active — <code>ssh "+u.Name+"@"+a.host+"</code>.")))
} }
@ -1005,7 +1046,7 @@ func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) {
// alike; plan only affects quotas, enforced by AgentGit, not account existence. // 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 // Failures are logged but never block BBS verification, and it is a no-op when
// Forgejo is unconfigured. // 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 == "" { if u == nil || !a.forgejo.Configured() || u.Name == "" || u.Email == "" {
return return
} }
@ -1018,6 +1059,15 @@ func (a *app) provisionGit(u *store.User) {
return return
} }
log.Info("provisioned git account", "user", u.Name, "host", a.forgejo.BaseURL) 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)
}
}
// Email the verified address their web sign-in link + one-time password so // Email the verified address their web sign-in link + one-time password so
// they can log in to the Forgejo UI and create repositories. Best-effort: // they can log in to the Forgejo UI and create repositories. Best-effort:
// the account already exists, so a mail failure must not block anything. // the account already exists, so a mail failure must not block anything.
@ -1030,8 +1080,9 @@ func (a *app) provisionGit(u *store.User) {
} }
// gitWelcomeEmailBody is the plain-text email sent when a member's AgentGit // gitWelcomeEmailBody is the plain-text email sent when a member's AgentGit
// (Forgejo) account is created: web login link, username, and the one-time // (Forgejo) account is created or reset — on provisioning and by the
// password they must change on first sign-in. // notify-creds ops command: web login link, username, and the one-time password
// they must change on first sign-in.
func gitWelcomeEmailBody(name, password, loginURL string) string { func gitWelcomeEmailBody(name, password, loginURL string) string {
return "Hi " + name + ",\n\n" + return "Hi " + name + ",\n\n" +
"Your git account is ready. Sign in to the web interface here:\n\n" + "Your git account is ready. Sign in to the web interface here:\n\n" +
@ -1044,6 +1095,16 @@ func gitWelcomeEmailBody(name, password, loginURL string) string {
"If you didn't request this, you can ignore this email.\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. // verifyPage renders the minimal confirmation result page.
func verifyPage(title, body string) string { func verifyPage(title, body string) string {
return "<!doctype html><meta charset=utf-8><title>" + title + "</title>" + return "<!doctype html><meta charset=utf-8><title>" + title + "</title>" +
@ -1341,29 +1402,95 @@ func (a *app) runNews(s ssh.Session, name string) error {
return news.RunReader(s, addr, name) return news.RunReader(s, addr, name)
} }
// mailClientFor builds a paid-gated AgentMail client for a member, connecting to // mailAddress is a member's email address, e.g. alice@bbs.profullstack.com.
// the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain }
// "<name>*<master>") 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 // mailEnabled reports whether member mailboxes can be provisioned (Mailu admin
// the IMAP connection/login fails. // 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 <name>@<mailDomain> 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
// "<addr>*<master>") 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 <name>@<mailDomain> address. Returns an error
// if the IMAP connection/login fails.
func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) { func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") // Mailu keys mailboxes by full address, so the IMAP login (and the master
login := su.Name // login "<addr>*<master>") must use the address, not the bare handle.
login := a.mailAddress(su.Name)
if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" { if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
login = su.Name + "*" + master login = a.mailAddress(su.Name) + "*" + master
} }
cfg := mailbox.IMAPConfig{ 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"), SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
Username: login, Username: login,
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"), Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
// Mailu's front nginx pre-authenticates against its user DB before
// proxying, which rejects the "<addr>*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. // SMTPUser/SMTPPass left empty: submit via the trusted local relay.
} }
tr, err := mailbox.NewIMAPTransport(cfg) tr, err := mailbox.NewIMAPTransport(cfg)
if err != nil { if err != nil {
return nil, err 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 // handleMail routes a Founding Lifetime member into AgentMail: an interactive
@ -1387,11 +1514,18 @@ func (a *app) handleMail(s ssh.Session) {
_ = s.Exit(1) _ = s.Exit(1)
return return
} }
if !a.ensurePremium(&u) { if !u.EmailVerified {
wish.Println(s, " mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@"+a.host) wish.Println(s, " verify your email first: ssh -t join@"+a.host)
_ = s.Exit(1) _ = s.Exit(1)
return 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") sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
defer func() { _ = a.st.EndSession(sessID) }() defer func() { _ = a.st.EndSession(sessID) }()
@ -1501,6 +1635,62 @@ func (a *app) handleChat(s ssh.Session) {
} }
} }
// handleMsg is the member-to-member messaging route: `ssh msg@host <user> [text]`
// leaves a note in <user>'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+" <user> [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 // 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). // existing account name that isn't their own (e.g. ssh anthony@host).
// Returns false when the route should fall through to the hub. // Returns false when the route should fall through to the hub.

View file

@ -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 "<addr>*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]

View file

@ -1,15 +1,24 @@
# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env # Mailu configuration — copy to deploy/mailu/mailu.env and fill the secrets.
# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway). # See docs/mail.md for the full setup (DNS, certs, gateway).
# #
# Generate secrets with: openssl rand -hex 16 # 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 <name>@bbs.profullstack.com, served from mail.profullstack.com.
# --- General ----------------------------------------------------------------- # --- General -----------------------------------------------------------------
SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16 SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16
DOMAIN=mail.profullstack.com # member addresses are <name>@mail.profullstack.com DOMAIN=bbs.profullstack.com # member addresses are <name>@bbs.profullstack.com
HOSTNAMES=mail.profullstack.com,smtp.profullstack.com HOSTNAMES=mail.profullstack.com,smtp.profullstack.com
POSTMASTER=postmaster POSTMASTER=postmaster
# Apex profullstack.com is reserved for corporate mail and is NOT served here. # 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 # 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). # it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh).
TLS_FLAVOR=mail 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 # A Dovecot master user lets the agentbbs gateway open any member's mailbox with
# one secret (login "<name>*<master>"). Created by deploy/mailu/provision-mailbox.sh. # one secret (login "<name>*<master>"). Created by deploy/mailu/provision-mailbox.sh.
# Mirror these into the agentbbs service env: # Mirror these into the agentbbs service env:
# AGENTBBS_MAIL_ADDR_DOMAIN=bbs.profullstack.com
# AGENTBBS_MAIL_DOMAIN=mail.profullstack.com # AGENTBBS_MAIL_DOMAIN=mail.profullstack.com
# AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993 # AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993
# AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25 # AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25
# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080
# AGENTBBS_MAIL_API_TOKEN=<the API_TOKEN above>
# AGENTBBS_MAIL_MASTER_USER=gateway # AGENTBBS_MAIL_MASTER_USER=gateway
# AGENTBBS_MAIL_MASTER_PASS=<the master password you set> # AGENTBBS_MAIL_MASTER_PASS=<the master password you set>
# --- Admin bootstrap --------------------------------------------------------- # --- Admin bootstrap ---------------------------------------------------------
INITIAL_ADMIN_ACCOUNT=admin INITIAL_ADMIN_ACCOUNT=admin
INITIAL_ADMIN_DOMAIN=mail.profullstack.com INITIAL_ADMIN_DOMAIN=bbs.profullstack.com
INITIAL_ADMIN_PW=CHANGEME_admin_password INITIAL_ADMIN_PW=CHANGEME_admin_password

View file

@ -14,7 +14,8 @@
set -euo pipefail set -euo pipefail
MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}" 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}" MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}"
QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB

View file

@ -210,6 +210,10 @@ directly over SFTP with the member's existing SSH login key.
member reaches their private files with the same key they log in with member reaches their private files with the same key they log in with
(`sftp files@bbs.profullstack.com`). `scp`/`rsync -e ssh` work over the same (`sftp files@bbs.profullstack.com`). `scp`/`rsync -e ssh` work over the same
endpoint. endpoint.
- **Eligibility:** members-only, **free for every member** (free and paid alike,
like IRC and News — not a Premium-gated perk). Non-members are refused at the
handshake; guests don't see the in-BBS browser. Operators can revoke an
individual account's SFTP access without affecting its BBS login.
- **In-BBS view:** a TUI file browser for the user's own workspace and the - **In-BBS view:** a TUI file browser for the user's own workspace and the
shared area (list, rename, delete, up/download path, view usage vs. quota). shared area (list, rename, delete, up/download path, view usage vs. quota).
- **Operator TUI:** an admin management surface for the SFTP server — list - **Operator TUI:** an admin management surface for the SFTP server — list

View file

@ -17,6 +17,20 @@ The username (`files`) is conventional and ignored — **identity is your SSH
key** (one key = one account, like the rest of the BBS). `scp`/`rsync` work key** (one key = one account, like the rest of the BBS). `scp`/`rsync` work
because they tunnel over the same SSH transport. because they tunnel over the same SSH transport.
## Who can use it
**Members only — but free for every member.** Like IRC and News, file storage is
gated on membership, *not* on the paid Founding Lifetime plan:
- **Non-members can't connect.** A key that isn't a registered account is
refused at the SFTP handshake (`this key isn't a member — register first`), and
guests don't see the in-hub Files browser.
- **Every verified member can connect, run, and join** — free and paid alike.
There is no Premium gate anywhere in the Files path; the plan only affects
unrelated perks (custom email, domains, Tor).
- Operators can revoke an individual account's SFTP access (abuse response)
without touching its BBS login — see the management TUI below.
## Two areas ## Two areas
When you connect you see a virtual root with two directories: When you connect you see a virtual root with two directories:

View file

@ -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 AgentBBS gives **every verified member** (free and paid alike) a real mailbox at
`<name>@mail.profullstack.com`, reached two ways: `<name>@bbs.profullstack.com`, reached two ways:
- **Webmail**`https://mail.profullstack.com` (Roundcube), the only - **Webmail**`https://mail.profullstack.com` (Roundcube).
member-facing mail surface.
- **AgentMail** — the in-BBS client (`internal/mailbox`): the `Mail` hub entry - **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 or `ssh mail@bbs.profullstack.com` (a TUI for humans, a JSON bot mode for
agents). It connects to this stack. 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 The apex `profullstack.com` is **reserved for corporate mail** and is not served
here — member mail lives only on the `mail.` subdomain. here.
## Architecture ## 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 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** - 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 - **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 only ACME client). Caddy obtains the `mail.profullstack.com` cert; the cert
block; [`deploy/mailu/refresh-certs.sh`](../deploy/mailu/refresh-certs.sh) refresher copies it into Mailu and reloads on renewal.
copies it into Mailu and reloads it on renewal — the same pattern as the
Ergo/IRC and NNTP cert refreshers.
- The **agentbbs gateway** reads/sends on behalf of members: IMAP via a Dovecot - 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 **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. 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 `<name>@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) ───────────┐ ┌─────────── 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) │ copies LE cert (refresh-certs.sh)
clients → Mailu front (:25 :465 :587 :993 :995) ──→ Postfix / Dovecot / rspamd 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 ──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 ## 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 | | Type | Host | Value |
|---|---|---| |---|---|---|
| A | `mail.profullstack.com` | host IP | | A | `mail.profullstack.com` | host IP |
| A | `smtp.profullstack.com` | host IP | | MX | `bbs.profullstack.com` | `10 mail.profullstack.com.` |
| MX | `mail.profullstack.com` | `10 mail.profullstack.com.` | | TXT (SPF) | `bbs.profullstack.com` | `v=spf1 mx -all` |
| TXT (SPF) | `mail.profullstack.com` | `v=spf1 mx -all` | | TXT (DMARC) | `_dmarc.bbs.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@bbs.profullstack.com` |
| TXT (DMARC) | `_dmarc.mail.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.profullstack.com` | | TXT (DKIM) | `dkim._domainkey.bbs.profullstack.com` | from `flask mailu config-export` after first boot |
| TXT (DKIM) | `dkim._domainkey.mail.profullstack.com` | from `flask mailu config-export` after first boot |
| PTR | host IP | `mail.profullstack.com` (set at your VPS provider) | | PTR | host IP | `mail.profullstack.com` (set at your VPS provider) |
> **Port 25 / deliverability:** many cloud providers block outbound `:25` by > **Port 25 / deliverability:** many cloud providers (incl. DigitalOcean) block
> default — request an unblock, set the PTR/rDNS, and warm the IP, or relay > outbound `:25` by default — request an unblock, set the PTR/rDNS, and warm the
> outbound through a smarthost. Inbound MX and the gateway's local submission > IP, or relay outbound through a smarthost. Inbound MX and the gateway's local
> work regardless. > submission work regardless.
## Install ## Install
```bash ```bash
cd /opt/agentbbs/deploy/mailu 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 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)" AGENTBBS_MAIL_MASTER_USER=gateway ./provision-mailbox.sh --master "$(openssl rand -hex 16)"
``` ```
Add the Caddy site (setup.sh writes this when `MAIL=1`): 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.
```
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.
```
## agentbbs gateway env ## 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 | | Var | Value |
|---|---| |---|---|
| `AGENTBBS_MAIL_ADDR_DOMAIN` | `bbs.profullstack.com` |
| `AGENTBBS_MAIL_DOMAIN` | `mail.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_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_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 `<addr>*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 ## Provisioning member mailboxes
A mailbox must exist before the gateway can open it. Provision when a member Provisioning is automatic at `join@` verification. To create or backfill by hand:
becomes paid (or backfill):
```bash ```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 (Set `MAIL_DOMAIN=bbs.profullstack.com` for the script, since the address domain
the login form `alice*gateway` + the master password — which is exactly what 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 `internal/mailbox`'s IMAP adapter sends. See
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for the master-user [`deploy/mailu/README.md`](../deploy/mailu/README.md) for details.
override and operational details.
## Webmail only for members ## Webmail only for members

View file

@ -17,6 +17,7 @@ import (
"github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
) )
// Live is one connected SSH session, as seen by the registry. // Live is one connected SSH session, as seen by the registry.
@ -70,13 +71,13 @@ var menuItems = []struct {
} }
var ( var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) dimStyle = ui.Dim
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) warnStyle = ui.Danger
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) okStyle = lipgloss.NewStyle().Foreground(ui.Green)
headStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa")) headStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
frameStyle = lipgloss.NewStyle().Padding(1, 2) frameStyle = ui.Frame
) )
// Model is the admin console. // Model is the admin console.
@ -306,7 +307,7 @@ func (m Model) View() string {
} }
header := titleStyle.Render("AgentBBS admin") + dimStyle.Render(" · "+m.admin.Name) 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 != "" { if m.note != "" {
out += "\n" + m.note out += "\n" + m.note
} }

View file

@ -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). // 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 // The route only opens for accounts whose name is in the operator allowlist
// (see IsAdmin); the name itself confers nothing. // (see IsAdmin); the name itself confers nothing — so "root" is just a familiar
var AdminNames = map[string]bool{"admin": true, "sysop": true} // 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). // TorURLNames route to the one-shot "fetch a URL over Tor" command (premium).
var TorURLNames = map[string]bool{"tor-url": true} 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). // management TUI (operator-gated).
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] } func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
// MsgNames route a member-to-member message: `ssh msg@host <user>` leaves a
// note in the recipient's BBS inbox (store-and-forward, see the Members plugin).
var MsgNames = map[string]bool{"msg": true, "message": true}
// 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 // systemReserved are names that don't drive an SSH route but would still
// collide with a per-user subdomain (<name>.<host>), the agent route, or common // collide with a per-user subdomain (<name>.<host>), the agent route, or common
// infra hostnames — so members may not claim them as account names. // infra hostnames — so members may not claim them as account names.
@ -129,7 +137,7 @@ func IsReservedName(name string) bool {
n := strings.ToLower(name) n := strings.ToLower(name)
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[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 true
} }
return strings.HasPrefix(n, "video-") // video-<code> call routes return strings.HasPrefix(n, "video-") // video-<code> call routes

View file

@ -3,7 +3,7 @@ package auth
import "testing" import "testing"
func TestIsAdminName(t *testing.T) { func TestIsAdminName(t *testing.T) {
for _, name := range []string{"admin", "ADMIN", "sysop"} { for _, name := range []string{"admin", "ADMIN", "sysop", "root"} {
if !IsAdminName(name) { if !IsAdminName(name) {
t.Errorf("IsAdminName(%q) = false, want true", name) t.Errorf("IsAdminName(%q) = false, want true", name)
} }

View file

@ -142,6 +142,58 @@ func (c Config) EnsureUserReset(username, email string) (created bool, password
return false, pw, nil return false, pw, 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. // userExists reports whether a Forgejo user with this name is present.
func (c Config) userExists(username string) (bool, error) { func (c Config) userExists(username string) (bool, error) {
status, resp, err := c.do(http.MethodGet, "/users/"+username, nil) status, resp, err := c.do(http.MethodGet, "/users/"+username, nil)

View file

@ -174,3 +174,65 @@ func TestEnsureUserNoOpWhenExists(t *testing.T) {
t.Fatal("must not POST when the user already exists") 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)
}
}

View file

@ -19,21 +19,12 @@ import (
"github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/ui"
) )
var ( var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) theme = ui.New(ui.Green)
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"))
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a")) 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 // 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") b.WriteString(bannerStyle.Render(m.banner) + "\n\n")
} }
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind) 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 != "" { if m.motd != "" {
b.WriteString("\n" + motdStyle.Render(m.motd) + "\n") b.WriteString("\n" + theme.Card("", m.motd) + "\n")
} }
b.WriteString("\n") b.WriteString("\n")
row := 0 row := 0
for _, p := range m.plugins { if len(m.plugins) > 0 {
label := p.Title() b.WriteString(theme.Section("Features") + "\n")
if p.RequiresAuth() && m.user.Kind == auth.Guest { for _, p := range m.plugins {
label += lockStyle.Render(" [members]") 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 { if len(m.apps) > 0 {
label := app.Title b.WriteString("\n" + theme.Section("Sessions") + "\n")
if app.Locked != "" { for _, app := range m.apps {
label += lockStyle.Render(" [locked]") 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 != "" { if m.note != "" {
b.WriteString("\n" + lockStyle.Render(m.note)) b.WriteString("\n" + ui.Danger.Render(m.note))
} }
return frameStyle.Render(b.String()) return ui.Frame.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))
} }

View file

@ -135,7 +135,7 @@ func RunBot(ctx context.Context, c *Client, args []string, in io.Reader, out io.
if len(args) > 3 { if len(args) > 3 {
on = !strings.EqualFold(args[3], "off") && args[3] != "false" && args[3] != "0" 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) err = c.Flag(ctx, mailbox, uid, on)
} else { } else {
err = c.MarkSeen(ctx, mailbox, uid, on) err = c.MarkSeen(ctx, mailbox, uid, on)

View file

@ -7,16 +7,19 @@ import (
"strings" "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 { type Identity struct {
Name string // local-part / handle, e.g. "alice" 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. // ErrNotMember is returned when a caller without a registered handle attempts a
var ErrNotPaid = errors.New("AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com") // 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. // method returns plain structs, so the same calls serve humans and agents.
type Client struct { type Client struct {
t Transport t Transport
@ -25,8 +28,8 @@ type Client struct {
pageSize int pageSize int
} }
// NewClient builds a paid-gated client. domain is the mail domain (e.g. // NewClient builds a member-gated client. domain is the email address domain
// mail.profullstack.com); pageSize defaults to 50 when <= 0. // (e.g. bbs.profullstack.com); pageSize defaults to 50 when <= 0.
func NewClient(t Transport, id Identity, domain string, pageSize int) *Client { func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
if pageSize <= 0 { if pageSize <= 0 {
pageSize = 50 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} 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) Address() string { return c.id.Name + "@" + c.domain }
func (c *Client) gate() error { func (c *Client) gate() error {
if c.id.Name == "" || !c.id.Paid { if c.id.Name == "" {
return ErrNotPaid return ErrNotMember
} }
return nil return nil
} }

View file

@ -24,6 +24,11 @@ type IMAPConfig struct {
// SMTPUser/SMTPPass default to Username/Password when empty. // SMTPUser/SMTPPass default to Username/Password when empty.
SMTPUser string SMTPUser string
SMTPPass 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 // 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. // NewIMAPTransport dials the IMAP server, logs in, and returns a Transport.
func NewIMAPTransport(cfg IMAPConfig) (Transport, error) { 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 { if err != nil {
return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err) return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err)
} }

View file

@ -15,7 +15,7 @@ func seeded() *MemoryTransport {
} }
func paidClient(t Transport) *Client { 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) { func TestParseFormatAddress(t *testing.T) {
@ -45,9 +45,15 @@ func TestValidEmailAndDraft(t *testing.T) {
} }
func TestGate(t *testing.T) { func TestGate(t *testing.T) {
c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "mail.profullstack.com", 0) // A free member (Paid: false) now has full mail access.
if _, err := c.Inbox(context.Background(), 0); !errors.Is(err, ErrNotPaid) { c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "bbs.profullstack.com", 0)
t.Fatalf("expected ErrNotPaid, got %v", err) 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) t.Fatalf("send: %v", err)
} }
sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent}) 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) t.Fatalf("sent: %+v", sent)
} }

View file

@ -1,8 +1,9 @@
// Package mailbox is the BBS-side mail client for Founding Lifetime members: a // Package mailbox is the BBS-side mail client for members (a free benefit of
// transport-agnostic core (read, search, compose, send, flag, delete) with a // membership): a transport-agnostic core (read, search, compose, send, flag,
// Bubble Tea TUI for humans and a line-oriented JSON mode for agents/bots. It // delete) with a Bubble Tea TUI for humans and a line-oriented JSON mode for
// talks to the self-hosted Mailu stack (Dovecot IMAP + Postfix submission) at // agents/bots. Addresses are <name>@bbs.profullstack.com; it talks to the
// mail.profullstack.com / smtp.profullstack.com. // 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 // The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are
// deliberately the same so tooling can move between them. // deliberately the same so tooling can move between them.

197
internal/mailu/mailu.go Normal file
View file

@ -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
// <name>@<domain> (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
}

View file

@ -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"])
}
}

View file

@ -173,15 +173,17 @@ func parseRange(spec string) (low, high int64) {
if len(parts) == 1 { if len(parts) == 1 {
h, err := strconv.ParseInt(parts[0], 10, 64) h, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil { if err != nil {
h = math.MaxInt64 return 0, 0 // malformed — empty range instead of all articles
return 0, h
} }
return h, h 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) h, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil { if err != nil {
h = math.MaxInt64 return 0, 0 // malformed — empty range
} }
return l, h return l, h
} }

View file

@ -8,15 +8,16 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
"github.com/dustin/go-nntp" "github.com/dustin/go-nntp"
"github.com/profullstack/agentbbs/internal/ui"
) )
var ( var (
nTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc")) theme = ui.New(ui.Purple)
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(lipgloss.Color("#38bdf8")) nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(ui.Cyan)
nMeta = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) nMeta = ui.Dim
nFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) nFrom = lipgloss.NewStyle().Foreground(ui.Green)
nErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) nErr = ui.Danger
nHint = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
) )
// RunReader connects the member to the loopback NNTP server and drives the // 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) status = "\n" + nMeta.Render(m.status)
} }
return lipgloss.NewStyle().Padding(0, 1).Render( 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 { func (m *model) viewGroups() string {

View file

@ -22,6 +22,14 @@ type Context struct {
DataDir string DataDir string
// AssetsDir is the read-only platform assets tree (wads, binaries). // AssetsDir is the read-only platform assets tree (wads, binaries).
AssetsDir string 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. // Plugin is the only integration point between a feature and the hub.

View file

@ -13,8 +13,11 @@
package pods package pods
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -87,6 +90,69 @@ func (m *Manager) hasMount(name, dest string) bool {
return false 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 <data>/agent/<user>
// (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. // Engine reports the active container engine.
func (m *Manager) Engine() string { return m.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 // Bind the host's public_html into the pod so a member's edits at
// ~/public_html are exactly what Caddy serves at <name>.<host>. // ~/public_html are exactly what Caddy serves at <name>.<host>.
_, pubSpec := m.publicHTMLMount(user) _, 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" { if m.engine == "docker" {
// Under docker the pod runs as uid 1000 (never container root), so the // 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 // 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() m.mu.Lock()
idle := m.attached[name] == 0 idle := m.attached[name] == 0
m.mu.Unlock() m.mu.Unlock()
if pubSpec != "" && idle && !m.hasMount(name, "/home/dev/public_html") { // Recreate an idle pod when it's missing the public_html bind OR is
_ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate with the bind // 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 { } else {
_ = exec.Command(m.engine, "start", name).Run() // no-op if running _ = exec.Command(m.engine, "start", name).Run() // no-op if running
m.tuneApt(name)
return name, nil return name, nil
} }
} }
@ -154,6 +229,9 @@ func (m *Manager) ensure(user string) (string, error) {
if pubSpec != "" { if pubSpec != "" {
args = append(args, "-v", pubSpec) args = append(args, "-v", pubSpec)
} }
if agentDir != "" {
args = append(args, "-v", agentDir+":/run/agentbbs-agent")
}
if m.engine == "docker" { if m.engine == "docker" {
// Rootful docker: a breakout is host-root, so refuse to hand out // Rootful docker: a breakout is host-root, so refuse to hand out
// container root — run as uid 1000 with no caps and no privilege // 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 { if err != nil {
return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out))) return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out)))
} }
m.tuneApt(name)
return name, nil 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. // Attach provisions the pod and wires the SSH session to a shell inside it.
// Blocks until the shell exits or the session closes. // Blocks until the shell exits or the session closes.
func (m *Manager) Attach(s ssh.Session, user string) error { 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 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") shell := env("AGENTBBS_POD_SHELL", "/bin/bash")
cmd := exec.Command(m.engine, "exec", "-it", cmd := exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, shell, "-l")...)
"-e", "TERM="+ptyReq.Term,
name, shell, "-l")
f, err := pty.Start(cmd) f, err := pty.Start(cmd)
if err != nil { if err != nil {
// busybox-ish images may lack bash // 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) f, err = pty.Start(cmd)
if err != nil { if err != nil {
return fmt.Errorf("pods: attach failed: %w", err) return fmt.Errorf("pods: attach failed: %w", err)

View file

@ -87,16 +87,45 @@ func Classify(raw string) (Kind, error) {
// isBlockedIP reports whether an address must not be dialed: loopback, // isBlockedIP reports whether an address must not be dialed: loopback,
// link-local (incl. the 169.254.169.254 cloud-metadata endpoint), private // 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 { func isBlockedIP(ip net.IP) bool {
return ip == nil || if ip == nil ||
ip.IsLoopback() || ip.IsLoopback() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() || ip.IsMulticast() ||
ip.IsUnspecified() || 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 // guardURL validates scheme and resolves the host, rejecting any URL that

View file

@ -5,6 +5,8 @@ package store
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"strings"
"time" "time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@ -41,10 +43,22 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) {
u.EmailVerified = verified != 0 u.EmailVerified = verified != 0
u.Premium = premium != 0 u.Premium = premium != 0
u.Banned = banned != 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 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. // Score is one leaderboard entry.
type Score struct { type Score struct {
User string User string
@ -100,6 +114,21 @@ type Store interface {
AddChat(userID int64, username, role, text string) error AddChat(userID int64, username, role, text string) error
RecentChats(username string, n int) ([]ChatMessage, 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). // Custom domains mapped to a member's homepage (public_html).
// MapDomain binds domain→username, returning ErrDomainTaken if it is // MapDomain binds domain→username, returning ErrDomainTaken if it is
// already claimed by someone else (re-binding to the same owner is a no-op). // 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, key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '' 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) { 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 { if err != nil {
return User{}, err return User{}, err
} }
id, _ := res.LastInsertId() id, err := res.LastInsertId()
return User{ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil 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: case err != nil:
return User{}, err return User{}, err
} }
@ -721,6 +763,75 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error)
return out, rows.Err() 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 { func (s *sqliteStore) MapDomain(domain, username string) error {
var owner string var owner string
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner) err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner)

View file

@ -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")
}
}

159
internal/ui/theme.go Normal file
View file

@ -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(" · "))
}

View file

@ -3,11 +3,14 @@
package about package about
import ( import (
"strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/ui"
) )
type Plugin struct{} type Plugin struct{}
@ -26,22 +29,57 @@ type model struct{ user auth.User }
func (m model) Init() tea.Cmd { return nil } func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyMsg); ok { if k, ok := msg.(tea.KeyMsg); ok {
return m, plugin.Exit switch k.String() {
case "esc", "q", "enter", "ctrl+c", " ":
return m, plugin.Exit
}
} }
return m, nil return m, nil
} }
var ( var (
h = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) theme = ui.New(ui.Green)
d = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) taglineStyle = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("245"))
cmdStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Cyan)
) )
func (m model) View() string { // route is one connection entry point shown in the CONNECT card.
return lipgloss.NewStyle().Padding(1, 2).Render( type route struct {
h.Render("AgentBBS") + " — a modern BBS over SSH for humans and AI agents.\n\n" + cmd, desc string
" ssh bbs@profullstack.com this hub (guests welcome)\n" + badgeVar, tag string
" 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")) 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)
} }

View file

@ -18,6 +18,7 @@ import (
"github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
) )
// Plugin is the AgentGames hub entry. // Plugin is the AgentGames hub entry.
@ -52,12 +53,12 @@ const (
) )
var ( var (
title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) title = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) dim = ui.Dim
cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) cursor = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
head = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa")) head = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) warn = ui.Danger
frame = lipgloss.NewStyle().Padding(1, 2) frame = ui.Frame
) )
var actions = []string{"Ladder", "Replays", "Play vs bot"} var actions = []string{"Ladder", "Replays", "Play vs bot"}
@ -302,7 +303,7 @@ func (m *model) View() string {
case scPlay: case scPlay:
body, help = m.viewPlay() 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 != "" { if m.note != "" {
out += "\n" + m.note out += "\n" + m.note
} }
@ -393,7 +394,7 @@ func (m *model) viewPlay() (string, string) {
func (m *model) row(i int) string { func (m *model) row(i int) string {
if i == m.cursor { if i == m.cursor {
return cursor.Render("> ") return cursor.Render(" ")
} }
return " " return " "
} }

View file

@ -1,37 +1,67 @@
// Package arcade is the flagship plugin (PRD §5.1): classic terminal games. // Package arcade is the flagship plugin (PRD §5.1): classic terminal games.
// DOOM runs as a sandboxed external binary (doom-ascii + Freedoom); built-in // DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon
// TUI games (snake) feed the global leaderboards. // Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games
// (snake, hangman) feed the global leaderboards.
package arcade package arcade
import ( import (
"fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/ui"
) )
type Plugin struct{} type Plugin struct{}
func (Plugin) ID() string { return "arcade" } func (Plugin) ID() string { return "arcade" }
func (Plugin) Title() string { return "Arcade" } func (Plugin) Title() string { return "Arcade" }
func (Plugin) Description() string { return "DOOM (ASCII), snake, leaderboards" } func (Plugin) Description() string {
func (Plugin) RequiresAuth() bool { return false } 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 { func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
return newMenu(user, ctx) 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. // entry is one row in the arcade menu.
type entry struct { type entry struct {
label string section string
desc string label string
run func(m *menu) (tea.Model, tea.Cmd) desc string
run func(m *menu) (tea.Model, tea.Cmd)
} }
type menu struct { type menu struct {
@ -45,44 +75,89 @@ type menu struct {
child tea.Model // snake / leaderboard take over here child tea.Model // snake / leaderboard take over here
} }
var ( var theme = ui.New(ui.Gold)
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"))
)
func newMenu(user auth.User, ctx plugin.Context) *menu { func newMenu(user auth.User, ctx plugin.Context) *menu {
m := &menu{user: user, ctx: ctx} m := &menu{user: user, ctx: ctx}
// --- DOOM (per WAD) ---
for _, wad := range findWADs(ctx, user) { for _, wad := range findWADs(ctx, user) {
wad := wad wad := wad
m.entries = append(m.entries, entry{ m.entries = append(m.entries, entry{
label: "DOOM — " + filepath.Base(wad), section: "DOOM",
desc: "doom-ascii in a sandbox (24-bit color terminal recommended)", label: "DOOM — " + filepath.Base(wad),
run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(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{ m.entries = append(m.entries, entry{
label: "DOOM — not installed", section: "DOOM",
desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom", label: "DOOM — not installed",
run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil }, 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, m.entries = append(m.entries,
entry{ entry{
label: "Snake", section: "BUILT-IN",
desc: "built-in; high scores hit the global leaderboard", label: "Snake",
desc: "built-in; high scores hit the global leaderboard",
run: func(m *menu) (tea.Model, tea.Cmd) { run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newSnake(m.user, m.ctx, m.width, m.height) m.child = newSnake(m.user, m.ctx, m.width, m.height)
return m, m.child.Init() return m, m.child.Init()
}, },
}, },
entry{ entry{
label: "Leaderboard", section: "BUILT-IN",
desc: "global top scores", label: "Hangman",
desc: "built-in word game; high scores hit the global leaderboard",
run: func(m *menu) (tea.Model, tea.Cmd) { 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() return m, m.child.Init()
}, },
}, },
@ -116,24 +191,93 @@ func doomBin(ctx plugin.Context) string {
return "" 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 // 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. // doom-ascii on a real PTY. Savegames land in the per-user work dir.
func (m *menu) launchDoom(wad string) tea.Cmd { func (m *menu) launchDoom(wad string) tea.Cmd {
bin := doomBin(m.ctx) bin := doomBin(m.ctx)
work := m.ctx.DataDir work := m.workDir(filepath.Join("doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad))))
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)
}
cmd := m.ctx.Sandbox.Command(work, bin, "-iwad", 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 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 } 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 return m, cmd
} }
switch msg := msg.(type) { switch msg := msg.(type) {
case doomDoneMsg: case gameDoneMsg:
if msg.err != nil { if msg.err != nil {
m.note = "doom exited: " + msg.err.Error() m.note = msg.name + " exited: " + msg.err.Error()
} }
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
@ -180,19 +324,23 @@ func (m *menu) View() string {
if m.child != nil { if m.child != nil {
return m.child.View() 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 { for i, e := range m.entries {
cur := " " if e.section != prevSection {
if i == m.cursor { if prevSection != "" {
cur = cStyle.Render("> ") 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 != "" { 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. // backMsg returns from a child (snake/leaderboard) to the arcade menu.

View file

@ -4,24 +4,27 @@ import (
"fmt" "fmt"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/store" "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 { type board struct {
ctx plugin.Context ctx plugin.Context
game string
scores []store.Score scores []store.Score
err error 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 { func (b *board) Init() tea.Cmd {
return func() tea.Msg { 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} 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 { func (b *board) View() string {
s := tStyle.Render("Leaderboard — snake") + "\n\n" s := theme.Title("Leaderboard — "+b.game) + "\n\n"
switch { switch {
case b.err != nil: case b.err != nil:
s += eStyle.Render("error: " + b.err.Error()) s += ui.Danger.Render("error: " + b.err.Error())
case len(b.scores) == 0: 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: default:
for i, sc := range b.scores { for i, sc := range b.scores {
s += fmt.Sprintf("%2d. %-20s %6d\n", i+1, sc.User, sc.Score) s += fmt.Sprintf("%2d. %-20s %6d\n", i+1, sc.User, sc.Score)
} }
} }
s += "\n" + dStyle.Render("any key to return") s += "\n" + ui.KeyBar("any-key return to menu")
return lipgloss.NewStyle().Padding(1, 2).Render(s) return ui.Frame.Render(s)
} }

193
plugins/arcade/hangman.go Normal file
View file

@ -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 AZ
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 AZ 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)
}

View file

@ -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))
}
}

37
plugins/hello/hello.go Normal file
View file

@ -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.")
}

421
plugins/members/members.go Normal file
View file

@ -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 <user>` 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))
}
}

View file

@ -18,6 +18,7 @@ import (
"github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/plugin"
qi "github.com/profullstack/agentbbs/internal/qryptinvite" qi "github.com/profullstack/agentbbs/internal/qryptinvite"
"github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
) )
// Plugin is the hub registration. It admits members only (guests have no // 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 { func (m model) View() string {
return lipgloss.NewStyle().Padding(1, 2).Render( return ui.Frame.Render(m.body + "\n\n" + ui.KeyBar("esc/q return to menu"))
m.body + "\n\n" + dStyle.Render("press any key to return"))
} }
var ( var (
hStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) hStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) dStyle = ui.Dim
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) errStyle = ui.Danger
urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")) urlStyle = lipgloss.NewStyle().Foreground(ui.Blue)
) )

57
pods/Containerfile Normal file
View file

@ -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"]

View file

@ -3,10 +3,13 @@
# ./assets — Freedoom by default (PRD §9.1). Run on the host before enabling # ./assets — Freedoom by default (PRD §9.1). Run on the host before enabling
# the arcade's DOOM entries. # 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 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 set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@ -14,6 +17,16 @@ ASSETS="$ROOT/assets"
BUILD="$ROOT/.build" BUILD="$ROOT/.build"
FREEDOOM_VERSION="${FREEDOOM_VERSION:-0.13.0}" 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" mkdir -p "$ASSETS/bin" "$ASSETS/wads" "$BUILD"
# --- doom-ascii ------------------------------------------------------------- # --- doom-ascii -------------------------------------------------------------
@ -47,12 +60,39 @@ else
fi fi
# --- Doom shareware (optional) ---------------------------------------------- # --- 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" echo ">> fetching Doom shareware episode"
curl -fsSL -o "$ASSETS/wads/doom1.wad" \ curl -fsSL -o "$ASSETS/wads/doom1.wad" \
"https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad" "https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad"
echo ">> installed doom1.wad (shareware)" echo ">> installed doom1.wad (shareware)"
fi 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:" echo ">> done. WADs:"
ls -l "$ASSETS/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

63
scripts/rebuild-pods.sh Executable file
View file

@ -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-<name>-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@<host>`. 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@'."

229
setup.sh
View file

@ -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}" GO_VERSION="${GO_VERSION:-1.26.4}"
POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}" 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_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) 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) 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 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.<roo
FORGEJO_HTTP_ADDR="${FORGEJO_HTTP_ADDR:-127.0.0.1:3000}" # Forgejo loopback HTTP (Caddy fronts it) FORGEJO_HTTP_ADDR="${FORGEJO_HTTP_ADDR:-127.0.0.1:3000}" # Forgejo loopback HTTP (Caddy fronts it)
FORGEJO_DATA="${FORGEJO_DATA:-/var/lib/forgejo}" # Forgejo state dir (repos, db) FORGEJO_DATA="${FORGEJO_DATA:-/var/lib/forgejo}" # Forgejo state dir (repos, db)
FORGEJO_ADMIN_USER="${FORGEJO_ADMIN_USER:-agentgit-admin}" # Forgejo admin used to provision members FORGEJO_ADMIN_USER="${FORGEJO_ADMIN_USER:-agentgit-admin}" # Forgejo admin used to provision members
FORGEJO_SSH_PORT="${FORGEJO_SSH_PORT:-2222}" # Forgejo built-in SSH server port (git push); host :22 is agentbbs, :2202 admin
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
@ -119,8 +121,11 @@ if ! command -v yt-dlp >/dev/null; then
fi fi
# ---- 2. Go toolchain (system go is too old; pin GO_VERSION) ----------------- # ---- 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" 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}" log "installing Go ${GO_VERSION}"
tmp="$(mktemp -d)" tmp="$(mktemp -d)"
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o "$tmp/go.tgz" \ 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/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/web" # site root
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/domains" # symlink farm: custom domain -> users/<name>/public_html install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/domains" # symlink farm: custom domain -> users/<name>/public_html
[ -f "$DATA_DIR/web/index.html" ] || cat > "$DATA_DIR/web/index.html" <<HTML # Landing page (always regenerated — it's templated marketing content, not user
<!doctype html><meta charset=utf-8><title>AgentBBS</title> # data): explains what a BBS is and lists every way in. Edit here to change it.
<style>body{background:#000;color:#33ff66;font:16px/1.6 monospace;max-width:44rem;margin:4rem auto;padding:0 1rem}a{color:#60a5fa}</style> cat > "$DATA_DIR/web/index.html" <<HTML
<h1>AgentBBS</h1> <!doctype html>
<p>A BBS over SSH for humans and AI agents.</p> <html lang="en">
<pre> ssh join@${DOMAIN} # register your key, get started <meta charset="utf-8">
ssh bbs@${DOMAIN} # look around as a guest <meta name="viewport" content="width=device-width, initial-scale=1">
ssh pod@${DOMAIN} # your personal Linux pod (\$1/mo)</pre> <title>AgentBBS — a bulletin board system over SSH</title>
<p>User homepages live at <code>/~name</code> — and members can point their own domain at one (<code>ssh domain@${DOMAIN} add yourdomain.com</code>).</p> <meta name="description" content="AgentBBS: a 1980s-style bulletin board system, reborn over SSH, for humans and AI agents. Arcade, IRC, Usenet, mail, git, a Linux pod and your own homepage.">
<style>
:root { --fg:#33ff66; --dim:#1f9e44; --link:#60a5fa; --bg:#000; }
* { box-sizing: border-box; }
body {
background: var(--bg); color: var(--fg);
font: 15px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
max-width: 56rem; margin: 0 auto; padding: 2.5rem 1.1rem 4rem;
text-shadow: 0 0 2px rgba(51,255,102,.35);
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
h2 { color: var(--fg); margin: 2.2rem 0 .6rem; font-size: 1rem; letter-spacing: .04em; }
h2::before { content: "▌ "; color: var(--dim); }
p { margin: .5rem 0; }
.dim { color: var(--dim); }
pre { margin: .4rem 0; white-space: pre-wrap; }
.banner { color: var(--fg); line-height: 1.15; font-size: clamp(7px, 2.1vw, 13px); margin: 0 0 .4rem; }
.cmds b { color: var(--fg); font-weight: 600; }
.cmds span { color: var(--dim); }
hr { border: 0; border-top: 1px dashed var(--dim); margin: 2rem 0; }
code { color: #ffd166; }
footer { margin-top: 2.5rem; color: var(--dim); font-size: .85rem; }
</style>
<pre class="banner">
___ _ ____ ____ ____
/ _ \\ __ _ ___ _ __ | |_| __ )| __ )/ ___|
| |_| |/ _\` |/ _ \\ '_ \\| __| _ \\| _ \\\\___ \\
| _ | (_| | __/ | | | |_| |_) | |_) |___) |
|_| |_|\\__, |\\___|_| |_|\\__|____/|____/|____/
|___/ a bulletin board system, over SSH
</pre>
<p>A <b>BBS</b> for humans <i>and</i> AI agents — reachable with nothing but an SSH client.
Arcade games, chat, newsgroups, mail, git, a Linux pod, and your own homepage.</p>
<pre class="cmds"><span># first time? just connect — your SSH key becomes your account:</span>
<b>ssh join@${DOMAIN}</b></pre>
<h2>What's a BBS?</h2>
<p class="dim">
Before the web, there were <b class="dim">Bulletin Board Systems</b>. 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 <i>SysOp</i> (system operator), and their machine usually had just one phone
line, so only one caller at a time. You waited your turn.
</p>
<p class="dim">
Once connected you got glowing ANSI text art and menus you drove from the keyboard:
public <i>message boards</i>, <i>door games</i> (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 <i>FidoNet</i> or <i>Usenet</i> — 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.
</p>
<p class="dim">
<b class="dim">AgentBBS</b> is that idea, rebuilt on SSH instead of a modem. Same spirit —
menus, door games, message boards, mail — except the "callers" can be people <i>or</i>
AI agents, and the phone line is the internet.
</p>
<h2>Dial in — commands</h2>
<pre class="cmds"><b>ssh join@${DOMAIN}</b> <span>register your key — get a username, a pod &amp; a homepage</span>
<b>ssh bbs@${DOMAIN}</b> <span>look around as a guest</span>
<b>ssh NAME@${DOMAIN}</b> <span>sign in — the hub: arcade, chat, news, mail, pod, homepage</span>
<b>ssh pod@${DOMAIN}</b> <span>your personal Linux pod — Claude Code &amp; Codex preinstalled</span>
<b>ssh mail@${DOMAIN}</b> <span>your mailbox</span>
<b>ssh -t news@${DOMAIN}</b> <span>the Usenet-style newsreader</span>
<b>ssh irc@${DOMAIN}</b> <span>the members' IRC, from your terminal</span>
<b>ssh game@${DOMAIN}</b> <span>AgentGames — line-delimited JSON, for bots</span>
<b>ssh domain@${DOMAIN} add yourdomain.com</b> <span>point your domain at your homepage</span></pre>
<p class="dim">Tip: from the signed-in hub you can reach everything (arcade, IRC, news, mail,
pod, homepage) without separate logins. The arcade has <b class="dim">DOOM, Space
Invaders, Pac-Man, Tetris, Snake &amp; Hangman</b>.</p>
<h2>Around the board — on the web</h2>
<pre class="cmds"><b><a href="https://${GIT_DOMAIN}">${GIT_DOMAIN}</a></b> <span>AgentGit — every member gets ${GIT_DOMAIN}/&lt;name&gt;</span>
<b><a href="https://${IRC_DOMAIN}">${IRC_DOMAIN}</a></b> <span>IRC (${IRC_DOMAIN}:6697, TLS) — SASL as your BBS name</span>
<b>https://${DOMAIN}/~NAME</b> <span>member homepages (also NAME.${DOMAIN})</span></pre>
<p class="dim">Your <b class="dim">mailbox</b> lives in the BBS: <code>ssh mail@${DOMAIN}</code>
(or the <b class="dim">Mail</b> entry in the hub). Premium members get a forwarding
<code>name@${DOMAIN}</code> address.</p>
<h2>Git, the easy way</h2>
<p class="dim">Membership <i>is</i> your git account. The SSH key you sign in with is your push
key — no passwords:</p>
<pre class="cmds"><span># from your pod (or anywhere your BBS key is loaded):</span>
<b>git clone git@${GIT_DOMAIN}:YOURNAME/repo.git</b>
<span># your profile &amp; repos are public at</span> <b><a href="https://${GIT_DOMAIN}">${GIT_DOMAIN}/YOURNAME</a></b></pre>
<hr>
<footer>
AgentBBS · one SSH connection from anywhere.
<span class="dim">No app. No account form. Just <code>ssh join@${DOMAIN}</code>.</span>
</footer>
</html>
HTML HTML
chown "$SVC_USER:$SVC_USER" "$DATA_DIR/web/index.html" chown "$SVC_USER:$SVC_USER" "$DATA_DIR/web/index.html"
@ -191,8 +292,10 @@ else
fi fi
if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/scripts/fetch-assets.sh" ]; then if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/scripts/fetch-assets.sh" ]; then
log "fetching arcade assets (set FETCH_ASSETS=0 to skip)" fetch_flags=""
( cd "$SRC_DIR" && ./scripts/fetch-assets.sh ) || warn "asset fetch failed; arcade may be limited" [ "$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 fi
# Add swap on tiny droplets before the build (and for runtime headroom). # 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" \ 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)" 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 --------------------------------------------------- # ---- 6. environment file ---------------------------------------------------
ENV_DIR=/etc/agentbbs ENV_DIR=/etc/agentbbs
install -d -m 0750 "$ENV_DIR" install -d -m 0750 "$ENV_DIR"
@ -244,10 +368,11 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
# AGENTBBS_SIGNUP_NOTIFY=anthony@profullstack.com # AGENTBBS_SIGNUP_NOTIFY=anthony@profullstack.com
# Membership model: # Membership model:
# Free verified members get their own Docker pod (ssh pod@) and a homepage # Free verified members get their own Docker pod (ssh pod@), a homepage at
# at https://${DOMAIN}/~<name>. # https://${DOMAIN}/~<name>, AND a real mailbox <name>@${DOMAIN} on the
# Premium \$10 one-time, lifetime — a personal <name>@${DOMAIN} email # self-hosted Mailu stack (read it in the hub's "Mail" or via webmail).
# (forwardemail.net) plus custom domains (ssh domain@). Offered at join@. # 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): # 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 # 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_CURRENCY=USD
# AGENTBBS_PREMIUM_BLOCKCHAIN=eth # AGENTBBS_PREMIUM_BLOCKCHAIN=eth
# Premium email aliases (<name>@${DOMAIN}) auto-created on forwardemail.net. # Member email (free for every verified member). Addresses are <name>@${DOMAIN}
# Without an API key the address is shown but not created (add it manually). # (the address domain), while the Mailu server lives on the mail host below.
# AGENTBBS_FORWARDEMAIL_API_KEY= # Mailboxes are auto-provisioned at join@ via the Mailu admin REST API: set the
# AGENTBBS_FORWARDEMAIL_DOMAIN=${DOMAIN} # API token (API_TOKEN in deploy/mailu/mailu.env). Without it the address is
# AGENTBBS_WEBMAIL_URL=https://webmail.${DOMAIN} # 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=<mailu 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=<gateway master password>
# AgentGit (git.profullstack.com): every verified member — free and paid alike — # AgentGit (git.profullstack.com): every verified member — free and paid alike —
# is provisioned a Forgejo account when they confirm their email. The admin token # 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" chmod 0640 "$file"
} }
# CoinPay: API key (read by the coinpay CLI) + merchant/business id. # 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 COINPAY_API_KEY "${COINPAY_API_KEY:-}"
upsert_env AGENTBBS_COINPAY_MERCHANT_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" 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:-}}" 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##*:} HTTP_PORT = ${FORGEJO_HTTP_ADDR##*:}
DOMAIN = ${GIT_DOMAIN} DOMAIN = ${GIT_DOMAIN}
ROOT_URL = https://${GIT_DOMAIN}/ ROOT_URL = https://${GIT_DOMAIN}/
DISABLE_SSH = true SSH_DOMAIN = ${GIT_DOMAIN}
START_SSH_SERVER = false # 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] [database]
DB_TYPE = sqlite3 DB_TYPE = sqlite3
@ -851,7 +1003,10 @@ ROOT = ${FORGEJO_DATA}/repos
[service] [service]
DISABLE_REGISTRATION = true DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true # Public read: member profiles (git.${DOMAIN#*.}/<name>) 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 DEFAULT_KEEP_EMAIL_PRIVATE = true
[security] [security]
@ -897,6 +1052,9 @@ UNIT
systemctl is-active --quiet forgejo \ systemctl is-active --quiet forgejo \
|| warn "forgejo failed to start — check: journalctl -u forgejo -n50" || 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 # 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 # an admin-scoped token in agentbbs.env. Guarded on the token being empty so
# reruns never create duplicate tokens. # 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 \ --password "$FJ_ADMIN_PW" --must-change-password=false --config "$FORGEJO_CONF" >/dev/null 2>&1 \
|| true || true
FJ_TOKEN=$(sudo -u forgejo GITEA_WORK_DIR="$FORGEJO_DATA" /usr/local/bin/forgejo admin user generate-access-token \ 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) --config "$FORGEJO_CONF" 2>/dev/null | grep -oE '[0-9a-f]{40}' | head -1)
if [ -n "$FJ_TOKEN" ]; then if [ -n "$FJ_TOKEN" ]; then
upsert_env AGENTBBS_FORGEJO_URL "https://${GIT_DOMAIN}" upsert_env AGENTBBS_FORGEJO_URL "https://${GIT_DOMAIN}"
@ -921,18 +1079,25 @@ else
systemctl disable --now forgejo >/dev/null 2>&1 || true systemctl disable --now forgejo >/dev/null 2>&1 || true
fi 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 # Self-hosted Postfix+Dovecot+Roundcube+rspamd via Docker Compose. Mailu owns
# the mail ports; Caddy fronts the loopback webmail and supplies the TLS cert # 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, # (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of EVERY verified member
# DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0. # (free + paid) — addresses are <name>@${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" MAILU_DIR="${SRC_DIR}/deploy/mailu"
if [ "$MAIL" = "1" ]; then if [ "$MAIL" = "1" ]; then
log "configuring Mailu mail stack (${MAIL_DOMAIN})" log "configuring Mailu mail stack (server ${MAIL_DOMAIN}, addresses @${DOMAIN})"
# Tell agentbbs how to reach the mailbox backend (master user/pass are secrets # Tell agentbbs how to reach the mailbox backend (master user/pass + the Mailu
# the operator sets; see docs/mail.md). # API token are secrets the operator sets; see docs/mail.md).
upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}" 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" 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). # Cert refresher: copy Caddy's mail cert into Mailu on renewal (like news/IRC).