server: reserve measurement addresses; serve the UI on both families
fmr keeps .151/::151 for measurement. Their diagnostic value is entirely in their listening state being known: a TLS handshake that completes on a port nothing listens on proves interception, with no competing explanation. One stray bind turns that proof into a shrug, and nothing about the failure is visible — the run still says the network is clean. CheckReserved refuses to start when a listener would take one. Wildcards are refused outright, because that is how this actually happens: every listener defaults to ":port" and the next one added gets copied from an existing default, claiming every address without anyone deciding to. Reserved is not silent, though. The first version of the guard would have refused the live config's UDP and canary-DNS binds on .151, which are deliberate — as is STUN's RFC 5780 alternate. Reserving an address and then forbidding the measurements that need it defeats the purpose. The rule is narrower: no services, and never ports 80 or 443. The adb-beacon receiver was wildcard-bound to 0.0.0.0:443, holding port 443 on every IPv4 address including the reserved one, so the IPv4 interception test had been compromised for as long as it had run. It is disabled; restore with systemctl enable --now echolot-adb-beacon. This also marks the guard's limit: it governs this server's listeners, and a process outside its config can still pollute a reserved address. The admin UI and ACME responder were single-address, which is why the UI could only live on ::2 and why the server was reachable over IPv6 alone — the thing that made it look nonexistent from a phone without working IPv6. Both now take address lists like every other listener. Verified from outside: .150/::150/::2 answer on 443 with a valid cert for fmr.echo-lot.app, .151/::151 are closed on 80 and 443, and canary DNS is still up on .151. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9ee8bc2ae
commit
5b5a54db7d
@@ -325,7 +325,17 @@ func serve(cfg *config.Config) error {
|
||||
}
|
||||
admin := ui.Handler()
|
||||
|
||||
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
||||
// One listener per configured address, all serving the same handler.
|
||||
//
|
||||
// Multi-address rather than a wildcard because this host reserves addresses for measurement:
|
||||
// binding 0.0.0.0 would put the admin UI on port 443 of the reserved pair, and their value
|
||||
// comes precisely from nothing answering there. Explicit addresses are also what let the
|
||||
// service and management addresses differ without a second process.
|
||||
adminAddrs := config.Addrs(cfg.AdminListen)
|
||||
if len(adminAddrs) == 0 {
|
||||
return fmt.Errorf("admin: no listen address configured")
|
||||
}
|
||||
var adminTLS *tls.Config
|
||||
if cfg.AdminTLSCert != "" {
|
||||
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
|
||||
// the control plane, so it is reuse rather than new machinery, and one process with one
|
||||
@@ -335,16 +345,33 @@ func serve(cfg *config.Config) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin TLS: %w", err)
|
||||
}
|
||||
adminSrv.TLSConfig = reloader.TLSConfig()
|
||||
adminTLS = reloader.TLSConfig()
|
||||
if exp := reloader.NotAfter(); !exp.IsZero() {
|
||||
slog.Info("admin UI TLS", "listen", cfg.AdminListen, "cert_expires", exp.Format(time.RFC3339))
|
||||
slog.Info("admin UI TLS", "listen", adminAddrs, "cert_expires", exp.Format(time.RFC3339))
|
||||
if time.Until(exp) < 14*24*time.Hour {
|
||||
slog.Warn("admin certificate expires soon", "expires", exp.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServeTLS("", "")) }()
|
||||
} else {
|
||||
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
|
||||
}
|
||||
// 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
|
||||
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.
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin listen %s: %w", addr, err)
|
||||
}
|
||||
srv := &http.Server{Handler: admin, ReadHeaderTimeout: 10 * time.Second, TLSConfig: adminTLS}
|
||||
adminSrvs = append(adminSrvs, srv)
|
||||
go func(ln net.Listener, addr string) {
|
||||
if adminTLS != nil {
|
||||
errCh <- fmt.Errorf("admin %s: %w", addr, srv.ServeTLS(ln, "", ""))
|
||||
return
|
||||
}
|
||||
errCh <- fmt.Errorf("admin %s: %w", addr, srv.Serve(ln))
|
||||
}(ln, addr)
|
||||
}
|
||||
|
||||
// ACME HTTP-01 responder. Permanent rather than started per renewal: nothing binds and
|
||||
@@ -358,14 +385,21 @@ func serve(cfg *config.Config) error {
|
||||
if err := acmehttp.EnsureWebroot(webroot); err != nil {
|
||||
return fmt.Errorf("acme webroot: %w", err)
|
||||
}
|
||||
acmeSrv := &http.Server{
|
||||
Addr: cfg.ACMEHTTPListen,
|
||||
Handler: acmehttp.Handler(webroot, cfg.AdminBaseURL),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
acmeHandler := acmehttp.Handler(webroot, cfg.AdminBaseURL)
|
||||
for _, addr := range config.Addrs(cfg.ACMEHTTPListen) {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acme-http listen %s: %w", addr, err)
|
||||
}
|
||||
srv := &http.Server{Handler: acmeHandler, ReadHeaderTimeout: 10 * time.Second}
|
||||
go func(ln net.Listener, addr string) {
|
||||
errCh <- fmt.Errorf("acme-http %s: %w", addr, srv.Serve(ln))
|
||||
}(ln, addr)
|
||||
}
|
||||
slog.Info("acme http-01 responder", "listen", cfg.ACMEHTTPListen, "webroot", webroot,
|
||||
"redirects_to", cfg.AdminBaseURL)
|
||||
go func() { errCh <- fmt.Errorf("acme-http: %w", acmeSrv.ListenAndServe()) }()
|
||||
// Every address the name may resolve to needs the responder: the CA picks one, and a
|
||||
// challenge that lands on an unbound address fails a renewal rather than a request.
|
||||
slog.Info("acme http-01 responder", "listen", config.Addrs(cfg.ACMEHTTPListen),
|
||||
"webroot", webroot, "redirects_to", cfg.AdminBaseURL)
|
||||
}
|
||||
|
||||
// UDP data plane — one socket per configured address. Distinct sockets
|
||||
@@ -457,7 +491,7 @@ func serve(cfg *config.Config) error {
|
||||
}
|
||||
|
||||
slog.Info("listening",
|
||||
"control", ctlAddrs, "admin", cfg.AdminListen, "udp", udpAddrs,
|
||||
"control", ctlAddrs, "admin", adminAddrs, "udp", udpAddrs,
|
||||
"tcp", config.Addrs(cfg.TCPListen), "stun", config.Addrs(cfg.StunListen),
|
||||
"dns", config.Addrs(cfg.DNSListen), "capabilities", ctl.Capabilities)
|
||||
|
||||
@@ -467,7 +501,9 @@ func serve(cfg *config.Config) error {
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = ctlSrv.Shutdown(shutCtx)
|
||||
_ = adminSrv.Shutdown(shutCtx)
|
||||
for _, srv := range adminSrvs {
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
}
|
||||
for _, c := range udpConns {
|
||||
_ = c.Close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user