# zddc-server A purpose-built HTTPS file server for ZDDC document archives. Designed to replace `caddy file-server --browse` with features specific to ZDDC workflows. ## Features - **High-performance static file serving** — ETag, conditional GET, Cache-Control - **Cascading `.zddc` ACL** — email-based allow/deny lists evaluated bottom-up from requested directory to root - **Caddy-compatible JSON listings** — the Archive Browser works without modification - **Virtual `.archive` index** — resolve the earliest revision of any tracked document by URL - **Filesystem watcher** — archive index updates automatically when files change - **Flexible TLS modes** — self-signed, real certificates, or plain HTTP - **Podman-native** — multi-stage build, non-root runtime, SELinux-compatible volumes ## Quick Start ```sh # Build the container image podman build -t zddc-server . # Run against your archive root podman run --rm \ -v /srv/archive:/data:z \ -e ZDDC_ROOT=/data \ -p 8443:8443 \ zddc-server ``` Or with podman-compose: ```sh ZDDC_DATA_DIR=/srv/archive podman-compose up --build ``` > **Docker users:** Replace `podman` with `docker` and `podman-compose` with `docker-compose`. Remove the `:z` volume suffix (that is a SELinux/podman convention). ## Environment Variables | Variable | Default | Description | |---|---|---| | `ZDDC_ROOT` | *(required)* | Absolute path to the served file tree | | `ZDDC_ADDR` | `:8443` | Bind address (host:port) | | `ZDDC_TLS_CERT` | *(empty)* | Path to PEM certificate file. `none` = plain HTTP (no TLS); empty = generate self-signed | | `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-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. ### CORS The default `ZDDC_CORS_ORIGIN=https://zddc.varasys.io` exists so the canonical ZDDC tool builds (hosted at `zddc.varasys.io`) can call back into your deployed `zddc-server` without extra configuration. If you self-host the tools on your own domain (e.g. `tools.acme.com`), set: ```sh 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-Auth-Request-Email` header crosses the boundary. ## TLS ### Plain HTTP (no TLS) Set `ZDDC_TLS_CERT=none` to run without TLS. Recommended when an upstream reverse proxy (nginx, Caddy, Traefik) terminates external TLS and talks to zddc-server over plain HTTP on a private network: ```sh podman run --rm \ -v /srv/archive:/data:z \ -e ZDDC_ROOT=/data \ -e ZDDC_TLS_CERT=none \ -e ZDDC_ADDR=:8080 \ -p 8080:8080 \ zddc-server ``` When `ZDDC_TLS_CERT` / `ZDDC_TLS_KEY` are empty (or when using real certificates), zddc-server generates an ECDSA P-256 self-signed certificate in memory at startup. The certificate changes on every restart — this is intentional and acceptable when an upstream reverse proxy terminates external TLS and uses this server only for encrypted in-datacenter transport. To use a real certificate (e.g. from Let's Encrypt or an internal CA): ```sh podman run --rm \ -v /etc/ssl/zddc:/certs:z,ro \ -e ZDDC_TLS_CERT=/certs/server.crt \ -e ZDDC_TLS_KEY=/certs/server.key \ ... ``` ## Authentication zddc-server does **not** perform authentication itself. It reads the user's email address 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 no `.zddc` rules is publicly accessible; a directory with an allowlist requires a matching email. ## `.zddc` Access Control Files Place a `.zddc` YAML file in any directory to control access. Rules cascade from parent directories — child rules are appended to (not replaced by) parent rules. ```yaml # Example .zddc file acl: allow: - "*@mycompany.com" # all users at mycompany.com - "contractor@partner.com" # specific external user deny: - "intern@mycompany.com" # override: block this specific user ``` ### ACL evaluation order Rules are evaluated **bottom-up**: starting at the requested directory and walking toward the root. The first explicit match (allow or deny) at any level wins. 1. Check deny patterns at the current level — if email matches → **403 Forbidden** 2. Check allow patterns at the current level — if email matches → **allow** 3. No match at this level → walk up to parent directory and repeat 4. If no `.zddc` files were found anywhere in the chain → **allow** (public, no rules) 5. If `.zddc` files exist but email matched nothing → **403 Forbidden** (not on any list) This model supports three user tiers in a single tree: | Level | Rule | Effect | |---|---|---| | Root | `allow: ["*@company.com"]` | All company users see everything | | Project dir | `allow: ["team@company.com"]` | Restricts to the project team | | Vendor subdir | `allow: ["vendor@ext.com"]` | Grants a third-party access to their folder only | A vendor navigating to their subdirectory is allowed by the deepest matching rule, even if a higher-level rule would deny them. ### Glob patterns `*` matches any sequence of characters within one side of the `@` boundary: | Pattern | Matches | |---|---| | `*@mycompany.com` | Any user at mycompany.com | | `alice@*` | alice at any domain | | `*` | Any non-empty email | | `alice@example.com` | Exact match only | ### Directory visibility 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 Two prefixes are filtered from listings under `ZDDC_ROOT`: - **`.`-prefixed** (e.g. `/.devshell/`, `/Project-A/.internal/notes.md`) — excluded from listings **and** 404 on direct HTTP access. 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. - **`_`-prefixed** (e.g. `/_template/`) — excluded from listings only. Direct URL access still works, so install.zip's `_template/` directory of bootstrap stubs is reachable but doesn't clutter the project picker. Use this for operator- managed scaffolding the user shouldn't browse to but might link to. ## Admin Debug Page `zddc-server` exposes a built-in debug page at `/.admin/` for operators who can push code/images but cannot `kubectl exec` into the running container. It surfaces: - **`/.admin/whoami`** — every header on the current request, the configured email header name, the value observed at that name, and the resolved email. This is the first thing to look at when access logs show `email=anonymous` — it tells you exactly which (if any) header the upstream proxy is sending. - **`/.admin/config`** — the resolved `Config` (env vars). Equivalent to `kubectl exec -- env | grep ^ZDDC_` for diagnosing chart / deployment overrides. - **`/.admin/logs`** — recent log entries (last 500) from an in-memory ring buffer. Optional `?level=info|warn|error|debug` and `?since=` query params. At `ZDDC_LOG_LEVEL=debug` every request also logs its full header map under `msg=request headers` — useful for diagnosing proxy / SSO header passthrough (e.g. confirming which header carries the email). Note: that dump includes auth tokens and cookies; only enable debug in trusted environments. - **`/.admin/`** — HTML dashboard that fetches the three JSON endpoints client-side. ### Authorization Authorization is via an `admins:` list in the **root** `.zddc` file (`/.zddc`). Patterns use the same glob syntax as `acl.allow` / `acl.deny`: ```yaml admins: - alice@mycompany.com - "*@admin.mycompany.com" acl: allow: - "*@mycompany.com" ``` Only the root-level `admins` entry is honored — subdirectory `.zddc` files' `admins` keys are ignored. Otherwise anyone with subtree write access could elevate themselves. If the root `.zddc` has no `admins` list (or no `.zddc` exists), every admin endpoint returns **404** to every caller. Non-admin requests also receive 404 (not 403) so the existence of the admin page is invisible to unauthorized callers. ### Caveats - Logs are in-memory and lost on restart. The buffer holds the most recent 500 records; for long-term audit, parse the stderr stream the way you already do. - The page reads only configuration and request state — it does not modify anything. - An interactive terminal is not yet available; that's planned as a follow-up behind a separate `ZDDC_ADMIN_TERM=1` env-var gate so it stays opt-in. ## Landing Page and Tool Install The recommended install drops `install.zip` (downloaded from `https://zddc.varasys.io/install.zip`) into `ZDDC_ROOT/`: ``` ZDDC_ROOT/ index.html ← landing page (current stable) archive.html ← archive browser transmittal.html classifier.html mdedit.html _template/ ← template directory of level-1 bootstrap stubs; rename a copy to / for each project Project-001/ archive.html ← level-1 stub: fetches ../archive.html transmittal.html classifier.html mdedit.html Project-002/ … ``` This is fully self-contained — no external dependencies. To make the deployment auto-track a published channel from `zddc.varasys.io`, drop `track-alpha.zip` / `track-beta.zip` / `track-stable.zip` (also at `https://zddc.varasys.io/`) over `ZDDC_ROOT/`: those replace the root `.html` files with level-2 bootstrap stubs that fetch the named channel from `zddc.varasys.io` on each page load. See [`bootstrap/README.md`](../bootstrap/README.md) for the full install guide, the `?v=…` URL parameter for per-request version selection, and the `ZDDC_CORS_ORIGIN` env var that lets `zddc-server` accept cross-origin calls from the level-2 source. The landing page fetches `GET /` (with `Accept: application/json`) to retrieve the list of top-level project directories the requesting user has access to. It renders checkboxes for each project and opens `archive.html?projects=Proj-A,Proj-B` when the user clicks "Open Archive". **Presets** (named project selections) are stored in the browser's `localStorage` — no server-side state required. **Shared URLs**: the `?projects=` parameter is preserved in the archive browser URL so users can email direct links to a pre-filtered view. If the recipient does not have access to a project listed in the URL, a warning banner is shown. ## Access Logging Every HTTP request is logged as a structured `slog` entry at `INFO` level: | Field | Description | |---|---| | `ts` | Request arrival timestamp (RFC3339) | | `email` | User email from the configured header, or `anonymous` | | `method` | HTTP method | | `path` | URL path | | `status` | HTTP response status code | | `bytes` | Response body bytes written | | `duration_ms` | Request duration in milliseconds | Log output goes to `stderr`. Use `ZDDC_LOG_LEVEL=warn` to suppress access logs if needed, or pipe `stderr` to a log aggregator. ## Virtual Archive Index (`.archive`) Any URL path segment named `.archive` (configurable via `ZDDC_INDEX_PATH`) is intercepted by the server and treated as a virtual document index. The index is built at startup by scanning all transmittal folders under `ZDDC_ROOT`. It maps each `(trackingNumber, revision, modifier)` tuple to the file from the **chronologically earliest** transmittal folder that contains it. ### URL patterns | URL | Resolves to | |---|---| | `GET /Project/.archive/TRK-001.html` | Latest base revision of TRK-001 | | `GET /Project/.archive/TRK-001_A.html` | Base revision A of TRK-001 | | `GET /Project/.archive/TRK-001_A+C1.html` | Modifier C1 of revision A of TRK-001 | | `GET /Project/.archive/` | JSON listing of all resolvable trackingNumber.html entries | All responses are `302 Found` redirects to the actual file URL. ACL is enforced on both the `.archive` context directory and the resolved target file. ### Why "earliest" transmittal? Any file claiming to be `TRK-001_A (IFC)` should be identical across transmittals (same content, same SHA-256). If the same tracking number and revision appears in multiple transmittals, the first one received chronologically is treated as the authoritative copy. A later arrival with a different hash is an error condition (to be detected separately). ### Index refresh The index refreshes automatically via an `fsnotify` filesystem watcher. Changes are debounced by 2 seconds before the relevant transmittal folder is re-indexed. > **Note for Azure Files**: Azure SMB mounts do not support `inotify`/`fsnotify` reliably. > The watcher will log a warning and the index will only be updated by restarting the server. ## ZDDC Filename Convention The server parses filenames following the ZDDC convention: ``` trackingNumber_revision (status) - title.extension ``` | Part | Format | Example | |---|---|---| | `trackingNumber` | No spaces or underscores | `123456-EL-SPC-2623` | | `revision` | `~?[A-Z0-9]+(\+[CBNQ][0-9]+)?` | `A`, `~B`, `C+C1` | | `status` | One of the valid status codes | `IFC`, `REC`, `---` | | `title` | Free text | `Electrical Specification` | Valid status codes: `IFA IFB IFC IFD IFI IFP IFR IFU REC RSA RSB RSC RSD RSI ---` Transmittal folder format: `YYYY-MM-DD_trackingNumber (STATUS) - title` ## Integration with Archive Browser The Archive Browser (`archive.html`) can connect to zddc-server in HTTP mode. The server returns JSON directory listings in exactly the same format as Caddy's `file-server --browse` — no changes to `archive/js/source.js` are needed. To use: install `archive.html` at `ZDDC_ROOT/archive.html` (or any subdirectory) — either the actual built tool from `install.zip`, or a level-1/level-2 bootstrap stub that fetches it. Then open it via the zddc-server URL; the app will auto-connect and scan the directory tree. ## Container image The runtime image is published to Codeberg's container registry via a local-build-and-push script (`release-image.sh` at the repo root). Each release publishes cascading channel tags: ``` codeberg.org/varasys/zddc-server:X.Y.Z # immutable, exact version codeberg.org/varasys/zddc-server:stable # current stable codeberg.org/varasys/zddc-server:beta # tracks stable-or-newer beta codeberg.org/varasys/zddc-server:alpha # tracks beta-or-newer alpha ``` `:latest` is intentionally not published — the project uses stable/beta/alpha channel terminology consistently. Pull and run: ```sh podman run --rm -p 8443:8443 \ -v /srv/archive:/srv:Z \ -e ZDDC_TLS_CERT=none \ -e ZDDC_ADDR=:8080 \ -e ZDDC_INSECURE_DIRECT=1 \ codeberg.org/varasys/zddc-server:stable ``` The image: - alpine-based, runs as non-root (UID 1000) - exposes 8443 by default - defaults `ZDDC_ROOT=/srv` (override or mount your archive there) - bundles the landing + archive tool HTML at `/opt/zddc-server/web` for self-contained demos (`ZDDC_ROOT=/opt/zddc-server/web`) - declares `VOLUME /srv` so the operator's data mount is explicit - ships a `HEALTHCHECK` for `docker run`; Kubernetes deployments override it ### Env-var contract (for chart consumers) Downstream Helm charts and Compose files should set these explicitly rather than relying on image defaults: | Variable | Typical value (behind ingress + SSO) | Purpose | |---|---|---| | `ZDDC_ROOT` | `/srv` | Path of the served archive (volume mount) | | `ZDDC_TLS_CERT` | `none` | TLS terminated upstream | | `ZDDC_INSECURE_DIRECT` | `1` | Acknowledge plain HTTP behind a trusted proxy | | `ZDDC_ADDR` | `:8080` | Match service / probe port | | `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header your auth proxy sets | | `ZDDC_CORS_ORIGIN` | `https://your-host` | Origins permitted to call back into the server | See "Environment Variables" above for the full list. ## Building ### Run as a container (build locally) ```sh podman build --target server -t zddc-server . ``` ### Compile native binaries (no Go installation required) Use the `binaries` build target to cross-compile for all platforms using podman as the build environment. Binaries are extracted directly to a local `dist/` directory — no container runs on your host. ```sh # From the zddc/ directory mkdir -p dist podman build --target binaries -o dist/ . ``` This produces: | File | Platform | |---|---| | `dist/zddc-server-linux-amd64` | Linux (x86-64) | | `dist/zddc-server-darwin-amd64` | macOS (Intel) | | `dist/zddc-server-darwin-arm64` | macOS (Apple Silicon) | | `dist/zddc-server-windows-amd64.exe` | Windows (x86-64) | All binaries are statically linked (CGO disabled) — no runtime dependencies. Run the binary directly: ```sh # Linux / macOS ZDDC_ROOT=/srv/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 ./dist/zddc-server-linux-amd64 # Windows (PowerShell) $env:ZDDC_ROOT="C:\archive"; $env:ZDDC_TLS_CERT="none"; $env:ZDDC_ADDR=":8080" .\dist\zddc-server-windows-amd64.exe ``` > **Docker users:** Replace `podman build` with `docker build`. The `--target` and > `-o` flags work identically in Docker BuildKit (enabled by default in Docker 23+). ## Release Tagging Follow the repository convention: `zddc-server-vX.Y.Z`. Two coordinated steps — git tag for auditability, then local image build and push: ```sh # 1. Tag the source. git tag zddc-server-v1.0.0 git push origin zddc-server-v1.0.0 # 2. Build and push the runtime image with cascading channel tags. sh release-image.sh 1.0.0 # → :1.0.0 :stable :beta :alpha sh release-image.sh 1.0.0-beta.1 beta # beta-channel release sh release-image.sh 1.0.0-alpha.2 alpha # alpha-channel release ``` Prerequisite: `podman login codeberg.org` (or `docker login`) once, authenticating with your Codeberg username and a personal token with `package:write` scope, generated at . The token is your local credential — it never lives in the repo. Single-developer / solo-release flow by design — no CI babysitting, no separate dashboard to debug when the registry doesn't show what you expected. See `release-image.sh` for what each tag publishes. --- **Notes:** - The container uses a multi-stage build - The `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision - ACL is enforced via bottom-up `.zddc` file evaluation