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.
|
||||
- **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.
|
||||
|
||||
|
|
@ -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).
|
||||
|
||||
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).
|
||||
|
||||
|
|
@ -268,7 +268,7 @@ ZDDC_DATA_DIR=/path/to/your/archive podman-compose -f zddc/podman-compose.yaml u
|
|||
|---|---|---|
|
||||
| `ZDDC_ROOT` | *(required)* | Path to served file tree |
|
||||
| `ZDDC_ADDR` | `:8443` | Bind address |
|
||||
| `ZDDC_EMAIL_HEADER` | `X-Email` | Header set by upstream proxy with user email |
|
||||
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header set by upstream proxy with user email (oauth2-proxy / nginx auth-request convention) |
|
||||
| `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment |
|
||||
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
|
||||
| `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated CORS allowlist; empty value disables CORS. Default lets tools served from zddc.varasys.io call back into a customer-deployed server. |
|
||||
|
|
@ -286,4 +286,6 @@ git push --tags
|
|||
- The container image does NOT require Go on the host — the Containerfile uses a multi-stage build
|
||||
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
|
||||
- The `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision
|
||||
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Email` header
|
||||
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`)
|
||||
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
||||
- **Reserved hidden URL segments**: any path under `ZDDC_ROOT` whose URL contains a dot-prefixed segment (e.g. `/.devshell/coder/...`) returns 404 on direct HTTP access and is excluded from listings. Only `.archive` (virtual archive index) and `.admin` (debug page) are exempt. Lets operators co-locate side-state (caches, dev-shell home dirs) with served data on the same PVC.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ No lint/typecheck/format commands exist — vanilla JS + POSIX sh by design.
|
|||
## Things that bite if you forget
|
||||
|
||||
- **`dist/` is gitignored but force-committed** (`git add -f tool/dist/tool.html`). Never hand-edit a `dist/` file.
|
||||
- **Never write to `website/index.html` or `website/releases/*` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/<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://`.
|
||||
- **`</` 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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<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>Per-request access logging</strong> keyed to the authenticated user.</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_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index |
|
||||
| `ZDDC_EMAIL_HEADER` | `X-Email` | HTTP request header containing the authenticated user's email |
|
||||
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | HTTP request header containing the authenticated user's email (the oauth2-proxy / nginx auth-request convention) |
|
||||
| `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated allowlist of origins permitted to make cross-origin requests. Empty value disables CORS entirely. Default lets ZDDC tools served from `zddc.varasys.io` (e.g. via the bootstrap pattern) call back into your deployed server. |
|
||||
|
||||
`ZDDC_TLS_CERT=none` disables TLS entirely (plain HTTP). Both cert and key must be set together when using real certificates.
|
||||
|
|
@ -64,8 +64,8 @@ ZDDC_CORS_ORIGIN=https://tools.acme.com
|
|||
Multiple origins are comma-separated. To disable CORS entirely (e.g. when
|
||||
all clients are same-origin), set `ZDDC_CORS_ORIGIN=` (empty value). The
|
||||
middleware echoes the matched origin back per-request and sets
|
||||
`Access-Control-Allow-Credentials: true` so the upstream-set `X-Email`
|
||||
header crosses the boundary.
|
||||
`Access-Control-Allow-Credentials: true` so the upstream-set
|
||||
`X-Auth-Request-Email` header crosses the boundary.
|
||||
|
||||
## TLS
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ podman run --rm \
|
|||
## Authentication
|
||||
|
||||
zddc-server does **not** perform authentication itself. It reads the user's email address
|
||||
from a request header (default: `X-Email`) that must be set by an upstream reverse proxy
|
||||
from a request header (default: `X-Auth-Request-Email`) that must be set by an upstream reverse proxy
|
||||
(nginx, Caddy, Traefik, Azure Application Gateway, etc.) after authenticating the user.
|
||||
|
||||
If the header is absent, the user is treated as anonymous (empty email). A directory with
|
||||
|
|
@ -163,6 +163,59 @@ even if a higher-level rule would deny them.
|
|||
Directories for which the user lacks access are **omitted** from JSON listings entirely —
|
||||
they are neither listed nor queryable. A direct request to a denied path returns `403`.
|
||||
|
||||
### Reserved hidden segments
|
||||
|
||||
Any path under `ZDDC_ROOT` whose URL contains a dot-prefixed segment (e.g. `/.devshell/`,
|
||||
`/Project-A/.internal/notes.md`) is **404** on direct HTTP access and is excluded from
|
||||
listings. The recognized virtual prefixes (`.archive`, `.admin`) are explicitly permitted
|
||||
through. This lets operators store side-state (caches, dev-shell home dirs, snapshot
|
||||
staging) on the same volume that's served, without exposing it.
|
||||
|
||||
## Admin Debug Page
|
||||
|
||||
`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can
|
||||
push code/images but cannot `kubectl exec` into the running container. It surfaces:
|
||||
|
||||
- **`/.admin/whoami`** — every header on the current request, the configured email
|
||||
header name, the value observed at that name, and the resolved email. This is the
|
||||
first thing to look at when access logs show `email=anonymous` — it tells you
|
||||
exactly which (if any) header the upstream proxy is sending.
|
||||
- **`/.admin/config`** — the resolved `Config` (env vars). Equivalent to
|
||||
`kubectl exec -- env | grep ^ZDDC_` for diagnosing chart / deployment overrides.
|
||||
- **`/.admin/logs`** — recent log entries (last 500) from an in-memory ring buffer.
|
||||
Optional `?level=info|warn|error|debug` and `?since=<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
|
||||
|
||||
The recommended install drops `install.zip` (downloaded from
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
setupLogger(cfg.LogLevel)
|
||||
logRing := setupLogger(cfg.LogLevel)
|
||||
|
||||
slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr)
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ func main() {
|
|||
// HTTP handler
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, handler.ACLMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dispatch(cfg, idx, w, r)
|
||||
dispatch(cfg, idx, logRing, w, r)
|
||||
})))))
|
||||
|
||||
srv := &http.Server{
|
||||
|
|
@ -106,10 +106,18 @@ func main() {
|
|||
}
|
||||
|
||||
// dispatch routes a request to the appropriate handler.
|
||||
func dispatch(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request) {
|
||||
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := r.URL.Path
|
||||
email := handler.EmailFromContext(r)
|
||||
|
||||
// Admin debug routes — gated by IsAdmin allowlist in <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
|
||||
if urlPath == "/" {
|
||||
accept := r.Header.Get("Accept")
|
||||
|
|
@ -122,6 +130,24 @@ func dispatch(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *h
|
|||
// Split path into segments
|
||||
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
|
||||
// Reserve dot-prefixed path segments. The listing pipeline already hides
|
||||
// hidden entries (internal/listing/listing.go:17, projectshandler.go:40),
|
||||
// but direct URL access would still serve them. 404 here so hidden trees
|
||||
// like /srv/.devshell (the in-image dev-shell's persistent home dir on
|
||||
// the same Azure Files PVC as served data) cannot be fetched. The
|
||||
// recognized virtual prefixes (.admin handled above, cfg.IndexPath
|
||||
// handled below) are explicitly allowed through.
|
||||
for _, seg := range segments {
|
||||
if seg == "" || !strings.HasPrefix(seg, ".") {
|
||||
continue
|
||||
}
|
||||
if seg == cfg.IndexPath {
|
||||
continue
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for .archive segment in the path
|
||||
for i, seg := range segments {
|
||||
if seg == cfg.IndexPath {
|
||||
|
|
@ -181,7 +207,11 @@ func dispatch(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *h
|
|||
handler.ServeFile(w, r, absPath)
|
||||
}
|
||||
|
||||
func setupLogger(level string) {
|
||||
// setupLogger installs a slog default that fans every record out to stderr
|
||||
// (the existing TextHandler — user-visible logging is unchanged) AND to an
|
||||
// in-memory ring buffer that backs the /.admin/logs endpoint. Returns the
|
||||
// ring so handlers can read it.
|
||||
func setupLogger(level string) *handler.LogRing {
|
||||
var l slog.Level
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
|
|
@ -193,5 +223,9 @@ func setupLogger(level string) {
|
|||
default:
|
||||
l = slog.LevelInfo
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l})))
|
||||
ring := handler.NewLogRing(500)
|
||||
text := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l})
|
||||
rh := handler.NewRingHandler(ring, l)
|
||||
slog.SetDefault(slog.New(handler.NewMultiHandler(text, rh)))
|
||||
return ring
|
||||
}
|
||||
|
|
|
|||
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
|
||||
LogLevel string // ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
|
||||
IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive)
|
||||
EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Email)
|
||||
EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email)
|
||||
CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ func Load() (Config, error) {
|
|||
TLSKey: os.Getenv("ZDDC_TLS_KEY"),
|
||||
LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"),
|
||||
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
|
||||
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Email"),
|
||||
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
||||
CORSOrigins: parseCORSOrigins(),
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ func Load() (Config, error) {
|
|||
return Config{}, errors.New("ZDDC_TLS_CERT and ZDDC_TLS_KEY must both be set or both be empty")
|
||||
}
|
||||
|
||||
// Plain HTTP mode trusts the X-Email header from any client. That is only
|
||||
// Plain HTTP mode trusts the email header from any client. That is only
|
||||
// safe behind an authenticating reverse proxy, so refuse to start when
|
||||
// binding plain HTTP to a non-loopback interface unless the operator has
|
||||
// explicitly acknowledged the deployment shape via ZDDC_INSECURE_DIRECT=1.
|
||||
|
|
|
|||
|
|
@ -83,8 +83,8 @@ func TestLoad(t *testing.T) {
|
|||
if cfg.IndexPath != ".archive" {
|
||||
t.Errorf("IndexPath = %q, want .archive", cfg.IndexPath)
|
||||
}
|
||||
if cfg.EmailHeader != "X-Email" {
|
||||
t.Errorf("EmailHeader = %q, want X-Email", cfg.EmailHeader)
|
||||
if cfg.EmailHeader != "X-Auth-Request-Email" {
|
||||
t.Errorf("EmailHeader = %q, want X-Auth-Request-Email", cfg.EmailHeader)
|
||||
}
|
||||
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://zddc.varasys.io" {
|
||||
t.Errorf("CORSOrigins = %v, want [https://zddc.varasys.io]", cfg.CORSOrigins)
|
||||
|
|
|
|||
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
|
||||
// allowlist, the middleware echoes that origin in Access-Control-Allow-Origin
|
||||
// and sets Access-Control-Allow-Credentials: true (we use credentials so the
|
||||
// reverse-proxy-set X-Email header / cookies cross the boundary). Vary: Origin
|
||||
// reverse-proxy-set email header / cookies cross the boundary). Vary: Origin
|
||||
// is always set when the allowlist is non-empty so caches do not collapse
|
||||
// per-origin responses.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -72,12 +72,12 @@ func TestCORSMiddleware(t *testing.T) {
|
|||
cfg: allowed,
|
||||
method: http.MethodOptions,
|
||||
origin: "https://zddc.varasys.io",
|
||||
acrHeaders: "X-Email, Content-Type",
|
||||
acrHeaders: "X-Auth-Request-Email, Content-Type",
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantAllowOrig: "https://zddc.varasys.io",
|
||||
wantAllowCreds: "true",
|
||||
wantVary: "Origin",
|
||||
wantAllowHdrs: "X-Email, Content-Type",
|
||||
wantAllowHdrs: "X-Auth-Request-Email, Content-Type",
|
||||
wantNextCalled: false,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
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.
|
||||
//
|
||||
// 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 {
|
||||
ACL ACLRules `yaml:"acl"`
|
||||
ACL ACLRules `yaml:"acl"`
|
||||
Admins []string `yaml:"admins"`
|
||||
}
|
||||
|
||||
// ParseFile reads and parses a .zddc YAML file.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ services:
|
|||
ZDDC_ADDR: ":8443"
|
||||
ZDDC_LOG_LEVEL: debug
|
||||
ZDDC_INDEX_PATH: .archive
|
||||
ZDDC_EMAIL_HEADER: X-Email
|
||||
# ZDDC_EMAIL_HEADER defaults to X-Auth-Request-Email — uncomment to override.
|
||||
# ZDDC_EMAIL_HEADER: X-Auth-Request-Email
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${ZDDC_DATA_DIR:-./testdata}
|
||||
|
|
|
|||
Loading…
Reference in a new issue