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>
18 KiB
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
.zddcACL — email-based allow/deny lists evaluated bottom-up from requested directory to root - Caddy-compatible JSON listings — the Archive Browser works without modification
- Virtual
.archiveindex — 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
# 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:
ZDDC_DATA_DIR=/srv/archive podman-compose up --build
Docker users: Replace
podmanwithdockerandpodman-composewithdocker-compose. Remove the:zvolume 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:
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:
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):
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.
# 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.
- Check deny patterns at the current level — if email matches → 403 Forbidden
- Check allow patterns at the current level — if email matches → allow
- No match at this level → walk up to parent directory and repeat
- If no
.zddcfiles were found anywhere in the chain → allow (public, no rules) - If
.zddcfiles 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
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 showemail=anonymous— it tells you exactly which (if any) header the upstream proxy is sending./.admin/config— the resolvedConfig(env vars). Equivalent tokubectl 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|debugand?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:
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=1env-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 <project-name>/ 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
<tool>.html files with level-2 bootstrap stubs that fetch the named
channel from zddc.varasys.io on each page load. See
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/fsnotifyreliably. 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
Each zddc-server-vX.Y.Z git tag publishes a runtime image to Codeberg's
container registry via the Woodpecker CI pipeline at .woodpecker.yml,
with 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:
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/webfor self-contained demos (ZDDC_ROOT=/opt/zddc-server/web) - declares
VOLUME /srvso the operator's data mount is explicit - ships a
HEALTHCHECKfordocker 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)
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.
# 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:
# 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 buildwithdocker build. The--targetand-oflags work identically in Docker BuildKit (enabled by default in Docker 23+).
Release Tagging
Follow the repository convention: zddc-server-vX.Y.Z
git tag zddc-server-v1.0.0
git push --tags
The zddc-server-v* tag triggers the .woodpecker.yml pipeline which
builds and publishes the container image. See "Container image" above
for the resulting image URLs.
The first time the pipeline runs you must configure two Woodpecker secrets in repo Settings → Woodpecker:
codeberg_user— your Codeberg username (e.g.VARASYS)codeberg_token— a personal token withpackage:writescope, generated at https://codeberg.org/user/settings/applications
These never live in the repo; they are referenced from the pipeline via
from_secret:.
Notes:
- The container uses a multi-stage build
- The
.archivevirtual path resolves ZDDC tracking numbers to their earliest-received revision - ACL is enforced via bottom-up
.zddcfile evaluation