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= — 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 = ` zddc-server — admin debug

zddc-server admin debug

Live state from the running process. Fetched client-side; refresh each section with its button.

whoami

What headers actually arrived. The configured_email_header is the header name the binary is reading; observed_email is the value at that name; headers is everything received.

loading…

config

Effective config from environment variables. tls_cert / tls_key show the supplied path strings; file contents are not read.

loading…

logs level:

loading…
` 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) }