Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 105 additions & 5 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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"
Expand All @@ -60,6 +61,7 @@ import (
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/irc"
"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"
Expand Down Expand Up @@ -316,6 +318,8 @@ func (a *app) router() wish.Middleware {
a.handleIRC(s)
case auth.IsNewsName(user):
a.handleNews(s)
case auth.IsMailName(user):
a.handleMail(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":
Expand All @@ -330,12 +334,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.
Expand Down Expand Up @@ -474,6 +475,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 {
Expand Down Expand Up @@ -1275,6 +1298,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) {
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ require (
github.com/charmbracelet/wish v1.4.7
github.com/creack/pty v1.1.24
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/livekit/protocol v1.46.0
github.com/livekit/server-sdk-go/v2 v2.16.6
Expand Down Expand Up @@ -46,6 +48,7 @@ require (
github.com/dennwc/iters v1.2.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 h1:R90ND7acg9HKYj3oJBKKefk73DULdC7IlcnS7MV0X1s=
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1/go.mod h1:elGbp3dKCIIdwu6jm3y6L93EVn+I6MSzYrcZXhpNS3Y=
github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89/go.mod h1:ZoDWdnxro8Kesk3zrCNOHNFWtajFPSnDMjVEjGjQu/0=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
Expand Down
7 changes: 7 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ var IRCNames = map[string]bool{"irc": true}
// via an in-process newsreader. Free for any registered member, like irc@.
var NewsNames = map[string]bool{"news": true}

// MailNames route a Founding Lifetime (paid) member into the AgentMail client —
// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY.
var MailNames = map[string]bool{"mail": true}

// GameNames are usernames that route to AgentGames: the line-delimited-JSON
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
var GameNames = map[string]bool{"game": true, "games": true}
Expand Down Expand Up @@ -99,6 +103,9 @@ func IsIRCName(u string) bool { return IRCNames[strings.ToLower(u)] }
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }

// IsMailName reports whether the SSH username requests the AgentMail client.
func IsMailName(u string) bool { return MailNames[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.
Expand Down
26 changes: 26 additions & 0 deletions internal/brand/banner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Package brand holds AgentBBS's terminal branding — the ASCII logo shown
// at the top of the join@ onboarding and the ssh <name>@ hub prompts.
// Scaled down from the Profullstack </> mark.
package brand

import "strings"

// logo is the ASCII rendition of the </> brand mark (UTF-8 block glyphs).
const logo = `
▓████▓▒
▒█████▓
+▒▓ +▓████▓+ ▒+
:+▒▓▓▓▓ :▓██▓█▓+ ▒▓▓▓▒+
+▒▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓+ ▒▓▓▓▓▓▒▒+
:+▒▓▓▓▓▓▓▓▒+ ▒▓▓▓▓▓▒ +▒▓▓▓▓▓▓▒▒:
▒▓▓▒▓▒▒▒: +▓▓▓▓▓▓ :▒▒▒▒▒▒▒▒
▒▒▒▒++ ▒▓▓▓▓▓+ ++▒▒▒▒
▒▒+ +▓▓▓▓▓▒ ++▒
▒▒▒▒+ ▒▓▓▓▓▒ +▒▒▒
▒▒▒▒▒▒▒+ ▒▓▓▓▓▒ +▒▒▒▒▒▒▒
+▒▒▒▒▒▒▒▒▒+ +▓▓▓▓▓: +▒▒▒▒▒▒▒▒++
`

// Logo returns the plain (unstyled) multi-line ASCII brand banner, without
// leading or trailing blank lines. The hub/join@ flows apply their own color.
func Logo() string { return strings.Trim(logo, "\n") }
2 changes: 1 addition & 1 deletion internal/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ var (
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
selStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0"))
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#38bdf8"))
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a"))
motdStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#4ade80")).
Expand Down
Loading
Loading