Signing in and being allowed to administer the server were the same question: the OIDC callback refused a session outright to anyone outside the admin group. A legitimate user could authenticate, be told what they could not do, and be left with no way to see or delete the data their own devices had uploaded. They are separate questions now. Everyone who authenticates gets a session; the admin flag rides inside the MAC'd payload, so promoting yourself means forging a signature rather than editing a cookie, and a role that does not parse fails closed to "user". Pages scope themselves through visibleDevices/mayTouchRun rather than filtering individually — per-page scoping is what the next page added will be missing, and that failure is silent, since a listing that leaks other people's uploads looks exactly like one that does not. Someone else's run answers 404, not 403: a distinguishable refusal would confirm the run exists. Revoking devices and minting enrolment tokens affect the whole server and stay behind adminOnly at the route table, where someone looking for who-may-do-what will actually find it. Ownership is re-read per request instead of captured at sign-in, so unlinking an account takes effect immediately rather than at session expiry. Tests cover that, plus the degenerate case of an empty subject, which must own nothing rather than everything with an empty account id. Also: attribute the ICMPv6 finding per network. It compared "is IPv6 configured anywhere on this device" against "did any network answer", which on a phone reports IPv6-is-broken about a network where IPv6 was never configured. network_ref is null on every test, so the probe now records per-network outcomes structurally rather than as prose a finding would have to parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
365 lines
13 KiB
Go
365 lines
13 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
|
|
// 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)
|
|
})
|
|
|
|
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)))
|
|
|
|
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)
|
|
}
|
|
|
|
// ---- 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
|
|
}
|