Generalize the admin model from "single root super-admin" to a delegated chain: a `<dir>/.zddc/admins` list grants admin authority for that subtree, with a strict-ancestor rule preventing self-elevation (you cannot edit the .zddc that grants your own authority — only files strictly below it). Add a guided server-rendered editor at /.admin/zddc/edit?path=<dir> so subtree admins can manage their fiefdoms without filesystem access. JSON API at /.admin/zddc covers GET (file + effective chain + can_edit), POST (atomic write + cache invalidation), DELETE, plus a /tree endpoint listing every .zddc visible to the caller. Optional theming via <root>/.admin.css. Validation: glob syntax check, root-self-demotion rejection, reserved-prefix path guard, YAML round-trip sanity. Writes are atomic (temp file + fsync + rename) and invalidate the policy cache. Also includes the prior in-flight `Title` field on ProjectInfo so per-project .zddc titles surface on the landing-page picker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
277 lines
9.4 KiB
Go
277 lines
9.4 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. The /whoami,
|
|
// /config, /logs, and dashboard sub-routes are super-admin-only (gated
|
|
// by zddc.IsAdmin against the root .zddc); 404 leaks no information
|
|
// about admin endpoint existence.
|
|
//
|
|
// /.admin/zddc/* — the .zddc editor — is reachable to ANY subtree-admin
|
|
// (not just root), so it is dispatched out to ServeZddc before the
|
|
// super-admin gate; ServeZddc applies its own broader hasAnyAdminScope
|
|
// check internally.
|
|
func ServeAdmin(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) {
|
|
// Trim the prefix, keep a leading "/" for sub-route matching.
|
|
sub := strings.TrimPrefix(r.URL.Path, AdminPathPrefix)
|
|
if sub == "" {
|
|
sub = "/"
|
|
}
|
|
|
|
// /.admin/zddc/* — subtree admins reach this; ServeZddc gates itself.
|
|
if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") {
|
|
ServeZddc(cfg, w, r)
|
|
return
|
|
}
|
|
|
|
email := EmailFromContext(r)
|
|
if !zddc.IsAdmin(cfg.Root, email) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|