Files
echolot/server/internal/adminui/render.go
T
mrambossekandClaude Opus 5 40e76c52ca adminui: show the enrolment link as a QR code
Enrolling a device that cannot reach the admin UI meant transcribing a
200-character link with a base64 pin in it — the step the link format
exists to avoid, and the one where a pin wrong by one character fails
later as an inscrutable TLS error.

Rendered as inline SVG rather than a PNG data: URI, because the page's
CSP is default-src 'none' and means it: a data: image would need img-src
opened, markup needs nothing. One path rather than a rect per module,
since a link this long encodes to about 60x60 and two thousand elements
is a lot of DOM for a picture of a square. It is generated from the same
validated value as the href, so a rejected link produces neither.

This relaxes the stdlib-only rule, deliberately and recorded in CLAUDE.md.
The rule bought one self-contained binary with no supply chain to audit,
which one small pure-Go package barely dents; F-Droid never applied to
the server, only the app ships there. A correct QR encoder is ~500 lines
of Reed-Solomon that nobody should be hand-writing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:48:02 +02:00

421 lines
20 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminui
import (
"bytes"
"html/template"
"log/slog"
"net/http"
"strings"
)
// 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 },
// verdictClass keeps an uploaded string out of the class attribute. The verdict arrives inside
// a document a device sent us, so interpolating it into markup would be trusting a stranger's
// text with a place in the stylesheet; mapping through a fixed set costs nothing and closes it.
"verdictClass": func(v string) string {
switch strings.ToLower(v) {
case "green", "yellow", "red", "inconclusive":
return "v-" + strings.ToLower(v)
default:
return "v-unknown"
}
},
// verdictLabel says what the light means rather than what it is called. "yellow" is a colour;
// "worth a look" is a finding, and the reader is here to act on it.
"verdictLabel": func(v string) string {
switch strings.ToLower(v) {
case "green":
return "clean"
case "yellow":
return "worth a look"
case "red":
return "faults found"
case "inconclusive":
return "inconclusive"
default:
return "not recorded"
}
},
}).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)
}
// The visual language is an echo sounder's, which is what the name means: an instrument that emits
// a ping and reads what comes back. That gives the palette (the colours of a water column rather
// than a neutral near-black), the type (machine-set, because an instrument's readings are), and
// the one piece of real ornament — a trace of returns across time on the runs page.
//
// No web fonts: the CSP forbids loading anything, and shipping font files with a single Go binary
// would trade the property that makes this server pleasant to run for a typeface. So the character
// has to come from treatment — tracking, case, scale, rules — rather than from novel letterforms.
//
// Tables become stacked records below 46rem rather than scrolling sideways. That is not a fallback:
// a sounding log prints as label-and-value pairs, and on a phone that form is easier to read than
// any table, so the mobile layout is the more faithful one of the two.
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{
--abyss:#071419; --hull:#0d2028; --raise:#122a34; --rule:#17323d;
--ink:#dce8ea; --dim:#7d97a1; --trace:#6fc9b4;
--green:#57ad82; --amber:#cf9b3c; --red:#c25757; --slate:#62767f;
--mono:ui-monospace,"SF Mono","IBM Plex Mono","JetBrains Mono",Menlo,Consolas,monospace;
--prose:system-ui,-apple-system,"Segoe UI",sans-serif;
color-scheme:dark;
}
*{box-sizing:border-box}
body{margin:0;background:var(--abyss);color:var(--ink);
font:400 15px/1.55 var(--prose);-webkit-text-size-adjust:100%}
/* ---- masthead ------------------------------------------------------------------------ */
/* Wraps rather than overflows: a rigid row pushes the account and its sign-out button past
the edge of a phone screen, where they cannot be reached at all. */
.top{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1.4rem;
padding:.85rem 1.1rem;background:var(--hull);border-bottom:1px solid var(--rule)}
.mark{font:600 .95rem/1 var(--mono);letter-spacing:.02em;margin:0;color:var(--ink)}
.mark span{color:var(--trace)}
.top nav{display:flex;flex-wrap:wrap;gap:.15rem .9rem}
.top nav a{font:500 .82rem/1 var(--mono);letter-spacing:.06em;color:var(--dim);
text-decoration:none;padding:.35rem 0;border-bottom:1px solid transparent}
.top nav a:hover{color:var(--ink)}
.top nav a[aria-current]{color:var(--trace);border-bottom-color:var(--trace)}
.who{margin-left:auto;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;
font:.78rem/1.3 var(--mono);color:var(--dim)}
main{padding:1.1rem;max-width:64rem}
/* ---- headings: a graduation mark, like a depth scale ------------------------------- */
h2{font:500 1.05rem/1.2 var(--mono);letter-spacing:-.01em;margin:1.4rem 0 .2rem;
padding-left:.7rem;border-left:2px solid var(--trace)}
h2:first-child{margin-top:0}
h3{font:500 .8rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
color:var(--dim);margin:0 0 .7rem}
.lede{color:var(--dim);font-size:.9rem;margin:.5rem 0 1rem;max-width:46rem}
/* ---- readout: how an instrument prints a value ------------------------------------- */
.readout{list-style:none;margin:0;padding:0}
.readout li{display:flex;align-items:baseline;gap:.5rem;padding:.3rem 0;
font:.85rem/1.4 var(--mono)}
.readout .k{color:var(--dim);white-space:nowrap}
/* The dotted leader is how a sounding log runs a label out to its value. It is also the thing
that lets a label and a number sit on one line at any width without a table. */
.readout .lead{flex:1 1 auto;min-width:1.5rem;align-self:center;height:1px;
background:repeating-linear-gradient(90deg,var(--rule) 0 2px,transparent 2px 5px)}
.readout .v{color:var(--ink);text-align:right;overflow-wrap:anywhere}
/* ---- the trace: one bar per run, oldest to newest ---------------------------------- */
/* The signature, and the only ornament here: an echo sounder draws returns against time, and
so does this. Rows arrive newest-first, so the strip is reversed in CSS rather than in Go. */
.trace{display:flex;flex-direction:row-reverse;justify-content:flex-end;align-items:flex-end;gap:2px;
height:3rem;padding:.7rem .8rem;background:var(--hull);
border:1px solid var(--rule);border-radius:3px;overflow:hidden}
.trace i{flex:1 1 3px;min-width:2px;max-width:9px;border-radius:1px;opacity:.9}
.trace .v-green{height:35%;background:var(--green)}
.trace .v-yellow{height:65%;background:var(--amber)}
.trace .v-red{height:100%;background:var(--red)}
.trace .v-inconclusive{height:22%;background:var(--slate)}
.trace .v-unknown{height:12%;background:var(--rule)}
.trace-key{display:flex;flex-wrap:wrap;gap:.3rem .9rem;margin:.45rem 0 0;
font:.72rem/1 var(--mono);letter-spacing:.05em;color:var(--dim)}
.trace-key b{font-weight:400;color:var(--dim)}
.trace-key em{font-style:normal;display:inline-block;width:.5rem;height:.5rem;
border-radius:1px;margin-right:.35rem;vertical-align:baseline;background:var(--rule)}
.trace-key em.v-green{background:var(--green)}
.trace-key em.v-yellow{background:var(--amber)}
.trace-key em.v-red{background:var(--red)}
.trace-key em.v-inconclusive{background:var(--slate)}
/* ---- records: tables that stack on a phone ----------------------------------------- */
.rec{border:1px solid var(--rule);border-radius:3px;background:var(--hull);
padding:.75rem .85rem;margin:.5rem 0}
.rec-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem;
font:.85rem/1.3 var(--mono);margin-bottom:.35rem}
.rec-head .id{overflow-wrap:anywhere;color:var(--ink)}
.rec form{margin-top:.6rem}
/* Why a check matters is a sentence, so it is set as one — full width under the row rather
than squeezed into a column, where it would wrap to a ribbon two words wide. */
.why{font:.85rem/1.5 var(--prose);color:var(--dim);margin-top:.45rem;max-width:52rem}
.tag{font:.68rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
padding:.24rem .45rem;border-radius:2px;border:1px solid currentColor;white-space:nowrap}
.v-green{color:var(--green)} .v-yellow{color:var(--amber)}
.v-red{color:var(--red)} .v-inconclusive{color:var(--slate)} .v-unknown{color:var(--dim)}
/* ---- panels, controls, states ------------------------------------------------------ */
.narrow{max-width:27rem}
.panel{background:var(--hull);border:1px solid var(--rule);border-radius:3px;
padding:.95rem 1rem;margin:.9rem 0;min-width:0}
/* White plate behind the code: a QR needs the light modules to actually be light, and this
page is dark. */
.qr{display:inline-block;background:#fff;padding:8px;border-radius:4px;margin:.2rem 0;line-height:0}
.qr svg{display:block;width:min(240px,60vw);height:auto}
.empty{border:1px dashed var(--rule);border-radius:3px;padding:1.4rem 1rem;
color:var(--dim);font-size:.9rem}
code,pre,.mono{font-family:var(--mono);font-size:.82rem}
code{overflow-wrap:anywhere;color:var(--trace)}
pre{background:#040d11;border:1px solid var(--rule);border-radius:3px;padding:.8rem;
overflow:auto;max-height:32rem;max-width:100%;color:var(--ink)}
a{color:var(--trace)}
button{font:500 .82rem/1 var(--mono);letter-spacing:.05em;background:var(--trace);
color:#04181a;border:0;border-radius:3px;padding:.55rem .9rem;cursor:pointer}
.btn{display:inline-block;font:500 .82rem/1 var(--mono);letter-spacing:.05em;
background:var(--trace);color:#04181a;border-radius:3px;padding:.55rem .9rem;
text-decoration:none}
button.plain{background:transparent;color:var(--dim);border:1px solid var(--rule)}
button.danger{background:transparent;color:var(--red);border:1px solid var(--red)}
button:hover{filter:brightness(1.08)}
input{font:.9rem var(--mono);background:#040d11;color:var(--ink);border:1px solid var(--rule);
border-radius:3px;padding:.55rem .6rem;max-width:100%;width:100%}
label{display:block;font:.72rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;
color:var(--dim);margin:.9rem 0 .3rem}
.err{border:1px solid var(--red);color:var(--ink);background:rgba(194,87,87,.09);
padding:.6rem .75rem;border-radius:3px;font-size:.9rem}
.muted{color:var(--dim)}
form.inline{display:inline}
:focus-visible{outline:2px solid var(--trace);outline-offset:2px}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
/* ---- above 46rem the records line up in columns ------------------------------------ */
@media (min-width:46rem){
.top{padding:.85rem 1.6rem}
main{padding:1.6rem}
.recs{margin:.8rem 0}
/* Every row shares one grid, so the columns agree across rows without a header or a table. */
.rec{display:grid;grid-template-columns:minmax(12.5rem,18rem) minmax(0,1fr) auto;gap:.35rem 1.4rem;
align-items:baseline;background:none;border:0;border-bottom:1px solid var(--rule);
border-radius:0;padding:.6rem 0;margin:0}
.rec-head{margin:0;flex-direction:column;align-items:flex-start;gap:.3rem}
.rec form{margin:0}
/* Widths follow the content: a device name needs room, a finding count does not. */
.rec .readout{display:grid;grid-template-columns:1.7fr .9fr .9fr 1.1fr;gap:.15rem 1.2rem}
.rec .readout li{padding:0}
.rec .readout .lead{display:none}
.rec .readout .v{text-align:left}
.open{white-space:nowrap}
/* Spans the full row: the sentence is the useful part, not a fourth column. */
.why{grid-column:1/-1;margin-top:.1rem}
}
</style></head><body>
<header class="top">
<h1 class="mark">echo<span>lot</span></h1>
{{if ne .Page "login"}}
<nav>
<a href="/"{{if eq .Page "dashboard"}} aria-current="page"{{end}}>overview</a>
<a href="/devices"{{if eq .Page "devices"}} aria-current="page"{{end}}>devices</a>
<a href="/runs"{{if eq .Page "runs"}} aria-current="page"{{end}}>runs</a>
</nav>
<span class="who">{{.Session.Display}}{{if not .Session.Admin}} &middot; your account{{end}}
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
</span>
{{end}}
</header>
<main>
{{if eq .Page "login"}}
<h2>Sign in</h2>
<p class="lede">This server keeps the measurements your devices have uploaded.</p>
{{if .OIDC}}
<p><a class="btn" href="/auth/start">Sign in with your identity provider</a></p>
{{end}}
{{if .LocalSet}}
<form method="post" action="/login" class="panel narrow">
<h3>Break-glass account</h3>
<label for="u">Username</label>
<input id="u" name="username" autocomplete="username" value="{{.AdminUser}}">
<label for="p">Password</label>
<input id="p" name="password" type="password" autocomplete="current-password">
<p><button>Sign in</button></p>
</form>
{{else}}
<p class="err">No break-glass account is set. Run
<code>echolot-server --set-admin-password</code> on the host to create one.</p>
{{end}}
{{else if eq .Page "dashboard"}}
<h2>{{if .Admin}}This server{{else}}Your account{{end}}</h2>
<ul class="readout panel">
<li><span class="k">{{if .Admin}}devices enrolled{{else}}your devices{{end}}</span>
<span class="lead"></span><span class="v">{{.Devices}}</span></li>
{{if .Admin}}
<li><span class="k">linked to an account</span>
<span class="lead"></span><span class="v">{{.Linked}}</span></li>
{{end}}
<li><span class="k">{{if .Admin}}runs stored{{else}}your runs{{end}}</span>
<span class="lead"></span><span class="v">{{.Runs}}</span></li>
{{if .Admin}}
<li><span class="k">server version</span>
<span class="lead"></span><span class="v">{{.Version}}</span></li>
{{end}}
</ul>
{{if not .Admin}}
<div class="panel">
<p>You can see every device you have signed in on, read everything they have uploaded, and
delete any of it.</p>
<p class="muted">Enrolling devices, revoking them, and reading other people's uploads need an
administrator account.</p>
</div>
{{end}}
{{with .SelfTest}}
<h2>Self-test</h2>
<p class="lede">What this server can measure from where it stands, checked at startup. A
capability missing here is missing from every run this server takes part in &mdash; so a
client asking for that measurement gets nothing, rather than a wrong answer.</p>
<ul class="readout panel">
<li><span class="k">kernel settings</span><span class="lead"></span>
<span class="v {{if .SysctlOK}}v-green{{else}}v-yellow{{end}}">{{if .SysctlOK}}as needed{{else}}need attention{{end}}</span></li>
<li><span class="k">egress path MTU</span><span class="lead"></span>
<span class="v {{if .MTUOK}}v-green{{else}}v-yellow{{end}}">{{if .MTUOK}}full 1500{{else}}reduced{{end}}</span></li>
</ul>
{{if .Sysctls}}
<h3>Kernel settings</h3>
<div class="recs">
{{range .Sysctls}}
<div class="rec">
<div class="rec-head"><span class="id">{{.Name}}</span>
<span class="tag {{if eq .Severity "ok"}}v-green{{else}}v-yellow{{end}}">{{.Severity}}</span></div>
<ul class="readout">
<li><span class="k">found</span><span class="lead"></span><span class="v">{{.Got}}</span></li>
<li><span class="k">wanted</span><span class="lead"></span><span class="v">{{.Want}}</span></li>
</ul>
<div class="why">{{.Why}}</div>
</div>
{{end}}
</div>
{{end}}
{{if .EgressMTU}}
<h3>Egress path MTU</h3>
<div class="recs">
{{range .EgressMTU}}
<div class="rec">
<div class="rec-head"><span class="id">{{.Target}}</span>
<span class="tag {{if .FullMTU}}v-green{{else}}v-yellow{{end}}">{{if .FullMTU}}full{{else}}reduced{{end}}</span></div>
<ul class="readout">
<li><span class="k">discovered</span><span class="lead"></span>
<span class="v">{{if .DiscoveredMTU}}{{.DiscoveredMTU}} bytes{{else}}not measured{{end}}</span></li>
</ul>
{{with .Err}}<div class="why">{{.}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}
{{else if eq .Page "devices"}}
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
{{with .Link}}
<div class="panel">
<h3>Enrolment link</h3>
<p class="lede">Single use, valid 24 hours. Treat it like a password until it is spent.</p>
<!-- On the phone being enrolled this is the whole procedure: the scheme is registered by the
app, so following the link hands it the token directly. Copying a 200-character string
between two devices is the step that goes wrong, and it does not have to happen at all. -->
{{with $.LinkHref}}<p><a class="btn" href="{{.}}">Open in the Echolot app</a></p>{{end}}
<p class="muted">Works on the phone you are enrolling. From another device, scan this:</p>
{{with $.LinkQR}}<div class="qr">{{.}}</div>{{end}}
<p class="muted">Or copy the link into the app's enrolment field, or deliver it over adb.</p>
<p><code>{{.}}</code></p>
<p class="muted mono">adb shell am start -a android.intent.action.VIEW -d "{{.}}"</p>
</div>
{{end}}
{{if .Admin}}
<form method="post" action="/enroll-tokens">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button>Create enrolment link</button>
</form>
{{end}}
{{if .Rows}}<div class="recs">
{{range .Rows}}
<div class="rec">
<div class="rec-head">
<span class="id">{{if .Name}}{{.Name}}{{else}}{{.ID}}{{end}}</span>
{{if .LinkedToAccount}}<span class="tag v-green">{{.AccountName}}</span>
{{else}}<span class="tag v-unknown">no account</span>{{end}}
</div>
<ul class="readout">
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.ID}}</span></li>
<li><span class="k">enrolled</span><span class="lead"></span>
<span class="v">{{.Enrolled.Format "2006-01-02 15:04"}}</span></li>
<li><span class="k">runs</span><span class="lead"></span><span class="v">{{.Runs}}</span></li>
</ul>
{{if $.Admin}}
<form method="post" action="/devices/{{.ID}}/revoke">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="danger">Revoke</button>
</form>
{{end}}
</div>
{{end}}
</div>{{else}}
<p class="empty">{{if .Admin}}No devices yet. Create an enrolment link and open it on the phone
you want to measure from.{{else}}No devices yet. Sign in from the Echolot app on your phone to
link one to this account.{{end}}</p>
{{end}}
{{else if eq .Page "runs"}}
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</h2>
<p class="lede">Each run is shown exactly as it arrived, at the privacy level its uploader chose.
Nothing here can un-redact one.</p>
{{if .Rows}}
<div class="trace">{{range .Rows}}<i class="{{verdictClass .Verdict}}"></i>{{end}}</div>
<p class="trace-key"><b>oldest &rarr; newest</b>
<b><em class="v-green"></em>clean</b>
<b><em class="v-yellow"></em>worth a look</b>
<b><em class="v-red"></em>faults</b>
<b><em class="v-inconclusive"></em>inconclusive</b></p>
{{end}}
{{if .Rows}}<div class="recs">
{{range .Rows}}
<div class="rec">
<div class="rec-head">
<span class="id">{{.UploadedAt.Format "2006-01-02 15:04"}}</span>
<span class="tag {{verdictClass .Verdict}}">{{verdictLabel .Verdict}}</span>
</div>
<ul class="readout">
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.DeviceName}}</span></li>
<li><span class="k">findings</span><span class="lead"></span><span class="v">{{.FindingCount}}</span></li>
<li><span class="k">size</span><span class="lead"></span><span class="v">{{kb .SizeBytes}} kB</span></li>
<li><span class="k">privacy</span><span class="lead"></span><span class="v">{{.Anonymization}}</span></li>
</ul>
<div class="open"><a href="/runs/{{.DeviceID}}/{{.ID}}">Open run</a></div>
</div>
{{end}}
</div>{{else}}
<p class="empty">Nothing uploaded yet. Take a measurement in the app and upload it; it will
appear here.</p>
{{end}}
{{else if eq .Page "run"}}
<h2>Run {{.ID}}</h2>
<p class="lede">The document as stored, indented for reading. Nothing has been added or removed.</p>
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="danger">Delete this run</button>
</form>
<pre>{{.JSON}}</pre>
{{end}}
</main></body></html>
`