mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Merge remote-tracking branch 'origin/main' into feat/files-sftp
# Conflicts: # cmd/agentbbs/main.go # internal/auth/auth.go # internal/store/store.go
This commit is contained in:
commit
38e4797ee9
43 changed files with 2916 additions and 380 deletions
74
.github/workflows/deploy.yml
vendored
74
.github/workflows/deploy.yml
vendored
|
|
@ -1,10 +1,18 @@
|
|||
name: deploy
|
||||
|
||||
# Fully autonomous, idempotent deploy. On every push to main (or manual
|
||||
# dispatch) this SSHes to the bbs.profullstack.com droplet and re-runs the
|
||||
# idempotent provisioner (setup.sh), which pulls origin, rebuilds the Go
|
||||
# binaries, and restarts the agentbbs service that answers
|
||||
# `ssh join@bbs.profullstack.com`. Re-running is always safe.
|
||||
# dispatch) this builds the Go binaries ON THE RUNNER (which has plenty of
|
||||
# RAM), ships them to the bbs.profullstack.com droplet, and re-runs the
|
||||
# idempotent provisioner (setup.sh) with SKIP_BUILD=1 so the tiny droplet
|
||||
# never has to compile. setup.sh still pulls origin, refreshes config/assets,
|
||||
# and restarts the agentbbs service that answers `ssh join@bbs.profullstack.com`.
|
||||
# Re-running is always safe.
|
||||
#
|
||||
# Why build on the runner: the droplet is a ~458MB box also running ergo,
|
||||
# forgejo, tor, podman and the live agentbbs. The Go linker's peak memory was
|
||||
# OOM-killing the build — and with it the sshd serving the deploy session,
|
||||
# surfacing as "Connection closed by remote host" (exit 255). Compiling on the
|
||||
# 16GB runner removes that failure mode entirely.
|
||||
#
|
||||
# Required repo secrets (Settings -> Secrets and variables -> Actions):
|
||||
# DEPLOY_SSH_KEY private key whose public half is in the droplet admin
|
||||
|
|
@ -30,6 +38,8 @@ jobs:
|
|||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
|
|
@ -43,7 +53,55 @@ jobs:
|
|||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Provision / redeploy (idempotent)
|
||||
- name: Detect droplet architecture
|
||||
id: arch
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }}
|
||||
run: |
|
||||
uname_m="$(ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \
|
||||
-o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
"${DEPLOY_USER}@${DEPLOY_HOST}" 'uname -m')"
|
||||
case "$uname_m" in
|
||||
x86_64|amd64) goarch=amd64 ;;
|
||||
aarch64|arm64) goarch=arm64 ;;
|
||||
*) echo "::error::unsupported droplet arch '$uname_m'"; exit 1 ;;
|
||||
esac
|
||||
echo "goarch=$goarch" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::droplet arch $uname_m -> GOARCH=$goarch"
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build binaries (on the runner, not the droplet)
|
||||
env:
|
||||
GOOS: linux
|
||||
GOARCH: ${{ steps.arch.outputs.goarch }}
|
||||
CGO_ENABLED: '0' # pure-Go (modernc sqlite) — static, portable binary
|
||||
run: |
|
||||
mkdir -p dist
|
||||
go build -trimpath -o dist/agentbbs ./cmd/agentbbs
|
||||
go build -trimpath -o dist/ascii-live ./cmd/ascii-live
|
||||
file dist/* || true
|
||||
|
||||
- name: Ship binaries to the droplet
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }}
|
||||
run: |
|
||||
# scp can only name one remote target; copy each binary explicitly.
|
||||
scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \
|
||||
-o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
dist/agentbbs "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-agentbbs"
|
||||
scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \
|
||||
-o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
dist/ascii-live "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-ascii-live"
|
||||
|
||||
- name: Provision / redeploy (idempotent, SKIP_BUILD=1)
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }}
|
||||
|
|
@ -76,7 +134,11 @@ jobs:
|
|||
fi
|
||||
git -C "$SRC" fetch --depth 1 origin "$BRANCH"
|
||||
git -C "$SRC" reset --hard "origin/$BRANCH"
|
||||
exec env BRANCH="$BRANCH" \
|
||||
# Install the runner-built binaries, then tell setup.sh not to compile.
|
||||
install -m 0755 /tmp/agentbbs-deploy-agentbbs /usr/local/bin/agentbbs
|
||||
install -m 0755 /tmp/agentbbs-deploy-ascii-live /usr/local/bin/ascii-live
|
||||
rm -f /tmp/agentbbs-deploy-agentbbs /tmp/agentbbs-deploy-ascii-live
|
||||
exec env BRANCH="$BRANCH" SKIP_BUILD=1 \
|
||||
COINPAY_API_KEY="${COINPAY_API_KEY:-}" \
|
||||
COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \
|
||||
AGENTBBS_QRYPT_ISSUER_KEY="${AGENTBBS_QRYPT_ISSUER_KEY:-}" \
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -6,5 +6,11 @@
|
|||
*.log
|
||||
.env
|
||||
|
||||
# Mailu runtime: secrets, local network override, and state
|
||||
deploy/mailu/mailu.env
|
||||
deploy/mailu/docker-compose.override.yml
|
||||
deploy/mailu/data/
|
||||
deploy/mailu/certs/
|
||||
|
||||
# Claude Code local worktrees/state
|
||||
.claude/
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -114,17 +114,18 @@ name is an existing AgentBBS member (registration is off — your BBS account *i
|
|||
your IRC identity):
|
||||
|
||||
```bash
|
||||
# zero-setup: built-in client over SSH (members only)
|
||||
ssh -t irc@bbs.profullstack.com
|
||||
# native client — SASL account = your BBS member name
|
||||
# native TLS client — SASL account = your BBS member name
|
||||
/connect irc.bbs.profullstack.com 6697
|
||||
# browser / agent over WebSocket
|
||||
wss://bbs.profullstack.com/irc
|
||||
```
|
||||
|
||||
`ssh irc@` is a built-in IRC client (`internal/irc`) that authenticates you to
|
||||
the network automatically — no client to install. Set `IRC=0` to skip the
|
||||
server. Full details: [`docs/irc.md`](docs/irc.md).
|
||||
Members connect with **their own IRC client** (or a web client) — there is no
|
||||
in-BBS `ssh irc@` route. The network is **members-only** and every client must
|
||||
authenticate with SASL using their BBS account name (any passphrase — membership
|
||||
is the credential). Set `IRC=0` to skip the server.
|
||||
|
||||
Full details: [`docs/irc.md`](docs/irc.md).
|
||||
|
||||
### News (Usenet) server
|
||||
|
||||
|
|
|
|||
|
|
@ -77,11 +77,12 @@ func (r *liveReg) List() []admin.Live {
|
|||
// Kill closes a live session by id. Returns false if it is already gone.
|
||||
func (r *liveReg) Kill(id int64) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
e, ok := r.m[id]
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(r.m, id)
|
||||
_ = e.s.Close()
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@
|
|||
// emailed code, then offers $99 Founding Lifetime (CoinPay)
|
||||
// ssh pod@host your personal Linux pod — free for verified members
|
||||
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
|
||||
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
|
||||
// ssh <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,
|
||||
// agent-vs-agent (also on wss://host/play). See docs/agentgames.md
|
||||
//
|
||||
|
|
@ -28,6 +31,7 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -57,11 +61,11 @@ import (
|
|||
"github.com/profullstack/agentbbs/internal/chat"
|
||||
"github.com/profullstack/agentbbs/internal/files"
|
||||
"github.com/profullstack/agentbbs/internal/forgejo"
|
||||
"github.com/profullstack/agentbbs/internal/forwardemail"
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
"github.com/profullstack/agentbbs/internal/hub"
|
||||
"github.com/profullstack/agentbbs/internal/mail"
|
||||
"github.com/profullstack/agentbbs/internal/mailbox"
|
||||
"github.com/profullstack/agentbbs/internal/mailu"
|
||||
"github.com/profullstack/agentbbs/internal/news"
|
||||
"github.com/profullstack/agentbbs/internal/payments"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
|
|
@ -73,6 +77,8 @@ import (
|
|||
"github.com/profullstack/agentbbs/plugins/about"
|
||||
"github.com/profullstack/agentbbs/plugins/agentgames"
|
||||
"github.com/profullstack/agentbbs/plugins/arcade"
|
||||
"github.com/profullstack/agentbbs/plugins/hello"
|
||||
"github.com/profullstack/agentbbs/plugins/members"
|
||||
qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
|
||||
)
|
||||
|
||||
|
|
@ -94,22 +100,25 @@ func envInt(k string, def int) int {
|
|||
}
|
||||
|
||||
type app struct {
|
||||
st store.Store
|
||||
pods *pods.Manager // nil when no container engine on host
|
||||
sites *sites.Manager
|
||||
registry []plugin.Plugin
|
||||
sandbox *sandbox.Runner
|
||||
mail mail.Config
|
||||
fe forwardemail.Config // premium @bbs email provisioning
|
||||
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
|
||||
live *liveReg // in-memory live-session registry (admin console)
|
||||
files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0)
|
||||
gamesReg *games.Registry // AgentGames catalog
|
||||
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
|
||||
dataDir string
|
||||
assets string
|
||||
host string // public hostname used in user-facing messages
|
||||
newsAddr string // loopback NNTP address the news@ reader dials
|
||||
st store.Store
|
||||
pods *pods.Manager // nil when no container engine on host
|
||||
sites *sites.Manager
|
||||
registry []plugin.Plugin
|
||||
sandbox *sandbox.Runner
|
||||
mail mail.Config
|
||||
mailu *mailu.Client // member mailbox provisioning (nil when unconfigured)
|
||||
mailDomain string // email address domain, e.g. bbs.profullstack.com
|
||||
mailHost string // mail server host (IMAP/SMTP), e.g. mail.profullstack.com
|
||||
webmailURL string // webmail (Roundcube) URL shown to members
|
||||
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
|
||||
live *liveReg // in-memory live-session registry (admin console)
|
||||
files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0)
|
||||
gamesReg *games.Registry // AgentGames catalog
|
||||
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
|
||||
dataDir string
|
||||
assets string
|
||||
host string // public hostname used in user-facing messages
|
||||
newsAddr string // loopback NNTP address the news@ reader dials
|
||||
}
|
||||
|
||||
// Version is the agentbbs stack release, surfaced via `agentbbs version` and
|
||||
|
|
@ -152,28 +161,34 @@ func main() {
|
|||
}
|
||||
|
||||
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
|
||||
fe := forwardemail.ConfigFromEnv()
|
||||
if fe.Domain == "" {
|
||||
// Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com),
|
||||
// not the BBS host and not the apex (which is reserved for corporate mail).
|
||||
fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
|
||||
// Member email addresses are <name>@<addr-domain> (e.g. bbs.profullstack.com).
|
||||
// The mail server (IMAP/SMTP/webmail) lives on a dedicated host
|
||||
// (mail.profullstack.com); the apex is reserved for corporate mail.
|
||||
mailHost := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
|
||||
mailDomain := env("AGENTBBS_MAIL_ADDR_DOMAIN", host)
|
||||
mailuClient := mailu.NewFromEnv()
|
||||
if !mailuClient.Configured() {
|
||||
mailuClient = nil
|
||||
}
|
||||
a := &app{
|
||||
st: st,
|
||||
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
|
||||
mail: mail.ConfigFromEnv(),
|
||||
fe: fe,
|
||||
forgejo: forgejo.ConfigFromEnv(),
|
||||
live: newLiveReg(),
|
||||
dataDir: dataDir,
|
||||
assets: env("AGENTBBS_ASSETS", "./assets"),
|
||||
host: host,
|
||||
st: st,
|
||||
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
|
||||
mail: mail.ConfigFromEnv(),
|
||||
mailu: mailuClient,
|
||||
mailDomain: mailDomain,
|
||||
mailHost: mailHost,
|
||||
webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost),
|
||||
forgejo: forgejo.ConfigFromEnv(),
|
||||
live: newLiveReg(),
|
||||
dataDir: dataDir,
|
||||
assets: env("AGENTBBS_ASSETS", "./assets"),
|
||||
host: host,
|
||||
}
|
||||
a.gamesReg = games.Catalog()
|
||||
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
|
||||
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
|
||||
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
|
||||
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}}
|
||||
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}, hello.Plugin{}}
|
||||
|
||||
// Files (SFTP): per-user workspaces + a shared public area, reached over the
|
||||
// :22 listener via `sftp files@<host>` (docs/files.md). Disable with
|
||||
|
|
@ -346,6 +361,8 @@ func (a *app) router() wish.Middleware {
|
|||
a.handleMail(s)
|
||||
case auth.IsFilesAdminName(user):
|
||||
filesAdminHandler(s)
|
||||
case auth.IsMsgName(user):
|
||||
a.handleMsg(s)
|
||||
case isVideo:
|
||||
a.handleVideo(s, code)
|
||||
case user == "agent":
|
||||
|
|
@ -373,7 +390,11 @@ func (a *app) hubMOTD(u auth.User) string {
|
|||
return "You're browsing as a guest.\n" + body +
|
||||
"\nssh join@" + a.host + " to claim a username, a pod & a homepage."
|
||||
}
|
||||
return "Welcome back, " + u.Name + ".\n" + body
|
||||
welcome := "Welcome back, " + u.Name + "."
|
||||
if n, err := a.st.UnreadCount(u.Name); err == nil && n > 0 {
|
||||
welcome += fmt.Sprintf(" 📬 %d unread — open Members ▸ inbox (i).", n)
|
||||
}
|
||||
return welcome + "\n" + body
|
||||
}
|
||||
|
||||
// teaHandler builds the hub model for guests, members, and agents.
|
||||
|
|
@ -417,12 +438,22 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|||
// provisions their @host email alias on the transition).
|
||||
a.ensurePremium(&su)
|
||||
u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID}
|
||||
// Backfill the git.profullstack.com account + SSH key on login. Idempotent
|
||||
// and off the hot path: members who verified before AgentGit existed (or
|
||||
// before their key was registered) get provisioned on their next visit.
|
||||
if su.EmailVerified {
|
||||
suCopy, key := su, authorizedKey(s)
|
||||
go a.provisionGit(&suCopy, key)
|
||||
}
|
||||
}
|
||||
|
||||
sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub")
|
||||
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
|
||||
|
||||
ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets}
|
||||
ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets, Host: a.host}
|
||||
if pty, _, ok := s.Pty(); ok {
|
||||
ctx.Term = pty.Term // ncurses arcade games need the client's TERM
|
||||
}
|
||||
if u.Kind != auth.Guest {
|
||||
ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name)
|
||||
_ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755)
|
||||
|
|
@ -492,19 +523,22 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio
|
|||
Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }},
|
||||
})
|
||||
|
||||
// Mail — a Founding Lifetime Member perk: the AgentMail TUI.
|
||||
// Mail — a free benefit of membership: the AgentMail TUI for your
|
||||
// <name>@<mailDomain> mailbox.
|
||||
mailLock := ""
|
||||
switch {
|
||||
case guest:
|
||||
mailLock = membersOnly
|
||||
case !su.Premium:
|
||||
mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
|
||||
case !a.mailEnabled():
|
||||
mailLock = "mail is temporarily unavailable on this host"
|
||||
}
|
||||
apps = append(apps, hub.SessionApp{
|
||||
Title: "Mail",
|
||||
Description: "your " + a.fe.Domain + " mailbox",
|
||||
Description: "your " + a.mailAddress(su.Name) + " mailbox",
|
||||
Locked: mailLock,
|
||||
Cmd: sessionExec{run: func() error {
|
||||
// Make sure the mailbox exists before opening it.
|
||||
_ = a.ensureMailbox(su)
|
||||
c, err := a.mailClientFor(su)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -619,7 +653,7 @@ func (a *app) handleJoin(s ssh.Session) {
|
|||
}, "\n"))
|
||||
|
||||
// 1) email -> emailed code -> enter code. A verified account is a free
|
||||
// member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub.
|
||||
// member: it gets a Docker pod, a mailbox, IRC/news, and a /~name homepage.
|
||||
if !u.EmailVerified {
|
||||
if !a.verifyEmailInteractive(s, in, &u) {
|
||||
_ = s.Exit(1)
|
||||
|
|
@ -628,22 +662,41 @@ func (a *app) handleJoin(s ssh.Session) {
|
|||
a.notifySignup(u)
|
||||
}
|
||||
|
||||
// Every verified member gets a homepage at https://<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)
|
||||
_ = a.ensureMailbox(u)
|
||||
// Give them a webmail password so free members can log into webmail. The
|
||||
// in-BBS reader uses the gateway master user and needs no password, but
|
||||
// Roundcube does. (Re)set on each join@; they can change it in webmail.
|
||||
webmailPW := a.setWebmailPassword(u)
|
||||
|
||||
wish.Println(s, "\n"+strings.Join([]string{
|
||||
includes := []string{
|
||||
" You're in. One login gets you everything — no other servers to ssh into:",
|
||||
"",
|
||||
" ssh " + u.Name + "@" + a.host,
|
||||
"",
|
||||
" Inside, free membership includes:",
|
||||
" • your own Linux pod (a full shell)",
|
||||
" • email " + a.mailAddress(u.Name) + " (pick “Mail” in the hub)",
|
||||
" • IRC chat + Usenet/news (members-only)",
|
||||
" • the arcade & games",
|
||||
" • your homepage https://" + a.host + "/~" + u.Name,
|
||||
}, "\n"))
|
||||
}
|
||||
if a.webmailURL != "" && webmailPW != "" {
|
||||
includes = append(includes,
|
||||
"",
|
||||
" Webmail (read your mail in a browser):",
|
||||
" • url "+a.webmailURL,
|
||||
" • login "+a.mailAddress(u.Name),
|
||||
" • password "+webmailPW+" (change it in webmail Settings)",
|
||||
)
|
||||
} else if a.webmailURL != "" {
|
||||
includes = append(includes, " • webmail "+a.webmailURL)
|
||||
}
|
||||
wish.Println(s, "\n"+strings.Join(includes, "\n"))
|
||||
|
||||
// 2) Founding Lifetime ($99 one-time): personal @host email + custom domains.
|
||||
// 2) Founding Lifetime ($99 one-time): custom domains + Tor shell.
|
||||
a.offerPremium(s, &u)
|
||||
_ = s.Exit(0)
|
||||
}
|
||||
|
|
@ -793,7 +846,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
|
|||
}
|
||||
if ok {
|
||||
*u = vu
|
||||
a.provisionGit(u)
|
||||
a.provisionGit(u, authorizedKey(s))
|
||||
wish.Println(s, " Email confirmed ✓")
|
||||
return true
|
||||
}
|
||||
|
|
@ -803,10 +856,11 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
|
|||
return false
|
||||
}
|
||||
|
||||
// ensurePremium upgrades *u to premium if its CoinPay charge has settled,
|
||||
// provisioning the member's @host email alias on the transition. It is silent
|
||||
// (no session output) so it is safe to call from the hub. Returns the current
|
||||
// premium state.
|
||||
// ensurePremium upgrades *u to premium if its CoinPay charge has settled. It is
|
||||
// silent (no session output) so it is safe to call from the hub. Returns the
|
||||
// current premium state. Email is no longer a premium perk — every verified
|
||||
// member gets a mailbox (see ensureMailbox) — so this only unlocks custom
|
||||
// domains and the Tor shell.
|
||||
func (a *app) ensurePremium(u *store.User) bool {
|
||||
if u.Premium {
|
||||
return true
|
||||
|
|
@ -823,33 +877,25 @@ func (a *app) ensurePremium(u *store.User) bool {
|
|||
return false
|
||||
}
|
||||
u.Premium = true
|
||||
// Create their <name>@host alias forwarding to the email they verified.
|
||||
if a.fe.Configured() && u.Email != "" {
|
||||
if err := a.fe.CreateAlias(u.Name, u.Email); err != nil {
|
||||
log.Error("forwardemail alias", "err", err, "alias", a.fe.Address(u.Name))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail
|
||||
// URL, the in-hub Mail/Tor entries, and custom domains.
|
||||
// showPremiumWelcome prints a premium member's perks: custom domains and the
|
||||
// in-hub Tor shell. (Email is free for all members — see the join@ summary.)
|
||||
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
|
||||
lines := []string{
|
||||
"",
|
||||
" ★ Founding Lifetime Member — thanks! Your perks:",
|
||||
" ★ Founding Lifetime Member — thanks! Your bonus perks:",
|
||||
"",
|
||||
" mailbox " + a.fe.Address(u.Name),
|
||||
" webmail https://" + a.fe.Domain,
|
||||
" mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
|
||||
" domains ssh domain@" + a.host + " add <yourdomain.com>",
|
||||
" tor pick “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
|
||||
"",
|
||||
}
|
||||
wish.Println(s, strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// offerPremium pitches the $99 Founding Lifetime membership — a personal @host email and
|
||||
// custom domains. When CoinPay can mint a charge in-session it shows the exact
|
||||
// offerPremium pitches the $99 Founding Lifetime membership — custom domains and
|
||||
// the Tor shell. When CoinPay can mint a charge in-session it shows the exact
|
||||
// amount and deposit address; otherwise it falls back to a pay command.
|
||||
// Non-blocking: the member pays out of band and perks unlock on their next
|
||||
// connect (or re-running join@).
|
||||
|
|
@ -866,9 +912,9 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
|
|||
" ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time",
|
||||
" Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.",
|
||||
"",
|
||||
" Everything in your free membership stays free — founding adds these",
|
||||
" bonus features, forever:",
|
||||
" • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")",
|
||||
" Everything in your free membership stays free — including your",
|
||||
" " + a.mailAddress(u.Name) + " mailbox. Founding adds these bonus",
|
||||
" features, forever:",
|
||||
" • custom domains point yourdomain.com at your homepage",
|
||||
" • Tor a “Tor shell” in your pod — everything over Tor",
|
||||
" • locked-in price founding rate is yours for life — never renew, never pay again",
|
||||
|
|
@ -968,7 +1014,7 @@ func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) {
|
|||
"Run <code>ssh join@"+a.host+"</code> to get a fresh confirmation link.")))
|
||||
return
|
||||
}
|
||||
a.provisionGit(&u)
|
||||
a.provisionGit(&u, "") // web flow: no SSH session key; key is added on next BBS login
|
||||
_, _ = w.Write([]byte(verifyPage("Email confirmed ✓",
|
||||
"Welcome, "+u.Name+". Your account is active — <code>ssh "+u.Name+"@"+a.host+"</code>.")))
|
||||
}
|
||||
|
|
@ -978,7 +1024,7 @@ func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) {
|
|||
// alike; plan only affects quotas, enforced by AgentGit, not account existence.
|
||||
// Failures are logged but never block BBS verification, and it is a no-op when
|
||||
// Forgejo is unconfigured.
|
||||
func (a *app) provisionGit(u *store.User) {
|
||||
func (a *app) provisionGit(u *store.User, pubKey string) {
|
||||
if u == nil || !a.forgejo.Configured() || u.Name == "" || u.Email == "" {
|
||||
return
|
||||
}
|
||||
|
|
@ -990,6 +1036,40 @@ func (a *app) provisionGit(u *store.User) {
|
|||
if created {
|
||||
log.Info("provisioned git account", "user", u.Name, "host", a.forgejo.BaseURL)
|
||||
}
|
||||
// Register the BBS SSH key so the member can push with the same key they sign
|
||||
// in with. No-op when called without a session key (e.g. the web verify flow).
|
||||
if pubKey != "" {
|
||||
if added, err := a.forgejo.EnsureKey(u.Name, "agentbbs", pubKey); err != nil {
|
||||
log.Error("forgejo ssh key", "user", u.Name, "err", err)
|
||||
} else if added {
|
||||
log.Info("registered git ssh key", "user", u.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gitWelcomeEmailBody is the plain-text email sent by the notify-creds ops
|
||||
// command when a member's AgentGit (Forgejo) account is created or reset: web
|
||||
// login link, username, and the one-time password to change on first sign-in.
|
||||
func gitWelcomeEmailBody(name, password, loginURL string) string {
|
||||
return "Hi " + name + ",\n\n" +
|
||||
"Your git account is ready. Sign in to the web interface here:\n\n" +
|
||||
" " + loginURL + "\n\n" +
|
||||
" username: " + name + "\n" +
|
||||
" password: " + password + "\n\n" +
|
||||
"You'll be asked to set a new password the first time you sign in.\n" +
|
||||
"After that, click the \"+\" (top right) → \"New Repository\" to create repos.\n\n" +
|
||||
"Pushing over git uses your registered SSH key — no password needed.\n\n" +
|
||||
"If you didn't request this, you can ignore this email.\n"
|
||||
}
|
||||
|
||||
// authorizedKey renders the session's public key as a single authorized_keys
|
||||
// line, or "" when the session has no key (guests / keyboard-interactive).
|
||||
func authorizedKey(s ssh.Session) string {
|
||||
pk := s.PublicKey()
|
||||
if pk == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pk)))
|
||||
}
|
||||
|
||||
// verifyPage renders the minimal confirmation result page.
|
||||
|
|
@ -1289,29 +1369,95 @@ func (a *app) runNews(s ssh.Session, name string) error {
|
|||
return news.RunReader(s, addr, name)
|
||||
}
|
||||
|
||||
// mailClientFor builds a paid-gated AgentMail client for a member, connecting to
|
||||
// the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login
|
||||
// "<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
|
||||
// the IMAP connection/login fails.
|
||||
// mailAddress is a member's email address, e.g. alice@bbs.profullstack.com.
|
||||
func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain }
|
||||
|
||||
// mailEnabled reports whether member mailboxes can be provisioned (Mailu admin
|
||||
// API configured). When false the address is still shown but not created.
|
||||
func (a *app) mailEnabled() bool { return a.mailu.Configured() }
|
||||
|
||||
// ensureMailbox provisions the member's <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) {
|
||||
domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
|
||||
login := su.Name
|
||||
// Mailu keys mailboxes by full address, so the IMAP login (and the master
|
||||
// login "<addr>*<master>") must use the address, not the bare handle.
|
||||
login := a.mailAddress(su.Name)
|
||||
if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
|
||||
login = su.Name + "*" + master
|
||||
login = a.mailAddress(su.Name) + "*" + master
|
||||
}
|
||||
cfg := mailbox.IMAPConfig{
|
||||
IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", domain+":993"),
|
||||
IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"),
|
||||
SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
|
||||
Username: login,
|
||||
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
|
||||
// Mailu's front nginx pre-authenticates against its user DB before
|
||||
// proxying, which rejects the "<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.
|
||||
}
|
||||
tr, err := mailbox.NewIMAPTransport(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, domain, 50), nil
|
||||
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, a.mailDomain, 50), nil
|
||||
}
|
||||
|
||||
// handleMail routes a Founding Lifetime member into AgentMail: an interactive
|
||||
|
|
@ -1335,11 +1481,18 @@ func (a *app) handleMail(s ssh.Session) {
|
|||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if !a.ensurePremium(&u) {
|
||||
wish.Println(s, " mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@"+a.host)
|
||||
if !u.EmailVerified {
|
||||
wish.Println(s, " verify your email first: ssh -t join@"+a.host)
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if !a.mailEnabled() {
|
||||
wish.Println(s, " mail is temporarily unavailable on this host.")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
// Mail is a free benefit of membership — make sure the mailbox exists.
|
||||
_ = a.ensureMailbox(u)
|
||||
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
|
||||
defer func() { _ = a.st.EndSession(sessID) }()
|
||||
|
||||
|
|
@ -1449,6 +1602,62 @@ func (a *app) handleChat(s ssh.Session) {
|
|||
}
|
||||
}
|
||||
|
||||
// handleMsg is the member-to-member messaging route: `ssh msg@host <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
|
||||
// existing account name that isn't their own (e.g. ssh anthony@host).
|
||||
// Returns false when the route should fall through to the hub.
|
||||
|
|
|
|||
36
deploy/mailu/docker-compose.override.yml.example
Normal file
36
deploy/mailu/docker-compose.override.yml.example
Normal 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]
|
||||
|
|
@ -1,15 +1,24 @@
|
|||
# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env
|
||||
# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway).
|
||||
# Mailu configuration — copy to deploy/mailu/mailu.env and fill the secrets.
|
||||
# See docs/mail.md for the full setup (DNS, certs, gateway).
|
||||
#
|
||||
# Generate secrets with: openssl rand -hex 16
|
||||
#
|
||||
# NOTE: DOMAIN is the member ADDRESS domain (the @-part); HOSTNAMES is the mail
|
||||
# SERVER host (TLS/HELO + webmail/admin/API). These deliberately differ:
|
||||
# members get <name>@bbs.profullstack.com, served from mail.profullstack.com.
|
||||
|
||||
# --- General -----------------------------------------------------------------
|
||||
SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16
|
||||
DOMAIN=mail.profullstack.com # member addresses are <name>@mail.profullstack.com
|
||||
DOMAIN=bbs.profullstack.com # member addresses are <name>@bbs.profullstack.com
|
||||
HOSTNAMES=mail.profullstack.com,smtp.profullstack.com
|
||||
POSTMASTER=postmaster
|
||||
# Apex profullstack.com is reserved for corporate mail and is NOT served here.
|
||||
|
||||
# Admin REST API: agentbbs auto-provisions member mailboxes through it. Mirror
|
||||
# this value into the agentbbs service as AGENTBBS_MAIL_API_TOKEN.
|
||||
API=true
|
||||
API_TOKEN=CHANGEME_api_token # openssl rand -hex 24
|
||||
|
||||
# TLS_FLAVOR=mail: Mailu does NOT run its own ACME (Caddy owns :80/:443). We feed
|
||||
# it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh).
|
||||
TLS_FLAVOR=mail
|
||||
|
|
@ -32,13 +41,16 @@ MESSAGE_SIZE_LIMIT=52428800 # 50 MB
|
|||
# A Dovecot master user lets the agentbbs gateway open any member's mailbox with
|
||||
# one secret (login "<name>*<master>"). Created by deploy/mailu/provision-mailbox.sh.
|
||||
# Mirror these into the agentbbs service env:
|
||||
# AGENTBBS_MAIL_ADDR_DOMAIN=bbs.profullstack.com
|
||||
# AGENTBBS_MAIL_DOMAIN=mail.profullstack.com
|
||||
# AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993
|
||||
# AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25
|
||||
# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080
|
||||
# AGENTBBS_MAIL_API_TOKEN=<the API_TOKEN above>
|
||||
# AGENTBBS_MAIL_MASTER_USER=gateway
|
||||
# AGENTBBS_MAIL_MASTER_PASS=<the master password you set>
|
||||
|
||||
# --- Admin bootstrap ---------------------------------------------------------
|
||||
INITIAL_ADMIN_ACCOUNT=admin
|
||||
INITIAL_ADMIN_DOMAIN=mail.profullstack.com
|
||||
INITIAL_ADMIN_DOMAIN=bbs.profullstack.com
|
||||
INITIAL_ADMIN_PW=CHANGEME_admin_password
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
set -euo pipefail
|
||||
|
||||
MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}"
|
||||
DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}"
|
||||
# The address domain (the @-part), which may differ from the mail server host.
|
||||
DOMAIN="${MAIL_ADDR_DOMAIN:-${MAIL_DOMAIN:-bbs.profullstack.com}}"
|
||||
MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}"
|
||||
QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB
|
||||
|
||||
|
|
|
|||
146
docs/mail.md
146
docs/mail.md
|
|
@ -1,16 +1,22 @@
|
|||
# Mail — self-hosted Mailu at `mail.profullstack.com`
|
||||
# Mail — self-hosted Mailu
|
||||
|
||||
AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at
|
||||
`<name>@mail.profullstack.com`, reached two ways:
|
||||
AgentBBS gives **every verified member** (free and paid alike) a real mailbox at
|
||||
`<name>@bbs.profullstack.com`, reached two ways:
|
||||
|
||||
- **Webmail** — `https://mail.profullstack.com` (Roundcube), the only
|
||||
member-facing mail surface.
|
||||
- **Webmail** — `https://mail.profullstack.com` (Roundcube).
|
||||
- **AgentMail** — the in-BBS client (`internal/mailbox`): the `Mail` hub entry
|
||||
or `ssh mail@bbs.profullstack.com` (a TUI for humans, a JSON bot mode for
|
||||
agents). It connects to this stack.
|
||||
|
||||
Two distinct names are involved — don't conflate them:
|
||||
|
||||
| | value | role |
|
||||
|---|---|---|
|
||||
| **Address domain** | `bbs.profullstack.com` | the `@`-part of member addresses (`AGENTBBS_MAIL_ADDR_DOMAIN`) |
|
||||
| **Mail server host** | `mail.profullstack.com` | where IMAP/SMTP/webmail actually run (`AGENTBBS_MAIL_DOMAIN`) |
|
||||
|
||||
The apex `profullstack.com` is **reserved for corporate mail** and is not served
|
||||
here — member mail lives only on the `mail.` subdomain.
|
||||
here.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -19,97 +25,139 @@ Mailu (Postfix + Dovecot + Roundcube + rspamd) runs as a Docker Compose stack:
|
|||
|
||||
- Mailu owns the **mail ports** on the host: `25, 465, 587, 993, 995`.
|
||||
- Mailu's HTTP front is bound to **loopback** (`127.0.0.1:8080`); **Caddy**
|
||||
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin).
|
||||
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin + API).
|
||||
- **TLS:** `TLS_FLAVOR=mail` — Mailu does *not* run its own ACME (Caddy is the
|
||||
only ACME client). Caddy obtains the `mail.profullstack.com` cert from its site
|
||||
block; [`deploy/mailu/refresh-certs.sh`](../deploy/mailu/refresh-certs.sh)
|
||||
copies it into Mailu and reloads it on renewal — the same pattern as the
|
||||
Ergo/IRC and NNTP cert refreshers.
|
||||
only ACME client). Caddy obtains the `mail.profullstack.com` cert; the cert
|
||||
refresher copies it into Mailu and reloads on renewal.
|
||||
- The **agentbbs gateway** reads/sends on behalf of members: IMAP via a Dovecot
|
||||
**master user** (one secret opens any mailbox), SMTP via the co-located relay
|
||||
on `127.0.0.1:25`. Members therefore never manage an IMAP/SMTP password.
|
||||
- **Provisioning** is automatic: when a member verifies their email at `join@`
|
||||
(or opens `Mail`), agentbbs ensures `<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) ───────────┐
|
||||
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front, HTTP)
|
||||
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front: webmail/admin/API)
|
||||
└───────────────┬─────────────────────┘
|
||||
│ copies LE cert (refresh-certs.sh)
|
||||
clients → Mailu front (:25 :465 :587 :993 :995) ──→ Postfix / Dovecot / rspamd
|
||||
▲
|
||||
agentbbs ──IMAP 993 (master user)──┘ ──SMTP 127.0.0.1:25 (local relay)──▶
|
||||
agentbbs ──admin API (token) http://127.0.0.1:8080/api/v1──▶ (auto-provision)
|
||||
```
|
||||
|
||||
## DNS
|
||||
|
||||
`mail.profullstack.com` and `smtp.profullstack.com` A records are added. Also set:
|
||||
Mail is delivered to the **address domain** (`bbs.profullstack.com`), so its MX
|
||||
must point at the **server host** (`mail.profullstack.com`):
|
||||
|
||||
| Type | Host | Value |
|
||||
|---|---|---|
|
||||
| A | `mail.profullstack.com` | host IP |
|
||||
| A | `smtp.profullstack.com` | host IP |
|
||||
| MX | `mail.profullstack.com` | `10 mail.profullstack.com.` |
|
||||
| TXT (SPF) | `mail.profullstack.com` | `v=spf1 mx -all` |
|
||||
| TXT (DMARC) | `_dmarc.mail.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.profullstack.com` |
|
||||
| TXT (DKIM) | `dkim._domainkey.mail.profullstack.com` | from `flask mailu config-export` after first boot |
|
||||
| MX | `bbs.profullstack.com` | `10 mail.profullstack.com.` |
|
||||
| TXT (SPF) | `bbs.profullstack.com` | `v=spf1 mx -all` |
|
||||
| TXT (DMARC) | `_dmarc.bbs.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@bbs.profullstack.com` |
|
||||
| TXT (DKIM) | `dkim._domainkey.bbs.profullstack.com` | from `flask mailu config-export` after first boot |
|
||||
| PTR | host IP | `mail.profullstack.com` (set at your VPS provider) |
|
||||
|
||||
> **Port 25 / deliverability:** many cloud providers block outbound `:25` by
|
||||
> default — request an unblock, set the PTR/rDNS, and warm the IP, or relay
|
||||
> outbound through a smarthost. Inbound MX and the gateway's local submission
|
||||
> work regardless.
|
||||
> **Port 25 / deliverability:** many cloud providers (incl. DigitalOcean) block
|
||||
> outbound `:25` by default — request an unblock, set the PTR/rDNS, and warm the
|
||||
> IP, or relay outbound through a smarthost. Inbound MX and the gateway's local
|
||||
> submission work regardless.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd /opt/agentbbs/deploy/mailu
|
||||
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, etc.
|
||||
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, API_TOKEN, DOMAIN=bbs.profullstack.com, HOSTNAMES=mail.profullstack.com
|
||||
docker compose up -d
|
||||
# seed the gateway master user + (optionally) backfill member mailboxes:
|
||||
# add the address domain + the gateway master user:
|
||||
docker compose exec admin flask mailu domain bbs.profullstack.com
|
||||
AGENTBBS_MAIL_MASTER_USER=gateway ./provision-mailbox.sh --master "$(openssl rand -hex 16)"
|
||||
```
|
||||
|
||||
Add the Caddy site (setup.sh writes this when `MAIL=1`):
|
||||
|
||||
```
|
||||
mail.profullstack.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
Then install the cert refresher on a timer (setup.sh does this too):
|
||||
|
||||
```bash
|
||||
install -m 0755 deploy/mailu/refresh-certs.sh /usr/local/bin/agentbbs-mailu-certs
|
||||
# systemd timer runs it every ~12h; first run swaps in the real cert once Caddy issues it.
|
||||
```
|
||||
setup.sh writes the Caddy `mail.profullstack.com` site and the cert-refresh
|
||||
timer when `MAIL=1`, and brings the stack up once `mailu.env` exists.
|
||||
|
||||
## agentbbs gateway env
|
||||
|
||||
Set these on the agentbbs service so the `Mail` hub entry / `ssh mail@` work:
|
||||
Set these on the agentbbs service (setup.sh §9e upserts the non-secret ones):
|
||||
|
||||
| Var | Value |
|
||||
|---|---|
|
||||
| `AGENTBBS_MAIL_ADDR_DOMAIN` | `bbs.profullstack.com` |
|
||||
| `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` |
|
||||
| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` |
|
||||
| `AGENTBBS_MAIL_IMAP_ADDR` | `127.0.0.1:14143` (Dovecot direct, loopback) |
|
||||
| `AGENTBBS_MAIL_IMAP_PLAINTEXT` | `1` (the loopback path is plaintext) |
|
||||
| `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` |
|
||||
| `AGENTBBS_MAIL_ADMIN_URL` | `http://127.0.0.1:8080` |
|
||||
| `AGENTBBS_MAIL_API_TOKEN` | the Mailu `API_TOKEN` (secret) |
|
||||
| `AGENTBBS_MAIL_MASTER_USER` | `gateway` |
|
||||
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above |
|
||||
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above (secret) |
|
||||
| `AGENTBBS_WEBMAIL_URL` | `https://mail.profullstack.com` (default = mail host) |
|
||||
|
||||
Without `AGENTBBS_MAIL_API_TOKEN` auto-provisioning is skipped (the address is
|
||||
still shown); without `AGENTBBS_MAIL_MASTER_PASS` the gateway can't open
|
||||
mailboxes.
|
||||
|
||||
### Why the gateway talks to Dovecot directly (plaintext loopback)
|
||||
|
||||
Mailu's **front** (nginx mail proxy) pre-authenticates every IMAP/SMTP login
|
||||
against Mailu's user DB before proxying to Dovecot — and it rejects the Dovecot
|
||||
master-user login form `<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
|
||||
|
||||
A mailbox must exist before the gateway can open it. Provision when a member
|
||||
becomes paid (or backfill):
|
||||
Provisioning is automatic at `join@` verification. To create or backfill by hand:
|
||||
|
||||
```bash
|
||||
deploy/mailu/provision-mailbox.sh alice # creates alice@mail.profullstack.com
|
||||
deploy/mailu/provision-mailbox.sh alice # creates alice@bbs.profullstack.com
|
||||
```
|
||||
|
||||
The Dovecot **master user** (`gateway`) then authenticates as any member with
|
||||
the login form `alice*gateway` + the master password — which is exactly what
|
||||
(Set `MAIL_DOMAIN=bbs.profullstack.com` for the script, since the address domain
|
||||
differs from the server host.)
|
||||
|
||||
The Dovecot **master user** (`gateway`) authenticates as any member with the
|
||||
login form `alice*gateway` + the master password — exactly what
|
||||
`internal/mailbox`'s IMAP adapter sends. See
|
||||
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for the master-user
|
||||
override and operational details.
|
||||
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for details.
|
||||
|
||||
## Webmail only for members
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
// Live is one connected SSH session, as seen by the registry.
|
||||
|
|
@ -70,13 +71,13 @@ var menuItems = []struct {
|
|||
}
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
headStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
|
||||
frameStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||||
dimStyle = ui.Dim
|
||||
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||||
warnStyle = ui.Danger
|
||||
okStyle = lipgloss.NewStyle().Foreground(ui.Green)
|
||||
headStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
|
||||
frameStyle = ui.Frame
|
||||
)
|
||||
|
||||
// Model is the admin console.
|
||||
|
|
@ -306,7 +307,7 @@ func (m Model) View() string {
|
|||
}
|
||||
|
||||
header := titleStyle.Render("AgentBBS admin") + dimStyle.Render(" · "+m.admin.Name)
|
||||
out := header + "\n\n" + body + "\n" + dimStyle.Render(help)
|
||||
out := header + "\n\n" + body + "\n" + ui.KeyBar(help)
|
||||
if m.note != "" {
|
||||
out += "\n" + m.note
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,9 @@ var DomainNames = map[string]bool{"domain": true, "domains": true}
|
|||
|
||||
// AdminNames are usernames that route to the privileged admin console (PRD §6).
|
||||
// The route only opens for accounts whose name is in the operator allowlist
|
||||
// (see IsAdmin); the name itself confers nothing.
|
||||
var AdminNames = map[string]bool{"admin": true, "sysop": true}
|
||||
// (see IsAdmin); the name itself confers nothing — so "root" is just a familiar
|
||||
// alias here, not a backdoor.
|
||||
var AdminNames = map[string]bool{"admin": true, "sysop": true, "root": true}
|
||||
|
||||
// TorURLNames route to the one-shot "fetch a URL over Tor" command (premium).
|
||||
var TorURLNames = map[string]bool{"tor-url": true}
|
||||
|
|
@ -112,6 +113,13 @@ func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
|
|||
// management TUI (operator-gated).
|
||||
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
|
||||
|
||||
// MsgNames route a member-to-member message: `ssh msg@host <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
|
||||
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
||||
// infra hostnames — so members may not claim them as account names.
|
||||
|
|
@ -129,7 +137,7 @@ func IsReservedName(name string) bool {
|
|||
n := strings.ToLower(name)
|
||||
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
|
||||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
|
||||
MailNames[n] || FilesAdminNames[n] || systemReserved[n] {
|
||||
MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || systemReserved[n] {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package auth
|
|||
import "testing"
|
||||
|
||||
func TestIsAdminName(t *testing.T) {
|
||||
for _, name := range []string{"admin", "ADMIN", "sysop"} {
|
||||
for _, name := range []string{"admin", "ADMIN", "sysop", "root"} {
|
||||
if !IsAdminName(name) {
|
||||
t.Errorf("IsAdminName(%q) = false, want true", name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,58 @@ func (c Config) EnsureUser(username, email string) (created bool, err error) {
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// EnsureKey registers an SSH public key on the member's Forgejo account so the
|
||||
// key they use for the BBS is also their git push key ("BBS membership is the
|
||||
// git account"). It is idempotent: added is false when the same key material is
|
||||
// already present. A blank key is a no-op. title labels the key in Forgejo.
|
||||
func (c Config) EnsureKey(username, title, pubKey string) (added bool, err error) {
|
||||
if !c.Configured() {
|
||||
return false, fmt.Errorf("forgejo not configured")
|
||||
}
|
||||
pubKey = strings.TrimSpace(pubKey)
|
||||
if pubKey == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Skip if this key (ignoring the trailing comment) is already on the account.
|
||||
if status, resp, e := c.do(http.MethodGet, "/users/"+username+"/keys", nil); e == nil && status == http.StatusOK {
|
||||
var keys []struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if json.Unmarshal([]byte(resp), &keys) == nil {
|
||||
want := keyMaterial(pubKey)
|
||||
for _, k := range keys {
|
||||
if keyMaterial(k.Key) == want {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"title": title, "key": pubKey, "read_only": false})
|
||||
status, resp, err := c.do(http.MethodPost, "/admin/users/"+username+"/keys", body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if status == http.StatusUnprocessableEntity {
|
||||
return false, nil // key already exists (raced or comment differs)
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return false, fmt.Errorf("forgejo add key %q: %d: %s", username, status, truncate(resp, 200))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// keyMaterial returns the type+base64 of an authorized-key line, dropping the
|
||||
// optional comment so the same key compares equal regardless of how it's labeled.
|
||||
func keyMaterial(authorizedKey string) string {
|
||||
f := strings.Fields(strings.TrimSpace(authorizedKey))
|
||||
if len(f) >= 2 {
|
||||
return f[0] + " " + f[1]
|
||||
}
|
||||
return strings.TrimSpace(authorizedKey)
|
||||
}
|
||||
|
||||
// userExists reports whether a Forgejo user with this name is present.
|
||||
func (c Config) userExists(username string) (bool, error) {
|
||||
status, resp, err := c.do(http.MethodGet, "/users/"+username, nil)
|
||||
|
|
|
|||
|
|
@ -93,3 +93,65 @@ func TestEnsureUserNoOpWhenExists(t *testing.T) {
|
|||
t.Fatal("must not POST when the user already exists")
|
||||
}
|
||||
}
|
||||
|
||||
const aliceKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY alice@bbs"
|
||||
|
||||
func TestEnsureKeyAddsWhenMissing(t *testing.T) {
|
||||
var posted map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/alice/keys":
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/alice/keys":
|
||||
_ = json.NewDecoder(r.Body).Decode(&posted)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
default:
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Config{BaseURL: srv.URL, Token: "secret"}
|
||||
added, err := c.EnsureKey("alice", "agentbbs", aliceKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
if !added {
|
||||
t.Fatal("expected added=true")
|
||||
}
|
||||
if posted["key"] != aliceKey {
|
||||
t.Errorf("posted key = %v", posted["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyIdempotentIgnoringComment(t *testing.T) {
|
||||
posted := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
posted = true
|
||||
}
|
||||
// Same key material, different comment — must be treated as already present.
|
||||
_, _ = w.Write([]byte(`[{"key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY different-comment"}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Config{BaseURL: srv.URL, Token: "secret"}
|
||||
added, err := c.EnsureKey("alice", "agentbbs", aliceKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
if added {
|
||||
t.Fatal("expected added=false when key material already present")
|
||||
}
|
||||
if posted {
|
||||
t.Fatal("must not POST when the key already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyBlankIsNoOp(t *testing.T) {
|
||||
c := Config{BaseURL: "https://git.example.com", Token: "t"}
|
||||
if added, err := c.EnsureKey("alice", "agentbbs", " "); err != nil || added {
|
||||
t.Fatalf("blank key should be a silent no-op, got added=%v err=%v", added, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,21 +19,12 @@ import (
|
|||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
selStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0"))
|
||||
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
theme = ui.New(ui.Green)
|
||||
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a"))
|
||||
motdStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#4ade80")).
|
||||
Foreground(lipgloss.Color("252")).
|
||||
Padding(0, 1)
|
||||
frameStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||
)
|
||||
|
||||
// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC
|
||||
|
|
@ -170,42 +161,37 @@ func (m Model) View() string {
|
|||
b.WriteString(bannerStyle.Render(m.banner) + "\n\n")
|
||||
}
|
||||
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
|
||||
b.WriteString(titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n")
|
||||
b.WriteString(theme.Title("AgentBBS") + ui.Dim.Render(" · "+who) + "\n")
|
||||
if m.motd != "" {
|
||||
b.WriteString("\n" + motdStyle.Render(m.motd) + "\n")
|
||||
b.WriteString("\n" + theme.Card("", m.motd) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
row := 0
|
||||
for _, p := range m.plugins {
|
||||
label := p.Title()
|
||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||
label += lockStyle.Render(" [members]")
|
||||
if len(m.plugins) > 0 {
|
||||
b.WriteString(theme.Section("Features") + "\n")
|
||||
for _, p := range m.plugins {
|
||||
badge := ""
|
||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||
badge = ui.Badge(ui.BadgeMuted, "members")
|
||||
}
|
||||
b.WriteString(theme.MenuItem(row == m.cursor, p.Title(), badge, p.Description()))
|
||||
row++
|
||||
}
|
||||
b.WriteString(m.renderRow(row, label, p.Description()))
|
||||
row++
|
||||
}
|
||||
for _, app := range m.apps {
|
||||
label := app.Title
|
||||
if app.Locked != "" {
|
||||
label += lockStyle.Render(" [locked]")
|
||||
if len(m.apps) > 0 {
|
||||
b.WriteString("\n" + theme.Section("Sessions") + "\n")
|
||||
for _, app := range m.apps {
|
||||
badge := ""
|
||||
if app.Locked != "" {
|
||||
badge = ui.Badge(ui.BadgeGold, "locked")
|
||||
}
|
||||
b.WriteString(theme.MenuItem(row == m.cursor, app.Title, badge, app.Description))
|
||||
row++
|
||||
}
|
||||
b.WriteString(m.renderRow(row, label, app.Description))
|
||||
row++
|
||||
}
|
||||
b.WriteString("\n" + dimStyle.Render("↑/↓ move · enter select · ctrl+c back · q quit"))
|
||||
b.WriteString("\n" + ui.KeyBar("↑/↓ move · enter select · ctrl+c back · q quit"))
|
||||
if m.note != "" {
|
||||
b.WriteString("\n" + lockStyle.Render(m.note))
|
||||
b.WriteString("\n" + ui.Danger.Render(m.note))
|
||||
}
|
||||
return frameStyle.Render(b.String())
|
||||
}
|
||||
|
||||
// renderRow renders one menu line with the cursor and dimmed description. The
|
||||
// selected row's cursor and label are highlighted.
|
||||
func (m Model) renderRow(i int, label, desc string) string {
|
||||
cur := " "
|
||||
if i == m.cursor {
|
||||
cur = cursorStyle.Render("❯ ")
|
||||
label = selStyle.Render(label)
|
||||
}
|
||||
return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc))
|
||||
return ui.Frame.Render(b.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ func RunBot(ctx context.Context, c *Client, args []string, in io.Reader, out io.
|
|||
if len(args) > 3 {
|
||||
on = !strings.EqualFold(args[3], "off") && args[3] != "false" && args[3] != "0"
|
||||
}
|
||||
if args[0] == "flag" {
|
||||
if strings.ToLower(args[0]) == "flag" {
|
||||
err = c.Flag(ctx, mailbox, uid, on)
|
||||
} else {
|
||||
err = c.MarkSeen(ctx, mailbox, uid, on)
|
||||
|
|
|
|||
|
|
@ -7,16 +7,19 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// Identity is the acting member and whether they hold the paid membership.
|
||||
// Identity is the acting member. AgentMail is a free benefit of membership, so
|
||||
// having a registered handle is the only requirement; Paid is retained for
|
||||
// tier-aware features (e.g. quotas) but no longer gates access.
|
||||
type Identity struct {
|
||||
Name string // local-part / handle, e.g. "alice"
|
||||
Paid bool // Founding Lifetime Member; mail is gated on this
|
||||
Paid bool // Founding Lifetime Member (informational; does not gate mail)
|
||||
}
|
||||
|
||||
// ErrNotPaid is returned to a non-paid member attempting a mail action.
|
||||
var ErrNotPaid = errors.New("AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com")
|
||||
// ErrNotMember is returned when a caller without a registered handle attempts a
|
||||
// mail action. AgentMail is open to every verified member.
|
||||
var ErrNotMember = errors.New("AgentMail is a member feature — register first: ssh join@bbs.profullstack.com")
|
||||
|
||||
// Client is the ergonomic, paid-gated facade the TUI and bot mode use. Every
|
||||
// Client is the ergonomic, member-gated facade the TUI and bot mode use. Every
|
||||
// method returns plain structs, so the same calls serve humans and agents.
|
||||
type Client struct {
|
||||
t Transport
|
||||
|
|
@ -25,8 +28,8 @@ type Client struct {
|
|||
pageSize int
|
||||
}
|
||||
|
||||
// NewClient builds a paid-gated client. domain is the mail domain (e.g.
|
||||
// mail.profullstack.com); pageSize defaults to 50 when <= 0.
|
||||
// NewClient builds a member-gated client. domain is the email address domain
|
||||
// (e.g. bbs.profullstack.com); pageSize defaults to 50 when <= 0.
|
||||
func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
|
|
@ -34,12 +37,12 @@ func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
|
|||
return &Client{t: t, id: id, domain: domain, pageSize: pageSize}
|
||||
}
|
||||
|
||||
// Address is the member's own mailbox address, e.g. alice@mail.profullstack.com.
|
||||
// Address is the member's own mailbox address, e.g. alice@bbs.profullstack.com.
|
||||
func (c *Client) Address() string { return c.id.Name + "@" + c.domain }
|
||||
|
||||
func (c *Client) gate() error {
|
||||
if c.id.Name == "" || !c.id.Paid {
|
||||
return ErrNotPaid
|
||||
if c.id.Name == "" {
|
||||
return ErrNotMember
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ type IMAPConfig struct {
|
|||
// SMTPUser/SMTPPass default to Username/Password when empty.
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
// Plaintext dials IMAP without TLS. Used only for a co-located backend over
|
||||
// loopback (the Mailu gateway hitting Dovecot directly on 127.0.0.1, bypassing
|
||||
// the front's auth proxy so master-user login works) — the password never
|
||||
// leaves the host. Never enable it for a remote server.
|
||||
Plaintext bool
|
||||
}
|
||||
|
||||
// imapTransport is a Transport backed by a single authenticated IMAP connection
|
||||
|
|
@ -38,7 +43,11 @@ type imapTransport struct {
|
|||
|
||||
// NewIMAPTransport dials the IMAP server, logs in, and returns a Transport.
|
||||
func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
|
||||
c, err := imapclient.DialTLS(cfg.IMAPAddr, nil)
|
||||
dial := imapclient.DialTLS
|
||||
if cfg.Plaintext {
|
||||
dial = imapclient.DialInsecure
|
||||
}
|
||||
c, err := dial(cfg.IMAPAddr, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ func seeded() *MemoryTransport {
|
|||
}
|
||||
|
||||
func paidClient(t Transport) *Client {
|
||||
return NewClient(t, Identity{Name: "alice", Paid: true}, "mail.profullstack.com", 50)
|
||||
return NewClient(t, Identity{Name: "alice", Paid: true}, "bbs.profullstack.com", 50)
|
||||
}
|
||||
|
||||
func TestParseFormatAddress(t *testing.T) {
|
||||
|
|
@ -45,9 +45,15 @@ func TestValidEmailAndDraft(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGate(t *testing.T) {
|
||||
c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "mail.profullstack.com", 0)
|
||||
if _, err := c.Inbox(context.Background(), 0); !errors.Is(err, ErrNotPaid) {
|
||||
t.Fatalf("expected ErrNotPaid, got %v", err)
|
||||
// A free member (Paid: false) now has full mail access.
|
||||
c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "bbs.profullstack.com", 0)
|
||||
if _, err := c.Inbox(context.Background(), 0); err != nil {
|
||||
t.Fatalf("free member should have mail access, got %v", err)
|
||||
}
|
||||
// Only a caller without a registered handle is rejected.
|
||||
anon := NewClient(seeded(), Identity{Name: "", Paid: true}, "bbs.profullstack.com", 0)
|
||||
if _, err := anon.Inbox(context.Background(), 0); !errors.Is(err, ErrNotMember) {
|
||||
t.Fatalf("expected ErrNotMember, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +112,7 @@ func TestSendAndReply(t *testing.T) {
|
|||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
|
||||
if len(sent) != 1 || sent[0].From.Address != "alice@mail.profullstack.com" || sent[0].Subject != "Hi" {
|
||||
if len(sent) != 1 || sent[0].From.Address != "alice@bbs.profullstack.com" || sent[0].Subject != "Hi" {
|
||||
t.Fatalf("sent: %+v", sent)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Package mailbox is the BBS-side mail client for Founding Lifetime members: a
|
||||
// transport-agnostic core (read, search, compose, send, flag, delete) with a
|
||||
// Bubble Tea TUI for humans and a line-oriented JSON mode for agents/bots. It
|
||||
// talks to the self-hosted Mailu stack (Dovecot IMAP + Postfix submission) at
|
||||
// mail.profullstack.com / smtp.profullstack.com.
|
||||
// Package mailbox is the BBS-side mail client for members (a free benefit of
|
||||
// membership): a transport-agnostic core (read, search, compose, send, flag,
|
||||
// delete) with a Bubble Tea TUI for humans and a line-oriented JSON mode for
|
||||
// agents/bots. Addresses are <name>@bbs.profullstack.com; it talks to the
|
||||
// self-hosted Mailu stack (Dovecot IMAP + Postfix submission) hosted on
|
||||
// mail.profullstack.com.
|
||||
//
|
||||
// The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are
|
||||
// deliberately the same so tooling can move between them.
|
||||
|
|
|
|||
197
internal/mailu/mailu.go
Normal file
197
internal/mailu/mailu.go
Normal 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
|
||||
}
|
||||
124
internal/mailu/mailu_test.go
Normal file
124
internal/mailu/mailu_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -173,15 +173,17 @@ func parseRange(spec string) (low, high int64) {
|
|||
if len(parts) == 1 {
|
||||
h, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
h = math.MaxInt64
|
||||
return 0, h
|
||||
return 0, 0 // malformed — empty range instead of all articles
|
||||
}
|
||||
return h, h
|
||||
}
|
||||
l, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
l, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0 // malformed — empty range
|
||||
}
|
||||
h, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
h = math.MaxInt64
|
||||
return 0, 0 // malformed — empty range
|
||||
}
|
||||
return l, h
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,15 +8,16 @@ import (
|
|||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/dustin/go-nntp"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
var (
|
||||
nTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc"))
|
||||
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(lipgloss.Color("#38bdf8"))
|
||||
nMeta = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
nFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
nErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
nHint = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||
theme = ui.New(ui.Purple)
|
||||
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(ui.Cyan)
|
||||
nMeta = ui.Dim
|
||||
nFrom = lipgloss.NewStyle().Foreground(ui.Green)
|
||||
nErr = ui.Danger
|
||||
)
|
||||
|
||||
// RunReader connects the member to the loopback NNTP server and drives the
|
||||
|
|
@ -311,7 +312,7 @@ func (m *model) frame(header, body, hint string) string {
|
|||
status = "\n" + nMeta.Render(m.status)
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(
|
||||
nTitle.Render(header) + "\n\n" + body + status + "\n\n" + nHint.Render(hint))
|
||||
theme.Title(header) + "\n\n" + body + status + "\n\n" + ui.KeyBar(hint))
|
||||
}
|
||||
|
||||
func (m *model) viewGroups() string {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ type Context struct {
|
|||
DataDir string
|
||||
// AssetsDir is the read-only platform assets tree (wads, binaries).
|
||||
AssetsDir string
|
||||
// Host is the BBS hostname (e.g. bbs.profullstack.com), for building
|
||||
// member homepage URLs (https://Host/~name) and similar links.
|
||||
Host string
|
||||
// Term is the client PTY's terminal type (e.g. xterm-256color). Needed by
|
||||
// sandboxed ncurses games (Space Invaders, Pac-Man, Tetris, Moon Patrol),
|
||||
// which call initscr() and fail with "Error opening terminal" if TERM is
|
||||
// unset — the systemd daemon environment has no TERM to inherit.
|
||||
Term string
|
||||
}
|
||||
|
||||
// Plugin is the only integration point between a feature and the hub.
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@
|
|||
package pods
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -87,6 +90,69 @@ func (m *Manager) hasMount(name, dest string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// hasImage reports whether the named container is running the given image.
|
||||
// Used to roll out a new pod image: a mismatch triggers an idle recreate so
|
||||
// members pick up added tooling without losing their home volume. A blank or
|
||||
// unresolvable image name is treated as a match (never heal on uncertainty).
|
||||
func (m *Manager) hasImage(name, image string) bool {
|
||||
if image == "" {
|
||||
return true
|
||||
}
|
||||
out, err := exec.Command(m.engine, "container", "inspect", "-f", "{{.ImageName}}", name).Output()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
got := strings.TrimSpace(string(out))
|
||||
// Normalize: inspect may report "localhost/agentbbs-pod:latest" while m.image
|
||||
// is the same; also tolerate the docker.io/library/ prefix podman adds.
|
||||
norm := func(s string) string {
|
||||
s = strings.TrimPrefix(s, "docker.io/library/")
|
||||
s = strings.TrimPrefix(s, "docker.io/")
|
||||
s = strings.TrimPrefix(s, "localhost/")
|
||||
return s
|
||||
}
|
||||
return norm(got) == norm(image)
|
||||
}
|
||||
|
||||
// agentDir is the host directory bind-mounted into a pod at /run/agentbbs-agent,
|
||||
// where Attach drops a forwarded SSH-agent socket. Derived as <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.
|
||||
func (m *Manager) Engine() string { return m.engine }
|
||||
|
||||
|
|
@ -102,6 +168,9 @@ func (m *Manager) ensure(user string) (string, error) {
|
|||
// Bind the host's public_html into the pod so a member's edits at
|
||||
// ~/public_html are exactly what Caddy serves at <name>.<host>.
|
||||
_, pubSpec := m.publicHTMLMount(user)
|
||||
// Bind a per-user agent dir into the pod; Attach drops a forwarded SSH-agent
|
||||
// socket here so `git push` uses the member's own key (see startAgent).
|
||||
agentDir := m.agentDir(user)
|
||||
if m.engine == "docker" {
|
||||
// Under docker the pod runs as uid 1000 (never container root), so the
|
||||
// named home volume — and the bind-mounted public_html — must be owned
|
||||
|
|
@ -131,11 +200,17 @@ func (m *Manager) ensure(user string) (string, error) {
|
|||
m.mu.Lock()
|
||||
idle := m.attached[name] == 0
|
||||
m.mu.Unlock()
|
||||
if pubSpec != "" && idle && !m.hasMount(name, "/home/dev/public_html") {
|
||||
_ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate with the bind
|
||||
// Recreate an idle pod when it's missing the public_html bind OR is
|
||||
// running an out-of-date image (e.g. a new pod image with added tooling).
|
||||
// The home volume persists across rm, so member data is kept; a busy pod
|
||||
// heals on its next idle attach instead.
|
||||
needsHeal := idle && ((pubSpec != "" && !m.hasMount(name, "/home/dev/public_html")) ||
|
||||
(agentDir != "" && !m.hasMount(name, "/run/agentbbs-agent")) ||
|
||||
!m.hasImage(name, m.image))
|
||||
if needsHeal {
|
||||
_ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate
|
||||
} else {
|
||||
_ = exec.Command(m.engine, "start", name).Run() // no-op if running
|
||||
m.tuneApt(name)
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -154,6 +229,9 @@ func (m *Manager) ensure(user string) (string, error) {
|
|||
if pubSpec != "" {
|
||||
args = append(args, "-v", pubSpec)
|
||||
}
|
||||
if agentDir != "" {
|
||||
args = append(args, "-v", agentDir+":/run/agentbbs-agent")
|
||||
}
|
||||
if m.engine == "docker" {
|
||||
// Rootful docker: a breakout is host-root, so refuse to hand out
|
||||
// container root — run as uid 1000 with no caps and no privilege
|
||||
|
|
@ -178,28 +256,9 @@ func (m *Manager) ensure(user string) (string, error) {
|
|||
if err != nil {
|
||||
return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
m.tuneApt(name)
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// tuneApt makes apt usable inside the hardened pod. apt drops privileges to
|
||||
// the _apt user for downloads (setgroups/setegid/seteuid), which needs
|
||||
// CAP_SETUID/CAP_SETGID/CAP_CHOWN — caps we intentionally drop (cap-drop ALL).
|
||||
// Rather than re-grant those to the whole container, disable apt's download
|
||||
// sandbox so package management runs as the pod's (rootless-mapped) root.
|
||||
//
|
||||
// Only applies to the podman/container-root path; under docker the pod runs as
|
||||
// uid 1000 and can't write /etc/apt (apt isn't usable there by design). Failure
|
||||
// is non-fatal: a missing config just means the user sees the old apt errors.
|
||||
func (m *Manager) tuneApt(name string) {
|
||||
if m.engine == "docker" {
|
||||
return
|
||||
}
|
||||
_ = exec.Command(m.engine, "exec", "--user", "root", name,
|
||||
"sh", "-c", `printf 'APT::Sandbox::User "root";\n' > /etc/apt/apt.conf.d/00no-sandbox`,
|
||||
).Run()
|
||||
}
|
||||
|
||||
// Attach provisions the pod and wires the SSH session to a shell inside it.
|
||||
// Blocks until the shell exits or the session closes.
|
||||
func (m *Manager) Attach(s ssh.Session, user string) error {
|
||||
|
|
@ -212,14 +271,22 @@ func (m *Manager) Attach(s ssh.Session, user string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Forward the client's SSH agent (ssh -A) into the pod so git push uses the
|
||||
// member's own key. No-op unless the client requested forwarding.
|
||||
execEnv := []string{"-e", "TERM=" + ptyReq.Term}
|
||||
if ssh.AgentRequested(s) {
|
||||
if sock, cleanup := m.startAgent(s, user); sock != "" {
|
||||
defer cleanup()
|
||||
execEnv = append(execEnv, "-e", "SSH_AUTH_SOCK="+sock)
|
||||
}
|
||||
}
|
||||
|
||||
shell := env("AGENTBBS_POD_SHELL", "/bin/bash")
|
||||
cmd := exec.Command(m.engine, "exec", "-it",
|
||||
"-e", "TERM="+ptyReq.Term,
|
||||
name, shell, "-l")
|
||||
cmd := exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, shell, "-l")...)
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
// busybox-ish images may lack bash
|
||||
cmd = exec.Command(m.engine, "exec", "-it", "-e", "TERM="+ptyReq.Term, name, "/bin/sh", "-l")
|
||||
cmd = exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, "/bin/sh", "-l")...)
|
||||
f, err = pty.Start(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pods: attach failed: %w", err)
|
||||
|
|
|
|||
|
|
@ -87,16 +87,45 @@ func Classify(raw string) (Kind, error) {
|
|||
|
||||
// isBlockedIP reports whether an address must not be dialed: loopback,
|
||||
// link-local (incl. the 169.254.169.254 cloud-metadata endpoint), private
|
||||
// (RFC1918 / fc00::/7), multicast, or unspecified.
|
||||
// (RFC1918 / fc00::/7), multicast, unspecified, shared/reserved ranges
|
||||
// (100.64.0.0/10, 198.18.0.0/15), and documentation/example networks.
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip == nil ||
|
||||
if ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsPrivate()
|
||||
ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
// Shared address space (Carrier-Grade NAT / RFC 6598)
|
||||
// 100.64.0.0/10
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
b := ip4[0]
|
||||
// 100.64.0.0 - 100.127.255.255
|
||||
if b == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
|
||||
return true
|
||||
}
|
||||
// Benchmarking (RFC 2544) 198.18.0.0/15
|
||||
// 198.18.0.0 - 198.19.255.255
|
||||
if b == 198 && (ip4[1] == 18 || ip4[1] == 19) {
|
||||
return true
|
||||
}
|
||||
// Documentation / example (RFC 5737)
|
||||
// 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24
|
||||
if b == 192 && ip4[1] == 0 && ip4[2] == 2 {
|
||||
return true
|
||||
}
|
||||
if b == 198 && ip4[1] == 51 && ip4[2] == 100 {
|
||||
return true
|
||||
}
|
||||
if b == 203 && ip4[1] == 0 && ip4[2] == 113 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// guardURL validates scheme and resolves the host, rejecting any URL that
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ package store
|
|||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
|
@ -41,10 +43,22 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
|||
u.EmailVerified = verified != 0
|
||||
u.Premium = premium != 0
|
||||
u.Banned = banned != 0
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
if t, err := time.Parse(time.RFC3339, created); err == nil {
|
||||
u.CreatedAt = t
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Message is one member-to-member note in the store-and-forward inbox.
|
||||
type Message struct {
|
||||
ID int64
|
||||
From string
|
||||
To string
|
||||
Body string
|
||||
Read bool
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Score is one leaderboard entry.
|
||||
type Score struct {
|
||||
User string
|
||||
|
|
@ -100,6 +114,21 @@ type Store interface {
|
|||
AddChat(userID int64, username, role, text string) error
|
||||
RecentChats(username string, n int) ([]ChatMessage, error)
|
||||
|
||||
// Member-to-member messaging (store-and-forward inbox).
|
||||
|
||||
// SendMessage leaves a note from→to in the recipient's inbox.
|
||||
SendMessage(from, to, body string) error
|
||||
// Inbox returns up to n messages addressed to username, newest first.
|
||||
Inbox(username string, n int) ([]Message, error)
|
||||
// UnreadCount reports how many unread messages username has waiting.
|
||||
UnreadCount(username string) (int, error)
|
||||
// MarkRead marks the given message ids read (scoped to username so a member
|
||||
// can only clear their own mail). Empty ids is a no-op.
|
||||
MarkRead(username string, ids []int64) error
|
||||
// OnlineUsers reports the set of usernames with an open session (no
|
||||
// ended_at), for the members directory presence dots.
|
||||
OnlineUsers() (map[string]bool, error)
|
||||
|
||||
// Custom domains mapped to a member's homepage (public_html).
|
||||
// MapDomain binds domain→username, returning ErrDomainTaken if it is
|
||||
// already claimed by someone else (re-binding to the same owner is a no-op).
|
||||
|
|
@ -506,6 +535,15 @@ CREATE TABLE IF NOT EXISTS files_settings (
|
|||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_user, id DESC);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
|
|
@ -516,8 +554,12 @@ func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
|||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return User{ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get user id after insert: %w", err)
|
||||
}
|
||||
return User{
|
||||
ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil
|
||||
case err != nil:
|
||||
return User{}, err
|
||||
}
|
||||
|
|
@ -721,6 +763,75 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) SendMessage(from, to, body string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`,
|
||||
from, to, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) {
|
||||
if n <= 0 {
|
||||
n = 50
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, from_user, to_user, body, read, created_at
|
||||
FROM messages WHERE to_user = ? ORDER BY id DESC LIMIT ?`, username, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Message
|
||||
for rows.Next() {
|
||||
var m Message
|
||||
var read int
|
||||
var at string
|
||||
if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Body, &read, &at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Read = read != 0
|
||||
m.At, _ = time.Parse(time.RFC3339, at)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UnreadCount(username string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE to_user = ? AND read = 0`, username).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MarkRead(username string, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
q := `UPDATE messages SET read = 1 WHERE to_user = ? AND id IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, username)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
_, err := s.db.Exec(q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) OnlineUsers() (map[string]bool, error) {
|
||||
rows, err := s.db.Query(`SELECT DISTINCT username FROM sessions WHERE ended_at IS NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
online := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
online[strings.ToLower(name)] = true
|
||||
}
|
||||
return online, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MapDomain(domain, username string) error {
|
||||
var owner string
|
||||
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner)
|
||||
|
|
|
|||
82
internal/store/store_messages_test.go
Normal file
82
internal/store/store_messages_test.go
Normal 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
159
internal/ui/theme.go
Normal 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(" · "))
|
||||
}
|
||||
|
|
@ -3,11 +3,14 @@
|
|||
package about
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
type Plugin struct{}
|
||||
|
|
@ -26,22 +29,57 @@ type model struct{ user auth.User }
|
|||
func (m model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
return m, plugin.Exit
|
||||
if k, ok := msg.(tea.KeyMsg); ok {
|
||||
switch k.String() {
|
||||
case "esc", "q", "enter", "ctrl+c", " ":
|
||||
return m, plugin.Exit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var (
|
||||
h = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
d = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
theme = ui.New(ui.Green)
|
||||
taglineStyle = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("245"))
|
||||
cmdStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Cyan)
|
||||
)
|
||||
|
||||
func (m model) View() string {
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||
h.Render("AgentBBS") + " — a modern BBS over SSH for humans and AI agents.\n\n" +
|
||||
" ssh bbs@profullstack.com this hub (guests welcome)\n" +
|
||||
" ssh join@profullstack.com register your SSH key\n" +
|
||||
" ssh pod@profullstack.com your own Linux pod (members, $1/mo via coinpay)\n\n" +
|
||||
d.Render("Maintained by Profullstack, Inc. · AgentGames spec at logicsrc.com\n\npress any key to return"))
|
||||
// route is one connection entry point shown in the CONNECT card.
|
||||
type route struct {
|
||||
cmd, desc string
|
||||
badgeVar, tag string
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
const cmdW, descW = 28, 22
|
||||
|
||||
routes := []route{
|
||||
{"ssh bbs@profullstack.com", "the public hub", ui.BadgeOK, "guests welcome"},
|
||||
{"ssh join@profullstack.com", "register your SSH key", ui.BadgeInfo, "free"},
|
||||
{"ssh pod@profullstack.com", "your own Linux pod", ui.BadgeGold, "$1/mo · members"},
|
||||
}
|
||||
|
||||
rows := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
cmdStyle.Width(cmdW).Render(r.cmd),
|
||||
ui.Body.Width(descW).Render(r.desc),
|
||||
ui.Badge(r.badgeVar, r.tag),
|
||||
))
|
||||
}
|
||||
|
||||
footer := ui.Dim.Render("Maintained by Profullstack, Inc.") + "\n" +
|
||||
ui.Dim.Render("AgentGames spec → logicsrc.com")
|
||||
|
||||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||||
theme.Title("AgentBBS"),
|
||||
taglineStyle.Render("a modern BBS over SSH — for humans and AI agents"),
|
||||
"",
|
||||
theme.Card("Connect", strings.Join(rows, "\n")),
|
||||
"",
|
||||
footer,
|
||||
"",
|
||||
ui.KeyBar("esc/q return to menu"),
|
||||
)
|
||||
return ui.Frame.Render(body)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/profullstack/agentbbs/internal/games"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
// Plugin is the AgentGames hub entry.
|
||||
|
|
@ -52,12 +53,12 @@ const (
|
|||
)
|
||||
|
||||
var (
|
||||
title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
head = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
|
||||
warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
frame = lipgloss.NewStyle().Padding(1, 2)
|
||||
title = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||||
dim = ui.Dim
|
||||
cursor = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||||
head = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
|
||||
warn = ui.Danger
|
||||
frame = ui.Frame
|
||||
)
|
||||
|
||||
var actions = []string{"Ladder", "Replays", "Play vs bot"}
|
||||
|
|
@ -302,7 +303,7 @@ func (m *model) View() string {
|
|||
case scPlay:
|
||||
body, help = m.viewPlay()
|
||||
}
|
||||
out := title.Render("AgentGames") + "\n\n" + body + "\n" + dim.Render(help)
|
||||
out := title.Render("AgentGames") + "\n\n" + body + "\n" + ui.KeyBar(help)
|
||||
if m.note != "" {
|
||||
out += "\n" + m.note
|
||||
}
|
||||
|
|
@ -393,7 +394,7 @@ func (m *model) viewPlay() (string, string) {
|
|||
|
||||
func (m *model) row(i int) string {
|
||||
if i == m.cursor {
|
||||
return cursor.Render("> ")
|
||||
return cursor.Render("❯ ")
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,67 @@
|
|||
// Package arcade is the flagship plugin (PRD §5.1): classic terminal games.
|
||||
// DOOM runs as a sandboxed external binary (doom-ascii + Freedoom); built-in
|
||||
// TUI games (snake) feed the global leaderboards.
|
||||
// DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon
|
||||
// Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games
|
||||
// (snake, hangman) feed the global leaderboards.
|
||||
package arcade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (Plugin) ID() string { return "arcade" }
|
||||
func (Plugin) Title() string { return "Arcade" }
|
||||
func (Plugin) Description() string { return "DOOM (ASCII), snake, leaderboards" }
|
||||
func (Plugin) RequiresAuth() bool { return false }
|
||||
func (Plugin) ID() string { return "arcade" }
|
||||
func (Plugin) Title() string { return "Arcade" }
|
||||
func (Plugin) Description() string {
|
||||
return "DOOM, Space Invaders, Pac-Man, Tetris, snake, hangman & leaderboards"
|
||||
}
|
||||
func (Plugin) RequiresAuth() bool { return false }
|
||||
|
||||
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
|
||||
return newMenu(user, ctx)
|
||||
}
|
||||
|
||||
// extGame is an 80s arcade classic launched as a sandboxed subprocess on a real
|
||||
// PTY — the doom-ascii pattern, generalized. The binary is resolved from the
|
||||
// platform assets dir first, then the host PATH and the well-known distro game
|
||||
// dirs, so either `scripts/fetch-assets.sh --arcade` (distro install) or a
|
||||
// hand-built binary dropped in assets/bin makes the game appear in the menu.
|
||||
type extGame struct {
|
||||
id string // stable id; also the per-user save subdir under arcade/
|
||||
label string // menu label
|
||||
desc string // one-line menu description
|
||||
bins []string // candidate binary names (first that resolves wins)
|
||||
args []string // launch args (most need none)
|
||||
}
|
||||
|
||||
// extGames is the arcade catalog of external classics, in menu order.
|
||||
var extGames = []extGame{
|
||||
{id: "invaders", label: "Space Invaders", desc: "nInvaders — shoot the descending alien fleet", bins: []string{"ninvaders", "nInvaders"}},
|
||||
{id: "pacman", label: "Pac-Man", desc: "pacman4console — clear the maze, dodge the ghosts", bins: []string{"pacman4console"}},
|
||||
{id: "tetris", label: "Tetris", desc: "tint — stack the falling tetrominoes", bins: []string{"tint", "vitetris", "tetris"}},
|
||||
{id: "moonpatrol", label: "Moon Patrol", desc: "moon-buggy — jump the craters across the lunar surface", bins: []string{"moon-buggy"}},
|
||||
}
|
||||
|
||||
// gameDirs are the well-known locations distro packages drop game binaries.
|
||||
// Debian/Ubuntu put them in /usr/games, which is usually off the daemon's PATH,
|
||||
// so we probe these explicitly in addition to exec.LookPath.
|
||||
var gameDirs = []string{"/usr/games", "/usr/local/games", "/usr/local/bin", "/usr/bin"}
|
||||
|
||||
// entry is one row in the arcade menu.
|
||||
type entry struct {
|
||||
label string
|
||||
desc string
|
||||
run func(m *menu) (tea.Model, tea.Cmd)
|
||||
section string
|
||||
label string
|
||||
desc string
|
||||
run func(m *menu) (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
type menu struct {
|
||||
|
|
@ -45,44 +75,89 @@ type menu struct {
|
|||
child tea.Model // snake / leaderboard take over here
|
||||
}
|
||||
|
||||
var (
|
||||
tStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#fbbf24"))
|
||||
dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
cStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24"))
|
||||
eStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
)
|
||||
var theme = ui.New(ui.Gold)
|
||||
|
||||
func newMenu(user auth.User, ctx plugin.Context) *menu {
|
||||
m := &menu{user: user, ctx: ctx}
|
||||
|
||||
// --- DOOM (per WAD) ---
|
||||
for _, wad := range findWADs(ctx, user) {
|
||||
wad := wad
|
||||
m.entries = append(m.entries, entry{
|
||||
label: "DOOM — " + filepath.Base(wad),
|
||||
desc: "doom-ascii in a sandbox (24-bit color terminal recommended)",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(wad) },
|
||||
section: "DOOM",
|
||||
label: "DOOM — " + filepath.Base(wad),
|
||||
desc: "doom-ascii in a sandbox (24-bit color terminal recommended)",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(wad) },
|
||||
})
|
||||
}
|
||||
if len(m.entries) == 0 {
|
||||
if doomBin(ctx) == "" {
|
||||
m.entries = append(m.entries, entry{
|
||||
label: "DOOM — not installed",
|
||||
desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil },
|
||||
section: "DOOM",
|
||||
label: "DOOM — not installed",
|
||||
desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil },
|
||||
})
|
||||
}
|
||||
|
||||
// --- arcade classics (external binaries) ---
|
||||
var arcadeFound bool
|
||||
for _, g := range extGames {
|
||||
g := g
|
||||
if resolveBin(ctx, g.bins) == "" {
|
||||
continue
|
||||
}
|
||||
arcadeFound = true
|
||||
m.entries = append(m.entries, entry{
|
||||
section: "ARCADE",
|
||||
label: g.label,
|
||||
desc: g.desc,
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchExt(g) },
|
||||
})
|
||||
}
|
||||
if !arcadeFound {
|
||||
m.entries = append(m.entries, entry{
|
||||
section: "ARCADE",
|
||||
label: "Arcade classics — not installed",
|
||||
desc: "run scripts/fetch-assets.sh --arcade on the host (Space Invaders, Pac-Man, Tetris, Moon Patrol)",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "arcade binaries missing on host"; return m, nil },
|
||||
})
|
||||
}
|
||||
|
||||
// --- built-in (leaderboard-backed) ---
|
||||
m.entries = append(m.entries,
|
||||
entry{
|
||||
label: "Snake",
|
||||
desc: "built-in; high scores hit the global leaderboard",
|
||||
section: "BUILT-IN",
|
||||
label: "Snake",
|
||||
desc: "built-in; high scores hit the global leaderboard",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) {
|
||||
m.child = newSnake(m.user, m.ctx, m.width, m.height)
|
||||
return m, m.child.Init()
|
||||
},
|
||||
},
|
||||
entry{
|
||||
label: "Leaderboard",
|
||||
desc: "global top scores",
|
||||
section: "BUILT-IN",
|
||||
label: "Hangman",
|
||||
desc: "built-in word game; high scores hit the global leaderboard",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) {
|
||||
m.child = newBoard(m.ctx)
|
||||
m.child = newHangman(m.user, m.ctx)
|
||||
return m, m.child.Init()
|
||||
},
|
||||
},
|
||||
entry{
|
||||
section: "BUILT-IN",
|
||||
label: "Leaderboard — Snake",
|
||||
desc: "global top snake scores",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) {
|
||||
m.child = newBoard(m.ctx, "snake")
|
||||
return m, m.child.Init()
|
||||
},
|
||||
},
|
||||
entry{
|
||||
section: "BUILT-IN",
|
||||
label: "Leaderboard — Hangman",
|
||||
desc: "global top hangman scores",
|
||||
run: func(m *menu) (tea.Model, tea.Cmd) {
|
||||
m.child = newBoard(m.ctx, "hangman")
|
||||
return m, m.child.Init()
|
||||
},
|
||||
},
|
||||
|
|
@ -116,24 +191,93 @@ func doomBin(ctx plugin.Context) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// resolveBin finds the first candidate binary that exists: bundled in the
|
||||
// platform assets dir, on PATH, or in a well-known distro game dir.
|
||||
func resolveBin(ctx plugin.Context, names []string) string {
|
||||
for _, n := range names {
|
||||
if p := filepath.Join(ctx.AssetsDir, "bin", n); isExec(p) {
|
||||
return p
|
||||
}
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
for _, d := range gameDirs {
|
||||
if p := filepath.Join(d, n); isExec(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isExec(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0
|
||||
}
|
||||
|
||||
// workDir returns the writable per-game save dir: a stable path for members,
|
||||
// a throwaway temp dir for guests.
|
||||
func (m *menu) workDir(sub string) string {
|
||||
if m.ctx.DataDir == "" {
|
||||
d, _ := os.MkdirTemp("", "agentbbs-guest-"+strings.ReplaceAll(sub, "/", "-")+"-")
|
||||
return d
|
||||
}
|
||||
d := filepath.Join(m.ctx.DataDir, "arcade", sub)
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
}
|
||||
|
||||
// gameEnv is the minimal environment handed to a sandboxed game. It does NOT
|
||||
// inherit the daemon's environment (which carries operator secrets like
|
||||
// COINPAY_API_KEY) — a third-party game binary has no business seeing those.
|
||||
// TERM comes from the client PTY so ncurses games (Space Invaders, Pac-Man,
|
||||
// Tetris, Moon Patrol) can open the terminal; without it initscr() fails with
|
||||
// "Error opening terminal" and the game exits before drawing a frame.
|
||||
func (m *menu) gameEnv(work string) []string {
|
||||
term := m.ctx.Term
|
||||
if term == "" {
|
||||
term = "xterm-256color" // sane default if the client didn't request a PTY type
|
||||
}
|
||||
return []string{
|
||||
"TERM=" + term,
|
||||
"PATH=/usr/games:/usr/local/games:/usr/local/bin:/usr/bin:/bin",
|
||||
"HOME=" + work,
|
||||
"LANG=C.UTF-8", // unicode box-drawing for the ncurses games
|
||||
}
|
||||
}
|
||||
|
||||
// launchDoom suspends the TUI and bridges the session to a sandboxed
|
||||
// doom-ascii on a real PTY. Savegames land in the per-user work dir.
|
||||
func (m *menu) launchDoom(wad string) tea.Cmd {
|
||||
bin := doomBin(m.ctx)
|
||||
work := m.ctx.DataDir
|
||||
if work == "" { // guests: throwaway saves
|
||||
work, _ = os.MkdirTemp("", "agentbbs-guest-doom-")
|
||||
} else {
|
||||
work = filepath.Join(work, "doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad)))
|
||||
_ = os.MkdirAll(work, 0o755)
|
||||
}
|
||||
work := m.workDir(filepath.Join("doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad))))
|
||||
cmd := m.ctx.Sandbox.Command(work, bin, "-iwad", wad)
|
||||
cmd.Env = m.gameEnv(work)
|
||||
return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg {
|
||||
return doomDoneMsg{err: err}
|
||||
return gameDoneMsg{name: "DOOM", err: err}
|
||||
})
|
||||
}
|
||||
|
||||
type doomDoneMsg struct{ err error }
|
||||
// launchExt suspends the TUI and bridges the session to a sandboxed arcade
|
||||
// classic on a real PTY (the generalized doom path).
|
||||
func (m *menu) launchExt(g extGame) tea.Cmd {
|
||||
bin := resolveBin(m.ctx, g.bins)
|
||||
if bin == "" { // raced with an uninstall; surface rather than exec ""
|
||||
m.note = g.label + " is no longer installed on the host"
|
||||
return nil
|
||||
}
|
||||
work := m.workDir(g.id)
|
||||
cmd := m.ctx.Sandbox.Command(work, bin, g.args...)
|
||||
cmd.Env = m.gameEnv(work)
|
||||
return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg {
|
||||
return gameDoneMsg{name: g.label, err: err}
|
||||
})
|
||||
}
|
||||
|
||||
type gameDoneMsg struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *menu) Init() tea.Cmd { return nil }
|
||||
|
||||
|
|
@ -151,9 +295,9 @@ func (m *menu) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, cmd
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case doomDoneMsg:
|
||||
case gameDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.note = "doom exited: " + msg.err.Error()
|
||||
m.note = msg.name + " exited: " + msg.err.Error()
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
|
|
@ -180,19 +324,23 @@ func (m *menu) View() string {
|
|||
if m.child != nil {
|
||||
return m.child.View()
|
||||
}
|
||||
s := tStyle.Render("Arcade") + "\n\n"
|
||||
s := theme.Title("Arcade") + ui.Dim.Render(" · classic terminal games, sandboxed") + "\n\n"
|
||||
prevSection := ""
|
||||
for i, e := range m.entries {
|
||||
cur := " "
|
||||
if i == m.cursor {
|
||||
cur = cStyle.Render("> ")
|
||||
if e.section != prevSection {
|
||||
if prevSection != "" {
|
||||
s += "\n"
|
||||
}
|
||||
s += theme.Section(e.section) + "\n"
|
||||
prevSection = e.section
|
||||
}
|
||||
s += fmt.Sprintf("%s%s\n %s\n", cur, e.label, dStyle.Render(e.desc))
|
||||
s += theme.MenuItem(i == m.cursor, e.label, "", e.desc)
|
||||
}
|
||||
s += "\n" + dStyle.Render("↑/↓ move · enter play · q back to hub")
|
||||
s += "\n" + ui.KeyBar("↑/↓ move · enter play · q back to hub")
|
||||
if m.note != "" {
|
||||
s += "\n" + eStyle.Render(m.note)
|
||||
s += "\n" + ui.Danger.Render(m.note)
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(s)
|
||||
return ui.Frame.Render(s)
|
||||
}
|
||||
|
||||
// backMsg returns from a child (snake/leaderboard) to the arcade menu.
|
||||
|
|
|
|||
|
|
@ -4,24 +4,27 @@ import (
|
|||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
// board renders the global top scores (PRD §5.1 leaderboards).
|
||||
// board renders the global top scores for one game (PRD §5.1 leaderboards).
|
||||
type board struct {
|
||||
ctx plugin.Context
|
||||
game string
|
||||
scores []store.Score
|
||||
err error
|
||||
}
|
||||
|
||||
func newBoard(ctx plugin.Context) *board { return &board{ctx: ctx} }
|
||||
func newBoard(ctx plugin.Context, game string) *board {
|
||||
return &board{ctx: ctx, game: game}
|
||||
}
|
||||
|
||||
func (b *board) Init() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
scores, err := b.ctx.Store.TopScores("snake", 10)
|
||||
scores, err := b.ctx.Store.TopScores(b.game, 10)
|
||||
return boardMsg{scores: scores, err: err}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,17 +45,17 @@ func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (b *board) View() string {
|
||||
s := tStyle.Render("Leaderboard — snake") + "\n\n"
|
||||
s := theme.Title("Leaderboard — "+b.game) + "\n\n"
|
||||
switch {
|
||||
case b.err != nil:
|
||||
s += eStyle.Render("error: " + b.err.Error())
|
||||
s += ui.Danger.Render("error: " + b.err.Error())
|
||||
case len(b.scores) == 0:
|
||||
s += dStyle.Render("no scores yet — be the first")
|
||||
s += ui.Dim.Render("no scores yet — be the first")
|
||||
default:
|
||||
for i, sc := range b.scores {
|
||||
s += fmt.Sprintf("%2d. %-20s %6d\n", i+1, sc.User, sc.Score)
|
||||
}
|
||||
}
|
||||
s += "\n" + dStyle.Render("any key to return")
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(s)
|
||||
s += "\n" + ui.KeyBar("any-key return to menu")
|
||||
return ui.Frame.Render(s)
|
||||
}
|
||||
|
|
|
|||
193
plugins/arcade/hangman.go
Normal file
193
plugins/arcade/hangman.go
Normal 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 A–Z
|
||||
guessed map[byte]bool // letters tried this word
|
||||
wrong int // wrong guesses on the current word
|
||||
score int64
|
||||
solved int // words solved this run
|
||||
won bool // current word fully revealed
|
||||
dead bool // ran out of guesses
|
||||
saved bool
|
||||
}
|
||||
|
||||
const hangmanMaxWrong = 6
|
||||
|
||||
// hangmanWords is the word bank — common, all-caps, letters only so the masked
|
||||
// display and A–Z input stay simple.
|
||||
var hangmanWords = []string{
|
||||
"TERMINAL", "SANDBOX", "KEYBOARD", "NETWORK", "PROTOCOL", "FIREWALL",
|
||||
"COMPILER", "VARIABLE", "FUNCTION", "POINTER", "BINARY", "KERNEL",
|
||||
"PACKET", "ROUTER", "CIPHER", "GALLOWS", "ARCADE", "INVADER",
|
||||
"GHOST", "MAZE", "ROCKET", "LASER", "CRATER", "PIXEL",
|
||||
"WIDGET", "BUBBLE", "GOPHER", "DAEMON", "SOCKET", "THREAD",
|
||||
"BUFFER", "MODEM", "CURSOR", "SYNTAX", "MODULE", "VECTOR",
|
||||
}
|
||||
|
||||
func newHangman(user auth.User, ctx plugin.Context) *hangman {
|
||||
h := &hangman{user: user, ctx: ctx}
|
||||
h.deal()
|
||||
return h
|
||||
}
|
||||
|
||||
// deal starts a fresh word with full lives.
|
||||
func (h *hangman) deal() {
|
||||
h.word = hangmanWords[rand.Intn(len(hangmanWords))]
|
||||
h.guessed = make(map[byte]bool)
|
||||
h.wrong = 0
|
||||
h.won = false
|
||||
}
|
||||
|
||||
// revealed reports whether every letter of the word has been guessed.
|
||||
func (h *hangman) revealed() bool {
|
||||
for i := 0; i < len(h.word); i++ {
|
||||
if !h.guessed[h.word[i]] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *hangman) Init() tea.Cmd { return nil }
|
||||
|
||||
func (h *hangman) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
key, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return h, nil
|
||||
}
|
||||
switch key.String() {
|
||||
case "q", "esc":
|
||||
return h, back
|
||||
case "r":
|
||||
if h.dead {
|
||||
return newHangman(h.user, h.ctx), nil
|
||||
}
|
||||
return h, nil
|
||||
case " ", "enter":
|
||||
if h.won {
|
||||
h.deal() // advance to the next word
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
if h.dead || h.won {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// A single letter is a guess.
|
||||
s := key.String()
|
||||
if len(s) != 1 {
|
||||
return h, nil
|
||||
}
|
||||
c := s[0]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c -= 'a' - 'A'
|
||||
}
|
||||
if c < 'A' || c > 'Z' || h.guessed[c] {
|
||||
return h, nil
|
||||
}
|
||||
h.guessed[c] = true
|
||||
|
||||
if strings.IndexByte(h.word, c) < 0 {
|
||||
h.wrong++
|
||||
if h.wrong >= hangmanMaxWrong {
|
||||
h.dead = true
|
||||
// Guests play, members persist (PRD §5.1).
|
||||
if !h.saved && h.user.Kind != auth.Guest && h.user.StoreID > 0 && h.score > 0 {
|
||||
_ = h.ctx.Store.AddScore(h.user.StoreID, "hangman", h.score)
|
||||
h.saved = true
|
||||
}
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
if h.revealed() {
|
||||
h.won = true
|
||||
h.solved++
|
||||
// Longer words and unused guesses are worth more.
|
||||
h.score += int64(len(h.word)*10 + (hangmanMaxWrong-h.wrong)*5)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// hangmanStages are the gallows ASCII for 0..6 wrong guesses.
|
||||
var hangmanStages = []string{
|
||||
" +---+\n |\n |\n |\n ===",
|
||||
" +---+\n O |\n |\n |\n ===",
|
||||
" +---+\n O |\n | |\n |\n ===",
|
||||
" +---+\n O |\n /| |\n |\n ===",
|
||||
" +---+\n O |\n /|\\ |\n |\n ===",
|
||||
" +---+\n O |\n /|\\ |\n / |\n ===",
|
||||
" +---+\n O |\n /|\\ |\n / \\ |\n ===",
|
||||
}
|
||||
|
||||
var (
|
||||
hmWordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24")).Bold(true)
|
||||
hmWrongStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
hmGoodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
hmGallows = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
)
|
||||
|
||||
func (h *hangman) View() string {
|
||||
out := fmt.Sprintf("Hangman — score %d · solved %d\n\n", h.score, h.solved)
|
||||
out += hmGallows.Render(hangmanStages[h.wrong]) + "\n\n"
|
||||
|
||||
// Masked word: reveal the whole thing once the round is over.
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(h.word); i++ {
|
||||
if i > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
if h.guessed[h.word[i]] || h.dead {
|
||||
b.WriteByte(h.word[i])
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out += hmWordStyle.Render(b.String()) + "\n\n"
|
||||
|
||||
// Wrong letters tried.
|
||||
var wrong []string
|
||||
for c := byte('A'); c <= 'Z'; c++ {
|
||||
if h.guessed[c] && strings.IndexByte(h.word, c) < 0 {
|
||||
wrong = append(wrong, string(c))
|
||||
}
|
||||
}
|
||||
out += fmt.Sprintf("misses (%d/%d): ", h.wrong, hangmanMaxWrong)
|
||||
if len(wrong) > 0 {
|
||||
out += hmWrongStyle.Render(strings.Join(wrong, " "))
|
||||
} else {
|
||||
out += hmGallows.Render("—")
|
||||
}
|
||||
out += "\n\n"
|
||||
|
||||
switch {
|
||||
case h.dead:
|
||||
out += hmWrongStyle.Render("☠ out of guesses — the word was "+h.word) + "\n"
|
||||
out += "r restart · q back"
|
||||
case h.won:
|
||||
out += hmGoodStyle.Render("✓ solved!") + " space next word · q back"
|
||||
default:
|
||||
out += "guess a letter · q back"
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(out)
|
||||
}
|
||||
86
plugins/arcade/hangman_test.go
Normal file
86
plugins/arcade/hangman_test.go
Normal 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
37
plugins/hello/hello.go
Normal 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
421
plugins/members/members.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
qi "github.com/profullstack/agentbbs/internal/qryptinvite"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
// Plugin is the hub registration. It admits members only (guests have no
|
||||
|
|
@ -99,13 +100,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m model) View() string {
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||
m.body + "\n\n" + dStyle.Render("press any key to return"))
|
||||
return ui.Frame.Render(m.body + "\n\n" + ui.KeyBar("esc/q return to menu"))
|
||||
}
|
||||
|
||||
var (
|
||||
hStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa"))
|
||||
hStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||||
dStyle = ui.Dim
|
||||
errStyle = ui.Danger
|
||||
urlStyle = lipgloss.NewStyle().Foreground(ui.Blue)
|
||||
)
|
||||
|
|
|
|||
57
pods/Containerfile
Normal file
57
pods/Containerfile
Normal 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"]
|
||||
|
|
@ -3,10 +3,13 @@
|
|||
# ./assets — Freedoom by default (PRD §9.1). Run on the host before enabling
|
||||
# the arcade's DOOM entries.
|
||||
#
|
||||
# scripts/fetch-assets.sh [--shareware]
|
||||
# scripts/fetch-assets.sh [--shareware] [--arcade]
|
||||
#
|
||||
# --shareware additionally fetches the freely redistributable doom1.wad
|
||||
# shareware episode.
|
||||
# shareware episode.
|
||||
# --arcade installs the 80s arcade classics (Space Invaders, Pac-Man, Tetris,
|
||||
# Moon Patrol) the arcade plugin launches via the same sandboxed-PTY path as
|
||||
# DOOM. Needs apt + sudo (Debian/Ubuntu); the menu lists whatever installs.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
|
@ -14,6 +17,16 @@ ASSETS="$ROOT/assets"
|
|||
BUILD="$ROOT/.build"
|
||||
FREEDOOM_VERSION="${FREEDOOM_VERSION:-0.13.0}"
|
||||
|
||||
want_shareware=0
|
||||
want_arcade=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--shareware) want_shareware=1 ;;
|
||||
--arcade) want_arcade=1 ;;
|
||||
*) echo "!! unknown flag: $arg (use --shareware and/or --arcade)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$ASSETS/bin" "$ASSETS/wads" "$BUILD"
|
||||
|
||||
# --- doom-ascii -------------------------------------------------------------
|
||||
|
|
@ -47,12 +60,39 @@ else
|
|||
fi
|
||||
|
||||
# --- Doom shareware (optional) ----------------------------------------------
|
||||
if [ "${1:-}" = "--shareware" ] && [ ! -f "$ASSETS/wads/doom1.wad" ]; then
|
||||
if [ "$want_shareware" = 1 ] && [ ! -f "$ASSETS/wads/doom1.wad" ]; then
|
||||
echo ">> fetching Doom shareware episode"
|
||||
curl -fsSL -o "$ASSETS/wads/doom1.wad" \
|
||||
"https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad"
|
||||
echo ">> installed doom1.wad (shareware)"
|
||||
fi
|
||||
|
||||
# --- Arcade classics (optional) ---------------------------------------------
|
||||
# Tiny, well-packaged ncurses C programs from the distro (Debian/Ubuntu
|
||||
# universe). They land in /usr/games, which the arcade plugin probes alongside
|
||||
# assets/bin and PATH. The arcade menu lists whichever of these is present.
|
||||
ARCADE_PKGS="ninvaders pacman4console moon-buggy tint"
|
||||
if [ "$want_arcade" = 1 ]; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
SUDO=""
|
||||
[ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
echo ">> installing arcade classics: $ARCADE_PKGS"
|
||||
$SUDO apt-get update -y
|
||||
# Install individually so one missing package doesn't abort the rest.
|
||||
for pkg in $ARCADE_PKGS; do
|
||||
$SUDO apt-get install -y "$pkg" || echo "!! $pkg not available; skipping"
|
||||
done
|
||||
else
|
||||
echo "!! --arcade needs apt-get (Debian/Ubuntu)." >&2
|
||||
echo " On other distros, install equivalents of: $ARCADE_PKGS" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ">> done. WADs:"
|
||||
ls -l "$ASSETS/wads"
|
||||
echo ">> arcade classics on host:"
|
||||
for bin in ninvaders pacman4console moon-buggy tint vitetris; do
|
||||
p="$(command -v "$bin" 2>/dev/null || true)"
|
||||
[ -z "$p" ] && [ -x "/usr/games/$bin" ] && p="/usr/games/$bin"
|
||||
[ -n "$p" ] && echo " $bin -> $p"
|
||||
done
|
||||
|
|
|
|||
63
scripts/rebuild-pods.sh
Executable file
63
scripts/rebuild-pods.sh
Executable 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
229
setup.sh
|
|
@ -34,6 +34,7 @@ HTTP_ADDR="${HTTP_ADDR:-127.0.0.1:8088}" # agentbbs /verify endpoint (join@ emai
|
|||
GO_VERSION="${GO_VERSION:-1.26.4}"
|
||||
POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}"
|
||||
FETCH_ASSETS="${FETCH_ASSETS:-1}" # set 0 to skip the DOOM/Freedoom arcade assets
|
||||
FETCH_ARCADE="${FETCH_ARCADE:-1}" # set 0 to skip the 80s arcade classics (apt: ninvaders, pacman4console, moon-buggy, tint)
|
||||
SKIP_BUILD="${SKIP_BUILD:-0}" # set 1 to use prebuilt /usr/local/bin/{agentbbs,ascii-live} (tiny droplets can't compile)
|
||||
SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set 0 to skip)
|
||||
SELF_UPDATE="${SELF_UPDATE:-1}" # set 0 to skip the autonomous self-update systemd timer
|
||||
|
|
@ -52,6 +53,7 @@ MAIL_DOMAIN="${MAIL_DOMAIN:-mail.${DOMAIN#*.}}" # mail host (default: mail.<roo
|
|||
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_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' "$*"; }
|
||||
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||
|
|
@ -119,8 +121,11 @@ if ! command -v yt-dlp >/dev/null; then
|
|||
fi
|
||||
|
||||
# ---- 2. Go toolchain (system go is too old; pin GO_VERSION) -----------------
|
||||
# Skipped entirely when SKIP_BUILD=1: the CI deploy builds the binaries on the
|
||||
# runner and ships them, so the droplet needs no Go toolchain at all.
|
||||
GO_ROOT="/usr/local/go"
|
||||
if [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then
|
||||
if [ "$SKIP_BUILD" != "1" ] && \
|
||||
[ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then
|
||||
log "installing Go ${GO_VERSION}"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o "$tmp/go.tgz" \
|
||||
|
|
@ -166,15 +171,111 @@ install -d -o "$SVC_USER" -g "$SVC_USER" -m 0700 "$DATA_DIR/ssh" # host key
|
|||
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/users" # tilde homepages live here
|
||||
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/web" # site root
|
||||
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/domains" # symlink farm: custom domain -> users/<name>/public_html
|
||||
[ -f "$DATA_DIR/web/index.html" ] || cat > "$DATA_DIR/web/index.html" <<HTML
|
||||
<!doctype html><meta charset=utf-8><title>AgentBBS</title>
|
||||
<style>body{background:#000;color:#33ff66;font:16px/1.6 monospace;max-width:44rem;margin:4rem auto;padding:0 1rem}a{color:#60a5fa}</style>
|
||||
<h1>AgentBBS</h1>
|
||||
<p>A BBS over SSH for humans and AI agents.</p>
|
||||
<pre> ssh join@${DOMAIN} # register your key, get started
|
||||
ssh bbs@${DOMAIN} # look around as a guest
|
||||
ssh pod@${DOMAIN} # your personal Linux pod (\$1/mo)</pre>
|
||||
<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>
|
||||
# Landing page (always regenerated — it's templated marketing content, not user
|
||||
# data): explains what a BBS is and lists every way in. Edit here to change it.
|
||||
cat > "$DATA_DIR/web/index.html" <<HTML
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AgentBBS — a bulletin board system over SSH</title>
|
||||
<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 & 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 & 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 & 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}/<name></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 & 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
|
||||
chown "$SVC_USER:$SVC_USER" "$DATA_DIR/web/index.html"
|
||||
|
||||
|
|
@ -191,8 +292,10 @@ else
|
|||
fi
|
||||
|
||||
if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/scripts/fetch-assets.sh" ]; then
|
||||
log "fetching arcade assets (set FETCH_ASSETS=0 to skip)"
|
||||
( cd "$SRC_DIR" && ./scripts/fetch-assets.sh ) || warn "asset fetch failed; arcade may be limited"
|
||||
fetch_flags=""
|
||||
[ "$FETCH_ARCADE" = "1" ] && fetch_flags="--arcade"
|
||||
log "fetching arcade assets (set FETCH_ASSETS=0 to skip; FETCH_ARCADE=0 for DOOM only)"
|
||||
( cd "$SRC_DIR" && ./scripts/fetch-assets.sh $fetch_flags ) || warn "asset fetch failed; arcade may be limited"
|
||||
fi
|
||||
|
||||
# Add swap on tiny droplets before the build (and for runtime headroom).
|
||||
|
|
@ -210,6 +313,27 @@ fi
|
|||
sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \
|
||||
podman pull -q "$POD_IMAGE" >/dev/null 2>&1 || warn "could not pre-pull $POD_IMAGE (pods will pull on first use)"
|
||||
|
||||
# Build the member pod image (FROM $POD_IMAGE): adds git, openssh-client, Node,
|
||||
# and the Claude Code + Codex CLIs so members can code in their pod (BYO API
|
||||
# key). podman layer-caches, so an unchanged Containerfile rebuilds cheaply. On
|
||||
# failure we keep the base image rather than break pod launches.
|
||||
if [ -f "$SRC_DIR/pods/Containerfile" ]; then
|
||||
log "building member pod image (localhost/agentbbs-pod:latest)"
|
||||
if sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \
|
||||
podman build -t localhost/agentbbs-pod:latest \
|
||||
-f "$SRC_DIR/pods/Containerfile" "$SRC_DIR/pods" >/dev/null 2>&1; then
|
||||
POD_IMAGE="localhost/agentbbs-pod:latest"
|
||||
elif sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \
|
||||
podman image exists localhost/agentbbs-pod:latest >/dev/null 2>&1; then
|
||||
# A transient build failure (e.g. registry/network hiccup) must not downgrade
|
||||
# pods back to the base image — keep using the previously built one.
|
||||
POD_IMAGE="localhost/agentbbs-pod:latest"
|
||||
warn "pod image rebuild failed — using the existing localhost/agentbbs-pod:latest"
|
||||
else
|
||||
warn "pod image build failed — keeping $POD_IMAGE (run: podman build -f $SRC_DIR/pods/Containerfile $SRC_DIR/pods)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 6. environment file ---------------------------------------------------
|
||||
ENV_DIR=/etc/agentbbs
|
||||
install -d -m 0750 "$ENV_DIR"
|
||||
|
|
@ -244,10 +368,11 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
|
|||
# AGENTBBS_SIGNUP_NOTIFY=anthony@profullstack.com
|
||||
|
||||
# Membership model:
|
||||
# Free verified members get their own Docker pod (ssh pod@) and a homepage
|
||||
# at https://${DOMAIN}/~<name>.
|
||||
# Premium \$10 one-time, lifetime — a personal <name>@${DOMAIN} email
|
||||
# (forwardemail.net) plus custom domains (ssh domain@). Offered at join@.
|
||||
# Free verified members get their own Docker pod (ssh pod@), a homepage at
|
||||
# https://${DOMAIN}/~<name>, AND a real mailbox <name>@${DOMAIN} on the
|
||||
# self-hosted Mailu stack (read it in the hub's "Mail" or via webmail).
|
||||
# Premium \$10 one-time, lifetime — custom domains (ssh domain@) + a Tor shell.
|
||||
# Offered at join@.
|
||||
|
||||
# Premium payments hit the CoinPay REST API directly (no coinpay CLI needed):
|
||||
# join@ creates a charge and shows the amount + deposit address; a later connect
|
||||
|
|
@ -261,11 +386,23 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
|
|||
# AGENTBBS_PREMIUM_CURRENCY=USD
|
||||
# AGENTBBS_PREMIUM_BLOCKCHAIN=eth
|
||||
|
||||
# Premium email aliases (<name>@${DOMAIN}) auto-created on forwardemail.net.
|
||||
# Without an API key the address is shown but not created (add it manually).
|
||||
# AGENTBBS_FORWARDEMAIL_API_KEY=
|
||||
# AGENTBBS_FORWARDEMAIL_DOMAIN=${DOMAIN}
|
||||
# AGENTBBS_WEBMAIL_URL=https://webmail.${DOMAIN}
|
||||
# Member email (free for every verified member). Addresses are <name>@${DOMAIN}
|
||||
# (the address domain), while the Mailu server lives on the mail host below.
|
||||
# Mailboxes are auto-provisioned at join@ via the Mailu admin REST API: set the
|
||||
# API token (API_TOKEN in deploy/mailu/mailu.env). Without it the address is
|
||||
# shown but not created. See docs/mail.md.
|
||||
# AGENTBBS_MAIL_ADDR_DOMAIN=${DOMAIN} # the @-part of member addresses
|
||||
# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080 # Mailu admin (loopback)
|
||||
# AGENTBBS_MAIL_API_TOKEN=<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 —
|
||||
# is provisioned a Forgejo account when they confirm their email. The admin token
|
||||
|
|
@ -333,6 +470,14 @@ upsert_env() { # KEY VALUE — skips when VALUE is empty
|
|||
chmod 0640 "$file"
|
||||
}
|
||||
# CoinPay: API key (read by the coinpay CLI) + merchant/business id.
|
||||
# Point existing installs at the freshly built member pod image (fresh installs
|
||||
# get it from the env-file template below). Only when we actually have the custom
|
||||
# image — a transient podman failure in the deploy's rootless context must never
|
||||
# downgrade a working install back to the base ubuntu (the daemon builds/uses the
|
||||
# image from its own session regardless).
|
||||
if [ "$POD_IMAGE" = "localhost/agentbbs-pod:latest" ]; then
|
||||
upsert_env AGENTBBS_POD_IMAGE "$POD_IMAGE"
|
||||
fi
|
||||
upsert_env COINPAY_API_KEY "${COINPAY_API_KEY:-}"
|
||||
upsert_env AGENTBBS_COINPAY_MERCHANT_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}"
|
||||
upsert_env COINPAY_BUSINESS_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}"
|
||||
|
|
@ -839,8 +984,15 @@ HTTP_ADDR = ${FORGEJO_HTTP_ADDR%%:*}
|
|||
HTTP_PORT = ${FORGEJO_HTTP_ADDR##*:}
|
||||
DOMAIN = ${GIT_DOMAIN}
|
||||
ROOT_URL = https://${GIT_DOMAIN}/
|
||||
DISABLE_SSH = true
|
||||
START_SSH_SERVER = false
|
||||
SSH_DOMAIN = ${GIT_DOMAIN}
|
||||
# Built-in SSH server (in-process, runs as the forgejo user) so members can push
|
||||
# with the same key they use for the BBS. Host :22 is agentbbs and :2202 is the
|
||||
# admin OpenSSH, so Forgejo gets its own port; clones use ssh://git@host:PORT/.
|
||||
DISABLE_SSH = false
|
||||
START_SSH_SERVER = true
|
||||
SSH_USER = git
|
||||
SSH_PORT = ${FORGEJO_SSH_PORT}
|
||||
SSH_LISTEN_PORT = ${FORGEJO_SSH_PORT}
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
|
|
@ -851,7 +1003,10 @@ ROOT = ${FORGEJO_DATA}/repos
|
|||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = true
|
||||
# Public read: member profiles (git.${DOMAIN#*.}/<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
|
||||
|
||||
[security]
|
||||
|
|
@ -897,6 +1052,9 @@ UNIT
|
|||
systemctl is-active --quiet forgejo \
|
||||
|| warn "forgejo failed to start — check: journalctl -u forgejo -n50"
|
||||
|
||||
# Open the Forgejo SSH port so members can push (git@${GIT_DOMAIN}:${FORGEJO_SSH_PORT}).
|
||||
ufw allow "${FORGEJO_SSH_PORT}/tcp" >/dev/null 2>&1 || true
|
||||
|
||||
# First-run: create the admin agentbbs uses to mint member accounts, and store
|
||||
# an admin-scoped token in agentbbs.env. Guarded on the token being empty so
|
||||
# reruns never create duplicate tokens.
|
||||
|
|
@ -907,7 +1065,7 @@ UNIT
|
|||
--password "$FJ_ADMIN_PW" --must-change-password=false --config "$FORGEJO_CONF" >/dev/null 2>&1 \
|
||||
|| true
|
||||
FJ_TOKEN=$(sudo -u forgejo GITEA_WORK_DIR="$FORGEJO_DATA" /usr/local/bin/forgejo admin user generate-access-token \
|
||||
--username "$FORGEJO_ADMIN_USER" --token-name "agentbbs-$(date +%s)" --scopes write:admin \
|
||||
--username "$FORGEJO_ADMIN_USER" --token-name "agentbbs-$(date +%s)" --scopes write:admin,read:user,write:user \
|
||||
--config "$FORGEJO_CONF" 2>/dev/null | grep -oE '[0-9a-f]{40}' | head -1)
|
||||
if [ -n "$FJ_TOKEN" ]; then
|
||||
upsert_env AGENTBBS_FORGEJO_URL "https://${GIT_DOMAIN}"
|
||||
|
|
@ -921,18 +1079,25 @@ else
|
|||
systemctl disable --now forgejo >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ---- 9e. Mailu mail stack (co-located mail.${DOMAIN#*.}) --------------------
|
||||
# ---- 9e. Mailu mail stack (server on ${MAIL_DOMAIN}) ------------------------
|
||||
# Self-hosted Postfix+Dovecot+Roundcube+rspamd via Docker Compose. Mailu owns
|
||||
# the mail ports; Caddy fronts the loopback webmail and supplies the TLS cert
|
||||
# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of paid members. Full setup,
|
||||
# DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0.
|
||||
# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of EVERY verified member
|
||||
# (free + paid) — addresses are <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"
|
||||
if [ "$MAIL" = "1" ]; then
|
||||
log "configuring Mailu mail stack (${MAIL_DOMAIN})"
|
||||
# Tell agentbbs how to reach the mailbox backend (master user/pass are secrets
|
||||
# the operator sets; see docs/mail.md).
|
||||
log "configuring Mailu mail stack (server ${MAIL_DOMAIN}, addresses @${DOMAIN})"
|
||||
# Tell agentbbs how to reach the mailbox backend (master user/pass + the Mailu
|
||||
# API token are secrets the operator sets; see docs/mail.md).
|
||||
upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}"
|
||||
upsert_env AGENTBBS_MAIL_IMAP_ADDR "${MAIL_DOMAIN}:993"
|
||||
upsert_env AGENTBBS_MAIL_ADDR_DOMAIN "${DOMAIN}"
|
||||
# The gateway reads Dovecot DIRECTLY over loopback (docker-compose.override.yml
|
||||
# publishes it on 127.0.0.1:14143), bypassing Mailu's front nginx auth proxy so
|
||||
# the master-user login works. Plaintext is safe — it never leaves the host.
|
||||
# See docs/mail.md ("Why the gateway talks to Dovecot directly").
|
||||
upsert_env AGENTBBS_MAIL_IMAP_ADDR "127.0.0.1:14143"
|
||||
upsert_env AGENTBBS_MAIL_IMAP_PLAINTEXT "1"
|
||||
upsert_env AGENTBBS_MAIL_SMTP_ADDR "127.0.0.1:25"
|
||||
|
||||
# Cert refresher: copy Caddy's mail cert into Mailu on renewal (like news/IRC).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue