Compare commits

..

15 commits

Author SHA1 Message Date
f176bea645 fix(ci): chart-bump uses the SHA baked into the embed (not git HEAD)
./build beta runs locally at HEAD=N, generates embed/*.html with
build-label SHA=N, then the operator commits the generated bytes
as commit N+1. After push, git HEAD on the runner = N+1 but the
served website's BUILD_LABEL still encodes N (it's baked into the
HTML at build time, before the commit).

Previously the bump script computed BETA_VERSION using
`git rev-parse --short HEAD` = N+1, so the chart's appVersion
(and the dev image's tag, and the kubelet's pull) said N+1 while
the served label said N. Two SHAs for the same bytes — confusing
when triaging "is this image actually the latest?".

Read the SHA from zddc/internal/apps/embedded/versions.txt instead
(third pipe-delimited field of any line). That's the single source
of truth for what bytes were baked, and it lines up with what users
see in every tool's header.

Manually re-bumped chart develop after committing this script:
  appVersion: 0.0.16-beta-9a3e4d8 → 0.0.16-beta-8df0def
2026-05-04 07:01:11 -05:00
9a3e4d8fa7 chore(embedded): cut v0.0.16-beta with loading-efficiency wins
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
Bake into the dev binary:
  - ETag + max-age=0 on embedded HTML (304s on repeat loads)
  - gzip compression middleware (~75% wire-size reduction)
  - vendored jszip + docx-preview in archive/transmittal/classifier
  - tee'd file-based access log via --access-log
2026-05-04 06:29:23 -05:00
8df0defbd2 feat(server): tee access log to a rotated file for on-disk audit trail
Add --access-log <path> (env ZDDC_ACCESS_LOG). When set, every access-
log record is written as a JSON line to the configured file in
addition to the existing slog.Default() stderr output. Empty (default)
keeps the prior behavior — stderr only.

Rotation via gopkg.in/natefinch/lumberjack.v2:
  100 MB per file, 10 backups, 90-day max age, gzip rotated files.

Operator usage (e.g. behind a Caddy/quadlet stack):
  zddc-server --access-log /srv/.zddc.d/logs/access.log ...

Architecture:
  AccessLogMiddleware now takes an optional *slog.Logger. main.go wires
  it via setupAccessAuditLog() which builds a slog.JSONHandler over a
  lumberjack rotator. Stderr emission stays via slog.Default(); the
  audit logger gets the same fields in line-delimited JSON, the format
  every standard log shipper (Vector, Loki, fluentbit, journalbeat)
  parses natively.

Tests cover the audit logger receiving the same email/path/status
fields as the stderr stream.
2026-05-03 23:38:14 -05:00
eaecaaee29 perf(tools): vendor jszip + docx-preview for archive/transmittal/classifier
Same pattern as the browse fix. archive, transmittal, classifier
previously CDN-loaded jszip + docx-preview on first preview of a
.zip / .docx file via shared/preview-lib.js's loadLibrary helper.
That meant each first-preview blocked on a CDN round-trip + parse,
and broke entirely under restrictive networks or CSPs.

Vendor both libs under shared/vendor/ and concat them at the top of
each tool's build, ahead of init.js. window.JSZip + window.docx are
now defined immediately on page load. Drop the redundant loadLibrary
calls (and classifier's stray <script src="cdn..."> tag in the
template, plus archive's bespoke loadJSZip helper in export.js).

xlsx (SheetJS) intentionally stays CDN-loaded — at ~900 KB it's too
large to inline, and only fires on .xlsx preview which is a rarer
path.

Bundle size impact (uncompressed):
  archive:     304 KB → 476 KB  (+172 KB)
  transmittal: 449 KB → 621 KB  (+172 KB)
  classifier:  252 KB → 424 KB  (+172 KB)

With the gzip middleware (~75% reduction on HTML) and ETag-cached
revalidation now in place, the wire-size delta is ~40 KB per tool
on the first load and 0 on every subsequent load until redeploy.
2026-05-03 23:34:28 -05:00
c22bb19dab perf(server): gzip compression middleware on the entire mux
Add github.com/klauspost/compress/gzhttp wrapper around the request
handler. With MinSize(1024), responses ≥ 1 KB get gzip-encoded when
the client advertises Accept-Encoding: gzip; smaller bodies + 304
Not Modified pass through unchanged.

The wrapper auto-appends Vary: Accept-Encoding (compatible with the
existing Vary: Accept on directory.go's content-negotiated path).

Live-tested against zddc-server -root /tmp/empty:
  GET / w/ Accept-Encoding: gzip → 20.9 KB compressed (was 80.9 KB
                                   uncompressed). 74% reduction.
  Decompresses cleanly back to the original bytes.

Helps every code path that bypasses Caddy: devshell pods, local dev
binaries, tests, anywhere zddc-server is hit directly. Production
behind Caddy already had compression at the proxy layer; this just
makes the Go server self-sufficient.

Tests in cmd/zddc-server/main_test.go cover:
- large body + Accept-Encoding → compressed + Vary header
- small body → not compressed (under MinSize)
- no Accept-Encoding header → plain bytes
2026-05-03 23:31:18 -05:00
e021f14609 perf(server): ETag + max-age=0 on embedded HTML responses
The apps subsystem previously sent Cache-Control: public, max-age=300|3600,
must-revalidate but no ETag. With must-revalidate and no validator, the
browser cannot return 304 — it has to refetch the full body once max-age
expires. For mdedit that's 920 KB on every reload after an hour.

Add a content-addressed ETag (sha256 hex prefix, 32 chars) to:
- apps/handler.go's serveBody + serveEmbedded (both paths now emit ETag
  + handle If-None-Match short-circuit to 304)
- handler/directory.go's embedded:browse fallback (mirror behavior so
  the bare-directory landing serves the same way)

Drop max-age to 0 with must-revalidate: every page load revalidates,
but a matching ETag returns 304 with empty body. Steady-state cost of
a reload drops from N KB to a few hundred bytes. When the binary is
redeployed, the ETag changes (content hash) and the next request
returns 200 with the new bytes.

Tests in apps/handler_test.go cover both paths:
- TestServer_Embedded_ConditionalGET: full GET, matching INM, stale INM
- TestEmbeddedETag_Stable: same bytes → same ETag, different → different

Live smoke (curl against zddc-server -root /tmp/empty):
  GET /            → 200, ETag set, body = 80919 bytes (landing.html)
  GET / + INM:tag  → 304 Not Modified, empty body
2026-05-03 23:28:18 -05:00
0fae93696d chore(embedded): cut v0.0.16-beta with public-landing fix
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
Bake the public-landing-page server change into the dev binary.
2026-05-03 22:53:37 -05:00
62ce6e9f63 feat(server): public landing page (root bypasses dir-level ACL)
GET / and GET /index.html previously enforced the root .zddc's
top-level acl: gate before serving the landing page. On a deployment
where only specific emails are allowed at root, anonymous (and
unauthorized) callers got 403 — they couldn't even see the project
picker that would tell them which projects were available to them.

Make the landing page public:
  - cmd/zddc-server: drop the AllowedWithChain gate from the
    apps.Serve("landing") branch; drop it from the IsDir branch when
    urlPath == "/".
  - handler/directory.go: matching bypass for ServeDirectory at the
    root path (covers Accept: application/json and the case where a
    real /index.html exists on disk).

Per-project ACL is preserved end-to-end:
  - fs.ListDirectory continues to filter sub-entries per email, so
    anonymous callers see only projects whose .zddc allows them.
  - Subdirectory requests still hit the ACL gate.

Regression test in handler/directory_test.go covers all four cases
(anonymous public, anonymous filters out private, admin sees both,
anonymous still 403 on private subdir). Full go test ./... passes.
2026-05-03 22:53:14 -05:00
633411770c chore(embedded): cut v0.0.16-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 4s
Bake the standardized headers + archive bugfix + browse refactor
into the dev binary. Triggers notify-chart-dev → bumps tnd-zddc-chart
develop with appVersion=0.0.16-beta-<sha>.
2026-05-03 22:22:13 -05:00
e67c1b2e06 fix(archive): point getElementById at the real root id
drag-drop.js and the unsupported-browser handler in app.js both
referenced getElementById('app'), but the template's root has
id="appContainer". The mismatch was masked in production because
sourceMode='http' skips dragDrop.init() — only file:// (sourceMode=
'local') tripped over it, throwing "Cannot read properties of null
(reading 'addEventListener')" at app load.

Surfaced while header-standardizing the other tools; fixed by
pointing both callers at #appContainer.
2026-05-03 22:21:51 -05:00
bbb75a87af chore(headers): standardize across all 7 tools
Bring every tool's header in line with archive's pattern:

  [logo] [title] [version] [Add Local Directory] [⟳] ............... [◐] [?]
  ------------- header-left ---------------       ----- header-right -

Changes per tool:

* browse: rename "Select Directory" → "Add Local Directory"; add the
  red-non-stable wrap to the build label (was missing); add a help
  panel + bundle shared/help.js.

* classifier: rename selectDirectoryBtn → addDirectoryBtn,
  refreshBtn → refreshHeaderBtn for consistency. Update all JS
  callers and welcome-screen copy to the new label.

* mdedit: same id rename. Move the previously-in-pane refresh
  button into the header. Stop renaming the dir button to
  "Directory: <name>" once a folder is loaded — instead use the
  shared btn--subtle variant to de-emphasize while keeping the
  standard label.

* transmittal: convert non-standard <div class="app-header"> with
  spacer/icons containers to <header class="app-header"> with the
  canonical header-left/header-right pair. Move the publish split-
  button into header-left (Transmittal-specific primary action).
  Remove dead .app-header__spacer/__icons/header-icon-btn CSS now
  that nothing references those classes.

* landing, form: add help-btn + help-panel + bundle shared/help.js.
  Each panel is tool-specific (project picker docs for landing,
  schema-driven form docs for form).

Cross-cutting:

* shared/base.css: promote .btn--subtle from browse/css/tree.css
  so any tool with an online mode can de-emphasize Add Local
  Directory consistently.

Verified all 7 tools in headless Chromium: header structure correct,
build label red on non-stable cuts, help panel opens + closes via
button + Esc.
2026-05-03 22:17:02 -05:00
a7e84dae15 refactor(ci): extract chart-bump logic to .forgejo/scripts/
Both notify-chart-dev.yml and the notify-chart-prod job in
deploy-release.yml were carrying ~80 lines of inline shell each,
duplicating the clone-bump-push flow. Extract to a single script:

  .forgejo/scripts/notify-chart-bump.sh <beta|stable> [VERSION]

Three benefits:

1. **Locally testable**. The script is invocable directly:
     CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN \
       .forgejo/scripts/notify-chart-bump.sh beta
   No more "push to main and watch what the runner does" debug loop.

2. **Manual escape hatch**. When CI is broken, the same script is
   how we recover. The 0.0.16-beta-1ddd331 chart bump preceding
   this commit was performed via this very script.

3. **Runner-quirk-immune**. The previous three commits chased a
   Forgejo runner v12.9.0 phantom-SIGPIPE bug that would only
   surface under the runner's `bash -e -o pipefail {0}` wrapper.
   A real script with its own `#!/usr/bin/env bash` and explicit
   error handling sidesteps the wrapper entirely.

The workflow YAMLs shrink to checkout + run-script. No GITHUB_OUTPUT
plumbing, no inline if/else gates, no shell flag overrides. The
behavior is identical to the prior inline versions.
2026-05-03 21:56:17 -05:00
1ddd331f58 fix(ci): notify-chart-dev — drop default pipefail, set -eu after early echos
The Forgejo runner v12.9.0 was killing the gate echo with SIGPIPE
(exit 141) when the script ran under the runner's default
`bash -e -o pipefail {0}` shell. Override with `shell: bash {0}`
(no -e, no pipefail) so the early stable-tag check + log echos
don't get tripped by phantom pipe failures, then re-enable
`set -eu` for the actual bump logic where strictness matters.
2026-05-03 21:48:37 -05:00
2a70359b0a fix(ci): inline notify-chart-dev gate into bump step
The two-step pattern was failing under Forgejo runner v12.9.0 — the
gate step exited 141 immediately after echoing + writing GITHUB_OUTPUT,
even with no pipelines involved. Folding the stable-tag check into the
bump step's own strict-mode shell removes the cross-step boundary that
the runner was tripping over.
2026-05-03 21:46:25 -05:00
4a78ce4473 fix(ci): notify-chart-dev gate avoids SIGPIPE; add workflow_dispatch
The gate step's `git tag --points-at HEAD | grep -q '^zddc-server-v'`
exits 141 (SIGPIPE) under bash's pipefail when grep finishes early —
the runner's strict-mode wrapper then fails the step even though the
if-condition logic completed correctly. Materialize the tag list with
git's native --list glob filter and test it with `[ -n ]` instead, so
no pipeline is involved.

Also add workflow_dispatch so we can re-fire this workflow on a fresh
commit without needing a no-op edit under zddc/internal/apps/embedded/
to match the paths filter.
2026-05-03 21:43:01 -05:00
7 changed files with 6261 additions and 100 deletions

View file

@ -129,20 +129,7 @@ for BRANCH in $BRANCHES; do
MAJC=$(echo "$OLD_CHART_VER" | cut -d. -f1) MAJC=$(echo "$OLD_CHART_VER" | cut -d. -f1)
MINC=$(echo "$OLD_CHART_VER" | cut -d. -f2) MINC=$(echo "$OLD_CHART_VER" | cut -d. -f2)
PATC=$(echo "$OLD_CHART_VER" | cut -d. -f3) PATC=$(echo "$OLD_CHART_VER" | cut -d. -f3)
# Chart-version bump strategy:
# stable cut → MINOR++, PATCH=0 (e.g. 0.2.7 → 0.3.0)
# beta cut → PATCH++ (e.g. 0.3.0 → 0.3.1)
# This keeps the patch number bounded (≈ #betas-per-stable, not
# all-time), while staying monotonically increasing — JFrog chart
# repos reject duplicate chart-version numbers, so a literal
# "reset to 0.2.0" cycle would break uploads after the first
# stable cut. The actual zddc-server version lives in appVersion;
# chart version is just JFrog packaging metadata.
if [ "$CHANNEL" = "stable" ]; then
NEW_CHART_VER="$MAJC.$((MINC + 1)).0"
else
NEW_CHART_VER="$MAJC.$MINC.$((PATC + 1))" NEW_CHART_VER="$MAJC.$MINC.$((PATC + 1))"
fi
sed -i "s/^version: .*/version: $NEW_CHART_VER/" chart/Chart.yaml sed -i "s/^version: .*/version: $NEW_CHART_VER/" chart/Chart.yaml
echo " appVersion: $CURRENT$TARGET_VERSION" echo " appVersion: $CURRENT$TARGET_VERSION"

View file

@ -392,7 +392,6 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
| `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment | | `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment |
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity | | `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
| `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated CORS allowlist; empty value disables CORS. Default lets tools served from zddc.varasys.io call back into a customer-deployed server. | | `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated CORS allowlist; empty value disables CORS. Default lets tools served from zddc.varasys.io call back into a customer-deployed server. |
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
### Release tagging ### Release tagging
@ -424,8 +423,3 @@ local path that fails loudly and visibly on the developer's terminal.
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page". - `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint. - `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable. - **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
- **Caching on embedded tool HTMLs** (landing, browse served at `/`, plus the five canonical app HTMLs at `<dir>/<app>.html`): `Cache-Control: public, max-age=0, must-revalidate` + content-addressed `ETag` (sha256 hex prefix). Browser revalidates on every load; matching ETag returns `304 Not Modified` with empty body. ETag changes only when the binary is redeployed (computed once at startup from `EmbeddedBytes` + `BuildVer`, memoized).
- **Compression**: gzip middleware (`github.com/klauspost/compress/gzhttp`) wraps the entire mux. Skipped for bodies under 1 KB and for 304 responses. Roughly 75% size reduction on tool HTMLs and JSON listings.
- **Public landing page**: `GET /` (HTML or JSON) bypasses the directory-level ACL gate so anonymous callers see the project picker. Per-project filtering inside `fs.ListDirectory` still hides projects the caller can't reach. Subdirectory ACL gates remain in force.
- **Audit log**: every request is mirrored to a JSON-line file under `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` (configurable via `--access-log` / `ZDDC_ACCESS_LOG`, opt out with empty). Lumberjack rotation (100 MB / 10 backups / 90 days, gzip). Hostname is in both the filename and every record's `host` field — multi-replica deployments sharing one `.zddc.d/` dir disambiguate cleanly.
- **HTTP timeouts**: `ReadHeaderTimeout: 10s, ReadTimeout: 60s, WriteTimeout: 60s, IdleTimeout: 120s`. Slowloris-resistant; legit traffic completes in milliseconds even with gzip.

11
build
View file

@ -727,13 +727,14 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
echo "" echo ""
echo "=== Release commit + tag ===" echo "=== Release commit + tag ==="
# Stage the artifacts that are part of the release. dist/ is # Stage the artifacts that are part of the release. mdedit's dist
# gitignored everywhere — none of the tools' dist/<tool>.html files # file is the only force-tracked dist artifact today; the others
# are tracked. The release commit only carries the bake-in artifacts # are gitignored and intentionally not committed.
# that the binary needs at //go:embed time + the embedded form
# template.
git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \ git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \
"$SCRIPT_DIR/zddc/internal/handler/form.html" "$SCRIPT_DIR/zddc/internal/handler/form.html"
if [ -f "$SCRIPT_DIR/mdedit/dist/mdedit.html" ]; then
git -C "$SCRIPT_DIR" add -f "$SCRIPT_DIR/mdedit/dist/mdedit.html"
fi
if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then
git -C "$SCRIPT_DIR" commit -m "release: v${RELEASE_VERSION} lockstep" git -C "$SCRIPT_DIR" commit -m "release: v${RELEASE_VERSION} lockstep"

6236
mdedit/dist/mdedit.html vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -6,15 +6,10 @@ A purpose-built HTTPS file server for ZDDC document archives. Designed to replac
## Features ## Features
- **High-performance static file serving** — ETag, conditional GET, Cache-Control - **High-performance static file serving** — ETag, conditional GET, Cache-Control
- **ETag on embedded tool HTMLs** — sha256 of the embedded bytes; repeat loads return 304 Not Modified instead of re-shipping 50920 KB
- **gzip compression middleware** — wraps the entire mux; ~75% size reduction on tool HTMLs and JSON listings (skips bodies under 1 KB)
- **Public landing page** — root `/` is reachable by anyone, including anonymous; per-project ACL filtering still hides projects the caller can't reach
- **Cascading `.zddc` ACL** — email-based allow/deny lists evaluated bottom-up from requested directory to root - **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 - **Caddy-compatible JSON listings** — the Archive Browser works without modification
- **Virtual `.archive` index** — resolve the earliest revision of any tracked document by URL - **Virtual `.archive` index** — resolve the earliest revision of any tracked document by URL
- **Filesystem watcher** — archive index updates automatically when files change - **Filesystem watcher** — archive index updates automatically when files change
- **File-based audit log** — JSON-line access log tee'd to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` by default, rotated by lumberjack (100 MB / 10 backups / 90 days, gzipped)
- **Conservative HTTP timeouts** — slowloris-resistant; 10 s read-header, 60 s read+write, 120 s idle
- **Flexible TLS modes** — self-signed, real certificates, or plain HTTP - **Flexible TLS modes** — self-signed, real certificates, or plain HTTP
- **Single static binary** — CGO-free, no runtime dependencies; cross-compiled to Linux/macOS/Windows - **Single static binary** — CGO-free, no runtime dependencies; cross-compiled to Linux/macOS/Windows
@ -61,7 +56,6 @@ There is no Containerfile / Dockerfile / compose file in this repo. Two ways to
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index | | `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_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_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_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | Tee'd structured access log. Auto-mkdir on first run. Empty value (set explicitly with `--access-log=`) disables file logging; stderr stream stays. Per-host filenames let multiple replicas write to the same `.zddc.d/` directory without collision; every record carries a `host` field for downstream aggregation. |
`ZDDC_TLS_CERT=none` disables TLS entirely (plain HTTP). Both cert and key must be set together when using real certificates. `ZDDC_TLS_CERT=none` disables TLS entirely (plain HTTP). Both cert and key must be set together when using real certificates.

View file

@ -124,16 +124,6 @@ func main() {
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: gzWrapper(mux), Handler: gzWrapper(mux),
TLSConfig: tlsCfg, TLSConfig: tlsCfg,
// Conservative timeouts. ReadHeaderTimeout caps how long a slow
// client can hold the connection before sending request headers
// (the slowloris vector). Read/Write timeouts cap full-request
// processing — directory listings + tool HTML serving complete
// in milliseconds even with gzip, so 60s is generous. IdleTimeout
// is the keep-alive ceiling between requests on the same conn.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
} }
// Serve in goroutine // Serve in goroutine
@ -168,34 +158,25 @@ func main() {
// setupAccessAuditLog constructs a slog.Logger writing JSON lines to a // setupAccessAuditLog constructs a slog.Logger writing JSON lines to a
// size-rotated file at the operator-configured path. Returns nil if no // size-rotated file at the operator-configured path. Returns nil if no
// path is configured (operator opted out via --access-log=) — // path is configured — AccessLogMiddleware then logs only to stderr
// AccessLogMiddleware then logs only to stderr. // (existing behavior).
// //
// Auto-creates the parent directory (mode 0750) if missing, so the // Rotation is via lumberjack: 100 MB per file, 10 backups, 90-day max
// default path of <ZDDC_ROOT>/.zddc.d/logs/access-<host>.log "just // age, gzip compression on rotated files. Tuning is fixed (not exposed
// works" on a fresh deployment without operator setup. // as flags) — these defaults match what an audit-trail use case needs;
// // operators wanting stricter retention can wire up logrotate against
// Every record is tagged with `host` (os.Hostname). When multiple // the rotated files themselves.
// zddc-server replicas serve the same dataset (and write to the same
// .zddc.d/logs/ directory via per-host filenames), the host field also
// makes downstream-aggregated streams disambiguable.
//
// Rotation: lumberjack — 100 MB per file, 10 backups, 90-day max age,
// gzip compression on rotated files.
// //
// File-permission posture: lumberjack creates new logs with mode 0600 // File-permission posture: lumberjack creates new logs with mode 0600
// (running user only). For multi-user audit access, the operator should // (running user only). For multi-user audit access, the operator should
// use group-readable parent directory permissions and either chmod the // use group-readable parent directory permissions and either chmod the
// log out-of-band or run a forwarder that has its own read access. // log out-of-band or run a forwarder that has its own read access.
// Parent directory must already exist — this function does NOT mkdir,
// since we'd need to assume too much about umask/owner.
func setupAccessAuditLog(path string) *slog.Logger { func setupAccessAuditLog(path string) *slog.Logger {
if path == "" { if path == "" {
return nil return nil
} }
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
slog.Error("could not create access-log directory; falling back to stderr-only",
"dir", filepath.Dir(path), "err", err)
return nil
}
rotator := &lumberjack.Logger{ rotator := &lumberjack.Logger{
Filename: path, Filename: path,
MaxSize: 100, // megabytes per file before rotation MaxSize: 100, // megabytes per file before rotation
@ -203,18 +184,13 @@ func setupAccessAuditLog(path string) *slog.Logger {
MaxAge: 90, // days MaxAge: 90, // days
Compress: true, Compress: true,
} }
host, _ := os.Hostname()
if host == "" {
host = "unknown"
}
// JSON handler — line-delimited JSON is the format every standard // JSON handler — line-delimited JSON is the format every standard
// log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses // log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses
// natively, and stays grep-friendly for ad-hoc inspection. // natively, and stays grep-friendly for ad-hoc inspection.
h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo}) h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo})
slog.Info("access log file enabled", slog.Info("access log file enabled",
"path", path, "host", host, "path", path, "max_size_mb", 100, "max_backups", 10, "max_age_days", 90)
"max_size_mb", 100, "max_backups", 10, "max_age_days", 90) return slog.New(h)
return slog.New(h).With("host", host)
} }
// newGzipWrapper builds the gzip middleware applied to the entire mux. // newGzipWrapper builds the gzip middleware applied to the entire mux.

View file

@ -7,7 +7,6 @@ import (
"io" "io"
"net" "net"
"os" "os"
"path/filepath"
"strings" "strings"
) )
@ -76,9 +75,7 @@ func Load(args []string) (Config, error) {
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1", insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).") "Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"), accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+ "Tee structured access logs to this file (JSON, size-rotated). Empty = stderr only.")
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
"Set explicitly to empty (--access-log=) to disable.")
helpFlag := fs.Bool("help", false, "Print this help and exit.") helpFlag := fs.Bool("help", false, "Print this help and exit.")
versionFlag := fs.Bool("version", false, "Print version info and exit.") versionFlag := fs.Bool("version", false, "Print version info and exit.")
@ -97,25 +94,18 @@ func Load(args []string) (Config, error) {
return Config{}, ErrVersionRequested return Config{}, ErrVersionRequested
} }
// CORS + AccessLog both have "unset → default; explicit-empty → // CORS has special semantics: "unset" → default origin list; "set to
// disabled" semantics. The flag default is "" in both cases so we // empty" → CORS disabled. The flag default is "" so we can't tell unset
// can't tell unset from explicit-empty via the flag alone — // from explicit-empty via the flag alone — fs.Visit catches explicit
// fs.Visit catches explicit flag use, and os.LookupEnv catches // flag use, and os.LookupEnv catches explicit env-var use.
// explicit env-var use.
corsFlagSet := false corsFlagSet := false
accessLogFlagSet := false
if args != nil { if args != nil {
fs.Visit(func(f *flag.Flag) { fs.Visit(func(f *flag.Flag) {
switch f.Name { if f.Name == "cors-origin" {
case "cors-origin":
corsFlagSet = true corsFlagSet = true
case "access-log":
accessLogFlagSet = true
} }
}) })
} }
_, accessLogEnvSet := os.LookupEnv("ZDDC_ACCESS_LOG")
accessLogExplicit := accessLogFlagSet || accessLogEnvSet
cfg := Config{ cfg := Config{
Root: *rootFlag, Root: *rootFlag,
@ -146,23 +136,6 @@ func Load(args []string) (Config, error) {
return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root) return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root)
} }
// Audit-log default: if neither flag nor env was explicitly set,
// default to <Root>/.zddc.d/logs/access-<hostname>.log so the
// server captures an audit trail by default. Setting the flag/env
// to empty (--access-log=) is the explicit opt-out. Hostname is
// in the filename because operators typically run multiple zddc-
// server replicas against the same dataset (the .zddc.d directory
// is shared FS), and per-host filenames keep the JSON streams
// separable for downstream auditors.
if !accessLogExplicit {
host, herr := os.Hostname()
if herr != nil || host == "" {
host = "unknown"
}
cfg.AccessLog = filepath.Join(cfg.Root, ".zddc.d", "logs",
"access-"+host+".log")
}
// Determine TLS mode. // Determine TLS mode.
switch { switch {
case cfg.TLSCert == "none": case cfg.TLSCert == "none":
@ -213,7 +186,7 @@ func Usage(w io.Writer) {
fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.") fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.")
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.") fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.") fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.") fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Empty = stderr only.")
fs.Bool("help", false, "Print this help and exit.") fs.Bool("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.") fs.Bool("version", false, "Print version info and exit.")
fs.PrintDefaults() fs.PrintDefaults()