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:
parent
03f83ad211
commit
9ef90800b1
18 changed files with 1155 additions and 25 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
96
zddc/cmd/zddc-server/main_test.go
Normal file
96
zddc/cmd/zddc-server/main_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
268
zddc/internal/handler/adminhandler.go
Normal file
268
zddc/internal/handler/adminhandler.go
Normal 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)
|
||||||
|
}
|
||||||
232
zddc/internal/handler/adminhandler_test.go
Normal file
232
zddc/internal/handler/adminhandler_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
169
zddc/internal/handler/logring.go
Normal file
169
zddc/internal/handler/logring.go
Normal 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}
|
||||||
|
}
|
||||||
133
zddc/internal/handler/logring_test.go
Normal file
133
zddc/internal/handler/logring_test.go
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
28
zddc/internal/zddc/admin.go
Normal file
28
zddc/internal/zddc/admin.go
Normal 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
|
||||||
|
}
|
||||||
108
zddc/internal/zddc/admin_test.go
Normal file
108
zddc/internal/zddc/admin_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue