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>
427 lines
16 KiB
Go
427 lines
16 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package adminui serves the operator's web interface.
|
|
//
|
|
// Everything here is behind authentication, without exception. The previous arrangement — an
|
|
// unauthenticated listener kept safe by binding to loopback — worked exactly until the address
|
|
// changed, and then failed silently and publicly. Binding address is a deployment detail; it is
|
|
// not an access control, and this package does not treat it as one.
|
|
//
|
|
// Rendered server-side with html/template and no JavaScript. The pages are lists and forms; a
|
|
// framework would add a build step, a dependency tree and an update treadmill to a program that
|
|
// currently has none of those.
|
|
package adminui
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"echo-lot.app/server/internal/adminauth"
|
|
"echo-lot.app/server/internal/oidc"
|
|
"echo-lot.app/server/internal/runs"
|
|
"echo-lot.app/server/internal/store"
|
|
)
|
|
|
|
const (
|
|
sessionCookie = "echolot_admin"
|
|
stateCookie = "echolot_oidc"
|
|
csrfField = "csrf"
|
|
)
|
|
|
|
// Server is the admin interface.
|
|
type Server struct {
|
|
Store *store.Store
|
|
Runs *runs.Store
|
|
OIDC *oidc.Verifier // admin client; nil when no IdP is configured
|
|
Sessions *adminauth.Sessions
|
|
Throttle *adminauth.Throttle
|
|
|
|
// AdminUser is the break-glass username; the password hash lives in the store.
|
|
AdminUser string
|
|
// BaseURL is where this UI is reachable, for building the OIDC redirect. Must match the URI
|
|
// registered at the IdP exactly.
|
|
BaseURL string
|
|
// ClientSecret authenticates the confidential admin client at the token endpoint.
|
|
ClientSecret string
|
|
// Secure marks cookies Secure. Off only for loopback HTTP, where there is no network to
|
|
// intercept and browsers refuse Secure cookies over plaintext anyway.
|
|
Secure bool
|
|
|
|
// EnrollLink builds the §2.1 bootstrap link for a token. Injected rather than rebuilt here,
|
|
// so the SPKI pin and public URL stay owned by the control server that actually knows them.
|
|
EnrollLink func(token string) string
|
|
// ControlURL is where devices should actually connect, handed out by /v1/discover so the
|
|
// enrollment link can show the public name instead. ServerName is for display.
|
|
ControlURL string
|
|
ServerName string
|
|
// SelfTest and Version render on the dashboard.
|
|
SelfTest func() any
|
|
Version string
|
|
}
|
|
|
|
// Handler builds the routes. Only /healthz is reachable without a session.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Unauthenticated: a health check that required a session would be no use to a monitor, and
|
|
// it discloses nothing beyond "the process is up".
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"ok":true,"version":%q}`+"\n", s.Version)
|
|
})
|
|
|
|
// Unauthenticated on purpose, and deliberately says almost nothing: where the control plane
|
|
// is, and nothing about who may talk to it.
|
|
//
|
|
// This exists so an enrollment link can carry the name a person recognises while the app
|
|
// still connects to the name that selects the pinned certificate. It hands out an address,
|
|
// never a pin — the pin travels in the link itself. Serving the pin here would collapse
|
|
// pinning to whatever the CA system says, and pinning exists precisely to survive a
|
|
// certificate authority the operator does not control.
|
|
//
|
|
// So the worst an intercepted discovery can do is send a device to the wrong host, where the
|
|
// pin check fails. That is a denial of service, not a compromise.
|
|
mux.HandleFunc("GET /v1/discover", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"control_url": s.ControlURL,
|
|
"name": s.ServerName,
|
|
})
|
|
})
|
|
|
|
mux.HandleFunc("GET /login", s.loginForm)
|
|
mux.HandleFunc("POST /login", s.loginSubmit)
|
|
mux.HandleFunc("GET /auth/start", s.oidcStart)
|
|
mux.HandleFunc("GET /admin/callback", s.oidcCallback)
|
|
mux.HandleFunc("POST /logout", s.logout)
|
|
|
|
// Any signed-in account. These handlers scope what they show to the session themselves —
|
|
// an admin sees everything, a user sees their own devices and runs.
|
|
mux.HandleFunc("GET /", s.guard(s.dashboard))
|
|
mux.HandleFunc("GET /devices", s.guard(s.devices))
|
|
mux.HandleFunc("GET /runs", s.guard(s.runsList))
|
|
mux.HandleFunc("GET /runs/{device}/{id}", s.guard(s.runView))
|
|
mux.HandleFunc("POST /runs/{device}/{id}/delete", s.guard(s.runDelete))
|
|
// Deleting your own upload is yours to do; revoking a device or minting an enrolment token
|
|
// 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
|
|
}
|
|
|
|
// guard requires a valid session, and checks CSRF on anything that changes state.
|
|
func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Session)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sess := s.session(r)
|
|
if sess == nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
// 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) {
|
|
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
h(w, r, sess)
|
|
}
|
|
}
|
|
|
|
func (s *Server) session(r *http.Request) *adminauth.Session {
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
sess, err := s.Sessions.Parse(c.Value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return sess
|
|
}
|
|
|
|
// csrfToken derives a per-session token. Derived rather than stored so it needs no server-side
|
|
// state and cannot drift out of sync with the session it belongs to.
|
|
func (s *Server) csrfToken(sess *adminauth.Session) string {
|
|
sum := sha256.Sum256([]byte("csrf|" + sess.Subject + "|" + sess.Expires.String()))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:16])
|
|
}
|
|
|
|
func (s *Server) csrfOK(r *http.Request, sess *adminauth.Session) bool {
|
|
if err := r.ParseForm(); err != nil {
|
|
return false
|
|
}
|
|
return r.PostFormValue(csrfField) == s.csrfToken(sess)
|
|
}
|
|
|
|
// adminOnly refuses a handler to a signed-in account that is not an administrator.
|
|
//
|
|
// A separate wrapper rather than a check inside each handler: an authorisation rule that has to be
|
|
// remembered in every handler is one that will eventually be forgotten in a new one, and the route
|
|
// table is where someone looks to find out who may do what.
|
|
func (s *Server) adminOnly(
|
|
h func(http.ResponseWriter, *http.Request, *adminauth.Session),
|
|
) func(http.ResponseWriter, *http.Request, *adminauth.Session) {
|
|
return func(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
|
if !sess.Admin {
|
|
slog.Info("admin action refused", "account", sess.Subject, "path", r.URL.Path)
|
|
http.Error(w, "that action needs an administrator account", http.StatusForbidden)
|
|
return
|
|
}
|
|
h(w, r, sess)
|
|
}
|
|
}
|
|
|
|
func (s *Server) setSession(w http.ResponseWriter, subject, display string, admin bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: s.Sessions.Issue(subject, display, admin),
|
|
Path: "/",
|
|
HttpOnly: true, // the cookie is a bearer credential; script has no business reading it
|
|
Secure: s.Secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
|
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
|
|
// ---- local password ---------------------------------------------------------------------
|
|
|
|
func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// The delay is applied before the answer, so a wrong guess costs time whether or not the
|
|
// username exists — the timing carries no information either way.
|
|
if d := s.Throttle.Delay(); d > 0 {
|
|
time.Sleep(d)
|
|
}
|
|
user := r.PostFormValue("username")
|
|
pass := r.PostFormValue("password")
|
|
|
|
cred := s.Store.LocalAdmin()
|
|
if cred == nil || !cred.Verify(user, pass) {
|
|
s.Throttle.Failed()
|
|
slog.Info("admin login failed", "user", user, "from", clientIP(r))
|
|
s.render(w, r, "login", map[string]any{
|
|
"Error": "Incorrect username or password.",
|
|
"OIDC": s.oidcAvailable(),
|
|
})
|
|
return
|
|
}
|
|
s.Throttle.Succeeded()
|
|
slog.Info("admin login", "user", user, "method", "local", "from", clientIP(r))
|
|
s.setSession(w, "local:"+cred.Username, cred.Username, true)
|
|
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 {
|
|
return s.OIDC != nil && s.OIDC.Config().Enabled() && s.BaseURL != ""
|
|
}
|
|
|
|
// oidcStart redirects to the IdP with state and PKCE.
|
|
//
|
|
// PKCE even though this is a confidential client: it costs one hash and closes code interception
|
|
// independently of the secret, which is worth having when the redirect crosses a browser.
|
|
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
|
|
if !s.oidcAvailable() {
|
|
http.Error(w, "no identity provider is configured on this server", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
d, err := s.OIDC.Discover(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "identity provider unreachable: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
state, verifier := randomToken(), randomToken()
|
|
challenge := sha256.Sum256([]byte(verifier))
|
|
|
|
// state and the PKCE verifier ride in one short-lived cookie: the callback must prove it
|
|
// belongs to the browser that started the flow, or an attacker can feed us their own code.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: stateCookie, Value: state + "." + verifier, Path: "/",
|
|
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
|
|
})
|
|
|
|
q := url.Values{
|
|
"response_type": {"code"},
|
|
"client_id": {s.OIDC.Config().ClientID},
|
|
"redirect_uri": {s.redirectURI()},
|
|
"scope": {"openid profile email"},
|
|
"state": {state},
|
|
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
|
|
"code_challenge_method": {"S256"},
|
|
}
|
|
http.Redirect(w, r, d.AuthorizationEndpoint+"?"+q.Encode(), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) redirectURI() string {
|
|
return strings.TrimRight(s.BaseURL, "/") + "/admin/callback"
|
|
}
|
|
|
|
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
|
if !s.oidcAvailable() {
|
|
http.Error(w, "no identity provider configured", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
c, err := r.Cookie(stateCookie)
|
|
if err != nil {
|
|
http.Error(w, "sign-in did not start here — try again from the login page", http.StatusBadRequest)
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
|
|
|
|
state, verifier, ok := strings.Cut(c.Value, ".")
|
|
if !ok || state == "" || r.URL.Query().Get("state") != state {
|
|
http.Error(w, "sign-in state did not match — start again", http.StatusBadRequest)
|
|
return
|
|
}
|
|
code := r.URL.Query().Get("code")
|
|
if code == "" {
|
|
http.Error(w, "no authorization code returned: "+r.URL.Query().Get("error"), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
idToken, err := s.exchange(r.Context(), code, verifier)
|
|
if err != nil {
|
|
slog.Info("admin oidc exchange failed", "err", err, "from", clientIP(r))
|
|
http.Error(w, "could not complete sign-in", http.StatusBadGateway)
|
|
return
|
|
}
|
|
claims, err := s.OIDC.Verify(r.Context(), idToken)
|
|
if err != nil {
|
|
slog.Info("admin oidc token rejected", "err", err, "from", clientIP(r))
|
|
http.Error(w, "the identity token was not accepted", http.StatusForbidden)
|
|
return
|
|
}
|
|
// Authentication and authorisation are answered separately here. Someone who is not in the
|
|
// admin group has still proved who they are, and their own uploads are their business to
|
|
// manage — refusing them a session outright, as this used to, left a legitimate account with
|
|
// no way to see or delete the data it had sent.
|
|
admin := s.OIDC.IsAdmin(claims)
|
|
slog.Info("login", "account", claims.AccountID(), "method", "oidc", "admin", admin,
|
|
"from", clientIP(r))
|
|
s.setSession(w, claims.AccountID(), claims.Display(), admin)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// exchange trades the authorization code for tokens at the IdP.
|
|
func (s *Server) exchange(ctx context.Context, code, verifier string) (string, error) {
|
|
d, err := s.OIDC.Discover(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
form := url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {code},
|
|
"redirect_uri": {s.redirectURI()},
|
|
"client_id": {s.OIDC.Config().ClientID},
|
|
"code_verifier": {verifier},
|
|
}
|
|
if s.ClientSecret != "" {
|
|
form.Set("client_secret", s.ClientSecret)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.TokenEndpoint,
|
|
strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
|
}
|
|
var tok struct {
|
|
IDToken string `json:"id_token"`
|
|
}
|
|
if err := json.Unmarshal(body, &tok); err != nil {
|
|
return "", err
|
|
}
|
|
if tok.IDToken == "" {
|
|
return "", fmt.Errorf("token endpoint returned no id_token")
|
|
}
|
|
return tok.IDToken, nil
|
|
}
|
|
|
|
func randomToken() string {
|
|
b := make([]byte, 32)
|
|
_, _ = rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// clientIP is for logs only. X-Forwarded-For is deliberately ignored: nothing is meant to sit in
|
|
// front of this listener, so a header claiming otherwise is a caller's assertion about itself.
|
|
func clientIP(r *http.Request) string {
|
|
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
|
|
return r.RemoteAddr[:i]
|
|
}
|
|
return r.RemoteAddr
|
|
}
|