server: relay a test device's adb endpoint, because mDNS does not cross subnets
The beacon this replaces was a separate service wildcard-bound to 0.0.0.0:443 - it silently occupied port 443 on the reserved measurement addresses, voiding the IPv4 interception proof for as long as it ran, and it accepted a port report from anyone who could reach it. So this lives where the repo's own post-mortem said it belongs: POST on the control plane authenticated by the device credential, GET on the admin UI behind the existing apiAdmin helper. No new listener, no new port, no wildcard. Entries expire after 24h (ECHOLOT_ADB_ENDPOINT_RETENTION_H) on both write and read - a LAN address is a breadcrumb for driving a test device, not measurement data worth keeping. Also records the BLE peer-comparison design: the case for it is that BLE is out-of-band, which is what makes client isolation measurable at all - silence over IP cannot distinguish an isolating AP from an absent peer, and a peer confirming out-of-band that it was listening turns that silence into proof. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab6e278272
commit
ae63bd7c7f
@@ -0,0 +1,66 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The read side of the dev relay (control/devtools.go submits, this reads back). It is here, in
|
||||
// the UI that already authenticates, rather than in a listener of its own — that is the lesson of
|
||||
// the beacon receiver it replaces, which was a separate unauthenticated service on port 443 of the
|
||||
// addresses reserved for measurement.
|
||||
|
||||
// adbRow is one relayed endpoint as the API and the dashboard both see it.
|
||||
//
|
||||
// age_s is computed rather than left to the reader: the port it describes rotates every few
|
||||
// minutes, so how old the report is decides whether it is worth trying at all.
|
||||
type adbRow struct {
|
||||
Device string `json:"device"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ReportedAt time.Time `json:"reported_at"`
|
||||
SourceIP string `json:"source_ip,omitempty"`
|
||||
AgeS int `json:"age_s"`
|
||||
}
|
||||
|
||||
// adbRows reads the store's live endpoints (already newest first, already aged out).
|
||||
func (s *Server) adbRows() []adbRow {
|
||||
now := time.Now().UTC()
|
||||
eps := s.Store.ADBEndpoints()
|
||||
rows := make([]adbRow, 0, len(eps))
|
||||
for _, e := range eps {
|
||||
age := int(now.Sub(e.ReportedAt).Seconds())
|
||||
if age < 0 {
|
||||
age = 0 // a clock that ran backwards should read "just now", not negative
|
||||
}
|
||||
rows = append(rows, adbRow{
|
||||
Device: e.Device, Host: e.Host, Port: e.Port,
|
||||
DeviceName: e.DeviceName, Note: e.Note,
|
||||
ReportedAt: e.ReportedAt, SourceIP: e.SourceIP, AgeS: age,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// adbEndpointsAPI is GET /admin/adb-endpoints — the developer's half of the relay.
|
||||
//
|
||||
// Authenticated exactly like the mint endpoint, so `curl -u admin:PASS` works from a script and a
|
||||
// signed-in browser session works without one. Admin-only: a LAN address and a debug port are the
|
||||
// operator's business, and a user's uploads are not made safer by handing out either.
|
||||
func (s *Server) adbEndpointsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.apiAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
rows := s.adbRows()
|
||||
if rows == nil {
|
||||
rows = []adbRow{} // an empty list, never null: a caller should not have to special-case it
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rows)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user