// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package config import ( "fmt" "net" "sort" "strings" ) // Listener is one configured listen spec, named for the error message. type Listener struct { Name string // the flag/env this came from, e.g. "control-listen" Spec string // comma-separated listen addresses // Measurement marks a listener that is part of the instrument rather than a service on the // host. Those belong on the reserved addresses — STUN's RFC 5780 alternate, the UDP data // plane, the canary DNS — and reserving an address only to forbid the measurements that need // it would defeat the purpose. Measurement bool } // webPorts are the ports whose closed state on a reserved address is itself the measurement. // // A TLS handshake that completes on a port known not to be listening proves interception, with no // competing explanation. That proof is the whole reason for reserving an address, and it survives // exactly as long as nothing binds these two ports there. var webPorts = map[string]bool{"80": true, "443": true} // CheckReserved refuses to start when a listener would occupy an address reserved for measurement. // // The reserved addresses are the instrument, not the service. Their diagnostic value comes from // their listening state being *known*: if nothing listens on port 443 there, then a TLS handshake // that completes proves something on the path intercepted it, with no other explanation available. // One stray listener silently converts that proof into an ambiguity. // // This is a hard stop rather than a warning for the same reason as [Config.checkAdminExposure]: the // failure is invisible. A polluted reserved address does not crash, log, or behave oddly — it just // quietly turns a conclusive test into an inconclusive one, and the first symptom is a measurement // that says the network is clean when it is not. Nobody reads a warning for that. // // Wildcard binds are the realistic way this happens. Every listener defaults to ":port", and the // next one added will be copied from an existing default; that binds every address on the host, // reserved ones included, without anyone deciding to. func (c *Config) CheckReserved(listeners []Listener) error { reserved := c.ReservedIPs() if len(reserved) == 0 { return nil } var problems []string for _, l := range listeners { for _, addr := range Addrs(l.Spec) { host, port, err := net.SplitHostPort(addr) if err != nil { // Not host:port — a bare port or something malformed. Leave it to the listener // itself to complain; guessing here would produce a confusing error about the // wrong problem. continue } host = strings.Trim(host, "[]") if host == "" || host == "0.0.0.0" || host == "::" { problems = append(problems, fmt.Sprintf( " --%s=%q binds every address on this host, including the reserved ones", l.Name, addr)) continue } ip := net.ParseIP(host) if ip == nil { continue // a hostname; cannot resolve it here without lying about what we checked } for _, r := range reserved { if !ip.Equal(r) { continue } switch { case webPorts[port]: problems = append(problems, fmt.Sprintf( " --%s=%q puts port %s on reserved address %s, which is the one thing "+ "that address exists to keep closed", l.Name, addr, port, r)) case !l.Measurement: problems = append(problems, fmt.Sprintf( " --%s=%q binds reserved address %s; only measurement listeners belong there", l.Name, addr, r)) } } } } if len(problems) == 0 { return nil } sort.Strings(problems) return fmt.Errorf( "refusing to start: these listeners would occupy addresses reserved for measurement\n%s\n"+ "\nReserved: %s\n"+ "Those addresses are the instrument. A test can only prove interception on a port that\n"+ "is known not to be listening, so anything bound there destroys the conclusion rather\n"+ "than merely sharing the address.\n"+ " Fix it one of three ways:\n"+ " - bind each listener to explicit service addresses instead of a wildcard\n"+ " - remove the address from ECHOLOT_RESERVED_ADDRS if it is no longer reserved\n"+ " - unset ECHOLOT_RESERVED_ADDRS if this host has no reserved addresses", strings.Join(problems, "\n"), joinIPs(reserved)) } // ReservedIPs parses the configured reserved addresses, ignoring anything unparseable. func (c *Config) ReservedIPs() []net.IP { var out []net.IP for _, s := range Addrs(c.ReservedAddrs) { if ip := net.ParseIP(strings.Trim(s, "[]")); ip != nil { out = append(out, ip) } } return out } func joinIPs(ips []net.IP) string { s := make([]string, 0, len(ips)) for _, ip := range ips { s = append(s, ip.String()) } return strings.Join(s, ", ") }