Listings now filter both '.' and '_' prefixes: - '.' entries: excluded from listings AND 404 on direct HTTP access (existing behavior). For invisible side-state like .devshell. - '_' entries: excluded from listings only — direct URL access still works. For operator scaffolding like install.zip's _template/ directory of bootstrap stubs that should be reachable but should not appear in the project picker. Filter applied at both listing entry points: ServeProjectList (the project picker JSON at GET / Accept: application/json) and the generic listing/FromDirEntries (used by ServeDirectory for sub-directory browse listings). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
480 lines
18 KiB
Markdown
480 lines
18 KiB
Markdown
# 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=<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
|
|
`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`](../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
|
|
|
|
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:
|
|
|
|
```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`
|
|
|
|
```sh
|
|
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 with `package:write` scope, 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 `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision
|
|
- ACL is enforced via bottom-up `.zddc` file evaluation
|