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)
MINC=$(echo "$OLD_CHART_VER" | cut -d. -f2)
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))"
fi
NEW_CHART_VER="$MAJC.$MINC.$((PATC + 1))"
sed -i "s/^version: .*/version: $NEW_CHART_VER/" chart/Chart.yaml
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_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_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
@ -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".
- `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.
- **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 "=== Release commit + tag ==="
# Stage the artifacts that are part of the release. dist/ is
# gitignored everywhere — none of the tools' dist/<tool>.html files
# are tracked. The release commit only carries the bake-in artifacts
# that the binary needs at //go:embed time + the embedded form
# template.
# Stage the artifacts that are part of the release. mdedit's dist
# file is the only force-tracked dist artifact today; the others
# are gitignored and intentionally not committed.
git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \
"$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
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
- **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
- **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
- **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
- **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_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_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.

View file

@ -124,16 +124,6 @@ func main() {
Addr: cfg.Addr,
Handler: gzWrapper(mux),
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
@ -168,34 +158,25 @@ func main() {
// setupAccessAuditLog constructs a slog.Logger writing JSON lines to a
// size-rotated file at the operator-configured path. Returns nil if no
// path is configured (operator opted out via --access-log=) —
// AccessLogMiddleware then logs only to stderr.
// path is configured — AccessLogMiddleware then logs only to stderr
// (existing behavior).
//
// Auto-creates the parent directory (mode 0750) if missing, so the
// default path of <ZDDC_ROOT>/.zddc.d/logs/access-<host>.log "just
// works" on a fresh deployment without operator setup.
//
// Every record is tagged with `host` (os.Hostname). When multiple
// 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.
// Rotation is via lumberjack: 100 MB per file, 10 backups, 90-day max
// age, gzip compression on rotated files. Tuning is fixed (not exposed
// as flags) — these defaults match what an audit-trail use case needs;
// operators wanting stricter retention can wire up logrotate against
// the rotated files themselves.
//
// File-permission posture: lumberjack creates new logs with mode 0600
// (running user only). For multi-user audit access, the operator should
// use group-readable parent directory permissions and either chmod the
// 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 {
if path == "" {
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{
Filename: path,
MaxSize: 100, // megabytes per file before rotation
@ -203,18 +184,13 @@ func setupAccessAuditLog(path string) *slog.Logger {
MaxAge: 90, // days
Compress: true,
}
host, _ := os.Hostname()
if host == "" {
host = "unknown"
}
// JSON handler — line-delimited JSON is the format every standard
// log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses
// natively, and stays grep-friendly for ad-hoc inspection.
h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo})
slog.Info("access log file enabled",
"path", path, "host", host,
"max_size_mb", 100, "max_backups", 10, "max_age_days", 90)
return slog.New(h).With("host", host)
"path", path, "max_size_mb", 100, "max_backups", 10, "max_age_days", 90)
return slog.New(h)
}
// newGzipWrapper builds the gzip middleware applied to the entire mux.

View file

@ -7,7 +7,6 @@ import (
"io"
"net"
"os"
"path/filepath"
"strings"
)
@ -76,9 +75,7 @@ func Load(args []string) (Config, error) {
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
"Set explicitly to empty (--access-log=) to disable.")
"Tee structured access logs to this file (JSON, size-rotated). Empty = stderr only.")
helpFlag := fs.Bool("help", false, "Print this help 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
}
// CORS + AccessLog both have "unset → default; explicit-empty →
// disabled" semantics. The flag default is "" in both cases so we
// can't tell unset from explicit-empty via the flag alone —
// fs.Visit catches explicit flag use, and os.LookupEnv catches
// explicit env-var use.
// CORS has special semantics: "unset" → default origin list; "set to
// empty" → CORS disabled. The flag default is "" so we can't tell unset
// from explicit-empty via the flag alone — fs.Visit catches explicit
// flag use, and os.LookupEnv catches explicit env-var use.
corsFlagSet := false
accessLogFlagSet := false
if args != nil {
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "cors-origin":
if f.Name == "cors-origin" {
corsFlagSet = true
case "access-log":
accessLogFlagSet = true
}
})
}
_, accessLogEnvSet := os.LookupEnv("ZDDC_ACCESS_LOG")
accessLogExplicit := accessLogFlagSet || accessLogEnvSet
cfg := Config{
Root: *rootFlag,
@ -146,23 +136,6 @@ func Load(args []string) (Config, error) {
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.
switch {
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("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
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("version", false, "Print version info and exit.")
fs.PrintDefaults()