diff --git a/docs/build-status.md b/docs/build-status.md index 113a0e3..7fa418a 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -1379,6 +1379,60 @@ poisons `/releases/latest` for the string-comparing updater the moment anyone ta The stale `server-v0.9.2` release (same code lineage, wrong number, created during the confusion) remains in Gitea but is harmless now that v0.11.3 outranks it as latest. +## Design note: what BLE between two devices is actually for (2026-08-02, not built) + +Two or more phones running Echolot, talking over Bluetooth LE. The schema already anticipates +this — `Trigger.PEER` and the whole `peer.*` test family (`peer.reachability`, `peer.isolation`, +`peer.multicast`, `peer.lan_train`, `peer.lease_diff`) are in the registry, unused — and the +prober measured `peer.ble_advertise` **SUPPORTED on both known devices**, so the mechanism is +proven; what has been missing is a reason that beats "use the server". + +**The reason is that BLE is out-of-band.** Everything else this app does depends on the network +under test being at least partly functional. A second device reachable over a radio that shares +nothing with the wifi turns several measurements from ambiguous into conclusive: + +1. **Client isolation becomes measurable at all.** Today, "I sent a packet to the peer and heard + nothing" cannot distinguish AP client isolation from the peer being asleep, gone, or on a + different VLAN — the failure mode is silence, and silence has too many parents. With BLE the + peer confirms out-of-band that it was listening on address X at time T, so silence over IP + becomes *proof* of isolation rather than a guess. This is the single strongest argument for + the feature, and it mirrors the rule this project keeps rediscovering: a measurement that + cannot separate "nothing happened" from "nothing was tried" is not a measurement. +2. **Differential diagnosis: the network or this phone?** Two devices on the same SSID, one + resolving DNS and one not, settles in seconds what a single device cannot settle at all — + and it is the same distinction `system_verdict` exists to draw, only with a second opinion + instead of Android's. Natural finding: *this device fails where a peer on the same link + succeeds* → look at the device (private DNS, ad blocker, per-client router rule, MAC + randomization), not the router. +3. **Two DHCP servers on one L2**, the classic invisible fault: peers compare lease source, + subnet and gateway (`peer.lease_diff`). Disagreement is conclusive and needs no server. +4. **Coverage and roaming**, later: several devices sampling RSSI in different rooms, exchanging + summaries over BLE, gives a picture no single device standing in one place can produce. + +**What crosses the link is a summary, never the document.** A measurement document describes +someone's home network in detail; broadcasting it to whoever is nearby would betray the whole +posture of §8. The peer payload should be: a *hashed* network identity (so two devices can agree +they are on the same L2 without either putting the SSID/BSSID on the air in the clear), the §7.3 +category verdicts, the finding codes, and an IP endpoint plus a one-shot nonce for the LAN tests. +Findings and verdicts are already the interpretation layer — exactly the right granularity to +share. + +**Privacy constraints, which are not optional here.** A BLE advertiser is a tracking beacon: it +must be user-initiated, time-boxed to the run, carry no identifier that is stable across runs +(the resolvable-private-address default plus a per-session ephemeral id), and pair by a code the +two humans can see. "Discoverable by default" would make this app a worse citizen than the +networks it audits. + +**Deliberately not doing:** clock synchronisation over BLE. GATT latency is jitter measured in +tens of milliseconds, which is the same order as the one-way delays worth measuring; peers should +sync against the server's `time.server_offset` and use BLE only to correlate run ids. Nor should +BLE become a transport for uploads — it is a *comparison* channel. + +Staging when it happens: `peer.isolation` first (highest value, needs only advertise + connect + +a nonce exchange), then `peer.lease_diff` (pure summary comparison, no extra plumbing), then the +rest. Needs `BLUETOOTH_ADVERTISE/CONNECT/SCAN` in the manifest, which the app does not yet +request. + ## v0.11.3 live on fmr; trains validated end to end (2026-08-02) Deployed via `--self-update` (the pre-signing v0.11.2 updater accepted the first signed release, diff --git a/server/README.md b/server/README.md index 010bad3..04d008c 100644 --- a/server/README.md +++ b/server/README.md @@ -143,6 +143,47 @@ curl -sk https://:8443/v1/profile -H 'Authorization: Bearer ' The SPKI pin clients must verify is logged at startup (`pin-sha256`). +## Dev relay (adb endpoint) + +Development scaffolding, not protocol: it appears in no capability list and in no measurement +document, and `docs/probe-protocol.md` does not describe it. + +It exists because **mDNS does not cross subnets**. Android's wireless debugging advertises adbd's +port over mDNS and rotates that port every few minutes, so a developer on another subnet cannot +discover it at all. An Echolot instance running on the test LAN can — and relays it here. + +``` +POST /v1/devtools/adb-endpoint control plane, device credential (same Bearer as /v1/sessions) + {"host":"10.13.102.128","port":45305,"device_name":"TB330FU","note":"…"} +GET /admin/adb-endpoints admin UI, session cookie or HTTP Basic +``` + +Read it back from the developer's machine (the admin listener is loopback-only, so over the same +SSH tunnel as everything else): + +```sh +curl -s -u admin: -H 'Accept: application/json' \ + http://127.0.0.1:8444/admin/adb-endpoints +# → [{"device":"…","host":"10.13.102.128","port":45305,"device_name":"TB330FU", +# "reported_at":"…","source_ip":"…","age_s":37}] +``` + +The same list is a card on the admin dashboard, so it is usable without curl. + +The newest report per submitting device wins — a rotated port makes the previous one wrong, not +historical. The device id comes from the credential and `source_ip` from the connection, so neither +is something a body can claim. `ECHOLOT_ADB_ENDPOINT_RETENTION_H` (default 24) bounds how long an +entry lives; `0` keeps it until the device replaces it. The window exists because the value is a +LAN address that stops being true within minutes: keeping it afterwards discloses the inside of +someone's network in exchange for nothing. + +**Why it lives on these two listeners and nowhere else.** Its predecessor was a separate Python +service wildcard-bound to `0.0.0.0:443`. That silently occupied port 443 on the addresses reserved +for measurement — voiding the IPv4 interception proof for as long as it ran — and it accepted a port +report from anyone who could reach it. So: no new listener, no new port, no wildcard bind, and +nothing unauthenticated. The submit side is rate-limited by the existing §2.5 actions bucket +(`ECHOLOT_RATE_ACTIONS_PER_MIN`). + ## Development ```sh diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 85febff..381b0c4 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -194,6 +194,9 @@ func serve(cfg *config.Config) error { if err != nil { return fmt.Errorf("state store: %w", err) } + // Dev-relay breadcrumbs carry a LAN address, so they expire on a clock like the canary DNS + // log does rather than sitting in the state file until someone notices them. + st.SetADBEndpointRetention(time.Duration(cfg.ADBEndpointRetentionH) * time.Hour) cert, err := loadOrCreateCert(cfg) if err != nil { return fmt.Errorf("tls: %w", err) diff --git a/server/internal/adminui/adbendpoints.go b/server/internal/adminui/adbendpoints.go new file mode 100644 index 0000000..f526b84 --- /dev/null +++ b/server/internal/adminui/adbendpoints.go @@ -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) +} diff --git a/server/internal/adminui/adbendpoints_test.go b/server/internal/adminui/adbendpoints_test.go new file mode 100644 index 0000000..3368de2 --- /dev/null +++ b/server/internal/adminui/adbendpoints_test.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package adminui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "echo-lot.app/server/internal/store" +) + +func adbFixture(t *testing.T) *Server { + t.Helper() + s := tokenFixture(t) + s.Store.SetADBEndpointRetention(24 * time.Hour) + now := time.Now().UTC() + for _, e := range []store.ADBEndpoint{ + {Device: "dev-a", Host: "10.13.102.128", Port: 45305, DeviceName: "TB330FU", + SourceIP: "10.13.102.128", ReportedAt: now.Add(-10 * time.Minute)}, + {Device: "dev-b", Host: "10.13.102.55", Port: 5555, + SourceIP: "10.13.102.55", ReportedAt: now.Add(-time.Minute)}, + } { + if err := s.Store.PutADBEndpoint(e); err != nil { + t.Fatal(err) + } + } + return s +} + +// A LAN address and a live debug port are exactly what must not be readable by anyone who can +// reach the listener — which is how the beacon receiver this replaces worked. +func TestADBEndpointsReadRequiresAuth(t *testing.T) { + h := adbFixture(t).Handler() + + req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" { + t.Fatalf("unauthenticated: code=%d, want 401 with a challenge", rec.Code) + } + if rec.Body.Len() > 0 && json.Valid(rec.Body.Bytes()) { + t.Fatalf("a refusal returned a JSON body: %s", rec.Body.String()) + } + + req = httptest.NewRequest("GET", "/admin/adb-endpoints", nil) + req.SetBasicAuth("admin", "wrong") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("bad password: code=%d, want 401", rec.Code) + } +} + +func TestADBEndpointsReadNewestFirst(t *testing.T) { + h := adbFixture(t).Handler() + req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil) + req.SetBasicAuth("admin", "a-long-test-password") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String()) + } + var rows []adbRow + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("got %d rows, want 2: %s", len(rows), rec.Body.String()) + } + if rows[0].Device != "dev-b" { + t.Fatalf("rows are not newest first: %+v", rows) + } + if rows[0].Host != "10.13.102.55" || rows[0].Port != 5555 || rows[0].SourceIP == "" { + t.Fatalf("row is missing what a developer came for: %+v", rows[0]) + } + // age_s is the field that says whether the port is worth trying at all. + if rows[0].AgeS < 50 || rows[0].AgeS > 120 { + t.Fatalf("age_s = %d, want roughly 60", rows[0].AgeS) + } + if rows[1].AgeS <= rows[0].AgeS { + t.Fatalf("ages do not follow the ordering: %+v", rows) + } +} + +// The card exists so the relay is usable without curl. Rendered here because a template error is +// only found when the page is executed, not when it is parsed. +func TestDashboardShowsTheRelayToAnAdmin(t *testing.T) { + s := adbFixture(t) + h := s.Handler() + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{ + Name: sessionCookie, Value: s.Sessions.Issue("local:admin", "admin", true), + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("dashboard: code=%d", rec.Code) + } + for _, want := range []string{"Dev relay", "10.13.102.55:5555", "min ago"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("the dashboard card does not show %q", want) + } + } + + // A plain user gets no rows at all — not an empty card, no card. + req = httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{ + Name: sessionCookie, Value: s.Sessions.Issue("oidc#someone", "Someone", false), + }) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if strings.Contains(rec.Body.String(), "10.13.102.55") { + t.Fatal("a non-admin session was shown a LAN address") + } +} + +// The read is a GET, so a signed-in browser session must not be asked for a CSRF token it has no +// form to carry — but a session that is not an administrator is still refused. +func TestADBEndpointsReadFromABrowserSession(t *testing.T) { + s := adbFixture(t) + h := s.Handler() + + for _, tc := range []struct { + name string + admin bool + wantCode int + }{ + {"admin", true, http.StatusOK}, + {"plain user", false, http.StatusForbidden}, + } { + req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil) + req.AddCookie(&http.Cookie{ + Name: sessionCookie, + Value: s.Sessions.Issue("local:admin", "admin", tc.admin), + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != tc.wantCode { + t.Errorf("%s: code=%d, want %d (%s)", tc.name, rec.Code, tc.wantCode, rec.Body.String()) + } + } +} diff --git a/server/internal/adminui/auth.go b/server/internal/adminui/auth.go index add8cd7..6ed06ab 100644 --- a/server/internal/adminui/auth.go +++ b/server/internal/adminui/auth.go @@ -121,6 +121,9 @@ func (s *Server) Handler() http.Handler { // scripts. Authenticates its own way — see apiAdmin — because guard's redirect-to-login is // useless to a caller without a browser. mux.HandleFunc("POST /admin/enroll-tokens", s.enrollTokensAPI) + // The dev relay's read side (adbendpoints.go), authenticated the same way and for the same + // reason: a developer reads it with curl from another subnet, where a login redirect is no use. + mux.HandleFunc("GET /admin/adb-endpoints", s.adbEndpointsAPI) return mux } @@ -133,7 +136,7 @@ func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Ses http.Redirect(w, r, "/login", http.StatusSeeOther) return } - if r.Method != http.MethodGet && r.Method != http.MethodHead { + if !safeMethod(r) { // SameSite=Lax already blocks cross-site form posts in current browsers, but this // is the control that does not depend on the browser being current. if !s.csrfOK(r, sess) { @@ -145,6 +148,13 @@ func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Ses } } +// safeMethod reports whether a request only reads. CSRF protection applies to the others: there is +// nothing for a cross-site form to ride on when the handler changes nothing, and demanding a token +// on a GET would make a read endpoint unusable from the session that is already signed in. +func safeMethod(r *http.Request) bool { + return r.Method == http.MethodGet || r.Method == http.MethodHead +} + func (s *Server) session(r *http.Request) *adminauth.Session { c, err := r.Cookie(sessionCookie) if err != nil { @@ -242,17 +252,17 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) { // apiAdmin authenticates a programmatic admin request: the normal session cookie, or HTTP Basic // against the break-glass credential for callers without a cookie jar (the README's curl). // -// The cookie path keeps CSRF, exactly like guard: a cookie is an ambient credential and this -// endpoint changes state. Basic auth is exempt — the password is supplied explicitly per -// request, so there is nothing for a cross-site form to ride on — and a wrong guess pays the -// same throttle as the login form, so this is no better a password oracle than that is. +// The cookie path keeps CSRF on anything that changes state, exactly like guard: a cookie is an +// ambient credential. Basic auth is exempt — the password is supplied explicitly per request, so +// there is nothing for a cross-site form to ride on — and a wrong guess pays the same throttle as +// the login form, so this is no better a password oracle than that is. func (s *Server) apiAdmin(w http.ResponseWriter, r *http.Request) (subject string, ok bool) { if sess := s.session(r); sess != nil { if !sess.Admin { http.Error(w, "that action needs an administrator account", http.StatusForbidden) return "", false } - if !s.csrfOK(r, sess) { + if !safeMethod(r) && !s.csrfOK(r, sess) { http.Error(w, "stale form — reload the page and try again", http.StatusForbidden) return "", false } diff --git a/server/internal/adminui/pages.go b/server/internal/adminui/pages.go index dbafdea..fbff6a7 100644 --- a/server/internal/adminui/pages.go +++ b/server/internal/adminui/pages.go @@ -84,15 +84,22 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminau if s.SelfTest != nil && sess.Admin { selftest = s.SelfTest() } + // Same reasoning for the dev relay, and one more: the rows carry a LAN address and a debug + // port, so they go no further than the account that runs the server. + var adb []adbRow + if sess.Admin { + adb = s.adbRows() + } s.render(w, r, "dashboard", map[string]any{ - "Session": sess, - "CSRF": s.csrfToken(sess), - "Devices": len(devices), - "Linked": linked, - "Runs": s.totalRuns(devices), - "SelfTest": selftest, - "Version": s.Version, - "Admin": sess.Admin, + "Session": sess, + "CSRF": s.csrfToken(sess), + "Devices": len(devices), + "Linked": linked, + "Runs": s.totalRuns(devices), + "SelfTest": selftest, + "ADBEndpoints": adb, + "Version": s.Version, + "Admin": sess.Admin, }) } diff --git a/server/internal/adminui/render.go b/server/internal/adminui/render.go index eb62093..0514ece 100644 --- a/server/internal/adminui/render.go +++ b/server/internal/adminui/render.go @@ -8,6 +8,7 @@ import ( "html/template" "log/slog" "net/http" + "strconv" "strings" ) @@ -26,6 +27,20 @@ var tpl = template.Must(template.New("base").Funcs(template.FuncMap{ return "v-unknown" } }, + // ago renders an age the way someone says it out loud. The dev relay reports a port that + // rotates every few minutes, so "4 m ago" is the entire question a reader has about a row. + "ago": func(seconds int) string { + switch { + case seconds < 45: + return "just now" + case seconds < 90*60: + return strconv.Itoa((seconds+30)/60) + " min ago" + case seconds < 48*3600: + return strconv.Itoa((seconds+1800)/3600) + " h ago" + default: + return strconv.Itoa(seconds/86400) + " d ago" + } + }, // verdictLabel says what the light means rather than what it is called. "yellow" is a colour; // "worth a look" is a finding, and the reader is here to act on it. "verdictLabel": func(v string) string { @@ -321,6 +336,31 @@ const baseHTML = ` {{end}} {{end}} + {{with .ADBEndpoints}} +

Dev relay

+

Wireless-debug endpoints reported by Echolot instances on a test network. mDNS + does not cross subnets, so a device on that network relays adbd’s rotating port here for + a developer sitting elsewhere. Nothing here is a measurement, and the entries expire — + a port older than a few minutes has probably already rotated.

+
+ {{range .}} +
+
+ {{if .DeviceName}}{{.DeviceName}}{{else}}{{.Device}}{{end}} + {{ago .AgeS}} +
+
    +
  • adb connect + {{.Host}}:{{.Port}}
  • +
  • device{{.Device}}
  • +
  • reported from + {{if .SourceIP}}{{.SourceIP}}{{else}}—{{end}}
  • +
+ {{with .Note}}
{{.}}
{{end}} +
+ {{end}} +
+ {{end}} {{else if eq .Page "devices"}}

{{if .Admin}}Devices{{else}}Your devices{{end}}

diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 03d43a9..c8f251d 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -95,6 +95,11 @@ type Config struct { // How long canary DNS query logs are kept, in hours (spec §6; privacy default 24). DNSLogRetentionH int // ECHOLOT_DNS_LOG_RETENTION_H / --dns-log-retention-h + // How long relayed adb endpoints are kept, in hours. Same 24-hour default and the same + // reasoning as the DNS log: the value is a LAN address, it stops being true within minutes, + // and there is nothing to gain from remembering it afterwards. + ADBEndpointRetentionH int // ECHOLOT_ADB_ENDPOINT_RETENTION_H / --adb-endpoint-retention-h + // Client compatibility window. Bounds are SemVer; an empty maximum means unbounded. The // defaults sit at breaking boundaries, so shipping a patch never requires changing them. MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version @@ -226,6 +231,7 @@ func Load(args []string) (*Config, *Actions, error) { fs.IntVar(&c.RateUDPPps, "rate-udp-pps", envInt("RATE_UDP_PPS", 25_000), "per-credential and per-IP data-plane packet ceiling, packets/s, silent drop; 0 disables") fs.IntVar(&c.RateUDPKbps, "rate-udp-kbps", envInt("RATE_UDP_KBPS", 250_000), "per-credential and per-IP data-plane byte ceiling, kbit/s, silent drop; 0 disables") fs.IntVar(&c.DNSLogRetentionH, "dns-log-retention-h", envInt("DNS_LOG_RETENTION_H", 24), "hours canary DNS query logs are kept (spec §6 privacy default 24); 0 keeps until the ring overwrites") + fs.IntVar(&c.ADBEndpointRetentionH, "adb-endpoint-retention-h", envInt("ADB_ENDPOINT_RETENTION_H", 24), "hours a relayed adb endpoint is kept (dev tooling; see server/README.md); 0 keeps it until the device replaces it") fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)") fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded") fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)") diff --git a/server/internal/control/control.go b/server/internal/control/control.go index c0caf93..feb277e 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -192,6 +192,11 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount)) mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount)) mux.HandleFunc("GET /v1/account", gate(s.accountStatus)) + // Dev tooling, not protocol (see devtools.go). It shares the actions bucket rather than + // getting a ceiling of its own: it is a POST from an enrolled device that makes the server + // write, which is exactly what that limit is for, and a knob nobody tunes is a knob that + // eventually disagrees with the one next to it. + mux.HandleFunc("POST /v1/devtools/adb-endpoint", gate(s.rateLimited(s.RateActions, s.submitADBEndpoint))) return mux } diff --git a/server/internal/control/devtools.go b/server/internal/control/devtools.go new file mode 100644 index 0000000..0695f86 --- /dev/null +++ b/server/internal/control/devtools.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package control + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/netip" + "strings" + "time" + + "echo-lot.app/server/internal/store" +) + +// The dev relay: an Echolot instance on a test LAN reports where adbd's wireless-debug listener +// can be reached, and a developer on another subnet reads it back from the admin UI. Documented in +// server/README.md; deliberately absent from probe-protocol.md and from the advertised capability +// list, because it measures nothing — it is scaffolding for driving a test device. +// +// It sits on the control plane rather than on a listener of its own, and that is the whole point. +// The receiver this replaces was a separate service wildcard-bound to 0.0.0.0:443, which silently +// occupied port 443 on the addresses reserved for measurement and voided the IPv4 interception +// proof for as long as it ran — and it accepted a port report from anyone who could reach it. Here +// there is no new port, no wildcard bind, and the device credential authenticates the submitter. + +// maxADBNoteLen and maxADBNameLen bound what a submitter can store. Free text from a device ends +// up in the state file and on an operator's page; a label is a few words, so a few words is what +// is kept. +const ( + maxADBNameLen = 64 + maxADBNoteLen = 200 +) + +// submitADBEndpoint records the calling device's wireless-debug endpoint (POST +// /v1/devtools/adb-endpoint). Newest report per device wins; see store.PutADBEndpoint. +func (s *Server) submitADBEndpoint(w http.ResponseWriter, r *http.Request) { + dev := s.Store.DeviceByCredential(bearer(r)) + if dev == nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) + return + } + var body struct { + Host string `json:"host"` + Port int `json:"port"` + DeviceName string `json:"device_name"` + Note string `json:"note"` + } + // A few hundred bytes of JSON at most; the limit is here so a stuck or hostile client cannot + // stream a body at a handler that has no reason to read one. + if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad body"}) + return + } + host := strings.TrimSpace(body.Host) + if !plausibleHost(host) { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "host must be an IP address or a hostname", + }) + return + } + if body.Port < 1 || body.Port > 65535 { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "port must be between 1 and 65535", + }) + return + } + e := store.ADBEndpoint{ + Device: dev.ID, + Host: host, + Port: body.Port, + DeviceName: clip(body.DeviceName, maxADBNameLen), + Note: clip(body.Note, maxADBNoteLen), + ReportedAt: time.Now().UTC(), + SourceIP: remoteIP(r), + } + if err := s.Store.PutADBEndpoint(e); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + slog.Info("adb endpoint reported", "device", dev.ID, "name", e.DeviceName, + "host", e.Host, "port", e.Port, "from", e.SourceIP) + writeJSON(w, http.StatusCreated, map[string]any{ + "device": e.Device, "host": e.Host, "port": e.Port, + "reported_at": e.ReportedAt.Format(time.RFC3339), + }) +} + +// plausibleHost accepts an IP literal or a DNS-shaped name. +// +// A shape check and nothing more: the address is meaningful only on the reporter's own LAN, so +// this server can never confirm it is reachable or even real. What it can do is refuse a value +// that could not be an address at all, which keeps junk out of the state file and out of the +// command an operator is about to paste into a shell. +func plausibleHost(h string) bool { + if h == "" || len(h) > 253 { + return false + } + if _, err := netip.ParseAddr(strings.Trim(h, "[]")); err == nil { + return true + } + for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for i := 0; i < len(label); i++ { + c := label[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-': + default: + return false + } + } + } + return true +} + +// clip trims a caller-supplied label to a bounded length. +func clip(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) > n { + return s[:n] + } + return s +} diff --git a/server/internal/control/devtools_test.go b/server/internal/control/devtools_test.go new file mode 100644 index 0000000..e46135e --- /dev/null +++ b/server/internal/control/devtools_test.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package control + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "echo-lot.app/server/internal/store" +) + +// devtoolsFixture is a server with one enrolled device, and that device's credential. +func devtoolsFixture(t *testing.T) (*Server, string) { + t.Helper() + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + st.SetADBEndpointRetention(24 * time.Hour) + tok, err := st.NewEnrollToken(time.Hour, "test") + if err != nil { + t.Fatal(err) + } + dev, err := st.Redeem(tok, "tablet") + if err != nil { + t.Fatal(err) + } + return &Server{Store: st}, dev.Credential +} + +func postEndpoint(t *testing.T, s *Server, cred, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest("POST", "/v1/devtools/adb-endpoint", strings.NewReader(body)) + if cred != "" { + req.Header.Set("Authorization", "Bearer "+cred) + } + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + return rec +} + +// The receiver this replaces took a port report from anyone who could reach it. This one does not. +func TestADBEndpointNeedsADeviceCredential(t *testing.T) { + s, _ := devtoolsFixture(t) + rec := postEndpoint(t, s, "", `{"host":"10.13.102.128","port":45305}`) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("no credential: code=%d, want 401", rec.Code) + } + rec = postEndpoint(t, s, "not-a-credential", `{"host":"10.13.102.128","port":45305}`) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong credential: code=%d, want 401", rec.Code) + } + if got := s.Store.ADBEndpoints(); len(got) != 0 { + t.Fatalf("an unauthenticated report was stored: %+v", got) + } +} + +func TestADBEndpointRejectsImplausibleHostAndPort(t *testing.T) { + s, cred := devtoolsFixture(t) + for _, body := range []string{ + `{"host":"","port":45305}`, + `{"host":"10.13.102.128 && rm -rf /","port":45305}`, + `{"host":"not a host","port":45305}`, + `{"host":"-bad.example","port":45305}`, + `{"host":"10.13.102.128","port":0}`, + `{"host":"10.13.102.128","port":65536}`, + `{"host":"10.13.102.128","port":-1}`, + `not json at all`, + } { + rec := postEndpoint(t, s, cred, body) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: code=%d, want 400 (%s)", body, rec.Code, strings.TrimSpace(rec.Body.String())) + } + } + if got := s.Store.ADBEndpoints(); len(got) != 0 { + t.Fatalf("a rejected report was stored anyway: %+v", got) + } +} + +func TestADBEndpointStoresTheObservedSourceAndTheCallersDeviceID(t *testing.T) { + s, cred := devtoolsFixture(t) + rec := postEndpoint(t, s, cred, + `{"host":"10.13.102.128","port":45305,"device_name":"TB330FU","note":"wireless debugging"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String()) + } + got := s.Store.ADBEndpoints() + if len(got) != 1 { + t.Fatalf("got %d rows, want 1", len(got)) + } + e := got[0] + if e.Host != "10.13.102.128" || e.Port != 45305 || e.DeviceName != "TB330FU" { + t.Fatalf("report not stored as sent: %+v", e) + } + // The device id comes from the credential and the source IP from the connection, so neither is + // something the body can claim. + if e.Device == "" { + t.Fatal("the submitting device was not recorded") + } + if e.SourceIP != "192.0.2.1" { // httptest's RemoteAddr + t.Fatalf("source IP = %q, want the observed remote address", e.SourceIP) + } + if e.ReportedAt.IsZero() { + t.Fatal("no server-side timestamp was recorded") + } +} + +func TestPlausibleHost(t *testing.T) { + for _, tc := range []struct { + host string + want bool + }{ + {"10.13.102.128", true}, + {"192.168.1.1", true}, + {"fe80::1", true}, + {"[2001:db8::1]", true}, + {"tablet.lan", true}, + {"tablet", true}, + {"a-b.example.net.", true}, + {"", false}, + {"not a host", false}, + {"10.0.0.1:5555", false}, // the port is its own field; a host must not smuggle one + {"-lead.example", false}, + {"trail-.example", false}, + {"a..b", false}, + {"http://10.0.0.1", false}, + {strings.Repeat("x", 254), false}, + } { + if got := plausibleHost(tc.host); got != tc.want { + t.Errorf("plausibleHost(%q) = %v, want %v", tc.host, got, tc.want) + } + } +} diff --git a/server/internal/store/adbendpoint.go b/server/internal/store/adbendpoint.go new file mode 100644 index 0000000..7c78117 --- /dev/null +++ b/server/internal/store/adbendpoint.go @@ -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...) +} diff --git a/server/internal/store/adbendpoint_test.go b/server/internal/store/adbendpoint_test.go new file mode 100644 index 0000000..997ade4 --- /dev/null +++ b/server/internal/store/adbendpoint_test.go @@ -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) + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 195bee4..76ff70a 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -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) {