From 6d042a9d8959ec039ac7639c5572aa24160f9ce5 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 23:25:23 +0200 Subject: [PATCH] server: control plane shares port 443 with the admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captive portals, hotel wifi and corporate firewalls routinely permit only 80 and 443 — exactly the 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 about why. The two cannot share a certificate, so sharing the port needs two names. The control plane is trusted by SPKI pin and uses a long-lived self-signed certificate; a browser needs one a CA vouches for. One name on one port is one certificate. 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 fleet. One listener now picks the certificate by SNI and the handler by Host. Both have to agree, or a client gets the pinned certificate with the admin UI behind it. 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 nothing points at it. Verified per SNI on 443: fmr.echo-lot.app serves the Let's Encrypt cert, fmr-1.echo-lot.app serves the self-signed one whose pin is unchanged, and /v1/profile answers 401 on the control name against 303 to the login page on the UI name. Co-Authored-By: Claude Opus 5 --- docs/build-status.md | 27 +++++++++++++++++ server/cmd/echolot-server/main.go | 49 ++++++++++++++++++++++++++++--- server/internal/config/config.go | 14 +++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) 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")