eleven / messenger Public

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>
Paul Campbell 66242b0d41d6de04e25a3e8daf95ea9aa3ef5e58 parent 25d02ee
9 files changed, +348 −204
  • cloud.go +83 −104
  • cloud_db.go +100 −19
  • cloud_test.go +85 −36
  • control.go +36 −25
  • controld_test.go +11 −11
  • docs/cloud-provisioner.md +27 −8
  • go.mod +1 −0
  • go.sum +2 −0
  • home/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, &notnull, &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",