bbs.profullstack.com launch kit: provisioner, custom domains, email verify, ascii-live

- setup.sh: idempotent one-shot droplet provisioner — agentbbs on :22
  (admin OpenSSH moved to :2202), rootless podman, Caddy front end for
  https://bbs.profullstack.com with tilde-style /~user homepages
- internal/sites + domain@ SSH route: self-service custom domains
  (ssh domain@host add example.com) backed by a symlink farm and an
  on-demand-TLS ask endpoint so Caddy only issues certs for mapped hosts
- internal/mail + join@ email verification: optional email at signup,
  confirmation link served by a loopback /verify endpoint behind Caddy
- internal/source + cmd/ascii-live: live video → terminal ASCII groundwork
  (docs/ascii-live.md)
- store: additive sqlite migrations (email/verify columns, domains table)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-11 15:53:19 +00:00
parent 9b0f465946
commit f3f8e70996
14 changed files with 2005 additions and 27 deletions

View file

@ -7,18 +7,25 @@
// ssh join@host onboarding: registers your key, prints instructions,
// and disconnects — no session
// ssh pod@host your personal Linux pod (paid membership, $1/mo via coinpay)
// ssh domain@host point your own domain at your homepage (add/rm/list)
//
// Subcommands:
//
// agentbbs serve (default)
// agentbbs grant-pod NAME MONTHS manually extend a pod subscription
// 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
package main
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
@ -40,10 +47,12 @@ import (
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/mail"
"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/plugins/about"
"github.com/profullstack/agentbbs/plugins/arcade"
@ -59,8 +68,10 @@ func env(k, def string) string {
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
dataDir string
assets string
host string // public hostname used in user-facing messages
@ -80,15 +91,39 @@ func main() {
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
}
a := &app{
st: st,
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
mail: mail.ConfigFromEnv(),
dataDir: dataDir,
assets: env("AGENTBBS_ASSETS", "./assets"),
host: env("AGENTBBS_HOST", "profullstack.com"),
}
a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}}
// Custom domains: maintain the symlink farm Caddy serves and answer its
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
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(); err == nil {
a.pods = m
log.Info("pods enabled", "engine", m.Engine())
@ -97,6 +132,21 @@ func main() {
}
log.Info("sandbox", "mode", a.sandbox.Mode())
// 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("/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)
}
}()
addr := env("AGENTBBS_ADDR", ":2222")
srv, err := wish.NewServer(
wish.WithAddress(addr),
@ -144,6 +194,8 @@ func (a *app) router() wish.Middleware {
switch {
case auth.IsJoinName(user):
a.handleJoin(s)
case auth.IsDomainName(user):
a.handleDomain(s)
case auth.IsPodName(user):
a.handlePod(s)
case isVideo:
@ -198,6 +250,10 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
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.registry), []tea.ProgramOption{tea.WithAltScreen()}
}
@ -222,13 +278,43 @@ func (a *app) handleJoin(s ssh.Session) {
}
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join")
// Collect an email and send a confirmation link. The connecting key is the
// "uploaded" public key; this prompt adds (or refreshes) the email and
// re-issues verification. Requires an interactive session (ssh join@host).
wish.Print(s, " Email (for account confirmation): ")
line, _ := bufio.NewReader(s).ReadString('\n')
email := strings.TrimSpace(line)
confirm := " confirm no email captured — re-run from an interactive terminal: ssh join@" + a.host
if validEmail(email) {
token := randToken()
if err := a.st.SetEmailVerification(u.ID, email, token); err != nil {
log.Error("set verification", "err", err)
confirm = " confirm error saving email; please retry"
} else {
url := "https://" + a.host + "/verify?token=" + token
switch {
case !a.mail.Configured():
log.Warn("smtp not configured — confirmation link not emailed", "email", email, "url", url)
confirm = " confirm email is not configured on this host yet; an admin must verify you"
case a.mail.Send(email, "Confirm your AgentBBS account", verifyEmailBody(u.Name, url)) != nil:
confirm = " confirm couldn't send the email; please retry or contact an admin"
default:
confirm = " confirm check " + email + " for a confirmation link to activate your account"
}
}
} else if email != "" {
confirm = " confirm that doesn't look like an email — re-run: ssh join@" + a.host
}
ref := payments.Reference("pod", fp)
wish.Println(s, strings.Join([]string{
wish.Println(s, "\n"+strings.Join([]string{
"",
" Welcome to AgentBBS — you're registered.",
"",
" account " + u.Name,
" key " + fp,
confirm,
"",
" BBS hub ssh " + u.Name + "@" + a.host,
" Guest hub ssh bbs@" + a.host,
@ -241,6 +327,151 @@ func (a *app) handleJoin(s ssh.Session) {
_ = s.Exit(0)
}
// verifyEmailBody is the plain-text confirmation email.
func verifyEmailBody(name, url string) string {
return "Hi " + name + ",\n\n" +
"Confirm your AgentBBS account by opening this link:\n\n" +
" " + url + "\n\n" +
"If you didn't request this, you can ignore this email.\n"
}
// randToken returns a 128-bit hex token for email confirmation.
func randToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// 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
}
_, _ = w.Write([]byte(verifyPage("Email confirmed ✓",
"Welcome, "+u.Name+". Your account is active — <code>ssh "+u.Name+"@"+a.host+"</code>.")))
}
// 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
}
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())
@ -255,6 +486,13 @@ func (a *app) handlePod(s ssh.Session) {
_ = s.Exit(1)
return
}
// Email must be confirmed before paid features unlock (set
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to disable 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+" (then open the link we email you).")
_ = s.Exit(1)
return
}
until, ok, _ := a.st.PodPaidUntil(u.ID)
if !ok || time.Now().After(until) {
@ -386,6 +624,55 @@ func grantPod(st store.Store, args []string) {
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