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
@@ -41,6 +41,11 @@ type Config struct {
|
||||
// Admin UI / health listener (spec §7: localhost-only by default)
|
||||
AdminListen string // ECHOLOT_ADMIN_LISTEN / --admin-listen
|
||||
|
||||
// ReservedAddrs are IPs reserved for measurement: addresses whose listening state must stay
|
||||
// known, so that "nothing answered on port 443" is a fact about the network rather than a
|
||||
// fact about this server's configuration. Enforced by CheckReserved.
|
||||
ReservedAddrs string // ECHOLOT_RESERVED_ADDRS / --reserved-addrs
|
||||
|
||||
// State directory: device store, generated TLS material.
|
||||
StateDir string // ECHOLOT_STATE_DIR / --state-dir
|
||||
|
||||
@@ -162,6 +167,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.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")
|
||||
fs.StringVar(&c.SelfUpdateAPI, "self-update-api", envOr("SELF_UPDATE_API", ""), "Gitea repo API base for self-update; empty disables")
|
||||
@@ -216,6 +222,9 @@ func Load(args []string) (*Config, *Actions, error) {
|
||||
if err := c.checkAdminExposure(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := c.CheckReserved(c.Listeners()); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if c.Docker && (a.InstallSystemd || a.UninstallSystemd || a.SelfUpdate) {
|
||||
return nil, nil, fmt.Errorf("systemd/self-update actions are native-mode only (container detected; override with ECHOLOT_DOCKER=0 if this is wrong)")
|
||||
@@ -276,6 +285,28 @@ func (c *Config) checkAdminExposure() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Listeners enumerates every configured listen spec, for CheckReserved.
|
||||
//
|
||||
// Kept as one list here rather than checked at each call site, so a listener added later is
|
||||
// caught by the compiler when this function is updated — and, more to the point, so that the
|
||||
// person adding one sees the reserved-address rule exists at all.
|
||||
func (c *Config) Listeners() []Listener {
|
||||
return []Listener{
|
||||
// The instrument: these belong on the reserved addresses as much as anywhere.
|
||||
{Name: "control-listen", Spec: c.ControlListen, Measurement: true},
|
||||
{Name: "udp-listen", Spec: c.UDPListen, Measurement: true},
|
||||
{Name: "tcp-listen", Spec: c.TCPListen, Measurement: true},
|
||||
{Name: "dns-listen", Spec: c.DNSListen, Measurement: true},
|
||||
{Name: "stun-listen", Spec: c.StunListen, Measurement: true},
|
||||
// http-echo is deliberately not marked as measurement: it is cleartext HTTP, so on a
|
||||
// reserved address it would be the very listener that ruins the port-80 test.
|
||||
{Name: "http-echo-listen", Spec: c.HTTPEchoListen},
|
||||
// Services. These have no business on an address kept for measuring.
|
||||
{Name: "admin-listen", Spec: c.AdminListen},
|
||||
{Name: "acme-http-listen", Spec: c.ACMEHTTPListen},
|
||||
}
|
||||
}
|
||||
|
||||
// Addrs splits a comma-separated listen spec into individual addresses.
|
||||
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind
|
||||
// (":8443") would also claim addresses reserved for other purposes (e.g. an
|
||||
|
||||
Reference in New Issue
Block a user