// 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 = ` Echolot — {{.Page}}

echolot

{{if ne .Page "login"}} {{.Session.Display}}{{if not .Session.Admin}} · your account{{end}}
{{end}}
{{if eq .Page "login"}}

Sign in

This server keeps the measurements your devices have uploaded.

{{if .OIDC}}

Sign in with your identity provider

{{end}} {{if .LocalSet}}

Break-glass account

{{else}}

No break-glass account is set. Run echolot-server --set-admin-password on the host to create one.

{{end}} {{else if eq .Page "dashboard"}}

{{if .Admin}}This server{{else}}Your account{{end}}

{{if not .Admin}}

You can see every device you have signed in on, read everything they have uploaded, and delete any of it.

Enrolling devices, revoking them, and reading other people's uploads need an administrator account.

{{end}} {{with .SelfTest}}

Self-test

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 — so a client asking for that measurement gets nothing, rather than a wrong answer.

{{if .Sysctls}}

Kernel settings

{{range .Sysctls}}
{{.Name}} {{.Severity}}
  • found{{.Got}}
  • wanted{{.Want}}
{{.Why}}
{{end}}
{{end}} {{if .EgressMTU}}

Egress path MTU

{{range .EgressMTU}}
{{.Target}} {{if .FullMTU}}full{{else}}reduced{{end}}
  • discovered {{if .DiscoveredMTU}}{{.DiscoveredMTU}} bytes{{else}}not measured{{end}}
{{with .Err}}
{{.}}
{{end}}
{{end}}
{{end}} {{end}} {{else if eq .Page "devices"}}

{{if .Admin}}Devices{{else}}Your devices{{end}}

{{with .Link}}

Enrolment link

Single use, valid 24 hours. Treat it like a password until it is spent.

{{.}}

adb shell am start -a android.intent.action.VIEW -d "{{.}}"

{{end}} {{if .Admin}}
{{end}} {{if .Rows}}
{{range .Rows}}
{{if .Name}}{{.Name}}{{else}}{{.ID}}{{end}} {{if .LinkedToAccount}}{{.AccountName}} {{else}}no account{{end}}
  • device{{.ID}}
  • enrolled {{.Enrolled.Format "2006-01-02 15:04"}}
  • runs{{.Runs}}
{{if $.Admin}}
{{end}}
{{end}}
{{else}}

{{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}}

{{end}} {{else if eq .Page "runs"}}

{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}

Each run is shown exactly as it arrived, at the privacy level its uploader chose. Nothing here can un-redact one.

{{if .Rows}}
{{range .Rows}}{{end}}

oldest → newest clean worth a look faults inconclusive

{{end}} {{if .Rows}}
{{range .Rows}}
{{.UploadedAt.Format "2006-01-02 15:04"}} {{verdictLabel .Verdict}}
  • device{{.DeviceName}}
  • findings{{.FindingCount}}
  • size{{kb .SizeBytes}} kB
  • privacy{{.Anonymization}}
{{end}}
{{else}}

Nothing uploaded yet. Take a measurement in the app and upload it; it will appear here.

{{end}} {{else if eq .Page "run"}}

Run {{.ID}}

The document as stored, indented for reading. Nothing has been added or removed.

{{.JSON}}
{{end}}
`