Signing in and being allowed to administer the server were the same question: the OIDC callback refused a session outright to anyone outside the admin group. A legitimate user could authenticate, be told what they could not do, and be left with no way to see or delete the data their own devices had uploaded. They are separate questions now. Everyone who authenticates gets a session; the admin flag rides inside the MAC'd payload, so promoting yourself means forging a signature rather than editing a cookie, and a role that does not parse fails closed to "user". Pages scope themselves through visibleDevices/mayTouchRun rather than filtering individually — per-page scoping is what the next page added will be missing, and that failure is silent, since a listing that leaks other people's uploads looks exactly like one that does not. Someone else's run answers 404, not 403: a distinguishable refusal would confirm the run exists. Revoking devices and minting enrolment tokens affect the whole server and stay behind adminOnly at the route table, where someone looking for who-may-do-what will actually find it. Ownership is re-read per request instead of captured at sign-in, so unlinking an account takes effect immediately rather than at session expiry. Tests cover that, plus the degenerate case of an empty subject, which must own nothing rather than everything with an empty account id. Also: attribute the ICMPv6 finding per network. It compared "is IPv6 configured anywhere on this device" against "did any network answer", which on a phone reports IPv6-is-broken about a network where IPv6 was never configured. network_ref is null on every test, so the probe now records per-network outcomes structurally rather than as prose a finding would have to parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
7.7 KiB
Go
183 lines
7.7 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"
|
|
)
|
|
|
|
// 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 — {{.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}}{{if not .Session.Admin}} <span class="muted">(your account)</span>{{end}}
|
|
<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>{{if .Admin}}devices{{else}}your devices{{end}}</span></div>
|
|
{{if .Admin}}<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>{{end}}
|
|
<div class="stat"><b>{{.Runs}}</b><span>{{if .Admin}}stored runs{{else}}your runs{{end}}</span></div>
|
|
</div>
|
|
{{if not .Admin}}
|
|
<div class="card">
|
|
<p>This is your account. You can see the devices you have signed in on, review everything
|
|
they have uploaded, and delete any of it.</p>
|
|
<p class="muted">Administering the server — enrolling devices, revoking them, and
|
|
seeing other people's uploads — needs an administrator account.</p>
|
|
</div>
|
|
{{end}}
|
|
{{if .Admin}}
|
|
<div class="card">
|
|
<h3>Server</h3>
|
|
<p class="muted">version {{.Version}}</p>
|
|
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
{{else if eq .Page "devices"}}
|
|
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
|
{{with .Link}}
|
|
<div class="card">
|
|
<p><b>Enrolment link</b> — 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}}
|
|
{{if .Admin}}
|
|
<form method="post" action="/enroll-tokens">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<button>Create enrolment link</button>
|
|
</form>
|
|
{{end}}
|
|
<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">—</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>{{if $.Admin}}<form method="post" action="/devices/{{.ID}}/revoke" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button class="danger">Revoke</button></form>{{end}}</td>
|
|
</tr>
|
|
{{else}}
|
|
<tr><td colspan="6" class="muted">{{if $.Admin}}No devices enrolled.{{else}}You have not signed in on any device yet. Sign in from the Echolot app to link one.{{end}}</td></tr>
|
|
{{end}}
|
|
</table>
|
|
|
|
{{else if eq .Page "runs"}}
|
|
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</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">—</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>
|
|
`
|