From f3f8e7099654d1cf84347921f595615096ac9580 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 11 Jun 2026 15:53:19 +0000 Subject: [PATCH] bbs.profullstack.com launch kit: provisioner, custom domains, email verify, ascii-live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/agentbbs/main.go | 293 ++++++++++++++++++++++++++- cmd/ascii-live/main.go | 107 ++++++++++ docs/ascii-live.md | 279 ++++++++++++++++++++++++++ docs/custom-domains.md | 72 +++++++ go.mod | 3 +- internal/auth/auth.go | 7 + internal/mail/mail.go | 60 ++++++ internal/sites/sites.go | 149 ++++++++++++++ internal/sites/sites_test.go | 112 +++++++++++ internal/source/source.go | 268 +++++++++++++++++++++++++ internal/source/source_test.go | 80 ++++++++ internal/store/store.go | 225 ++++++++++++++++++--- internal/store/store_email_test.go | 68 +++++++ setup.sh | 309 +++++++++++++++++++++++++++++ 14 files changed, 2005 insertions(+), 27 deletions(-) create mode 100644 cmd/ascii-live/main.go create mode 100644 docs/ascii-live.md create mode 100644 docs/custom-domains.md create mode 100644 internal/mail/mail.go create mode 100644 internal/sites/sites.go create mode 100644 internal/sites/sites_test.go create mode 100644 internal/source/source.go create mode 100644 internal/source/source_test.go create mode 100644 internal/store/store_email_test.go create mode 100755 setup.sh diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index f5bfd9b..f6b1ae3 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -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:///~ 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 ssh join@"+a.host+" to get a fresh confirmation link."))) + return + } + _, _ = w.Write([]byte(verifyPage("Email confirmed ✓", + "Welcome, "+u.Name+". Your account is active — ssh "+u.Name+"@"+a.host+"."))) +} + +// verifyPage renders the minimal confirmation result page. +func verifyPage(title, body string) string { + return "" + title + "" + + "" + + "

" + title + "

" + body + "

" +} + +// 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 + " -> ", + "", + " 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 point a domain at ~"+u.Name, + " ssh domain@"+a.host+" rm 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 +// ` / `unmap-domain `, mirroring grant-pod. +func domainCmd(st store.Store, dataDir, cmd string, args []string) { + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "usage: agentbbs %s \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 := "\n\n" + + "~" + name + "\n" + + "\n" + + "

~" + name + "

\n" + + "

This is " + name + "'s corner of AgentBBS.

\n" + + "

Edit ~/public_html/index.html in your pod (ssh pod@" + host + ") to make it yours.

\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 diff --git a/cmd/ascii-live/main.go b/cmd/ascii-live/main.go new file mode 100644 index 0000000..8368d5a --- /dev/null +++ b/cmd/ascii-live/main.go @@ -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-@ will serve. +// +// Usage: +// +// ascii-live watch [--fps N] [--width COLS] +// +// ascii-live watch "https://youtube.com/live/" +// 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 [--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 +} diff --git a/docs/ascii-live.md b/docs/ascii-live.md new file mode 100644 index 0000000..cbef9e2 --- /dev/null +++ b/docs/ascii-live.md @@ -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-@` 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-@) 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-@`, `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 ` 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-@`) + +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 ` + "room chat +alongside" model does not fit. ASCII Live adopts username routing: + +``` +ssh tv@host # browse the public stream directory (hub menu) +ssh tv-@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/" # 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-@` 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 ` — 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-@` 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-@`. +- **≥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. diff --git a/docs/custom-domains.md b/docs/custom-domains.md new file mode 100644 index 0000000..64bba42 --- /dev/null +++ b/docs/custom-domains.md @@ -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** — `/domains/` → `/users//public_html`. + Caddy's catch-all `https://` site uses `root * /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. diff --git a/go.mod b/go.mod index 1908d13..723094e 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/wish v1.4.7 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/pion/webrtc/v4 v4.2.15 golang.org/x/crypto v0.50.0 + golang.org/x/term v0.42.0 modernc.org/sqlite v1.52.0 ) @@ -59,7 +61,6 @@ require ( github.com/lithammer/shortuuid/v4 v4.2.0 // indirect github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // 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/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/magefile/mage v1.17.0 // indirect diff --git a/internal/auth/auth.go b/internal/auth/auth.go index aa737ea..3751ba1 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -36,6 +36,10 @@ var PodNames = map[string]bool{"pod": true} // visitor's public key, print instructions, and disconnect. 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. 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. 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. // Usernames prefixed "agent-" are automated clients (PRD §3). func KindFor(username string) Kind { diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..228498f --- /dev/null +++ b/internal/mail/mail.go @@ -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)) +} diff --git a/internal/sites/sites.go b/internal/sites/sites.go new file mode 100644 index 0000000..ec1bc9b --- /dev/null +++ b/internal/sites/sites.go @@ -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 /domains/ -> /users//public_html +// lets Caddy serve any mapped domain with `root * /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 // /users + domDir string // /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 / 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()) +} diff --git a/internal/sites/sites_test.go b/internal/sites/sites_test.go new file mode 100644 index 0000000..3797419 --- /dev/null +++ b/internal/sites/sites_test.go @@ -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") + } +} diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 0000000..4870173 --- /dev/null +++ b/internal/source/source.go @@ -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() + } +} diff --git a/internal/source/source_test.go b/internal/source/source_test.go new file mode 100644 index 0000000..26bf40b --- /dev/null +++ b/internal/source/source_test.go @@ -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) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 3db9c21..5848bd7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -12,11 +12,30 @@ import ( // User is a persisted account (member or agent; guests are never stored). type User struct { - ID int64 - Name string - Kind string - PubKeyFP string - CreatedAt time.Time + ID int64 + Name string + Kind string + PubKeyFP string + Email string + EmailVerified bool + 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. @@ -40,6 +59,13 @@ type Store interface { // LastSeen reports the start of the user's most recent session. 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) EndSession(sessionID int64) error @@ -54,6 +80,15 @@ type Store interface { AddChat(userID int64, username, role, text string) 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 } @@ -64,9 +99,21 @@ type ChatMessage struct { 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. 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 } // Open opens (and migrates) the SQLite store at path. @@ -79,9 +126,54 @@ func Open(path string) (Store, error) { db.Close() return nil, err } + if err := migrate(db); err != nil { + db.Close() + return nil, err + } 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, ¬null, &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 = ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, @@ -122,15 +214,18 @@ CREATE TABLE IF NOT EXISTS pod_subscriptions ( payment_ref TEXT NOT NULL DEFAULT '', 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) { - var u User - 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) + u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name)) 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) if err != nil { return User{}, err @@ -143,7 +238,6 @@ func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp { return User{}, ErrKeyMismatch } - u.CreatedAt, _ = time.Parse(time.RFC3339, created) return u, nil } @@ -151,17 +245,37 @@ func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) { if fp == "" { return User{}, false, nil } - var u User - var created string - 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 { + u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE pubkey_fp = ?`, fp)) + if errors.Is(err, sql.ErrNoRows) { return User{}, false, nil } if err != nil { 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 } @@ -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) { - var u User - 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) - if err == sql.ErrNoRows { + u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name)) + if errors.Is(err, sql.ErrNoRows) { return User{}, false, nil } if err != nil { return User{}, false, err } - u.CreatedAt, _ = time.Parse(time.RFC3339, created) return u, true, nil } @@ -296,4 +406,73 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) 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() } diff --git a/internal/store/store_email_test.go b/internal/store/store_email_test.go new file mode 100644 index 0000000..f4eb662 --- /dev/null +++ b/internal/store/store_email_test.go @@ -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) + } +} diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..730a11f --- /dev/null +++ b/setup.sh @@ -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 @` 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//public_html +[ -f "$DATA_DIR/web/index.html" ] || cat > "$DATA_DIR/web/index.html" <AgentBBS + +

AgentBBS

+

A BBS over SSH for humans and AI agents.

+
  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)
+

User homepages live at /~name — and members can point their own domain at one (ssh domain@${DOMAIN} add yourdomain.com).

+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" <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 < /etc/ssh/sshd_config.d/10-agentbbs-admin.conf <@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 < 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 <@${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 point your own domain at your homepage + Web https://${DOMAIN}/ site root + https://${DOMAIN}/~ a member's homepage + https:// 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} @${DOMAIN} works." +warn "If you attached a DigitalOcean Cloud Firewall, also allow ${ADMIN_SSH_PORT}, 22, 80, 443 there."