Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Fable 5 0eaba6150b adminui: an admin interface, behind authentication without exception
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 36s
server-release / release (push) Successful in 38s
Replaces the unauthenticated admin mux. Everything but /healthz requires a
session, and that is the point: the previous arrangement relied on binding to
loopback, which worked exactly until the address changed and then failed
silently and publicly. A binding address is a deployment detail, not an access
control, and this package does not treat it as one.

Two ways in. OIDC through the confidential client, with state and PKCE - PKCE
even here, because it costs one hash and closes code interception independently
of the secret. And the break-glass password, throttled, for when the IdP is the
thing that is broken. Signing in without the admin group is refused with the
group named, because "you are not an admin" is a different problem from "your
password is wrong" and the remedy is elsewhere.

Sessions are MAC-checked cookies: HttpOnly, SameSite=Lax, Secure when TLS is on.
CSRF tokens are derived from the session rather than stored, so there is no
server-side table to keep in sync, and they are required on every state-changing
POST - SameSite already blocks cross-site posts in current browsers, but this is
the control that does not depend on the browser being current.

Server-rendered with html/template and no JavaScript: the pages are lists and
forms, and a framework would add a build step, a dependency tree and an update
treadmill to a program that has none of those. The CSP is default-src 'none'
accordingly.

Pages: overview, devices (with revocation and enrolment-link minting), uploaded
runs and a run viewer. Revocations and deletions are logged with who did them.
Runs are shown exactly as uploaded, at the privacy level their uploader chose -
nothing in the UI can un-redact one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:31:06 +02:00
mrambossekandClaude Fable 5 7bb54e1ec8 docs: record the admin-listener exposure, and the encrypted-upload design
The incident is written down with its cause rather than just its fix: the admin
listener was built localhost-only, and that assumption travelled with it when I
changed the address. The compounding error is the one worth remembering -
checkAdminExposure verifies encryption and says nothing about authentication, so
it passed and gave false confidence. A green light on an adjacent property is
worse than no check.

Also records the encrypted-upload idea while the reasoning is fresh, including
the four consequences that decide whether it is worth building: what metadata
must stay readable (and what the UI loses if it does not), that losing the
passphrase loses the data by design, that metadata is not hidden regardless, and
that it makes a server-side anonymization floor unenforceable - which is fine,
since encryption serves the same purpose better.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:04:09 +02:00
mrambossekandClaude Fable 5 c7750fbf0b oidc: one verifier per issuer, because IdPs mint one per application
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 34s
server-release / release (push) Successful in 35s
Authentik derives the issuer from the application slug, so two applications mean
two issuers - and a token's `iss` must match whoever signed it. A single pinned
issuer could therefore only ever serve one of the two clients.

So there is a verifier per issuer, and each accepts only the client belonging to
it. That is tighter than the previous arrangement as well as more general: a
token minted for the phone cannot be replayed at the admin login, and vice
versa, because they arrive at different verifiers with different audiences.

ECHOLOT_OIDC_APP_ISSUER is optional - empty means both clients share
ECHOLOT_OIDC_ISSUER, which is what IdPs with one global issuer do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 18:47:34 +02:00
7 changed files with 800 additions and 43 deletions
+52
View File
@@ -1002,3 +1002,55 @@ Also fixed: the Settings *Preview what an upload would send* button did nothing.
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
preview rather than silently ignoring the tap.
### Security: the admin listener was publicly exposed for ~15 minutes (2026-08-01)
Moving the admin listener to `[::2]:443` for the UI exposed `/admin/enroll-tokens` and
`/admin/selftest` to the internet **with no authentication**. Anyone who could reach
`fmr.echo-lot.app` could mint enrolment tokens.
The listener was designed localhost-only — its own flag help says *"keep localhost"* — and that
assumption travelled with it when the address changed. The compounding error: `checkAdminExposure`,
added the same day, verifies **encryption** and says nothing about **authentication**. It passed,
and a green light on an adjacent property is worse than no check, because it invites you to stop
looking.
Closed by returning to loopback (the TLS and ACME work is retained, just not exposed). All 68 device
enrolments matched the timestamps of test runs, so there is no evidence of abuse — but the window
existed on a freshly published hostname and absence cannot be proven. 39 unused enrolment tokens
were purged, since any could have been minted by someone else and they cost nothing to replace, and
63 test devices removed.
**The admin listener does not become reachable again until it authenticates.** That reorders the UI
work: auth on the listener first, everything else after.
### Open: encrypted uploads, where the operator cannot read the data
Not built. Recorded because the shape is decided by a few early choices, and the current design
happens to leave the door open.
The goal: hand someone an account, let them upload, and be unable to read what they uploaded.
Sketch: a random per-account **master key**, generated on the first device and wrapped under a
key derived from a passphrase (PBKDF2-HMAC-SHA256 — stdlib on both sides). The wrapped key is
stored server-side as an opaque blob, so a new device signs in, fetches it, and unwraps locally;
the server never sees either key. Runs are encrypted client-side with AES-256-GCM, fresh nonce per
run. All of this is stdlib in Go and `javax.crypto` in Kotlin — no dependency either side.
Four consequences that decide whether it is worth it:
1. **What stays readable determines what the UI can do.** The server builds its index by *parsing*
the document — verdict, finding count, started_at. An opaque payload means the client supplies
that metadata or the index disappears, and with it retention-by-verdict and any "runs with
findings" view. The honest version supplies only run id, timestamp and size, and moves the rest
client-side.
2. **Lose the passphrase, lose the data.** That is the feature working, and also the support
burden. It needs a recovery code printed at setup, not a reset flow — there is nothing to reset.
3. **Metadata is not hidden.** The operator still sees which account uploaded, when, how often and
how large. "Cannot see it" is about content, not existence, and saying otherwise would oversell.
4. **It makes `min_anonymization` unenforceable** — a server cannot check a level it cannot read.
That is not a conflict so much as a redundancy: the anonymization floor exists to protect the
user from the operator, and encryption does that better. The two should not both be demanded of
one upload.
What keeps this possible: uploads are already stored byte-for-byte as received, and every index
field is derived in one function (`runs.Put`). The thing to avoid is admin features that *require*
reading content — those would have to be unbuilt later.
+49 -39
View File
@@ -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"
@@ -184,22 +184,33 @@ func serve(cfg *config.Config) error {
// Identity is optional. Without an issuer the server simply has no sign-in, and
// uploads=account can never be satisfied — which is the honest outcome, not a silent
// downgrade to anonymous.
var idp *oidc.Verifier
if cfg.OIDCIssuer != "" && (cfg.OIDCClientID != "" || cfg.OIDCAppClientID != "") {
// One verifier per issuer. An IdP may mint a distinct issuer per application — Authentik
// derives it from the application slug — and a token's `iss` must match whoever signed it.
// Each verifier accepts only the client belonging to its own issuer, so a token minted for
// the phone cannot be replayed at the admin login and vice versa.
var idp, adminIdP *oidc.Verifier
appIssuer := cfg.OIDCAppIssuer
if appIssuer == "" {
appIssuer = cfg.OIDCIssuer // IdPs with one global issuer
}
if appIssuer != "" && cfg.OIDCAppClientID != "" {
idp = oidc.New(oidc.Config{
Issuer: cfg.OIDCIssuer,
ClientID: cfg.OIDCClientID,
AppClientID: cfg.OIDCAppClientID,
AdminGroup: cfg.OIDCAdminGroup,
Issuer: appIssuer, AppClientID: cfg.OIDCAppClientID, AdminGroup: cfg.OIDCAdminGroup,
}, nil)
slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer,
"admin_client_id", cfg.OIDCClientID, "app_client_id", cfg.OIDCAppClientID,
"admin_group", cfg.OIDCAdminGroup)
slog.Info("identity: app client", "issuer", appIssuer, "client_id", cfg.OIDCAppClientID)
}
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
adminIdP = oidc.New(oidc.Config{
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, AdminGroup: cfg.OIDCAdminGroup,
}, nil)
slog.Info("identity: admin client", "issuer", cfg.OIDCIssuer,
"client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup)
if cfg.OIDCAdminGroup == "" {
slog.Warn("no admin group set: nobody will be an admin via OIDC " +
"(set ECHOLOT_OIDC_ADMIN_GROUP)")
}
} else if cfg.UploadsMode == string(runs.ModeAccount) {
}
if idp == nil && adminIdP == nil && cfg.UploadsMode == string(runs.ModeAccount) {
slog.Warn("uploads=account but no identity provider is configured — " +
"every upload will be refused")
}
@@ -216,6 +227,7 @@ func serve(cfg *config.Config) error {
AppRange: appRange,
PublicControlURL: publicControlURL(cfg),
OIDC: idp,
AdminOIDC: adminIdP,
}
// Left nil when there is no raw socket, so the handler answers "not implemented" with a
// reason rather than failing somewhere deeper.
@@ -285,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"))
// 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 {
http.Error(w, err.Error(), 500)
return
return fmt.Errorf("admin session secret: %w", err)
}
// 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),
})
})
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
+346
View File
@@ -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
}
+168
View File
@@ -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)
}
+170
View File
@@ -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 = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Echolot &mdash; {{.Page}}</title>
<style>
:root{color-scheme:dark}
body{font:15px/1.5 system-ui,sans-serif;margin:0;background:#14161a;color:#e6e6e6}
header{display:flex;gap:1.2rem;align-items:baseline;padding:.8rem 1.2rem;background:#1c1f25;border-bottom:1px solid #2b2f36}
header h1{font-size:1.1rem;margin:0;font-weight:600}
header nav a{color:#9ecbff;text-decoration:none;margin-right:1rem}
header .who{margin-left:auto;color:#9aa3ad;font-size:.9rem}
main{padding:1.2rem;max-width:70rem}
table{border-collapse:collapse;width:100%;margin:.6rem 0}
th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid #2b2f36;vertical-align:top}
th{color:#9aa3ad;font-weight:500;font-size:.85rem}
code,pre{font-family:ui-monospace,monospace;font-size:.85rem}
pre{background:#0f1114;padding:.8rem;border-radius:6px;overflow:auto;max-height:34rem}
.card{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:1rem;margin:.8rem 0}
.grid{display:flex;gap:1rem;flex-wrap:wrap}
.stat{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:.8rem 1.2rem;min-width:8rem}
.stat b{display:block;font-size:1.6rem;font-weight:600}
.stat span{color:#9aa3ad;font-size:.85rem}
button{font:inherit;background:#2d6cdf;color:#fff;border:0;border-radius:6px;padding:.4rem .8rem;cursor:pointer}
button.danger{background:#8b2f2f}
button.plain{background:#3a3f47}
input{font:inherit;background:#0f1114;color:#e6e6e6;border:1px solid #2b2f36;border-radius:6px;padding:.4rem .6rem}
.err{background:#3a1f1f;border:1px solid #7a3b3b;padding:.6rem .8rem;border-radius:6px}
.muted{color:#9aa3ad}
form.inline{display:inline}
</style></head><body>
{{if ne .Page "login"}}
<header>
<h1>Echolot</h1>
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
<span class="who">{{.Session.Display}}
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
</span>
</header>
{{end}}
<main>
{{if eq .Page "login"}}
<h2>Sign in</h2>
{{with .Error}}<p class="err">{{.}}</p>{{end}}
{{if .OIDC}}
<p><a href="/auth/start"><button>Sign in with your identity provider</button></a></p>
<p class="muted">or use the break-glass account:</p>
{{end}}
{{if .LocalSet}}
<form method="post" action="/login" class="card">
<p><label>Username<br><input name="username" value="{{.AdminUser}}" autocomplete="username"></label></p>
<p><label>Password<br><input name="password" type="password" autocomplete="current-password"></label></p>
<p><button>Sign in</button></p>
</form>
{{else}}
<p class="err">No break-glass admin is set. Run
<code>echolot-server --set-admin-password</code> on the host.</p>
{{end}}
{{else if eq .Page "dashboard"}}
<div class="grid">
<div class="stat"><b>{{.Devices}}</b><span>devices</span></div>
<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>
<div class="stat"><b>{{.Runs}}</b><span>stored runs</span></div>
</div>
<div class="card">
<h3>Server</h3>
<p class="muted">version {{.Version}}</p>
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
</div>
{{else if eq .Page "devices"}}
<h2>Devices</h2>
{{with .Link}}
<div class="card">
<p><b>Enrolment link</b> &mdash; single use, valid 24 hours. Treat it like a password until spent.</p>
<p><code>{{.}}</code></p>
<p class="muted">On a device with adb:<br>
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
</div>
{{end}}
<form method="post" action="/enroll-tokens">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button>Create enrolment link</button>
</form>
<table>
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
{{range .Rows}}
<tr>
<td><code>{{.ID}}</code></td>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">&mdash;</span>{{end}}</td>
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
<td>{{.Runs}}</td>
<td><form method="post" action="/devices/{{.ID}}/revoke" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="danger">Revoke</button></form></td>
</tr>
{{else}}
<tr><td colspan="6" class="muted">No devices enrolled.</td></tr>
{{end}}
</table>
{{else if eq .Page "runs"}}
<h2>Uploaded runs</h2>
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
here can un-redact a run.</p>
<table>
<tr><th>Uploaded</th><th>Device</th><th>Verdict</th><th>Findings</th><th>Size</th><th>Level</th><th></th></tr>
{{range .Rows}}
<tr>
<td>{{.UploadedAt.Format "2006-01-02 15:04"}}</td>
<td>{{.DeviceName}}</td>
<td>{{if .Verdict}}{{.Verdict}}{{else}}<span class="muted">&mdash;</span>{{end}}</td>
<td>{{.FindingCount}}</td>
<td>{{kb .SizeBytes}} kB</td>
<td>{{.Anonymization}}</td>
<td><a href="/runs/{{.DeviceID}}/{{.ID}}">open</a></td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">Nothing uploaded yet.</td></tr>
{{end}}
</table>
{{else if eq .Page "run"}}
<h2>Run {{.ID}}</h2>
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="danger">Delete this run</button>
</form>
<pre>{{.JSON}}</pre>
{{end}}
</main></body></html>
`
+7
View File
@@ -74,6 +74,12 @@ type Config struct {
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id (confidential, admin UI)
OIDCAppClientID string // ECHOLOT_OIDC_APP_CLIENT_ID / --oidc-app-client-id (public, the phone app)
// Issuer for the app's client, when the IdP gives each application its own.
//
// Authentik derives the issuer from the application slug, so two applications mean two
// issuers — and a token's `iss` must match the one that minted it. Empty means both clients
// share ECHOLOT_OIDC_ISSUER, which is what IdPs with a single global issuer do.
OIDCAppIssuer string // ECHOLOT_OIDC_APP_ISSUER / --oidc-app-issuer
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
// Break-glass admin username; the password lives hashed in the state store.
@@ -167,6 +173,7 @@ func Load(args []string) (*Config, *Actions, error) {
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
fs.StringVar(&c.OIDCAppIssuer, "oidc-app-issuer", envOr("OIDC_APP_ISSUER", ""), "issuer for the app client when the IdP uses per-application issuers; empty = same as --oidc-issuer")
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
fs.StringVar(&c.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE")
fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)")
+5 -1
View File
@@ -59,8 +59,12 @@ type Server struct {
// Granted server->client sends (spec §5). Both consume an asymmetric grant.
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
// OIDC verifies ID tokens when the operator has configured an issuer (may be nil).
// OIDC verifies ID tokens presented by the *app* (may be nil).
OIDC *oidc.Verifier
// AdminOIDC verifies tokens from the admin UI's own client. Separate because an IdP may
// give each application its own issuer — Authentik derives it from the application slug —
// and a verifier pins exactly one issuer and the clients belonging to it.
AdminOIDC *oidc.Verifier
// Runs stores uploaded measurement documents (may be nil: uploads unsupported).
Runs *runs.Store
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil: