ZDDC/zddc/README.md
ZDDC bdac8dc4fb docs: clean up drift left over from the Codeberg release-assets refactor
The 2dc9ad2 commit ("refactor: distribute via Codeberg release assets,
drop the upstream image") rewrote AGENTS.md and CLAUDE.md but left
several pre-existing references to the old write-to-website/releases
flow and the now-removed Containerfile / podman-compose / release-image.sh.
This sweeps the rest:

- CLAUDE.md
  - drop "podman/podman-compose" from the zddc/ blurb (no Containerfile)
  - drop the broken `podman build -t zddc-server zddc/` command
  - rewrite the "Most-used commands" table so --release semantics match
    actual behavior (tag + Codeberg upload, not file write)
  - rewrite "Things that bite": replace "never write to website/releases/"
    and the obsolete "alpha exception" bullet with the new rules
    ($CODEBERG_TOKEN required, dist files no longer force-tracked, etc.)
  - rewrite the website/ description in "Repo shape" to reflect that
    only index.html + manifest.json live there now

- ARCHITECTURE.md
  - rewrite the website/ directory tree (no more <tool>_v*.html, _stable
    symlinks, or _alpha/_beta files)
  - rewrite "Channels" section: every cut now tags + uploads to Codeberg,
    alpha/beta have .N counters and matching tags, no more in-place
    overwrites
  - rewrite the build-label table: dev builds carry the next-stable
    target as a -alpha pre-release suffix with full timestamp + dirty
    marker (was: "Built: <ts> BETA")
  - update level-2 bootstrap description: resolves channel via
    manifest.json, fetches /releases/<tag>/<asset>, not a flat URL
  - update landing-tool description: ships only as Codeberg release
    asset, not a committed website/releases/landing_v<X>.html

- AGENTS.md
  - update website/ tree to the post-refactor layout
  - replace the two-step podman build / podman-compose run blocks under
    zddc-server with a Go build + go run quickstart (no container in
    this repo)
  - drop the "Containerfile uses a multi-stage build" note from the
    "Notes" list (Containerfile is gone)
  - drop the stale "landing/build.sh writes website/index.html" note —
    website/index.html is now hand-edited, not produced by landing's
    build

- README.md (top-level)
  - tools table no longer links to /releases/<tool>_stable.html
    (those URLs return 404 post-refactor); link to the releases page
    once instead

- bootstrap/README.md
  - update the "permanent pin" URL examples and CORS verification
    snippet to use /releases/<tag>/<asset> URLs (Caddy → Codeberg)
    instead of the old flat /releases/<tool>_<channel>.html pattern
  - explain that channel resolution is via manifest.json now

- zddc/README.md
  - rewrite Quick Start: download a release binary or build from source,
    no `podman build`
  - rewrite TLS examples to invoke ./zddc-server directly instead of
    `podman run ... zddc-server` (image name no longer exists)
  - mention ZDDC_INSECURE_DIRECT in the env-var table and the plain-HTTP
    example — startup is refused without it on non-loopback binds
  - replace the "Container image" section with "Distribution" (binaries
    on Codeberg, no image) and the "Building" section with go build
    instructions
  - replace "Release Tagging" with documentation of zddc/release.sh
    (the canonical replacement for release-image.sh, which is gone)

- shared/build-lib.sh
  - fix the comment claiming "plain builds mirror to website/releases/"
    — they don't anymore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:01:20 -05:00

477 lines
21 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
- **Single static binary** — CGO-free, no runtime dependencies; cross-compiled to Linux/macOS/Windows
## Quick Start
zddc-server ships as a cross-compiled binary distributed via Codeberg release assets. The website at `zddc.varasys.io` reverse-proxies download URLs to Codeberg.
```sh
# Download the latest stable binary for your platform from
# https://zddc.varasys.io/releases/ (page lists all platforms + channels)
curl -L -o zddc-server \
https://zddc.varasys.io/releases/zddc-server-vX.Y.Z/zddc-server-linux-amd64
chmod +x zddc-server
# Run against your archive root (HTTPS on :8443 with an in-memory self-signed cert)
ZDDC_ROOT=/srv/archive ./zddc-server
```
Or build from source (requires Go 1.24+):
```sh
git clone https://codeberg.org/VARASYS/ZDDC.git
cd ZDDC/zddc
go build -o zddc-server ./cmd/zddc-server
ZDDC_ROOT=/srv/archive ./zddc-server
```
For plain HTTP behind a reverse proxy, set `ZDDC_TLS_CERT=none` and `ZDDC_INSECURE_DIRECT=1` — see "TLS" below.
There is no Containerfile / Dockerfile / compose file in this repo. Operators who want to run zddc-server inside a container can write a minimal Dockerfile that copies the static binary into a `scratch` or `alpine` base, or use the chart Dockerfiles in `tnd-zddc-chart` (which compile from source at build time).
## 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_INSECURE_DIRECT` | *(empty)* | Must be `1` when `ZDDC_TLS_CERT=none` and the bind address is non-loopback. Acknowledges that an authenticating reverse proxy is in front of zddc-server; without it, plain-HTTP non-loopback startup is refused |
| `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. zddc-server requires `ZDDC_INSECURE_DIRECT=1` for any non-loopback
bind in this mode — an explicit acknowledgement that an authenticating proxy sits in front:
```sh
ZDDC_ROOT=/srv/archive \
ZDDC_TLS_CERT=none \
ZDDC_ADDR=:8080 \
ZDDC_INSECURE_DIRECT=1 \
./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
ZDDC_ROOT=/srv/archive \
ZDDC_TLS_CERT=/etc/ssl/zddc/server.crt \
ZDDC_TLS_KEY=/etc/ssl/zddc/server.key \
./zddc-server
```
## 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 the `_template/` directory of bootstrap stubs created
by the install snippet 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.
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_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 is a short shell snippet copy-pasted from the
"Install on your server" section of `https://zddc.varasys.io/`. There
are four snippets, all of which `cd`-and-curl into `ZDDC_ROOT/`:
- **Self-contained** — fetches the five current-stable tool HTMLs and
populates a `_template/` directory of level-1 bootstrap stubs. No
runtime dependency on `zddc.varasys.io`. Re-run to update.
- **Track stable / beta / alpha** — fetches five tiny level-2 stubs
(~10 KB total) that fetch the named channel from `zddc.varasys.io`
on every page load.
After running one of the snippets, the deployment looks like:
```
ZDDC_ROOT/
index.html ← landing page (current stable, or level-2 stub)
archive.html ← archive browser (likewise)
transmittal.html
classifier.html
mdedit.html
_template/ ← level-1 bootstrap stubs (self-contained snippet only);
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/
```
The level-2 stubs require `zddc-server` to accept cross-origin requests
from `zddc.varasys.io`, controlled via `ZDDC_CORS_ORIGIN`. See
[`bootstrap/README.md`](../bootstrap/README.md) for the full install
guide and the `?v=…` URL parameter for per-request version selection.
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 fetched by the self-contained install snippet, 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.
## Distribution
Each release is a Codeberg git tag (`zddc-server-vX.Y.Z` for stable, `zddc-server-vX.Y.Z-{alpha,beta}.N` for pre-releases) with four pre-built binaries attached as release assets:
| File | Platform |
|---|---|
| `zddc-server-linux-amd64` | Linux (x86-64) |
| `zddc-server-darwin-amd64` | macOS (Intel) |
| `zddc-server-darwin-arm64` | macOS (Apple Silicon) |
| `zddc-server-windows-amd64.exe` | Windows (x86-64) |
All binaries are statically linked (CGO disabled), built with `-trimpath -ldflags="-s -w -X main.version=<ver>"`. No runtime dependencies.
Download URLs go through the website's Caddy proxy:
```
https://zddc.varasys.io/releases/zddc-server-vX.Y.Z/zddc-server-linux-amd64
```
(Caddy reverse-proxies that to the Codeberg release-asset URL — operators only ever talk to `zddc.varasys.io`.) Browse all versions at <https://zddc.varasys.io/releases/>.
There is no container image. The chart Dockerfiles in `tnd-zddc-chart` compile zddc-server from source at build time, fetching the right tag from Codeberg directly. If you want your own image, copy the static binary into a `FROM scratch` or `FROM alpine` base in a few lines.
### Env-var contract (for chart consumers)
Downstream Helm charts and Compose files should set these explicitly:
| 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 from source
Requires Go 1.24+.
```sh
# Single binary for the host platform
(cd zddc && go build -o zddc-server ./cmd/zddc-server)
# All four release platforms (cross-compiled, statically linked)
sh build.sh # at the repo root — silently skips if Go isn't on PATH
# → outputs to zddc/dist/zddc-server-{linux,darwin,windows}-*
```
To run unit tests:
```sh
(cd zddc && go test ./...)
```
## Release tagging
`sh zddc/release.sh` is the canonical path. It tags the commit, cross-compiles the four binaries (native Go), and uploads them as Codeberg release assets via the shared `publish-codeberg-release.sh` helper.
```sh
sh zddc/release.sh # alpha cut, version auto-derived (default)
sh zddc/release.sh alpha # same
sh zddc/release.sh beta # beta cut
sh zddc/release.sh stable # stable cut, patch++ from latest stable
sh zddc/release.sh stable 0.1.0 # stable cut, explicit version
```
**Default channel is `alpha`** so a stable-equivalent tag never appears by accident during active development. Pass `beta` to soak; pass `stable` only when deliberately promoting. The script tags the commit but does NOT push — finish with `git push origin <branch>` and `git push origin <tag>`.
Prerequisites:
- Go 1.24+ on PATH.
- `$CODEBERG_TOKEN` exported, scoped to write the VARASYS/ZDDC repo. Generate one at <https://codeberg.org/user/settings/applications>.
After the script returns successfully, regenerate the website's versions index from the new release list:
```sh
sh build.sh
git add website/releases/index.html website/releases/manifest.json
git commit -m "release: zddc-server vX.Y.Z"
git push origin main
git push origin zddc-server-vX.Y.Z
```
Single-developer / solo-release flow by design — no CI babysitting, no separate dashboard to debug. The script fails loudly and visibly on the developer's terminal if anything goes wrong.
### Versioning
Pre-release semver. Stable cuts get clean `vX.Y.Z` tags. Alpha and beta cuts get `vX.Y.Z-alpha.N` / `vX.Y.Z-beta.N` where `X.Y.Z` is the next patch of the latest clean stable and `N` is a per-channel counter that resets when stable advances.
```
alpha → v0.0.8-alpha.1
alpha → v0.0.8-alpha.2
beta → v0.0.8-beta.1
alpha → v0.0.8-alpha.3 (alpha and beta count separately)
stable → v0.0.8 (counter resets at next-patch advance)
alpha → v0.0.9-alpha.1
```
Pre-release semver ordering (`0.0.8-alpha.1 < 0.0.8-alpha.2 < 0.0.8-beta.1 < 0.0.8`) is honored by all standard tooling Codeberg release sorting, `git tag --sort=-v:refname`, `sort -V`, npm, cargo so consumers can pin or compare versions without surprises.
---
**Notes:**
- The `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision
- ACL is enforced via bottom-up `.zddc` file evaluation