mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 23:07:28 +00:00
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:
parent
9b0f465946
commit
f3f8e70996
14 changed files with 2005 additions and 27 deletions
|
|
@ -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
107
cmd/ascii-live/main.go
Normal 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
279
docs/ascii-live.md
Normal 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
72
docs/custom-domains.md
Normal 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
3
go.mod
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
60
internal/mail/mail.go
Normal 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
149
internal/sites/sites.go
Normal 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())
|
||||||
|
}
|
||||||
112
internal/sites/sites_test.go
Normal file
112
internal/sites/sites_test.go
Normal 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
268
internal/source/source.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
80
internal/source/source_test.go
Normal file
80
internal/source/source_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, ¬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 = `
|
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() }
|
||||||
|
|
|
||||||
68
internal/store/store_email_test.go
Normal file
68
internal/store/store_email_test.go
Normal 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
309
setup.sh
Executable 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."
|
||||||
Loading…
Add table
Add a link
Reference in a new issue