server: sqid space subdomains + confirm-ready-before-handoff (#332)
Two changes to the cloud provisioner, toward short meaningful addresses
and a rock-solid create → open flow.
sqid addressing: a space's subdomain is now sqids(id) — the sqid of its
integer primary key in the spaces table — so a4f.<domain> is short AND
reversibly encodes the id (spaceID("a4f") → 42, "which server this is").
SQLite supplies the id (INTEGER PRIMARY KEY AUTOINCREMENT); sqids-go
encodes it (lowercase base36, minLength 3, default blocklist, no reuse of
a torn-down id's label). The wordlist slug generator + re-roll loop are
gone: creation reserves the id first (quota-checked, atomic), turns it
into the host, then creates the instance — distinct ids give distinct
labels by construction, so no collisions. Additive migration rebuilds an
existing host-PK spaces table (data preserved).
Confirm-ready: prewarmCert becomes confirmPublicReady — after the router
reload, add-instance issues an HTTPS request THROUGH the local router
(SNI = new host) and waits for a non-5xx reply before returning the setup
link. So a click always lands on a ready admin sign-on, never a
mid-provision error; on a per-host-cert box it also warms the cert (no
first-hit "not secure" race). The home "Create a new space" spinner stays
up for the whole call, revealing the link only once the space is confirmed
up.
Tests: sqid round-trip + uniqueness over 1k ids, sqid-host create flow,
old-schema DB migration, confirmPublicReady invoked on create. go
build/vet/test + npm green.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+348
−204
cloud.go+83 −104cloud_db.go+100 −19cloud_test.go+85 −36control.go+36 −25controld_test.go+11 −11docs/cloud-provisioner.md+27 −8go.mod+1 −0go.sum+2 −0home/web/static/home.js+3 −1
diff --git a/cloud.go b/cloud.go| index fc83943..9552d49 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -5,90 +5,69 @@ package main |
| // 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. |
| +// deliberately unprivileged and sandboxed, holding policy (tokens, quotas, |
| +// sqid addressing) 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" |
| + |
| + sqids "github.com/sqids/sqids-go" |
| ) |
| // 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", |
| -} |
| +// Space subdomains are sqids of the space's integer id (its DB primary key), so |
| +// the hostname is short AND reversibly encodes the id — given a4f.<domain> you |
| +// can recover "server 42". Lowercase base36 keeps labels DNS-safe (case- |
| +// insensitive); minLength 3 gives a4f-length labels that grow only as ids do; |
| +// the default blocklist keeps rude combinations from ever being minted. The |
| +// alphabet is shuffled internally per-id, so consecutive ids don't produce |
| +// consecutive labels. |
| +var spaceSqids = mustSqids() |
| -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", |
| +func mustSqids() *sqids.Sqids { |
| + s, err := sqids.New(sqids.Options{ |
| + Alphabet: "0123456789abcdefghijklmnopqrstuvwxyz", |
| + MinLength: 3, |
| + }) |
| + if err != nil { |
| + panic("cloud: sqids init: " + err.Error()) |
| + } |
| + return s |
| } |
| -// 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, |
| +// spaceSlug encodes a space id to its subdomain label. |
| +func spaceSlug(id int64) (string, error) { |
| + return spaceSqids.Encode([]uint64{uint64(id)}) |
| } |
| -// 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 |
| +// spaceID decodes a subdomain label back to its space id. ok=false if the label |
| +// isn't a canonical encoding of a single id (sqids will decode some non-canonical |
| +// strings, so we re-encode and compare — the standard round-trip guard). |
| +func spaceID(slug string) (id int64, ok bool) { |
| + nums := spaceSqids.Decode(slug) |
| + if len(nums) != 1 { |
| + return 0, false |
| + } |
| + if enc, err := spaceSqids.Encode(nums); err != nil || enc != slug { |
| + return 0, false |
| + } |
| + return int64(nums[0]), true |
| } |
| // cloudServer is the provisioner's HTTP surface. |
| @@ -140,8 +119,10 @@ func (s *cloudServer) admin(next http.HandlerFunc) http.HandlerFunc { |
| } |
| } |
| -// createSpaceHandler provisions one instance for a requester: quota check, roll |
| -// a slug, ask the control daemon to create it, record it in the ledger. |
| +// createSpaceHandler provisions one instance for a requester: reserve the next |
| +// id (quota-checked), turn it into a sqid hostname, ask the control daemon to |
| +// create it, then finalize the ledger. No slug re-roll loop — the id is unique |
| +// by construction, so its sqid hostname is always fresh. |
| func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| callerID := r.Context().Value(callerKey{}).(string) |
| var req struct { |
| @@ -157,60 +138,58 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| return |
| } |
| - n, err := s.db.spaceCount(callerID, req.Requester) |
| + // Reserve the id (fails closed if over quota). The row is pending (NULL host) |
| + // until the instance is up; on any failure below we delete it so it doesn't |
| + // hold a quota slot. |
| + id, overQuota, err := s.db.allocateSpace(callerID, req.Requester, s.quota) |
| if err != nil { |
| writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") |
| return |
| } |
| - if n >= s.quota { |
| + if overQuota { |
| writeControlErr(w, http.StatusConflict, "quota_exceeded", |
| - fmt.Sprintf("this account already has %d spaces (the limit)", n)) |
| + fmt.Sprintf("this account already has %d spaces (the limit)", s.quota)) |
| 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 |
| + slug, err := spaceSlug(id) |
| + if err != nil { |
| + s.db.deleteSpaceByID(id) |
| + writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed") |
| + return |
| + } |
| + host := slug + "." + s.baseDomain |
| + |
| + status, body, err := s.controlCreate(host) |
| + if err != nil { |
| + s.db.deleteSpaceByID(id) |
| + 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.finalizeSpace(id, host); err != nil { |
| + // The instance is up but recording its host failed — log loudly; the |
| + // requester keeps the space, and the pending row still holds the slot. |
| + log.Printf("cloud: ledger finalize for %s (id %d) failed: %v", host, id, err) |
| } |
| + log.Printf("cloud: created %s (id %d, caller %.8s…)", host, id, callerID) |
| + writeJSON(w, http.StatusCreated, map[string]any{ |
| + "host": host, |
| + "url": body["url"], |
| + "setup_link": body["setup_link"], |
| + }) |
| + case status == http.StatusServiceUnavailable: |
| + // The box is full: relay the stable code with the member-facing message. |
| + s.db.deleteSpaceByID(id) |
| + writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) |
| + default: |
| + s.db.deleteSpaceByID(id) |
| + 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") |
| } |
| - writeControlErr(w, http.StatusInternalServerError, "create_failed", "could not find a free address") |
| } |
| // controlCreate posts one creation request to the control daemon. |
diff --git a/cloud_db.go b/cloud_db.go| index 56cdb2c..a164cce 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -35,10 +35,10 @@ func openCloudDB(path string) (*cloudDB, error) { |
| 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. |
| +// migrate is additive, 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(` |
| + if _, 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 ( |
| @@ -48,14 +48,62 @@ CREATE TABLE IF NOT EXISTS caller_tokens ( |
| 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. |
| +-- Provisioned spaces: the quota ledger AND the id source for a space's hostname. |
| +-- The integer id is the primary key; the subdomain label is sqids(id) (see |
| +-- cloud.go), so the hostname encodes the id reversibly. host is assigned only |
| +-- after the id is known (it's NULL while a create is in flight), so it can't be |
| +-- the PK. requester is the caller's opaque user id (a home server's random uid). |
| CREATE TABLE IF NOT EXISTS spaces ( |
| - host TEXT PRIMARY KEY, |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + host TEXT UNIQUE, |
| caller_id TEXT NOT NULL, |
| requester TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| -);`) |
| +);`); err != nil { |
| + return err |
| + } |
| + return db.migrateSpacesID() |
| +} |
| + |
| +// migrateSpacesID rebuilds a spaces table created under the original host-PK |
| +// schema (no integer id) into the id-keyed one above. AUTOINCREMENT means a |
| +// torn-down space's id is never reused, so its sqid subdomain can't be handed to |
| +// a different space later. A no-op once the id column exists. |
| +func (db *cloudDB) migrateSpacesID() error { |
| + rows, err := db.sql.Query(`PRAGMA table_info(spaces)`) |
| + if err != nil { |
| + return err |
| + } |
| + hasID := false |
| + for rows.Next() { |
| + var cid, notnull, pk int |
| + var name, ctype string |
| + var dflt sql.NullString |
| + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + if name == "id" { |
| + hasID = true |
| + } |
| + } |
| + rows.Close() |
| + if hasID { |
| + return nil |
| + } |
| + // Preserve any existing rows (they get fresh sequential ids in created_at order). |
| + _, err = db.sql.Exec(` |
| +ALTER TABLE spaces RENAME TO spaces_old; |
| +CREATE TABLE spaces ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + host TEXT UNIQUE, |
| + caller_id TEXT NOT NULL, |
| + requester TEXT NOT NULL, |
| + created_at INTEGER NOT NULL |
| +); |
| +INSERT INTO spaces (host, caller_id, requester, created_at) |
| + SELECT host, caller_id, requester, created_at FROM spaces_old ORDER BY created_at; |
| +DROP TABLE spaces_old;`) |
| return err |
| } |
| @@ -124,21 +172,51 @@ func (db *cloudDB) revokeCallerToken(id string) (bool, error) { |
| 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) { |
| +// allocateSpace reserves the next id for a (caller, requester) if they're under |
| +// quota, atomically: the quota count and the insert share one transaction (and |
| +// SetMaxOpenConns(1) serializes writers), so two concurrent creates can't both |
| +// slip past the limit. The row is inserted with a NULL host — the caller turns |
| +// the returned id into a hostname via sqids, then finalizeSpace records it. |
| +// overQuota=true (with a zero id) means the request is at/over the limit. |
| +func (db *cloudDB) allocateSpace(callerID, requester string, quota int) (id int64, overQuota bool, err error) { |
| + tx, err := db.sql.Begin() |
| + if err != nil { |
| + return 0, false, err |
| + } |
| + defer tx.Rollback() |
| var n int |
| - err := db.sql.QueryRow( |
| + if err := tx.QueryRow( |
| `SELECT COUNT(*) FROM spaces WHERE caller_id = ? AND requester = ?`, callerID, requester, |
| - ).Scan(&n) |
| - return n, err |
| + ).Scan(&n); err != nil { |
| + return 0, false, err |
| + } |
| + if n >= quota { |
| + return 0, true, nil |
| + } |
| + res, err := tx.Exec( |
| + `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL, ?, ?, ?)`, |
| + callerID, requester, time.Now().Unix(), |
| + ) |
| + if err != nil { |
| + return 0, false, err |
| + } |
| + if id, err = res.LastInsertId(); err != nil { |
| + return 0, false, err |
| + } |
| + return id, false, tx.Commit() |
| } |
| -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(), |
| - ) |
| +// finalizeSpace records the hostname once the instance is created. |
| +func (db *cloudDB) finalizeSpace(id int64, host string) error { |
| + _, err := db.sql.Exec(`UPDATE spaces SET host = ? WHERE id = ?`, host, id) |
| + return err |
| +} |
| + |
| +// deleteSpaceByID rolls back an allocation whose instance never came up, so a |
| +// failed create doesn't hold a quota slot. (The id is still spent — AUTOINCREMENT |
| +// never reuses it — which is fine; ids are cheap and gaps are harmless.) |
| +func (db *cloudDB) deleteSpaceByID(id int64) error { |
| + _, err := db.sql.Exec(`DELETE FROM spaces WHERE id = ?`, id) |
| return err |
| } |
| @@ -149,9 +227,12 @@ type Space struct { |
| CreatedAt int64 `json:"created_at"` |
| } |
| +// listSpaces returns a requester's finalized spaces (a NULL host is a create |
| +// still in flight or one that failed before rollback — not shown). |
| 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`, |
| + `SELECT host, created_at FROM spaces |
| + WHERE caller_id = ? AND requester = ? AND host IS NOT NULL ORDER BY id`, |
| callerID, requester, |
| ) |
| if err != nil { |
diff --git a/cloud_test.go b/cloud_test.go| index cf49df9..877a1d1 100644 |
| --- a/cloud_test.go |
| +++ b/cloud_test.go |
| @@ -2,15 +2,14 @@ package main |
| import ( |
| "bytes" |
| + "database/sql" |
| "encoding/json" |
| "net" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| - "regexp" |
| "strings" |
| - "sync/atomic" |
| "testing" |
| ) |
| @@ -202,28 +201,36 @@ func TestCloudRelaysAtCapacity(t *testing.T) { |
| } |
| } |
| -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) |
| +func TestCloudSqidHosts(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) |
| 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) |
| + // Each create gets a distinct sqid subdomain that decodes to a fresh, |
| + // increasing id — no wordlist, no re-roll. Fresh DB → ids 1, 2, 3… |
| + var ids []int64 |
| + for i := 0; i < s.quota; i++ { |
| + resp := createSpace(t, srv, tok, "u") |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create %d: status %d", i, resp.StatusCode) |
| + } |
| + host, _ := decodeBody(t, resp)["host"].(string) |
| + label, _, ok := strings.Cut(host, ".") |
| + if !ok { |
| + t.Fatalf("host %q has no label", host) |
| + } |
| + id, ok := spaceID(label) |
| + if !ok { |
| + t.Fatalf("label %q of host %q is not a canonical sqid", label, host) |
| + } |
| + ids = append(ids, id) |
| + } |
| + if ids[0] != 1 { |
| + t.Errorf("first space id = %d, want 1", ids[0]) |
| } |
| - if calls.Load() != 2 { |
| - t.Errorf("control calls = %d, want 2", calls.Load()) |
| + for i := 1; i < len(ids); i++ { |
| + if ids[i] <= ids[i-1] { |
| + t.Errorf("ids not strictly increasing: %v", ids) |
| + } |
| } |
| } |
| @@ -278,27 +285,69 @@ func TestCloudAdminTokenLifecycle(t *testing.T) { |
| } |
| } |
| -func TestGenerateSlug(t *testing.T) { |
| - labelRe := regexp.MustCompile(`^[a-z]+-[a-z]+-[0-9]{2}$`) |
| - for i := 0; i < 200; i++ { |
| - slug, err := generateSlug() |
| +func TestCloudDBMigratesOldSpaces(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "cloud.db") |
| + // Stand up the ORIGINAL host-PK spaces schema (pre-sqids) with a row, exactly |
| + // as a DB provisioned before this change would have. |
| + raw, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := raw.Exec(` |
| +CREATE TABLE spaces (host TEXT PRIMARY KEY, caller_id TEXT NOT NULL, requester TEXT NOT NULL, created_at INTEGER NOT NULL); |
| +INSERT INTO spaces(host, caller_id, requester, created_at) VALUES('old.example.com', 'c', 'r', 100);`); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw.Close() |
| + |
| + // Reopening through the app migrates the table to the id-keyed schema. |
| + db, err := openCloudDB(path) |
| + if err != nil { |
| + t.Fatalf("openCloudDB (migrate): %v", err) |
| + } |
| + // The existing row survived (with a fresh id)… |
| + spaces, err := db.listSpaces("c", "r") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(spaces) != 1 || spaces[0].Host != "old.example.com" { |
| + t.Fatalf("after migrate, spaces = %+v, want [old.example.com]", spaces) |
| + } |
| + // …and new allocations continue past it (ids are AUTOINCREMENT, never reused). |
| + id, over, err := db.allocateSpace("c2", "r2", 5) |
| + if err != nil || over { |
| + t.Fatalf("allocate after migrate: id=%d over=%v err=%v", id, over, err) |
| + } |
| + if id <= 1 { |
| + t.Errorf("new id = %d, want > 1 (the migrated row took id 1)", id) |
| + } |
| +} |
| + |
| +func TestSpaceSqidRoundTrip(t *testing.T) { |
| + seen := map[string]bool{} |
| + for id := int64(1); id <= 1000; id++ { |
| + slug, err := spaceSlug(id) |
| if err != nil { |
| - t.Fatal(err) |
| - } |
| - if !labelRe.MatchString(slug) { |
| - t.Fatalf("slug %q does not match adjective-noun-NN", slug) |
| + t.Fatalf("encode id %d: %v", id, err) |
| } |
| - if reservedSlugs[slug] { |
| - t.Fatalf("slug %q is reserved", slug) |
| + if len(slug) < 3 { |
| + t.Errorf("slug %q for id %d is under the 3-char minimum", slug, id) |
| } |
| if !validHost(slug + ".example.com") { |
| - t.Fatalf("slug %q makes an invalid host", slug) |
| + t.Errorf("slug %q makes an invalid host", slug) |
| + } |
| + if got, ok := spaceID(slug); !ok || got != id { |
| + t.Errorf("round-trip id %d -> %q -> (%d, %v), want (%d, true)", id, slug, got, ok, id) |
| + } |
| + if seen[slug] { |
| + t.Errorf("slug collision at id %d: %q already seen", id, slug) |
| } |
| + seen[slug] = true |
| } |
| - // 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) |
| + // Garbage / non-canonical labels don't decode to an id. |
| + for _, bad := range []string{"", "!!!", "ABC", "a-b"} { |
| + if _, ok := spaceID(bad); ok { |
| + t.Errorf("spaceID(%q) decoded, want not-ok", bad) |
| } |
| } |
| } |
diff --git a/control.go b/control.go| index f76ac10..dcd8fb6 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -221,13 +221,14 @@ func addInstance(reg *Registry, opts addInstanceOpts) (*addInstanceResult, error |
| return nil, fmt.Errorf("reload %s: %v (route added; run: systemctl reload %s)", routerUnit, err, routerUnit) |
| } |
| - // Warm the TLS cert now, so the setup link below works the instant it's |
| - // clicked. Without this the first visitor triggers autocert's on-demand ACME |
| - // issuance mid-handshake (a few seconds) and the browser shows a scary "not |
| - // private" warning until it completes — exactly the wrong first impression for |
| - // a freshly-provisioned space. Best-effort: a failure just falls back to |
| - // today's on-first-hit behaviour. |
| - prewarmCert(host, routerHTTPSAddr) |
| + // Confirm the space answers through the PUBLIC path (router → this instance) |
| + // before we hand back a setup link, so the moment a human clicks it they land |
| + // on a ready admin sign-on — never a mid-provision error. This also warms the |
| + // per-host TLS cert as a side effect (the handshake triggers autocert), so the |
| + // first visitor doesn't race on-demand issuance. Best-effort: on failure we |
| + // still return (the instance passed its own health check); the public path is |
| + // almost certainly up a beat later. |
| + confirmPublicReady(host, routerHTTPSAddr) |
| // 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. |
| @@ -531,26 +532,36 @@ func rollbackLive(host, prev, unit, socket string) { |
| _ = waitHealthy(socket, 20*time.Second) |
| } |
| -// prewarmCert makes the local router obtain host's TLS certificate now, so the |
| -// first real visitor to a freshly-created instance doesn't hit autocert's |
| -// seconds-long on-demand ACME issuance mid-handshake (which shows a browser "not |
| -// secure" warning until it lands). It just completes a TLS handshake to the |
| -// router's :443 with host as the SNI — that alone triggers `GetCertificate(host)` |
| -// and caches the result; no HTTP request is needed. Best-effort with a couple of |
| -// retries (the router may not have finished reloading the new route on the first |
| -// try); any failure falls back to today's issue-on-first-hit behaviour. A var so |
| -// tests can stub it. See docs/cloud-provisioner.md. |
| -var prewarmCert = func(host, httpsAddr string) { |
| - dialer := &tls.Dialer{Config: &tls.Config{ServerName: host}} |
| - for attempt := 0; attempt < 3; attempt++ { |
| - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| - conn, err := dialer.DialContext(ctx, "tcp", httpsAddr) |
| - cancel() |
| +// confirmPublicReady blocks until a freshly-created host answers an HTTPS request |
| +// through the local router (dialed at httpsAddr with host as the SNI, so the real |
| +// router route + cert are exercised), or a short deadline passes. It guarantees |
| +// the setup link works on first click — the visitor reaches a ready admin sign-on |
| +// rather than a mid-provision error — and warms the per-host TLS cert as a side |
| +// effect (the handshake triggers autocert issuance). Best-effort: it returns |
| +// quietly on timeout (the instance already passed its own socket health check, so |
| +// the public path is a beat behind at most). A var so tests can stub it. See |
| +// docs/cloud-provisioner.md. |
| +var confirmPublicReady = func(host, httpsAddr string) { |
| + client := &http.Client{ |
| + Timeout: 15 * time.Second, |
| + Transport: &http.Transport{ |
| + // Always dial the local router; the SNI/Host is the real host, so the |
| + // router serves that host's cert and routes to its instance. |
| + DialTLSContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&tls.Dialer{Config: &tls.Config{ServerName: host}}).DialContext(ctx, "tcp", httpsAddr) |
| + }, |
| + }, |
| + } |
| + deadline := time.Now().Add(25 * time.Second) |
| + for time.Now().Before(deadline) { |
| + resp, err := client.Get("https://" + host + "/") |
| if err == nil { |
| - conn.Close() |
| - return |
| + resp.Body.Close() |
| + if resp.StatusCode < 500 { |
| + return |
| + } |
| } |
| - time.Sleep(time.Second) |
| + time.Sleep(500 * time.Millisecond) |
| } |
| } |
diff --git a/controld_test.go b/controld_test.go| index 0a35937..d2b2713 100644 |
| --- a/controld_test.go |
| +++ b/controld_test.go |
| @@ -20,11 +20,11 @@ import ( |
| func controlTestEnv(t *testing.T) *Registry { |
| t.Helper() |
| - oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk, oldWarm := instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes, prewarmCert |
| + oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk, oldReady := instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes, confirmPublicReady |
| t.Cleanup(func() { |
| - instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes, prewarmCert = oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk, oldWarm |
| + instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes, confirmPublicReady = oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk, oldReady |
| }) |
| - prewarmCert = func(string, string) {} // no router to dial in tests |
| + confirmPublicReady = func(string, string) {} // no router to dial in tests |
| instanceDataDir = t.TempDir() |
| optDir = t.TempDir() |
| @@ -154,19 +154,19 @@ func TestControldCreatesInstance(t *testing.T) { |
| } |
| } |
| -func TestControldPrewarmsCert(t *testing.T) { |
| +func TestControldConfirmsPublicReady(t *testing.T) { |
| reg := controlTestEnv(t) |
| - var warmed string |
| - prewarmCert = func(host, addr string) { warmed = host } |
| + var confirmed string |
| + confirmPublicReady = func(host, addr string) { confirmed = host } |
| srv, token := newTestControld(t, reg, 40) |
| - if resp := controlCreate(t, srv, token, "warm.example.com"); resp.StatusCode != http.StatusCreated { |
| + if resp := controlCreate(t, srv, token, "ready.example.com"); resp.StatusCode != http.StatusCreated { |
| t.Fatalf("status = %d, want 201", resp.StatusCode) |
| } |
| - // The cert is warmed for the new host, so the returned setup link works on |
| - // first click instead of racing autocert's on-demand issuance. |
| - if warmed != "warm.example.com" { |
| - t.Errorf("prewarmCert called with %q, want warm.example.com", warmed) |
| + // Readiness through the public path is confirmed for the new host before the |
| + // setup link is returned, so a click lands on a ready admin sign-on. |
| + if confirmed != "ready.example.com" { |
| + t.Errorf("confirmPublicReady called with %q, want ready.example.com", confirmed) |
| } |
| } |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index fc35ff9..a4c9108 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -90,15 +90,34 @@ 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. |
| +### Addressing (sqids) |
| + |
| +A space's subdomain label is **`sqids(id)`** — the sqid of its integer primary |
| +key in the `spaces` table. So `a4f.<base-domain>` both *is* short and *encodes* |
| +the id reversibly: `spaceID("a4f")` recovers the id, i.e. "which server this is." |
| +SQLite supplies the id (`INTEGER PRIMARY KEY AUTOINCREMENT`); Go's `sqids-go` |
| +does the encoding (lowercase base36, min length 3 so labels grow only as ids do, |
| +default blocklist so nothing rude is minted, `AUTOINCREMENT` so a torn-down |
| +space's id — and thus its label — is never reused). Creation reserves the id |
| +first (quota-checked), turns it into the hostname, then creates the instance; |
| +there's no wordlist and no collision re-roll, because distinct ids give distinct |
| +labels by construction. |
| + |
| +### Certs + readiness |
| + |
| The base domain (`LCHAT_CLOUD_BASE_DOMAIN`) needs **wildcard DNS** pointing at |
| -the box. There's no wildcard *certificate* — each space gets its own cert |
| -(autocert HTTP-01), and provisioning **pre-warms** it: after the instance is |
| -healthy, `add-instance` completes one TLS handshake to the local router with the |
| -new host's SNI, which triggers issuance and caches the cert *before* the setup |
| -link is returned. Without that, the first visitor would race the seconds-long |
| -on-demand issuance and see a browser "not secure" warning until it lands — the |
| -wrong first impression for a brand-new space. Pre-warm is best-effort; if it |
| -fails, the cert still issues on the genuine first hit. |
| +the box. Certs are per-host (autocert HTTP-01) unless the box is configured for a |
| +wildcard cert (DNS-01 — see `instance-router.md`). Either way, provisioning |
| +**confirms readiness through the public path before returning the setup link**: |
| +after the instance passes its own socket health check, `add-instance` issues an |
| +HTTPS request *through the local router* (dialed at `:443` with the new host's |
| +SNI) and waits for a non-5xx reply. That guarantees the moment a human clicks the |
| +setup link they reach a ready admin sign-on — never a mid-provision error — and, |
| +on a per-host-cert box, warms the cert as a side effect (the handshake triggers |
| +issuance) so there's no first-hit "not secure" race. Best-effort: on timeout it |
| +returns anyway (the instance already health-checked; the public path is a beat |
| +behind at most). The home UI keeps a spinner up for the whole call, so the |
| +"Create a new space" button only reveals the link once the space is confirmed up. |
| 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 |
diff --git a/go.mod b/go.mod| index dfdda43..dcfa4d8 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -22,6 +22,7 @@ require ( |
| github.com/mitchellh/mapstructure v1.5.0 // indirect |
| github.com/ncruces/go-strftime v0.1.9 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + github.com/sqids/sqids-go v0.4.1 // indirect |
| github.com/x448/float16 v0.8.4 // indirect |
| golang.org/x/net v0.25.0 // indirect |
| golang.org/x/sys v0.28.0 // indirect |
diff --git a/go.sum b/go.sum| index e5b4bb5..4ecc46d 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -33,6 +33,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb |
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +github.com/sqids/sqids-go v0.4.1 h1:eQKYzmAZbLlRwHeHYPF35QhgxwZHLnlmVj9AkIj/rrw= |
| +github.com/sqids/sqids-go v0.4.1/go.mod h1:EMwHuPQgSNFS0A49jESTfIQS+066XQTVhukrzEPScl8= |
| github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= |
| github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= |
| github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index cd0e44e..88c7781 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -189,7 +189,9 @@ async function createSpace() { |
| } |
| const btn = $("new-space"); |
| btn.disabled = true; // one space per click — a double-tap would spend quota twice |
| - spaceStatus("Building your space…", "busy"); |
| + // The request only returns once the server has confirmed the space is live and |
| + // reachable (see confirmPublicReady), so the link we reveal works on first tap. |
| + spaceStatus("Building your space and getting it ready…", "busy"); |
| try { |
| const res = await fetch("/api/spaces", { |
| method: "POST", |