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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user