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

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

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

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

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

View file

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