ZDDC/zddc/internal/handler/adminhandler.go
ZDDC 9ef90800b1 feat(zddc-server): admin debug page + X-Auth-Request-Email default + hidden-segment guard
Three improvements bundled because they all ship as zddc-server v0.0.2:

* /.admin/ debug dashboard with /whoami, /config, /logs sub-routes.
  Authorization via a top-level `admins:` glob list in <ZDDC_ROOT>/.zddc
  (root-only — subdir entries deliberately ignored to prevent privilege
  escalation via subtree write access). Non-admin requests get 404 so the
  page is invisible. Recent logs surface via a 500-entry slog ring buffer
  teed off the existing TextHandler. Lets operators debug without
  kubectl exec.

* Default ZDDC_EMAIL_HEADER changes from `X-Email` to
  `X-Auth-Request-Email` — the oauth2-proxy / nginx auth-request
  convention that the TND helm chart already sets explicitly.
  Operators who set the env var explicitly are unaffected; deployments
  relying on the previous default need to set ZDDC_EMAIL_HEADER=X-Email
  or update their proxy.

* dispatch() rejects any URL whose segments contain a dot prefix other
  than the recognized virtual prefixes (.admin, cfg.IndexPath /
  .archive). Matches the existing listing-pipeline filter so hidden
  subtrees on the served PVC (e.g. /srv/.devshell — used by the
  in-cluster dev-shell for persistent home-dir state) become
  unreachable via direct HTTP fetch, not just hidden in listings.

Refreshes the X-Email reference in website/index.html accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:02:06 -05:00

268 lines
9.1 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// AdminPathPrefix is the URL prefix at which the admin debug page is served.
// Hardcoded — see plan: collision with a real archive folder named ".admin"
// is essentially impossible, and we intercept in dispatch() before filesystem
// resolution. If a real conflict ever shows up, make this a config value.
const AdminPathPrefix = "/.admin"
// ServeAdmin is the entry point for /.admin/* routes. It enforces the
// admins-allowlist gate (returns 404 on non-admin so the existence of the
// admin page is not leaked) and dispatches to a sub-handler.
//
// Auth model: a request is admin if EmailFromContext(r) matches an entry in
// the Admins list of <cfg.Root>/.zddc. See zddc.IsAdmin.
func ServeAdmin(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
// Trim the prefix, keep a leading "/" for sub-route matching.
sub := strings.TrimPrefix(r.URL.Path, AdminPathPrefix)
if sub == "" {
sub = "/"
}
switch sub {
case "/", "":
serveAdminDashboard(w, r)
case "/whoami":
serveAdminWhoami(cfg, email, w, r)
case "/config":
serveAdminConfig(cfg, w, r)
case "/logs":
serveAdminLogs(ring, w, r)
default:
http.NotFound(w, r)
}
}
// writeJSON writes v as indented JSON. Sets Content-Type and disables caching
// (admin views are always live).
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// serveAdminWhoami returns the data needed to debug header-passthrough
// problems: which header the server is configured to read, what value (if
// any) arrived under that header, the resolved email, and a dump of every
// header on the request. This is the actual answer to "is X-Auth-Request-Email
// arriving at the binary?".
func serveAdminWhoami(cfg config.Config, email string, w http.ResponseWriter, r *http.Request) {
// r.Header keys are canonicalized by net/http (e.g. "x-auth-request-email"
// becomes "X-Auth-Request-Email"). Iterate to a stable order.
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
headers := make(map[string][]string, len(keys))
for _, k := range keys {
headers[k] = r.Header.Values(k)
}
type response struct {
ConfiguredEmailHeader string `json:"configured_email_header"`
ObservedEmail string `json:"observed_email"`
ResolvedEmail string `json:"resolved_email"`
RemoteAddr string `json:"remote_addr"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string][]string `json:"headers"`
}
writeJSON(w, response{
ConfiguredEmailHeader: cfg.EmailHeader,
ObservedEmail: r.Header.Get(cfg.EmailHeader),
ResolvedEmail: email,
RemoteAddr: r.RemoteAddr,
Method: r.Method,
URL: r.URL.String(),
Headers: headers,
})
}
// serveAdminConfig dumps the parsed Config. Field semantics:
//
// - TLSCert / TLSKey are reported as the env-var values supplied by the
// operator (typically a file path or the literal "none"). The contents of
// the cert/key files are never read or echoed.
// - All other fields are echoes of operator-supplied env vars or sensible
// defaults — none constitute a secret.
func serveAdminConfig(cfg config.Config, w http.ResponseWriter, r *http.Request) {
type response struct {
Root string `json:"root"`
Addr string `json:"addr"`
TLSCert string `json:"tls_cert"`
TLSKey string `json:"tls_key"`
TLSMode string `json:"tls_mode"`
LogLevel string `json:"log_level"`
IndexPath string `json:"index_path"`
EmailHeader string `json:"email_header"`
CORSOrigins []string `json:"cors_origins"`
}
writeJSON(w, response{
Root: cfg.Root,
Addr: cfg.Addr,
TLSCert: cfg.TLSCert,
TLSKey: cfg.TLSKey,
TLSMode: cfg.TLSMode,
LogLevel: cfg.LogLevel,
IndexPath: cfg.IndexPath,
EmailHeader: cfg.EmailHeader,
CORSOrigins: cfg.CORSOrigins,
})
}
// serveAdminLogs returns the ring buffer's current contents. Optional query
// params:
//
// - level=debug|info|warn|error — minimum level to include
// - since=<RFC3339> — drop entries strictly older than this ts
func serveAdminLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
if ring == nil {
writeJSON(w, []LogEntry{})
return
}
entries := ring.Snapshot()
if levelStr := r.URL.Query().Get("level"); levelStr != "" {
min := levelRank(levelStr)
out := entries[:0]
for _, e := range entries {
if levelRank(strings.ToLower(e.Level)) >= min {
out = append(out, e)
}
}
entries = out
}
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
if since, err := time.Parse(time.RFC3339, sinceStr); err == nil {
out := entries[:0]
for _, e := range entries {
if !e.Time.Before(since) {
out = append(out, e)
}
}
entries = out
}
}
writeJSON(w, entries)
}
func levelRank(s string) int {
switch strings.ToLower(s) {
case "debug":
return 0
case "info":
return 1
case "warn", "warning":
return 2
case "error":
return 3
default:
return 1 // unknown → info
}
}
// adminDashboardHTML is the static dashboard page. Self-contained: all CSS
// and JS inline, no external assets. Three sections that fetch the JSON
// endpoints client-side and render the result.
const adminDashboardHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>zddc-server — admin debug</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 1.5rem; color: #222; max-width: 1100px; }
h1 { margin: 0 0 1rem; font-size: 1.4rem; }
h2 { margin: 1.5rem 0 .4rem; font-size: 1.05rem; border-bottom: 1px solid #ddd; padding-bottom: .2rem; }
pre { background: #f5f5f5; padding: .75rem; border-radius: 4px; overflow: auto; font-size: 12px; max-height: 28rem; }
.row { display: flex; gap: .5rem; align-items: center; margin-bottom: .25rem; }
button { font: inherit; padding: .25rem .75rem; cursor: pointer; }
.err { color: #b00020; }
.muted { color: #666; font-size: .9em; }
code { background: #eee; padding: 0 .25rem; border-radius: 2px; font-size: 12px; }
</style>
</head>
<body>
<h1>zddc-server admin debug</h1>
<p class="muted">Live state from the running process. Fetched client-side; refresh each section with its button.</p>
<h2>whoami <button data-target="whoami">refresh</button></h2>
<p class="muted">What headers actually arrived. The <code>configured_email_header</code> is the header name the binary is reading; <code>observed_email</code> is the value at that name; <code>headers</code> is everything received.</p>
<pre id="whoami">loading…</pre>
<h2>config <button data-target="config">refresh</button></h2>
<p class="muted">Effective config from environment variables. <code>tls_cert</code> / <code>tls_key</code> show the supplied path strings; file contents are not read.</p>
<pre id="config">loading…</pre>
<h2>logs <button data-target="logs">refresh</button> <span class="muted">level: <select id="level"><option value="">all</option><option value="debug">debug+</option><option value="info" selected>info+</option><option value="warn">warn+</option><option value="error">error</option></select></span></h2>
<pre id="logs">loading…</pre>
<script>
async function load(target, qs) {
const el = document.getElementById(target);
el.textContent = "loading…";
el.classList.remove("err");
try {
const url = "%[1]s/" + target + (qs ? ("?" + qs) : "");
const resp = await fetch(url, { headers: { Accept: "application/json" } });
const text = await resp.text();
if (!resp.ok) {
el.classList.add("err");
el.textContent = "HTTP " + resp.status + "\n\n" + text;
return;
}
try {
el.textContent = JSON.stringify(JSON.parse(text), null, 2);
} catch {
el.textContent = text;
}
} catch (e) {
el.classList.add("err");
el.textContent = String(e);
}
}
document.querySelectorAll("button[data-target]").forEach(b => {
b.addEventListener("click", () => {
const t = b.dataset.target;
const qs = t === "logs" ? ("level=" + (document.getElementById("level").value || "")) : "";
load(t, qs);
});
});
document.getElementById("level").addEventListener("change", () => load("logs", "level=" + (document.getElementById("level").value || "")));
load("whoami");
load("config");
load("logs", "level=info");
</script>
</body>
</html>
`
func serveAdminDashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, adminDashboardHTML, AdminPathPrefix)
}