diff --git a/AGENTS.md b/AGENTS.md index 9f9f145..3a2bf70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ Three channels: - **Stable**: versioned, immutable. `sh tool/build.sh --release [version]` writes `website/releases/_v.html`, refreshes the `_stable.html` symlink, and tags `-v`. Skips automatically if source has not changed since the latest tag. Pass an explicit version to override auto-increment. - **Beta**: mutable. `sh tool/build.sh --release beta` overwrites `website/releases/_beta.html` in place. No tag. The on-page label is `beta · · ` so the source is recoverable from git via the SHA. -- **Alpha**: mutable, analogous. `sh tool/build.sh --release alpha`. +- **Alpha**: mutable, analogous. `sh tool/build.sh --release alpha`. **Also**: every plain (non-release) `tool/build.sh` invocation reasserts a relative symlink `website/releases/_alpha.html` → `../..//dist/.html`, so the alpha hyperlinks on the website always serve whatever dist currently holds (no file copy, idempotent — git sees nothing on a rebuild). Symlinked alpha pages carry the dev label `Built: BETA` (red) since the dist file does. A deliberate `--release alpha` overwrites the symlink with a real file labeled `alpha · · `; the next plain build re-symlinks it. Deployment must serve from the repo working folder (or otherwise resolve `../..//dist/` relative to `website/releases/`). Stable releases do **not** automatically clobber `_alpha.html` / `_beta.html` — those keep whatever was last built into them. Use `./freshen-channel ` (see "Freshen helper" below) to drag a channel forward to current stable; never `git checkout` the main worktree by hand for this. @@ -173,7 +173,7 @@ After cutting a stable release, run `git push --tags` to publish the tag. The "skip if no source change since last tag" guard for stable releases compares **HEAD** to the latest tag — uncommitted working-tree changes are invisible. If you edit a tool and want a stable release to actually fire, commit the change first; otherwise the build prints `no source changes since -vX.Y.Z — skipping` and exits 0. Alpha and beta channel builds always rebuild (no skip check). -Agents must **never** write to `website/releases/` or `website/index.html` directly — always go through `--release` or `./freshen-channel`. +Agents must **never** write to `website/releases/_v*.html`, `website/releases/_stable.html`, `website/releases/_beta.html`, or `website/index.html` directly — always go through `--release` or `./freshen-channel`. (The `_alpha.html` files are an exception: every plain build reasserts them as symlinks into `/dist/`, as described above.) `landing/build.sh --release ` additionally writes `website/index.html` (the root URL of zddc.varasys.io). @@ -268,7 +268,7 @@ ZDDC_DATA_DIR=/path/to/your/archive podman-compose -f zddc/podman-compose.yaml u |---|---|---| | `ZDDC_ROOT` | *(required)* | Path to served file tree | | `ZDDC_ADDR` | `:8443` | Bind address | -| `ZDDC_EMAIL_HEADER` | `X-Email` | Header set by upstream proxy with user email | +| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header set by upstream proxy with user email (oauth2-proxy / nginx auth-request convention) | | `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment | | `ZDDC_LOG_LEVEL` | `info` | Logging verbosity | | `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated CORS allowlist; empty value disables CORS. Default lets tools served from zddc.varasys.io call back into a customer-deployed server. | @@ -286,4 +286,6 @@ git push --tags - The container image does NOT require Go on the host — the Containerfile uses a multi-stage build - Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories - The `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision -- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Email` header +- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`) +- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page". +- **Reserved hidden URL segments**: any path under `ZDDC_ROOT` whose URL contains a dot-prefixed segment (e.g. `/.devshell/coder/...`) returns 404 on direct HTTP access and is excluded from listings. Only `.archive` (virtual archive index) and `.admin` (debug page) are exempt. Lets operators co-locate side-state (caches, dev-shell home dirs) with served data on the same PVC. diff --git a/CLAUDE.md b/CLAUDE.md index 9109ada..11ce8fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ No lint/typecheck/format commands exist — vanilla JS + POSIX sh by design. ## Things that bite if you forget - **`dist/` is gitignored but force-committed** (`git add -f tool/dist/tool.html`). Never hand-edit a `dist/` file. -- **Never write to `website/index.html` or `website/releases/*` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/_v.html` (immutable) and refresh `_stable.html`; alpha/beta overwrite `_.html` in place. +- **Never write to `website/index.html`, `website/releases/_v*.html`, `website/releases/_stable.html`, or `website/releases/_beta.html` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/_v.html` (immutable) and refresh `_stable.html`; alpha/beta overwrite `_.html` in place. **Exception: `_alpha.html` files** — every plain `tool/build.sh` reasserts them as relative symlinks into `/dist/`, so dev builds stay clean in git. `--release alpha` overwrites the symlink with a real file; the next plain build re-symlinks. - **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`. - **``** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining. - **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests. diff --git a/website/index.html b/website/index.html index 31812cb..7c315b6 100644 --- a/website/index.html +++ b/website/index.html @@ -168,7 +168,7 @@

zddc-server is a small Go binary purpose-built to serve ZDDC archives. Any web server gives you online mode; zddc-server adds things a generic web server can't:

    -
  • Access control via .zddc files. Behind a reverse proxy that authenticates users and sets an X-Email request header, zddc-server consults YAML .zddc files in directories — cascading bottom-up; deeper rules override. No database, no admin UI.
  • +
  • Access control via .zddc files. Behind a reverse proxy that authenticates users and sets an X-Auth-Request-Email request header, zddc-server consults YAML .zddc files in directories — cascading bottom-up; deeper rules override. No database, no admin UI.
  • Virtual .archive URL space. GET /Project/.archive/123-XYZ.html resolves to the canonical revision file at request time. Computed from filenames; no cache, no separate index file.
  • Per-request access logging keyed to the authenticated user.
  • TLS, ETags, conditional GET, CORS, autoindex. The mundane glue.
  • diff --git a/zddc/README.md b/zddc/README.md index 2965bed..8f3908a 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -45,7 +45,7 @@ ZDDC_DATA_DIR=/srv/archive podman-compose up --build | `ZDDC_TLS_KEY` | *(empty)* | Path to PEM private key file. Required when `ZDDC_TLS_CERT` is a file path; ignored otherwise | | `ZDDC_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | | `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index | -| `ZDDC_EMAIL_HEADER` | `X-Email` | HTTP request header containing the authenticated user's email | +| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | HTTP request header containing the authenticated user's email (the oauth2-proxy / nginx auth-request convention) | | `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated allowlist of origins permitted to make cross-origin requests. Empty value disables CORS entirely. Default lets ZDDC tools served from `zddc.varasys.io` (e.g. via the bootstrap pattern) call back into your deployed server. | `ZDDC_TLS_CERT=none` disables TLS entirely (plain HTTP). Both cert and key must be set together when using real certificates. @@ -64,8 +64,8 @@ ZDDC_CORS_ORIGIN=https://tools.acme.com Multiple origins are comma-separated. To disable CORS entirely (e.g. when all clients are same-origin), set `ZDDC_CORS_ORIGIN=` (empty value). The middleware echoes the matched origin back per-request and sets -`Access-Control-Allow-Credentials: true` so the upstream-set `X-Email` -header crosses the boundary. +`Access-Control-Allow-Credentials: true` so the upstream-set +`X-Auth-Request-Email` header crosses the boundary. ## TLS @@ -103,7 +103,7 @@ podman run --rm \ ## Authentication zddc-server does **not** perform authentication itself. It reads the user's email address -from a request header (default: `X-Email`) that must be set by an upstream reverse proxy +from a request header (default: `X-Auth-Request-Email`) that must be set by an upstream reverse proxy (nginx, Caddy, Traefik, Azure Application Gateway, etc.) after authenticating the user. If the header is absent, the user is treated as anonymous (empty email). A directory with @@ -163,6 +163,59 @@ even if a higher-level rule would deny them. Directories for which the user lacks access are **omitted** from JSON listings entirely — they are neither listed nor queryable. A direct request to a denied path returns `403`. +### Reserved hidden segments + +Any path under `ZDDC_ROOT` whose URL contains a dot-prefixed segment (e.g. `/.devshell/`, +`/Project-A/.internal/notes.md`) is **404** on direct HTTP access and is excluded from +listings. The recognized virtual prefixes (`.archive`, `.admin`) are explicitly permitted +through. This lets operators store side-state (caches, dev-shell home dirs, snapshot +staging) on the same volume that's served, without exposing it. + +## Admin Debug Page + +`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can +push code/images but cannot `kubectl exec` into the running container. It surfaces: + +- **`/.admin/whoami`** — every header on the current request, the configured email + header name, the value observed at that name, and the resolved email. This is the + first thing to look at when access logs show `email=anonymous` — it tells you + exactly which (if any) header the upstream proxy is sending. +- **`/.admin/config`** — the resolved `Config` (env vars). Equivalent to + `kubectl exec -- env | grep ^ZDDC_` for diagnosing chart / deployment overrides. +- **`/.admin/logs`** — recent log entries (last 500) from an in-memory ring buffer. + Optional `?level=info|warn|error|debug` and `?since=` query params. +- **`/.admin/`** — HTML dashboard that fetches the three JSON endpoints client-side. + +### Authorization + +Authorization is via an `admins:` list in the **root** `.zddc` file (`/.zddc`). +Patterns use the same glob syntax as `acl.allow` / `acl.deny`: + +```yaml +admins: + - alice@mycompany.com + - "*@admin.mycompany.com" +acl: + allow: ["*@mycompany.com"] +``` + +Only the root-level `admins` entry is honored — subdirectory `.zddc` files' +`admins` keys are ignored. Otherwise anyone with subtree write access could +elevate themselves. + +If the root `.zddc` has no `admins` list (or no `.zddc` exists), every admin +endpoint returns **404** to every caller. Non-admin requests also receive 404 +(not 403) so the existence of the admin page is invisible to unauthorized +callers. + +### Caveats + +- Logs are in-memory and lost on restart. The buffer holds the most recent 500 + records; for long-term audit, parse the stderr stream the way you already do. +- The page reads only configuration and request state — it does not modify anything. +- An interactive terminal is not yet available; that's planned as a follow-up + behind a separate `ZDDC_ADMIN_TERM=1` env-var gate so it stays opt-in. + ## Landing Page and Tool Install The recommended install drops `install.zip` (downloaded from diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 355775d..2de0965 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -26,7 +26,7 @@ func main() { os.Exit(1) } - setupLogger(cfg.LogLevel) + logRing := setupLogger(cfg.LogLevel) slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr) @@ -66,7 +66,7 @@ func main() { // HTTP handler mux := http.NewServeMux() mux.Handle("/", handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, handler.ACLMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - dispatch(cfg, idx, w, r) + dispatch(cfg, idx, logRing, w, r) }))))) srv := &http.Server{ @@ -106,10 +106,18 @@ func main() { } // dispatch routes a request to the appropriate handler. -func dispatch(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request) { +func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w http.ResponseWriter, r *http.Request) { urlPath := r.URL.Path email := handler.EmailFromContext(r) + // Admin debug routes — gated by IsAdmin allowlist in /.zddc. + // Non-admins receive 404 (not 403) so the existence of the admin page + // is invisible to unauthorized callers. + if urlPath == handler.AdminPathPrefix || strings.HasPrefix(urlPath, handler.AdminPathPrefix+"/") { + handler.ServeAdmin(cfg, ring, w, r) + return + } + // Project list API: GET / with Accept: application/json if urlPath == "/" { accept := r.Header.Get("Accept") @@ -122,6 +130,24 @@ func dispatch(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *h // Split path into segments segments := strings.Split(strings.Trim(urlPath, "/"), "/") + // Reserve dot-prefixed path segments. The listing pipeline already hides + // hidden entries (internal/listing/listing.go:17, projectshandler.go:40), + // but direct URL access would still serve them. 404 here so hidden trees + // like /srv/.devshell (the in-image dev-shell's persistent home dir on + // the same Azure Files PVC as served data) cannot be fetched. The + // recognized virtual prefixes (.admin handled above, cfg.IndexPath + // handled below) are explicitly allowed through. + for _, seg := range segments { + if seg == "" || !strings.HasPrefix(seg, ".") { + continue + } + if seg == cfg.IndexPath { + continue + } + http.NotFound(w, r) + return + } + // Check for .archive segment in the path for i, seg := range segments { if seg == cfg.IndexPath { @@ -181,7 +207,11 @@ func dispatch(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *h handler.ServeFile(w, r, absPath) } -func setupLogger(level string) { +// setupLogger installs a slog default that fans every record out to stderr +// (the existing TextHandler — user-visible logging is unchanged) AND to an +// in-memory ring buffer that backs the /.admin/logs endpoint. Returns the +// ring so handlers can read it. +func setupLogger(level string) *handler.LogRing { var l slog.Level switch strings.ToLower(level) { case "debug": @@ -193,5 +223,9 @@ func setupLogger(level string) { default: l = slog.LevelInfo } - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l}))) + ring := handler.NewLogRing(500) + text := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l}) + rh := handler.NewRingHandler(ring, l) + slog.SetDefault(slog.New(handler.NewMultiHandler(text, rh))) + return ring } diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go new file mode 100644 index 0000000..2d91ab2 --- /dev/null +++ b/zddc/cmd/zddc-server/main_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/archive" + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/handler" +) + +// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that +// rejects requests whose URL contains a dot-prefixed segment (other than +// the recognized virtual prefixes .archive and /.admin handled separately). +// +// The guard exists so the in-image dev-shell can keep persistent state +// (settings, source clones, Go module cache) under /srv/.devshell on the +// same Azure Files PVC as served data without ever exposing those files +// via direct HTTP fetch. +func TestDispatchHidesDotPrefixedSegments(t *testing.T) { + root := t.TempDir() + + // Realistic shape: a project dir, a hidden top-level dir, and a hidden + // sibling of a normal file inside the project. + mustMkdir(t, filepath.Join(root, "Project-A")) + mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok") + mustMkdir(t, filepath.Join(root, ".devshell")) + mustMkdir(t, filepath.Join(root, ".devshell", "coder")) + mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "secret") + mustMkdir(t, filepath.Join(root, "Project-A", ".internal")) + mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret") + + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + + cfg := config.Config{ + Root: root, + IndexPath: ".archive", + EmailHeader: "X-Auth-Request-Email", + } + ring := handler.NewLogRing(10) + + cases := []struct { + name string + path string + wantStatus int + }{ + // Hidden top-level dir — every shape blocked. + {"hidden top dir", "/.devshell/", http.StatusNotFound}, + {"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound}, + + // Hidden segment under a real project dir — also blocked. + {"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound}, + + // Sanity: recognized virtual prefixes are NOT blocked. .archive falls + // through to its own handler (which 404s on missing tracking number, + // but importantly NOT via the dot-prefix guard); .admin is handled + // by an earlier dispatch branch and hits the IsAdmin gate. + {".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler, status matches + {".admin not blocked by guard", "/.admin/whoami", http.StatusNotFound}, // no admins configured → IsAdmin false → 404 from admin handler + + // Normal files unaffected. + {"plain file", "/Project-A/doc.txt", http.StatusOK}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, rec, req) + if rec.Code != tc.wantStatus { + t.Errorf("path=%q status=%d want=%d body=%q", + tc.path, rec.Code, tc.wantStatus, rec.Body.String()) + } + }) + } +} + +func mustMkdir(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } +} + +func mustWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index ce11acc..e46958d 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -17,7 +17,7 @@ type Config struct { TLSMode string // computed from TLSCert/TLSKey: none/selfsigned/provided LogLevel string // ZDDC_LOG_LEVEL — debug/info/warn/error (default info) IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive) - EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Email) + EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email) CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables } @@ -30,7 +30,7 @@ func Load() (Config, error) { TLSKey: os.Getenv("ZDDC_TLS_KEY"), LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"), IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"), - EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Email"), + EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"), CORSOrigins: parseCORSOrigins(), } @@ -60,7 +60,7 @@ func Load() (Config, error) { return Config{}, errors.New("ZDDC_TLS_CERT and ZDDC_TLS_KEY must both be set or both be empty") } - // Plain HTTP mode trusts the X-Email header from any client. That is only + // Plain HTTP mode trusts the email header from any client. That is only // safe behind an authenticating reverse proxy, so refuse to start when // binding plain HTTP to a non-loopback interface unless the operator has // explicitly acknowledged the deployment shape via ZDDC_INSECURE_DIRECT=1. diff --git a/zddc/internal/config/config_test.go b/zddc/internal/config/config_test.go index 7769669..848c8a8 100644 --- a/zddc/internal/config/config_test.go +++ b/zddc/internal/config/config_test.go @@ -83,8 +83,8 @@ func TestLoad(t *testing.T) { if cfg.IndexPath != ".archive" { t.Errorf("IndexPath = %q, want .archive", cfg.IndexPath) } - if cfg.EmailHeader != "X-Email" { - t.Errorf("EmailHeader = %q, want X-Email", cfg.EmailHeader) + if cfg.EmailHeader != "X-Auth-Request-Email" { + t.Errorf("EmailHeader = %q, want X-Auth-Request-Email", cfg.EmailHeader) } if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://zddc.varasys.io" { t.Errorf("CORSOrigins = %v, want [https://zddc.varasys.io]", cfg.CORSOrigins) diff --git a/zddc/internal/handler/adminhandler.go b/zddc/internal/handler/adminhandler.go new file mode 100644 index 0000000..8b80567 --- /dev/null +++ b/zddc/internal/handler/adminhandler.go @@ -0,0 +1,268 @@ +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 /.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= — 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) +} diff --git a/zddc/internal/handler/adminhandler_test.go b/zddc/internal/handler/adminhandler_test.go new file mode 100644 index 0000000..9e7d008 --- /dev/null +++ b/zddc/internal/handler/adminhandler_test.go @@ -0,0 +1,232 @@ +package handler + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" +) + +// adminTestRoot creates a temp dir, writes a .zddc with the given admins +// list, and returns a Config pointing at it. +func adminTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) { + t.Helper() + root := t.TempDir() + if len(admins) > 0 { + var b strings.Builder + b.WriteString("admins:\n") + for _, a := range admins { + b.WriteString(" - \"") + b.WriteString(a) + b.WriteString("\"\n") + } + if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(b.String()), 0o644); err != nil { + t.Fatalf("write .zddc: %v", err) + } + } + return config.Config{ + Root: root, + Addr: ":8443", + EmailHeader: "X-Auth-Request-Email", + }, NewLogRing(50) +} + +// requestWithEmail builds a request whose context already carries email (as +// the real ACLMiddleware would inject) and whose path is path. +func requestWithEmail(method, path, email string) *http.Request { + r := httptest.NewRequest(method, path, nil) + if email != "" { + r.Header.Set("X-Auth-Request-Email", email) + ctx := context.WithValue(r.Context(), EmailKey, email) + r = r.WithContext(ctx) + } + return r +} + +func TestServeAdminAuthGate(t *testing.T) { + cfg, ring := adminTestRoot(t, []string{"alice@example.com"}) + + cases := []struct { + name string + path string + email string + wantStatus int + }{ + // Anonymous (no email) — every path is hidden. + {"anonymous /.admin/", "/.admin/", "", http.StatusNotFound}, + {"anonymous /.admin/whoami", "/.admin/whoami", "", http.StatusNotFound}, + {"anonymous /.admin/config", "/.admin/config", "", http.StatusNotFound}, + {"anonymous /.admin/logs", "/.admin/logs", "", http.StatusNotFound}, + + // Logged-in non-admin — 404 (existence not leaked). + {"non-admin /.admin/", "/.admin/", "bob@example.com", http.StatusNotFound}, + {"non-admin /.admin/whoami", "/.admin/whoami", "bob@example.com", http.StatusNotFound}, + + // Admin — every defined path responds 200. + {"admin /.admin/", "/.admin/", "alice@example.com", http.StatusOK}, + {"admin /.admin/whoami", "/.admin/whoami", "alice@example.com", http.StatusOK}, + {"admin /.admin/config", "/.admin/config", "alice@example.com", http.StatusOK}, + {"admin /.admin/logs", "/.admin/logs", "alice@example.com", http.StatusOK}, + + // Admin hitting an undefined sub-route — 404. + {"admin unknown subroute", "/.admin/nope", "alice@example.com", http.StatusNotFound}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, tc.path, tc.email)) + if rec.Code != tc.wantStatus { + t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String()) + } + }) + } +} + +func TestServeAdminWhoamiPayload(t *testing.T) { + cfg, ring := adminTestRoot(t, []string{"alice@example.com"}) + rec := httptest.NewRecorder() + r := requestWithEmail(http.MethodGet, "/.admin/whoami", "alice@example.com") + r.Header.Set("X-Other-Header", "hi there") + + ServeAdmin(cfg, ring, rec, r) + + if rec.Code != 200 { + t.Fatalf("status = %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json", ct) + } + + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String()) + } + if got["configured_email_header"] != "X-Auth-Request-Email" { + t.Errorf("configured_email_header = %v", got["configured_email_header"]) + } + if got["observed_email"] != "alice@example.com" { + t.Errorf("observed_email = %v", got["observed_email"]) + } + if got["resolved_email"] != "alice@example.com" { + t.Errorf("resolved_email = %v", got["resolved_email"]) + } + headers, _ := got["headers"].(map[string]any) + if _, ok := headers["X-Auth-Request-Email"]; !ok { + t.Errorf("headers map missing X-Auth-Request-Email: %+v", headers) + } + if _, ok := headers["X-Other-Header"]; !ok { + t.Errorf("headers map missing X-Other-Header: %+v", headers) + } +} + +func TestServeAdminConfigPayload(t *testing.T) { + cfg, ring := adminTestRoot(t, []string{"alice@example.com"}) + cfg.LogLevel = "info" + cfg.IndexPath = ".archive" + cfg.CORSOrigins = []string{"https://zddc.varasys.io"} + + rec := httptest.NewRecorder() + ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/config", "alice@example.com")) + + if rec.Code != 200 { + t.Fatalf("status = %d", rec.Code) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + for _, want := range []string{"root", "addr", "email_header", "log_level", "cors_origins"} { + if _, ok := got[want]; !ok { + t.Errorf("config payload missing key %q: %+v", want, got) + } + } + if got["email_header"] != "X-Auth-Request-Email" { + t.Errorf("email_header = %v", got["email_header"]) + } +} + +func TestServeAdminLogsPayload(t *testing.T) { + cfg, ring := adminTestRoot(t, []string{"alice@example.com"}) + rh := NewRingHandler(ring, slog.LevelDebug) + logger := slog.New(rh) + logger.Info("first") + logger.Warn("second", "code", 42) + + rec := httptest.NewRecorder() + ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/logs", "alice@example.com")) + + if rec.Code != 200 { + t.Fatalf("status = %d", rec.Code) + } + var got []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String()) + } + if len(got) != 2 { + t.Fatalf("entries = %d, want 2", len(got)) + } + if got[0]["message"] != "first" || got[1]["message"] != "second" { + t.Errorf("ordering wrong: %v / %v", got[0]["message"], got[1]["message"]) + } +} + +func TestServeAdminLogsLevelFilter(t *testing.T) { + cfg, ring := adminTestRoot(t, []string{"alice@example.com"}) + rh := NewRingHandler(ring, slog.LevelDebug) + logger := slog.New(rh) + logger.Debug("d") + logger.Info("i") + logger.Warn("w") + + rec := httptest.NewRecorder() + ServeAdmin(cfg, ring, rec, + requestWithEmail(http.MethodGet, "/.admin/logs?level=warn", "alice@example.com")) + + var got []map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + if len(got) != 1 || got[0]["message"] != "w" { + t.Errorf("level=warn filter failed: %+v", got) + } +} + +func TestServeAdminDashboardHTML(t *testing.T) { + cfg, ring := adminTestRoot(t, []string{"alice@example.com"}) + + rec := httptest.NewRecorder() + ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/", "alice@example.com")) + + if rec.Code != 200 { + t.Fatalf("status = %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html", ct) + } + body := rec.Body.String() + for _, want := range []string{"", "/.admin/", `data-target="whoami"`, `data-target="config"`, `data-target="logs"`} { + if !strings.Contains(body, want) { + t.Errorf("dashboard missing %q", want) + } + } +} + +func TestServeAdminNoAdminsConfiguredHidesEverything(t *testing.T) { + // .zddc exists but has no admins list — page is invisible to all. + cfg, ring := adminTestRoot(t, nil) + if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil { + t.Fatalf("write .zddc: %v", err) + } + + rec := httptest.NewRecorder() + ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/whoami", "alice@example.com")) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404 (no admins configured)", rec.Code) + } +} diff --git a/zddc/internal/handler/cors.go b/zddc/internal/handler/cors.go index 823e641..7862a50 100644 --- a/zddc/internal/handler/cors.go +++ b/zddc/internal/handler/cors.go @@ -11,7 +11,7 @@ import ( // On any request whose Origin header exactly matches an entry in the // allowlist, the middleware echoes that origin in Access-Control-Allow-Origin // and sets Access-Control-Allow-Credentials: true (we use credentials so the -// reverse-proxy-set X-Email header / cookies cross the boundary). Vary: Origin +// reverse-proxy-set email header / cookies cross the boundary). Vary: Origin // is always set when the allowlist is non-empty so caches do not collapse // per-origin responses. // diff --git a/zddc/internal/handler/cors_test.go b/zddc/internal/handler/cors_test.go index 90b0512..da2e6f1 100644 --- a/zddc/internal/handler/cors_test.go +++ b/zddc/internal/handler/cors_test.go @@ -72,12 +72,12 @@ func TestCORSMiddleware(t *testing.T) { cfg: allowed, method: http.MethodOptions, origin: "https://zddc.varasys.io", - acrHeaders: "X-Email, Content-Type", + acrHeaders: "X-Auth-Request-Email, Content-Type", wantStatus: http.StatusNoContent, wantAllowOrig: "https://zddc.varasys.io", wantAllowCreds: "true", wantVary: "Origin", - wantAllowHdrs: "X-Email, Content-Type", + wantAllowHdrs: "X-Auth-Request-Email, Content-Type", wantNextCalled: false, }, { diff --git a/zddc/internal/handler/logring.go b/zddc/internal/handler/logring.go new file mode 100644 index 0000000..950ead7 --- /dev/null +++ b/zddc/internal/handler/logring.go @@ -0,0 +1,169 @@ +package handler + +import ( + "context" + "log/slog" + "sync" + "time" +) + +// LogEntry is one captured slog record, in a shape suitable for JSON. +type LogEntry struct { + Time time.Time `json:"time"` + Level string `json:"level"` + Message string `json:"message"` + Attrs map[string]any `json:"attrs,omitempty"` +} + +// LogRing is a fixed-size circular buffer of recent slog records, populated +// by RingHandler. Snapshot returns a copy in chronological order (oldest → +// newest); the buffer is never blocking. +type LogRing struct { + mu sync.Mutex + entries []LogEntry + size int + next int // index of the slot the next record will be written into + count int // number of entries currently held (≤ size) +} + +// NewLogRing creates a ring with capacity n. Panics on n ≤ 0 — that's a +// programming error, not a runtime condition. +func NewLogRing(n int) *LogRing { + if n <= 0 { + panic("logring: capacity must be > 0") + } + return &LogRing{ + entries: make([]LogEntry, n), + size: n, + } +} + +func (r *LogRing) push(e LogEntry) { + r.mu.Lock() + defer r.mu.Unlock() + r.entries[r.next] = e + r.next = (r.next + 1) % r.size + if r.count < r.size { + r.count++ + } +} + +// Snapshot returns the current contents in chronological order. +func (r *LogRing) Snapshot() []LogEntry { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]LogEntry, r.count) + if r.count < r.size { + copy(out, r.entries[:r.count]) + return out + } + // Buffer full and wrapping: oldest is at r.next, newest at r.next-1. + copy(out, r.entries[r.next:]) + copy(out[r.size-r.next:], r.entries[:r.next]) + return out +} + +// RingHandler is a slog.Handler that pushes records into a LogRing. +type RingHandler struct { + ring *LogRing + level slog.Leveler + attrs []slog.Attr + group string +} + +// NewRingHandler returns a handler that records into ring at level and above. +func NewRingHandler(ring *LogRing, level slog.Leveler) *RingHandler { + return &RingHandler{ring: ring, level: level} +} + +func (h *RingHandler) Enabled(_ context.Context, l slog.Level) bool { + min := slog.LevelInfo + if h.level != nil { + min = h.level.Level() + } + return l >= min +} + +func (h *RingHandler) Handle(_ context.Context, r slog.Record) error { + attrs := make(map[string]any, r.NumAttrs()+len(h.attrs)) + for _, a := range h.attrs { + attrs[a.Key] = a.Value.Any() + } + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.Any() + return true + }) + if len(attrs) == 0 { + attrs = nil + } + h.ring.push(LogEntry{ + Time: r.Time, + Level: r.Level.String(), + Message: r.Message, + Attrs: attrs, + }) + return nil +} + +func (h *RingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs)) + merged = append(merged, h.attrs...) + merged = append(merged, attrs...) + return &RingHandler{ring: h.ring, level: h.level, attrs: merged, group: h.group} +} + +func (h *RingHandler) WithGroup(name string) slog.Handler { + // We don't render groups specially — just track the deepest one for + // debugging context. Most zddc-server logging is flat. + return &RingHandler{ring: h.ring, level: h.level, attrs: h.attrs, group: name} +} + +// MultiHandler fans out each Handle call to every wrapped handler. Used to +// tee slog output to both stderr (the existing TextHandler) and the in-memory +// ring buffer that backs the admin /.admin/logs endpoint. +type MultiHandler struct { + handlers []slog.Handler +} + +// NewMultiHandler returns a handler that broadcasts to all of hs. +func NewMultiHandler(hs ...slog.Handler) *MultiHandler { + return &MultiHandler{handlers: hs} +} + +func (m *MultiHandler) Enabled(ctx context.Context, l slog.Level) bool { + for _, h := range m.handlers { + if h.Enabled(ctx, l) { + return true + } + } + return false +} + +func (m *MultiHandler) Handle(ctx context.Context, r slog.Record) error { + var firstErr error + for _, h := range m.handlers { + if !h.Enabled(ctx, r.Level) { + continue + } + if err := h.Handle(ctx, r.Clone()); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (m *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + out := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + out[i] = h.WithAttrs(attrs) + } + return &MultiHandler{handlers: out} +} + +func (m *MultiHandler) WithGroup(name string) slog.Handler { + out := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + out[i] = h.WithGroup(name) + } + return &MultiHandler{handlers: out} +} diff --git a/zddc/internal/handler/logring_test.go b/zddc/internal/handler/logring_test.go new file mode 100644 index 0000000..6258205 --- /dev/null +++ b/zddc/internal/handler/logring_test.go @@ -0,0 +1,133 @@ +package handler + +import ( + "context" + "log/slog" + "strings" + "testing" + "time" +) + +func TestLogRingChronological(t *testing.T) { + r := NewLogRing(3) + + // Push 5 entries; the oldest 2 should be evicted. + for i := 0; i < 5; i++ { + r.push(LogEntry{ + Time: time.Unix(int64(i), 0).UTC(), + Level: "INFO", + Message: "msg", + Attrs: map[string]any{"i": i}, + }) + } + + snap := r.Snapshot() + if len(snap) != 3 { + t.Fatalf("len(snap) = %d, want 3", len(snap)) + } + for i, e := range snap { + wantI := i + 2 // 2,3,4 + if got := e.Attrs["i"]; got != wantI { + t.Errorf("snap[%d].Attrs.i = %v, want %d", i, got, wantI) + } + } +} + +func TestLogRingEmpty(t *testing.T) { + r := NewLogRing(5) + if got := r.Snapshot(); len(got) != 0 { + t.Errorf("empty ring snapshot len = %d, want 0", len(got)) + } +} + +func TestLogRingPartialFill(t *testing.T) { + r := NewLogRing(10) + for i := 0; i < 3; i++ { + r.push(LogEntry{Message: "x", Attrs: map[string]any{"i": i}}) + } + if got := r.Snapshot(); len(got) != 3 { + t.Errorf("partial-fill snapshot len = %d, want 3", len(got)) + } +} + +func TestRingHandlerCapturesRecord(t *testing.T) { + ring := NewLogRing(10) + h := NewRingHandler(ring, slog.LevelInfo) + logger := slog.New(h) + + logger.Info("hello", "user", "alice", "n", 7) + + snap := ring.Snapshot() + if len(snap) != 1 { + t.Fatalf("len(snap) = %d, want 1", len(snap)) + } + e := snap[0] + if e.Message != "hello" { + t.Errorf("message = %q, want %q", e.Message, "hello") + } + if e.Level != slog.LevelInfo.String() { + t.Errorf("level = %q, want %q", e.Level, slog.LevelInfo.String()) + } + if e.Attrs["user"] != "alice" { + t.Errorf("attrs.user = %v, want alice", e.Attrs["user"]) + } + if e.Attrs["n"] != int64(7) { + t.Errorf("attrs.n = %v (%T), want int64(7)", e.Attrs["n"], e.Attrs["n"]) + } +} + +func TestRingHandlerLevelFilter(t *testing.T) { + ring := NewLogRing(10) + h := NewRingHandler(ring, slog.LevelWarn) + logger := slog.New(h) + + logger.Debug("d") + logger.Info("i") + logger.Warn("w") + logger.Error("e") + + snap := ring.Snapshot() + if len(snap) != 2 { + t.Fatalf("len(snap) = %d, want 2 (warn+error)", len(snap)) + } + if snap[0].Message != "w" || snap[1].Message != "e" { + t.Errorf("messages = [%q %q], want [w e]", snap[0].Message, snap[1].Message) + } +} + +func TestMultiHandlerFansOut(t *testing.T) { + ring := NewLogRing(10) + rh := NewRingHandler(ring, slog.LevelDebug) + + var buf strings.Builder + th := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + + multi := NewMultiHandler(th, rh) + logger := slog.New(multi) + + logger.Info("teed", "a", 1) + + if !strings.Contains(buf.String(), "teed") { + t.Errorf("text handler did not receive: %q", buf.String()) + } + snap := ring.Snapshot() + if len(snap) != 1 || snap[0].Message != "teed" { + t.Errorf("ring did not receive: %+v", snap) + } +} + +func TestMultiHandlerEnabled(t *testing.T) { + ring := NewLogRing(10) + rhInfo := NewRingHandler(ring, slog.LevelInfo) + rhError := NewRingHandler(ring, slog.LevelError) + + multi := NewMultiHandler(rhInfo, rhError) + + // Multi.Enabled should be true if ANY child is enabled. + if !multi.Enabled(context.Background(), slog.LevelInfo) { + t.Error("Enabled(Info) = false; expected true (rhInfo accepts it)") + } + if multi.Enabled(context.Background(), slog.LevelDebug) { + t.Error("Enabled(Debug) = true; expected false (no child accepts it)") + } +} diff --git a/zddc/internal/zddc/admin.go b/zddc/internal/zddc/admin.go new file mode 100644 index 0000000..ee819a6 --- /dev/null +++ b/zddc/internal/zddc/admin.go @@ -0,0 +1,28 @@ +package zddc + +import "path/filepath" + +// IsAdmin reports whether email is listed in the admins entry of the ROOT +// .zddc file (/.zddc). Subdirectory .zddc files' admins keys are +// deliberately ignored — admin grants are a server-wide role, and honoring +// them in subtrees would let anyone with subtree write access elevate +// themselves. +// +// Patterns use the same glob syntax as acl.allow / acl.deny (see +// matchesPattern). Returns false if the root file does not exist, has an +// empty Admins list, or no entry matches. An empty email never matches. +func IsAdmin(fsRoot, email string) bool { + if email == "" { + return false + } + zf, err := ParseFile(filepath.Join(fsRoot, ".zddc")) + if err != nil { + return false + } + for _, pattern := range zf.Admins { + if matchesPattern(pattern, email) { + return true + } + } + return false +} diff --git a/zddc/internal/zddc/admin_test.go b/zddc/internal/zddc/admin_test.go new file mode 100644 index 0000000..50222af --- /dev/null +++ b/zddc/internal/zddc/admin_test.go @@ -0,0 +1,108 @@ +package zddc + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIsAdmin(t *testing.T) { + cases := []struct { + name string + zddcBody string // contents of /.zddc; empty string means no file + email string + want bool + }{ + { + name: "no zddc file → not admin", + zddcBody: "", + email: "alice@example.com", + want: false, + }, + { + name: "zddc file with no admins key → not admin", + zddcBody: "acl:\n allow: [\"*\"]\n", + email: "alice@example.com", + want: false, + }, + { + name: "zddc file with empty admins list → not admin", + zddcBody: "admins: []\n", + email: "alice@example.com", + want: false, + }, + { + name: "exact-match admin → admin", + zddcBody: "admins:\n - alice@example.com\n", + email: "alice@example.com", + want: true, + }, + { + name: "domain glob admin → admin", + zddcBody: "admins:\n - \"*@example.com\"\n", + email: "alice@example.com", + want: true, + }, + { + name: "domain glob admin, wrong domain → not admin", + zddcBody: "admins:\n - \"*@example.com\"\n", + email: "alice@other.org", + want: false, + }, + { + name: "non-matching listed admin → not admin", + zddcBody: "admins:\n - bob@example.com\n", + email: "alice@example.com", + want: false, + }, + { + name: "empty email never matches even if pattern is *", + zddcBody: "admins:\n - \"*\"\n", + email: "", + want: false, + }, + { + name: "acl deny does not affect admins", + zddcBody: "acl:\n deny: [\"*@example.com\"]\nadmins:\n - alice@example.com\n", + email: "alice@example.com", + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + if tc.zddcBody != "" { + if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(tc.zddcBody), 0o644); err != nil { + t.Fatalf("write .zddc: %v", err) + } + } + if got := IsAdmin(root, tc.email); got != tc.want { + t.Errorf("IsAdmin(%q, %q) = %v, want %v", root, tc.email, got, tc.want) + } + }) + } +} + +// TestIsAdminSubdirIgnored documents that admins entries in subdirectory +// .zddc files are NOT honored — only the root .zddc grants admin. Otherwise +// anyone with subtree write access could elevate themselves. +func TestIsAdminSubdirIgnored(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "project") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Root has no admins; subdir tries to grant admin. + if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil { + t.Fatalf("write root .zddc: %v", err) + } + if err := os.WriteFile(filepath.Join(sub, ".zddc"), []byte("admins:\n - mallory@example.com\n"), 0o644); err != nil { + t.Fatalf("write subdir .zddc: %v", err) + } + + if IsAdmin(root, "mallory@example.com") { + t.Error("subdir .zddc admins entry was honored — that is a privilege-escalation hole") + } +} diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 7f62c2f..d3a6785 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -13,8 +13,14 @@ type ACLRules struct { } // ZddcFile represents the parsed contents of a .zddc configuration file. +// +// Admins is honored only in the root .zddc file (/.zddc); subdir +// .zddc files have their Admins entry ignored by IsAdmin so that someone who +// can write into a subtree cannot grant themselves admin access. ACL on the +// other hand cascades — see EffectivePolicy / AllowedWithChain. type ZddcFile struct { - ACL ACLRules `yaml:"acl"` + ACL ACLRules `yaml:"acl"` + Admins []string `yaml:"admins"` } // ParseFile reads and parses a .zddc YAML file. diff --git a/zddc/podman-compose.yaml b/zddc/podman-compose.yaml index 643d576..0858ff8 100644 --- a/zddc/podman-compose.yaml +++ b/zddc/podman-compose.yaml @@ -23,7 +23,8 @@ services: ZDDC_ADDR: ":8443" ZDDC_LOG_LEVEL: debug ZDDC_INDEX_PATH: .archive - ZDDC_EMAIL_HEADER: X-Email + # ZDDC_EMAIL_HEADER defaults to X-Auth-Request-Email — uncomment to override. + # ZDDC_EMAIL_HEADER: X-Auth-Request-Email volumes: - type: bind source: ${ZDDC_DATA_DIR:-./testdata}