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>
169 lines
4.9 KiB
Go
169 lines
4.9 KiB
Go
// 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)
|
|
}
|