server: upstream trains, observed TTL/DSCP/ECN, rate limits, action ids

Types 0x03/0x04/0x05 land with a bounded columnar train buffer (head kept,
truncation declared) and grant-free multi-part reports - a report row is
smaller than the packet it answers, so $3.4 holds without a grant. The
read loop now collects TTL/TOS cmsgs on Linux, replacing the 0xFF stubs in
the observation block with what the kernel saw; downtrain gained a dscp
parameter, so DSCP survival is measurable in both directions.

Rate limiting ($2.5) exists now: per-credential AND per-source buckets,
429 on the control plane, silent drop on the data plane after the HMAC
gate and before the replay window. UDP ceilings default above the largest
legitimate run - a limit that clips a real measurement produces a
confidently wrong number.

Every granted packet carries its action_id at payload[8:16]; overlapping
actions were unattributable before. Canary DNS logs now honor the stated
24h privacy default. /admin/enroll-tokens answers the spec's JSON shape.
protocol_version 1.0.1 (additive).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 13:04:54 +02:00
co-authored by Claude Opus 5
parent f6849f8e6a
commit 8118e213ae
26 changed files with 1390 additions and 61 deletions
+39
View File
@@ -117,6 +117,10 @@ func (s *Server) Handler() http.Handler {
// affects the whole server, so those stay with the admin.
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.adminOnly(s.revokeDevice)))
mux.HandleFunc("POST /enroll-tokens", s.guard(s.adminOnly(s.mintToken)))
// The spec-shaped mint endpoint (§2.1: {token, expires_in_s, enroll_uri}), for curl and
// 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)
return mux
}
@@ -235,6 +239,41 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// 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.
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) {
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
return "", false
}
return sess.Subject, true
}
if user, pass, hasBasic := r.BasicAuth(); hasBasic {
if d := s.Throttle.Delay(); d > 0 {
time.Sleep(d)
}
if cred := s.Store.LocalAdmin(); cred != nil && cred.Verify(user, pass) {
s.Throttle.Succeeded()
return "local:" + user, true
}
s.Throttle.Failed()
slog.Info("admin api auth failed", "user", user, "from", clientIP(r))
}
w.Header().Set("WWW-Authenticate", `Basic realm="echolot-admin"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return "", false
}
// ---- OIDC -------------------------------------------------------------------------------
func (s *Server) oidcAvailable() bool {
@@ -0,0 +1,88 @@
// 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/adminauth"
)
// tokenFixture wires just enough of the Server for the mint endpoint: a break-glass admin and
// a stand-in EnrollLink (the real one belongs to the control server, injected the same way).
func tokenFixture(t *testing.T) *Server {
t.Helper()
s, _, _, _ := fixture(t)
secret, err := s.Store.SessionSecret()
if err != nil {
t.Fatal(err)
}
s.Sessions = adminauth.NewSessions(secret, time.Hour)
s.Throttle = adminauth.NewThrottle()
cred, err := adminauth.NewCredential("admin", "a-long-test-password")
if err != nil {
t.Fatal(err)
}
if err := s.Store.SetLocalAdmin(cred); err != nil {
t.Fatal(err)
}
s.EnrollLink = func(tok string) string { return "echolot://enroll?v=1&t=" + tok }
return s
}
func TestEnrollTokensAPISpecShape(t *testing.T) {
h := tokenFixture(t).Handler()
// No credentials → 401 with a challenge, never a token.
req := httptest.NewRequest("POST", "/admin/enroll-tokens?note=phone", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("unauthenticated: code=%d", rec.Code)
}
// Wrong password → still 401.
req = httptest.NewRequest("POST", "/admin/enroll-tokens", 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)
}
// Basic + Accept: application/json → the §2.1 shape.
req = httptest.NewRequest("POST", "/admin/enroll-tokens?note=phone", nil)
req.SetBasicAuth("admin", "a-long-test-password")
req.Header.Set("Accept", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("mint: code=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Token string `json:"token"`
ExpiresS int `json:"expires_in_s"`
EnrollURI string `json:"enroll_uri"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Token == "" || body.ExpiresS != 86400 || !strings.HasPrefix(body.EnrollURI, "echolot://enroll?") {
t.Fatalf("spec shape violated: %+v", body)
}
// Without Accept: the browser flow — redirect to the QR page, link in the query.
req = httptest.NewRequest("POST", "/admin/enroll-tokens", nil)
req.SetBasicAuth("admin", "a-long-test-password")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/devices?link=") {
t.Fatalf("html flow: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
+35
View File
@@ -167,6 +167,41 @@ func (s *Server) mintToken(w http.ResponseWriter, r *http.Request, sess *adminau
http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther)
}
// enrollTokensAPI is POST /admin/enroll-tokens, the endpoint the spec's §2.1 example names.
// Content-negotiated: Accept: application/json gets the spec shape {token, expires_in_s,
// enroll_uri}; anything else (a browser) gets the same redirect-to-QR flow as the form above,
// so the one path serves both audiences.
func (s *Server) enrollTokensAPI(w http.ResponseWriter, r *http.Request) {
subject, ok := s.apiAdmin(w, r)
if !ok {
return
}
note := r.URL.Query().Get("note")
if note == "" {
note = "admin-api"
}
const ttl = 24 * time.Hour
tok, err := s.Store.NewEnrollToken(ttl, note)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
slog.Info("enrolment token minted", "by", subject, "note", note)
if !strings.Contains(r.Header.Get("Accept"), "application/json") {
http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "application/json")
// The whole link, not the bare token (§2.1): the server is the only party holding URL, pin
// and token at once, and a hand-assembled pin wrong by one character fails as an inscrutable
// TLS error later rather than loudly here.
_ = json.NewEncoder(w).Encode(map[string]any{
"token": tok,
"expires_in_s": int(ttl.Seconds()),
"enroll_uri": s.EnrollLink(tok),
})
}
// EnrollLink is supplied by the caller so this package does not need the control server's pin.
var _ = 0