diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 1610ad6..4139581 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -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" @@ -297,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")) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - // 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), - }) - }) + // 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 { + return fmt.Errorf("admin session secret: %w", err) + } + 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 diff --git a/server/internal/adminui/auth.go b/server/internal/adminui/auth.go new file mode 100644 index 0000000..bcb4130 --- /dev/null +++ b/server/internal/adminui/auth.go @@ -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 +} diff --git a/server/internal/adminui/pages.go b/server/internal/adminui/pages.go new file mode 100644 index 0000000..bf06674 --- /dev/null +++ b/server/internal/adminui/pages.go @@ -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) +} diff --git a/server/internal/adminui/render.go b/server/internal/adminui/render.go new file mode 100644 index 0000000..e9f582f --- /dev/null +++ b/server/internal/adminui/render.go @@ -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 = ` + + +Echolot — {{.Page}} + +{{if ne .Page "login"}} +
+

Echolot

+ + {{.Session.Display}} +
+
+
+{{end}} +
+ +{{if eq .Page "login"}} +

Sign in

+ {{with .Error}}

{{.}}

{{end}} + {{if .OIDC}} +

+

or use the break-glass account:

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

+

+

+
+ {{else}} +

No break-glass admin is set. Run + echolot-server --set-admin-password on the host.

+ {{end}} + +{{else if eq .Page "dashboard"}} +
+
{{.Devices}}devices
+
{{.Linked}}signed in
+
{{.Runs}}stored runs
+
+
+

Server

+

version {{.Version}}

+ {{with .SelfTest}}
{{printf "%+v" .}}
{{end}} +
+ +{{else if eq .Page "devices"}} +

Devices

+ {{with .Link}} +
+

Enrolment link — single use, valid 24 hours. Treat it like a password until spent.

+

{{.}}

+

On a device with adb:
+ adb shell am start -a android.intent.action.VIEW -d "{{.}}"

+
+ {{end}} +
+ + +
+ + + {{range .Rows}} + + + + + + + + + {{else}} + + {{end}} +
DeviceNameAccountEnrolledRuns
{{.ID}}{{if .Name}}{{.Name}}{{else}}{{end}}{{if .LinkedToAccount}}{{.AccountName}}{{else}}not signed in{{end}}{{.Enrolled.Format "2006-01-02 15:04"}}{{.Runs}}
+ +
No devices enrolled.
+ +{{else if eq .Page "runs"}} +

Uploaded runs

+

Shown exactly as uploaded, at the privacy level the uploader chose. Nothing + here can un-redact a run.

+ + + {{range .Rows}} + + + + + + + + + + {{else}} + + {{end}} +
UploadedDeviceVerdictFindingsSizeLevel
{{.UploadedAt.Format "2006-01-02 15:04"}}{{.DeviceName}}{{if .Verdict}}{{.Verdict}}{{else}}{{end}}{{.FindingCount}}{{kb .SizeBytes}} kB{{.Anonymization}}open
Nothing uploaded yet.
+ +{{else if eq .Page "run"}} +

Run {{.ID}}

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