server: pre-warm a new instance's TLS cert during provisioning (#330)
A freshly-provisioned space (or any add-instance host) had a first-hit race: autocert issues the cert on the first HTTPS handshake, which takes a few seconds, so the very first visitor — often the admin clicking the setup link the instant it's returned — saw a browser "This Connection Is Not Private" warning until issuance landed. Hit on the live provisioner (aqua-dune-54.spaces.elevenmessenger.com). addInstance now pre-warms: after the router reload, it completes one TLS handshake to the local router (127.0.0.1:443, overridable via LCHAT_ROUTER_HTTPS_ADDR) with the new host as SNI, which triggers autocert's GetCertificate and caches the cert before the setup link is returned. Best-effort with a short retry (the router may still be reloading the route); on failure it falls back to today's issue-on-first-hit. prewarmCert is a stubbable var; controld_test covers that provisioning invokes it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+64
−3
control.go+37 −0controld_test.go+19 −2docs/cloud-provisioner.md+8 −1
diff --git a/control.go b/control.go| index d6c7880..f76ac10 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -3,6 +3,7 @@ package main |
| import ( |
| "bufio" |
| "context" |
| + "crypto/tls" |
| "flag" |
| "fmt" |
| "net" |
| @@ -30,6 +31,11 @@ var ( |
| instanceSocketDir = envOr("LCHAT_SOCKET_DIR", "/run/lchat") // <host>.sock lives here |
| ) |
| +// routerHTTPSAddr is where the local router serves TLS — prewarmCert dials it to |
| +// trigger cert issuance for a new host. Loopback by default (the router binds |
| +// :443 on the same box); overridable if the router listens elsewhere. |
| +var routerHTTPSAddr = envOr("LCHAT_ROUTER_HTTPS_ADDR", "127.0.0.1:443") |
| + |
| var hostnameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) |
| // validHost reports whether h is a plausible DNS hostname. It's strict because |
| @@ -215,6 +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) |
| + |
| // 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), |
| @@ -517,6 +531,29 @@ 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() |
| + if err == nil { |
| + conn.Close() |
| + return |
| + } |
| + time.Sleep(time.Second) |
| + } |
| +} |
| + |
| // socketClient returns an HTTP client that dials an instance's unix socket |
| // (request URLs use the placeholder host http://lchat). |
| func socketClient(socket string) *http.Client { |
diff --git a/controld_test.go b/controld_test.go| index c882705..0a35937 100644 |
| --- a/controld_test.go |
| +++ b/controld_test.go |
| @@ -20,10 +20,11 @@ import ( |
| func controlTestEnv(t *testing.T) *Registry { |
| t.Helper() |
| - oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk := instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes |
| + oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk, oldWarm := instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes, prewarmCert |
| t.Cleanup(func() { |
| - instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes = oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk |
| + instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes, prewarmCert = oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk, oldWarm |
| }) |
| + prewarmCert = func(string, string) {} // no router to dial in tests |
| instanceDataDir = t.TempDir() |
| optDir = t.TempDir() |
| @@ -153,6 +154,22 @@ func TestControldCreatesInstance(t *testing.T) { |
| } |
| } |
| +func TestControldPrewarmsCert(t *testing.T) { |
| + reg := controlTestEnv(t) |
| + var warmed string |
| + prewarmCert = func(host, addr string) { warmed = host } |
| + srv, token := newTestControld(t, reg, 40) |
| + |
| + if resp := controlCreate(t, srv, token, "warm.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) |
| + } |
| +} |
| + |
| func TestControldAtCapacity(t *testing.T) { |
| reg := controlTestEnv(t) |
| srv, token := newTestControld(t, reg, 1) |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 8ee45ed..fc35ff9 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -91,7 +91,14 @@ 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. |
| +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. |
| 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 |