diff --git a/docs/build-status.md b/docs/build-status.md index 156593f..6f782fa 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -1189,3 +1189,30 @@ beside an admin session. The one thing that changes on the device side is that the POST becomes HTTPS. That is a real certificate rather than a self-signed one, so it costs a URL scheme rather than any trust plumbing. + +## The control plane shares port 443 (2026-08-01) + +`fmr-1.echo-lot.app:443` is the control plane, `fmr.echo-lot.app:443` the admin UI, both on +`.150`/`::150`, one listener, selected by SNI for the certificate and by `Host` for the handler. + +The reason is not tidiness, it is reachability. Captive portals, hotel wifi and corporate firewalls +routinely permit only 80 and 443 — which is exactly the population of networks this tool exists to +diagnose. A control plane on 8443 is unreachable precisely when it matters most, and it fails as +"cannot reach server", which tells the user nothing. + +They cannot share a certificate, which is why this needs two names. The control plane is trusted by +SPKI pin and so uses a long-lived self-signed certificate; a browser needs one a CA vouches for. +One name on one port is one certificate, so the port can only be shared by splitting the names. +Pinning the Let's Encrypt key instead was considered and rejected: it survives renewal only while +key reuse holds, so a routine key rotation would brick the whole fleet. + +Verified per SNI on 443: `fmr.echo-lot.app` serves `issuer=Let's Encrypt`, `fmr-1.echo-lot.app` +serves the self-signed cert whose pin is unchanged (`zRV9…Xlg=`), `/v1/profile` answers 401 on the +control name and 303 to the login page on the UI name. + +**8443 stays open.** Devices enrolled before this carry that URL in their settings, and closing it +for the sake of a port number would strand every one of them. It can go once no enrolled device +still points at it — not before. + +The rule from the naming change still binds: `fmr` may be a CNAME to exactly one host and never a +multi-address record, because a pinned client that reaches a different key does not fail over. diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 4913e6a..ac9269f 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -390,6 +390,41 @@ func serve(cfg *config.Config) error { // Kept for shutdown: each listener gets its own server, and a graceful stop has to reach all // of them or an in-flight admin request is cut off mid-response on every address but one. var adminSrvs []*http.Server + // Sharing port 443 between two services that cannot share a certificate. The name in the TLS + // handshake picks the certificate, and the name in the request picks the handler; both have to + // agree or a client would get the pinned certificate and the admin UI behind it. + // + // The control plane keeps its own listener as well. Devices enrolled before this carry the old + // URL in their settings, and taking that away would strand every one of them for the sake of a + // port number. + ctlHandler := ctl.Handler() + sharedCert := cert + pickCert := func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { + if cfg.ControlHostname != "" && strings.EqualFold(hi.ServerName, cfg.ControlHostname) { + return &sharedCert, nil + } + if adminTLS != nil && adminTLS.GetCertificate != nil { + return adminTLS.GetCertificate(hi) + } + return &sharedCert, nil + } + route := func(w http.ResponseWriter, r *http.Request) { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if cfg.ControlHostname != "" && strings.EqualFold(host, cfg.ControlHostname) { + ctlHandler.ServeHTTP(w, r) + return + } + admin.ServeHTTP(w, r) + } + sharedTLS := &tls.Config{GetCertificate: pickCert, MinVersion: tls.VersionTLS12} + if cfg.ControlHostname != "" { + slog.Info("control plane shares the admin port", + "hostname", cfg.ControlHostname, "listen", adminAddrs) + } + for _, addr := range adminAddrs { // Bound before the goroutine starts, so a bad address fails startup rather than being // reported asynchronously after the process has already declared itself healthy. @@ -397,14 +432,20 @@ func serve(cfg *config.Config) error { if err != nil { return fmt.Errorf("admin listen %s: %w", addr, err) } - srv := &http.Server{Handler: admin, ReadHeaderTimeout: 10 * time.Second, TLSConfig: adminTLS} + srv := &http.Server{ + Handler: http.HandlerFunc(route), + ReadHeaderTimeout: 10 * time.Second, + TLSConfig: sharedTLS, + } adminSrvs = append(adminSrvs, srv) go func(ln net.Listener, addr string) { - if adminTLS != nil { - errCh <- fmt.Errorf("admin %s: %w", addr, srv.ServeTLS(ln, "", "")) + // Plaintext only where there is no certificate at all — checkAdminExposure has + // already refused that anywhere but loopback. + if adminTLS == nil && cfg.ControlHostname == "" { + errCh <- fmt.Errorf("admin %s: %w", addr, srv.Serve(ln)) return } - errCh <- fmt.Errorf("admin %s: %w", addr, srv.Serve(ln)) + errCh <- fmt.Errorf("admin %s: %w", addr, srv.ServeTLS(ln, "", "")) }(ln, addr) } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index fb9222d..89789d9 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -46,6 +46,19 @@ type Config struct { // fact about this server's configuration. Enforced by CheckReserved. ReservedAddrs string // ECHOLOT_RESERVED_ADDRS / --reserved-addrs + // ControlHostname lets the control plane share port 443 with the admin UI. + // + // They cannot share a certificate: the control plane is trusted by SPKI pin and so uses a + // long-lived self-signed certificate, while a browser needs one a CA vouches for. One name on + // one port means one certificate, so sharing the port requires two names — this one selects + // the pinned certificate and the control-plane routes by SNI, everything else gets the admin + // UI. Empty leaves the control plane on its own listener only. + // + // Why bother: captive portals and corporate firewalls routinely permit only 80 and 443, which + // are exactly the networks this tool exists to diagnose. A control plane on 8443 is + // unreachable precisely when it matters most. + ControlHostname string // ECHOLOT_CONTROL_HOSTNAME / --control-hostname + // State directory: device store, generated TLS material. StateDir string // ECHOLOT_STATE_DIR / --state-dir @@ -167,6 +180,7 @@ func Load(args []string) (*Config, *Actions, error) { fs.StringVar(&c.HTTPEchoListen, "http-echo-listen", envOr("HTTP_ECHO_LISTEN", ""), "optional CLEARTEXT http-echo listen address(es); empty disables (spec §4)") fs.StringVar(&c.MTUProbeTargets, "mtu-probe-targets", envOr("MTU_PROBE_TARGETS", "1.1.1.1,2606:4700:4700::1111"), "egress-MTU self-proof anchors, comma-separated") fs.StringVar(&c.AdminListen, "admin-listen", envOr("ADMIN_LISTEN", "127.0.0.1:8444"), "admin/health listen address (keep localhost)") + fs.StringVar(&c.ControlHostname, "control-hostname", envOr("CONTROL_HOSTNAME", ""), "hostname that selects the pinned control-plane certificate when sharing the admin UI's port") fs.StringVar(&c.ReservedAddrs, "reserved-addrs", envOr("RESERVED_ADDRS", ""), "comma-separated IPs reserved for measurement; no listener but the STUN alternate may bind them") fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)") fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name")