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>
268 lines
9.1 KiB
Go
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)
|
|
}
|