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>
This commit is contained in:
ZDDC 2026-04-28 14:02:06 -05:00
parent 03f83ad211
commit 9ef90800b1
18 changed files with 1155 additions and 25 deletions

View file

@ -165,7 +165,7 @@ Three channels:
- **Stable**: versioned, immutable. `sh tool/build.sh --release [version]` writes `website/releases/<tool>_v<version>.html`, refreshes the `<tool>_stable.html` symlink, and tags `<tool>-v<version>`. Skips automatically if source has not changed since the latest tag. Pass an explicit version to override auto-increment. - **Stable**: versioned, immutable. `sh tool/build.sh --release [version]` writes `website/releases/<tool>_v<version>.html`, refreshes the `<tool>_stable.html` symlink, and tags `<tool>-v<version>`. 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/<tool>_beta.html` in place. No tag. The on-page label is `beta · <date> · <sha>` so the source is recoverable from git via the SHA. - **Beta**: mutable. `sh tool/build.sh --release beta` overwrites `website/releases/<tool>_beta.html` in place. No tag. The on-page label is `beta · <date> · <sha>` 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/<tool>_alpha.html``../../<tool>/dist/<tool>.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: <ts> BETA` (red) since the dist file does. A deliberate `--release alpha` overwrites the symlink with a real file labeled `alpha · <date> · <sha>`; the next plain build re-symlinks it. Deployment must serve from the repo working folder (or otherwise resolve `../../<tool>/dist/` relative to `website/releases/`).
Stable releases do **not** automatically clobber `<tool>_alpha.html` / `<tool>_beta.html` — those keep whatever was last built into them. Use `./freshen-channel <tool> <channel>` (see "Freshen helper" below) to drag a channel forward to current stable; never `git checkout` the main worktree by hand for this. Stable releases do **not** automatically clobber `<tool>_alpha.html` / `<tool>_beta.html` — those keep whatever was last built into them. Use `./freshen-channel <tool> <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 <tool>-vX.Y.Z — skipping` and exits 0. Alpha and beta channel builds always rebuild (no skip check). 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 <tool>-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/<tool>_v*.html`, `website/releases/<tool>_stable.html`, `website/releases/<tool>_beta.html`, or `website/index.html` directly — always go through `--release` or `./freshen-channel`. (The `<tool>_alpha.html` files are an exception: every plain build reasserts them as symlinks into `<tool>/dist/`, as described above.)
`landing/build.sh --release <version>` additionally writes `website/index.html` (the root URL of zddc.varasys.io). `landing/build.sh --release <version>` 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_ROOT` | *(required)* | Path to served file tree |
| `ZDDC_ADDR` | `:8443` | Bind address | | `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_INDEX_PATH` | `.archive` | Virtual archive index URL segment |
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity | | `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. | | `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 - 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 - 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 - 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.

View file

@ -38,7 +38,7 @@ No lint/typecheck/format commands exist — vanilla JS + POSIX sh by design.
## Things that bite if you forget ## 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. - **`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/<tool>_v<ver>.html` (immutable) and refresh `<tool>_stable.html`; alpha/beta overwrite `<tool>_<channel>.html` in place. - **Never write to `website/index.html`, `website/releases/<tool>_v*.html`, `website/releases/<tool>_stable.html`, or `website/releases/<tool>_beta.html` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/<tool>_v<ver>.html` (immutable) and refresh `<tool>_stable.html`; alpha/beta overwrite `<tool>_<channel>.html` in place. **Exception: `<tool>_alpha.html` files** — every plain `tool/build.sh` reasserts them as relative symlinks into `<tool>/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://`. - **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`.
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining. - **`</` in JS string/template literals breaks inline `<script>`** 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. - **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.

View file

@ -168,7 +168,7 @@
<p style="margin-top: var(--spacing-md);"><strong><code class="inline">zddc-server</code></strong> is a small Go binary purpose-built to serve ZDDC archives. <em>Any</em> web server gives you online mode; <code class="inline">zddc-server</code> adds things a generic web server can't:</p> <p style="margin-top: var(--spacing-md);"><strong><code class="inline">zddc-server</code></strong> is a small Go binary purpose-built to serve ZDDC archives. <em>Any</em> web server gives you online mode; <code class="inline">zddc-server</code> adds things a generic web server can't:</p>
<ul class="feature-list"> <ul class="feature-list">
<li><strong>Access control via <code class="inline">.zddc</code> files.</strong> Behind a reverse proxy that authenticates users and sets an <code class="inline">X-Email</code> request header, <code class="inline">zddc-server</code> consults YAML <code class="inline">.zddc</code> files in directories — cascading bottom-up; deeper rules override. No database, no admin UI.</li> <li><strong>Access control via <code class="inline">.zddc</code> files.</strong> Behind a reverse proxy that authenticates users and sets an <code class="inline">X-Auth-Request-Email</code> request header, <code class="inline">zddc-server</code> consults YAML <code class="inline">.zddc</code> files in directories — cascading bottom-up; deeper rules override. No database, no admin UI.</li>
<li><strong>Virtual <code class="inline">.archive</code> URL space.</strong> <code class="inline">GET /Project/.archive/123-XYZ.html</code> resolves to the canonical revision file at request time. Computed from filenames; no cache, no separate index file.</li> <li><strong>Virtual <code class="inline">.archive</code> URL space.</strong> <code class="inline">GET /Project/.archive/123-XYZ.html</code> resolves to the canonical revision file at request time. Computed from filenames; no cache, no separate index file.</li>
<li><strong>Per-request access logging</strong> keyed to the authenticated user.</li> <li><strong>Per-request access logging</strong> keyed to the authenticated user.</li>
<li><strong>TLS, ETags, conditional GET, CORS, autoindex.</strong> The mundane glue.</li> <li><strong>TLS, ETags, conditional GET, CORS, autoindex.</strong> The mundane glue.</li>

View file

@ -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_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_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index | | `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_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. `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 Multiple origins are comma-separated. To disable CORS entirely (e.g. when
all clients are same-origin), set `ZDDC_CORS_ORIGIN=` (empty value). The all clients are same-origin), set `ZDDC_CORS_ORIGIN=` (empty value). The
middleware echoes the matched origin back per-request and sets middleware echoes the matched origin back per-request and sets
`Access-Control-Allow-Credentials: true` so the upstream-set `X-Email` `Access-Control-Allow-Credentials: true` so the upstream-set
header crosses the boundary. `X-Auth-Request-Email` header crosses the boundary.
## TLS ## TLS
@ -103,7 +103,7 @@ podman run --rm \
## Authentication ## Authentication
zddc-server does **not** perform authentication itself. It reads the user's email address 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. (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 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 — 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`. 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=<RFC3339>` 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_ROOT>/.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 ## Landing Page and Tool Install
The recommended install drops `install.zip` (downloaded from The recommended install drops `install.zip` (downloaded from

View file

@ -26,7 +26,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
setupLogger(cfg.LogLevel) logRing := setupLogger(cfg.LogLevel)
slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr) slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr)
@ -66,7 +66,7 @@ func main() {
// HTTP handler // HTTP handler
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/", handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, handler.ACLMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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{ srv := &http.Server{
@ -106,10 +106,18 @@ func main() {
} }
// dispatch routes a request to the appropriate handler. // 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 urlPath := r.URL.Path
email := handler.EmailFromContext(r) email := handler.EmailFromContext(r)
// Admin debug routes — gated by IsAdmin allowlist in <root>/.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 // Project list API: GET / with Accept: application/json
if urlPath == "/" { if urlPath == "/" {
accept := r.Header.Get("Accept") 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 // Split path into segments
segments := strings.Split(strings.Trim(urlPath, "/"), "/") 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 // Check for .archive segment in the path
for i, seg := range segments { for i, seg := range segments {
if seg == cfg.IndexPath { 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) 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 var l slog.Level
switch strings.ToLower(level) { switch strings.ToLower(level) {
case "debug": case "debug":
@ -193,5 +223,9 @@ func setupLogger(level string) {
default: default:
l = slog.LevelInfo 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
} }

View file

@ -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)
}
}

View file

@ -17,7 +17,7 @@ type Config struct {
TLSMode string // computed from TLSCert/TLSKey: none/selfsigned/provided TLSMode string // computed from TLSCert/TLSKey: none/selfsigned/provided
LogLevel string // ZDDC_LOG_LEVEL — debug/info/warn/error (default info) LogLevel string // ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive) 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 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"), TLSKey: os.Getenv("ZDDC_TLS_KEY"),
LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"), LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"),
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"), IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Email"), EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
CORSOrigins: parseCORSOrigins(), 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") 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 // safe behind an authenticating reverse proxy, so refuse to start when
// binding plain HTTP to a non-loopback interface unless the operator has // binding plain HTTP to a non-loopback interface unless the operator has
// explicitly acknowledged the deployment shape via ZDDC_INSECURE_DIRECT=1. // explicitly acknowledged the deployment shape via ZDDC_INSECURE_DIRECT=1.

View file

@ -83,8 +83,8 @@ func TestLoad(t *testing.T) {
if cfg.IndexPath != ".archive" { if cfg.IndexPath != ".archive" {
t.Errorf("IndexPath = %q, want .archive", cfg.IndexPath) t.Errorf("IndexPath = %q, want .archive", cfg.IndexPath)
} }
if cfg.EmailHeader != "X-Email" { if cfg.EmailHeader != "X-Auth-Request-Email" {
t.Errorf("EmailHeader = %q, want X-Email", cfg.EmailHeader) t.Errorf("EmailHeader = %q, want X-Auth-Request-Email", cfg.EmailHeader)
} }
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://zddc.varasys.io" { if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://zddc.varasys.io" {
t.Errorf("CORSOrigins = %v, want [https://zddc.varasys.io]", cfg.CORSOrigins) t.Errorf("CORSOrigins = %v, want [https://zddc.varasys.io]", cfg.CORSOrigins)

View file

@ -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 <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)
}

View file

@ -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{"<!DOCTYPE html>", "/.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)
}
}

View file

@ -11,7 +11,7 @@ import (
// On any request whose Origin header exactly matches an entry in the // On any request whose Origin header exactly matches an entry in the
// allowlist, the middleware echoes that origin in Access-Control-Allow-Origin // allowlist, the middleware echoes that origin in Access-Control-Allow-Origin
// and sets Access-Control-Allow-Credentials: true (we use credentials so the // 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 // is always set when the allowlist is non-empty so caches do not collapse
// per-origin responses. // per-origin responses.
// //

View file

@ -72,12 +72,12 @@ func TestCORSMiddleware(t *testing.T) {
cfg: allowed, cfg: allowed,
method: http.MethodOptions, method: http.MethodOptions,
origin: "https://zddc.varasys.io", origin: "https://zddc.varasys.io",
acrHeaders: "X-Email, Content-Type", acrHeaders: "X-Auth-Request-Email, Content-Type",
wantStatus: http.StatusNoContent, wantStatus: http.StatusNoContent,
wantAllowOrig: "https://zddc.varasys.io", wantAllowOrig: "https://zddc.varasys.io",
wantAllowCreds: "true", wantAllowCreds: "true",
wantVary: "Origin", wantVary: "Origin",
wantAllowHdrs: "X-Email, Content-Type", wantAllowHdrs: "X-Auth-Request-Email, Content-Type",
wantNextCalled: false, wantNextCalled: false,
}, },
{ {

View file

@ -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}
}

View file

@ -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)")
}
}

View file

@ -0,0 +1,28 @@
package zddc
import "path/filepath"
// IsAdmin reports whether email is listed in the admins entry of the ROOT
// .zddc file (<fsRoot>/.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
}

View file

@ -0,0 +1,108 @@
package zddc
import (
"os"
"path/filepath"
"testing"
)
func TestIsAdmin(t *testing.T) {
cases := []struct {
name string
zddcBody string // contents of <root>/.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")
}
}

View file

@ -13,8 +13,14 @@ type ACLRules struct {
} }
// ZddcFile represents the parsed contents of a .zddc configuration file. // ZddcFile represents the parsed contents of a .zddc configuration file.
//
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.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 { type ZddcFile struct {
ACL ACLRules `yaml:"acl"` ACL ACLRules `yaml:"acl"`
Admins []string `yaml:"admins"`
} }
// ParseFile reads and parses a .zddc YAML file. // ParseFile reads and parses a .zddc YAML file.

View file

@ -23,7 +23,8 @@ services:
ZDDC_ADDR: ":8443" ZDDC_ADDR: ":8443"
ZDDC_LOG_LEVEL: debug ZDDC_LOG_LEVEL: debug
ZDDC_INDEX_PATH: .archive 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: volumes:
- type: bind - type: bind
source: ${ZDDC_DATA_DIR:-./testdata} source: ${ZDDC_DATA_DIR:-./testdata}