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
@@ -143,6 +143,47 @@ curl -sk https://<host>:8443/v1/profile -H 'Authorization: Bearer <credential>'
|
||||
|
||||
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:<password> -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `<!doctype html>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{with .ADBEndpoints}}
|
||||
<h2>Dev relay</h2>
|
||||
<p class="lede">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.</p>
|
||||
<div class="recs">
|
||||
{{range .}}
|
||||
<div class="rec">
|
||||
<div class="rec-head">
|
||||
<span class="id">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.Device}}{{end}}</span>
|
||||
<span class="tag v-unknown">{{ago .AgeS}}</span>
|
||||
</div>
|
||||
<ul class="readout">
|
||||
<li><span class="k">adb connect</span><span class="lead"></span>
|
||||
<span class="v">{{.Host}}:{{.Port}}</span></li>
|
||||
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.Device}}</span></li>
|
||||
<li><span class="k">reported from</span><span class="lead"></span>
|
||||
<span class="v">{{if .SourceIP}}{{.SourceIP}}{{else}}—{{end}}</span></li>
|
||||
</ul>
|
||||
{{with .Note}}<div class="why">{{.}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{else if eq .Page "devices"}}
|
||||
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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