Merge remote-tracking branch 'origin/main' into fix/pod-public-html-bind-mount

# Conflicts:
#	cmd/agentbbs/main.go
#	internal/hub/hub.go
#	internal/pods/pods.go
This commit is contained in:
Anthony Ettinger 2026-06-15 14:51:55 +00:00
commit 662250af35
33 changed files with 3127 additions and 42 deletions

View file

@ -8,6 +8,8 @@
// emailed code, then offers $99 Founding Lifetime (CoinPay)
// ssh pod@host your personal Linux pod — free for verified members
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
// ssh <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,
@ -53,6 +55,7 @@ import (
gossh "golang.org/x/crypto/ssh"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/brand"
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/forgejo"
@ -60,6 +63,7 @@ import (
"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/news"
"github.com/profullstack/agentbbs/internal/payments"
"github.com/profullstack/agentbbs/internal/plugin"
@ -71,6 +75,7 @@ import (
"github.com/profullstack/agentbbs/plugins/about"
"github.com/profullstack/agentbbs/plugins/agentgames"
"github.com/profullstack/agentbbs/plugins/arcade"
"github.com/profullstack/agentbbs/plugins/members"
qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
)
@ -170,7 +175,7 @@ func main() {
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}}
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}}
// Custom domains: maintain the symlink farm Caddy serves and answer its
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
@ -315,6 +320,10 @@ func (a *app) router() wish.Middleware {
a.handleTorCmd(s)
case auth.IsNewsName(user):
a.handleNews(s)
case auth.IsMailName(user):
a.handleMail(s)
case auth.IsMsgName(user):
a.handleMsg(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":
@ -329,12 +338,9 @@ func (a *app) router() wish.Middleware {
}
// bbsBanner is the ASCII brand mark shown atop the hub menu and the join@ flow.
const bbsBanner = "" +
"┌─┐┬─┐┌─┐┌─┐┬ ┬┬ ┬ ┌─┐┌┬┐┌─┐┌─┐┬┌─\n" +
"├─┘├┬┘│ │├┤ │ ││ │ └─┐ │ ├─┤│ ├┴┐\n" +
"┴ ┴└─└─┘└ └─┘┴─┘┴─┘└─┘ ┴ ┴ ┴└─┘┴ ┴ .com"
var bbsBanner = brand.Logo()
var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#38bdf8"))
var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a"))
// hubMOTD is the welcome message shown in a box on the hub menu. The body is
// operator-overridable via AGENTBBS_MOTD; it is tailored for guests vs members.
@ -345,7 +351,11 @@ func (a *app) hubMOTD(u auth.User) string {
return "You're browsing as a guest.\n" + body +
"\nssh join@" + a.host + " to claim a username, a pod & a homepage."
}
return "Welcome back, " + u.Name + ".\n" + body
welcome := "Welcome back, " + u.Name + "."
if n, err := a.st.UnreadCount(u.Name); err == nil && n > 0 {
welcome += fmt.Sprintf(" 📬 %d unread — open Members ▸ inbox (i).", n)
}
return welcome + "\n" + body
}
// teaHandler builds the hub model for guests, members, and agents.
@ -394,7 +404,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub")
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets}
ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets, Host: a.host}
if u.Kind != auth.Guest {
ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name)
_ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755)
@ -464,6 +474,28 @@ 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.
mailLock := ""
switch {
case guest:
mailLock = membersOnly
case !su.Premium:
mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
}
apps = append(apps, hub.SessionApp{
Title: "Mail",
Description: "your " + a.fe.Domain + " mailbox",
Locked: mailLock,
Cmd: sessionExec{run: func() error {
c, err := a.mailClientFor(su)
if err != nil {
return err
}
defer c.Close()
return mailbox.RunReader(s, c)
}},
})
// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
torLock := ""
switch {
@ -1239,6 +1271,83 @@ 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.
func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
login := su.Name
if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
login = su.Name + "*" + master
}
cfg := mailbox.IMAPConfig{
IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", domain+":993"),
SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
Username: login,
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
// 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
}
// handleMail routes a Founding Lifetime member into AgentMail: an interactive
// TUI when a PTY is present and no command is given, or the JSON bot mode
// (ssh mail@host <cmd>, or no PTY) for agents.
func (a *app) handleMail(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "mail@ needs your registered SSH key. New here? ssh join@"+a.host)
_ = s.Exit(1)
return
}
u, 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
}
if u.Banned {
wish.Println(s, "this account is suspended.")
_ = 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)
_ = s.Exit(1)
return
}
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
defer func() { _ = a.st.EndSession(sessID) }()
c, err := a.mailClientFor(u)
if err != nil {
wish.Println(s, "mail: "+err.Error())
_ = s.Exit(1)
return
}
defer c.Close()
args := s.Command()
_, _, hasPty := s.Pty()
if len(args) > 0 || !hasPty {
// Agent/bot mode: JSON in, JSON out.
if err := mailbox.RunBot(s.Context(), c, args, s, s); err != nil {
_ = s.Exit(1)
}
return
}
if err := mailbox.RunReader(s, c); err != nil {
wish.Println(s, "mail: "+err.Error())
_ = s.Exit(1)
}
}
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
// member's pod, never on the host. Premium; requires a PTY.
func (a *app) handleTorCmd(s ssh.Session) {
@ -1322,6 +1431,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.