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,66 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The read side of the dev relay (control/devtools.go submits, this reads back). It is here, in
|
||||
// the UI that already authenticates, rather than in a listener of its own — that is the lesson of
|
||||
// the beacon receiver it replaces, which was a separate unauthenticated service on port 443 of the
|
||||
// addresses reserved for measurement.
|
||||
|
||||
// adbRow is one relayed endpoint as the API and the dashboard both see it.
|
||||
//
|
||||
// age_s is computed rather than left to the reader: the port it describes rotates every few
|
||||
// minutes, so how old the report is decides whether it is worth trying at all.
|
||||
type adbRow struct {
|
||||
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 string `json:"source_ip,omitempty"`
|
||||
AgeS int `json:"age_s"`
|
||||
}
|
||||
|
||||
// adbRows reads the store's live endpoints (already newest first, already aged out).
|
||||
func (s *Server) adbRows() []adbRow {
|
||||
now := time.Now().UTC()
|
||||
eps := s.Store.ADBEndpoints()
|
||||
rows := make([]adbRow, 0, len(eps))
|
||||
for _, e := range eps {
|
||||
age := int(now.Sub(e.ReportedAt).Seconds())
|
||||
if age < 0 {
|
||||
age = 0 // a clock that ran backwards should read "just now", not negative
|
||||
}
|
||||
rows = append(rows, adbRow{
|
||||
Device: e.Device, Host: e.Host, Port: e.Port,
|
||||
DeviceName: e.DeviceName, Note: e.Note,
|
||||
ReportedAt: e.ReportedAt, SourceIP: e.SourceIP, AgeS: age,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// adbEndpointsAPI is GET /admin/adb-endpoints — the developer's half of the relay.
|
||||
//
|
||||
// Authenticated exactly like the mint endpoint, so `curl -u admin:PASS` works from a script and a
|
||||
// signed-in browser session works without one. Admin-only: a LAN address and a debug port are the
|
||||
// operator's business, and a user's uploads are not made safer by handing out either.
|
||||
func (s *Server) adbEndpointsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.apiAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
rows := s.adbRows()
|
||||
if rows == nil {
|
||||
rows = []adbRow{} // an empty list, never null: a caller should not have to special-case it
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rows)
|
||||
}
|
||||
Reference in New Issue
Block a user