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, // ssh join@host onboarding: registers your key, prints instructions,
// and disconnects — no session // and disconnects — no session
// ssh pod@host your personal Linux pod (paid membership, $1/mo via coinpay) // 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: // Subcommands:
// //
// agentbbs serve (default) // agentbbs serve (default)
// agentbbs grant-pod NAME MONTHS manually extend a pod subscription // 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 package main
import ( import (
"bufio"
"context" "context"
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@ -40,10 +47,12 @@ import (
"github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/payments"
"github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/pods" "github.com/profullstack/agentbbs/internal/pods"
"github.com/profullstack/agentbbs/internal/sandbox" "github.com/profullstack/agentbbs/internal/sandbox"
"github.com/profullstack/agentbbs/internal/sites"
"github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/plugins/about" "github.com/profullstack/agentbbs/plugins/about"
"github.com/profullstack/agentbbs/plugins/arcade" "github.com/profullstack/agentbbs/plugins/arcade"
@ -59,8 +68,10 @@ func env(k, def string) string {
type app struct { type app struct {
st store.Store st store.Store
pods *pods.Manager // nil when no container engine on host pods *pods.Manager // nil when no container engine on host
sites *sites.Manager
registry []plugin.Plugin registry []plugin.Plugin
sandbox *sandbox.Runner sandbox *sandbox.Runner
mail mail.Config
dataDir string dataDir string
assets string assets string
host string // public hostname used in user-facing messages host string // public hostname used in user-facing messages
@ -80,15 +91,39 @@ func main() {
grantPod(st, os.Args[2:]) grantPod(st, os.Args[2:])
return 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{ a := &app{
st: st, st: st,
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))), sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
mail: mail.ConfigFromEnv(),
dataDir: dataDir, dataDir: dataDir,
assets: env("AGENTBBS_ASSETS", "./assets"), assets: env("AGENTBBS_ASSETS", "./assets"),
host: env("AGENTBBS_HOST", "profullstack.com"), host: env("AGENTBBS_HOST", "profullstack.com"),
} }
a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}} 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 { if m, err := pods.Detect(); err == nil {
a.pods = m a.pods = m
log.Info("pods enabled", "engine", m.Engine()) log.Info("pods enabled", "engine", m.Engine())
@ -97,6 +132,21 @@ func main() {
} }
log.Info("sandbox", "mode", a.sandbox.Mode()) 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") addr := env("AGENTBBS_ADDR", ":2222")
srv, err := wish.NewServer( srv, err := wish.NewServer(
wish.WithAddress(addr), wish.WithAddress(addr),
@ -144,6 +194,8 @@ func (a *app) router() wish.Middleware {
switch { switch {
case auth.IsJoinName(user): case auth.IsJoinName(user):
a.handleJoin(s) a.handleJoin(s)
case auth.IsDomainName(user):
a.handleDomain(s)
case auth.IsPodName(user): case auth.IsPodName(user):
a.handlePod(s) a.handlePod(s)
case isVideo: case isVideo:
@ -198,6 +250,10 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
if u.Kind != auth.Guest { if u.Kind != auth.Guest {
ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name) ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name)
_ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755) _ = 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()} 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") _, _ = 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) ref := payments.Reference("pod", fp)
wish.Println(s, strings.Join([]string{ wish.Println(s, "\n"+strings.Join([]string{
"", "",
" Welcome to AgentBBS — you're registered.", " Welcome to AgentBBS — you're registered.",
"", "",
" account " + u.Name, " account " + u.Name,
" key " + fp, " key " + fp,
confirm,
"", "",
" BBS hub ssh " + u.Name + "@" + a.host, " BBS hub ssh " + u.Name + "@" + a.host,
" Guest hub ssh bbs@" + a.host, " Guest hub ssh bbs@" + a.host,
@ -241,6 +327,151 @@ func (a *app) handleJoin(s ssh.Session) {
_ = s.Exit(0) _ = 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. // handlePod admits paid members into their personal container.
func (a *app) handlePod(s ssh.Session) { func (a *app) handlePod(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey()) fp := auth.Fingerprint(s.PublicKey())
@ -255,6 +486,13 @@ func (a *app) handlePod(s ssh.Session) {
_ = s.Exit(1) _ = s.Exit(1)
return 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) until, ok, _ := a.st.PodPaidUntil(u.ID)
if !ok || time.Now().After(until) { 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)) 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 { func remoteIP(s ssh.Session) string {
if host, _, err := net.SplitHostPort(s.RemoteAddr().String()); err == nil { if host, _, err := net.SplitHostPort(s.RemoteAddr().String()); err == nil {
return host return host

107
cmd/ascii-live/main.go Normal file
View file

@ -0,0 +1,107 @@
// Command ascii-live previews a live video source as truecolor ASCII in the
// local terminal — the Phase 0 deliverable of docs/ascii-live.md. It shares
// the source adapter (internal/source) and renderer (internal/ascii) with the
// AgentBBS SSH route, so what you see here is what tv-<slug>@ will serve.
//
// Usage:
//
// ascii-live watch <url> [--fps N] [--width COLS]
//
// ascii-live watch "https://youtube.com/live/<id>"
// ascii-live watch "https://example.com/stream.m3u8"
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"golang.org/x/term"
"github.com/profullstack/agentbbs/internal/ascii"
"github.com/profullstack/agentbbs/internal/source"
)
func main() {
if len(os.Args) < 2 || os.Args[1] != "watch" {
usage()
os.Exit(2)
}
fs := flag.NewFlagSet("watch", flag.ExitOnError)
fps := fs.Int("fps", 10, "frames per second")
width := fs.Int("width", 120, "max width in terminal columns")
_ = fs.Parse(os.Args[2:])
if fs.NArg() < 1 {
usage()
os.Exit(2)
}
url := fs.Arg(0)
if err := run(url, *fps, *width); err != nil {
fmt.Fprintln(os.Stderr, "ascii-live: "+err.Error())
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: ascii-live watch <url> [--fps N] [--width COLS]")
}
func run(url string, fps, maxWidth int) error {
// Lock geometry at start (the decoder output width is fixed for the run,
// matching how internal/calls locks pw at join time).
cols, rows := terminalSize()
if maxWidth > 0 && maxWidth < cols {
cols = maxWidth
}
pw, ph := ascii.FitEven(cols, rows)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
w, err := source.Start(ctx, source.Options{
URL: url, FPS: fps, PW: pw, PH: ph, AllowHLS: true,
})
if err != nil {
return err
}
defer w.Close()
// Alt screen + hidden cursor; always restored on exit.
fmt.Print("\x1b[?1049h\x1b[?25l")
defer fmt.Print("\x1b[?25h\x1b[?1049l")
status := fmt.Sprintf("starting %s…", w.Kind)
for {
select {
case <-ctx.Done():
return nil
case s, ok := <-w.Status:
if ok {
status = s
}
case buf, ok := <-w.Frames:
if !ok {
return nil // stream ended
}
frameH := (len(buf) / 3) / pw
// Home the cursor and repaint in place.
fmt.Print("\x1b[H" + ascii.FrameRGB(buf, pw, frameH) +
"\r\n\x1b[2K " + url + " · " + w.Kind.String() + " · " + status + " · ctrl-c to quit")
}
}
}
// terminalSize returns the stdout terminal dimensions, or a sane default when
// stdout is not a TTY (e.g. piped output).
func terminalSize() (cols, rows int) {
if c, r, err := term.GetSize(int(os.Stdout.Fd())); err == nil && c > 0 && r > 0 {
return c, r
}
return 120, 40
}

279
docs/ascii-live.md Normal file
View file

@ -0,0 +1,279 @@
# PRD: ASCII Live (revised)
**Feature:** ASCII Live — watch live video sources as terminal-native ASCII over SSH and browser-terminal.
**Primary host:** AgentBBS (Go).
**Status:** Revised draft (supersedes the standalone "@logicsrc/plugin-ascii-live" PRD).
**Owner:** Profullstack / LogicSRC.
> **Why this revision exists.** The original PRD specified a single Node.js/TypeScript
> package (`@logicsrc/plugin-ascii-live`) hosted inside AgentBBS, with a fresh
> char-ramp ASCII renderer and PairUX support as a future phase. Grounding that
> against the real repos found two structural problems:
>
> 1. **AgentBBS is Go 1.26** (`go.mod`) with a Go plugin interface
> (`internal/plugin/plugin.go`). It cannot load a TS package. The TS
> `PluginDefinition` shape (`logicsrc/packages/plugin-core`) is a *different*
> plugin system — the logicsrc web/Hono/SDK world.
> 2. **The core capability already exists in AgentBBS.** `internal/ascii/ascii.go`
> renders RGB24 → truecolor half-block (▀) ANSI, and `internal/calls/` already
> joins a PairUX LiveKit call and renders it as ASCII over SSH (shipped/verified
> 2026-06-11, "128k truecolor cells streamed"). The original PRD's char-ramp
> renderer is a visual downgrade from this, and its "PairUX = future phase" is
> largely already done.
>
> This PRD refits the product as **two components** and scopes the MVP to the work
> that is genuinely new.
---
## 1. Architecture: two components
ASCII Live is **not** one package. It is:
| Component | Repo / stack | Responsibility |
|---|---|---|
| **A. `ascii-live` core** | **Go**, in `agentbbs` | Source adapters (YouTube/HLS), shared FFmpeg worker, viewer fan-out, terminal rendering, the `tv@`/`tv-<slug>@` SSH route, and a `cmd/ascii-live` local CLI. Reuses `internal/ascii` and the `internal/calls` patterns. |
| **B. `@logicsrc/plugin-ascii-live`** | **TS**, in `logicsrc` | Browser-terminal viewer (xterm.js + SSE), public stream directory, and the stream metadata/events API. Built as a logicsrc `PluginDefinition` (`routes` + `events` + `tuiPanels`) — the shape it is literally designed for. |
Component A is the MVP. Component B is V1 (the browser viewer) and is the *only*
place the `@logicsrc/plugin-ascii-live` TS package exists.
```
┌─────────────────────── AgentBBS (Go) ───────────────────────┐
YouTube URL ─▶ │ source adapter ─▶ shared FFmpeg worker ─▶ RGB24 frames │
HLS .m3u8 ─▶ │ (yt-dlp -g) (one per stream) │ │
PairUX call ─▶ │ LiveKit tracks ─▶ VP8→IVF→ffmpeg ───────────▶│ │
│ ▼ │
│ internal/ascii.FrameRGB (▀ truecolor)
│ │ │
│ fan-out multiplexer (1 worker → N) │
│ │ │ │
└────────────────────────────┼────────────┼────────────────────┘
▼ ▼ (frames via SSE)
SSH PTY viewers @logicsrc/plugin-ascii-live
(tv-<slug>@) browser xterm.js viewer
```
---
## 2. What already exists (reuse, do not rebuild)
- **`internal/ascii/ascii.go`** — `FrameRGB(buf, w, h)` renders packed RGB24 to
truecolor half-block ANSI (two pixels per cell via `▀`); `FitEven(cols, rows)`
clamps geometry for the renderer leaving a status line. **This is the default
renderer.**
- **`internal/calls/{calls,livekit}.go`** — joins a PairUX LiveKit call from an
SSH session (`video-<code>@`, `video@`), pipeline VP8 → IVF → ffmpeg → RGB24 →
ANSI. Codes are minted by PairUX only; SSH never creates calls.
- **`internal/store`** — pure-Go (modernc) sqlite store. ASCII Live metadata uses
this, not a parallel DB.
- **Plugin contract**`internal/plugin/plugin.go`
(`ID/Title/Description/RequiresAuth/New`, `ExitMsg`). Hub menu integration.
- **`~/src/intr0s`** — real branded intro/logo `.mp4` assets for intro/BRB cards.
### Hard-won pipeline lessons (must carry forward)
- Subscriber **must send PLI** or the SFU never forwards video.
- `lksdk` IVF replay mispaces — use `ReaderTrackWithFrameDuration`.
- Publish needs `VideoWidth/Height` or dynacast pauses the track.
- ffmpeg pipe needs `-probesize 32`.
- Go pinned **1.26** via `mise.toml` (lksdk requires it).
---
## 3. What is genuinely new (the MVP)
1. **URL source adapter**`yt-dlp -f 'best[height<=480]/best' -g <url>` to resolve
a YouTube Live URL to an HLS URL (or accept a direct `.m3u8`), then ffmpeg
`-vf "fps=N,scale=W:-2:flags=lanczos,format=rgb24" -pix_fmt rgb24 -f rawvideo`
→ RGB24 frames. (The existing path ingests LiveKit *tracks*; this adds a *URL*
source.) Always convert to `rgb24``yuv420p` will fail.
2. **Shared-worker fan-out****one** ffmpeg worker per distinct stream, **N**
PTY viewers attached. The current `calls.Handle` is standalone (one decode per
session, leaving ends the session). The multiplexer — viewer join/leave,
backpressure, reap-after-last-viewer-unless-pinned — is the headline value-add.
3. **SSRF / source hardening** — user-supplied URLs are a new attack surface the
`video@` route never had (PairUX mints all codes there). This is an **MVP
acceptance criterion**, not a later nicety. Block: private/link-local/loopback
IPs, cloud metadata ranges (169.254.169.254 etc.), `file://`, and any protocol
other than `http(s)` and the resolved HLS. Re-validate after DNS resolution and
after any redirect.
---
## 4. Interaction model — username routing, not slash commands
AgentBBS routes by **SSH username** (`bbs@`, `pod@`, `agent@`, `video-<code>@`) +
Bubble Tea hub menus. There is **no in-room slash-command chat layer**; chat is the
separate `agent@` surface. So the original `/ascii-live open <url>` + "room chat
alongside" model does not fit. ASCII Live adopts username routing:
```
ssh tv@host # browse the public stream directory (hub menu)
ssh tv-<slug>@host # attach directly to a running stream
```
- **Browsing/attaching** is open to guests for public streams.
- **Opening a new stream from a URL** is an authenticated action (hub menu for
members/agents, or the CLI/web) — never an anonymous SSH command, because that is
the SSRF + abuse surface. First viewer to open a URL becomes the worker owner.
- Chat, if shown, is a separate **lipgloss text region** beneath the frame (mirror
the existing status-line layout). Never composite chat into pixels.
The local CLI mirrors the surfaces for dev/testing:
```bash
ascii-live watch "https://youtube.com/live/<id>" # local terminal preview
ascii-live watch "pairux:room_abc123" # reuse the calls path
```
`ascii-live serve --ssh-port` is a **local dev convenience only** — production
reuses the AgentBBS `wish` server. Do **not** stand up a second SSH server.
---
## 5. Renderer
- **Default: truecolor half-block** via `internal/ascii.FrameRGB` (existing). This
is the house style (matches doom-ascii) and looks far better than char-ramp.
- **Themes are color-degradation fallbacks**, not the primary path:
- `truecolor` (default), `ansi` (256/16-color approximation),
`mono`/`green`/`amber` (char-ramp ` .:-=+*#%@` for terminals without
truecolor). The char ramp is the *fallback*, never the default.
- **Adaptive geometry** via `FitEven(cols, rows)`: clamp to even pixel height,
leave one status line, `width = min(config.width, terminal.columns)`.
---
## 6. Free vs paid
Reuse the existing CoinPay / pods plumbing — **no new billing system**.
- **Free:** public YouTube/HLS URLs, public `tv-<slug>@` directory, low default FPS,
header/watermark, session + concurrency limits.
- **Creator/Pro/Studio:** private streams, PairUX-as-source under access tokens,
no watermark, higher FPS, presets, archive/replay, API/webhooks. Gate via the
same plan capability check used elsewhere in AgentBBS.
---
## 7. Data model
Integrate with `internal/store` (pure-Go sqlite already in `agentbbs`). Do not
create a parallel DB. Minimal tables:
```sql
CREATE TABLE ascii_live_streams (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE,
owner_user_id TEXT,
source_type TEXT NOT NULL, -- youtube | hls | pairux
source_url TEXT NOT NULL,
title TEXT,
status TEXT NOT NULL, -- starting | live | stopped | error
fps INTEGER NOT NULL DEFAULT 10,
width INTEGER NOT NULL DEFAULT 120,
theme TEXT NOT NULL DEFAULT 'truecolor',
pinned INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME, stopped_at DATETIME,
error_message TEXT
);
CREATE TABLE ascii_live_viewers (
id TEXT PRIMARY KEY,
stream_id TEXT NOT NULL REFERENCES ascii_live_streams(id),
user_id TEXT,
connection_type TEXT NOT NULL, -- ssh | web
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
left_at DATETIME
);
```
(Presets table is optional, deferred to when saved presets ship.)
---
## 8. Config (Go)
Mirror the existing `AGENTBBS_*` / `LIVEKIT_*` env convention. Equivalent of the
original TS config as a Go struct + env knobs:
```
ASCIILIVE_ENABLED (bool)
ASCIILIVE_DEFAULT_FPS (int, default 10)
ASCIILIVE_DEFAULT_WIDTH (int, default 120)
ASCIILIVE_DEFAULT_THEME (truecolor|ansi|mono|green|amber)
ASCIILIVE_MAX_DURATION_MIN (int)
ASCIILIVE_MAX_CONCURRENT (int) # streams
ASCIILIVE_MAX_VIEWERS (int) # per stream
ASCIILIVE_MAX_FPS / _MAX_WIDTH (int)
ASCIILIVE_SHOW_HEADER (bool)
ASCIILIVE_WATERMARK (string, optional)
ASCIILIVE_ALLOW_HLS (bool) # direct .m3u8 input
```
---
## 9. Events (component B only)
The frame event model (`ascii_live.frame` carrying the full frame string) is for
**SSE/web delivery only** (component B). **SSH frames write straight to the PTY**
as `internal/calls` already does — do not route SSH frames through an event bus.
Bus events worth emitting for the web/directory + agents: `stream_started`,
`stream_stopped`, `error`, `viewer_joined`, `viewer_left` (metadata only, no
per-frame payload on the control bus).
---
## 10. Phases (revised)
| Phase | Scope | Notes vs original PRD |
|---|---|---|
| **0** | Go `cmd/ascii-live watch <url>` — yt-dlp resolve + ffmpeg rgb24 + `internal/ascii` render, FPS/width flags, clean Ctrl+C. | Was "local TS CLI"; now Go, reusing the renderer. |
| **1 (MVP)** | `tv@`/`tv-<slug>@` SSH route; authenticated open-from-URL; **shared worker fan-out** (1 ffmpeg → N viewers, reap after last); SSRF guard; truecolor render; theme/fps/width; status + clean errors; process cleanup. | Pulls original "Phase 3 multiplexing" into the MVP; drops the chat-room + char-ramp + TS-package assumptions. |
| **2** | Browser viewer = `@logicsrc/plugin-ascii-live` (TS, in logicsrc): xterm.js + SSE, shared stream state with SSH, public directory. | This is the *only* TS package. |
| **3** | PairUX-as-source under the `tv` directory with access tokens. | Mostly already built (`internal/calls`, raw tracks). Reframe as "expose existing call render in the directory + tokens," **not** a composed-stream ingest. |
| **4** | Paid/private sources, plan limits, usage tracking via existing CoinPay/pods. | |
| **5** | intr0s intro/BRB/outro cards; archive/replay; captions/transcripts. | |
| **6 (optional)** | ASCII-styled RTMP **publishing** — separate pipeline, not the viewer path. | Explicitly out of scope for everything above. |
---
## 11. MVP acceptance criteria
MVP (Phase 1) is complete when:
- A member can open a public YouTube Live or HLS URL (authenticated path).
- The stream renders as **truecolor half-block** ASCII over SSH via `tv-<slug>@`.
- **≥2 viewers** attach to the **same** stream with **exactly one** ffmpeg worker.
- The worker is **reaped after the last viewer leaves** (unless `pinned`).
- Theme, FPS, and width are adjustable within configured limits.
- **SSRF guard** rejects private/loopback/metadata IPs, `file://`, and non-http(s)
protocols, re-checking after DNS resolution and redirects.
- Errors are shown cleanly (no raw ffmpeg/yt-dlp dumps to normal users); admins/
debug mode can see detail.
- Process + temp cleanup is reliable on disconnect, close, and crash.
---
## 12. Decisions (resolving the original §29 open questions)
1. **SSH ownership** — AgentBBS owns SSH; reuse `wish`. No second server.
2. **PairUX shape** — raw LiveKit tracks (already shipped). No composed-stream ingest.
3. **Chat placement** — separate lipgloss text region below the frame; never in pixels.
4. **Audio** — ignored for MVP (already the case); captions are a later transcript feature.
5. **Free sources** — public-only; directory is a V1 web feature, not MVP.
6. **Self-host** — yes, day one (repo is OSS; Go CLI gives it for free).
7. **Standalone CLI** — yes, as a Go `cmd/ascii-live` in `agentbbs`, sharing one
frame package across `cmd/` and the SSH route. Not a separate TS tool.
---
## 13. Net
The "video → ASCII → SSH" core is already proven in this codebase. ASCII Live is
therefore a *small* feature: a Go source-adapter + shared-worker + `tv@` route
reusing `internal/ascii`/`internal/calls`, plus a separate TS
`@logicsrc/plugin-ascii-live` strictly for the browser viewer. Build it in that
order; keep RTMP publishing out of the viewer path entirely.

72
docs/custom-domains.md Normal file
View file

@ -0,0 +1,72 @@
# Custom domains
Members can point their own domain (e.g. `chovy.com`) at their AgentBBS
homepage — the same `public_html` that is served at `https://bbs.profullstack.com/~name`.
HTTPS is provisioned automatically on the first request.
## For a member
```sh
# list the domains pointed at your homepage
ssh domain@bbs.profullstack.com
# point a domain at your homepage
ssh domain@bbs.profullstack.com add chovy.com
# remove one
ssh domain@bbs.profullstack.com rm chovy.com
```
`domain@` requires your registered SSH key (run `ssh join@bbs.profullstack.com`
first if you haven't). After `add`, set DNS at your registrar:
- **Subdomain** (`blog.example.com`): `CNAME``bbs.profullstack.com`
- **Apex / root** (`example.com`): `A` record → the BBS host's IPv4
(apex domains can't be CNAMEs; some registrars offer ALIAS/flattening)
The first time someone visits `https://your-domain`, Caddy asks AgentBBS
whether the domain is mapped, gets a yes, issues a Let's Encrypt certificate,
and serves your `public_html`. Edit the page from your pod:
```sh
ssh pod@bbs.profullstack.com
$ nano ~/public_html/index.html
```
## For operators
The same thing from the box, no SSH-as-user needed:
```sh
agentbbs map-domain chovy.com chovy
agentbbs unmap-domain chovy.com chovy
```
## How it works
No custom Caddy module is required:
1. **Source of truth** — the `domains` table (`domain``username`) in the
SQLite store.
2. **Symlink farm**`<data>/domains/<domain>``<data>/users/<name>/public_html`.
Caddy's catch-all `https://` site uses `root * <data>/domains/{host}`, so the
requested host resolves straight to the owner's tree. Unmapped hosts hit a
nonexistent path and 404. The farm is rebuilt from the DB on startup
(`Manager.Sync`), so the DB stays authoritative.
3. **On-demand TLS** — Caddy's `on_demand_tls { ask … }` calls agentbbs on a
loopback endpoint (`AGENTBBS_ASK_ADDR`, default `127.0.0.1:8081`) before
issuing any certificate. It returns `200` only for mapped domains, so this
is **not** an open certificate relay.
Relevant code: `internal/sites/sites.go`, `internal/store` (`MapDomain`,
`DomainUser`, …), the `domain@` route + `map-domain`/`unmap-domain` subcommands
in `cmd/agentbbs/main.go`, and the Caddyfile in `setup.sh`.
### Ownership note
A mapped domain is reserved to its owner (another account gets
`domain already mapped`), but mapping does **not** itself prove the member owns
the DNS name — it just reserves it and primes cert issuance. Because a cert is
only ever issued once DNS actually points at this host, a squatter can't get a
working site for a domain they don't control. Add a DNS `TXT`-token challenge
later if stronger pre-verification is needed.

3
go.mod
View file

@ -9,9 +9,11 @@ require (
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7 github.com/charmbracelet/wish v1.4.7
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/livekit/protocol v1.46.0
github.com/livekit/server-sdk-go/v2 v2.16.6 github.com/livekit/server-sdk-go/v2 v2.16.6
github.com/pion/webrtc/v4 v4.2.15 github.com/pion/webrtc/v4 v4.2.15
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
golang.org/x/term v0.42.0
modernc.org/sqlite v1.52.0 modernc.org/sqlite v1.52.0
) )
@ -59,7 +61,6 @@ require (
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 // indirect github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 // indirect
github.com/livekit/protocol v1.46.0 // indirect
github.com/livekit/psrpc v0.7.1 // indirect github.com/livekit/psrpc v0.7.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/magefile/mage v1.17.0 // indirect github.com/magefile/mage v1.17.0 // indirect

View file

@ -36,6 +36,10 @@ var PodNames = map[string]bool{"pod": true}
// visitor's public key, print instructions, and disconnect. // visitor's public key, print instructions, and disconnect.
var JoinNames = map[string]bool{"join": true, "signup": true, "register": true} var JoinNames = map[string]bool{"join": true, "signup": true, "register": true}
// DomainNames are usernames that route to the custom-domain self-service flow:
// list/add/remove the domains pointed at a member's homepage.
var DomainNames = map[string]bool{"domain": true, "domains": true}
// IsGuestName reports whether the SSH username requests anonymous hub access. // IsGuestName reports whether the SSH username requests anonymous hub access.
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] } func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
@ -45,6 +49,9 @@ func IsPodName(u string) bool { return PodNames[strings.ToLower(u)] }
// IsJoinName reports whether the SSH username requests onboarding. // IsJoinName reports whether the SSH username requests onboarding.
func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] } func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] }
// IsDomainName reports whether the SSH username requests the custom-domain flow.
func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
// KindFor infers the identity kind from a (non-guest) username. // KindFor infers the identity kind from a (non-guest) username.
// Usernames prefixed "agent-" are automated clients (PRD §3). // Usernames prefixed "agent-" are automated clients (PRD §3).
func KindFor(username string) Kind { func KindFor(username string) Kind {

60
internal/mail/mail.go Normal file
View file

@ -0,0 +1,60 @@
// Package mail sends transactional email (account confirmation) over SMTP.
// It is intentionally tiny: standard net/smtp with STARTTLS, configured from
// AGENTBBS_SMTP_* env vars. When unconfigured, Configured() reports false and
// the caller logs the confirmation link instead of sending it.
package mail
import (
"fmt"
"net/smtp"
"os"
"strings"
)
// Config is an SMTP relay. Host+From are the minimum for Configured().
type Config struct {
Host string // smtp server host (no port)
Port string // default 587 (STARTTLS)
User string // auth user; empty = no auth
Pass string
From string // envelope + From: header
}
// ConfigFromEnv reads AGENTBBS_SMTP_{HOST,PORT,USER,PASS,FROM}.
func ConfigFromEnv() Config {
return Config{
Host: os.Getenv("AGENTBBS_SMTP_HOST"),
Port: os.Getenv("AGENTBBS_SMTP_PORT"),
User: os.Getenv("AGENTBBS_SMTP_USER"),
Pass: os.Getenv("AGENTBBS_SMTP_PASS"),
From: os.Getenv("AGENTBBS_SMTP_FROM"),
}
}
// Configured reports whether email can actually be sent.
func (c Config) Configured() bool { return c.Host != "" && c.From != "" }
// Send delivers a plain-text message. net/smtp negotiates STARTTLS when the
// server advertises it (the common case on :587). Implicit-TLS :465 is not
// supported — use a STARTTLS port.
func (c Config) Send(to, subject, body string) error {
if !c.Configured() {
return fmt.Errorf("smtp not configured")
}
port := c.Port
if port == "" {
port = "587"
}
var auth smtp.Auth
if c.User != "" {
auth = smtp.PlainAuth("", c.User, c.Pass, c.Host)
}
msg := "From: " + c.From + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" +
strings.ReplaceAll(body, "\n", "\r\n") + "\r\n"
return smtp.SendMail(c.Host+":"+port, auth, c.From, []string{to}, []byte(msg))
}

149
internal/sites/sites.go Normal file
View file

@ -0,0 +1,149 @@
// Package sites maps members' custom domains onto their homepage (the
// public_html that is also served at /~name).
//
// How it works without a custom Caddy module:
//
// - The DB (store.domains) is the source of truth for domain→user.
// - A symlink farm at <data>/domains/<domain> -> <data>/users/<name>/public_html
// lets Caddy serve any mapped domain with `root * <data>/domains/{host}`.
// - AskHandler answers Caddy's on-demand-TLS query so certificates are only
// issued for domains that are actually mapped (no open cert relay).
//
// A member points DNS (CNAME or A) at the BBS host; the moment a TLS handshake
// for that domain arrives, Caddy asks us, gets a 200, provisions a cert, and
// serves their homepage. See docs/custom-domains.md.
package sites
import (
"errors"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/profullstack/agentbbs/internal/store"
)
// ErrInvalidDomain is returned for syntactically invalid hostnames.
var ErrInvalidDomain = errors.New("invalid domain")
// A conservative DNS hostname: lowercase labels, a real TLD, no scheme/path.
var domainRe = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
// Normalize lowercases and trims a domain (drops a trailing dot, any scheme).
func Normalize(d string) string {
d = strings.ToLower(strings.TrimSpace(d))
d = strings.TrimPrefix(d, "https://")
d = strings.TrimPrefix(d, "http://")
d = strings.TrimSuffix(d, "/")
return strings.TrimSuffix(d, ".")
}
// Valid reports whether d is a usable custom domain.
func Valid(d string) bool { return len(d) <= 253 && domainRe.MatchString(d) }
// Manager owns the symlink farm and the on-demand-TLS ask endpoint.
type Manager struct {
st store.Store
usersDir string // <data>/users
domDir string // <data>/domains (the symlink farm Caddy serves)
}
// NewManager prepares the symlink farm under dataDir/domains.
func NewManager(st store.Store, dataDir string) (*Manager, error) {
domDir := filepath.Join(dataDir, "domains")
if err := os.MkdirAll(domDir, 0o755); err != nil {
return nil, err
}
return &Manager{
st: st,
usersDir: filepath.Join(dataDir, "users"),
domDir: domDir,
}, nil
}
// Add maps domain→username (DB row + symlink). Returns store.ErrDomainTaken if
// another member already owns it, or ErrInvalidDomain on a malformed host.
func (m *Manager) Add(domain, username string) (string, error) {
domain = Normalize(domain)
if !Valid(domain) {
return "", ErrInvalidDomain
}
if err := m.st.MapDomain(domain, username); err != nil {
return domain, err
}
return domain, m.link(domain, username)
}
// Remove unmaps a domain owned by username (DB row + symlink).
func (m *Manager) Remove(domain, username string) (string, error) {
domain = Normalize(domain)
if err := m.st.UnmapDomain(domain, username); err != nil {
return domain, err
}
_ = os.Remove(filepath.Join(m.domDir, domain))
return domain, nil
}
// List returns the domains mapped to username.
func (m *Manager) List(username string) ([]string, error) {
return m.st.DomainsForUser(username)
}
// Sync rebuilds the symlink farm from the DB. Run at startup so the farm
// survives a wiped data dir or hand edits, and so the DB stays authoritative.
func (m *Manager) Sync() error {
all, err := m.st.AllDomains()
if err != nil {
return err
}
for _, dm := range all {
if err := m.link(dm.Domain, dm.Username); err != nil {
return err
}
}
return nil
}
// link points <domDir>/<domain> at the user's public_html, replacing any stale
// link. The public_html is created if absent so the cert + serve path is ready
// before the member has logged in to seed a homepage.
func (m *Manager) link(domain, username string) error {
if !Valid(domain) {
return ErrInvalidDomain
}
target := filepath.Join(m.usersDir, username, "public_html")
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
link := filepath.Join(m.domDir, domain)
_ = os.Remove(link)
return os.Symlink(target, link)
}
// AskHandler answers Caddy's on-demand-TLS query: 200 when the domain is
// mapped (so a cert may be issued), 404 otherwise. Caddy passes the requested
// host as ?domain=. Bind this to loopback only.
func (m *Manager) AskHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
d := Normalize(r.URL.Query().Get("domain"))
if d == "" || !Valid(d) {
http.Error(w, "bad domain", http.StatusBadRequest)
return
}
if _, ok, err := m.st.DomainUser(d); err != nil {
http.Error(w, "lookup error", http.StatusInternalServerError)
return
} else if !ok {
http.Error(w, "unknown domain", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
})
}
// ServeAsk runs the ask endpoint (blocking). Intended for a goroutine.
func (m *Manager) ServeAsk(addr string) error {
return http.ListenAndServe(addr, m.AskHandler())
}

View file

@ -0,0 +1,112 @@
package sites
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/profullstack/agentbbs/internal/store"
)
func TestNormalizeAndValid(t *testing.T) {
cases := []struct {
in string
norm string
valid bool
}{
{"Chovy.com", "chovy.com", true},
{" https://Example.COM/ ", "example.com", true},
{"sub.example.co.uk.", "sub.example.co.uk", true},
{"localhost", "localhost", false}, // no TLD
{"bad_domain.com", "bad_domain.com", false}, // underscore
{"../etc/passwd", "../etc/passwd", false},
{"", "", false},
}
for _, c := range cases {
if got := Normalize(c.in); got != c.norm {
t.Errorf("Normalize(%q) = %q, want %q", c.in, got, c.norm)
}
if got := Valid(Normalize(c.in)); got != c.valid {
t.Errorf("Valid(Normalize(%q)) = %v, want %v", c.in, got, c.valid)
}
}
}
func TestManagerAddRemoveSyncAsk(t *testing.T) {
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
m, err := NewManager(st, dir)
if err != nil {
t.Fatal(err)
}
// Add creates the DB row and a symlink to the user's public_html.
if _, err := m.Add("Chovy.com", "chovy"); err != nil {
t.Fatalf("Add: %v", err)
}
link := filepath.Join(dir, "domains", "chovy.com")
target, err := os.Readlink(link)
if err != nil {
t.Fatalf("expected symlink at %s: %v", link, err)
}
if want := filepath.Join(dir, "users", "chovy", "public_html"); target != want {
t.Errorf("symlink target = %q, want %q", target, want)
}
// A different user cannot steal a mapped domain.
if _, err := m.Add("chovy.com", "someoneelse"); err != store.ErrDomainTaken {
t.Errorf("expected ErrDomainTaken, got %v", err)
}
// Invalid domains are rejected.
if _, err := m.Add("not a domain", "chovy"); err != ErrInvalidDomain {
t.Errorf("expected ErrInvalidDomain, got %v", err)
}
// Ask endpoint: 200 for mapped, 404 for unmapped, 400 for junk.
h := m.AskHandler()
for _, c := range []struct {
domain string
code int
}{
{"chovy.com", http.StatusOK},
{"unmapped.com", http.StatusNotFound},
{"localhost", http.StatusBadRequest},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/check?domain="+c.domain, nil)
h.ServeHTTP(rec, req)
if rec.Code != c.code {
t.Errorf("ask %q = %d, want %d", c.domain, rec.Code, c.code)
}
}
// Sync rebuilds the farm from the DB after the link is removed out-of-band.
if err := os.Remove(link); err != nil {
t.Fatal(err)
}
if err := m.Sync(); err != nil {
t.Fatalf("Sync: %v", err)
}
if _, err := os.Readlink(link); err != nil {
t.Errorf("Sync did not restore symlink: %v", err)
}
// Remove drops both the row and the link.
if _, err := m.Remove("chovy.com", "chovy"); err != nil {
t.Fatalf("Remove: %v", err)
}
if _, err := os.Lstat(link); !os.IsNotExist(err) {
t.Errorf("expected symlink gone, got err=%v", err)
}
if _, ok, _ := st.DomainUser("chovy.com"); ok {
t.Error("expected domain unmapped in store")
}
}

268
internal/source/source.go Normal file
View file

@ -0,0 +1,268 @@
// Package source turns a user-supplied URL (YouTube Live or direct HLS) into
// a stream of packed RGB24 frames, sized for the half-block terminal renderer
// in internal/ascii. It is the URL counterpart to internal/calls, which
// ingests LiveKit tracks: same RGB24 frame contract, different front end.
//
// A YouTube URL is resolved to a playable stream with `yt-dlp -g`; a direct
// .m3u8 is used as-is. The resolved URL is then decoded by ffmpeg into raw
// rgb24 frames. Every URL — the one the user typed and the one yt-dlp returns —
// is run through an SSRF guard (guardURL) before any connection is made.
package source
import (
"bufio"
"context"
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
"strings"
)
// Kind classifies a source URL.
type Kind string
const (
KindYouTube Kind = "youtube"
KindHLS Kind = "hls"
)
func (k Kind) String() string { return string(k) }
// Options configures a worker. PW/PH are the pixel dimensions the decoder
// scales to — compute them with ascii.FitEven(cols, rows).
type Options struct {
URL string
FPS int
PW, PH int
AllowHLS bool // permit direct .m3u8 / http(s) inputs (not just YouTube)
}
// Worker is one running decode pipeline: ffmpeg reading the resolved stream
// and emitting RGB24 frames on Frames. Mirrors internal/calls.session so the
// Phase 1 fan-out multiplexer can drive either source identically.
type Worker struct {
Frames chan []byte // each frame is PW*PH*3 bytes
Status chan string // human-readable status, best-effort (non-blocking)
Kind Kind // resolved source kind
ffmpeg *exec.Cmd
cancel context.CancelFunc
done chan struct{}
}
// youtubeHosts are the hostnames routed through yt-dlp resolution.
var youtubeHosts = map[string]bool{
"youtube.com": true,
"www.youtube.com": true,
"m.youtube.com": true,
"music.youtube.com": true,
"youtu.be": true,
}
// Classify decides how a raw URL is handled, and rejects anything that is not
// http(s). It does not touch the network.
func Classify(raw string) (Kind, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
default:
return "", fmt.Errorf("unsupported URL scheme %q (only http/https are allowed)", u.Scheme)
}
host := strings.ToLower(u.Hostname())
if youtubeHosts[host] {
return KindYouTube, nil
}
if strings.Contains(strings.ToLower(u.Path), ".m3u8") {
return KindHLS, nil
}
// Default unknown http(s) sources to HLS handling; the caller decides
// whether AllowHLS permits them.
return KindHLS, nil
}
// isBlockedIP reports whether an address must not be dialed: loopback,
// link-local (incl. the 169.254.169.254 cloud-metadata endpoint), private
// (RFC1918 / fc00::/7), multicast, or unspecified.
func isBlockedIP(ip net.IP) bool {
return ip == nil ||
ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified() ||
ip.IsPrivate()
}
// guardURL validates scheme and resolves the host, rejecting any URL that
// points at a private, loopback, link-local, or metadata address. It is the
// SSRF gate for both the user URL and the yt-dlp-resolved URL.
//
// Note: ffmpeg follows HTTP redirects internally, so a public host that 302s
// to a private one is not caught here. Re-checking post-redirect is a Phase 1
// item (see docs/ascii-live.md §3); for the local CLI the pre-DNS guard holds.
func guardURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
default:
return fmt.Errorf("refusing %q: only http/https are allowed", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("refusing URL with no host")
}
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("cannot resolve host %q: %w", host, err)
}
for _, ip := range ips {
if isBlockedIP(ip) {
return fmt.Errorf("refusing to connect to %s (%s): private or reserved address", host, ip)
}
}
return nil
}
// resolve guards the input URL, runs yt-dlp for YouTube sources, and guards the
// resolved URL too. It returns the playable stream URL and the source kind.
func resolve(ctx context.Context, opts Options) (streamURL string, kind Kind, err error) {
kind, err = Classify(opts.URL)
if err != nil {
return "", "", err
}
if kind == KindHLS && !opts.AllowHLS {
return "", "", fmt.Errorf("direct HLS/URL input is disabled on this host")
}
if err := guardURL(opts.URL); err != nil {
return "", "", err
}
if kind != KindYouTube {
return opts.URL, kind, nil
}
if _, err := exec.LookPath("yt-dlp"); err != nil {
return "", "", fmt.Errorf("yt-dlp is not installed — required to resolve YouTube URLs")
}
// -g prints the direct media URL(s); the format preference keeps the
// terminal-resolution stream small.
cmd := exec.CommandContext(ctx, "yt-dlp",
"-f", "best[height<=480]/best", "-g", "--no-warnings", opts.URL)
out, err := cmd.Output()
if err != nil {
return "", "", fmt.Errorf("could not resolve YouTube stream")
}
for _, line := range strings.Split(string(out), "\n") {
streamURL = strings.TrimSpace(line)
if streamURL != "" {
break // first URL is the (combined) video stream
}
}
if streamURL == "" {
return "", "", fmt.Errorf("could not resolve YouTube stream")
}
if err := guardURL(streamURL); err != nil {
return "", "", err
}
return streamURL, kind, nil
}
// Start resolves the source and launches the ffmpeg decode pipeline. The
// returned Worker emits frames until the stream ends or Close is called.
func Start(ctx context.Context, opts Options) (*Worker, error) {
if opts.FPS <= 0 {
opts.FPS = 10
}
if opts.PW <= 0 || opts.PH <= 0 {
return nil, fmt.Errorf("invalid frame geometry %dx%d", opts.PW, opts.PH)
}
streamURL, kind, err := resolve(ctx, opts)
if err != nil {
return nil, err
}
cctx, cancel := context.WithCancel(ctx)
w := &Worker{
Frames: make(chan []byte, 2),
Status: make(chan string, 8),
Kind: kind,
cancel: cancel,
done: make(chan struct{}),
}
// ffmpeg: resolved stream → fps-limited, scaled, rgb24 raw frames.
// format=rgb24 is mandatory — a yuv420p stream would otherwise fail the
// rawvideo muxer.
cmd := exec.CommandContext(cctx, "ffmpeg",
"-hide_banner", "-loglevel", "error", "-nostdin",
"-i", streamURL,
"-vf", fmt.Sprintf("fps=%d,scale=%d:%d:flags=lanczos,format=rgb24", opts.FPS, opts.PW, opts.PH),
"-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1",
)
cmd.Stderr = os.Stderr // ffmpeg decode errors surface in the host log
out, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, err
}
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("ffmpeg: %w", err)
}
w.ffmpeg = cmd
w.status(fmt.Sprintf("decoding %s source…", kind))
go w.pump(out, opts.PW*opts.PH*3)
return w, nil
}
// pump reads fixed-size frames from ffmpeg and forwards them, dropping rather
// than blocking when a viewer lags.
func (w *Worker) pump(out io.Reader, size int) {
defer close(w.Frames)
r := bufio.NewReaderSize(out, size)
for {
buf := make([]byte, size)
if _, err := io.ReadFull(r, buf); err != nil {
w.status("stream ended")
return
}
select {
case w.Frames <- buf:
case <-w.done:
return
default: // drop frame; keep latency low
}
}
}
func (w *Worker) status(msg string) {
select {
case w.Status <- msg:
default:
}
}
// Close tears down ffmpeg and stops the pump.
func (w *Worker) Close() {
select {
case <-w.done:
default:
close(w.done)
}
if w.cancel != nil {
w.cancel()
}
if w.ffmpeg != nil && w.ffmpeg.Process != nil {
_ = w.ffmpeg.Process.Kill()
_ = w.ffmpeg.Wait()
}
}

View file

@ -0,0 +1,80 @@
package source
import (
"net"
"testing"
)
func TestClassify(t *testing.T) {
cases := []struct {
raw string
want Kind
wantErr bool
}{
{"https://youtube.com/live/abc123", KindYouTube, false},
{"https://www.youtube.com/watch?v=abc", KindYouTube, false},
{"https://youtu.be/abc123", KindYouTube, false},
{"https://example.com/path/stream.m3u8", KindHLS, false},
{"http://cdn.example.com/live.m3u8?token=x", KindHLS, false},
{"https://example.com/whatever", KindHLS, false}, // unknown http(s) → HLS handling
{"file:///etc/passwd", "", true},
{"rtmp://example.com/live", "", true},
{"ftp://example.com/x", "", true},
}
for _, c := range cases {
got, err := Classify(c.raw)
if c.wantErr {
if err == nil {
t.Errorf("Classify(%q): expected error, got kind %q", c.raw, got)
}
continue
}
if err != nil {
t.Errorf("Classify(%q): unexpected error: %v", c.raw, err)
continue
}
if got != c.want {
t.Errorf("Classify(%q) = %q, want %q", c.raw, got, c.want)
}
}
}
func TestIsBlockedIP(t *testing.T) {
blocked := []string{
"127.0.0.1", // loopback
"::1", // loopback v6
"10.0.0.5", // private
"172.16.3.4", // private
"192.168.1.1", // private
"169.254.169.254", // link-local / cloud metadata
"fe80::1", // link-local v6
"fc00::1", // unique-local v6 (private)
"0.0.0.0", // unspecified
"224.0.0.1", // multicast
}
for _, s := range blocked {
ip := net.ParseIP(s)
if ip == nil {
t.Fatalf("bad test IP %q", s)
}
if !isBlockedIP(ip) {
t.Errorf("isBlockedIP(%s) = false, want true", s)
}
}
allowed := []string{
"8.8.8.8", // public
"1.1.1.1", // public
"142.250.72.46", // public (googlevideo-ish)
"2606:4700:4700::1111", // public v6
}
for _, s := range allowed {
ip := net.ParseIP(s)
if ip == nil {
t.Fatalf("bad test IP %q", s)
}
if isBlockedIP(ip) {
t.Errorf("isBlockedIP(%s) = true, want false", s)
}
}
}

View file

@ -16,9 +16,28 @@ type User struct {
Name string Name string
Kind string Kind string
PubKeyFP string PubKeyFP string
Email string
EmailVerified bool
CreatedAt time.Time CreatedAt time.Time
} }
// userCols is the column list (in struct order) for every user SELECT, kept in
// sync with scanUser.
const userCols = `id, name, kind, pubkey_fp, email, email_verified, created_at`
// scanUser reads one user row selected with userCols.
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
var u User
var verified int
var created string
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &created); err != nil {
return User{}, err
}
u.EmailVerified = verified != 0
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil
}
// Score is one leaderboard entry. // Score is one leaderboard entry.
type Score struct { type Score struct {
User string User string
@ -40,6 +59,13 @@ type Store interface {
// LastSeen reports the start of the user's most recent session. // LastSeen reports the start of the user's most recent session.
LastSeen(userID int64) (time.Time, bool, error) LastSeen(userID int64) (time.Time, bool, error)
// SetEmailVerification records the account's email and a fresh
// confirmation token, marking it unverified until the token is used.
SetEmailVerification(userID int64, email, token string) error
// VerifyEmail consumes a confirmation token: on match it marks the
// account verified, clears the token, and returns the account.
VerifyEmail(token string) (User, bool, error)
RecordSession(userID int64, username, remote, route string) (int64, error) RecordSession(userID int64, username, remote, route string) (int64, error)
EndSession(sessionID int64) error EndSession(sessionID int64) error
@ -54,6 +80,15 @@ type Store interface {
AddChat(userID int64, username, role, text string) error AddChat(userID int64, username, role, text string) error
RecentChats(username string, n int) ([]ChatMessage, error) RecentChats(username string, n int) ([]ChatMessage, error)
// Custom domains mapped to a member's homepage (public_html).
// MapDomain binds domain→username, returning ErrDomainTaken if it is
// already claimed by someone else (re-binding to the same owner is a no-op).
MapDomain(domain, username string) error
UnmapDomain(domain, username string) error
DomainUser(domain string) (string, bool, error)
DomainsForUser(username string) ([]string, error)
AllDomains() ([]DomainMap, error)
Close() error Close() error
} }
@ -64,9 +99,21 @@ type ChatMessage struct {
At time.Time At time.Time
} }
// DomainMap binds a custom domain to a member's homepage (the public_html that
// is also served at /~name). Used to serve, e.g., https://chovy.com from
// users/chovy/public_html.
type DomainMap struct {
Domain string
Username string
At time.Time
}
// ErrKeyMismatch means a username is already registered with another key. // ErrKeyMismatch means a username is already registered with another key.
var ErrKeyMismatch = errors.New("username registered with a different key") var ErrKeyMismatch = errors.New("username registered with a different key")
// ErrDomainTaken means a domain is already mapped to a different member.
var ErrDomainTaken = errors.New("domain already mapped to another account")
type sqliteStore struct{ db *sql.DB } type sqliteStore struct{ db *sql.DB }
// Open opens (and migrates) the SQLite store at path. // Open opens (and migrates) the SQLite store at path.
@ -79,9 +126,54 @@ func Open(path string) (Store, error) {
db.Close() db.Close()
return nil, err return nil, err
} }
if err := migrate(db); err != nil {
db.Close()
return nil, err
}
return &sqliteStore{db: db}, nil return &sqliteStore{db: db}, nil
} }
// migrate applies additive schema changes that must not fail on existing
// databases. New columns live here (not in schema) so there is one code path.
func migrate(db *sql.DB) error {
return ensureColumns(db, "users", [][2]string{
{"email", "email TEXT NOT NULL DEFAULT ''"},
{"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"},
{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"},
})
}
// ensureColumns adds any missing {name, "name TYPE …"} columns to table.
func ensureColumns(db *sql.DB, table string, cols [][2]string) error {
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
return err
}
have := map[string]bool{}
for rows.Next() {
var cid, notnull, pk int
var name, ctype string
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
have[name] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, c := range cols {
if !have[c[0]] {
if _, err := db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + c[1]); err != nil {
return err
}
}
}
return nil
}
const schema = ` const schema = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@ -122,15 +214,18 @@ CREATE TABLE IF NOT EXISTS pod_subscriptions (
payment_ref TEXT NOT NULL DEFAULT '', payment_ref TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
); );
CREATE TABLE IF NOT EXISTS domains (
domain TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_domains_user ON domains(username);
` `
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
var u User u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name))
var created string
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
switch { switch {
case err == sql.ErrNoRows: case errors.Is(err, sql.ErrNoRows):
res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp) res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp)
if err != nil { if err != nil {
return User{}, err return User{}, err
@ -143,7 +238,6 @@ func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp { if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp {
return User{}, ErrKeyMismatch return User{}, ErrKeyMismatch
} }
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil return u, nil
} }
@ -151,17 +245,37 @@ func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) {
if fp == "" { if fp == "" {
return User{}, false, nil return User{}, false, nil
} }
var u User u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE pubkey_fp = ?`, fp))
var created string if errors.Is(err, sql.ErrNoRows) {
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE pubkey_fp = ?`, fp).
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
if err == sql.ErrNoRows {
return User{}, false, nil return User{}, false, nil
} }
if err != nil { if err != nil {
return User{}, false, err return User{}, false, err
} }
u.CreatedAt, _ = time.Parse(time.RFC3339, created) return u, true, nil
}
func (s *sqliteStore) SetEmailVerification(userID int64, email, token string) error {
_, err := s.db.Exec(`UPDATE users SET email = ?, verify_token = ?, email_verified = 0 WHERE id = ?`,
email, token, userID)
return err
}
func (s *sqliteStore) VerifyEmail(token string) (User, bool, error) {
if token == "" {
return User{}, false, nil
}
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE verify_token = ?`, token))
if errors.Is(err, sql.ErrNoRows) {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
if _, err := s.db.Exec(`UPDATE users SET email_verified = 1, verify_token = '' WHERE id = ?`, u.ID); err != nil {
return User{}, false, err
}
u.EmailVerified = true
return u, true, nil return u, true, nil
} }
@ -236,17 +350,13 @@ func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error
} }
func (s *sqliteStore) UserByName(name string) (User, bool, error) { func (s *sqliteStore) UserByName(name string) (User, bool, error) {
var u User u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name))
var created string if errors.Is(err, sql.ErrNoRows) {
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
if err == sql.ErrNoRows {
return User{}, false, nil return User{}, false, nil
} }
if err != nil { if err != nil {
return User{}, false, err return User{}, false, err
} }
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, true, nil return u, true, nil
} }
@ -296,4 +406,73 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error)
return out, rows.Err() return out, rows.Err()
} }
func (s *sqliteStore) MapDomain(domain, username string) error {
var owner string
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner)
switch {
case err == nil:
if owner != username {
return ErrDomainTaken
}
return nil // already ours
case err != sql.ErrNoRows:
return err
}
_, err = s.db.Exec(`INSERT INTO domains (domain, username) VALUES (?,?)`, domain, username)
return err
}
func (s *sqliteStore) UnmapDomain(domain, username string) error {
_, err := s.db.Exec(`DELETE FROM domains WHERE domain = ? AND username = ?`, domain, username)
return err
}
func (s *sqliteStore) DomainUser(domain string) (string, bool, error) {
var username string
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&username)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, err
}
return username, true, nil
}
func (s *sqliteStore) DomainsForUser(username string) ([]string, error) {
rows, err := s.db.Query(`SELECT domain FROM domains WHERE username = ? ORDER BY domain`, username)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
func (s *sqliteStore) AllDomains() ([]DomainMap, error) {
rows, err := s.db.Query(`SELECT domain, username, created_at FROM domains ORDER BY domain`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DomainMap
for rows.Next() {
var dm DomainMap
var at string
if err := rows.Scan(&dm.Domain, &dm.Username, &at); err != nil {
return nil, err
}
dm.At, _ = time.Parse(time.RFC3339, at)
out = append(out, dm)
}
return out, rows.Err()
}
func (s *sqliteStore) Close() error { return s.db.Close() } func (s *sqliteStore) Close() error { return s.db.Close() }

View file

@ -0,0 +1,68 @@
package store
import (
"path/filepath"
"testing"
)
func TestEmailVerificationRoundtrip(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
u, err := st.EnsureUser("alice", "member", "SHA256:aaa")
if err != nil {
t.Fatalf("ensure: %v", err)
}
if u.EmailVerified {
t.Fatal("new user should be unverified")
}
if err := st.SetEmailVerification(u.ID, "alice@example.com", "tok123"); err != nil {
t.Fatalf("set: %v", err)
}
// Email is stored but not yet verified.
got, _, err := st.UserByFingerprint("SHA256:aaa")
if err != nil {
t.Fatalf("byfp: %v", err)
}
if got.Email != "alice@example.com" || got.EmailVerified {
t.Fatalf("pre-verify state wrong: %+v", got)
}
// Wrong token does nothing.
if _, ok, _ := st.VerifyEmail("nope"); ok {
t.Fatal("bad token should not verify")
}
// Correct token verifies and returns the account.
vu, ok, err := st.VerifyEmail("tok123")
if err != nil || !ok {
t.Fatalf("verify: ok=%v err=%v", ok, err)
}
if vu.Name != "alice" || !vu.EmailVerified {
t.Fatalf("verify returned wrong user: %+v", vu)
}
// Token is single-use (cleared after verification).
if _, ok, _ := st.VerifyEmail("tok123"); ok {
t.Fatal("token should be consumed after first use")
}
final, _, _ := st.UserByName("alice")
if !final.EmailVerified {
t.Fatal("user should remain verified")
}
}
func TestVerifyEmailEmptyToken(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
if _, ok, err := st.VerifyEmail(""); ok || err != nil {
t.Fatalf("empty token must be a clean miss: ok=%v err=%v", ok, err)
}
}

309
setup.sh Executable file
View file

@ -0,0 +1,309 @@
#!/usr/bin/env bash
#
# setup.sh — one-shot provisioner for an AgentBBS host (Ubuntu droplet).
#
# Brings a fresh box to: agentbbs on :22 (so `ssh join@bbs.profullstack.com`
# works with no -p), the admin OpenSSH moved to :2202, rootless podman for
# pods, a persistent SSH host key + sqlite store, and a Caddy front end serving
# https://bbs.profullstack.com plus tilde.town-style /~user homepages.
#
# It is idempotent — safe to re-run to update (it pulls + rebuilds + restarts).
#
# sudo ./setup.sh
#
# Override any default via env, e.g.:
# sudo DOMAIN=bbs.example.com ADMIN_SSH_PORT=2222 ./setup.sh
#
# ⚠️ The admin OpenSSH port changes to ADMIN_SSH_PORT. The script verifies the
# new port is listening BEFORE handing :22 to agentbbs and never drops your
# current session — but open a SECOND terminal and confirm
# `ssh -p <ADMIN_SSH_PORT> <you>@<host>` works before you log out.
set -euo pipefail
# ---- config (override via env) ---------------------------------------------
DOMAIN="${DOMAIN:-bbs.profullstack.com}"
ADMIN_SSH_PORT="${ADMIN_SSH_PORT:-2202}"
ACME_EMAIL="${ACME_EMAIL:-admin@profullstack.com}"
SVC_USER="${SVC_USER:-agentbbs}"
REPO="${REPO:-https://github.com/profullstack/agentbbs.git}"
SRC_DIR="${SRC_DIR:-/opt/agentbbs}"
DATA_DIR="${DATA_DIR:-/var/lib/agentbbs}"
ASK_ADDR="${ASK_ADDR:-127.0.0.1:8081}" # agentbbs on-demand-TLS ask endpoint (must match agentbbs.env)
GO_VERSION="${GO_VERSION:-1.26.4}"
POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}"
FETCH_ASSETS="${FETCH_ASSETS:-1}" # set 0 to skip the DOOM/Freedoom arcade assets
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "run as root (sudo ./setup.sh)"
. /etc/os-release 2>/dev/null || true
[ "${ID:-}" = "ubuntu" ] || warn "tested on Ubuntu; ${ID:-unknown} may differ"
case "$(uname -m)" in
x86_64|amd64) GOARCH=amd64 ;;
aarch64|arm64) GOARCH=arm64 ;;
*) die "unsupported arch $(uname -m)" ;;
esac
# ---- 1. packages -----------------------------------------------------------
log "installing packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
git ca-certificates curl ufw ffmpeg \
podman uidmap slirp4netns fuse-overlayfs \
debian-keyring debian-archive-keyring apt-transport-https >/dev/null
# yt-dlp from pip is fresher than apt; fall back to apt if pip is unavailable.
if ! command -v yt-dlp >/dev/null; then
log "installing yt-dlp"
apt-get install -y -qq python3-pip >/dev/null
pip3 install --quiet --break-system-packages -U yt-dlp 2>/dev/null \
|| apt-get install -y -qq yt-dlp >/dev/null \
|| warn "yt-dlp not installed — YouTube sources will fail until it is"
fi
# ---- 2. Go toolchain (system go is too old; pin GO_VERSION) -----------------
GO_ROOT="/usr/local/go"
if [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then
log "installing Go ${GO_VERSION}"
tmp="$(mktemp -d)"
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o "$tmp/go.tgz" \
|| die "could not download Go ${GO_VERSION}"
rm -rf "$GO_ROOT"
tar -C /usr/local -xzf "$tmp/go.tgz"
rm -rf "$tmp"
fi
export PATH="$GO_ROOT/bin:$PATH"
# ---- 3. service user + rootless podman prerequisites -----------------------
if ! id "$SVC_USER" >/dev/null 2>&1; then
log "creating service user $SVC_USER"
useradd --system --create-home --home-dir "/home/$SVC_USER" --shell /usr/sbin/nologin "$SVC_USER"
fi
SVC_UID="$(id -u "$SVC_USER")"
# subuid/subgid ranges let rootless podman build user namespaces for pods.
grep -q "^${SVC_USER}:" /etc/subuid || usermod --add-subuids 100000-165535 "$SVC_USER"
grep -q "^${SVC_USER}:" /etc/subgid || usermod --add-subgids 100000-165535 "$SVC_USER"
# linger keeps /run/user/$UID alive so podman works from a system service with
# no interactive login.
loginctl enable-linger "$SVC_USER" >/dev/null 2>&1 || true
# ---- 4. persistent data dir (host key + sqlite + per-user public_html) ------
log "preparing $DATA_DIR"
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0751 "$DATA_DIR" # others may traverse, not list
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0700 "$DATA_DIR/ssh" # host key stays private
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/users" # tilde homepages live here
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/web" # site root
install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/domains" # symlink farm: custom domain -> users/<name>/public_html
[ -f "$DATA_DIR/web/index.html" ] || cat > "$DATA_DIR/web/index.html" <<HTML
<!doctype html><meta charset=utf-8><title>AgentBBS</title>
<style>body{background:#000;color:#33ff66;font:16px/1.6 monospace;max-width:44rem;margin:4rem auto;padding:0 1rem}a{color:#60a5fa}</style>
<h1>AgentBBS</h1>
<p>A BBS over SSH for humans and AI agents.</p>
<pre> ssh join@${DOMAIN} # register your key, get started
ssh bbs@${DOMAIN} # look around as a guest
ssh pod@${DOMAIN} # your personal Linux pod (\$1/mo)</pre>
<p>User homepages live at <code>/~name</code> — and members can point their own domain at one (<code>ssh domain@${DOMAIN} add yourdomain.com</code>).</p>
HTML
chown "$SVC_USER:$SVC_USER" "$DATA_DIR/web/index.html"
# ---- 5. clone/update + build agentbbs --------------------------------------
if [ -d "$SRC_DIR/.git" ]; then
log "updating source in $SRC_DIR"
git -C "$SRC_DIR" pull --ff-only
else
log "cloning $REPO"
git clone --depth 1 "$REPO" "$SRC_DIR"
fi
if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/fetch-assets.sh" ]; then
log "fetching arcade assets (set FETCH_ASSETS=0 to skip)"
( cd "$SRC_DIR" && ./fetch-assets.sh ) || warn "asset fetch failed; arcade may be limited"
fi
log "building binaries"
( cd "$SRC_DIR" && go build -o /usr/local/bin/agentbbs ./cmd/agentbbs )
( cd "$SRC_DIR" && go build -o /usr/local/bin/ascii-live ./cmd/ascii-live )
# Pre-pull the pod base image as the service user so first pod launch is fast.
sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \
podman pull -q "$POD_IMAGE" >/dev/null 2>&1 || warn "could not pre-pull $POD_IMAGE (pods will pull on first use)"
# ---- 6. environment file ---------------------------------------------------
ENV_DIR=/etc/agentbbs
install -d -m 0750 "$ENV_DIR"
if [ ! -f "$ENV_DIR/agentbbs.env" ]; then
log "writing $ENV_DIR/agentbbs.env (fill in CoinPay/LiveKit before relying on pods/video)"
cat > "$ENV_DIR/agentbbs.env" <<ENV
# AgentBBS runtime config — edit then: systemctl restart agentbbs
AGENTBBS_ADDR=:22
AGENTBBS_HOST=${DOMAIN}
AGENTBBS_DATA=${DATA_DIR}
AGENTBBS_ASSETS=${SRC_DIR}/assets
AGENTBBS_POD_IMAGE=${POD_IMAGE}
# Custom domains: Caddy on-demand-TLS asks this loopback endpoint whether a
# requested host is mapped before issuing a certificate. Must match the
# Caddyfile's on_demand_tls ask URL.
AGENTBBS_ASK_ADDR=${ASK_ADDR}
# Pods (CoinPay \$1/mo membership) — required for pod@ to charge/verify:
# AGENTBBS_COINPAY_PAY_TMPL=
# AGENTBBS_COINPAY_VERIFY_CMD=
# PairUX video calls rendered as ASCII (video@ / tv@ PairUX sources):
# AGENTBBS_LIVEKIT_URL=
# AGENTBBS_LIVEKIT_KEY=
# AGENTBBS_LIVEKIT_SECRET=
# Agent chat backend (agent@), stdin->stdout, e.g. "claude -p":
# AGENTBBS_AGENT_CMD=
ENV
chmod 0640 "$ENV_DIR/agentbbs.env"
fi
# ---- 7. systemd unit (runs as $SVC_USER, binds :22 via ambient capability) --
log "installing agentbbs.service"
cat > /etc/systemd/system/agentbbs.service <<UNIT
[Unit]
Description=AgentBBS — BBS over SSH
After=network-online.target
Wants=network-online.target
[Service]
User=${SVC_USER}
Group=${SVC_USER}
WorkingDirectory=${SRC_DIR}
EnvironmentFile=${ENV_DIR}/agentbbs.env
Environment=XDG_RUNTIME_DIR=/run/user/${SVC_UID}
ExecStart=/usr/local/bin/agentbbs
Restart=always
RestartSec=2
# Bind :22 as a non-root user. We deliberately do NOT set NoNewPrivileges or
# the Protect*/ReadWritePaths sandbox here: rootless podman needs the setuid
# newuidmap/newgidmap helpers (blocked by NoNewPrivileges) and read-write
# access to the service user's home for container storage. The security
# boundary is the pod itself (cap-drop ALL etc. in internal/pods), not this
# orchestrator process, which already runs unprivileged as ${SVC_USER}.
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
# ---- 8. move admin OpenSSH to ADMIN_SSH_PORT (before agentbbs takes :22) -----
log "moving admin OpenSSH to :${ADMIN_SSH_PORT}"
install -d -m 0755 /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/10-agentbbs-admin.conf <<SSHD
# Admin OpenSSH moved off :22 so agentbbs can own it.
# Reach this box for administration with: ssh -p ${ADMIN_SSH_PORT} <user>@host
Port ${ADMIN_SSH_PORT}
SSHD
# Open the new admin port FIRST so the upcoming firewall enable can't lock us out.
ufw allow "${ADMIN_SSH_PORT}/tcp" >/dev/null
if sshd -t; then
systemctl restart ssh 2>/dev/null || systemctl restart sshd
else
die "sshd config test failed; not restarting (you are not locked out)"
fi
# Verify the admin port is actually listening before we free :22.
for _ in 1 2 3 4 5; do
ss -tlnp 2>/dev/null | grep -q ":${ADMIN_SSH_PORT} " && break
sleep 1
done
ss -tlnp 2>/dev/null | grep -q ":${ADMIN_SSH_PORT} " \
|| die "admin sshd is NOT listening on ${ADMIN_SSH_PORT} — aborting before touching :22. Your current session is still up; fix sshd and re-run."
# ---- 9. Caddy front end (HTTPS + tilde /~user homepages) --------------------
if ! command -v caddy >/dev/null; then
log "installing Caddy"
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
apt-get update -qq && apt-get install -y -qq caddy >/dev/null
fi
# Let Caddy (user 'caddy') read the per-user public_html trees.
usermod -aG "$SVC_USER" caddy 2>/dev/null || true
log "writing Caddyfile"
cat > /etc/caddy/Caddyfile <<CADDY
{
email ${ACME_EMAIL}
# Custom user domains get certificates on demand, but only for hosts the
# BBS has actually mapped — agentbbs answers this ask query (200/404) so
# this is not an open certificate relay.
on_demand_tls {
ask http://${ASK_ADDR}/check
}
}
${DOMAIN} {
encode zstd gzip
# tilde.town-style homepages: /~name[/path] -> users/name/public_html/path
@tilde path_regexp tilde ^/~([^/]+)(/.*)?\$
handle @tilde {
root * ${DATA_DIR}/users
rewrite * /{re.tilde.1}/public_html{re.tilde.2}
try_files {path} {path}/index.html
file_server browse
}
# site root
handle {
root * ${DATA_DIR}/web
file_server
}
}
# Custom domains a member pointed at this host (ssh domain@${DOMAIN} add ...).
# The symlink farm in domains/ maps each host to its owner's public_html, so
# {host} resolves to the right tree; unmapped hosts 404 (and never got a cert).
https:// {
encode zstd gzip
tls {
on_demand
}
root * ${DATA_DIR}/domains/{host}
try_files {path} {path}/index.html
file_server
}
CADDY
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
systemctl reload caddy 2>/dev/null || systemctl restart caddy
# ---- 10. firewall + start agentbbs on :22 ----------------------------------
log "configuring firewall + starting agentbbs"
ufw allow 22/tcp >/dev/null
ufw --force enable >/dev/null
systemctl enable --now agentbbs
sleep 1
systemctl is-active --quiet agentbbs \
|| die "agentbbs failed to start — check: journalctl -u agentbbs -n50"
# ---- done ------------------------------------------------------------------
log "AgentBBS is up."
cat <<DONE
DNS point ${DOMAIN} (A record) at this droplet's public IP.
Admin SSH ssh -p ${ADMIN_SSH_PORT} <you>@${DOMAIN} (your old key still works)
Users ssh join@${DOMAIN} register
ssh bbs@${DOMAIN} guest hub
ssh pod@${DOMAIN} personal pod
ssh domain@${DOMAIN} add <domain> point your own domain at your homepage
Web https://${DOMAIN}/ site root
https://${DOMAIN}/~<name> a member's homepage
https://<your-domain> a member's homepage on a custom domain (auto-HTTPS)
Config ${ENV_DIR}/agentbbs.env (set CoinPay + LiveKit, then: systemctl restart agentbbs)
Logs journalctl -u agentbbs -f
Update re-run this script (git pull + rebuild + restart)
DONE
warn "Before you log out: open a new terminal and confirm ssh -p ${ADMIN_SSH_PORT} <you>@${DOMAIN} works."
warn "If you attached a DigitalOcean Cloud Firewall, also allow ${ADMIN_SSH_PORT}, 22, 80, 443 there."