// 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) mux.HandleFunc("GET /", s.guard(s.dashboard)) mux.HandleFunc("GET /devices", s.guard(s.devices)) mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.revokeDevice)) mux.HandleFunc("POST /enroll-tokens", s.guard(s.mintToken)) 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)) 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) } func (s *Server) setSession(w http.ResponseWriter, subject, display string) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: s.Sessions.Issue(subject, display), 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) 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 } if !s.OIDC.IsAdmin(claims) { // Named explicitly: "you signed in but you are not an admin" is a different problem from // "your password is wrong", and the group is the thing to go and check. slog.Info("admin access denied: not in group", "account", claims.AccountID(), "want_group", s.OIDC.Config().AdminGroup, "have", claims.Groups) http.Error(w, fmt.Sprintf( "Signed in as %s, but that account is not in the %q group, so it cannot administer "+ "this server.", claims.Display(), s.OIDC.Config().AdminGroup), http.StatusForbidden) return } slog.Info("admin login", "account", claims.AccountID(), "method", "oidc", "from", clientIP(r)) s.setSession(w, claims.AccountID(), claims.Display()) 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 }