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
@@ -0,0 +1,151 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
svc4 = "89.185.109.150"
|
||||
res4 = "89.185.109.151"
|
||||
res6 = "2001:1ad0:c4fe:6767::151"
|
||||
)
|
||||
|
||||
func withReserved(l ...Listener) error {
|
||||
c := &Config{ReservedAddrs: res4 + "," + res6}
|
||||
return c.CheckReserved(l)
|
||||
}
|
||||
|
||||
func TestWildcardBindIsRefused(t *testing.T) {
|
||||
// The realistic failure: every listener defaults to ":port", and the next one added gets
|
||||
// copied from an existing default. Nobody decides to claim the reserved address; it just
|
||||
// happens, and nothing looks wrong afterwards.
|
||||
err := withReserved(Listener{Name: "control-listen", Spec: ":8443"})
|
||||
if err == nil {
|
||||
t.Fatal("a wildcard bind was allowed while addresses were reserved")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "binds every address") {
|
||||
t.Fatalf("the error should say why a wildcard is the problem, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebPortsOnReservedAreRefusedEvenForMeasurement(t *testing.T) {
|
||||
// The strictest rule, and the one carrying the diagnostic value: 80 and 443 must stay closed
|
||||
// on a reserved address whatever wants them, because their closed state *is* the measurement.
|
||||
for _, spec := range []string{res4 + ":443", "[" + res6 + "]:80"} {
|
||||
err := withReserved(Listener{Name: "control-listen", Spec: spec, Measurement: true})
|
||||
if err == nil {
|
||||
t.Fatalf("port 80/443 on a reserved address was allowed: %q", spec)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "keep closed") {
|
||||
t.Errorf("the error should explain what is lost, got: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceOnReservedIsRefused(t *testing.T) {
|
||||
if err := withReserved(Listener{Name: "admin-listen", Spec: res4 + ":8444"}); err == nil {
|
||||
t.Fatal("a service was allowed onto a reserved address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasurementListenersBelongOnReserved(t *testing.T) {
|
||||
// The live fmr config: the UDP data plane, canary DNS and STUN all bind the reserved pair on
|
||||
// purpose. A guard that refused this would be describing a rule nobody wants.
|
||||
err := withReserved(
|
||||
Listener{Name: "udp-listen", Spec: res4 + ":8442,[" + res6 + "]:8442", Measurement: true},
|
||||
Listener{Name: "dns-listen", Spec: res4 + ":53,[" + res6 + "]:53", Measurement: true},
|
||||
Listener{Name: "stun-listen", Spec: res4 + ":3478", Measurement: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("measurement listeners must be allowed on reserved addresses: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAddressesAreFine(t *testing.T) {
|
||||
err := withReserved(
|
||||
Listener{Name: "control-listen", Spec: svc4 + ":8443,[2001:1ad0:c4fe:6767::150]:8443"},
|
||||
Listener{Name: "admin-listen", Spec: "127.0.0.1:8444"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("service addresses should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpEchoIsNotTreatedAsMeasurement(t *testing.T) {
|
||||
// http-echo is cleartext HTTP. On a reserved address it is precisely the listener that would
|
||||
// ruin the port-80 test, so it does not get the measurement exemption.
|
||||
if err := withReserved(Listener{Name: "http-echo-listen", Spec: res4 + ":8080"}); err == nil {
|
||||
t.Fatal("http-echo was allowed onto a reserved address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoReservationMeansNoOpinion(t *testing.T) {
|
||||
// A host with nothing reserved must keep working exactly as before, wildcards included.
|
||||
c := &Config{}
|
||||
if err := c.CheckReserved([]Listener{{Name: "control-listen", Spec: ":8443"}}); err != nil {
|
||||
t.Fatalf("with no reserved addresses this must not interfere: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryOffenderIsNamed(t *testing.T) {
|
||||
// Reporting one problem at a time turns a config fix into several restart cycles, and on a
|
||||
// remote host each cycle is a chance to lock yourself out.
|
||||
err := withReserved(
|
||||
Listener{Name: "control-listen", Spec: ":8443"},
|
||||
Listener{Name: "tcp-listen", Spec: res4 + ":8441"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected a refusal")
|
||||
}
|
||||
for _, want := range []string{"control-listen", "tcp-listen"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error should name %s; got: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostnamesAreNotGuessedAt(t *testing.T) {
|
||||
// Resolving here would check a name against whatever DNS says at startup, which is not
|
||||
// necessarily what it will say later — and a guard that is sometimes right is worse than one
|
||||
// with a stated limit.
|
||||
if err := withReserved(Listener{Name: "control-listen", Spec: "fmr-1.echo-lot.app:8443"}); err != nil {
|
||||
t.Fatalf("a hostname must be left alone, not resolved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenersCoversEverySpec(t *testing.T) {
|
||||
// A listener missing from Listeners() is invisible to the guard, which is the one way this
|
||||
// protection fails silently. Fill every spec with the reserved address: each one that is
|
||||
// actually enumerated produces a complaint naming it.
|
||||
// Every spec on port 443 of the reserved address: the web-port rule applies to measurement
|
||||
// listeners too, so each one that is genuinely enumerated must produce a complaint.
|
||||
c := &Config{
|
||||
ReservedAddrs: res4,
|
||||
ControlListen: res4 + ":443",
|
||||
UDPListen: res4 + ":443",
|
||||
TCPListen: res4 + ":443",
|
||||
DNSListen: res4 + ":443",
|
||||
HTTPEchoListen: res4 + ":443",
|
||||
AdminListen: res4 + ":443",
|
||||
ACMEHTTPListen: res4 + ":443",
|
||||
StunListen: res4 + ":443",
|
||||
}
|
||||
err := c.CheckReserved(c.Listeners())
|
||||
if err == nil {
|
||||
t.Fatal("expected a refusal")
|
||||
}
|
||||
// Every spec is on the reserved address; the web-port rule catches even the measurement ones,
|
||||
// so anything missing from Listeners() is invisible here and that is what this asserts.
|
||||
for _, want := range []string{
|
||||
"control-listen", "udp-listen", "tcp-listen", "dns-listen",
|
||||
"http-echo-listen", "admin-listen", "acme-http-listen", "stun-listen",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s is not enumerated in Listeners(), so the guard cannot see it", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user