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>
This commit is contained in:
mrambossek
2026-08-01 19:31:06 +02:00
co-authored by Claude Fable 5
parent 7bb54e1ec8
commit 0eaba6150b
4 changed files with 713 additions and 31 deletions
+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>
`