eleven / messenger Public

server: cloud provisioner — self-serve space creation via home (#306)

* server: cloud provisioner — self-serve space creation via home

Two new subcommands of the router binary plus one home-server endpoint,
so a signed-in home user can get a fresh instance without an operator:

- lchat controld (lchat-control.service, root): the local control API
  anticipated in docs/instance-router.md — POST /instances on
  /run/lchat/control.sock, gated by a root-only bearer token, executing
  the add-instance core (now refactored into a callable addInstance()).
  Single authority on capacity: LCHAT_MAX_INSTANCES (default 40) plus a
  free-disk floor (LCHAT_MIN_FREE_GB, default 5) -> 503 at_capacity.
- lchat cloud (lchat-cloud.service, unprivileged/sandboxed): the public
  policy layer (-> cloud.elevenmessenger.com) — caller tokens for home
  servers (minted like push relay tokens), a per-user quota (default 3),
  and generated friendly slugs (amber-fox-42.<base-domain>). Receives
  the control token via systemd LoadCredential, so nothing running as
  the lchat uid can read it from disk.
- home POST /api/spaces: link-token-authed, rate-limited; forwards the
  user's opaque id as the quota key and relays the stable error codes
  (409 quota_exceeded, 503 at_capacity with the member-facing message).
  501 when no provisioner is configured — home stays strictly additive.

bootstrap installs the two units and mints /etc/lchat/control.token.
Design, trust model, and capacity math: docs/cloud-provisioner.md.
App UI (+ -> Sign in), bot sidecars on provisioned spaces, and a
never-claimed-space reaper are phase 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: router redirects unknown hosts to a fallback URL

With a wildcard DNS record at the box, typo'd or retired subdomains hit
the router; --fallback-url (env LCHAT_FALLBACK_URL, set via the new
optional /data/router.env) now 302s them to e.g. the apex marketing
site instead of a bare 404. Applied on :443 and on the :80 non-ACME
path (which previously redirected everything into a doomed TLS
handshake). Unset keeps today's 404.

Honest scope: over HTTPS this only helps hosts that still have a cert —
a never-served host fails TLS first, since HostPolicy deliberately
refuses to mint certs for unknown hosts (ACME rate-limit protection).
Full https-first coverage needs the wildcard-cert (DNS-01) option
already sketched in docs/instance-router.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: "Create a new space" on the backup page

The signed-in home page (home.elevenmessenger.com) grows a "Create a
new space" section. Once the vault is unlocked (create or restore), the
browser holds the link token POST /api/spaces needs and the DEK to save
the result — so creating a space also folds it straight into the
encrypted backup, and restore always finds the way back.

- GET /api/features {spaces: bool} lets the page hide the section on a
  home server without a provisioner configured (public: a boolean only).
- The button disables during the request (a double-tap would spend quota
  twice) and surfaces the provisioner's member-facing messages inline
  (quota reached, at-capacity) — never a floating toast.
- On success: the new space's host + a primary "Open your new space →"
  link (the one-time admin setup link), described in keyholder terms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bd67555794ce8143e9743d35c2a4c6e904f52a8e parent 57b8c47
22 files changed, +2271 −60
  • CLAUDE.md +5 −0
  • bootstrap.go +39 −0
  • cloud.go +370 −0
  • cloud_db.go +171 −0
  • cloud_test.go +304 −0
  • control.go +123 −54
  • controld.go +265 −0
  • controld_test.go +226 −0
  • deploy/systemd/lchat-cloud.service +47 −0
  • deploy/systemd/lchat-control.service +35 −0
  • deploy/systemd/lchat-router.service +3 −0
  • docs/cloud-provisioner.md +176 −0
  • docs/instance-router.md +16 −3
  • home/main.go +18 −1
  • home/provision.go +109 −0
  • home/provision_test.go +173 −0
  • home/web/pages/index.html +10 −0
  • home/web/static/home.css +16 −0
  • home/web/static/home.js +78 −0
  • main.go +6 −0
  • router.go +44 −2
  • router_test.go +37 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index ecb4461..6a0f594 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -89,6 +89,11 @@ in, so deploy is one file (no cgo).
A Go port (`lchat bot` subcommand) is the planned default; the Node version
stays as the reference implementation.
- `unfurl.go` — OpenGraph link previews (SSRF-guarded fetch + cache).
+- `controld.go` / `cloud.go` — self-serve space creation on a router box: a
+ root-only local control daemon that executes `add-instance` (with a capacity
+ cap), and the public, unprivileged provisioner in front of it (caller tokens,
+ per-user quotas, friendly slugs). The home server's `POST /api/spaces`
+ forwards a signed-in user's request to it. See `docs/cloud-provisioner.md`.
- `web/pages/*.html` — served with `__GROUP_NAME__` substituted (HTML-escaped).
- `web/static/*` — vanilla ES modules, **no build step**, served via `go:embed`
with `Cache-Control: no-cache`.
diff --git a/bootstrap.go b/bootstrap.go
index 54078ea..573aa04 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -33,6 +33,17 @@ var unitPush string
//go:embed deploy/systemd/lchat-home.service
var unitHome string
+// The control daemon + cloud provisioner (self-serve space creation — see
+// docs/cloud-provisioner.md). Like push/home they install everywhere but stay
+// inert: controld needs an operator enable (its token file is minted below);
+// cloud additionally needs /data/cloud.env.
+//
+//go:embed deploy/systemd/lchat-control.service
+var unitControl string
+
+//go:embed deploy/systemd/lchat-cloud.service
+var unitCloud string
+
// serviceUser is the unprivileged account every unit runs as (see the hardened
// units in deploy/systemd). bootstrap creates it and hands it the data + runtime
// dirs; the router keeps CAP_NET_BIND_SERVICE to still bind :80/:443.
@@ -65,6 +76,28 @@ func chownToService(path string) {
}
}
+// ensureControlToken mints the control daemon's bearer token into a ROOT-only
+// file, idempotently. Root-only is the point: every service shares the lchat
+// uid, so this token — not socket permissions — is what separates "may create
+// instances" (the cloud unit, via LoadCredential) from everything else.
+func ensureControlToken() error {
+ if _, err := os.Stat(controlTokenPath); err == nil {
+ return nil
+ }
+ tok, err := randToken()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(controlTokenPath), 0o700); err != nil {
+ return err
+ }
+ if err := os.WriteFile(controlTokenPath, []byte(tok+"\n"), 0o600); err != nil {
+ return err
+ }
+ fmt.Printf(" minted %s (root-only)\n", controlTokenPath)
+ return nil
+}
+
// runBootstrap installs the systemd units + runtime dir and starts the router, so
// provisioning a fresh box is one command after the binary is copied to
// /opt/lchat/lchat. Idempotent — safe to re-run. See docs/instance-router.md.
@@ -107,8 +140,14 @@ func runBootstrap(args []string) {
write(filepath.Join(*unitDir, "lchat-bot@.service"), unitBot)
write(filepath.Join(*unitDir, "lchat-push.service"), unitPush)
write(filepath.Join(*unitDir, "lchat-home.service"), unitHome)
+ write(filepath.Join(*unitDir, "lchat-control.service"), unitControl)
+ write(filepath.Join(*unitDir, "lchat-cloud.service"), unitCloud)
write("/etc/tmpfiles.d/lchat.conf", tmpfilesConf)
+ if err := ensureControlToken(); err != nil {
+ die("bootstrap: control token: %v", err)
+ }
+
if err := systemctl("daemon-reload"); err != nil {
die("bootstrap: %v", err)
}
diff --git a/cloud.go b/cloud.go
new file mode 100644
index 0000000..fc83943
--- /dev/null
+++ b/cloud.go
@@ -0,0 +1,370 @@
+package main
+
+// The cloud provisioner: the public, policy-enforcing face of instance creation
+// (→ cloud.elevenmessenger.com). Callers are home servers holding a minted
+// bearer token; each request names an opaque requester (the home server's
+// random user id) so a per-user quota can hold. The actual creation happens in
+// the control daemon (controld.go) over its local socket — this service is
+// deliberately unprivileged and sandboxed, holding policy (tokens, quotas, slug
+// generation) and no root powers. Any box running the lchat router can enable
+// it. See docs/cloud-provisioner.md.
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "math/big"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// spaceUnavailableMsg is the member-facing "we're full" message, relayed
+// verbatim by home servers and shown by clients.
+const spaceUnavailableMsg = "We can't create a new Eleven space right now — please try again later."
+
+// Slug wordlists: friendly, short, unambiguous (no near-homophones, nothing a
+// space owner would mind in their address). ~40×48×90 ≈ 170k combinations;
+// collisions just re-roll.
+var slugAdjectives = []string{
+ "amber", "aqua", "azure", "bold", "brave", "bright", "calm", "cedar",
+ "clear", "coral", "cosy", "crisp", "eager", "early", "fair", "fern",
+ "fleet", "fresh", "gentle", "gold", "green", "happy", "hazel", "indigo",
+ "ivory", "jade", "keen", "kind", "lively", "lucky", "lunar", "maple",
+ "merry", "noble", "olive", "pearl", "plum", "quiet", "rosy", "royal",
+ "sage", "scarlet", "silver", "sunny", "swift", "teal", "violet", "warm",
+}
+
+var slugNouns = []string{
+ "badger", "bay", "beacon", "birch", "bloom", "brook", "cabin", "canyon",
+ "cave", "cliff", "cove", "crane", "creek", "dawn", "deer",
+ "delta", "dune", "falcon", "fig", "finch", "fjord", "fox", "garden",
+ "glen", "grove", "harbor", "heron", "hill", "lake", "lark", "leaf",
+ "lynx", "meadow", "moss", "oak", "orchard", "otter", "owl", "pine",
+ "pond", "rain", "reef", "river", "robin", "sparrow", "spring", "stone",
+ "summit", "swan", "tide", "trail", "valley", "willow", "wolf", "wren",
+}
+
+// reservedSlugs are labels that must never become a space, even if a wordlist
+// edit accidentally introduces one — existing or plausible sibling hostnames
+// and generic infrastructure names.
+var reservedSlugs = map[string]bool{
+ "www": true, "mail": true, "api": true, "app": true, "admin": true,
+ "home": true, "push": true, "cloud": true, "team": true, "spaces": true,
+ "chat": true, "test": true, "staging": true, "status": true, "docs": true,
+ "blog": true, "help": true, "support": true, "store": true, "shop": true,
+}
+
+// generateSlug rolls a friendly space address label: adjective-noun-NN.
+func generateSlug() (string, error) {
+ pick := func(n int) (int, error) {
+ v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
+ if err != nil {
+ return 0, err
+ }
+ return int(v.Int64()), nil
+ }
+ a, err := pick(len(slugAdjectives))
+ if err != nil {
+ return "", err
+ }
+ n, err := pick(len(slugNouns))
+ if err != nil {
+ return "", err
+ }
+ num, err := pick(90)
+ if err != nil {
+ return "", err
+ }
+ slug := fmt.Sprintf("%s-%s-%d", slugAdjectives[a], slugNouns[n], num+10)
+ if reservedSlugs[slug] {
+ return generateSlug()
+ }
+ return slug, nil
+}
+
+// cloudServer is the provisioner's HTTP surface.
+type cloudServer struct {
+ db *cloudDB
+ adminHash string // sha256 of LCHAT_ADMIN_TOKEN; gates token mint/list/revoke
+ controlToken string
+ control *http.Client // dials the control daemon's unix socket
+ baseDomain string // spaces land at <slug>.<baseDomain>
+ quota int // max spaces per (caller, requester)
+}
+
+func (s *cloudServer) handler() http.Handler {
+ mux := http.NewServeMux()
+ // The provisioning surface: bearer caller token (a home server).
+ mux.HandleFunc("POST /api/instances", s.callerAuth(s.createSpaceHandler))
+ mux.HandleFunc("GET /api/instances", s.callerAuth(s.listSpacesHandler))
+ // Admin surface: mint / list / revoke caller tokens at runtime (mirrors the
+ // push relay's admin API).
+ mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler))
+ mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listTokensHandler))
+ mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeTokenHandler))
+ mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
+ return mux
+}
+
+// callerAuth resolves the bearer token to a caller id and passes it via the
+// request context.
+type callerKey struct{}
+
+func (s *cloudServer) callerAuth(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id, ok := s.db.authCaller(bearerTokenHeader(r))
+ if !ok {
+ writeControlErr(w, http.StatusUnauthorized, "unauthorized", "bad or missing caller token")
+ return
+ }
+ next(w, r.WithContext(context.WithValue(r.Context(), callerKey{}, id)))
+ }
+}
+
+func (s *cloudServer) admin(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !tokenHashEqual(bearerTokenHeader(r), s.adminHash) {
+ writeControlErr(w, http.StatusUnauthorized, "unauthorized", "bad or missing admin token")
+ return
+ }
+ next(w, r)
+ }
+}
+
+// createSpaceHandler provisions one instance for a requester: quota check, roll
+// a slug, ask the control daemon to create it, record it in the ledger.
+func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
+ callerID := r.Context().Value(callerKey{}).(string)
+ var req struct {
+ Requester string `json:"requester"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+ return
+ }
+ req.Requester = strings.TrimSpace(req.Requester)
+ if req.Requester == "" || len(req.Requester) > 128 {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "requester required (opaque id, ≤128 chars)")
+ return
+ }
+
+ n, err := s.db.spaceCount(callerID, req.Requester)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error")
+ return
+ }
+ if n >= s.quota {
+ writeControlErr(w, http.StatusConflict, "quota_exceeded",
+ fmt.Sprintf("this account already has %d spaces (the limit)", n))
+ return
+ }
+
+ // Roll slugs until one is free; host_exists means an unlucky collision with
+ // an existing route, so just re-roll. Anything else is final.
+ for attempt := 0; attempt < 3; attempt++ {
+ slug, err := generateSlug()
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "create_failed", "slug generation failed")
+ return
+ }
+ host := slug + "." + s.baseDomain
+ status, body, err := s.controlCreate(host)
+ if err != nil {
+ log.Printf("cloud: control call for %s: %v", host, err)
+ writeControlErr(w, http.StatusBadGateway, "create_failed", "the box's control daemon is unreachable")
+ return
+ }
+ switch {
+ case status == http.StatusCreated:
+ if err := s.db.insertSpace(host, callerID, req.Requester); err != nil {
+ // The instance exists but the ledger write failed — log loudly; the
+ // requester keeps the space, quota under-counts by one.
+ log.Printf("cloud: ledger insert for %s failed: %v", host, err)
+ }
+ log.Printf("cloud: created %s (caller %.8s…)", host, callerID)
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": host,
+ "url": body["url"],
+ "setup_link": body["setup_link"],
+ })
+ return
+ case status == http.StatusConflict && body["error"] == "host_exists":
+ continue // collision — re-roll
+ case status == http.StatusServiceUnavailable:
+ // The box is full: relay the stable code with the member-facing message.
+ writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg)
+ return
+ default:
+ msg, _ := body["message"].(string)
+ log.Printf("cloud: control create %s: HTTP %d %s", host, status, msg)
+ writeControlErr(w, http.StatusBadGateway, "create_failed", "space creation failed")
+ return
+ }
+ }
+ writeControlErr(w, http.StatusInternalServerError, "create_failed", "could not find a free address")
+}
+
+// controlCreate posts one creation request to the control daemon.
+func (s *cloudServer) controlCreate(host string) (int, map[string]any, error) {
+ payload, _ := json.Marshal(controlCreateReq{Host: host})
+ req, err := http.NewRequest("POST", "http://control/instances", bytes.NewReader(payload))
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.controlToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := s.control.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer resp.Body.Close()
+ var body map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return resp.StatusCode, map[string]any{}, nil
+ }
+ return resp.StatusCode, body, nil
+}
+
+func (s *cloudServer) listSpacesHandler(w http.ResponseWriter, r *http.Request) {
+ callerID := r.Context().Value(callerKey{}).(string)
+ requester := strings.TrimSpace(r.URL.Query().Get("requester"))
+ if requester == "" {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "requester query param required")
+ return
+ }
+ spaces, err := s.db.listSpaces(callerID, requester)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "list_failed", "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces})
+}
+
+// ---- admin: caller tokens ---------------------------------------------------
+
+func (s *cloudServer) mintTokenHandler(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Label string `json:"label"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Label) == "" {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "label required")
+ return
+ }
+ id, token, err := s.db.mintCallerToken(strings.TrimSpace(req.Label))
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "mint_failed", "db error")
+ return
+ }
+ // The one time the plaintext token is ever visible.
+ writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": req.Label, "token": token})
+}
+
+func (s *cloudServer) listTokensHandler(w http.ResponseWriter, r *http.Request) {
+ tokens, err := s.db.listCallerTokens()
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "list_failed", "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
+}
+
+func (s *cloudServer) revokeTokenHandler(w http.ResponseWriter, r *http.Request) {
+ ok, err := s.db.revokeCallerToken(r.PathValue("id"))
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "revoke_failed", "db error")
+ return
+ }
+ if !ok {
+ writeControlErr(w, http.StatusNotFound, "not_found", "no such token")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// readControlToken finds the control bearer token: systemd LoadCredential first
+// (how the lchat-cloud unit receives it without the file being readable by the
+// lchat user), then an explicit file path (dev).
+func readControlToken() (string, error) {
+ if dir := os.Getenv("CREDENTIALS_DIRECTORY"); dir != "" {
+ if b, err := os.ReadFile(filepath.Join(dir, "control-token")); err == nil {
+ return string(trimNL(b)), nil
+ }
+ }
+ if p := os.Getenv("LCHAT_CONTROL_TOKEN_FILE"); p != "" {
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return "", err
+ }
+ return string(trimNL(b)), nil
+ }
+ return "", fmt.Errorf("no control token: need systemd LoadCredential=control-token:… or LCHAT_CONTROL_TOKEN_FILE")
+}
+
+// runCloud: `lchat cloud` — serve the public provisioning API (fronted by the
+// router as a 'service' route, e.g. at cloud.elevenmessenger.com).
+func runCloud(args []string) {
+ fs := flag.NewFlagSet("cloud", flag.ExitOnError)
+ addr := fs.String("addr", envOr("ADDR", ":1749"), "TCP listen address (dev; production uses --socket)")
+ socketPath := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr")
+ dbPath := fs.String("db", envOr("LCHAT_CLOUD_DB", "cloud.db"), "provisioner SQLite path")
+ baseDomain := fs.String("base-domain", os.Getenv("LCHAT_CLOUD_BASE_DOMAIN"), "domain spaces are created under (required; needs wildcard DNS at this box)")
+ quota := fs.Int("quota", envInt("LCHAT_CLOUD_QUOTA", 3), "max spaces per caller+requester")
+ controlSocket := fs.String("control-socket", envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock"), "the control daemon's unix socket")
+ _ = fs.Parse(args)
+
+ if *baseDomain == "" || !validHost("x."+*baseDomain) {
+ die("cloud: --base-domain (env LCHAT_CLOUD_BASE_DOMAIN) is required, e.g. elevenmessenger.com")
+ }
+ admin := os.Getenv("LCHAT_ADMIN_TOKEN")
+ if admin == "" {
+ die("cloud: LCHAT_ADMIN_TOKEN is required (gates caller-token minting)")
+ }
+ controlToken, err := readControlToken()
+ if err != nil {
+ die("cloud: %v", err)
+ }
+ db, err := openCloudDB(*dbPath)
+ if err != nil {
+ die("cloud: open db: %v", err)
+ }
+
+ s := &cloudServer{
+ db: db,
+ adminHash: hashToken(admin),
+ controlToken: controlToken,
+ control: controlClient(*controlSocket),
+ baseDomain: *baseDomain,
+ quota: *quota,
+ }
+ ln, where, err := listen(*addr, *socketPath)
+ if err != nil {
+ die("cloud: listen: %v", err)
+ }
+ log.Printf("cloud: provisioning %s spaces on %s (db %s, quota %d)", *baseDomain, where, *dbPath, *quota)
+ srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 10 * time.Second}
+ if err := srv.Serve(ln); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// controlClient dials the control daemon's unix socket. Creation waits on a
+// real instance start + health check, so the timeout is generous.
+func controlClient(socket string) *http.Client {
+ return &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", socket)
+ },
+ },
+ }
+}
diff --git a/cloud_db.go b/cloud_db.go
new file mode 100644
index 0000000..56cdb2c
--- /dev/null
+++ b/cloud_db.go
@@ -0,0 +1,171 @@
+package main
+
+import (
+ "database/sql"
+ "time"
+)
+
+// cloudDB is the provisioner's state: which callers (home servers) may
+// provision, and which spaces each caller's users already have (the quota
+// ledger). It never holds message data, keys, or anything about a space beyond
+// its hostname — the requester ids are the home server's opaque random user
+// ids, meaningless outside it.
+type cloudDB struct{ sql *sql.DB }
+
+func openCloudDB(path string) (*cloudDB, error) {
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ // One writer at a time keeps WAL happy (mirrors openDB).
+ d.SetMaxOpenConns(1)
+ for _, p := range []string{
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA busy_timeout=5000",
+ "PRAGMA synchronous=NORMAL",
+ } {
+ if _, err := d.Exec(p); err != nil {
+ return nil, err
+ }
+ }
+ db := &cloudDB{sql: d}
+ if err := db.migrate(); err != nil {
+ return nil, err
+ }
+ return db, nil
+}
+
+// migrate is additive (CREATE TABLE IF NOT EXISTS), like every other schema in
+// this repo, so deploying new code over an existing DB is safe.
+func (db *cloudDB) migrate() error {
+ _, err := db.sql.Exec(`
+-- Who may provision: bearer tokens minted for home servers (cf. the push
+-- relay's relay_tokens). Stored hashed — a stolen DB yields no live credential.
+CREATE TABLE IF NOT EXISTS caller_tokens (
+ id TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+);
+-- Provisioned spaces: the quota ledger. requester is the caller's opaque user
+-- id (a home server's random uid) — never an email or name.
+CREATE TABLE IF NOT EXISTS spaces (
+ host TEXT PRIMARY KEY,
+ caller_id TEXT NOT NULL,
+ requester TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);`)
+ return err
+}
+
+// mintCallerToken creates a caller token, stores only its hash, and returns the
+// plaintext (shown once).
+func (db *cloudDB) mintCallerToken(label string) (id, token string, err error) {
+ if token, err = randToken(); err != nil {
+ return "", "", err
+ }
+ if id, err = randToken(); err != nil {
+ return "", "", err
+ }
+ _, err = db.sql.Exec(
+ `INSERT INTO caller_tokens(id, label, token_hash, created_at) VALUES(?, ?, ?, ?)`,
+ id, label, hashToken(token), time.Now().Unix(),
+ )
+ return id, token, err
+}
+
+// authCaller resolves a bearer token to its caller id, bumping last_used_at.
+func (db *cloudDB) authCaller(token string) (id string, ok bool) {
+ if token == "" {
+ return "", false
+ }
+ if db.sql.QueryRow(
+ `SELECT id FROM caller_tokens WHERE token_hash = ?`, hashToken(token),
+ ).Scan(&id) != nil {
+ return "", false
+ }
+ // Best-effort; a failed bump doesn't deny an otherwise-valid token.
+ _, _ = db.sql.Exec(`UPDATE caller_tokens SET last_used_at = ? WHERE id = ?`, time.Now().Unix(), id)
+ return id, true
+}
+
+// CallerToken is a token row for the admin listing — never the token itself.
+type CallerToken struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ CreatedAt int64 `json:"created_at"`
+ LastUsedAt *int64 `json:"last_used_at"`
+}
+
+func (db *cloudDB) listCallerTokens() ([]CallerToken, error) {
+ rows, err := db.sql.Query(`SELECT id, label, created_at, last_used_at FROM caller_tokens ORDER BY created_at`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []CallerToken{}
+ for rows.Next() {
+ var t CallerToken
+ if err := rows.Scan(&t.ID, &t.Label, &t.CreatedAt, &t.LastUsedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+func (db *cloudDB) revokeCallerToken(id string) (bool, error) {
+ res, err := db.sql.Exec(`DELETE FROM caller_tokens WHERE id = ?`, id)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+ return n > 0, nil
+}
+
+// spaceCount is the quota check: how many spaces this caller's requester
+// already provisioned.
+func (db *cloudDB) spaceCount(callerID, requester string) (int, error) {
+ var n int
+ err := db.sql.QueryRow(
+ `SELECT COUNT(*) FROM spaces WHERE caller_id = ? AND requester = ?`, callerID, requester,
+ ).Scan(&n)
+ return n, err
+}
+
+func (db *cloudDB) insertSpace(host, callerID, requester string) error {
+ _, err := db.sql.Exec(
+ `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(?, ?, ?, ?)`,
+ host, callerID, requester, time.Now().Unix(),
+ )
+ return err
+}
+
+// Space is one provisioned instance in a requester's listing.
+type Space struct {
+ Host string `json:"host"`
+ URL string `json:"url"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) {
+ rows, err := db.sql.Query(
+ `SELECT host, created_at FROM spaces WHERE caller_id = ? AND requester = ? ORDER BY created_at`,
+ callerID, requester,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Space{}
+ for rows.Next() {
+ var s Space
+ if err := rows.Scan(&s.Host, &s.CreatedAt); err != nil {
+ return nil, err
+ }
+ s.URL = "https://" + s.Host
+ out = append(out, s)
+ }
+ return out, rows.Err()
+}
diff --git a/cloud_test.go b/cloud_test.go
new file mode 100644
index 0000000..cf49df9
--- /dev/null
+++ b/cloud_test.go
@@ -0,0 +1,304 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+// fakeControl serves a stand-in control daemon on a unix socket, so the cloud
+// server dials it exactly as in production.
+func fakeControl(t *testing.T, handler http.HandlerFunc) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "lcc") // short path: sun_path limit
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ sock := filepath.Join(dir, "ctl.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := &http.Server{Handler: handler}
+ go srv.Serve(ln)
+ t.Cleanup(func() { srv.Close() })
+ return sock
+}
+
+// okControl answers every create with a fresh instance, like a healthy box.
+func okControl(t *testing.T) string {
+ return fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
+ var req controlCreateReq
+ json.NewDecoder(r.Body).Decode(&req)
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": req.Host,
+ "url": "https://" + req.Host,
+ "setup_link": "https://" + req.Host + "/join/setup#SECRET",
+ })
+ })
+}
+
+func newTestCloud(t *testing.T, controlSocket string) (*httptest.Server, *cloudServer) {
+ t.Helper()
+ db, err := openCloudDB(filepath.Join(t.TempDir(), "cloud.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := &cloudServer{
+ db: db,
+ adminHash: hashToken("admin-secret"),
+ controlToken: "ctl-secret",
+ control: controlClient(controlSocket),
+ baseDomain: "example.com",
+ quota: 3,
+ }
+ srv := httptest.NewServer(s.handler())
+ t.Cleanup(srv.Close)
+ return srv, s
+}
+
+// mintCaller mints a caller token through the admin API (exercising it on the way).
+func mintCaller(t *testing.T, srv *httptest.Server, label string) string {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{"label": label})
+ req, _ := http.NewRequest("POST", srv.URL+"/api/admin/tokens", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer admin-secret")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("mint: status %d", resp.StatusCode)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ tok, _ := m["token"].(string)
+ if tok == "" {
+ t.Fatal("mint: no token in response")
+ }
+ return tok
+}
+
+func createSpace(t *testing.T, srv *httptest.Server, token, requester string) *http.Response {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{"requester": requester})
+ req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(body))
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func TestCloudRequiresCallerToken(t *testing.T) {
+ srv, _ := newTestCloud(t, okControl(t))
+ if resp := createSpace(t, srv, "", "user-1"); resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("no token: status = %d, want 401", resp.StatusCode)
+ }
+ if resp := createSpace(t, srv, "nope", "user-1"); resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("bad token: status = %d, want 401", resp.StatusCode)
+ }
+}
+
+func TestCloudCreateAndList(t *testing.T) {
+ srv, _ := newTestCloud(t, okControl(t))
+ tok := mintCaller(t, srv, "home.example.com")
+
+ resp := createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("status = %d, want 201", resp.StatusCode)
+ }
+ m := decodeBody(t, resp)
+ host, _ := m["host"].(string)
+ if !strings.HasSuffix(host, ".example.com") {
+ t.Errorf("host = %q, want *.example.com", host)
+ }
+ if setup, _ := m["setup_link"].(string); !strings.Contains(setup, "/join/setup#") {
+ t.Errorf("setup_link = %v", m["setup_link"])
+ }
+
+ // The ledger recorded it, scoped to this requester.
+ req, _ := http.NewRequest("GET", srv.URL+"/api/instances?requester=user-1", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+ lresp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lresp.Body.Close()
+ var list struct{ Spaces []Space }
+ json.NewDecoder(lresp.Body).Decode(&list)
+ if len(list.Spaces) != 1 || list.Spaces[0].Host != host {
+ t.Errorf("spaces = %+v, want [%s]", list.Spaces, host)
+ }
+
+ // A different requester sees nothing.
+ req2, _ := http.NewRequest("GET", srv.URL+"/api/instances?requester=user-2", nil)
+ req2.Header.Set("Authorization", "Bearer "+tok)
+ lresp2, err := http.DefaultClient.Do(req2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lresp2.Body.Close()
+ var list2 struct{ Spaces []Space }
+ json.NewDecoder(lresp2.Body).Decode(&list2)
+ if len(list2.Spaces) != 0 {
+ t.Errorf("other requester sees %+v", list2.Spaces)
+ }
+}
+
+func TestCloudQuota(t *testing.T) {
+ srv, s := newTestCloud(t, okControl(t))
+ tok := mintCaller(t, srv, "home")
+
+ for i := 0; i < s.quota; i++ {
+ if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create %d: status %d", i, resp.StatusCode)
+ }
+ }
+ resp := createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusConflict {
+ t.Fatalf("over quota: status = %d, want 409", resp.StatusCode)
+ }
+ if m := decodeBody(t, resp); m["error"] != "quota_exceeded" {
+ t.Errorf("error = %v, want quota_exceeded", m["error"])
+ }
+ // Another user on the same home server is unaffected.
+ if resp := createSpace(t, srv, tok, "user-2"); resp.StatusCode != http.StatusCreated {
+ t.Errorf("other requester: status = %d, want 201", resp.StatusCode)
+ }
+}
+
+func TestCloudRelaysAtCapacity(t *testing.T) {
+ ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
+ writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", "instance limit reached (40/40)")
+ })
+ srv, _ := newTestCloud(t, ctl)
+ tok := mintCaller(t, srv, "home")
+
+ resp := createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", resp.StatusCode)
+ }
+ m := decodeBody(t, resp)
+ if m["error"] != "at_capacity" {
+ t.Errorf("error = %v, want at_capacity", m["error"])
+ }
+ if m["message"] != spaceUnavailableMsg {
+ t.Errorf("message = %v, want the member-facing capacity message", m["message"])
+ }
+}
+
+func TestCloudRerollsOnHostCollision(t *testing.T) {
+ var calls atomic.Int32
+ ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
+ if calls.Add(1) == 1 {
+ writeControlErr(w, http.StatusConflict, "host_exists", "host already exists")
+ return
+ }
+ var req controlCreateReq
+ json.NewDecoder(r.Body).Decode(&req)
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S",
+ })
+ })
+ srv, _ := newTestCloud(t, ctl)
+ tok := mintCaller(t, srv, "home")
+
+ resp := createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("status = %d, want 201 after re-roll", resp.StatusCode)
+ }
+ if calls.Load() != 2 {
+ t.Errorf("control calls = %d, want 2", calls.Load())
+ }
+}
+
+func TestCloudRequesterRequired(t *testing.T) {
+ srv, _ := newTestCloud(t, okControl(t))
+ tok := mintCaller(t, srv, "home")
+ if resp := createSpace(t, srv, tok, ""); resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("empty requester: status = %d, want 400", resp.StatusCode)
+ }
+}
+
+func TestCloudAdminTokenLifecycle(t *testing.T) {
+ srv, _ := newTestCloud(t, okControl(t))
+
+ // Admin surface is gated.
+ req, _ := http.NewRequest("GET", srv.URL+"/api/admin/tokens", nil)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("unauthed list: status %d, want 401", resp.StatusCode)
+ }
+
+ _ = mintCaller(t, srv, "home-a")
+
+ // Listing names the token but never carries its value.
+ req, _ = http.NewRequest("GET", srv.URL+"/api/admin/tokens", nil)
+ req.Header.Set("Authorization", "Bearer admin-secret")
+ resp, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var list struct{ Tokens []CallerToken }
+ json.NewDecoder(resp.Body).Decode(&list)
+ if len(list.Tokens) != 1 || list.Tokens[0].Label != "home-a" || list.Tokens[0].ID == "" {
+ t.Fatalf("tokens = %+v", list.Tokens)
+ }
+
+ // Revoke, then the caller token stops working.
+ req, _ = http.NewRequest("DELETE", srv.URL+"/api/admin/tokens/"+list.Tokens[0].ID, nil)
+ req.Header.Set("Authorization", "Bearer admin-secret")
+ resp2, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp2.Body.Close()
+ if resp2.StatusCode != http.StatusOK {
+ t.Fatalf("revoke: status %d", resp2.StatusCode)
+ }
+}
+
+func TestGenerateSlug(t *testing.T) {
+ labelRe := regexp.MustCompile(`^[a-z]+-[a-z]+-[0-9]{2}$`)
+ for i := 0; i < 200; i++ {
+ slug, err := generateSlug()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !labelRe.MatchString(slug) {
+ t.Fatalf("slug %q does not match adjective-noun-NN", slug)
+ }
+ if reservedSlugs[slug] {
+ t.Fatalf("slug %q is reserved", slug)
+ }
+ if !validHost(slug + ".example.com") {
+ t.Fatalf("slug %q makes an invalid host", slug)
+ }
+ }
+ // No wordlist entry may itself be reserved or collide with sibling services.
+ for _, w := range append(append([]string{}, slugAdjectives...), slugNouns...) {
+ if reservedSlugs[w] {
+ t.Errorf("wordlist entry %q is a reserved slug", w)
+ }
+ }
+}
diff --git a/control.go b/control.go
index 20352c2..d6c7880 100644
--- a/control.go
+++ b/control.go
@@ -22,11 +22,12 @@ import (
// docs/instance-router.md.
// Instance paths are fixed by the systemd units (deploy/systemd/lchat@.service),
-// so the control commands use these constants rather than per-invocation flags
-// that would silently diverge from where the instance actually listens + stores.
-const (
- instanceDataDir = "/data" // <host>.db + <host>.env live here
- instanceSocketDir = "/run/lchat" // <host>.sock lives here
+// so the control commands use these rather than per-invocation flags that would
+// silently diverge from where the instance actually listens + stores. Vars with
+// env overrides (cf. optDir) so tests can point them at temp dirs.
+var (
+ instanceDataDir = envOr("LCHAT_DATA_DIR", "/data") // <host>.db + <host>.env live here
+ instanceSocketDir = envOr("LCHAT_SOCKET_DIR", "/run/lchat") // <host>.sock lives here
)
var hostnameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
@@ -89,59 +90,84 @@ func reorderFlagsFirst(fs *flag.FlagSet, args []string) []string {
return append(flags, positional...)
}
-// runAddInstance: `lchat add-instance <host>` — mint token + env file, start the
-// instance unit, register the route, reload the router.
-func runAddInstance(args []string) {
- fs := flag.NewFlagSet("add-instance", flag.ExitOnError)
- registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
- unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix")
- routerUnit := fs.String("router-unit", envOr("LCHAT_ROUTER_UNIT", "lchat-router"), "router systemd unit to reload")
- adminToken := fs.String("admin-token", "", "import: use this admin token instead of minting a new one (preserves saved admin links when migrating an instance whose DB is already in place)")
- appStoreURL := fs.String("app-store-url", "", "per-instance LCHAT_APP_STORE_URL")
- homeOrigin := fs.String("home-origin", "", "per-instance LCHAT_HOME_ORIGIN")
- pushRelayURL := fs.String("push-relay-url", "", "per-instance LCHAT_PUSH_RELAY_URL")
- pushRelayToken := fs.String("push-relay-token", "", "per-instance LCHAT_PUSH_RELAY_TOKEN")
- _ = fs.Parse(reorderFlagsFirst(fs, args))
+// Sentinel errors so callers (the CLI, controld) can map failures to stable
+// outcomes without parsing message strings.
+var (
+ errHostExists = fmt.Errorf("host already exists")
+ errInvalidHost = fmt.Errorf("invalid host")
+)
- host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+// addInstanceOpts parameterizes one instance creation. Host is required; an
+// empty AdminToken mints a fresh one (a non-empty one imports an existing token,
+// the migration path).
+type addInstanceOpts struct {
+ Host string
+ AdminToken string
+ AppStoreURL string
+ HomeOrigin string
+ PushRelayURL string
+ PushRelayToken string
+ UnitTmpl string // default "lchat@"
+ RouterUnit string // default "lchat-router"
+}
+
+// addInstanceResult is what a successful creation yields. SetupLink may be empty
+// with SetupErr set: the invite mint is best-effort (the admin link still
+// bootstraps the instance), matching the CLI's long-standing behaviour.
+type addInstanceResult struct {
+ Host string
+ AdminToken string
+ SetupLink string
+ SetupErr error
+}
+
+// addInstance is the shared creation core behind `lchat add-instance` and the
+// control daemon (controld.go): mint token + env file, register the route, stage
+// + link the binary, start the unit, health-check, reload the router.
+func addInstance(reg *Registry, opts addInstanceOpts) (*addInstanceResult, error) {
+ host := strings.ToLower(strings.TrimSpace(opts.Host))
if !validHost(host) {
- die("add-instance: invalid host %q (want a hostname like chat.example.com)", host)
+ return nil, fmt.Errorf("%w: %q (want a hostname like chat.example.com)", errInvalidHost, host)
}
socket := filepath.Join(instanceSocketDir, host+".sock")
if err := checkSocketLen(socket); err != nil {
- die("add-instance: %v", err)
+ return nil, fmt.Errorf("%w: %v", errInvalidHost, err)
}
-
- reg, err := openRegistry(*registryPath)
- if err != nil {
- die("add-instance: open registry: %v", err)
+ unitTmpl := opts.UnitTmpl
+ if unitTmpl == "" {
+ unitTmpl = "lchat@"
+ }
+ routerUnit := opts.RouterUnit
+ if routerUnit == "" {
+ routerUnit = "lchat-router"
}
+
if existing, err := reg.get(host); err != nil {
- die("add-instance: %v", err)
+ return nil, err
} else if existing != nil && existing.Status == "ready" {
- die("add-instance: host %q already exists", host)
+ return nil, fmt.Errorf("%w: %q", errHostExists, host)
}
// A prior failed/creating attempt is overwritten on retry.
// The instance stores only the token's hash, so this is the one chance to
// capture the plaintext. Persist it (and per-instance config) to an
// EnvironmentFile the unit reads — that keeps it recoverable via `admin-link`.
- // --admin-token imports an existing token (migration): the instance's boot
- // rewrites admin_token_hash from the env, so passing the old token over a
- // pre-placed migrated DB leaves the hash — and every saved admin link — intact.
- token := *adminToken
+ // An imported AdminToken (migration): the instance's boot rewrites
+ // admin_token_hash from the env, so passing the old token over a pre-placed
+ // migrated DB leaves the hash — and every saved admin link — intact.
+ token := opts.AdminToken
if token == "" {
var err error
if token, err = randToken(); err != nil {
- die("add-instance: token: %v", err)
+ return nil, fmt.Errorf("token: %v", err)
}
}
env := map[string]string{"LCHAT_ADMIN_TOKEN": token}
for k, v := range map[string]string{
- "LCHAT_APP_STORE_URL": *appStoreURL,
- "LCHAT_HOME_ORIGIN": *homeOrigin,
- "LCHAT_PUSH_RELAY_URL": *pushRelayURL,
- "LCHAT_PUSH_RELAY_TOKEN": *pushRelayToken,
+ "LCHAT_APP_STORE_URL": opts.AppStoreURL,
+ "LCHAT_HOME_ORIGIN": opts.HomeOrigin,
+ "LCHAT_PUSH_RELAY_URL": opts.PushRelayURL,
+ "LCHAT_PUSH_RELAY_TOKEN": opts.PushRelayToken,
} {
if v != "" {
env[k] = v
@@ -149,14 +175,14 @@ func runAddInstance(args []string) {
}
envPath := filepath.Join(instanceDataDir, host+".env")
if err := writeEnvFile(envPath, env); err != nil {
- die("add-instance: write env file: %v", err)
+ return nil, fmt.Errorf("write env file: %v", err)
}
dbPath := filepath.Join(instanceDataDir, host+".db")
route := Route{Host: host, Kind: "instance", Socket: socket, DBPath: dbPath, Status: "creating",
- AppStoreURL: *appStoreURL, HomeOrigin: *homeOrigin, PushRelayURL: *pushRelayURL}
+ AppStoreURL: opts.AppStoreURL, HomeOrigin: opts.HomeOrigin, PushRelayURL: opts.PushRelayURL}
if err := reg.upsert(route); err != nil {
- die("add-instance: register: %v", err)
+ return nil, fmt.Errorf("register: %v", err)
}
// The instance's unit execs /opt/lchat/live/<host>; stage the running control
@@ -164,42 +190,82 @@ func runAddInstance(args []string) {
// A fresh instance therefore runs whatever build the operator's `lchat` is.
ver, err := ensureSelfStaged()
if err != nil {
- die("add-instance: stage binary: %v", err)
+ return nil, fmt.Errorf("stage binary: %v", err)
}
if err := pointLive(host, ver); err != nil {
- die("add-instance: point live symlink: %v", err)
+ return nil, fmt.Errorf("point live symlink: %v", err)
}
route.Version = ver
- unit := *unitTmpl + host
+ unit := unitTmpl + host
if err := systemctl("enable", "--now", unit); err != nil {
markInstanceFailed(reg, host, unit, "start: "+err.Error())
- die("add-instance: start %s: %v", unit, err)
+ return nil, fmt.Errorf("start %s: %v", unit, err)
}
if err := waitHealthy(socket, 15*time.Second); err != nil {
markInstanceFailed(reg, host, unit, "health: "+err.Error())
- die("add-instance: %v", err)
+ return nil, err
}
route.Status = "ready"
if err := reg.upsert(route); err != nil {
- die("add-instance: mark ready: %v", err)
+ return nil, fmt.Errorf("mark ready: %v", err)
}
- if err := systemctl("reload", *routerUnit); err != nil {
- die("add-instance: reload %s: %v (route added; run: systemctl reload %s)", *routerUnit, err, *routerUnit)
+ if err := systemctl("reload", routerUnit); err != nil {
+ return nil, fmt.Errorf("reload %s: %v (route added; run: systemctl reload %s)", routerUnit, err, routerUnit)
}
- fmt.Printf("added %s\n", host)
// The first-user setup link: claiming it makes you the instance's admin and
// lands you in the chat view, where the Eleven setup conversation takes over.
// Minting can only fail against an unhealthy instance (we just health-checked),
- // but if it does the admin link below still bootstraps the old way.
- if link, err := mintSetupInvite(socketClient(socket), "http://lchat", "https://"+host, token); err != nil {
- fmt.Printf(" (setup link unavailable: %v — mint later: lchat setup-invite %s)\n", err, host)
+ // but if it does the admin link still bootstraps the old way.
+ res := &addInstanceResult{Host: host, AdminToken: token}
+ res.SetupLink, res.SetupErr = mintSetupInvite(socketClient(socket), "http://lchat", "https://"+host, token)
+ if res.SetupErr != nil {
+ res.SetupLink = ""
+ }
+ return res, nil
+}
+
+// runAddInstance: `lchat add-instance <host>` — mint token + env file, start the
+// instance unit, register the route, reload the router.
+func runAddInstance(args []string) {
+ fs := flag.NewFlagSet("add-instance", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix")
+ routerUnit := fs.String("router-unit", envOr("LCHAT_ROUTER_UNIT", "lchat-router"), "router systemd unit to reload")
+ adminToken := fs.String("admin-token", "", "import: use this admin token instead of minting a new one (preserves saved admin links when migrating an instance whose DB is already in place)")
+ appStoreURL := fs.String("app-store-url", "", "per-instance LCHAT_APP_STORE_URL")
+ homeOrigin := fs.String("home-origin", "", "per-instance LCHAT_HOME_ORIGIN")
+ pushRelayURL := fs.String("push-relay-url", "", "per-instance LCHAT_PUSH_RELAY_URL")
+ pushRelayToken := fs.String("push-relay-token", "", "per-instance LCHAT_PUSH_RELAY_TOKEN")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("add-instance: open registry: %v", err)
+ }
+ res, err := addInstance(reg, addInstanceOpts{
+ Host: fs.Arg(0),
+ AdminToken: *adminToken,
+ AppStoreURL: *appStoreURL,
+ HomeOrigin: *homeOrigin,
+ PushRelayURL: *pushRelayURL,
+ PushRelayToken: *pushRelayToken,
+ UnitTmpl: *unitTmpl,
+ RouterUnit: *routerUnit,
+ })
+ if err != nil {
+ die("add-instance: %v", err)
+ }
+
+ fmt.Printf("added %s\n", res.Host)
+ if res.SetupErr != nil {
+ fmt.Printf(" (setup link unavailable: %v — mint later: lchat setup-invite %s)\n", res.SetupErr, res.Host)
} else {
- fmt.Printf(" get started (one-time link, makes you the admin):\n %s\n", link)
+ fmt.Printf(" get started (one-time link, makes you the admin):\n %s\n", res.SetupLink)
}
- fmt.Printf(" admin token link: https://%s/admin#token=%s\n (recover later: lchat admin-link %s / lchat setup-invite %s)\n", host, token, host, host)
+ fmt.Printf(" admin token link: https://%s/admin#token=%s\n (recover later: lchat admin-link %s / lchat setup-invite %s)\n", res.Host, res.AdminToken, res.Host, res.Host)
}
// runAddService: `lchat add-service <host> --socket <path>` — front an
@@ -519,7 +585,10 @@ func waitReachable(socket string, timeout time.Duration) error {
// --- helpers ---------------------------------------------------------------
-func systemctl(args ...string) error {
+// systemctl is a var so tests can stand in for systemd (e.g. bind the instance
+// socket themselves when they see "enable --now"); everything else in the
+// creation path — env files, registry, symlinks, health checks — runs for real.
+var systemctl = func(args ...string) error {
out, err := exec.Command("systemctl", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
diff --git a/controld.go b/controld.go
new file mode 100644
index 0000000..59173b9
--- /dev/null
+++ b/controld.go
@@ -0,0 +1,265 @@
+package main
+
+// The control daemon: the box's localhost-only provisioning surface, anticipated
+// in docs/instance-router.md as "the god surface … never exposed on a served
+// hostname". It executes the same creation core as `lchat add-instance`
+// (addInstance in control.go) on behalf of the unprivileged cloud provisioner
+// service (cloud.go), which is the public, policy-enforcing face at
+// cloud.elevenmessenger.com. The split exists because creation needs root
+// (systemctl, /opt/lchat writes) while everything network-facing runs sandboxed
+// as the lchat user. See docs/cloud-provisioner.md.
+//
+// Auth: every unit on the box shares the lchat uid, so socket permissions can't
+// distinguish the cloud service from a compromised instance. The gate is a
+// bearer token in a root-only file (/etc/lchat/control.token, 0600) that the
+// cloud unit alone receives via systemd LoadCredential.
+
+import (
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/user"
+ "strconv"
+ "sync"
+ "syscall"
+ "time"
+)
+
+// controlTokenPath is root-owned (0600): readable by controld and loadable into
+// the cloud unit by systemd, but never by anything running as the lchat user.
+const controlTokenPath = "/etc/lchat/control.token"
+
+// freeDiskBytes reports the free space where instance DBs live. A var so tests
+// can simulate a full disk without filling one.
+var freeDiskBytes = func(path string) (uint64, error) {
+ var st syscall.Statfs_t
+ if err := syscall.Statfs(path, &st); err != nil {
+ return 0, err
+ }
+ return uint64(st.Bavail) * uint64(st.Bsize), nil
+}
+
+// controld is the daemon's state: the registry it counts and creates into, the
+// capacity policy, and the token gate. Creation is serialized (mu) so the
+// capacity check and the create it guards are atomic.
+type controld struct {
+ reg *Registry
+ tokenHash string
+ maxInstances int
+ minFreeGB int
+ mu sync.Mutex
+}
+
+// controlCreateReq mirrors addInstanceOpts across the socket. AdminToken is the
+// migration path (import an existing token) — unused by the cloud provisioner,
+// kept so the control API can do everything the CLI can.
+type controlCreateReq struct {
+ Host string `json:"host"`
+ AdminToken string `json:"admin_token,omitempty"`
+ AppStoreURL string `json:"app_store_url,omitempty"`
+ HomeOrigin string `json:"home_origin,omitempty"`
+ PushRelayURL string `json:"push_relay_url,omitempty"`
+ PushRelayToken string `json:"push_relay_token,omitempty"`
+}
+
+// controlErr is the wire error shape: a stable machine code plus a human
+// message, so callers never parse message strings.
+type controlErr struct {
+ Code string `json:"error"`
+ Message string `json:"message"`
+}
+
+func writeControlErr(w http.ResponseWriter, status int, code, msg string) {
+ writeJSON(w, status, controlErr{Code: code, Message: msg})
+}
+
+func (c *controld) handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /instances", c.auth(c.createHandler))
+ return mux
+}
+
+// auth gates a handler behind the control token (constant-time hash compare,
+// same pattern as the push relay's admin gate).
+func (c *controld) auth(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !tokenHashEqual(bearerTokenHeader(r), c.tokenHash) {
+ writeControlErr(w, http.StatusUnauthorized, "unauthorized", "bad or missing control token")
+ return
+ }
+ next(w, r)
+ }
+}
+
+// atCapacity applies the box capacity policy: an instance-count ceiling and a
+// free-disk floor (disk, not RAM, is the binding resource — see
+// docs/instance-router.md § Sizing). It returns a human reason when full.
+func (c *controld) atCapacity() (string, error) {
+ routes, err := c.reg.list()
+ if err != nil {
+ return "", err
+ }
+ n := 0
+ for _, r := range routes {
+ if r.Kind == "instance" {
+ n++
+ }
+ }
+ if n >= c.maxInstances {
+ return fmt.Sprintf("instance limit reached (%d/%d)", n, c.maxInstances), nil
+ }
+ free, err := freeDiskBytes(instanceDataDir)
+ if err != nil {
+ return "", err
+ }
+ if free < uint64(c.minFreeGB)<<30 {
+ return fmt.Sprintf("low disk (%.1f GB free, floor %d GB)", float64(free)/(1<<30), c.minFreeGB), nil
+ }
+ return "", nil
+}
+
+func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) {
+ var req controlCreateReq
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+ return
+ }
+
+ // One create at a time: the capacity check and the creation it authorizes
+ // must not interleave with another create.
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ reason, err := c.atCapacity()
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "create_failed", err.Error())
+ return
+ }
+ if reason != "" {
+ log.Printf("controld: refusing %s: %s", req.Host, reason)
+ writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", reason)
+ return
+ }
+
+ res, err := addInstance(c.reg, addInstanceOpts{
+ Host: req.Host,
+ AdminToken: req.AdminToken,
+ AppStoreURL: req.AppStoreURL,
+ HomeOrigin: req.HomeOrigin,
+ PushRelayURL: req.PushRelayURL,
+ PushRelayToken: req.PushRelayToken,
+ })
+ switch {
+ case errors.Is(err, errInvalidHost):
+ writeControlErr(w, http.StatusBadRequest, "invalid_host", err.Error())
+ return
+ case errors.Is(err, errHostExists):
+ writeControlErr(w, http.StatusConflict, "host_exists", err.Error())
+ return
+ case err != nil:
+ log.Printf("controld: create %s: %v", req.Host, err)
+ writeControlErr(w, http.StatusInternalServerError, "create_failed", err.Error())
+ return
+ }
+ log.Printf("controld: created %s", res.Host)
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": res.Host,
+ "url": "https://" + res.Host,
+ "setup_link": res.SetupLink, // "" if the mint failed; admin_link still bootstraps
+ "admin_link": "https://" + res.Host + "/admin#token=" + res.AdminToken,
+ })
+}
+
+// tokenHashEqual compares a presented token against a stored hash in constant
+// time (cf. the push relay's admin gate).
+func tokenHashEqual(token, wantHash string) bool {
+ return token != "" && subtle.ConstantTimeCompare([]byte(hashToken(token)), []byte(wantHash)) == 1
+}
+
+// bearerTokenHeader pulls the token out of "Authorization: Bearer <t>".
+func bearerTokenHeader(r *http.Request) string {
+ const p = "Bearer "
+ h := r.Header.Get("Authorization")
+ if len(h) > len(p) && h[:len(p)] == p {
+ return h[len(p):]
+ }
+ return ""
+}
+
+// runControld: `lchat controld` — serve the control API on a local unix socket.
+// Runs as root (the lchat-control.service unit): it's the one process allowed to
+// call systemctl and write the version store, and it is never routed publicly.
+func runControld(args []string) {
+ fs := flag.NewFlagSet("controld", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ socketPath := fs.String("socket", envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock"), "unix socket to serve the control API on")
+ tokenFile := fs.String("token-file", envOr("LCHAT_CONTROL_TOKEN_FILE", controlTokenPath), "file holding the control bearer token")
+ maxInstances := fs.Int("max-instances", envInt("LCHAT_MAX_INSTANCES", 40), "refuse creation beyond this many instances on the box")
+ minFreeGB := fs.Int("min-free-gb", envInt("LCHAT_MIN_FREE_GB", 5), "refuse creation when the data disk has less than this free (GB)")
+ _ = fs.Parse(args)
+
+ tok, err := os.ReadFile(*tokenFile)
+ if err != nil {
+ die("controld: read token file: %v (run `lchat bootstrap` to mint it)", err)
+ }
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("controld: open registry: %v", err)
+ }
+ c := &controld{
+ reg: reg,
+ tokenHash: hashToken(string(trimNL(tok))),
+ maxInstances: *maxInstances,
+ minFreeGB: *minFreeGB,
+ }
+
+ if err := os.Remove(*socketPath); err != nil && !os.IsNotExist(err) {
+ die("controld: remove stale socket: %v", err)
+ }
+ ln, err := net.Listen("unix", *socketPath)
+ if err != nil {
+ die("controld: listen %s: %v", *socketPath, err)
+ }
+ // 0660 + group lchat: the sandboxed services can connect (auth is the token,
+ // not the socket), nobody else on the box can even dial.
+ if err := os.Chmod(*socketPath, 0o660); err != nil {
+ die("controld: chmod socket: %v", err)
+ }
+ if g, err := user.LookupGroup(serviceUser); err == nil {
+ if gid, err := strconv.Atoi(g.Gid); err == nil {
+ _ = os.Chown(*socketPath, -1, gid) // best-effort; needs root
+ }
+ }
+
+ log.Printf("controld: serving on %s (registry %s, max %d instances, %d GB disk floor)",
+ *socketPath, *registryPath, *maxInstances, *minFreeGB)
+ srv := &http.Server{Handler: c.handler(), ReadHeaderTimeout: 10 * time.Second}
+ if err := srv.Serve(ln); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// trimNL strips a single trailing newline a hand-edited token file may carry.
+func trimNL(b []byte) []byte {
+ for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r') {
+ b = b[:len(b)-1]
+ }
+ return b
+}
+
+// envInt reads an integer env var, falling back on absent or malformed values.
+func envInt(key string, def int) int {
+ if v := os.Getenv(key); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ return n
+ }
+ }
+ return def
+}
diff --git a/controld_test.go b/controld_test.go
new file mode 100644
index 0000000..c882705
--- /dev/null
+++ b/controld_test.go
@@ -0,0 +1,226 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// controlTestEnv redirects every path the creation core touches into temp dirs
+// and replaces systemd with a fake that binds the instance's unix socket itself
+// (serving the health check and the setup-invite mint), so addInstance runs its
+// real state machine — env files, registry, symlinks, health checks — with no
+// systemd and no /data.
+func controlTestEnv(t *testing.T) *Registry {
+ t.Helper()
+
+ oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk := instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes
+ t.Cleanup(func() {
+ instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes = oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk
+ })
+
+ instanceDataDir = t.TempDir()
+ optDir = t.TempDir()
+ // The socket dir must stay under the ~104-byte sun_path limit, and t.TempDir
+ // embeds the test name — use a plain short temp dir instead.
+ sockDir, err := os.MkdirTemp("", "lcs")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(sockDir) })
+ instanceSocketDir = sockDir
+
+ // Stage a stub file, not the multi-MB test binary.
+ stub := filepath.Join(t.TempDir(), "stub")
+ if err := os.WriteFile(stub, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ selfPath = func() (string, error) { return stub, nil }
+
+ freeDiskBytes = func(string) (uint64, error) { return 100 << 30, nil }
+
+ // The systemd stand-in: "enable --now lchat@<host>" binds the instance socket
+ // and answers like a healthy instance; everything else is a no-op.
+ systemctl = func(args ...string) error {
+ if len(args) == 3 && args[0] == "enable" && args[1] == "--now" && strings.HasPrefix(args[2], "lchat@") {
+ host := strings.TrimPrefix(args[2], "lchat@")
+ ln, err := net.Listen("unix", filepath.Join(instanceSocketDir, host+".sock"))
+ if err != nil {
+ return err
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
+ mux.HandleFunc("POST /api/admin/key-invites", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, 200, map[string]any{"ok": true})
+ })
+ srv := &http.Server{Handler: mux}
+ go srv.Serve(ln)
+ t.Cleanup(func() { srv.Close() })
+ }
+ return nil
+ }
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return reg
+}
+
+func newTestControld(t *testing.T, reg *Registry, maxInstances int) (*httptest.Server, string) {
+ t.Helper()
+ token := "test-control-token"
+ c := &controld{reg: reg, tokenHash: hashToken(token), maxInstances: maxInstances, minFreeGB: 5}
+ srv := httptest.NewServer(c.handler())
+ t.Cleanup(srv.Close)
+ return srv, token
+}
+
+func controlCreate(t *testing.T, srv *httptest.Server, token, host string) *http.Response {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{"host": host})
+ req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body))
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func decodeBody(t *testing.T, resp *http.Response) map[string]any {
+ t.Helper()
+ var m map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
+ t.Fatalf("decode body: %v", err)
+ }
+ return m
+}
+
+func TestControldRequiresToken(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, _ := newTestControld(t, reg, 40)
+
+ if resp := controlCreate(t, srv, "", "a.example.com"); resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("no token: status = %d, want 401", resp.StatusCode)
+ }
+ if resp := controlCreate(t, srv, "wrong", "a.example.com"); resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("bad token: status = %d, want 401", resp.StatusCode)
+ }
+}
+
+func TestControldCreatesInstance(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 40)
+
+ resp := controlCreate(t, srv, token, "new.example.com")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("status = %d, want 201", resp.StatusCode)
+ }
+ m := decodeBody(t, resp)
+ if m["host"] != "new.example.com" || m["url"] != "https://new.example.com" {
+ t.Errorf("host/url = %v/%v", m["host"], m["url"])
+ }
+ setup, _ := m["setup_link"].(string)
+ if !strings.HasPrefix(setup, "https://new.example.com/join/setup#") {
+ t.Errorf("setup_link = %q", setup)
+ }
+ admin, _ := m["admin_link"].(string)
+ if !strings.HasPrefix(admin, "https://new.example.com/admin#token=") {
+ t.Errorf("admin_link = %q", admin)
+ }
+
+ // The route is registered ready and the env file holds the same token.
+ rt, err := reg.get("new.example.com")
+ if err != nil || rt == nil || rt.Status != "ready" || rt.Kind != "instance" {
+ t.Fatalf("route = %+v, err %v", rt, err)
+ }
+ env, err := readEnvFile(filepath.Join(instanceDataDir, "new.example.com.env"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasSuffix(admin, env["LCHAT_ADMIN_TOKEN"]) {
+ t.Error("admin_link token does not match the env file")
+ }
+}
+
+func TestControldAtCapacity(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 1)
+
+ if err := reg.upsert(Route{Host: "full.example.com", Kind: "instance", Socket: "/x.sock", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ resp := controlCreate(t, srv, token, "new.example.com")
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", resp.StatusCode)
+ }
+ if m := decodeBody(t, resp); m["error"] != "at_capacity" {
+ t.Errorf("error = %v, want at_capacity", m["error"])
+ }
+}
+
+func TestControldServiceRoutesDontCountTowardCapacity(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 1)
+
+ // A 'service' route (home, push, cloud) must not eat instance capacity.
+ if err := reg.upsert(Route{Host: "home.example.com", Kind: "service", Socket: "/h.sock", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if resp := controlCreate(t, srv, token, "new.example.com"); resp.StatusCode != http.StatusCreated {
+ t.Errorf("status = %d, want 201", resp.StatusCode)
+ }
+}
+
+func TestControldDiskFloor(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 40)
+ freeDiskBytes = func(string) (uint64, error) { return 1 << 30, nil } // 1 GB free, floor is 5
+
+ resp := controlCreate(t, srv, token, "new.example.com")
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", resp.StatusCode)
+ }
+ if m := decodeBody(t, resp); m["error"] != "at_capacity" {
+ t.Errorf("error = %v, want at_capacity", m["error"])
+ }
+}
+
+func TestControldHostExists(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 40)
+
+ if err := reg.upsert(Route{Host: "taken.example.com", Kind: "instance", Socket: "/x.sock", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ resp := controlCreate(t, srv, token, "taken.example.com")
+ if resp.StatusCode != http.StatusConflict {
+ t.Fatalf("status = %d, want 409", resp.StatusCode)
+ }
+ if m := decodeBody(t, resp); m["error"] != "host_exists" {
+ t.Errorf("error = %v, want host_exists", m["error"])
+ }
+}
+
+func TestControldInvalidHost(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 40)
+
+ resp := controlCreate(t, srv, token, "Bad Host!")
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", resp.StatusCode)
+ }
+ if m := decodeBody(t, resp); m["error"] != "invalid_host" {
+ t.Errorf("error = %v, want invalid_host", m["error"])
+ }
+}
diff --git a/deploy/systemd/lchat-cloud.service b/deploy/systemd/lchat-cloud.service
new file mode 100644
index 0000000..3b13332
--- /dev/null
+++ b/deploy/systemd/lchat-cloud.service
@@ -0,0 +1,47 @@
+[Unit]
+Description=lchat cloud provisioner (self-serve space creation)
+After=network-online.target lchat-control.service
+Wants=network-online.target
+
+[Service]
+# /data/cloud.env holds LCHAT_ADMIN_TOKEN (gates caller-token minting),
+# LCHAT_CLOUD_BASE_DOMAIN, and optional LCHAT_CLOUD_QUOTA.
+EnvironmentFile=/data/cloud.env
+# The control token is root-only on disk; LoadCredential hands it to this unit
+# alone — a compromised instance (same lchat uid) can't read it.
+LoadCredential=control-token:/etc/lchat/control.token
+ExecStart=/opt/lchat/lchat cloud --socket /run/lchat/cloud.sock --db /data/cloud.db
+Restart=always
+RestartSec=1
+
+# --- hardening (see docs/instance-router.md § Hardening) ---
+User=lchat
+Group=lchat
+CapabilityBoundingSet=
+NoNewPrivileges=yes
+ProtectSystem=strict
+ProtectHome=yes
+ReadWritePaths=/data /run/lchat
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectControlGroups=yes
+ProtectProc=invisible
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+SystemCallFilter=@system-service
+SystemCallErrorNumber=EPERM
+SystemCallArchitectures=native
+MemoryMax=256M
+TasksMax=256
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/systemd/lchat-control.service b/deploy/systemd/lchat-control.service
new file mode 100644
index 0000000..d8d40b7
--- /dev/null
+++ b/deploy/systemd/lchat-control.service
@@ -0,0 +1,35 @@
+[Unit]
+Description=lchat control daemon (local provisioning API)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+# Optional overrides: LCHAT_MAX_INSTANCES / LCHAT_MIN_FREE_GB (capacity policy).
+EnvironmentFile=-/data/control.env
+ExecStart=/opt/lchat/lchat controld
+Restart=always
+RestartSec=1
+
+# --- hardening (see docs/instance-router.md § Hardening) ---
+# Runs as ROOT deliberately: this is the one process allowed to call systemctl
+# and write the version store (/opt/lchat), which is why it serves a local unix
+# socket only and is never routed publicly. NoNewPrivileges and the syscall
+# filter are omitted for the systemctl child; the rest of the sandbox applies.
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+MemoryMax=256M
+TasksMax=64
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/systemd/lchat-router.service b/deploy/systemd/lchat-router.service
index 6943e07..1547f47 100644
--- a/deploy/systemd/lchat-router.service
+++ b/deploy/systemd/lchat-router.service
@@ -4,6 +4,9 @@ After=network-online.target
Wants=network-online.target
[Service]
+# Optional overrides, e.g. LCHAT_FALLBACK_URL (where unknown hosts redirect
+# instead of 404 — pairs with a wildcard DNS record; see cloud-provisioner.md).
+EnvironmentFile=-/data/router.env
ExecStart=/opt/lchat/lchat router --registry /data/registry.db --acme-cache /data/acme
# add-instance triggers `systemctl reload lchat-router` to re-read the route table.
ExecReload=/bin/kill -HUP $MAINPID
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
new file mode 100644
index 0000000..8ee45ed
--- /dev/null
+++ b/docs/cloud-provisioner.md
@@ -0,0 +1,176 @@
+# 🤖 The cloud provisioner — self-serve space creation
+
+Status: **shipped** (server side). The app UI ("+ → Sign in → new space") is
+deliberately not built yet; this doc covers everything behind it.
+
+## The idea
+
+Creating an instance used to be an operator SSHing to the box and running
+`lchat add-instance`. The end state we want: a member signed in to their home
+server taps **+** in the app and gets a fresh, fully-isolated Eleven space —
+address, TLS, admin-granting setup link — in a few seconds. Two pieces make
+that possible:
+
+- **The cloud provisioner** (`lchat cloud`, e.g. at `cloud.elevenmessenger.com`)
+ can create instances on its box, gated by caller tokens, per-user quotas, and
+ a box capacity limit. It's a subcommand of the same binary that runs the
+ router, so **any host running Eleven can enable it** and get multi-instance
+ auto-provisioning — it isn't infrastructure only we can run.
+- **The home server** gains one endpoint (`POST /api/spaces`): a signed-in user
+ asks *their* home server for a space; home forwards the request to the cloud
+ provisioner it's paired with and relays the result.
+
+```
+app ── link token ──▶ home ── caller token ──▶ lchat cloud ── control token ──▶ lchat controld ──▶ systemctl…
+ (lchat-home unit) (lchat user, sandboxed, (root; local unix socket
+ public via the router) /run/lchat/control.sock)
+```
+
+The client then opens the returned **setup link** (`/join/setup#<T>`), claims
+it, becomes the space's admin, and runs the normal setup conversation. Adding
+the new space to the user's encrypted backup is the client's move, exactly like
+any other server — the blob stays content-blind.
+
+## The privilege split
+
+Creating an instance needs root (systemctl, writes to `/opt/lchat`), but
+everything network-facing on the box runs as the unprivileged `lchat` user
+under a strict systemd sandbox. So provisioning is two processes:
+
+- **`lchat controld`** (`lchat-control.service`, **root**) — the god surface
+ `instance-router.md` reserved: `POST /instances` on a local unix socket,
+ never routed publicly. It executes the same creation core as
+ `lchat add-instance` (`addInstance` in `control.go`), serialized, and is the
+ single authority on **capacity** (below).
+- **`lchat cloud`** (`lchat-cloud.service`, `lchat` user, sandboxed) — the
+ public policy layer: caller tokens, per-user quotas, friendly slug
+ generation. It holds no root powers; everything it can do to the box, it does
+ by asking controld.
+
+### Who holds which token
+
+| Token | Lives | Grants |
+|---|---|---|
+| control token | `/etc/lchat/control.token`, **root-only 0600**; handed to the cloud unit alone via systemd `LoadCredential` | create instances on the box |
+| cloud admin token (`LCHAT_ADMIN_TOKEN`) | `/data/cloud.env` | mint/list/revoke caller tokens |
+| caller token | the paired home server's env (`LCHAT_CLOUD_TOKEN`) | provision spaces, bounded by quota |
+| device link token | the user's device | ask *their* home server for a space |
+
+The root-only control token matters because **every service on the box shares
+the `lchat` uid** — socket permissions can't distinguish the cloud service from
+a compromised instance. A compromised instance can read `/data` (a known,
+accepted box-level exposure), but it cannot read the control token, so it
+cannot mint instances or reach the creation path. `lchat bootstrap` mints the
+token idempotently.
+
+## Capacity: the box says no before it falls over
+
+controld enforces two limits, checked atomically with each create
+(`LCHAT_MAX_INSTANCES`, default **40**; `LCHAT_MIN_FREE_GB`, default **5**):
+
+- **Instance ceiling.** The educated guess for a 4 GB box: the OS, router,
+ push, home, and the existing fleet use ~1 GB; an idle instance is ~25–40 MB
+ (no server-side crypto or media work, by design), so 40 spaces ≈ 1.4 GB with
+ ~1.5 GB headroom for spikes. When Go bot sidecars land (~25 MB each, vs
+ ~120 MB for today's Node sidecar), 40 spaces *with* bots still fits.
+- **Disk floor.** Disk, not RAM, is the long-run binding resource (attachments
+ are blobs in SQLite), so creation also refuses when `/data` has less than the
+ floor free.
+
+Past either limit the API answers `503 {error: "at_capacity"}` and the cloud
+layer relays the member-facing message: *"We can't create a new Eleven space
+right now — please try again later."* Clients show it verbatim and stay calm.
+
+## Addresses
+
+Spaces get generated friendly slugs — `amber-fox-42.<base-domain>` — from small
+embedded wordlists (collisions re-roll; a reserved blocklist keeps sibling
+names like `home`/`push`/`cloud` unmintable). The display name of the group is
+set in the setup conversation; the hostname is just an address, and hostnames
+are public in certificate-transparency logs regardless. User-chosen slugs (or
+custom domains) are a possible later feature, deliberately not v1.
+
+The base domain (`LCHAT_CLOUD_BASE_DOMAIN`) needs **wildcard DNS** pointing at
+the box; certs then issue lazily per host on first hit, as usual.
+
+A wildcard record also means typo'd or retired subdomains resolve to the
+router. Set the router's `LCHAT_FALLBACK_URL` (e.g. the apex marketing site) so
+those redirect somewhere useful instead of 404ing — with the caveat that over
+HTTPS an unknown host fails TLS before any redirect can happen (no cert is ever
+minted for it); the redirect covers `:80` and hosts whose cert outlived their
+route. Browsers that try https first still show a TLS error for garbage
+subdomains until the router grows wildcard-cert (DNS-01) support.
+
+## API
+
+### cloud (`lchat cloud`)
+
+```
+POST /api/instances bearer: caller token {requester: <opaque id>}
+ 201 {host, url, setup_link}
+ 409 {error: quota_exceeded} per (caller, requester) quota, default 3
+ 503 {error: at_capacity, message} the box is full
+GET /api/instances?requester=… this requester's spaces
+POST /api/admin/tokens bearer: admin token {label} → {id, token} (shown once)
+GET /api/admin/tokens list (never the token values)
+DELETE /api/admin/tokens/{id} revoke
+```
+
+### controld (`lchat controld`, local socket only)
+
+```
+POST /instances bearer: control token {host, admin_token?, …env fields}
+ 201 {host, url, setup_link, admin_link}
+ 400 invalid_host | 409 host_exists | 503 at_capacity | 500 create_failed
+```
+
+### home
+
+```
+POST /api/spaces bearer: device link token
+ → relays the cloud response (201 / 409 / 503 shapes above)
+ 501 {error: unavailable} this home server has no provisioner configured
+```
+
+Home's endpoint is link-token-authed (creating a space is additive, not
+destructive, so no fresh passkey ceremony) and rate-limited per IP; the real
+abuse bound is the provisioner's quota.
+
+## What this changes about who-sees-what
+
+Provisioning is the one place the home server (and the provisioner's ledger)
+learns a relationship between a user and an instance hostname — `user X
+provisioned host Y`. That's **operational metadata, held outside the blob**,
+and it's deliberate: quotas need a ledger. The requester id is the home
+server's random opaque uid (no email, no name), the blob stays content-blind,
+and a user who never provisions never appears in the ledger. Members who join
+by invite are invisible here, unchanged.
+
+## Enabling it on a box
+
+```sh
+lchat bootstrap # installs units, mints /etc/lchat/control.token
+systemctl enable --now lchat-control
+cat > /data/cloud.env <<EOF # then: chown lchat:lchat, chmod 600
+LCHAT_ADMIN_TOKEN=<mint one>
+LCHAT_CLOUD_BASE_DOMAIN=<domain with wildcard DNS at this box>
+EOF
+systemctl enable --now lchat-cloud
+lchat add-service cloud.<domain> --socket /run/lchat/cloud.sock
+# mint the home server's caller token:
+curl -sX POST https://cloud.<domain>/api/admin/tokens \
+ -H "Authorization: Bearer $ADMIN" -d '{"label":"home.<domain>"}'
+# hand it to home: LCHAT_CLOUD_URL + LCHAT_CLOUD_TOKEN in /data/home.env, restart lchat-home
+```
+
+## Phase 2 (designed for, not built)
+
+- **App UI** — "+ → Sign in (home.elevenmessenger.com) → new space", and the
+ server list syncing/sorting through the existing encrypted blob.
+- **Bot sidecars on provisioned spaces** — once the Go bot runtime
+ (`lchat bot`) lands; controld grows an attach-bot step.
+- **Reaper for never-claimed spaces** — setup invites already expire after 7
+ days; a zero-member space older than N days could be reclaimed so abandoned
+ creates don't eat the cap.
+- **Delete/deactivate via the API; multi-box placement** — a cloud front
+ choosing among several router boxes.
diff --git a/docs/instance-router.md b/docs/instance-router.md
index 66d4966..9297e26 100644
--- a/docs/instance-router.md
+++ b/docs/instance-router.md
@@ -75,9 +75,20 @@ One binary, three roles (subcommands):
`atomic.Pointer` so reloads and deploys swap it without dropping a connection.
WebSocket upgrades pass through (Go ≥1.12). A backend is usually one instance —
see *Backends*, below.
-- **Control API** — bound to a **localhost-only** unix socket, superadmin-gated;
- never exposed on a served hostname. This is the god surface (it can create
- instances and read admin links), so it stays off the public interface.
+- **Unknown hosts** — 404 by default; with `--fallback-url` (env
+ `LCHAT_FALLBACK_URL`, e.g. the apex marketing site) they 302 there instead, so
+ under a wildcard DNS record a typo'd or retired subdomain lands somewhere
+ useful. Scope caveat: on `:80` this always works, but over HTTPS it only helps
+ hosts that still *have* a cert (e.g. a removed instance) — a never-served host
+ fails the TLS handshake before any redirect, because `HostPolicy` refuses to
+ mint certs for unknown hosts (anything else would let strangers burn the ACME
+ rate limit). Full coverage needs the wildcard-cert option below.
+- **Control API** — shipped as `lchat controld` (`lchat-control.service`): a
+ local-only unix socket (`/run/lchat/control.sock`), gated by a root-only
+ bearer token, never exposed on a served hostname. This is the god surface (it
+ creates instances), so it stays off the public interface; its one consumer is
+ the cloud provisioner (`lchat cloud`), the public, unprivileged policy layer
+ for self-serve space creation. Full design: `cloud-provisioner.md`.
Threat model is unchanged: TLS terminates at the router, but message bodies are
already E2E ciphertext — the router sees exactly what the app sees, never
@@ -499,6 +510,8 @@ touching anything real.
- `lchat admin-link <host>` — recover the admin link from the instance DB (no
platform env lookup needed).
- `lchat instances` — the status table (now with a VERSION column).
+- `lchat controld` / `lchat cloud` — the local control daemon + the public
+ self-serve provisioner built on it (see `cloud-provisioner.md`).
Operator specifics — box addresses, DNS/record ids, the concrete migration
runbook — live in `CLAUDE.local.md`, not here.
diff --git a/home/main.go b/home/main.go
index 7cf0cf3..b1cf89f 100644
--- a/home/main.go
+++ b/home/main.go
@@ -44,7 +44,10 @@ type Server struct {
sessions *sessionStore
// limiter rate-limits the ceremony begin endpoints per IP — the abuse
// surface of an open, anonymous registration endpoint.
- limiter *rateLimiter
+ limiter *rateLimiter
+ // cloud is the optional space provisioner client (see provision.go); nil
+ // means this home server doesn't offer space creation.
+ cloud *cloudProvisioner
page []byte
enrollPage []byte
restorePage []byte
@@ -55,6 +58,10 @@ func main() {
dbPath := flag.String("db", envOr("LCHAT_HOME_DB", "home.db"), "path to SQLite database file (env: LCHAT_HOME_DB)")
origin := flag.String("origin", envOr("LCHAT_HOME_ORIGIN", "http://localhost:1748"),
"public origin, e.g. https://home.elevenmessenger.com (env: LCHAT_HOME_ORIGIN) — WebAuthn RP identity")
+ cloudURL := flag.String("cloud-url", os.Getenv("LCHAT_CLOUD_URL"),
+ "space provisioner base URL, e.g. https://cloud.elevenmessenger.com (env: LCHAT_CLOUD_URL); empty disables space creation")
+ cloudToken := flag.String("cloud-token", os.Getenv("LCHAT_CLOUD_TOKEN"),
+ "caller token for the space provisioner (env: LCHAT_CLOUD_TOKEN)")
flag.Parse()
db, err := openDB(*dbPath)
@@ -65,6 +72,9 @@ func main() {
if err != nil {
log.Fatalf("server: %v", err)
}
+ if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil {
+ log.Printf("space provisioning via %s", s.cloud.url)
+ }
log.Printf("home listening on %s (db: %s, origin: %s)", *addr, *dbPath, *origin)
if err := serve(*addr, s.handler()); err != nil {
log.Fatal(err)
@@ -162,6 +172,13 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("PUT /api/blob", s.putBlobHandler)
mux.HandleFunc("DELETE /api/blob", s.deleteBlobHandler)
+ // Space provisioning (optional: 501 unless a cloud provisioner is
+ // configured). Link-token auth + rate limit; the provisioner adds a quota.
+ // /api/features lets the page hide the "create a space" UI when this home
+ // server doesn't offer it (public — it reveals only whether the feature is on).
+ mux.HandleFunc("GET /api/features", s.featuresHandler)
+ mux.HandleFunc("POST /api/spaces", s.rateLimited(s.createSpaceHandler))
+
// Devices: list with a link token; revoke only with a fresh ceremony. The
// asymmetry is deliberate — a stolen link token must never reduce access.
mux.HandleFunc("GET /api/devices", s.listDevicesHandler)
diff --git a/home/provision.go b/home/provision.go
new file mode 100644
index 0000000..d5bb6ec
--- /dev/null
+++ b/home/provision.go
@@ -0,0 +1,109 @@
+package main
+
+// Space provisioning: a signed-in user asks their home server for a new Eleven
+// space, and home forwards the request to a cloud provisioner
+// (cloud.elevenmessenger.com — see docs/cloud-provisioner.md) using its minted
+// caller token. Home's role is deliberately thin: authenticate the user (device
+// link token), forward their opaque user id as the quota key, and relay the
+// result. The setup link comes back to the client, which claims it and then
+// adds the space to its own encrypted backup — the blob stays content-blind.
+//
+// Privacy note (deliberate, documented trade-off): provisioning is the one
+// place home learns a relationship between a user and an instance hostname —
+// operational metadata held by the provisioner's ledger, never in the blob.
+// A user who never provisions never appears there.
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// cloudProvisioner is home's client for one cloud provisioner. nil when the
+// deployment doesn't offer provisioning — home stays strictly additive.
+type cloudProvisioner struct {
+ url string // e.g. https://cloud.elevenmessenger.com
+ token string // caller token minted by the provisioner's admin
+ client *http.Client
+}
+
+func newCloudProvisioner(url, token string) *cloudProvisioner {
+ if url == "" || token == "" {
+ return nil
+ }
+ return &cloudProvisioner{
+ url: strings.TrimRight(url, "/"),
+ token: token,
+ // Creation waits on a real instance start + health check downstream, so
+ // the timeout is generous.
+ client: &http.Client{Timeout: 45 * time.Second},
+ }
+}
+
+// featuresHandler advertises which optional features this home server offers,
+// so the page can hide UI it can't serve. Public: it reveals only a boolean,
+// never a token or endpoint that would work without one.
+func (s *Server) featuresHandler(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"spaces": s.cloud != nil})
+}
+
+// createSpaceHandler: POST /api/spaces — provision a new space for the
+// signed-in user. Auth is the device link token (creating a space is additive,
+// not destructive, so no fresh ceremony is demanded); the provisioner's
+// per-user quota and this endpoint's rate limit bound abuse.
+func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.linkUser(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if s.cloud == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]any{
+ "error": "unavailable", "message": "this home server does not offer space creation",
+ })
+ return
+ }
+ status, body, err := s.cloud.createSpace(uid)
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "create_failed", "message": "could not reach the space provisioner",
+ })
+ return
+ }
+ // Relay the provisioner's stable codes as-is (201 created, 409 quota,
+ // 503 at-capacity with its member-facing message); anything unexpected is
+ // flattened so a client never depends on an undocumented shape.
+ switch status {
+ case http.StatusCreated, http.StatusConflict, http.StatusServiceUnavailable:
+ writeJSON(w, status, body)
+ default:
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "create_failed", "message": "space creation failed",
+ })
+ }
+}
+
+// createSpace forwards one provisioning request; the user id is the quota key
+// (opaque to the provisioner — random, no email or name anywhere).
+func (c *cloudProvisioner) createSpace(requester string) (int, map[string]any, error) {
+ payload, _ := json.Marshal(map[string]string{"requester": requester})
+ req, err := http.NewRequest("POST", c.url+"/api/instances", bytes.NewReader(payload))
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer resp.Body.Close()
+ var body map[string]any
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
+ body = map[string]any{}
+ }
+ return resp.StatusCode, body, nil
+}
diff --git a/home/provision_test.go b/home/provision_test.go
new file mode 100644
index 0000000..169e27a
--- /dev/null
+++ b/home/provision_test.go
@@ -0,0 +1,173 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// fakeCloud stands in for the cloud provisioner and captures what home sends.
+func fakeCloud(t *testing.T, status int, body map[string]any) (*httptest.Server, *http.Request, *[]byte) {
+ t.Helper()
+ var captured http.Request
+ var capturedBody []byte
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ captured = *r
+ buf := new(bytes.Buffer)
+ buf.ReadFrom(r.Body)
+ capturedBody = buf.Bytes()
+ writeJSON(w, status, body)
+ }))
+ t.Cleanup(srv.Close)
+ return srv, &captured, &capturedBody
+}
+
+func postSpaces(t *testing.T, ts *httptest.Server, token string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", nil)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func TestFeaturesReflectsProvisioner(t *testing.T) {
+ ts, s := newTestServer(t)
+
+ // Unconfigured: spaces off (and the endpoint needs no auth).
+ resp, err := http.Get(ts.URL + "/api/features")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var f map[string]any
+ json.NewDecoder(resp.Body).Decode(&f)
+ if f["spaces"] != false {
+ t.Errorf("unconfigured: spaces = %v, want false", f["spaces"])
+ }
+
+ // Configured: spaces on.
+ s.cloud = newCloudProvisioner("https://cloud.example.com", "tok")
+ resp2, err := http.Get(ts.URL + "/api/features")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp2.Body.Close()
+ var f2 map[string]any
+ json.NewDecoder(resp2.Body).Decode(&f2)
+ if f2["spaces"] != true {
+ t.Errorf("configured: spaces = %v, want true", f2["spaces"])
+ }
+}
+
+func TestCreateSpaceRequiresAuth(t *testing.T) {
+ ts, _ := newTestServer(t)
+ if resp := postSpaces(t, ts, ""); resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("no token: status = %d, want 401", resp.StatusCode)
+ }
+}
+
+func TestCreateSpaceUnconfigured(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, token := seedUser(t, s)
+
+ resp := postSpaces(t, ts, token)
+ if resp.StatusCode != http.StatusNotImplemented {
+ t.Fatalf("status = %d, want 501", resp.StatusCode)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ if m["error"] != "unavailable" {
+ t.Errorf("error = %v, want unavailable", m["error"])
+ }
+}
+
+func TestCreateSpaceRelaysSuccess(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, token := seedUser(t, s)
+
+ cloud, captured, capturedBody := fakeCloud(t, http.StatusCreated, map[string]any{
+ "host": "amber-fox-42.example.com", "url": "https://amber-fox-42.example.com",
+ "setup_link": "https://amber-fox-42.example.com/join/setup#S",
+ })
+ s.cloud = newCloudProvisioner(cloud.URL, "caller-token")
+
+ resp := postSpaces(t, ts, token)
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("status = %d, want 201", resp.StatusCode)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ if m["host"] != "amber-fox-42.example.com" || m["setup_link"] != "https://amber-fox-42.example.com/join/setup#S" {
+ t.Errorf("relayed body = %v", m)
+ }
+
+ // Home authenticated with its caller token and forwarded the user id (and
+ // nothing else) as the requester.
+ if got := captured.Header.Get("Authorization"); got != "Bearer caller-token" {
+ t.Errorf("cloud saw Authorization %q", got)
+ }
+ var sent map[string]string
+ json.Unmarshal(*capturedBody, &sent)
+ if sent["requester"] != uid || len(sent) != 1 {
+ t.Errorf("cloud saw body %v, want just requester=%s", sent, uid)
+ }
+}
+
+func TestCreateSpaceRelaysQuotaAndCapacity(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, token := seedUser(t, s)
+
+ quota, _, _ := fakeCloud(t, http.StatusConflict, map[string]any{
+ "error": "quota_exceeded", "message": "this account already has 3 spaces (the limit)",
+ })
+ s.cloud = newCloudProvisioner(quota.URL, "caller-token")
+ resp := postSpaces(t, ts, token)
+ if resp.StatusCode != http.StatusConflict {
+ t.Fatalf("quota: status = %d, want 409", resp.StatusCode)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ if m["error"] != "quota_exceeded" {
+ t.Errorf("quota: error = %v", m["error"])
+ }
+
+ full, _, _ := fakeCloud(t, http.StatusServiceUnavailable, map[string]any{
+ "error": "at_capacity", "message": "We can't create a new Eleven space right now — please try again later.",
+ })
+ s.cloud = newCloudProvisioner(full.URL, "caller-token")
+ resp = postSpaces(t, ts, token)
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("capacity: status = %d, want 503", resp.StatusCode)
+ }
+ var m2 map[string]any
+ json.NewDecoder(resp.Body).Decode(&m2)
+ if m2["error"] != "at_capacity" || m2["message"] == "" {
+ t.Errorf("capacity: body = %v", m2)
+ }
+}
+
+func TestCreateSpaceFlattensUnexpectedStatus(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, token := seedUser(t, s)
+
+ weird, _, _ := fakeCloud(t, http.StatusTeapot, map[string]any{"error": "internal-detail"})
+ s.cloud = newCloudProvisioner(weird.URL, "caller-token")
+
+ resp := postSpaces(t, ts, token)
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("status = %d, want 502", resp.StatusCode)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ if m["error"] != "create_failed" {
+ t.Errorf("error = %v, want create_failed", m["error"])
+ }
+}
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index c45192c..76b8670 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -55,6 +55,16 @@
</div>
</section>
+ <section id="space-section" class="hidden">
+ <h2>Create a new space</h2>
+ <p>Start your own private Eleven space — a group that's yours. You get a
+ one-time link that makes you its keyholder, and it's saved to your
+ passkey so you can sign back in from any device.</p>
+ <button id="new-space">Create a new space</button>
+ <p class="status" id="new-space-status"></p>
+ <div id="new-space-result" class="hidden"></div>
+ </section>
+
<section id="create-section">
<h2>New here?</h2>
<p>Create your Eleven passkey on this device. Spaces you save from their
diff --git a/home/web/static/home.css b/home/web/static/home.css
index be99636..a7287d7 100644
--- a/home/web/static/home.css
+++ b/home/web/static/home.css
@@ -146,4 +146,20 @@ body.manage section {
body.manage section h2 { font-size: 16px; margin: 0 0 4px; font-family: var(--font-display); }
body.manage section p { margin: 0 0 12px; font-size: 14px; color: var(--muted); }
+/* The freshly-created space's "open it" link reads as a primary button. */
+#new-space-result { margin-top: 12px; }
+#new-space-result p { margin: 0 0 10px; }
+a.btn-link {
+ display: inline-block;
+ font: inherit;
+ font-weight: 600;
+ text-decoration: none;
+ border-radius: 999px;
+ padding: 11px 18px;
+ background: var(--accent);
+ color: var(--accent-ink);
+ line-height: 1.2;
+}
+a.btn-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+
.hidden { display: none; }
diff --git a/home/web/static/home.js b/home/web/static/home.js
index d587a4e..4615acc 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -190,6 +190,77 @@ async function revokeDevice(id) {
await showDevices();
}
+// ---- create a new space -------------------------------------------------------
+
+// createSpace asks this home server to provision a brand-new Eleven space for
+// the signed-in user (POST /api/spaces, link-token auth), then folds it into
+// the backup so restore always finds the way back. The vault must be unlocked:
+// that's how we hold the DEK to save the new entry (and the link token to ask).
+async function createSpace() {
+ if (!vault) {
+ setStatus("new-space-status", "Unlock your backup first — your new space is saved into it.", "error");
+ return;
+ }
+ const btn = $("new-space");
+ btn.disabled = true; // one space per click — a double-tap would spend quota twice
+ setStatus("new-space-status", "Building your space…", "busy");
+ try {
+ const res = await fetch("/api/spaces", {
+ method: "POST",
+ headers: { Authorization: "Bearer " + state.token },
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ // Structured errors carry a member-facing message (quota, at-capacity).
+ setStatus("new-space-status",
+ body.message || body.error || `Couldn't create a space (HTTP ${res.status}).`, "error");
+ return;
+ }
+ // Save it into the backup (last-writer-wins per url, like the add form).
+ const url = body.url;
+ const host = new URL(url).host;
+ vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url);
+ vault.obj.servers.push({ url, name: host });
+ let saved = true;
+ try {
+ await saveVault();
+ } catch {
+ // The space exists even if the backup write raced; say so but still show it.
+ saved = false;
+ }
+ renderVault();
+ showNewSpaceResult(host, body.setup_link || url, saved);
+ setStatus("new-space-status", "");
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+function showNewSpaceResult(host, openLink, saved) {
+ const box = $("new-space-result");
+ box.classList.remove("hidden");
+ box.textContent = "";
+
+ const ready = document.createElement("p");
+ ready.className = "status ok";
+ ready.textContent = "Your space " + host + " is ready.";
+
+ const a = document.createElement("a");
+ a.className = "btn-link";
+ a.href = openLink;
+ a.target = "_blank";
+ a.rel = "noopener";
+ a.textContent = "Open your new space →";
+
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.textContent = saved
+ ? "This one-time link makes you the keyholder. It's saved in your backup below."
+ : "This one-time link makes you the keyholder. Couldn't save it to the backup just now — add it below to be safe.";
+
+ box.append(ready, a, note);
+}
+
// ---- add-server form ----------------------------------------------------------
async function addServer(e) {
@@ -226,6 +297,13 @@ if (!window.PublicKeyCredential) {
}
$("devices").addEventListener("click", () => showDevices().catch(fail("devices-status")));
$("add-form").addEventListener("submit", (e) => addServer(e).catch(fail("vault-status")));
+$("new-space").addEventListener("click", () => createSpace().catch(fail("new-space-status")));
+
+// Show the "create a space" section only where this home server offers it —
+// other people run their own home servers without a provisioner.
+apiJSON("/api/features")
+ .then((f) => { if (f.spaces) $("space-section").classList.remove("hidden"); })
+ .catch(() => { /* older home server without the endpoint: leave it hidden */ });
// Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in
// sessionStorage, so the vault can reopen without a ceremony.
diff --git a/main.go b/main.go
index cae86b1..dbd036c 100644
--- a/main.go
+++ b/main.go
@@ -153,6 +153,12 @@ func main() {
case "init-versions":
runInitVersions(os.Args[2:])
return
+ case "controld":
+ runControld(os.Args[2:])
+ return
+ case "cloud":
+ runCloud(os.Args[2:])
+ return
}
}
diff --git a/router.go b/router.go
index bae851b..d7828ca 100644
--- a/router.go
+++ b/router.go
@@ -25,6 +25,14 @@ import (
type router struct {
reg *Registry
upstreams atomic.Pointer[map[string]*httputil.ReverseProxy]
+ // fallbackURL, when set, is where requests for unknown hosts are redirected
+ // (e.g. the apex marketing site) instead of a bare 404 — a wildcard DNS
+ // record then makes typo'd or retired subdomains land somewhere useful. Note
+ // the limit: over HTTPS this only helps hosts that still HAVE a cert (e.g. a
+ // removed instance); a never-served host fails the TLS handshake first,
+ // because HostPolicy refuses to mint certs for unknown hosts (anything else
+ // would let strangers burn the ACME rate limit). The :80 path always works.
+ fallbackURL string
}
func newRouter(reg *Registry) (*router, error) {
@@ -76,12 +84,23 @@ func (rt *router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
proxy, ok := rt.current()[host]
if !ok {
- http.Error(w, "unknown host", http.StatusNotFound)
+ rt.unknownHost(w, r)
return
}
proxy.ServeHTTP(w, r)
}
+// unknownHost answers a request for a host we don't serve: a redirect to the
+// fallback (302 — a typo'd host must not be cached as permanently moved), or
+// the classic 404 when no fallback is configured.
+func (rt *router) unknownHost(w http.ResponseWriter, r *http.Request) {
+ if rt.fallbackURL != "" {
+ http.Redirect(w, r, rt.fallbackURL, http.StatusFound)
+ return
+ }
+ http.Error(w, "unknown host", http.StatusNotFound)
+}
+
// unixProxy reverse-proxies to a backend listening on a unix socket, preserving
// the client's Host header (so the backend sees the real hostname) and passing
// WebSocket upgrades straight through.
@@ -145,6 +164,8 @@ func runRouter(args []string) {
acmeCache := fs.String("acme-cache", envOr("LCHAT_ACME_CACHE", "/data/acme"), "autocert certificate cache dir (env: LCHAT_ACME_CACHE)")
httpsAddr := fs.String("https", envOr("LCHAT_HTTPS_ADDR", ":443"), "HTTPS listen address")
httpAddr := fs.String("http", envOr("LCHAT_HTTP_ADDR", ":80"), "HTTP listen address (ACME HTTP-01 challenge + redirect to https)")
+ fallbackURL := fs.String("fallback-url", os.Getenv("LCHAT_FALLBACK_URL"),
+ "redirect requests for unknown hosts here, e.g. https://elevenmessenger.com (env: LCHAT_FALLBACK_URL); empty keeps the plain 404")
_ = fs.Parse(args)
reg, err := openRegistry(*registryPath)
@@ -155,6 +176,7 @@ func runRouter(args []string) {
if err != nil {
log.Fatalf("router: load routes: %v", err)
}
+ rt.fallbackURL = *fallbackURL
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
@@ -181,10 +203,30 @@ func runRouter(args []string) {
}
}()
+ // The non-ACME :80 handler. Known hosts get autocert's classic redirect to
+ // https (GET/HEAD only, 302 — mirrored here since supplying any fallback
+ // replaces it); unknown hosts go to the fallback URL instead of a redirect
+ // into a doomed TLS handshake (no cert will ever exist for them).
+ httpFallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ host := r.Host
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ if !rt.allowed(host) {
+ rt.unknownHost(w, r)
+ return
+ }
+ if r.Method != "GET" && r.Method != "HEAD" {
+ http.Error(w, "Use HTTPS", http.StatusBadRequest)
+ return
+ }
+ http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusFound)
+ })
+
// ReadHeaderTimeout guards the public front door against Slowloris-style slow-
// header connection exhaustion. No ReadTimeout/WriteTimeout — those would break
// long-lived proxied WebSockets.
- httpSrv := &http.Server{Addr: *httpAddr, Handler: m.HTTPHandler(nil), ReadHeaderTimeout: 10 * time.Second}
+ httpSrv := &http.Server{Addr: *httpAddr, Handler: m.HTTPHandler(httpFallback), ReadHeaderTimeout: 10 * time.Second}
httpsSrv := &http.Server{Addr: *httpsAddr, Handler: rt, TLSConfig: m.TLSConfig(), ReadHeaderTimeout: 10 * time.Second}
// :80 serves the ACME HTTP-01 challenge and redirects everything else to https.
diff --git a/router_test.go b/router_test.go
index 7568b7a..fce6a7b 100644
--- a/router_test.go
+++ b/router_test.go
@@ -273,3 +273,40 @@ func tmpSock(t *testing.T) string {
t.Cleanup(func() { os.Remove(s) })
return s
}
+
+func TestRouterUnknownHostFallback(t *testing.T) {
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ rt, err := newRouter(reg)
+ if err != nil {
+ t.Fatalf("new router: %v", err)
+ }
+ rt.fallbackURL = "https://elevenmessenger.com"
+ front := httptest.NewServer(rt)
+ t.Cleanup(front.Close)
+
+ // Unknown host → 302 to the fallback, never a bare 404. 302 (not 301) so a
+ // typo'd host isn't cached as permanently moved.
+ req, _ := http.NewRequest(http.MethodGet, front.URL+"/some/path", nil)
+ req.Host = "typo.elevenmessenger.com"
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusFound {
+ t.Fatalf("status = %d, want 302", resp.StatusCode)
+ }
+ if loc := resp.Header.Get("Location"); loc != "https://elevenmessenger.com" {
+ t.Fatalf("Location = %q, want the fallback", loc)
+ }
+
+ // Without a fallback the classic 404 stands (covered again for regressions).
+ rt.fallbackURL = ""
+ if _, code := proxyGet(t, front.URL, "typo.elevenmessenger.com"); code != http.StatusNotFound {
+ t.Fatalf("no fallback: status = %d, want 404", code)
+ }
+}