// 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) // 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 } // 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 !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) { http.Error(w, "stale form — reload the page and try again", http.StatusForbidden) return } } h(w, r, sess) } } // 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 { 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 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 !safeMethod(r) && !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 }