mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
handleJoin called setWebmailPassword unconditionally, so every visit to join@ minted a fresh Roundcube password -- including for members who registered and verified long ago. join@ is the address people remember, so returning members type it out of habit. Doing so silently invalidated their webmail login: the replacement password scrolled past once in the join output, the old one was already dead, and nothing in the session said a credential had changed. The failure surfaces later, as "my password stopped working", with no way to connect it back to having typed join@ -- and it reads like a compromised account, which is an alarming thing to hand someone over a no-op visit. Mint the password only while actually onboarding: a new key registering, or an account that completes email verification in this session. A returning member is told the password is unchanged and pointed at passwd@, which already sets one password across git, mail and chat and is key-gated, so it doubles as the forgot-password path. Also replace the bare webmail URL line shown when no password is minted with the full url/login block, so the address and where to get credentials are always visible, and distinguish "unchanged" (returning) from "not set yet" (onboarding, but Mailu was unreachable). Verified by inspection only: this box has no Go toolchain (repo needs 1.26), so the build is left to CI. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2373 lines
84 KiB
Go
2373 lines
84 KiB
Go
// Command agentbbs runs the AgentBBS SSH platform (PRD §4).
|
||
//
|
||
// SSH routes (by username):
|
||
//
|
||
// ssh bbs@host the BBS hub, guests welcome (play@/guest@ are aliases)
|
||
// ssh <name>@host the hub as a member/agent (SSH key required)
|
||
// ssh join@host onboarding: registers your key, confirms your email with an
|
||
// 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 passwd@host reset ONE password across git + mail + chat (forgot-password;
|
||
// key-gated, password@ is an alias). See docs/credentials.md
|
||
// 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
|
||
//
|
||
// Subcommands:
|
||
//
|
||
// agentbbs serve (default)
|
||
// agentbbs grant-pod NAME MONTHS manually extend a pod subscription
|
||
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
|
||
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
|
||
// agentbbs mint-token NAME issue a WebSocket API token for NAME
|
||
// agentbbs qrypt-invite NAME mint a qrypt.chat anonymous invite for NAME
|
||
// agentbbs qrypt-issuer-keygen print a fresh qrypt issuer seed + public key
|
||
// agentbbs notify-creds [flags] (re)email verified members their git +
|
||
// mailbox creds/links (preview unless --send)
|
||
// agentbbs broadcast [flags] <text> announce to ALL members via inbox + email
|
||
// (preview unless --send; see broadcast.go)
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/charmbracelet/log"
|
||
"github.com/charmbracelet/ssh"
|
||
"github.com/charmbracelet/wish"
|
||
"github.com/charmbracelet/wish/activeterm"
|
||
bm "github.com/charmbracelet/wish/bubbletea"
|
||
"github.com/charmbracelet/wish/logging"
|
||
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/files"
|
||
"github.com/profullstack/agentbbs/internal/forgejo"
|
||
"github.com/profullstack/agentbbs/internal/games"
|
||
"github.com/profullstack/agentbbs/internal/gopher"
|
||
"github.com/profullstack/agentbbs/internal/hub"
|
||
"github.com/profullstack/agentbbs/internal/ircpass"
|
||
"github.com/profullstack/agentbbs/internal/mail"
|
||
"github.com/profullstack/agentbbs/internal/mailbox"
|
||
"github.com/profullstack/agentbbs/internal/mailclients"
|
||
"github.com/profullstack/agentbbs/internal/mailu"
|
||
"github.com/profullstack/agentbbs/internal/motd"
|
||
"github.com/profullstack/agentbbs/internal/news"
|
||
"github.com/profullstack/agentbbs/internal/payments"
|
||
"github.com/profullstack/agentbbs/internal/plugin"
|
||
"github.com/profullstack/agentbbs/internal/pods"
|
||
"github.com/profullstack/agentbbs/internal/sandbox"
|
||
"github.com/profullstack/agentbbs/internal/sites"
|
||
"github.com/profullstack/agentbbs/internal/store"
|
||
"github.com/profullstack/agentbbs/internal/tor"
|
||
"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"
|
||
)
|
||
|
||
func env(k, def string) string {
|
||
if v := os.Getenv(k); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
// envInt reads a positive integer environment variable, falling back to def.
|
||
func envInt(k string, def int) int {
|
||
if v := os.Getenv(k); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
return n
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
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
|
||
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
|
||
irc ircpass.Config // chat/IRC password reset bridge (privileged helper)
|
||
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
|
||
gopher *gopher.Server // gopher/hedgehog engine (nil when AGENTBBS_GOPHER=0)
|
||
}
|
||
|
||
// Version is the agentbbs stack release, surfaced via `agentbbs version` and
|
||
// logged at startup. Bump on each release of the bbs.profullstack.com stack.
|
||
const Version = "v0.1.0"
|
||
|
||
func main() {
|
||
if len(os.Args) > 1 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-v") {
|
||
fmt.Println("agentbbs " + Version)
|
||
return
|
||
}
|
||
dataDir := env("AGENTBBS_DATA", "./data")
|
||
_ = os.MkdirAll(filepath.Join(dataDir, "users"), 0o755)
|
||
|
||
st, err := store.Open(filepath.Join(dataDir, "agentbbs.db"))
|
||
if err != nil {
|
||
log.Fatal("store", "err", err)
|
||
}
|
||
defer st.Close()
|
||
|
||
if len(os.Args) > 1 && os.Args[1] == "grant-pod" {
|
||
grantPod(st, os.Args[2:])
|
||
return
|
||
}
|
||
if len(os.Args) > 1 && (os.Args[1] == "map-domain" || os.Args[1] == "unmap-domain") {
|
||
domainCmd(st, dataDir, os.Args[1], os.Args[2:])
|
||
return
|
||
}
|
||
if len(os.Args) > 1 && os.Args[1] == "mint-token" {
|
||
mintToken(st, os.Args[2:])
|
||
return
|
||
}
|
||
if len(os.Args) > 1 && os.Args[1] == "qrypt-invite" {
|
||
qryptInviteCmd(st, os.Args[2:])
|
||
return
|
||
}
|
||
if len(os.Args) > 1 && os.Args[1] == "notify-creds" {
|
||
notifyCreds(st, os.Args[2:])
|
||
return
|
||
}
|
||
if len(os.Args) > 1 && os.Args[1] == "broadcast" {
|
||
broadcastCmd(st, os.Args[2:])
|
||
return
|
||
}
|
||
if len(os.Args) > 1 && os.Args[1] == "provision-user" {
|
||
provisionUser(st, os.Args[2:])
|
||
return
|
||
}
|
||
if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" {
|
||
qryptIssuerKeygen()
|
||
return
|
||
}
|
||
|
||
host := env("AGENTBBS_HOST", "bbs.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(),
|
||
mailu: mailuClient,
|
||
mailDomain: mailDomain,
|
||
mailHost: mailHost,
|
||
webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost),
|
||
forgejo: forgejo.ConfigFromEnv(),
|
||
irc: ircpass.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), 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
|
||
// AGENTBBS_FILES=0. The in-BBS browser is a hub plugin; the operator
|
||
// management TUI is the sftp@ route.
|
||
if env("AGENTBBS_FILES", "1") == "1" {
|
||
fsvc, err := files.New(a.st, files.Config{
|
||
Root: filepath.Join(dataDir, "files"),
|
||
DefaultQuota: int64(envInt("AGENTBBS_FILES_QUOTA_MB", 1024)) << 20,
|
||
})
|
||
if err != nil {
|
||
log.Fatal("files", "err", err)
|
||
}
|
||
a.files = fsvc
|
||
a.registry = append(a.registry, files.NewPlugin(fsvc))
|
||
}
|
||
|
||
// Custom domains: maintain the symlink farm Caddy serves and answer its
|
||
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
|
||
if sm, err := sites.NewManager(st, dataDir); err != nil {
|
||
log.Warn("custom domains disabled", "err", err)
|
||
} else {
|
||
a.sites = sm
|
||
if err := sm.Sync(); err != nil {
|
||
log.Warn("domain symlink sync", "err", err)
|
||
}
|
||
askAddr := env("AGENTBBS_ASK_ADDR", "127.0.0.1:8081")
|
||
go func() {
|
||
log.Info("on-demand-tls ask listening", "addr", askAddr)
|
||
if err := sm.ServeAsk(askAddr); err != nil {
|
||
log.Error("ask server", "err", err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
if m, err := pods.Detect(filepath.Join(dataDir, "users")); err == nil {
|
||
a.pods = m
|
||
log.Info("pods enabled", "engine", m.Engine())
|
||
} else {
|
||
log.Warn("pods disabled", "reason", err)
|
||
}
|
||
log.Info("sandbox", "mode", a.sandbox.Mode())
|
||
|
||
// Shared Message of the Day from profullstack.com — shown on the hub (and on
|
||
// IRC via Ergo's MOTD). Cached + refreshed in the background so it never
|
||
// blocks session start. Override the source with AGENTBBS_MOTD_URL (empty
|
||
// disables remote fetch).
|
||
motd.Start(context.Background(), env("AGENTBBS_MOTD_URL", motd.DefaultURL), 30*time.Minute)
|
||
|
||
// Email confirmation endpoint (the link in the join@ verification mail).
|
||
// Loopback only; Caddy reverse-proxies /verify to it. Separate from the
|
||
// on-demand-TLS ask server above.
|
||
verifyAddr := env("AGENTBBS_HTTP_ADDR", "127.0.0.1:8088")
|
||
go func() {
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/verify", a.handleVerify)
|
||
mux.HandleFunc("/irc-auth", a.handleIRCAuth) // Ergo auth-script: members-only gate
|
||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||
log.Info("verify endpoint listening", "addr", verifyAddr)
|
||
srv := &http.Server{Addr: verifyAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||
if err := srv.ListenAndServe(); err != nil {
|
||
log.Error("verify server", "err", err)
|
||
}
|
||
}()
|
||
|
||
// AgentGames WebSocket endpoint (twin of the game@ SSH route). Loopback;
|
||
// Caddy proxies wss://host/play to it.
|
||
go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090"))
|
||
|
||
// Web file browser (files.<host>): webmail-password login over the same
|
||
// /me + /public storage as SFTP. Loopback; Caddy proxies files.<host> to it.
|
||
a.startFilesWeb()
|
||
|
||
// News (NNTP) server: the members-only Usenet network (docs/news.md). The
|
||
// loopback plaintext listener backs the in-BBS news@ reader; the public
|
||
// NNTPS listener (:563, TLS) serves desktop newsreaders and agents. Free for
|
||
// every registered member, like irc@. Disable with AGENTBBS_NEWS=0.
|
||
a.newsAddr = env("AGENTBBS_NEWS_ADDR", news.DefaultAddr)
|
||
if env("AGENTBBS_NEWS", "1") == "1" {
|
||
newsHost := env("AGENTBBS_NEWS_HOST", "news."+strings.TrimPrefix(host, "bbs."))
|
||
ns := news.New(st, newsHost)
|
||
if err := ns.SeedGroups(news.ParseGroups(os.Getenv("AGENTBBS_NEWS_GROUPS"))); err != nil {
|
||
log.Warn("news seed groups", "err", err)
|
||
}
|
||
go func() {
|
||
log.Info("news loopback listening", "addr", a.newsAddr)
|
||
if err := ns.ServeLoopback(context.Background(), a.newsAddr); err != nil {
|
||
log.Error("news loopback", "err", err)
|
||
}
|
||
}()
|
||
if cert, key := os.Getenv("AGENTBBS_NEWS_TLS_CERT"), os.Getenv("AGENTBBS_NEWS_TLS_KEY"); cert != "" && key != "" {
|
||
tlsAddr := env("AGENTBBS_NEWS_TLS_ADDR", ":563")
|
||
go func() {
|
||
log.Info("news NNTPS listening", "addr", tlsAddr, "host", newsHost)
|
||
if err := ns.ServeTLS(context.Background(), tlsAddr, cert, key); err != nil {
|
||
log.Error("news nntps", "err", err)
|
||
}
|
||
}()
|
||
} else {
|
||
log.Warn("news NNTPS disabled (no AGENTBBS_NEWS_TLS_CERT/KEY) — loopback news@ reader still works")
|
||
}
|
||
}
|
||
|
||
// Gopher (RFC 1436) + hedgehog: BBS content over gopher. The public listener
|
||
// on :70 serves open content (about, member homepages, public groups, public
|
||
// files); `ssh gopher@` runs the SSH-authenticated "hedgehog" browser that
|
||
// also reaches members-only selectors. See docs/gopher.md. Disable with
|
||
// AGENTBBS_GOPHER=0. (:70 is privileged — the operator grants the bind
|
||
// capability, e.g. setcap cap_net_bind_service, or maps a high port.)
|
||
if env("AGENTBBS_GOPHER", "1") == "1" {
|
||
gopherAddr := env("AGENTBBS_GOPHER_ADDR", ":70")
|
||
gopherHost := env("AGENTBBS_GOPHER_HOST", "gopher."+strings.TrimPrefix(host, "bbs."))
|
||
port := gopherAddr[strings.LastIndex(gopherAddr, ":")+1:]
|
||
pubGroups := news.ParseGroups(os.Getenv("AGENTBBS_GOPHER_NEWS_GROUPS"))
|
||
if len(pubGroups) == 0 {
|
||
pubGroups = []news.GroupSpec{{Name: "pfs.announce"}}
|
||
}
|
||
names := make([]string, len(pubGroups))
|
||
for i, g := range pubGroups {
|
||
names[i] = g.Name
|
||
}
|
||
a.gopher = gopher.New(st, dataDir, gopherHost, port, names, a.gopherAbout)
|
||
go func() {
|
||
log.Info("gopher listening", "addr", gopherAddr, "host", gopherHost)
|
||
if err := a.gopher.Serve(context.Background(), gopherAddr); err != nil {
|
||
log.Error("gopher listener", "err", err, "hint", "port 70 is privileged; set AGENTBBS_GOPHER_ADDR to a high port or grant cap_net_bind_service")
|
||
}
|
||
}()
|
||
}
|
||
|
||
addr := env("AGENTBBS_ADDR", ":2222")
|
||
opts := []ssh.Option{
|
||
wish.WithAddress(addr),
|
||
wish.WithHostKeyPath(filepath.Join(dataDir, "ssh", "host_ed25519")),
|
||
// Keys are always accepted at the transport layer; identity and
|
||
// authorization are resolved per-route in the session handler.
|
||
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { return true }),
|
||
// Keyless interactive auth admits guests (bbs@/play@) only.
|
||
wish.WithKeyboardInteractiveAuth(func(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { return true }),
|
||
wish.WithIdleTimeout(30 * time.Minute),
|
||
wish.WithMiddleware(
|
||
a.router(),
|
||
a.track(), // register every session for the admin console
|
||
logging.Middleware(),
|
||
),
|
||
}
|
||
// SFTP rides the same :22 listener as a subsystem; identity is the SSH key,
|
||
// so `sftp files@<host>` works with the member's login key.
|
||
if a.files != nil {
|
||
opts = append(opts, wish.WithSubsystem("sftp", a.files.Subsystem()))
|
||
}
|
||
srv, err := wish.NewServer(opts...)
|
||
if err != nil {
|
||
log.Fatal("server", "err", err)
|
||
}
|
||
|
||
done := make(chan os.Signal, 1)
|
||
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||
log.Info("agentbbs listening", "addr", addr, "version", Version)
|
||
go func() {
|
||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
|
||
log.Error("serve", "err", err)
|
||
done <- syscall.SIGTERM
|
||
}
|
||
}()
|
||
<-done
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
_ = srv.Shutdown(ctx)
|
||
}
|
||
|
||
// router dispatches a session by username (PRD §4.4 + pods addendum).
|
||
// The active-PTY guard applies to hub sessions only; join@ and pod@ check their
|
||
// own PTY (both are interactive) so they can return a tailored hint instead of
|
||
// activeterm's opaque rejection.
|
||
func (a *app) router() wish.Middleware {
|
||
btMw := bm.Middleware(a.teaHandler)
|
||
adminMw := bm.Middleware(a.adminTeaHandler)
|
||
filesAdminMw := bm.Middleware(a.filesAdminTeaHandler)
|
||
return func(next ssh.Handler) ssh.Handler {
|
||
hubHandler := activeterm.Middleware()(btMw(next))
|
||
adminHandler := activeterm.Middleware()(adminMw(next))
|
||
filesAdminHandler := activeterm.Middleware()(filesAdminMw(next))
|
||
return func(s ssh.Session) {
|
||
user := strings.ToLower(s.User())
|
||
code, isVideo := calls.RouteCode(user)
|
||
switch {
|
||
case auth.IsJoinName(user):
|
||
a.handleJoin(s)
|
||
case auth.IsDomainName(user):
|
||
a.handleDomain(s)
|
||
case auth.IsAdminName(user):
|
||
adminHandler(s)
|
||
case auth.IsGameName(user):
|
||
a.handleGame(s)
|
||
case auth.IsPodName(user):
|
||
a.handlePod(s)
|
||
case auth.IsTorURLName(user):
|
||
a.handleTorURL(s)
|
||
case auth.IsTorIRCName(user):
|
||
a.handleTorIRC(s)
|
||
case auth.IsTorName(user):
|
||
a.handleTorCmd(s)
|
||
case auth.IsNewsName(user):
|
||
a.handleNews(s)
|
||
case auth.IsGopherName(user):
|
||
a.handleGopher(s)
|
||
case auth.IsMailName(user):
|
||
a.handleMail(s)
|
||
case auth.IsFilesAdminName(user):
|
||
filesAdminHandler(s)
|
||
case auth.IsMsgName(user):
|
||
a.handleMsg(s)
|
||
case auth.IsPasswdName(user):
|
||
a.handlePasswd(s)
|
||
case isVideo:
|
||
a.handleVideo(s, code)
|
||
case user == "agent":
|
||
a.handleChat(s)
|
||
case a.handleFinger(s, user):
|
||
// fingered an existing account that isn't the caller's; done.
|
||
default:
|
||
hubHandler(s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// bbsBanner is the ASCII brand mark shown atop the hub menu and the join@ flow.
|
||
var bbsBanner = brand.Logo()
|
||
|
||
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.
|
||
func (a *app) hubMOTD(u auth.User) string {
|
||
body := env("AGENTBBS_MOTD",
|
||
"A terminal BBS for humans & AI agents.\nGames · IRC · News · a Linux pod · your own homepage.")
|
||
var s string
|
||
if u.Kind == auth.Guest {
|
||
s = "You're browsing as a guest.\n" + body +
|
||
"\nssh join@" + a.host + " to claim a username, a pod & a homepage."
|
||
} else {
|
||
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)
|
||
}
|
||
s = welcome + "\n" + body
|
||
}
|
||
// Append the shared daily Message of the Day from profullstack.com, if loaded.
|
||
if m := motd.Current(); m != "" {
|
||
s += "\n\n" + m
|
||
}
|
||
return s
|
||
}
|
||
|
||
// teaHandler builds the hub model for guests, members, and agents.
|
||
func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||
fp := auth.Fingerprint(s.PublicKey())
|
||
username := strings.ToLower(s.User())
|
||
|
||
var u auth.User
|
||
var su store.User
|
||
guest := auth.IsGuestName(username) || fp == ""
|
||
if guest {
|
||
// Keyless or explicitly anonymous → guest. Named accounts require a key.
|
||
if !auth.IsGuestName(username) {
|
||
wish.Println(s, "note: member access requires an SSH key; joining as guest.")
|
||
}
|
||
u = auth.User{Name: "guest", Kind: auth.Guest}
|
||
} else {
|
||
// A key maps to exactly one account: if this key is already
|
||
// registered, that identity wins regardless of the username typed.
|
||
var found bool
|
||
var err error
|
||
su, found, err = a.st.UserByFingerprint(fp)
|
||
if err == nil && !found {
|
||
su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp)
|
||
}
|
||
if errors.Is(err, store.ErrKeyMismatch) {
|
||
wish.Fatalln(s, "that username is registered with a different SSH key.")
|
||
return nil, nil
|
||
} else if err != nil {
|
||
wish.Fatalln(s, "account error: "+err.Error())
|
||
return nil, nil
|
||
}
|
||
if su.Banned {
|
||
wish.Fatalln(s, "this account is suspended. Contact an operator if you think this is a mistake.")
|
||
return nil, nil
|
||
}
|
||
if su.Name != username {
|
||
wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".")
|
||
}
|
||
// Catch a premium payment that settled since their last visit (silent;
|
||
// 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, 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)
|
||
// tilde.town-style web home: served at https://<host>/~<name> by the
|
||
// Caddy front end (see setup.sh). Seed an editable starter page so the
|
||
// URL works the moment a member first signs in.
|
||
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
|
||
}
|
||
return hub.New(u, ctx, a.enabledPlugins(), a.sessionApps(s, su, guest), bbsBanner, a.hubMOTD(u)), []tea.ProgramOption{tea.WithAltScreen()}
|
||
}
|
||
|
||
// sessionExec adapts a func to tea.ExecCommand so the hub can run a
|
||
// terminal-takeover feature (pod shell, IRC, news, mail, Tor) via tea.Exec and
|
||
// return to the menu afterwards. The feature reads and writes the ssh.Session
|
||
// directly, so the stream hooks are no-ops.
|
||
type sessionExec struct{ run func() error }
|
||
|
||
func (e sessionExec) Run() error { return e.run() }
|
||
func (e sessionExec) SetStdin(io.Reader) {}
|
||
func (e sessionExec) SetStdout(io.Writer) {}
|
||
func (e sessionExec) SetStderr(io.Writer) {}
|
||
|
||
// sessionApps builds the hub's terminal-takeover entries (pod, IRC, news, Tor)
|
||
// so a member reaches everything from one `ssh <name>@host` login instead of
|
||
// separate `ssh pod@`/`irc@`/`news@`/`tor@` connections (which still work as
|
||
// aliases, mainly for bots). Each entry is gated by membership/verification/plan
|
||
// and shown locked with a reason when unavailable.
|
||
func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.SessionApp {
|
||
membersOnly := "members only — register first: ssh join@" + a.host
|
||
apps := make([]hub.SessionApp, 0, 4)
|
||
|
||
// Pod — free for verified members.
|
||
podLock := ""
|
||
switch {
|
||
case guest:
|
||
podLock = membersOnly
|
||
case env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !su.EmailVerified:
|
||
podLock = "confirm your email first — re-run: ssh join@" + a.host
|
||
case a.pods == nil:
|
||
podLock = "pods are temporarily unavailable on this host"
|
||
}
|
||
apps = append(apps, hub.SessionApp{
|
||
Title: "Shell",
|
||
Description: "drop straight into a bash shell in your pod",
|
||
Locked: podLock,
|
||
Cmd: sessionExec{run: func() error { return a.pods.Exec(s, su.Name, []string{"bash", "-l"}) }},
|
||
})
|
||
apps = append(apps, hub.SessionApp{
|
||
Title: "Pod",
|
||
Description: "your own Linux pod — attach to its main session",
|
||
Locked: podLock,
|
||
Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }},
|
||
})
|
||
|
||
// IRC is members-only but accessed with an external client (or web) at
|
||
// irc.profullstack.com — not an in-BBS route — so it's not a hub menu item.
|
||
|
||
// News — free for any registered member.
|
||
newsLock := ""
|
||
if guest {
|
||
newsLock = membersOnly
|
||
}
|
||
apps = append(apps, hub.SessionApp{
|
||
Title: "News",
|
||
Description: "members-only Usenet/NNTP discussion",
|
||
Locked: newsLock,
|
||
Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }},
|
||
})
|
||
|
||
// Mail — a free benefit of membership: the AgentMail TUI for your
|
||
// <name>@<mailDomain> mailbox.
|
||
mailLock := ""
|
||
switch {
|
||
case guest:
|
||
mailLock = membersOnly
|
||
case !a.mailEnabled():
|
||
mailLock = "mail is temporarily unavailable on this host"
|
||
}
|
||
apps = append(apps, hub.SessionApp{
|
||
Title: "Mail",
|
||
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
|
||
}
|
||
defer c.Close()
|
||
return mailbox.RunReader(s, c)
|
||
}},
|
||
})
|
||
|
||
// Alternative mail clients: himalaya and meli, pointed at the same mailbox as
|
||
// the built-in AgentMail reader. Same lock rules as Mail, plus a lock when the
|
||
// binary isn't installed on this host so the entry explains itself.
|
||
for _, mc := range []mailclients.Client{mailclients.Himalaya, mailclients.Meli} {
|
||
mc := mc
|
||
lock := mailLock
|
||
if lock == "" && !mc.Available() {
|
||
lock = mc.Name() + " is not installed on this host"
|
||
}
|
||
apps = append(apps, hub.SessionApp{
|
||
Title: "Mail · " + mc.Title(),
|
||
Description: mc.Name() + " on your " + a.mailAddress(su.Name) + " mailbox",
|
||
Locked: lock,
|
||
Cmd: sessionExec{run: func() error {
|
||
_ = a.ensureMailbox(su)
|
||
return mc.Run(s, a.mailBackendFor(su))
|
||
}},
|
||
})
|
||
}
|
||
|
||
// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
|
||
torLock := ""
|
||
switch {
|
||
case guest:
|
||
torLock = membersOnly
|
||
case !su.Premium:
|
||
torLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
|
||
case a.pods == nil:
|
||
torLock = "pods are temporarily unavailable on this host"
|
||
}
|
||
apps = append(apps, hub.SessionApp{
|
||
Title: "Tor shell",
|
||
Description: "a torsocks shell in your pod (everything over Tor)",
|
||
Locked: torLock,
|
||
Cmd: sessionExec{run: func() error { return a.pods.Exec(s, su.Name, tor.Torsocks([]string{"bash", "-l"})) }},
|
||
})
|
||
|
||
return apps
|
||
}
|
||
|
||
// readLine reads one line of interactive input from an SSH session that is
|
||
// running under a client-allocated PTY. That detail is the whole reason this
|
||
// helper exists: when the client requests a PTY (which `ssh join@host` does by
|
||
// default) it puts its OWN terminal into raw mode, so it sends raw keystrokes —
|
||
// Enter arrives as '\r', not '\n' — and does NO local echo. bufio.ReadString
|
||
// ('\n') therefore blocks forever (the '\n' never comes) and the user sees a
|
||
// dead prompt. So we read byte-by-byte, accept either '\r' or '\n' as the line
|
||
// terminator, handle backspace, and echo printable bytes back ourselves.
|
||
func readLine(s ssh.Session, in *bufio.Reader) (string, error) {
|
||
var b []byte
|
||
for {
|
||
c, err := in.ReadByte()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
switch c {
|
||
case '\r', '\n':
|
||
wish.Print(s, "\r\n")
|
||
return string(b), nil
|
||
case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
|
||
return "", io.EOF
|
||
case 0x7f, '\b': // DEL / backspace: erase last char on screen too
|
||
if len(b) > 0 {
|
||
b = b[:len(b)-1]
|
||
wish.Print(s, "\b \b")
|
||
}
|
||
default:
|
||
if c >= 0x20 { // printable byte; ignore other control codes
|
||
b = append(b, c)
|
||
wish.Print(s, string(c))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// readSecret reads a line like readLine but echoes '*' for each character instead
|
||
// of the character itself, so a password isn't shown on screen. Same raw-PTY
|
||
// handling (accept '\r' or '\n', handle backspace, abort on Ctrl-C/Ctrl-D).
|
||
func readSecret(s ssh.Session, in *bufio.Reader) (string, error) {
|
||
var b []byte
|
||
for {
|
||
c, err := in.ReadByte()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
switch c {
|
||
case '\r', '\n':
|
||
wish.Print(s, "\r\n")
|
||
return string(b), nil
|
||
case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
|
||
return "", io.EOF
|
||
case 0x7f, '\b':
|
||
if len(b) > 0 {
|
||
b = b[:len(b)-1]
|
||
wish.Print(s, "\b \b")
|
||
}
|
||
default:
|
||
if c >= 0x20 {
|
||
b = append(b, c)
|
||
wish.Print(s, "*")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// handleJoin runs onboarding interactively in one SSH session: register the
|
||
// visitor's key, confirm their email with a code we email them, then offer the
|
||
// $99 Founding Lifetime membership (CoinPay). It then disconnects.
|
||
func (a *app) handleJoin(s ssh.Session) {
|
||
fp := auth.Fingerprint(s.PublicKey())
|
||
if fp == "" {
|
||
wish.Println(s, "join@ needs an SSH public key (try: ssh -i ~/.ssh/id_ed25519 join@"+a.host+")")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
// Onboarding reads an email and a verification code interactively, so it
|
||
// needs a terminal. Without a PTY the prompts would block forever (e.g. ssh
|
||
// launched with no controlling tty, which delegates prompts to ssh-askpass).
|
||
// Fail fast with a hint instead of hanging.
|
||
if _, _, hasPty := s.Pty(); !hasPty {
|
||
wish.Println(s, "join@ is interactive — reconnect with a terminal: ssh -t join@"+a.host)
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
wish.Println(s, "\n"+bannerStyle.Render(bbsBanner))
|
||
in := bufio.NewReader(s)
|
||
|
||
u, found, err := a.st.UserByFingerprint(fp)
|
||
if err != nil {
|
||
wish.Fatalln(s, "registration error: "+err.Error())
|
||
return
|
||
}
|
||
// Whether this session actually onboarded anyone: a brand-new key here, or an
|
||
// account that finishes email verification below. Everything with a credential
|
||
// side effect keys off this rather than off reaching join@ at all.
|
||
onboarding := !found
|
||
if !found {
|
||
// New key: show the acceptable-use terms and require acceptance before
|
||
// creating the account, then let the visitor pick their own handle (a
|
||
// returning key keeps the name it already chose).
|
||
wish.Println(s, "\n Welcome to AgentBBS — let's set up your account.")
|
||
if !a.acceptTerms(s, in) {
|
||
wish.Println(s, "\n You must accept the terms to register — no account was created.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if u, err = a.registerNewMember(s, in, fp); err != nil {
|
||
wish.Fatalln(s, "registration error: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join")
|
||
|
||
wish.Println(s, "\n"+strings.Join([]string{
|
||
" account " + u.Name,
|
||
" key " + fp,
|
||
}, "\n"))
|
||
|
||
// 1) email -> emailed code -> enter code. A verified account is a free
|
||
// 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)
|
||
return
|
||
}
|
||
a.notifySignup(u)
|
||
onboarding = true
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// Only while ONBOARDING. This used to run on every join@, which quietly broke
|
||
// returning members: join@ is the address people remember, so typing it again
|
||
// out of habit rotated a live Roundcube password: the replacement scrolled past
|
||
// in this output once, the old one was already dead, and nothing said a
|
||
// credential had changed. Rotating a working password must never be a side
|
||
// effect of saying hello -- passwd@ is the deliberate way to change one, and it
|
||
// is already key-gated, so it doubles as the forgot-password path.
|
||
var webmailPW string
|
||
if onboarding {
|
||
webmailPW = a.setWebmailPassword(u)
|
||
}
|
||
|
||
lead := " You're in. One login gets you everything — no other servers to ssh into:"
|
||
if !onboarding {
|
||
lead = " You're already a member — nothing changed. One login gets you everything:"
|
||
}
|
||
includes := []string{
|
||
lead,
|
||
"",
|
||
" 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,
|
||
}
|
||
switch {
|
||
case 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)",
|
||
)
|
||
case a.webmailURL != "" && !onboarding:
|
||
// Say the password is untouched. A returning member who came here looking
|
||
// for their credentials needs to know this visit did not change them, and
|
||
// where to go if they have lost them.
|
||
includes = append(includes,
|
||
"",
|
||
" Webmail (read your mail in a browser):",
|
||
" • url "+a.webmailURL,
|
||
" • login "+a.mailAddress(u.Name),
|
||
" • password unchanged — forgot it? ssh passwd@"+a.host,
|
||
)
|
||
case a.webmailURL != "":
|
||
// Onboarding, but minting the password failed (Mailu down or unconfigured).
|
||
// Don't imply one is waiting for them -- point at the way to set one.
|
||
includes = append(includes,
|
||
"",
|
||
" Webmail (read your mail in a browser):",
|
||
" • url "+a.webmailURL,
|
||
" • login "+a.mailAddress(u.Name),
|
||
" • password not set yet — set one: ssh passwd@"+a.host,
|
||
)
|
||
}
|
||
wish.Println(s, "\n"+strings.Join(includes, "\n"))
|
||
|
||
// 2) Founding Lifetime ($99 one-time): custom domains + Tor shell.
|
||
a.offerPremium(s, in, &u)
|
||
_ = s.Exit(0)
|
||
}
|
||
|
||
// acceptTerms shows the acceptable-use terms and requires the visitor to type
|
||
// "agree" before an account is created. AgentBBS is for lawful use only; illegal
|
||
// activity is grounds for an immediate, permanent ban. Returns true on acceptance.
|
||
func (a *app) acceptTerms(s ssh.Session, in *bufio.Reader) bool {
|
||
wish.Println(s, "\n"+strings.Join([]string{
|
||
" Terms of use — please read before you join:",
|
||
" • AgentBBS is for LAWFUL use only. Illegal activity is not permitted",
|
||
" and will result in an immediate, permanent ban — and may be reported",
|
||
" to the relevant authorities.",
|
||
" • Don't abuse the service, other members, or the shared infrastructure,",
|
||
" and don't use it to harm others.",
|
||
" • You are responsible for everything you — and any agents you run —",
|
||
" do here.",
|
||
}, "\n"))
|
||
wish.Print(s, "\n Type \"agree\" to accept and continue: ")
|
||
line, err := readLine(s, in)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||
case "agree", "i agree", "agreed", "yes", "y":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// fpToken derives up to n lowercase alphanumeric characters from an SSH key
|
||
// fingerprint, for use in a default username/home-dir. The raw base64
|
||
// fingerprint can contain '+' and '/', which are unsafe as a filesystem token,
|
||
// so we keep only [a-z0-9].
|
||
func fpToken(fp string, n int) string {
|
||
cleaned := strings.Map(func(r rune) rune {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||
return r
|
||
default:
|
||
return -1
|
||
}
|
||
}, strings.ToLower(strings.TrimPrefix(fp, "SHA256:")))
|
||
if len(cleaned) > n {
|
||
cleaned = cleaned[:n]
|
||
}
|
||
return cleaned
|
||
}
|
||
|
||
// registerNewMember asks the visitor to choose a username, then creates their
|
||
// member account under it. The name is sanitized to the hub/subdomain charset,
|
||
// rejected if reserved, and must be free; pressing enter accepts a generated
|
||
// member-<fp8> default. The chosen name is also the member's pod home
|
||
// (/home/<name>), so it must be a safe shell/filesystem token — the sanitizer
|
||
// (and the default below) keep it to lowercase [a-z0-9-]. Returns the user.
|
||
func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) {
|
||
def := "member-" + fpToken(fp, 8)
|
||
wish.Println(s, "\n Pick a username — letters, numbers and dashes, 3–20 chars.")
|
||
wish.Println(s, " It's your handle for ssh <name>@"+a.host+" (your pod home is /home/<name>).")
|
||
|
||
for tries := 0; tries < 5; tries++ {
|
||
wish.Print(s, "\n Username ["+def+"]: ")
|
||
line, err := readLine(s, in)
|
||
if err != nil {
|
||
return store.User{}, err
|
||
}
|
||
raw := strings.TrimSpace(line)
|
||
if raw == "" {
|
||
return a.st.EnsureUser(def, string(auth.Member), fp)
|
||
}
|
||
name, ok := auth.SanitizeUsername(raw)
|
||
switch {
|
||
case !ok && auth.IsReservedName(name):
|
||
wish.Println(s, " \""+name+"\" is reserved — pick another.")
|
||
continue
|
||
case !ok:
|
||
wish.Println(s, " needs 3–20 chars of letters, numbers or dashes — try again.")
|
||
continue
|
||
}
|
||
if _, taken, err := a.st.UserByName(name); err != nil {
|
||
return store.User{}, err
|
||
} else if taken {
|
||
wish.Println(s, " \""+name+"\" is taken — try another.")
|
||
continue
|
||
}
|
||
return a.st.EnsureUser(name, string(auth.Member), fp)
|
||
}
|
||
wish.Println(s, " Keeping "+def+" for now.")
|
||
return a.st.EnsureUser(def, string(auth.Member), fp)
|
||
}
|
||
|
||
// verifyEmailInteractive collects an email, emails a 6-digit code, and prompts
|
||
// the visitor to type it back. It updates *u and returns true once verified.
|
||
func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.User) bool {
|
||
var email string
|
||
for tries := 0; tries < 3; tries++ {
|
||
wish.Print(s, "\n Email: ")
|
||
line, err := readLine(s, in)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if e := strings.TrimSpace(line); validEmail(e) {
|
||
email = e
|
||
break
|
||
}
|
||
wish.Println(s, " that doesn't look like an email — try again.")
|
||
}
|
||
if email == "" {
|
||
wish.Println(s, " No valid email — run ssh join@"+a.host+" again when ready.")
|
||
return false
|
||
}
|
||
|
||
code := randCode()
|
||
if err := a.st.SetEmailVerification(u.ID, email, code); err != nil {
|
||
log.Error("set verification", "err", err)
|
||
wish.Println(s, " couldn't save your email; please retry.")
|
||
return false
|
||
}
|
||
switch {
|
||
case a.mail.Configured():
|
||
if err := a.mail.Send(email, "Your AgentBBS confirmation code", verifyCodeEmailBody(u.Name, code)); err != nil {
|
||
log.Error("send code", "err", err)
|
||
wish.Println(s, " couldn't email the code; please retry or contact an admin.")
|
||
return false
|
||
}
|
||
wish.Println(s, " Sent a 6-digit code to "+email+".")
|
||
default:
|
||
// No SMTP configured yet: show the code in-session so the box is still
|
||
// usable. Set AGENTBBS_SMTP_* in production so codes are emailed instead.
|
||
log.Warn("smtp not configured — showing join code in session", "email", email)
|
||
wish.Println(s, " (email isn't configured on this host yet — here is your code)")
|
||
wish.Println(s, " code: "+code)
|
||
}
|
||
|
||
for tries := 0; tries < 3; tries++ {
|
||
wish.Print(s, " Enter the code: ")
|
||
line, err := readLine(s, in)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
vu, ok, err := a.st.ConfirmEmailCode(u.ID, strings.TrimSpace(line))
|
||
if err != nil {
|
||
log.Error("confirm code", "err", err)
|
||
wish.Println(s, " verification error; please retry.")
|
||
return false
|
||
}
|
||
if ok {
|
||
*u = vu
|
||
a.provisionGit(u, authorizedKey(s))
|
||
wish.Println(s, " Email confirmed ✓")
|
||
return true
|
||
}
|
||
wish.Println(s, " that code didn't match — try again.")
|
||
}
|
||
wish.Println(s, " Too many attempts — run ssh join@"+a.host+" again for a fresh code.")
|
||
return false
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// Verify the CoinPay payment we created for them (if any) has settled.
|
||
if u.PremiumPayID == "" {
|
||
return false
|
||
}
|
||
if paid, checked := payments.VerifyPremium(u.PremiumPayID); !checked || !paid {
|
||
return false
|
||
}
|
||
if err := a.st.GrantPremium(u.ID, u.PremiumPayID); err != nil {
|
||
log.Error("grant premium", "err", err)
|
||
return false
|
||
}
|
||
u.Premium = true
|
||
return true
|
||
}
|
||
|
||
// mailWelcomeEmailBody is the plain-text email sent when a member's @host
|
||
// mailbox alias is provisioned: their new address and the webmail link. Used by
|
||
// the `notify-creds` backfill command (see notifycreds.go).
|
||
func mailWelcomeEmailBody(name, address, webmail string) string {
|
||
b := "Hi " + name + ",\n\n" +
|
||
"Your member mailbox is live:\n\n" +
|
||
" " + address + "\n\n" +
|
||
"Mail sent there forwards to this address.\n"
|
||
if webmail != "" {
|
||
b += "\nRead and send from the webmail interface here:\n\n" +
|
||
" " + webmail + "\n"
|
||
}
|
||
b += "\nIf you didn't request this, you can ignore this email.\n"
|
||
return b
|
||
}
|
||
|
||
// 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 bonus perks:",
|
||
"",
|
||
" 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 — custom domains and
|
||
// the Tor shell — and only mints a CoinPay charge if the member explicitly opts
|
||
// in at the prompt. Showing the pitch must NOT create a payment: minting on
|
||
// every join@ produced a $99 invoice for everyone who connected. Non-blocking:
|
||
// the member pays out of band and perks unlock on their next connect (or
|
||
// re-running join@).
|
||
func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) {
|
||
// Maybe they already paid (e.g. re-ran join@ after paying).
|
||
if a.ensurePremium(u) {
|
||
a.showPremiumWelcome(s, *u)
|
||
return
|
||
}
|
||
|
||
// Pitch only — no charge is created here.
|
||
wish.Println(s, "\n"+strings.Join([]string{
|
||
" ★ 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 — 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",
|
||
}, "\n"))
|
||
|
||
// Explicit opt-in. Anything but yes leaves with no payment created.
|
||
wish.Print(s, "\n Become a Founding member now? Type \"yes\" for a payment address [no]: ")
|
||
line, err := readLine(s, in)
|
||
if err != nil || !isYes(line) {
|
||
wish.Print(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n\n")
|
||
return
|
||
}
|
||
|
||
ref := payments.PremiumReference(u.PubKeyFP)
|
||
c, ok, err := payments.CreatePremiumCharge(ref)
|
||
if !ok || err != nil {
|
||
if err != nil {
|
||
log.Error("create premium charge", "err", err)
|
||
}
|
||
wish.Print(s, "\n Payment is temporarily unavailable — please try again shortly.\n\n")
|
||
return
|
||
}
|
||
// Remember the payment id so a later connect can confirm settlement.
|
||
if err := a.st.SetPremiumPayment(u.ID, c.ID); err != nil {
|
||
log.Error("store premium payment id", "err", err)
|
||
}
|
||
amount := "$" + payments.PremiumAmount() + " " + payments.PremiumCurrency()
|
||
if c.CryptoAmount != "" {
|
||
cur := c.Currency
|
||
if cur == "" {
|
||
cur = strings.ToUpper(payments.PremiumBlockchain())
|
||
}
|
||
amount += " (≈ " + c.CryptoAmount + " " + cur + ")"
|
||
}
|
||
lines := []string{
|
||
"",
|
||
" amount " + amount,
|
||
" send to " + c.Address,
|
||
}
|
||
if c.QR != "" {
|
||
lines = append(lines, " qr "+c.QR)
|
||
}
|
||
lines = append(lines,
|
||
"",
|
||
" Perks unlock once payment confirms — then re-run: ssh join@"+a.host,
|
||
"",
|
||
)
|
||
wish.Println(s, strings.Join(lines, "\n"))
|
||
}
|
||
|
||
// isYes reports whether a prompt line is an affirmative opt-in.
|
||
func isYes(line string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||
case "yes", "y":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// notifySignup emails the operator the details of a newly verified signup.
|
||
// No-op when SMTP isn't configured. Subject is "bbs" per the operator's filter.
|
||
func (a *app) notifySignup(u store.User) {
|
||
to := env("AGENTBBS_SIGNUP_NOTIFY", "anthony@profullstack.com")
|
||
if !a.mail.Configured() || to == "" {
|
||
return
|
||
}
|
||
body := "New AgentBBS signup\n\n" +
|
||
" username: " + u.Name + "\n" +
|
||
" email: " + u.Email + "\n" +
|
||
" key: " + u.PubKeyFP + "\n" +
|
||
" homepage: https://" + a.host + "/~" + u.Name + "\n"
|
||
if err := a.mail.Send(to, "bbs", body); err != nil {
|
||
log.Error("signup notify", "err", err, "to", to)
|
||
}
|
||
}
|
||
|
||
// verifyCodeEmailBody is the plain-text confirmation-code email.
|
||
func verifyCodeEmailBody(name, code string) string {
|
||
return "Hi " + name + ",\n\n" +
|
||
"Your AgentBBS confirmation code is:\n\n" +
|
||
" " + code + "\n\n" +
|
||
"Enter it in your open ssh join@ session to activate your account.\n" +
|
||
"If you didn't request this, you can ignore this email.\n"
|
||
}
|
||
|
||
// randCode returns a 6-digit numeric confirmation code.
|
||
func randCode() string {
|
||
var b [4]byte
|
||
_, _ = rand.Read(b[:])
|
||
return fmt.Sprintf("%06d", binary.BigEndian.Uint32(b[:])%1000000)
|
||
}
|
||
|
||
// validEmail is a deliberately loose check: one @, a dotted domain, no spaces.
|
||
func validEmail(e string) bool {
|
||
if len(e) < 3 || len(e) > 254 || strings.ContainsAny(e, " \t\r\n") {
|
||
return false
|
||
}
|
||
at := strings.LastIndexByte(e, '@')
|
||
if at <= 0 || at == len(e)-1 {
|
||
return false
|
||
}
|
||
return strings.Contains(e[at+1:], ".")
|
||
}
|
||
|
||
// handleVerify consumes the email confirmation link.
|
||
func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
u, ok, err := a.st.VerifyEmail(r.URL.Query().Get("token"))
|
||
if err != nil {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(verifyPage("Something went wrong", "Please try the link again in a moment.")))
|
||
return
|
||
}
|
||
if !ok {
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
_, _ = w.Write([]byte(verifyPage("Link invalid or expired",
|
||
"Run <code>ssh join@"+a.host+"</code> to get a fresh confirmation link.")))
|
||
return
|
||
}
|
||
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>.")))
|
||
}
|
||
|
||
// provisionGit ensures a verified member has a git.profullstack.com account on
|
||
// the AgentGit Forgejo backend. Every verified member gets one — free and paid
|
||
// 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, pubKey string) {
|
||
if u == nil || !a.forgejo.Configured() || u.Name == "" || u.Email == "" {
|
||
return
|
||
}
|
||
created, password, err := a.forgejo.EnsureUser(u.Name, u.Email)
|
||
if err != nil {
|
||
log.Error("forgejo provision", "user", u.Name, "err", err)
|
||
return
|
||
}
|
||
if !created {
|
||
return
|
||
}
|
||
log.Info("provisioned git account", "user", u.Name, "host", a.forgejo.BaseURL)
|
||
// Register the BBS SSH key so the member can push with the same key they sign
|
||
// in with. No-op when called without a session key (e.g. the web verify flow).
|
||
if pubKey != "" {
|
||
if added, err := a.forgejo.EnsureKey(u.Name, "agentbbs", pubKey); err != nil {
|
||
log.Error("forgejo ssh key", "user", u.Name, "err", err)
|
||
} else if added {
|
||
log.Info("registered git ssh key", "user", u.Name)
|
||
}
|
||
}
|
||
// Email the verified address their web sign-in link + one-time password so
|
||
// they can log in to the Forgejo UI and create repositories. Best-effort:
|
||
// the account already exists, so a mail failure must not block anything.
|
||
if a.mail.Configured() {
|
||
if err := a.mail.Send(u.Email, "Your git.profullstack.com account is ready",
|
||
gitWelcomeEmailBody(u.Name, password, a.forgejo.LoginURL())); err != nil {
|
||
log.Error("git welcome email", "user", u.Name, "err", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// gitWelcomeEmailBody is the plain-text email sent when a member's AgentGit
|
||
// (Forgejo) account is created or reset — on provisioning and by the
|
||
// notify-creds ops command: web login link, username, and the one-time password
|
||
// they must change on first sign-in.
|
||
func gitWelcomeEmailBody(name, password, loginURL string) string {
|
||
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.
|
||
func verifyPage(title, body string) string {
|
||
return "<!doctype html><meta charset=utf-8><title>" + title + "</title>" +
|
||
"<style>body{background:#000;color:#33ff66;font:16px/1.6 monospace;max-width:40rem;margin:5rem auto;padding:0 1rem}code{color:#60a5fa}</style>" +
|
||
"<h1>" + title + "</h1><p>" + body + "</p>"
|
||
}
|
||
|
||
// handleDomain is the custom-domain self-service route. It is non-interactive
|
||
// and driven by the SSH command, mirroring join@:
|
||
//
|
||
// ssh domain@host list your domains + usage
|
||
// ssh domain@host add example.com point a domain at your homepage
|
||
// ssh domain@host rm example.com remove one
|
||
//
|
||
// Members CNAME (or A-record) their domain at the BBS host; Caddy issues a
|
||
// cert on first hit and serves their public_html. Requires a registered key.
|
||
func (a *app) handleDomain(s ssh.Session) {
|
||
fp := auth.Fingerprint(s.PublicKey())
|
||
if fp == "" {
|
||
wish.Println(s, "domain@ 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
|
||
}
|
||
// Custom domains are a Founding Lifetime perk ($99 one-time). ensurePremium also
|
||
// catches a payment that settled since their last visit.
|
||
if !a.ensurePremium(&u) {
|
||
wish.Println(s, strings.Join([]string{
|
||
"",
|
||
" Custom domains are a Premium feature (" + payments.PremiumPriceLabel + ", one-time).",
|
||
" Upgrade: ssh join@" + a.host,
|
||
"",
|
||
}, "\n"))
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if a.sites == nil {
|
||
wish.Println(s, "custom domains are temporarily unavailable on this host.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "domain")
|
||
|
||
args := s.Command()
|
||
action := ""
|
||
if len(args) > 0 {
|
||
action = strings.ToLower(args[0])
|
||
}
|
||
switch {
|
||
case action == "add" && len(args) >= 2:
|
||
domain, err := a.sites.Add(args[1], u.Name)
|
||
switch {
|
||
case errors.Is(err, sites.ErrInvalidDomain):
|
||
wish.Println(s, "not a valid domain: "+args[1])
|
||
_ = s.Exit(1)
|
||
case errors.Is(err, store.ErrDomainTaken):
|
||
wish.Println(s, domain+" is already mapped to another account.")
|
||
_ = s.Exit(1)
|
||
case err != nil:
|
||
wish.Println(s, "could not map domain: "+err.Error())
|
||
_ = s.Exit(1)
|
||
default:
|
||
wish.Println(s, strings.Join([]string{
|
||
"",
|
||
" Mapped " + domain + " → ~" + u.Name + "",
|
||
"",
|
||
" Point your DNS at this host, then visit https://" + domain + ":",
|
||
" CNAME " + domain + " -> " + a.host,
|
||
" (apex) A " + domain + " -> <this host's IPv4>",
|
||
"",
|
||
" HTTPS is issued automatically on the first request.",
|
||
" Edit your page in your pod: ~/public_html/index.html",
|
||
"",
|
||
}, "\n"))
|
||
_ = s.Exit(0)
|
||
}
|
||
case (action == "rm" || action == "remove" || action == "del") && len(args) >= 2:
|
||
domain, err := a.sites.Remove(args[1], u.Name)
|
||
if err != nil {
|
||
wish.Println(s, "could not remove domain: "+err.Error())
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
wish.Println(s, "removed "+domain)
|
||
_ = s.Exit(0)
|
||
default:
|
||
domains, _ := a.sites.List(u.Name)
|
||
lines := []string{"", " Custom domains for ~" + u.Name + ":"}
|
||
if len(domains) == 0 {
|
||
lines = append(lines, " (none yet)")
|
||
}
|
||
for _, d := range domains {
|
||
lines = append(lines, " https://"+d)
|
||
}
|
||
lines = append(lines,
|
||
"",
|
||
" Usage:",
|
||
" ssh domain@"+a.host+" add <domain> point a domain at ~"+u.Name,
|
||
" ssh domain@"+a.host+" rm <domain> remove one",
|
||
"",
|
||
)
|
||
wish.Println(s, strings.Join(lines, "\n"))
|
||
_ = s.Exit(0)
|
||
}
|
||
}
|
||
|
||
// handlePod admits paid members into their personal container.
|
||
func (a *app) handlePod(s ssh.Session) {
|
||
fp := auth.Fingerprint(s.PublicKey())
|
||
if fp == "" {
|
||
wish.Println(s, "pod@ 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
|
||
}
|
||
// Pods are a FREE member benefit — the only gate is a confirmed email, so
|
||
// every registered member gets their own Docker pod (set
|
||
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to drop even that on a dev host).
|
||
if env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !u.EmailVerified {
|
||
wish.Println(s, " Confirm your email first — run: ssh join@"+a.host+" (we email you a code to enter).")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
|
||
if a.pods == nil {
|
||
wish.Println(s, "pods are temporarily unavailable on this host.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "pod")
|
||
defer func() { _ = a.st.EndSession(sessID) }()
|
||
if err := a.pods.Attach(s, u.Name); err != nil {
|
||
wish.Println(s, "pod error: "+err.Error())
|
||
_ = s.Exit(1)
|
||
}
|
||
}
|
||
|
||
// torMember resolves the caller's key to a premium member for the tor routes,
|
||
// printing a reason and returning ok=false otherwise. It records the session.
|
||
func (a *app) torMember(s ssh.Session, route string) (store.User, bool) {
|
||
fp := auth.Fingerprint(s.PublicKey())
|
||
if fp == "" {
|
||
wish.Println(s, route+"@ needs your registered SSH key. New here? ssh join@"+a.host)
|
||
_ = s.Exit(1)
|
||
return store.User{}, false
|
||
}
|
||
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 store.User{}, false
|
||
}
|
||
if u.Banned {
|
||
wish.Println(s, "this account is suspended.")
|
||
_ = s.Exit(1)
|
||
return store.User{}, false
|
||
}
|
||
if !a.ensurePremium(&u) {
|
||
wish.Println(s, " "+route+" is a Founding Lifetime Member feature ($99 one-time, lifetime). Upgrade: ssh join@"+a.host)
|
||
_ = s.Exit(1)
|
||
return store.User{}, false
|
||
}
|
||
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), route)
|
||
return u, true
|
||
}
|
||
|
||
// handleTorURL fetches a single URL over Tor and writes the body back. One-shot,
|
||
// host-side, and constrained (timeout + size cap, http/https only). Premium.
|
||
func (a *app) handleTorURL(s ssh.Session) {
|
||
u, ok := a.torMember(s, "tor-url")
|
||
if !ok {
|
||
return
|
||
}
|
||
args := s.Command()
|
||
if len(args) == 0 {
|
||
wish.Println(s, "usage: ssh tor-url@"+a.host+" <http(s)-url> (e.g. an .onion address)")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
url := args[0]
|
||
log.Info("tor-url fetch", "user", u.Name, "url", url)
|
||
body, err := tor.FetchURL(s.Context(), url)
|
||
if err != nil {
|
||
wish.Println(s, " "+err.Error())
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
_, _ = s.Write(body)
|
||
_ = s.Exit(0)
|
||
}
|
||
|
||
// handleTorIRC opens an interactive IRC-over-Tor session inside the member's
|
||
// pod (sandboxed). Premium; requires a PTY.
|
||
func (a *app) handleTorIRC(s ssh.Session) {
|
||
u, ok := a.torMember(s, "tor-irc")
|
||
if !ok {
|
||
return
|
||
}
|
||
args := s.Command()
|
||
if len(args) == 0 || !validIRCServer(args[0]) {
|
||
wish.Println(s, "usage: ssh -t tor-irc@"+a.host+" <server[:port]> (e.g. an .onion IRC server)")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if a.pods == nil {
|
||
wish.Println(s, "pods are temporarily unavailable on this host.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
log.Info("tor-irc connect", "user", u.Name, "server", args[0])
|
||
if err := a.pods.Exec(s, u.Name, tor.IRCArgv(args[0])); err != nil {
|
||
wish.Println(s, "tor-irc error: "+err.Error())
|
||
_ = s.Exit(1)
|
||
}
|
||
}
|
||
|
||
// handleIRCAuth is the loopback endpoint Ergo's auth-script calls to gate the
|
||
// IRC network on BBS membership — the single user-level source of truth. It
|
||
// answers {member, premium} for an account name: only members may connect, and
|
||
// premium (lifetime-paid) drives paid IRC features. Loopback only (shares the
|
||
// /verify server), so only the on-box Ergo can reach it. There is no in-BBS
|
||
// irc@ route: members use an external client (or web) at irc.profullstack.com.
|
||
func (a *app) handleIRCAuth(w http.ResponseWriter, r *http.Request) {
|
||
name := strings.TrimSpace(r.URL.Query().Get("account"))
|
||
w.Header().Set("Content-Type", "application/json")
|
||
u, found, err := a.st.UserByName(name)
|
||
if err != nil || !found || u.Banned {
|
||
_, _ = io.WriteString(w, `{"member":false,"premium":false}`)
|
||
return
|
||
}
|
||
// u.Premium is the stored lifetime flag; we don't run the (network) CoinPay
|
||
// settlement check on a per-connect auth hook — that happens at join@/hub.
|
||
if u.Premium {
|
||
_, _ = io.WriteString(w, `{"member":true,"premium":true}`)
|
||
return
|
||
}
|
||
_, _ = io.WriteString(w, `{"member":true,"premium":false}`)
|
||
}
|
||
|
||
// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server
|
||
// using an in-process newsreader: it authenticates to the loopback NNTP listener
|
||
// as the member and runs a Bubble Tea TUI to browse groups, read, and post. Free
|
||
// for any registered member; needs a PTY. External newsreaders and agents reach
|
||
// the same server over NNTPS at news.<host>:563.
|
||
func (a *app) handleNews(s ssh.Session) {
|
||
fp := auth.Fingerprint(s.PublicKey())
|
||
if fp == "" {
|
||
wish.Println(s, "news@ 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, "the news server is members-only — register first: ssh join@"+a.host)
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if u.Banned {
|
||
wish.Println(s, "this account is suspended.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news")
|
||
defer func() { _ = a.st.EndSession(sessID) }()
|
||
|
||
if err := a.runNews(s, u.Name); err != nil {
|
||
wish.Println(s, "news: "+err.Error())
|
||
_ = s.Exit(1)
|
||
}
|
||
}
|
||
|
||
// runNews runs the in-process newsreader TUI for name on the session. Shared by
|
||
// the news@ route and the hub's News entry.
|
||
func (a *app) runNews(s ssh.Session, name string) error {
|
||
addr := a.newsAddr
|
||
if addr == "" {
|
||
addr = news.DefaultAddr
|
||
}
|
||
log.Info("news connect", "user", name, "addr", addr)
|
||
return news.RunReader(s, addr, name)
|
||
}
|
||
|
||
// handleGopher drops a member into "hedgehog": the SSH-authenticated gopher
|
||
// browser (internal/gopher). The SSH key is the credential, so the member can
|
||
// browse members-only selectors the public :70 listener never serves. Free for
|
||
// any registered member, like news@; needs a PTY.
|
||
func (a *app) handleGopher(s ssh.Session) {
|
||
if a.gopher == nil {
|
||
wish.Println(s, "the gopher service is disabled on this server.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
fp := auth.Fingerprint(s.PublicKey())
|
||
if fp == "" {
|
||
wish.Println(s, "gopher@ 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, "hedgehog is members-only — register first: ssh join@"+a.host+
|
||
"\n(the public gopher server is at gopher://gopher."+strings.TrimPrefix(a.host, "bbs.")+")")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if u.Banned {
|
||
wish.Println(s, "this account is suspended.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "gopher")
|
||
defer func() { _ = a.st.EndSession(sessID) }()
|
||
|
||
if err := gopher.RunBrowser(s, a.gopher, u.Name); err != nil {
|
||
wish.Println(s, "gopher: "+err.Error())
|
||
_ = s.Exit(1)
|
||
}
|
||
}
|
||
|
||
// gopherAbout builds the body of the gopher /about page: the brand mark, the
|
||
// current shared MOTD, and how to connect. Passed to gopher.New so the page
|
||
// stays in sync with the live MOTD.
|
||
func (a *app) gopherAbout() string {
|
||
var b strings.Builder
|
||
b.WriteString(brand.Logo() + "\n\n")
|
||
b.WriteString("AgentBBS — a terminal BBS for humans & AI agents.\n\n")
|
||
b.WriteString("Connect:\n")
|
||
b.WriteString(" ssh " + strings.TrimPrefix(a.host, "bbs.") + " register & explore\n")
|
||
b.WriteString(" ssh gopher@" + a.host + " members-only gopher (hedgehog)\n")
|
||
b.WriteString(" gopher://gopher." + strings.TrimPrefix(a.host, "bbs.") + " this public server\n")
|
||
if m := motd.Current(); m != "" {
|
||
b.WriteString("\n" + m + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// 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) {
|
||
// 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 = a.mailAddress(su.Name) + "*" + master
|
||
}
|
||
cfg := mailbox.IMAPConfig{
|
||
IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"),
|
||
SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
|
||
// Dial the loopback relay but verify STARTTLS against the mail host, whose
|
||
// certificate it presents (the relay's cert is never for 127.0.0.1). This
|
||
// avoids the /etc/hosts loopback hack the transactional sender needs.
|
||
SMTPServerName: env("AGENTBBS_MAIL_SMTP_SERVERNAME", a.mailHost),
|
||
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}, a.mailDomain, 50), nil
|
||
}
|
||
|
||
// mailBackendFor resolves the same IMAP/SMTP connection mailClientFor uses, but
|
||
// as a plain mailclients.Backend so third-party clients (himalaya, meli) can be
|
||
// pointed at the member's mailbox. It reads the identical env so all three
|
||
// clients behave the same; the master secret stays on the host (these clients
|
||
// run host-side, never in a pod — see internal/mailclients).
|
||
func (a *app) mailBackendFor(su store.User) mailclients.Backend {
|
||
login := a.mailAddress(su.Name)
|
||
if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
|
||
login = a.mailAddress(su.Name) + "*" + master
|
||
}
|
||
imapHost, imapPort := splitHostPort(env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"), 993)
|
||
smtpHost, smtpPort := splitHostPort(env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"), 25)
|
||
return mailclients.Backend{
|
||
Name: su.Name,
|
||
Address: a.mailAddress(su.Name),
|
||
DisplayName: su.Name,
|
||
IMAPHost: imapHost,
|
||
IMAPPort: imapPort,
|
||
IMAPPlaintext: os.Getenv("AGENTBBS_MAIL_IMAP_PLAINTEXT") == "1",
|
||
SMTPHost: smtpHost,
|
||
SMTPPort: smtpPort,
|
||
// The default submission path is the trusted, unauthenticated loopback
|
||
// relay (127.0.0.1:25) the gateway uses — no STARTTLS. Operators pointing
|
||
// at a real submission port should set AGENTBBS_*_CONFIG_TEMPLATE.
|
||
SMTPPlaintext: os.Getenv("AGENTBBS_MAIL_SMTP_STARTTLS") != "1",
|
||
Login: login,
|
||
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
|
||
}
|
||
}
|
||
|
||
// splitHostPort splits a "host:port" into its parts, falling back to def when no
|
||
// port is present or it doesn't parse. Unlike net.SplitHostPort it tolerates a
|
||
// bare host.
|
||
func splitHostPort(addr string, def int) (string, int) {
|
||
host, portStr, err := net.SplitHostPort(addr)
|
||
if err != nil {
|
||
return addr, def
|
||
}
|
||
port, err := strconv.Atoi(portStr)
|
||
if err != nil {
|
||
return host, def
|
||
}
|
||
return host, port
|
||
}
|
||
|
||
// 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 !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) }()
|
||
|
||
// `ssh -t mail@host himalaya|meli` opens an alternative client instead of the
|
||
// built-in AgentMail reader. Requires a PTY (both are interactive).
|
||
if args := s.Command(); len(args) > 0 {
|
||
var mc mailclients.Client
|
||
switch strings.ToLower(args[0]) {
|
||
case "himalaya":
|
||
mc = mailclients.Himalaya
|
||
case "meli":
|
||
mc = mailclients.Meli
|
||
}
|
||
if mc.Name() != "" {
|
||
if _, _, hasPty := s.Pty(); !hasPty {
|
||
wish.Println(s, mc.Name()+" needs an interactive terminal — connect with: ssh -t "+mc.Name()+"@... (use: ssh -t mail@"+a.host+" "+mc.Name()+")")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if err := mc.Run(s, a.mailBackendFor(u)); err != nil {
|
||
wish.Println(s, "mail: "+err.Error())
|
||
_ = s.Exit(1)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
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) {
|
||
u, ok := a.torMember(s, "tor")
|
||
if !ok {
|
||
return
|
||
}
|
||
args := s.Command()
|
||
if len(args) == 0 {
|
||
wish.Println(s, "usage: ssh -t tor@"+a.host+" <command...> (runs in your pod, over Tor)")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if a.pods == nil {
|
||
wish.Println(s, "pods are temporarily unavailable on this host.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
log.Info("tor cmd", "user", u.Name, "argv", strings.Join(args, " "))
|
||
if err := a.pods.Exec(s, u.Name, tor.Torsocks(args)); err != nil {
|
||
wish.Println(s, "tor error: "+err.Error())
|
||
_ = s.Exit(1)
|
||
}
|
||
}
|
||
|
||
// validIRCServer accepts host or host:port with a sane charset (no shell/space).
|
||
func validIRCServer(s string) bool {
|
||
host := s
|
||
if i := strings.LastIndex(s, ":"); i > 0 {
|
||
port := s[i+1:]
|
||
host = s[:i]
|
||
if port == "" || len(port) > 5 {
|
||
return false
|
||
}
|
||
for _, r := range port {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
portNum, err := strconv.Atoi(port)
|
||
if err != nil || portNum < 1 || portNum > 65535 {
|
||
return false
|
||
}
|
||
}
|
||
if host == "" || len(host) > 255 {
|
||
return false
|
||
}
|
||
for _, r := range host {
|
||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-') {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// handleVideo joins a PairUX call rendered as ASCII (docs/video.md).
|
||
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
|
||
// minted by PairUX — starting a call requires already having one.
|
||
func (a *app) handleVideo(s ssh.Session, code string) {
|
||
identity := "ssh-guest"
|
||
if fp := auth.Fingerprint(s.PublicKey()); fp != "" {
|
||
if u, found, _ := a.st.UserByFingerprint(fp); found {
|
||
identity = "ssh-" + u.Name
|
||
}
|
||
}
|
||
sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "video")
|
||
defer func() { _ = a.st.EndSession(sessID) }()
|
||
if err := calls.Handle(s, code, identity); err != nil {
|
||
wish.Println(s, "video: "+err.Error())
|
||
}
|
||
}
|
||
|
||
// handleChat is the agent@ surface: talk to the operator's agent.
|
||
func (a *app) handleChat(s ssh.Session) {
|
||
u := auth.User{Name: "guest-" + remoteIP(s), Kind: auth.Guest}
|
||
if fp := auth.Fingerprint(s.PublicKey()); fp != "" {
|
||
if su, found, _ := a.st.UserByFingerprint(fp); found {
|
||
u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID}
|
||
}
|
||
}
|
||
sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "agent")
|
||
defer func() { _ = a.st.EndSession(sessID) }()
|
||
if err := chat.Handle(s, a.st, u); err != nil {
|
||
wish.Println(s, "chat: "+err.Error())
|
||
}
|
||
}
|
||
|
||
// handleMsg is the member-to-member messaging route. It leaves a note in one or
|
||
// more members' BBS inboxes:
|
||
//
|
||
// ssh msg@host bob hi one member
|
||
// ssh msg@host alice,bob,carol hi a group (comma-separated, no spaces)
|
||
// ssh msg@host all hi broadcast to every member (also: *, everyone)
|
||
//
|
||
// The body is the remaining args, or stdin when none are given (so
|
||
// `echo hi | ssh msg@host all` works). Members only; recipients read it in the
|
||
// hub's Members ▸ inbox. This is the BBS-inbox channel — to also reach members by
|
||
// email, the operator uses `agentbbs broadcast`.
|
||
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[,user2,…]|all> [message] (or pipe the message on stdin)")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
|
||
recipients, unknown, err := a.resolveRecipients(args[0], from.Name)
|
||
if err != nil {
|
||
wish.Println(s, "could not look up members: "+err.Error())
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if len(unknown) > 0 {
|
||
wish.Println(s, "no member named "+strings.Join(unknown, ", ")+
|
||
" — check the spelling (ssh <name>@"+a.host+" to finger).")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
if len(recipients) == 0 {
|
||
wish.Println(s, "no recipients (you can't message only 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
|
||
}
|
||
|
||
sent, err := a.st.SendMessageMulti(from.Name, recipients, body)
|
||
if err != nil {
|
||
wish.Println(s, "could not send: "+err.Error())
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
_, _ = a.st.RecordSession(from.ID, s.User(), remoteIP(s), "msg")
|
||
if sent == 1 {
|
||
wish.Println(s, "✓ message left for "+recipients[0]+" — they'll see it in Members ▸ inbox.")
|
||
} else {
|
||
wish.Println(s, fmt.Sprintf("✓ message left for %d members — they'll see it in Members ▸ inbox.", sent))
|
||
}
|
||
_ = s.Exit(0)
|
||
}
|
||
|
||
// allRecipientTokens are the case-insensitive specs that mean "every member".
|
||
var allRecipientTokens = map[string]bool{"all": true, "*": true, "everyone": true, "@all": true}
|
||
|
||
// resolveRecipients turns a msg@ recipient spec into a validated, de-duplicated
|
||
// list of member names, excluding the sender. The spec is either one of
|
||
// allRecipientTokens (→ every other member) or a comma-separated list of names.
|
||
// unknown holds any listed names that aren't members (so the caller can report
|
||
// them); a non-nil error means the directory lookup itself failed.
|
||
func (a *app) resolveRecipients(spec, sender string) (recipients, unknown []string, err error) {
|
||
if allRecipientTokens[strings.ToLower(strings.TrimSpace(spec))] {
|
||
users, err := a.st.ListUsers(100000)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
for _, u := range users {
|
||
if u.Banned || strings.EqualFold(u.Name, sender) {
|
||
continue
|
||
}
|
||
recipients = append(recipients, u.Name)
|
||
}
|
||
return recipients, nil, nil
|
||
}
|
||
|
||
seen := map[string]bool{}
|
||
for _, raw := range strings.Split(spec, ",") {
|
||
name := strings.ToLower(strings.TrimSpace(raw))
|
||
if name == "" || seen[name] || strings.EqualFold(name, sender) {
|
||
continue
|
||
}
|
||
seen[name] = true
|
||
u, ok, err := a.st.UserByName(name)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if !ok {
|
||
unknown = append(unknown, name)
|
||
continue
|
||
}
|
||
recipients = append(recipients, u.Name)
|
||
}
|
||
return recipients, unknown, nil
|
||
}
|
||
|
||
// handlePasswd is the self-service "reset my password everywhere" route. It is
|
||
// gated by the caller's registered SSH key (so it doubles as the forgot-password
|
||
// path — no old password needed) and sets ONE new password across every service
|
||
// that has its own credential: git (Forgejo), mail (Mailu webmail), and chat
|
||
// (IRC + The Lounge). Git push and BBS/SSH access are unaffected — those use the
|
||
// member's key, not a password.
|
||
//
|
||
// ssh passwd@host interactive: type a new password (twice), applied everywhere
|
||
// ssh passwd@host < file non-interactive: read the new password from stdin
|
||
// echo | ssh passwd@host empty stdin / no PTY: a strong password is generated for you
|
||
func (a *app) handlePasswd(s ssh.Session) {
|
||
fp := auth.Fingerprint(s.PublicKey())
|
||
if fp == "" {
|
||
wish.Println(s, "passwd@ 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
|
||
}
|
||
|
||
pw, generated, err := a.readNewPassword(s)
|
||
if err != nil {
|
||
wish.Println(s, "password reset cancelled.")
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
|
||
wish.Println(s, "")
|
||
wish.Println(s, "Setting your password across services…")
|
||
|
||
type result struct{ label, detail string }
|
||
var ok, failed []result
|
||
|
||
// git (Forgejo): make sure the account exists, then set the chosen password.
|
||
if a.forgejo.Configured() {
|
||
if _, _, e := a.forgejo.EnsureUser(u.Name, u.Email); e != nil {
|
||
failed = append(failed, result{"git ", e.Error()})
|
||
} else if e := a.forgejo.SetPassword(u.Name, pw); e != nil {
|
||
failed = append(failed, result{"git ", e.Error()})
|
||
} else {
|
||
ok = append(ok, result{"git ", a.forgejo.LoginURL() + " (username: " + u.Name + ")"})
|
||
}
|
||
}
|
||
|
||
// mail (Mailu webmail): ensure the mailbox exists, then set its password.
|
||
if a.mailEnabled() {
|
||
if e := a.ensureMailbox(u); e != nil {
|
||
failed = append(failed, result{"mail", e.Error()})
|
||
} else {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
e := a.mailu.SetPassword(ctx, u.Name, a.mailDomain, pw)
|
||
cancel()
|
||
if e != nil {
|
||
failed = append(failed, result{"mail", e.Error()})
|
||
} else {
|
||
ok = append(ok, result{"mail", a.webmailURL + " (" + a.mailAddress(u.Name) + ")"})
|
||
}
|
||
}
|
||
}
|
||
|
||
// chat (IRC + The Lounge): set via the privileged helper.
|
||
if a.irc.Configured() {
|
||
if e := a.irc.SetPassword(u.Name, pw); e != nil {
|
||
failed = append(failed, result{"chat", e.Error()})
|
||
} else {
|
||
ok = append(ok, result{"chat", "SASL on irc." + rootDomain(a.host) + " / web — account: " + u.Name})
|
||
}
|
||
}
|
||
|
||
wish.Println(s, "")
|
||
if generated {
|
||
wish.Println(s, "Your new password (save it now — it isn't shown again):")
|
||
wish.Println(s, " "+pw)
|
||
wish.Println(s, "")
|
||
}
|
||
for _, r := range ok {
|
||
wish.Println(s, " ✓ "+r.label+" "+r.detail)
|
||
}
|
||
for _, r := range failed {
|
||
wish.Println(s, " ✗ "+r.label+" "+r.detail)
|
||
}
|
||
if len(ok) == 0 && len(failed) == 0 {
|
||
wish.Println(s, " (no password-backed services are configured on this server)")
|
||
}
|
||
|
||
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "passwd")
|
||
|
||
// Best-effort confirmation email (never contains the password). Skipped when
|
||
// the member has no verified address or SMTP isn't configured.
|
||
if u.EmailVerified && u.Email != "" && a.mail.Configured() {
|
||
_ = a.mail.Send(u.Email, "Your "+rootDomain(a.host)+" password was changed",
|
||
passwdChangedEmailBody(u.Name, len(ok), remoteIP(s)))
|
||
}
|
||
|
||
if len(failed) > 0 {
|
||
_ = s.Exit(1)
|
||
return
|
||
}
|
||
_ = s.Exit(0)
|
||
}
|
||
|
||
// readNewPassword obtains the member's new password. With a PTY it prompts twice
|
||
// (masked) and requires the two entries to match and meet a minimum length. With
|
||
// no PTY it reads the password from stdin; if stdin is empty it generates a strong
|
||
// one and returns generated=true so the caller shows it to the member.
|
||
func (a *app) readNewPassword(s ssh.Session) (pw string, generated bool, err error) {
|
||
const minLen = 8
|
||
_, _, isPTY := s.Pty()
|
||
if !isPTY {
|
||
b, _ := io.ReadAll(io.LimitReader(s, 4096))
|
||
piped := strings.TrimSpace(string(b))
|
||
if piped == "" {
|
||
gen, e := randPassword()
|
||
return gen, true, e
|
||
}
|
||
if len(piped) < minLen {
|
||
return "", false, fmt.Errorf("password too short")
|
||
}
|
||
return piped, false, nil
|
||
}
|
||
|
||
in := bufio.NewReader(s)
|
||
for {
|
||
wish.Print(s, "New password (min "+fmt.Sprint(minLen)+" chars, blank to generate one): ")
|
||
first, e := readSecret(s, in)
|
||
if e != nil {
|
||
return "", false, e
|
||
}
|
||
if first == "" {
|
||
gen, e := randPassword()
|
||
return gen, true, e
|
||
}
|
||
if len(first) < minLen {
|
||
wish.Println(s, " too short — try again.")
|
||
continue
|
||
}
|
||
wish.Print(s, "Confirm new password: ")
|
||
second, e := readSecret(s, in)
|
||
if e != nil {
|
||
return "", false, e
|
||
}
|
||
if first != second {
|
||
wish.Println(s, " passwords didn't match — try again.")
|
||
continue
|
||
}
|
||
return first, false, nil
|
||
}
|
||
}
|
||
|
||
// randPassword returns a strong URL-safe-ish random password (24 hex chars).
|
||
func randPassword() (string, error) {
|
||
var b [12]byte
|
||
if _, err := rand.Read(b[:]); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(b[:]), nil
|
||
}
|
||
|
||
// rootDomain strips the first label off a host (bbs.profullstack.com →
|
||
// profullstack.com) so user-facing copy can name the shared apex.
|
||
func rootDomain(host string) string {
|
||
if i := strings.IndexByte(host, '.'); i >= 0 && strings.Contains(host[i+1:], ".") {
|
||
return host[i+1:]
|
||
}
|
||
return host
|
||
}
|
||
|
||
// passwdChangedEmailBody is the security-notice email sent after a successful
|
||
// reset. It deliberately never includes the password.
|
||
func passwdChangedEmailBody(name string, services int, ip string) string {
|
||
return "Hi " + name + ",\n\n" +
|
||
"Your password was just changed across your account services (git, mail, chat)" +
|
||
" via ssh passwd@.\n\n" +
|
||
fmt.Sprintf(" services updated: %d\n", services) +
|
||
" request IP: " + ip + "\n\n" +
|
||
"If this wasn't you, your SSH key may be compromised — rotate it and contact the operator.\n\n" +
|
||
"Note: git push and BBS/SSH login use your SSH key, not this password.\n"
|
||
}
|
||
|
||
// 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.
|
||
func (a *app) handleFinger(s ssh.Session, username string) bool {
|
||
if auth.IsGuestName(username) {
|
||
return false
|
||
}
|
||
u, found, err := a.st.UserByName(username)
|
||
if err != nil || !found {
|
||
return false // unclaimed name → hub (claim flow)
|
||
}
|
||
if fp := auth.Fingerprint(s.PublicKey()); fp != "" && fp == u.PubKeyFP {
|
||
return false // it's them → hub
|
||
}
|
||
|
||
lastSeen := "never"
|
||
if t, ok, _ := a.st.LastSeen(u.ID); ok {
|
||
lastSeen = t.Local().Format("2006-01-02 15:04 MST")
|
||
}
|
||
plan := "no plan."
|
||
for _, p := range []string{
|
||
filepath.Join(a.dataDir, "users", u.Name, ".plan"),
|
||
filepath.Join(a.dataDir, "users", u.Name, "plan.txt"),
|
||
} {
|
||
if b, err := os.ReadFile(p); err == nil {
|
||
plan = strings.TrimSpace(string(b))
|
||
break
|
||
}
|
||
}
|
||
_, _ = a.st.RecordSession(0, s.User(), remoteIP(s), "finger")
|
||
wish.Println(s, strings.Join([]string{
|
||
"",
|
||
" Login: " + u.Name + " Kind: " + u.Kind,
|
||
" Member since: " + u.CreatedAt.Format("2006-01-02") + " Last seen: " + lastSeen,
|
||
" Plan:",
|
||
" " + strings.ReplaceAll(plan, "\n", "\n "),
|
||
"",
|
||
}, "\n"))
|
||
_ = s.Exit(0)
|
||
return true
|
||
}
|
||
|
||
func grantPod(st store.Store, args []string) {
|
||
if len(args) < 2 {
|
||
fmt.Fprintln(os.Stderr, "usage: agentbbs grant-pod <username> <months>")
|
||
os.Exit(2)
|
||
}
|
||
months, err := strconv.Atoi(args[1])
|
||
if err != nil || months < 1 {
|
||
fmt.Fprintln(os.Stderr, "months must be a positive integer")
|
||
os.Exit(2)
|
||
}
|
||
u, err := st.EnsureUser(strings.ToLower(args[0]), string(auth.Member), "")
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "user:", err)
|
||
os.Exit(1)
|
||
}
|
||
until := time.Now().Add(time.Duration(months) * payments.PodTerm)
|
||
if err := st.GrantPod(u.ID, until, "manual"); err != nil {
|
||
fmt.Fprintln(os.Stderr, "grant:", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("pod granted to %s until %s\n", u.Name, until.Format(time.RFC3339))
|
||
}
|
||
|
||
// domainCmd is the ops side of custom domains: `agentbbs map-domain <domain>
|
||
// <user>` / `unmap-domain <domain> <user>`, mirroring grant-pod.
|
||
func domainCmd(st store.Store, dataDir, cmd string, args []string) {
|
||
if len(args) < 2 {
|
||
fmt.Fprintf(os.Stderr, "usage: agentbbs %s <domain> <username>\n", cmd)
|
||
os.Exit(2)
|
||
}
|
||
sm, err := sites.NewManager(st, dataDir)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "sites:", err)
|
||
os.Exit(1)
|
||
}
|
||
domain, user := args[0], strings.ToLower(args[1])
|
||
if cmd == "unmap-domain" {
|
||
d, err := sm.Remove(domain, user)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "unmap:", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("unmapped %s from %s\n", d, user)
|
||
return
|
||
}
|
||
d, err := sm.Add(domain, user)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "map:", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("mapped %s -> ~%s\n", d, user)
|
||
}
|
||
|
||
// seedHomepage creates a member's public_html (served at /~name by the Caddy
|
||
// front end) with a starter index.html, but never clobbers an edit they made.
|
||
func seedHomepage(dir, name, host string) {
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return
|
||
}
|
||
index := filepath.Join(dir, "index.html")
|
||
if _, err := os.Stat(index); err == nil {
|
||
return // user already has a homepage; leave it alone
|
||
}
|
||
page := "<!doctype html>\n<meta charset=utf-8>\n" +
|
||
"<title>~" + name + "</title>\n" +
|
||
"<style>body{background:#000;color:#33ff66;font:16px/1.5 monospace;max-width:42rem;margin:4rem auto;padding:0 1rem}a{color:#60a5fa}</style>\n" +
|
||
"<h1>~" + name + "</h1>\n" +
|
||
"<p>This is " + name + "'s corner of AgentBBS.</p>\n" +
|
||
"<p>Edit <code>~/public_html/index.html</code> in your pod (<code>ssh pod@" + host + "</code>) to make it yours.</p>\n"
|
||
_ = os.WriteFile(index, []byte(page), 0o644)
|
||
}
|
||
|
||
func remoteIP(s ssh.Session) string {
|
||
if host, _, err := net.SplitHostPort(s.RemoteAddr().String()); err == nil {
|
||
return host
|
||
}
|
||
return s.RemoteAddr().String()
|
||
}
|