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:
co-authored by
Claude Opus 5
parent
ab6e278272
commit
ae63bd7c7f
@@ -0,0 +1,117 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ADBEndpoint is one device's report of where its wireless-debug listener can be reached.
|
||||
//
|
||||
// Dev tooling, not measurement: mDNS does not cross subnets, so an Echolot instance sitting on the
|
||||
// test LAN discovers adbd's rotating port there and relays it here for a developer on another
|
||||
// subnet. It lives in the same state file as devices and tokens because it is a handful of rows —
|
||||
// a second file, or a database, would be more machinery than the data deserves.
|
||||
type ADBEndpoint struct {
|
||||
// Device is the id of the *submitting* device, taken from its credential rather than from the
|
||||
// body: the report is keyed by who sent it, so nobody can overwrite anyone else's row.
|
||||
Device string `json:"device"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ReportedAt time.Time `json:"reported_at"`
|
||||
// SourceIP is where the report arrived from, as this server observed it — the one field here
|
||||
// the submitter does not get to choose, and the one that says which side of the NAT it sent from.
|
||||
SourceIP string `json:"source_ip,omitempty"`
|
||||
}
|
||||
|
||||
// maxADBEndpoints bounds the table. One row per device under test is the expected scale; the cap
|
||||
// is what stops a fleet of enrolled devices from turning a debugging aid into unbounded state.
|
||||
const maxADBEndpoints = 16
|
||||
|
||||
// SetADBEndpointRetention sets how long relayed endpoints are kept; <= 0 keeps them until the
|
||||
// device replaces its own row or the cap evicts it.
|
||||
//
|
||||
// Not persisted: it is configuration (ECHOLOT_ADB_ENDPOINT_RETENTION_H), not state, and reading it
|
||||
// back out of the state file would let a stale copy outlive the operator's decision.
|
||||
func (s *Store) SetADBEndpointRetention(d time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.adbRetention = d
|
||||
}
|
||||
|
||||
// PutADBEndpoint records a device's endpoint, replacing whatever it reported before.
|
||||
//
|
||||
// Newest wins per device rather than appending: a rotated port makes the previous one wrong, not
|
||||
// historical, and an operator reading a list of dead ports would try them.
|
||||
func (s *Store) PutADBEndpoint(e ADBEndpoint) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if e.ReportedAt.IsZero() {
|
||||
e.ReportedAt = time.Now().UTC()
|
||||
}
|
||||
s.dropExpiredADBLocked(e.ReportedAt)
|
||||
replaced := false
|
||||
for i := range s.data.ADBEndpoints {
|
||||
if s.data.ADBEndpoints[i].Device == e.Device {
|
||||
s.data.ADBEndpoints[i] = e
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
s.data.ADBEndpoints = append(s.data.ADBEndpoints, e)
|
||||
}
|
||||
// Oldest first out when the cap is reached: the row nobody has refreshed is the one least
|
||||
// likely to still describe a listening port.
|
||||
if len(s.data.ADBEndpoints) > maxADBEndpoints {
|
||||
sort.SliceStable(s.data.ADBEndpoints, func(i, j int) bool {
|
||||
return s.data.ADBEndpoints[i].ReportedAt.Before(s.data.ADBEndpoints[j].ReportedAt)
|
||||
})
|
||||
s.data.ADBEndpoints = s.data.ADBEndpoints[len(s.data.ADBEndpoints)-maxADBEndpoints:]
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// ADBEndpoints returns the live reports, newest first.
|
||||
func (s *Store) ADBEndpoints() []ADBEndpoint {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
before := len(s.data.ADBEndpoints)
|
||||
s.dropExpiredADBLocked(time.Now().UTC())
|
||||
if len(s.data.ADBEndpoints) != before {
|
||||
// The read path writes too: an address that has expired should stop existing in the file,
|
||||
// not merely be filtered out of the answer. A failed save leaves the returned view correct
|
||||
// and the next write to retry, so it is not worth failing a read over.
|
||||
_ = s.save()
|
||||
}
|
||||
out := append([]ADBEndpoint(nil), s.data.ADBEndpoints...)
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].ReportedAt.After(out[j].ReportedAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
// dropExpiredADBLocked enforces the retention window.
|
||||
//
|
||||
// A LAN address earns an expiry that a device id does not: it describes the inside of somebody's
|
||||
// home or office network — which subnet, which host, which port a debug shell answers on — and it
|
||||
// stops being true within minutes, because adbd rotates the port. Keeping it after that trades
|
||||
// every bit of that disclosure for nothing at all. Applied on write and on read, whichever comes
|
||||
// first, so an idle server still forgets on schedule the moment anyone looks.
|
||||
func (s *Store) dropExpiredADBLocked(now time.Time) {
|
||||
if s.adbRetention <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := now.Add(-s.adbRetention)
|
||||
kept := s.data.ADBEndpoints[:0]
|
||||
for _, e := range s.data.ADBEndpoints {
|
||||
if e.ReportedAt.After(cutoff) {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
// Reallocate rather than reslice in place, so the dropped rows are not left addressable in the
|
||||
// old backing array.
|
||||
s.data.ADBEndpoints = append([]ADBEndpoint(nil), kept...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
data fileData
|
||||
// How long relayed adb endpoints live. Configuration rather than state, so it is set by the
|
||||
// caller after Open and never read back from the file (see SetADBEndpointRetention).
|
||||
adbRetention time.Duration
|
||||
}
|
||||
|
||||
type fileData struct {
|
||||
@@ -67,6 +70,9 @@ type fileData struct {
|
||||
// deleting it from the state file invalidates every session at once, which is how an
|
||||
// operator revokes them.
|
||||
SessionSecret string `json:"session_secret,omitempty"`
|
||||
// Dev-relay breadcrumbs (adbendpoint.go). Not measurement data and not part of the protocol;
|
||||
// they ride along here because this is already where small server state lives.
|
||||
ADBEndpoints []ADBEndpoint `json:"adb_endpoints,omitempty"`
|
||||
}
|
||||
|
||||
func Open(stateDir string) (*Store, error) {
|
||||
|
||||
Reference in New Issue
Block a user