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>
124 lines
4.7 KiB
Go
124 lines
4.7 KiB
Go
// 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, ", ")
|
|
}
|