diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go
index 1610ad6..4139581 100644
--- a/server/cmd/echolot-server/main.go
+++ b/server/cmd/echolot-server/main.go
@@ -19,7 +19,6 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
- "encoding/json"
"encoding/pem"
"errors"
"fmt"
@@ -39,6 +38,7 @@ import (
"echo-lot.app/server/internal/acmehttp"
"echo-lot.app/server/internal/adminauth"
+ "echo-lot.app/server/internal/adminui"
"echo-lot.app/server/internal/canarydns"
"echo-lot.app/server/internal/certreload"
"echo-lot.app/server/internal/compat"
@@ -297,36 +297,34 @@ func serve(cfg *config.Config) error {
return best
}
- // Admin/health (plain HTTP, localhost by default; spec §7)
- admin := http.NewServeMux()
- admin.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
- fmt.Fprintf(w, `{"ok":true,"version":%q}`, Version)
- })
- admin.HandleFunc("GET /admin/selftest", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(selftestPtr.Load())
- })
- // TODO(spec §7): enrollment token management + device list. Until the
- // admin UI exists, mint tokens with: echolot-admin (or curl on this
- // listener once the endpoint lands).
- admin.HandleFunc("POST /admin/enroll-tokens", func(w http.ResponseWriter, r *http.Request) {
- tok, err := st.NewEnrollToken(24*time.Hour, r.URL.Query().Get("note"))
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- // The whole bootstrap, not just the token: this is what gets pasted or turned into a
- // QR code, and assembling it here is what keeps an operator from transcribing a pin by
- // hand — a pin wrong by one character fails as an inscrutable TLS error days later.
- w.Header().Set("Content-Type", "application/json")
- enc := json.NewEncoder(w)
- enc.SetEscapeHTML(false) // the link is full of / and =; escaping them helps nobody
- _ = enc.Encode(map[string]any{
- "token": tok,
- "expires_in_s": 86400,
- "enroll_uri": ctl.EnrollmentLink(tok),
- })
- })
+ // The admin interface. Every route except /healthz requires a session — the old arrangement
+ // (no auth, kept safe by binding to loopback) failed the moment the address changed, and a
+ // binding address is a deployment detail rather than an access control.
+ secret, err := st.SessionSecret()
+ if err != nil {
+ return fmt.Errorf("admin session secret: %w", err)
+ }
+ adminSecure := cfg.AdminTLSCert != ""
+ ui := &adminui.Server{
+ Store: st,
+ Runs: runStore,
+ OIDC: adminIdP,
+ Sessions: adminauth.NewSessions(secret, 12*time.Hour),
+ Throttle: adminauth.NewThrottle(),
+ AdminUser: cfg.AdminUser,
+ BaseURL: cfg.AdminBaseURL,
+ ClientSecret: cfg.OIDCClientSecret,
+ Secure: adminSecure,
+ EnrollLink: ctl.EnrollmentLink,
+ SelfTest: func() any { return selftestPtr.Load() },
+ Version: Version,
+ }
+ if st.LocalAdmin() == nil && adminIdP == nil {
+ slog.Warn("nobody can sign in to the admin UI: no break-glass password is set " +
+ "(--set-admin-password) and no identity provider is configured")
+ }
+ admin := ui.Handler()
+
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
if cfg.AdminTLSCert != "" {
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
diff --git a/server/internal/adminui/auth.go b/server/internal/adminui/auth.go
new file mode 100644
index 0000000..bcb4130
--- /dev/null
+++ b/server/internal/adminui/auth.go
@@ -0,0 +1,346 @@
+// 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
+}
diff --git a/server/internal/adminui/pages.go b/server/internal/adminui/pages.go
new file mode 100644
index 0000000..bf06674
--- /dev/null
+++ b/server/internal/adminui/pages.go
@@ -0,0 +1,168 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package adminui
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "sort"
+ "time"
+
+ "echo-lot.app/server/internal/adminauth"
+ "echo-lot.app/server/internal/runs"
+ "echo-lot.app/server/internal/store"
+)
+
+func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
+ if s.session(r) != nil {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ s.render(w, r, "login", map[string]any{
+ "OIDC": s.oidcAvailable(),
+ "LocalSet": s.Store.LocalAdmin() != nil,
+ "AdminUser": s.AdminUser,
+ })
+}
+
+func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
+ devices := s.Store.Devices()
+ linked := 0
+ for _, d := range devices {
+ if d.LinkedToAccount() {
+ linked++
+ }
+ }
+ var selftest any
+ if s.SelfTest != nil {
+ selftest = s.SelfTest()
+ }
+ s.render(w, r, "dashboard", map[string]any{
+ "Session": sess,
+ "CSRF": s.csrfToken(sess),
+ "Devices": len(devices),
+ "Linked": linked,
+ "Runs": s.totalRuns(devices),
+ "SelfTest": selftest,
+ "Version": s.Version,
+ })
+}
+
+func (s *Server) totalRuns(devices []store.Device) int {
+ if s.Runs == nil {
+ return 0
+ }
+ n := 0
+ for _, d := range devices {
+ n += len(s.Runs.List(d.ID))
+ }
+ return n
+}
+
+func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
+ devices := s.Store.Devices()
+ // Newest first: the device someone is looking for is almost always the one just enrolled.
+ sort.Slice(devices, func(i, j int) bool { return devices[i].Enrolled.After(devices[j].Enrolled) })
+
+ type row struct {
+ store.Device
+ Runs int
+ }
+ rows := make([]row, 0, len(devices))
+ for _, d := range devices {
+ n := 0
+ if s.Runs != nil {
+ n = len(s.Runs.List(d.ID))
+ }
+ rows = append(rows, row{Device: d, Runs: n})
+ }
+ s.render(w, r, "devices", map[string]any{
+ "Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows,
+ "Link": r.URL.Query().Get("link"),
+ })
+}
+
+func (s *Server) revokeDevice(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
+ id := r.PathValue("id")
+ if err := s.Store.DeleteDevice(id); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ // Worth a log line: revoking a device is destructive, immediate, and someone will eventually
+ // want to know who did it and when.
+ slog.Info("device revoked", "device", id, "by", sess.Subject)
+ http.Redirect(w, r, "/devices", http.StatusSeeOther)
+}
+
+func (s *Server) mintToken(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
+ tok, err := s.Store.NewEnrollToken(24*time.Hour, "admin-ui")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ slog.Info("enrolment token minted", "by", sess.Subject)
+ // The whole link, not the bare token: it carries the URL and the pin as well, and assembling
+ // those by hand is where an operator gets a pin wrong by one character.
+ http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther)
+}
+
+// EnrollLink is supplied by the caller so this package does not need the control server's pin.
+var _ = 0
+
+func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
+ type row struct {
+ runs.Meta
+ DeviceName string
+ }
+ var rows []row
+ for _, d := range s.Store.Devices() {
+ if s.Runs == nil {
+ break
+ }
+ name := d.Name
+ if name == "" {
+ name = d.ID
+ }
+ for _, m := range s.Runs.List(d.ID) {
+ rows = append(rows, row{Meta: m, DeviceName: name})
+ }
+ }
+ sort.Slice(rows, func(i, j int) bool { return rows[i].UploadedAt.After(rows[j].UploadedAt) })
+ if len(rows) > 200 {
+ rows = rows[:200] // a page, not the archive; the count is on the dashboard
+ }
+ s.render(w, r, "runs", map[string]any{"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows})
+}
+
+func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
+ body, err := s.Runs.Get(r.PathValue("device"), r.PathValue("id"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ // Re-indented for reading, but otherwise exactly what was stored. An admin sees the document
+ // at the privacy level its uploader chose — there is nothing here that can un-redact it.
+ var pretty json.RawMessage = body
+ out, err := json.MarshalIndent(json.RawMessage(pretty), "", " ")
+ if err != nil {
+ out = body
+ }
+ s.render(w, r, "run", map[string]any{
+ "Session": sess, "CSRF": s.csrfToken(sess),
+ "Device": r.PathValue("device"), "ID": r.PathValue("id"),
+ "JSON": string(out),
+ })
+}
+
+func (s *Server) runDelete(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
+ device, id := r.PathValue("device"), r.PathValue("id")
+ if err := s.Runs.Delete(device, id); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ slog.Info("run deleted", "device", device, "run", id, "by", sess.Subject)
+ http.Redirect(w, r, "/runs", http.StatusSeeOther)
+}
diff --git a/server/internal/adminui/render.go b/server/internal/adminui/render.go
new file mode 100644
index 0000000..e9f582f
--- /dev/null
+++ b/server/internal/adminui/render.go
@@ -0,0 +1,170 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package adminui
+
+import (
+ "bytes"
+ "html/template"
+ "log/slog"
+ "net/http"
+)
+
+// Templates are parsed once at start. html/template escapes by context, which is what makes it
+// safe to render device names and finding text that ultimately arrived over a network.
+var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
+ "kb": func(n int64) int64 { return n / 1024 },
+}).Parse(baseHTML))
+
+func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
+ data["Page"] = page
+ var buf bytes.Buffer
+ if err := tpl.Execute(&buf, data); err != nil {
+ slog.Error("admin template", "page", page, "err", err)
+ http.Error(w, "template error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ // There is no script here and nothing loaded from anywhere else, so a strict policy costs
+ // nothing and closes injected-script attacks even if an escaping bug ever slips through.
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ _, _ = buf.WriteTo(w)
+}
+
+const baseHTML = `
+
+
+Echolot — {{.Page}}
+
+{{if ne .Page "login"}}
+
+{{end}}
+
+
+{{if eq .Page "login"}}
+ Sign in
+ {{with .Error}}{{.}}
{{end}}
+ {{if .OIDC}}
+
+ or use the break-glass account:
+ {{end}}
+ {{if .LocalSet}}
+
+ {{else}}
+ No break-glass admin is set. Run
+ echolot-server --set-admin-password on the host.
+ {{end}}
+
+{{else if eq .Page "dashboard"}}
+
+
{{.Devices}}devices
+
{{.Linked}}signed in
+
{{.Runs}}stored runs
+
+
+
Server
+
version {{.Version}}
+ {{with .SelfTest}}
{{printf "%+v" .}}{{end}}
+
+
+{{else if eq .Page "devices"}}
+ Devices
+ {{with .Link}}
+
+
Enrolment link — single use, valid 24 hours. Treat it like a password until spent.
+
{{.}}
+
On a device with adb:
+ adb shell am start -a android.intent.action.VIEW -d "{{.}}"
+
+ {{end}}
+
+
+ | Device | Name | Account | Enrolled | Runs | |
+ {{range .Rows}}
+
+ {{.ID}} |
+ {{if .Name}}{{.Name}}{{else}}—{{end}} |
+ {{if .LinkedToAccount}}{{.AccountName}}{{else}}not signed in{{end}} |
+ {{.Enrolled.Format "2006-01-02 15:04"}} |
+ {{.Runs}} |
+ |
+
+ {{else}}
+ | No devices enrolled. |
+ {{end}}
+
+
+{{else if eq .Page "runs"}}
+ Uploaded runs
+ Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
+ here can un-redact a run.
+
+ | Uploaded | Device | Verdict | Findings | Size | Level | |
+ {{range .Rows}}
+
+ | {{.UploadedAt.Format "2006-01-02 15:04"}} |
+ {{.DeviceName}} |
+ {{if .Verdict}}{{.Verdict}}{{else}}—{{end}} |
+ {{.FindingCount}} |
+ {{kb .SizeBytes}} kB |
+ {{.Anonymization}} |
+ open |
+
+ {{else}}
+ | Nothing uploaded yet. |
+ {{end}}
+
+
+{{else if eq .Page "run"}}
+ Run {{.ID}}
+
+ {{.JSON}}
+{{end}}
+
+
+`