server: relay a test device's adb endpoint, because mDNS does not cross subnets

The beacon this replaces was a separate service wildcard-bound to
0.0.0.0:443 - it silently occupied port 443 on the reserved measurement
addresses, voiding the IPv4 interception proof for as long as it ran, and
it accepted a port report from anyone who could reach it. So this lives
where the repo's own post-mortem said it belongs: POST on the control
plane authenticated by the device credential, GET on the admin UI behind
the existing apiAdmin helper. No new listener, no new port, no wildcard.

Entries expire after 24h (ECHOLOT_ADB_ENDPOINT_RETENTION_H) on both write
and read - a LAN address is a breadcrumb for driving a test device, not
measurement data worth keeping.

Also records the BLE peer-comparison design: the case for it is that BLE
is out-of-band, which is what makes client isolation measurable at all -
silence over IP cannot distinguish an isolating AP from an absent peer,
and a peer confirming out-of-band that it was listening turns that
silence into proof.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 14:31:01 +02:00
co-authored by Claude Opus 5
parent ab6e278272
commit ae63bd7c7f
15 changed files with 918 additions and 14 deletions
+137
View File
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package store
import (
"testing"
"time"
)
func adbStore(t *testing.T, retention time.Duration) *Store {
t.Helper()
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
s.SetADBEndpointRetention(retention)
return s
}
// The port rotates, so a device's previous report is wrong rather than historical: keeping both
// would leave an operator trying a dead port.
func TestNewestReportWinsPerDevice(t *testing.T) {
s := adbStore(t, 24*time.Hour)
now := time.Now().UTC()
for _, e := range []ADBEndpoint{
{Device: "dev-a", Host: "10.13.102.128", Port: 37089, ReportedAt: now.Add(-2 * time.Minute)},
{Device: "dev-b", Host: "10.13.102.55", Port: 5555, ReportedAt: now.Add(-time.Minute)},
{Device: "dev-a", Host: "10.13.102.128", Port: 33667, ReportedAt: now},
} {
if err := s.PutADBEndpoint(e); err != nil {
t.Fatal(err)
}
}
got := s.ADBEndpoints()
if len(got) != 2 {
t.Fatalf("got %d rows, want one per device: %+v", len(got), got)
}
// Newest first, so the most recently reported device leads.
if got[0].Device != "dev-a" || got[0].Port != 33667 {
t.Fatalf("newest-first/newest-wins violated: %+v", got)
}
if got[1].Device != "dev-b" || got[1].Port != 5555 {
t.Fatalf("the other device's report was disturbed: %+v", got)
}
}
func TestRetentionForgetsOldEndpoints(t *testing.T) {
s := adbStore(t, 24*time.Hour)
now := time.Now().UTC()
for _, e := range []ADBEndpoint{
{Device: "stale", Host: "10.0.0.9", Port: 5555, ReportedAt: now.Add(-25 * time.Hour)},
{Device: "fresh", Host: "10.0.0.10", Port: 5555, ReportedAt: now},
} {
if err := s.PutADBEndpoint(e); err != nil {
t.Fatal(err)
}
}
got := s.ADBEndpoints()
if len(got) != 1 || got[0].Device != "fresh" {
t.Fatalf("retention not enforced on write: %+v", got)
}
// Reads must age the table too: an idle server still has to forget on schedule, and nobody
// writes to this table between one debugging session and the next.
s.data.ADBEndpoints[0].ReportedAt = now.Add(-25 * time.Hour)
if got := s.ADBEndpoints(); len(got) != 0 {
t.Fatalf("read path did not expire entries: %+v", got)
}
// The drop is real, not just filtered out of the answer on the way past.
if len(s.data.ADBEndpoints) != 0 {
t.Fatalf("expired rows survived the read: %+v", s.data.ADBEndpoints)
}
}
func TestZeroRetentionKeepsUntilReplaced(t *testing.T) {
s := adbStore(t, 0)
if err := s.PutADBEndpoint(ADBEndpoint{
Device: "dev", Host: "10.0.0.1", Port: 5555,
ReportedAt: time.Now().UTC().Add(-1000 * time.Hour),
}); err != nil {
t.Fatal(err)
}
if got := s.ADBEndpoints(); len(got) != 1 {
t.Fatal("retention 0 must mean 'keep until replaced', not 'keep nothing'")
}
}
// Dev telemetry must not become unbounded state just because a lot of devices are enrolled.
func TestEndpointTableIsBounded(t *testing.T) {
s := adbStore(t, 24*time.Hour)
now := time.Now().UTC()
for i := 0; i < maxADBEndpoints+5; i++ {
if err := s.PutADBEndpoint(ADBEndpoint{
Device: string(rune('a'+i)) + "-dev", Host: "10.0.0.1", Port: 5555 + i,
ReportedAt: now.Add(time.Duration(i) * time.Second),
}); err != nil {
t.Fatal(err)
}
}
got := s.ADBEndpoints()
if len(got) != maxADBEndpoints {
t.Fatalf("table holds %d rows, want the cap of %d", len(got), maxADBEndpoints)
}
// The rows evicted are the oldest, so the newest report is still there.
if got[0].Port != 5555+maxADBEndpoints+4 {
t.Fatalf("the newest report was evicted: %+v", got[0])
}
}
// Restarting the server must not resurrect an address the retention window already dropped.
func TestExpiredEndpointsDoNotSurviveReopen(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
s.SetADBEndpointRetention(time.Hour)
if err := s.PutADBEndpoint(ADBEndpoint{
Device: "dev", Host: "10.0.0.1", Port: 5555, ReportedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
// Age it on disk the way wall-clock time would.
s.data.ADBEndpoints[0].ReportedAt = time.Now().UTC().Add(-2 * time.Hour)
if err := s.save(); err != nil {
t.Fatal(err)
}
again, err := Open(dir)
if err != nil {
t.Fatal(err)
}
again.SetADBEndpointRetention(time.Hour)
if got := again.ADBEndpoints(); len(got) != 0 {
t.Fatalf("a stale LAN address came back after a restart: %+v", got)
}
}