ZDDC/zddc/internal/config/config.go
ZDDC 8b6a2dc3e3 feat(zddc-server): apps fetch+cache subsystem with cascade overrides
Adds internal/apps/ package serving the five tool HTMLs at virtual paths
based on the surrounding folder name convention:

  archive      every directory (multi-project, project, archive, vendor)
  classifier   any Incoming/Working/Staging directory and subtree
  mdedit       any Working directory and subtree
  transmittal  any Staging directory and subtree
  landing      only at deployment root

The current-stable build of every tool is //go:embed'd into the binary
at compile time — that's the default with zero config. Operators
override per-directory via .zddc apps: entries; closer-to-leaf wins.

Spec syntax (in any apps: value):

  stable / beta / alpha / :stable          channel
  v0.0.4 / v0.0 / v0 / :v0.0.4              version
  https://my-mirror/releases                URL prefix only
  https://my-mirror/releases:beta           URL prefix + channel
  https://my-fork/archive.html              terminal full URL
  ./local.html / /abs/path.html             terminal local path

The special apps.default key provides a baseline URL prefix and channel
inherited by any app not overridden per-name. Per-axis cascade: a deeper
.zddc can override the URL, the channel, or both.

Cascade walks root→leaf; default applies first at each level, then the
per-app entry. Terminal sources (paths and full .html URLs) short-circuit
composition; deeper non-terminal entries override parent terminals.

URL sources fetch once on first request and cache forever in
<ZDDC_ROOT>/_app/<host>/<path> — different upstreams with the same
filename stay distinct. No background refresh, no SHA-256 verification:
operators delete the cache file to force a refetch. Concurrent misses
for the same source dedupe via a 30-line hand-rolled singleflight.

Per-request override: any user can append ?v=<spec> to a tool URL
(e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha, ?v=https://mirror/releases:beta)
to ask for a different build for one request. Security: ?v= serves
ONLY versions already in the cache (cache miss returns 404; path
sources are rejected outright with 400). Users cannot trigger
arbitrary upstream fetches via crafted URLs.

Failed URL fetches (network down, 5xx) fall back to embedded with a
one-time WARN log. The X-ZDDC-Source response header reports what
served: fetch:URL / cache:URL / path:/abs / embedded:<app>@<build>.

Wire-in (cmd/zddc-server/main.go): dispatch routes <dir>/<app>.html
through apps.MatchAppHTML + AppAvailableAt + apps.Server.Serve when
no real file exists. Direct URL access to /_app/... is blocked at
the dispatch layer — cached files must go through the apps resolver
so they get correct Content-Type and ACL gating.

Schema (internal/zddc/file.go): ZddcFile gains Apps map[string]string
for cascade overrides. Validator (internal/zddc/validate.go) accepts
the special "default" key alongside the five canonical app names and
all spec forms.

Removes ZDDC_APPS_* env vars (no admin UI, no refresh interval, no
upstream allow-list — the simpler model has fewer knobs).

40+ unit tests across the new package: parser shapes, cascade
resolution with default+per-app interactions, terminal short-circuit
semantics, ?v= cache-only enforcement, embedded fallback, atomic
cache writes, singleflight dedup. Plus end-to-end dispatch tests in
cmd/zddc-server/main_test.go.

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

132 lines
4.4 KiB
Go

package config
import (
"errors"
"fmt"
"net"
"os"
"strings"
)
// Config holds all runtime configuration loaded from environment variables.
type Config struct {
Root string // ZDDC_ROOT — absolute path to the served file tree
Addr string // ZDDC_ADDR — bind address (default :8443)
TLSCert string // ZDDC_TLS_CERT — path to PEM cert; empty = self-signed
TLSKey string // ZDDC_TLS_KEY — path to PEM key; empty = self-signed
TLSMode string // computed from TLSCert/TLSKey: none/selfsigned/provided
LogLevel string // ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive)
EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email)
CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables
// BuildVersion is baked into the X-ZDDC-Source header on embedded
// fallback responses so operators see exactly which binary's
// embedded HTML they're getting. Set at build time via -ldflags.
BuildVersion string
}
// Load reads configuration from environment variables and validates required fields.
func Load() (Config, error) {
cfg := Config{
Addr: getEnv("ZDDC_ADDR", ":8443"),
Root: os.Getenv("ZDDC_ROOT"),
TLSCert: os.Getenv("ZDDC_TLS_CERT"),
TLSKey: os.Getenv("ZDDC_TLS_KEY"),
LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"),
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
CORSOrigins: parseCORSOrigins(),
BuildVersion: getEnv("ZDDC_BUILD_VERSION", "dev"),
}
if cfg.Root == "" {
return Config{}, errors.New("ZDDC_ROOT environment variable is required")
}
info, err := os.Stat(cfg.Root)
if err != nil {
return Config{}, fmt.Errorf("ZDDC_ROOT %q is not accessible: %w", cfg.Root, err)
}
if !info.IsDir() {
return Config{}, fmt.Errorf("ZDDC_ROOT %q is not a directory", cfg.Root)
}
// Determine TLS mode
if cfg.TLSCert == "none" {
cfg.TLSMode = "none"
} else if cfg.TLSCert == "" && cfg.TLSKey == "" {
cfg.TLSMode = "selfsigned"
} else {
cfg.TLSMode = "provided"
}
// Cert and key must both be set or both be empty only when TLSMode == "provided"
if cfg.TLSMode == "provided" && (cfg.TLSCert == "") != (cfg.TLSKey == "") {
return Config{}, errors.New("ZDDC_TLS_CERT and ZDDC_TLS_KEY must both be set or both be empty")
}
// Plain HTTP mode trusts the email header from any client. That is only
// safe behind an authenticating reverse proxy, so refuse to start when
// binding plain HTTP to a non-loopback interface unless the operator has
// explicitly acknowledged the deployment shape via ZDDC_INSECURE_DIRECT=1.
if cfg.TLSMode == "none" && !isLoopbackAddr(cfg.Addr) && os.Getenv("ZDDC_INSECURE_DIRECT") != "1" {
return Config{}, fmt.Errorf(
"ZDDC_TLS_CERT=none binds plain HTTP to %q which trusts %s headers from any client; "+
"either use TLS (unset ZDDC_TLS_CERT or supply a cert), bind to loopback (127.0.0.1: or [::1]:), "+
"or set ZDDC_INSECURE_DIRECT=1 to confirm an authenticating reverse proxy is in front",
cfg.Addr, cfg.EmailHeader)
}
return cfg, nil
}
// isLoopbackAddr reports whether addr binds only to a loopback interface.
// addr is in net.Listen form: "host:port", ":port", or "[ipv6]:port".
// ":port" means all interfaces, so it is NOT loopback.
func isLoopbackAddr(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if host == "" {
return false
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsLoopback()
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// parseCORSOrigins reads ZDDC_CORS_ORIGIN as a comma-separated allowlist.
// Unset → default to https://zddc.varasys.io. Empty string → CORS disabled.
// Origins are not validated as URLs here; the middleware does an exact-match
// comparison against the request's Origin header.
func parseCORSOrigins() []string {
v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN")
if !ok {
return []string{"https://zddc.varasys.io"}
}
if v == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}