Compare commits
15 commits
ae758550a8
...
f176bea645
| Author | SHA1 | Date | |
|---|---|---|---|
| f176bea645 | |||
| 9a3e4d8fa7 | |||
| 8df0defbd2 | |||
| eaecaaee29 | |||
| c22bb19dab | |||
| e021f14609 | |||
| 0fae93696d | |||
| 62ce6e9f63 | |||
| 633411770c | |||
| e67c1b2e06 | |||
| bbb75a87af | |||
| a7e84dae15 | |||
| 1ddd331f58 | |||
| 2a70359b0a | |||
| 4a78ce4473 |
7 changed files with 6261 additions and 100 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
11
build
|
|
@ -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
6236
mdedit/dist/mdedit.html
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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 50–920 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue