adminui: an admin interface, behind authentication without exception
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7bb54e1ec8
commit
0eaba6150b
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user