server: control plane shares port 443 with the admin UI

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 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 23:25:23 +02:00
co-authored by Claude Opus 5
parent 4aaaa5f5d4
commit 6d042a9d89
3 changed files with 86 additions and 4 deletions
+45 -4
View File
@@ -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)
}
+14
View File
@@ -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")