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>
22 files changed,
+2271
−60
CLAUDE.md+5 −0bootstrap.go+39 −0cloud.go+370 −0cloud_db.go+171 −0cloud_test.go+304 −0control.go+123 −54controld.go+265 −0controld_test.go+226 −0deploy/systemd/lchat-cloud.service+47 −0deploy/systemd/lchat-control.service+35 −0deploy/systemd/lchat-router.service+3 −0docs/cloud-provisioner.md+176 −0docs/instance-router.md+16 −3home/main.go+18 −1home/provision.go+109 −0home/provision_test.go+173 −0home/web/pages/index.html+10 −0home/web/static/home.css+16 −0home/web/static/home.js+78 −0main.go+6 −0router.go+44 −2router_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) |
| + } |
| +} |