ZDDC/zddc
ZDDC fedc3650b5 fix(zddc-server): access log was always email=anonymous (middleware ordering)
The middleware chain in main.go was:

  AccessLogMiddleware ( CORSMiddleware ( ACLMiddleware ( dispatch ) ) )

ACLMiddleware extracts the user email from the configured header and
stores it in the request context via r.WithContext. But Go's context
propagates DOWN the chain (to handlers further in) — not back UP. The
new context-bearing request only exists inside the call to
next.ServeHTTP; once that returns, the outer middleware still has the
ORIGINAL request without the email. So AccessLogMiddleware's
EmailFromContext(r) call (which runs after next.ServeHTTP returns to
log the request) read from the original context and got an empty
string, falling through to "anonymous".

The /.profile/ page worked correctly because it reads the email
directly inside the handler — at that depth the context-bearing
request is the one in scope.

Fix: invert the chain so ACL is OUTERMOST.

  ACLMiddleware ( AccessLogMiddleware ( CORSMiddleware ( dispatch ) ) )

Now ACL extracts the email and the new request flows down through
AccessLog (which sees the email-bearing context), CORS, and dispatch.

Add three regression tests in middleware_test.go:

  TestAccessLogReadsEmailFromACLContext
    The fix: with ACL outer, AccessLog logs email=alice@example.com
    when X-Auth-Request-Email is set.

  TestAccessLogAnonymousWhenNoEmail
    The unchanged path: no header → email=anonymous (correct fallback).

  TestAccessLogOuterDoesNotSeeInnerContext
    Locks down Go's actual context-propagation behavior. Builds the
    INVERTED (buggy) chain and asserts that AccessLog (outer) does NOT
    see the email ACL (inner) set. If this ever fails, Go's context
    propagation has changed in a way that lets inner-set context flow
    upward — which would mean the reordering fix could be reverted.

All zddc-server tests pass via `go test ./...` (run in podman against
golang:1.24-alpine since this dev host doesn't have Go installed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:46:49 -05:00
..
cmd/zddc-server fix(zddc-server): access log was always email=anonymous (middleware ordering) 2026-04-30 12:46:49 -05:00
internal fix(zddc-server): access log was always email=anonymous (middleware ordering) 2026-04-30 12:46:49 -05:00
go.mod Initial commit 2026-04-27 11:05:47 -05:00
go.sum Initial commit 2026-04-27 11:05:47 -05:00
README.md docs: align with simplified release model 2026-04-30 09:56:34 -05:00
release.sh refactor: HTML tools live in website/releases/ as static files + symlink hierarchy 2026-04-30 09:40:16 -05:00

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.

# Pick a tag from https://codeberg.org/VARASYS/ZDDC/releases (filter by zddc-server-v*)
curl -L -o zddc-server \
  https://codeberg.org/VARASYS/ZDDC/releases/download/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+):

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. Two ways to run zddc-server in Kubernetes / containers:

  • The example Helm charts under helm/ (zddc-server-prod/ for stable / zddc-server-dev/ for tracking main HEAD) compile zddc-server from source via init container — no image registry needed.
  • Roll your own image: copy the static binary into a FROM scratch or FROM alpine base in a few lines.

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:

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:

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):

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.

# 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:

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 the unified install.sh script — cd into ZDDC_ROOT/ and run:

sh -c "$(curl -fsSL https://zddc.varasys.io/install.sh)"           # self-contained, current stable (default)
sh -c "$(curl -fsSL https://zddc.varasys.io/install.sh)" -- --mode track --channel beta   # track beta
sh -c "$(curl -fsSL https://zddc.varasys.io/install.sh)" -- --channel 0.0.2               # pin to a specific version

The script handles all three deployment patterns:

  • Self-contained (default) — fetches the five current-channel tool HTMLs and populates a _template/ directory of level-1 bootstrap stubs. No runtime dependency on zddc.varasys.io. Re-run to update.
  • Channel-tracking — fetches five tiny level-2 stubs (~10 KB total) that fetch the named channel from zddc.varasys.io on every page load.
  • Pin-to-version — copies a specific version's HTML locally; site does not move.

For project subdirectories, run the same command from inside <ZDDC_ROOT>/Project-X/ — it auto-detects the parent and installs four level-1 stubs that fetch ../<tool>.html.

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 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 stable release is a Codeberg git tag (zddc-server-vX.Y.Z) 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 from Codeberg directly:

https://codeberg.org/VARASYS/ZDDC/releases/download/zddc-server-vX.Y.Z/zddc-server-linux-amd64

Browse all releases at https://codeberg.org/VARASYS/ZDDC/releases.

There is no alpha/beta channel for binary distribution. Active dev/soak happens via the helm/zddc-server-dev/ chart, which builds zddc-server from source on every pod restart against any commit you point it at. There is no container image; if you want your own, copy the static binary into a FROM scratch or FROM alpine base in a few lines, or use one of the helm charts which compile from source via init container.

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+.

# 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:

(cd zddc && go test ./...)

Release tagging

sh zddc/release.sh is the canonical path. Stable cuts only. The script 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 zddc/release.sh                       # patch-bump from latest clean stable tag
sh zddc/release.sh 0.1.0                 # explicit version

The script tags the commit but does NOT push — finish with git push origin main and git push origin <tag>.

Prerequisites:

After the script returns successfully, the website's versions index doesn't need updating for zddc-server (it links out to the Codeberg release page directly). Just push:

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

Clean semver. Stable cuts get <tool>-vX.Y.Z tags. There are no alpha/beta channel tags for zddc-server — channel URLs are stable URLs by design (counters defeat that), and zddc-server has no static-asset distribution layer where channel mirrors would matter. Active dev runs via helm/zddc-server-dev/, which builds from source on each rollout.

The two existing zddc-server-v0.0.8-alpha.1 and zddc-server-v0.0.8-alpha.2 tags from a previous experiment stay as historical artifacts; no new alpha/beta tags are created going forward.


Notes:

  • The .archive virtual path resolves ZDDC tracking numbers to their earliest-received revision
  • ACL is enforced via bottom-up .zddc file evaluation