eleven / ocho Public

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>
Paul Campbell 9e785d981150476572fbcf3780851795e95904e0 parent ab3fab5
3 files changed, +64 −3
  • control.go +37 −0
  • controld_test.go +19 −2
  • docs/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