ZDDC/zddc/cmd/zddc-server/main.go
ZDDC d84c1908f6 feat(zddc): Phase 1 — embedded defaults.zddc + inherit + show-defaults
First step of the .zddc-first-configuration rollout: pure plumbing
that makes the future move-everything-out-of-Go work mechanically
possible without changing any current behaviour.

New pieces:

1. zddc/internal/zddc/defaults.zddc.yaml — a real YAML file in the
   repo. Single source of truth for the baked-in baseline; intentionally
   minimal in Phase 1 (just title + empty acl) so existing deployments
   stay bit-identical until Phase 2 starts populating the schema.

2. //go:embed (defaults.go) bakes the bytes into the binary so
   shipped deployments don't need the file. Operators who want a
   starting point export with:

       zddc-server show-defaults > /var/lib/zddc/root/.zddc

3. PolicyChain gains an Embedded ZddcFile field. EffectivePolicy
   layers in the embedded defaults as a baseline below the on-disk
   chain. Consumers that want the full effective view consult both;
   existing consumers that only read chain.Levels keep working
   bit-identically (the new field is additive).

4. New top-level `inherit:` key on ZddcFile. Default true. Set
   `inherit: false` on any on-disk .zddc to zero out chain.Embedded
   — the operator owns every rule from that level outward. Useful at
   the on-disk root to fully reject the embedded defaults; useful at
   deeper levels for sandbox subtrees.

5. `zddc-server show-defaults` (also accepts --show-defaults) subcommand
   dumps the embedded bytes to stdout — same shape as --print-rego.
   No flag plumbing needed beyond the existing args walk.

6. Tests: parse-roundtrip on the embedded file, presence in chain by
   default, inherit:false drops it, explicit inherit:true is a no-op
   versus the default.

Phase 2 (next): add a `paths:` recursive map + `default_tool:` /
`auto_own:` / `virtual:` keys, populate defaults.zddc.yaml with the
canonical ZDDC convention, and migrate apps.DefaultAppAt /
AutoOwnCanonicalNames / VirtualOnlyCanonicalNames to cascade lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:46:51 -05:00

1094 lines
41 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/cache"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/klauspost/compress/gzhttp"
"gopkg.in/natefinch/lumberjack.v2"
)
// version is the binary's own version, injected at build time via
// `-ldflags="-X main.version=..."`. Defaults to "dev" for unreleased
// builds; release pipelines pass the result of `git describe --tags`.
var version = "dev"
func main() {
// --print-rego: dump a bundled reference Rego policy and exit.
// Cheap escape hatch for operators standing up an external OPA who want
// a parity-tested baseline as a starting point for customization.
//
// --print-rego → standard cascade (commercial default)
// --print-rego=standard → same
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
for _, a := range os.Args[1:] {
switch a {
case "--print-rego", "--print-rego=standard":
fmt.Print(policy.ReferenceRego)
return
case "--print-rego=federal":
fmt.Print(policy.FederalRego)
return
case "show-defaults", "--show-defaults":
// Dump the embedded baseline .zddc to stdout. Pipe into a
// real file (e.g. $ZDDC_ROOT/.zddc) to start from the
// shipped defaults and edit; the on-disk copy then
// participates in the cascade alongside the embedded
// layer (both contribute; child wins). To ignore the
// embedded layer entirely after exporting, set
// `inherit: false` at the top of the exported file.
_, _ = os.Stdout.Write(zddc.EmbeddedDefaultsBytes())
return
}
}
cfg, err := config.Load(os.Args[1:])
if errors.Is(err, config.ErrHelpRequested) {
config.Usage(os.Stderr)
os.Exit(0)
}
if errors.Is(err, config.ErrVersionRequested) {
printVersions(os.Stdout)
os.Exit(0)
}
if err != nil {
fmt.Fprintf(os.Stderr, "configuration error: %v\n\nRun with --help for usage.\n", err)
os.Exit(1)
}
logRing := setupLogger(cfg.LogLevel)
embedded := apps.EmbeddedVersions()
slog.Info("zddc-server starting",
"version", version,
"root", cfg.Root,
"addr", cfg.Addr,
"embedded_apps", embeddedVersionsForLog(embedded))
// Client mode short-circuit: when cfg.Upstream is set, this binary
// runs as a downstream proxy/cache/mirror rather than a master.
// The master-side machinery below (archive index, watcher, apps
// server, policy decider, ACL middleware, token store) is all
// skipped — every request flows through the cache layer, which
// forwards to upstream and (in cache/mirror modes) persists the
// response under cfg.Root.
if cfg.Upstream != "" {
runClient(cfg)
return
}
// Build archive index
slog.Info("building archive index...")
start := time.Now()
idx, err := archive.BuildIndex(cfg.Root)
if err != nil {
slog.Error("failed to build archive index", "err", err)
os.Exit(1)
}
slog.Info("archive index built", "duration", time.Since(start))
// Apps fetch+cache subsystem.
appsServer, err := setupApps(cfg)
if err != nil {
slog.Error("failed to set up apps subsystem", "err", err)
os.Exit(1)
}
// TLS config
tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg)
if err != nil {
slog.Error("failed to configure TLS", "err", err)
os.Exit(1)
}
// Context for graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
// Start file-system watcher (best-effort live updates; misses cross-client
// writes on SMB/CIFS mounts since inotify only sees local-kernel events).
watcher, err := archive.NewWatcher(cfg.Root, idx)
if err != nil {
slog.Warn("failed to start filesystem watcher (index will not auto-update)", "err", err)
} else {
go func() {
if err := watcher.Start(ctx); err != nil && ctx.Err() == nil {
slog.Error("watcher error", "err", err)
}
}()
}
// Periodic full re-scan. Required when the served root is an SMB/CIFS
// share (Azure Files, etc.) — fsnotify sees only events the local kernel
// generates, so writes from other clients to the share are invisible to
// the watcher above. A periodic full walk closes that gap.
if cfg.ArchiveRescanInterval > 0 {
go runPeriodicRescan(ctx, cfg.Root, idx, cfg.ArchiveRescanInterval)
} else {
slog.Info("archive periodic rescan disabled (interval=0)")
}
// HTTP handler
mux := http.NewServeMux()
// Middleware chain (outermost → innermost):
// ACLMiddleware — extract email from cfg.EmailHeader, store in
// request context. Outermost so the email is
// available to AccessLogMiddleware (Go's context
// propagates DOWN the chain via r.WithContext, not
// UP — so AccessLog can't read a context value set
// by an inner middleware after next.ServeHTTP
// returns).
// AccessLogMiddleware — structured per-request log; reads email from
// the context the outer ACL middleware set.
// CORSMiddleware — Origin / preflight handling.
// dispatch — the actual request handler.
auditLogger := setupAccessAuditLog(cfg.AccessLog)
// Construct the policy decider once at startup. ZDDC_OPA_URL=internal
// (default) routes decisions through the in-process Go evaluator;
// http(s):// or unix:// values send each decision to an external
// OPA-compatible server (federal customers, custom Rego policies).
deciderCfg := policy.Config{
URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL,
CascadeMode: cfg.CascadeMode,
}
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
// the policy package's sentinel for "skip the wrapper").
if deciderCfg.CacheTTL == 0 {
deciderCfg.CacheTTL = -1
}
decider, err := policy.New(deciderCfg)
if err != nil {
slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
os.Exit(1)
}
// --no-auth swaps the configured decider for one that allows
// everything. Logged at warn level so an operator who set this
// inadvertently sees it on every restart.
if cfg.NoAuth {
decider = policy.AllowAllDecider{}
slog.Warn("--no-auth enabled: ACL enforcement is disabled. Every request is permitted regardless of .zddc rules.")
}
slog.Info("policy decider ready",
"mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL,
"cascade_mode", cfg.CascadeMode,
"no_auth", cfg.NoAuth)
// Token store: bearer-token issuance and validation.
// Persists under <ZDDC_ROOT>/.zddc.d/tokens/ — already excluded
// from public listings (fs.ListDirectory dot-prefix filter) and
// direct serving (dispatch's reserved-prefix guard). Failures here
// are non-fatal: token-based auth is opt-in per request, and
// header-based auth keeps working without it.
tokens, err := auth.NewStore(cfg.Root)
if err != nil {
slog.Warn("could not initialise token store; bearer-token auth disabled", "err", err)
tokens = nil
}
// Innermost handler: dispatch.
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, tokens, w, r)
})
inner = handler.CORSMiddleware(cfg, inner)
// HSTS only when zddc-server itself is the TLS-terminating endpoint.
// Behind an upstream proxy terminating TLS (cfg.TLSMode=="none"), the
// proxy is responsible for HSTS — adding it here would conflict.
if useTLS {
inner = handler.HSTSMiddleware(inner)
}
inner = handler.AccessLogMiddleware(auditLogger, inner)
inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
mux.Handle("/", inner)
gzWrapper, err := newGzipWrapper()
if err != nil {
slog.Error("gzhttp wrapper init", "err", err)
os.Exit(1)
}
srv := &http.Server{
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
if useTLS {
go func() {
slog.Info("listening", "addr", cfg.Addr, "tls", true)
if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
cancel()
}
}()
} else {
go func() {
slog.Info("listening", "addr", cfg.Addr, "tls", false)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
cancel()
}
}()
}
<-ctx.Done()
slog.Info("shutting down...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown error", "err", err)
}
slog.Info("stopped")
}
// runClient is the entry point when cfg.Upstream is set — a separate
// lifecycle from the master-side main(), with no archive index, no
// apps server, no watcher, no policy decider, no ACL middleware, no
// token store. The cache layer (zddc/internal/cache) is the entire
// request handler; AccessLog + HSTS + gzip wrap it the same way they
// wrap dispatch in master mode.
func runClient(cfg config.Config) {
cacheLayer, err := cache.New(cfg)
if err != nil {
slog.Error("client mode init failed", "err", err)
os.Exit(1)
}
slog.Info("client mode active",
"upstream", cacheLayer.Upstream(),
"mode", cacheLayer.Mode(),
"no_auth", cfg.NoAuth,
"skip_tls_verify", cfg.SkipTLSVerify)
if cfg.NoAuth {
slog.Warn("--no-auth enabled: incoming requests are not ACL-checked locally; trusting upstream's filtering.")
}
// Mirror walker: only constructed when --mode=mirror with at least
// one subtree (config validation ensures a default of "/" applies
// when the operator opted into mirror without specifying). Hooks
// itself into cacheLayer.onAccess; no further wiring needed here.
if cfg.Mode == "mirror" && len(cfg.MirrorSubtree) > 0 {
sched, err := cache.NewMirrorScheduler(cacheLayer, cfg.MirrorSubtree, cfg.MirrorMinInterval, 0)
if err != nil {
slog.Error("mirror scheduler init failed", "err", err)
os.Exit(1)
}
if sched != nil {
slog.Info("mirror walker armed",
"subtrees", sched.Subtrees(),
"min_interval", sched.MinInterval())
}
}
// Outbox: persist + replay offline writes. Only enabled in cache
// or mirror modes (proxy mode doesn't persist anything by design).
// A failure here is non-fatal: writes still flow live, but
// transport errors return 503 instead of being queued.
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
if cfg.Mode != "proxy" {
outbox, err := cache.NewOutbox(cacheLayer)
if err != nil {
slog.Warn("outbox init failed; offline writes will return 503", "err", err)
} else {
cacheLayer.SetOutbox(outbox)
pending, _ := outbox.Pending()
slog.Info("outbox ready", "dir", outbox.Dir(), "pending_at_startup", len(pending))
go outbox.RunReplayLoop(ctx)
}
}
tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg)
if err != nil {
slog.Error("failed to configure TLS", "err", err)
os.Exit(1)
}
auditLogger := setupAccessAuditLog(cfg.AccessLog)
var inner http.Handler = cacheLayer
inner = handler.CORSMiddleware(cfg, inner)
if useTLS {
inner = handler.HSTSMiddleware(inner)
}
inner = handler.AccessLogMiddleware(auditLogger, inner)
mux := http.NewServeMux()
mux.Handle("/", inner)
gzWrapper, err := newGzipWrapper()
if err != nil {
slog.Error("gzhttp wrapper init", "err", err)
os.Exit(1)
}
srv := &http.Server{
Addr: cfg.Addr,
Handler: gzWrapper(mux),
TLSConfig: tlsCfg,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
if useTLS {
go func() {
slog.Info("listening", "addr", cfg.Addr, "tls", true, "client_mode", true)
if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
cancel()
}
}()
} else {
go func() {
slog.Info("listening", "addr", cfg.Addr, "tls", false, "client_mode", true)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
cancel()
}
}()
}
<-ctx.Done()
slog.Info("shutting down...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown error", "err", err)
}
slog.Info("stopped")
}
// 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.
//
// 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.
//
// 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.
// policyModeLabel collapses cfg.OPAURL to a one-word mode label for the
// startup log so operators can grep for the active decider quickly.
func policyModeLabel(opaURL string) string {
switch {
case opaURL == "" || strings.EqualFold(opaURL, "internal"):
return "internal"
case strings.HasPrefix(opaURL, "unix://"):
return "external-unix"
case strings.HasPrefix(opaURL, "https://"):
return "external-https"
case strings.HasPrefix(opaURL, "http://"):
return "external-http"
default:
return "unknown"
}
}
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
MaxBackups: 10,
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)
}
// newGzipWrapper builds the gzip middleware applied to the entire mux.
// MinSize(1024) skips compressing tiny responses where the framing
// overhead exceeds the savings (304 Not Modified, error pages, small
// JSON listings under ~1 KB). The wrapper honors Accept-Encoding (passes
// through unchanged when the client doesn't advertise gzip), appends
// Vary: Accept-Encoding automatically, and passes through 304s untouched.
// Yields ~75% size reduction on the larger embedded HTML responses
// (mdedit: 920 KB → ~250 KB on the wire).
//
// Extracted so tests can construct an equivalent wrapper without going
// through the full main() server boot.
func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) {
return gzhttp.NewWrapper(gzhttp.MinSize(1024))
}
// setupApps creates the cache + fetcher + server. No seeding, no refresh,
// no admin UI — the server fetches once on first request, caches forever
// in <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure.
func setupApps(cfg config.Config) (*apps.Server, error) {
cache, err := apps.NewCache(filepath.Join(cfg.Root, apps.CacheDirName))
if err != nil {
return nil, fmt.Errorf("create cache: %w", err)
}
fetcher := apps.NewFetcher(cache, slog.Default())
// Apps signing pubkey. Resolution order, highest priority first:
// 1. --apps-pubkey / ZDDC_APPS_PUBKEY (path to PEM file)
// 2. apps_pubkey: inline PEM in the root <ZDDC_ROOT>/.zddc file
// (root-only — same trust-anchor treatment as admins:)
// 3. nothing → URL-fetched apps refuse-by-default; only embedded
// + local-path apps work
//
// Same posture as TLS certificates: zddc-server bakes nothing in.
// Operators using zddc.varasys.io's canonical channels download
// pubkey.pem from there and either configure the path via env/flag
// or paste the PEM contents inline into root .zddc.
switch {
case cfg.AppsPubKey != "":
pub, err := apps.LoadPubKey(cfg.AppsPubKey)
if err != nil {
return nil, fmt.Errorf("apps-pubkey: %w", err)
}
fetcher.VerifyKey = pub
slog.Info("apps signing pubkey loaded", "source", "env/flag", "path", cfg.AppsPubKey)
default:
// Fall back to apps_pubkey: in root .zddc.
rootZddc, err := zddc.ParseFile(filepath.Join(cfg.Root, ".zddc"))
if err == nil && rootZddc.AppsPubKey != "" {
pub, err := apps.ParsePubKeyPEM([]byte(rootZddc.AppsPubKey))
if err != nil {
return nil, fmt.Errorf("root .zddc apps_pubkey: %w", err)
}
fetcher.VerifyKey = pub
slog.Info("apps signing pubkey loaded", "source", "root .zddc apps_pubkey")
} else {
slog.Warn("apps-pubkey not configured; URL-fetched apps will be refused (only embedded + local-path apps will work). " +
"Set --apps-pubkey, ZDDC_APPS_PUBKEY, or apps_pubkey: in the root .zddc file to a PEM Ed25519 public key you trust.")
}
}
return apps.NewServer(cfg.Root, cache, fetcher, version), nil
}
// printVersions writes the binary version + the build label of every app
// embedded into the binary. Called by --version and reused for the
// startup log line.
func printVersions(w *os.File) {
fmt.Fprintf(w, "zddc-server %s\n\n", version)
embedded := apps.EmbeddedVersions()
if len(embedded) == 0 {
fmt.Fprintln(w, "Embedded tools: (none — run `sh build.sh` to populate)")
return
}
fmt.Fprintln(w, "Embedded tools:")
keys := make([]string, 0, len(embedded))
for k := range embedded {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(w, " %-12s %s\n", k, embedded[k])
}
}
// embeddedVersionsForLog formats the embedded-versions map as a single
// short string suitable for the startup `log/slog` line. Sorted by app
// name for stable output.
func embeddedVersionsForLog(embedded map[string]string) string {
if len(embedded) == 0 {
return "(none)"
}
keys := make([]string, 0, len(embedded))
for k := range embedded {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
// Strip any " · timestamp · sha" suffix so the log line stays compact;
// operators who want full detail run `zddc-server --version`.
v := embedded[k]
if i := strings.Index(v, " "); i > 0 {
v = v[:i]
}
parts = append(parts, k+"="+v)
}
return strings.Join(parts, " ")
}
// dispatch routes a request to the appropriate handler.
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
// URL paths are case-insensitive: resolve each segment against the
// on-disk casing under cfg.Root, preferring the all-lowercase
// variant when multiple case variants exist (lowercase is the
// project-wide canonical case for folders). The walk stops at the
// first segment that doesn't exist on disk so virtual prefixes
// (.profile, .archive, .tokens) and 404 paths flow through with
// their tail preserved verbatim. Downstream handlers see the
// canonical r.URL.Path; the access log captures the as-typed path
// before this rewrite.
if absPath, canonical, ok := appfs.ResolveCanonical(cfg.Root, r.URL.Path); ok {
_ = absPath
// Restore trailing slash so directory routing (which redirects
// no-trailing-slash requests) keeps its existing semantics.
if strings.HasSuffix(r.URL.Path, "/") && !strings.HasSuffix(canonical, "/") && canonical != "/" {
canonical += "/"
}
if canonical != r.URL.Path {
r.URL.Path = canonical
r.URL.RawPath = ""
}
}
urlPath := r.URL.Path
email := handler.EmailFromContext(r)
// Profile routes — the page itself is reachable to anyone (anonymous
// included); admin-only sub-resources (whoami / config / logs /
// projects / .zddc editor) keep their existing per-resource 404
// existence-leakage gates inside ServeProfile.
if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") {
handler.ServeProfile(cfg, ring, idx, w, r)
return
}
// Token self-service: HTML page at /.tokens, JSON API at
// /.api/tokens. Both routes require an authenticated user (the
// existing email middleware injects the email from upstream auth).
// Both routes refuse to serve when no token store is available
// (e.g. NewStore failed at startup) — handled inside the handlers.
if urlPath == handler.TokensPathPrefix || urlPath == handler.TokensPathPrefix+"/" {
handler.ServeTokensPage(cfg, tokens, w, r)
return
}
if urlPath == handler.TokensAPIPathPrefix || strings.HasPrefix(urlPath, handler.TokensAPIPathPrefix+"/") {
handler.ServeTokensAPI(cfg, tokens, w, r)
return
}
// Auth check endpoints — machine-only forward_auth targets used by
// upstream proxies (e.g. the dev-shell pod's Caddy in front of
// code-server) to gate routes on root-admin status. Handled before
// the reserved-prefix guard below so the .auth namespace passes
// through without being 404'd by the dot-prefix rule.
if urlPath == handler.AuthPathPrefix+"/admin" {
handler.ServeAuthAdmin(cfg, w, r)
return
}
// Project list API: GET / with Accept: application/json
if urlPath == "/" {
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/json") {
handler.ServeProjectList(cfg, w, r)
return
}
}
// Split path into segments
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
// Per-directory .zddc editor: <dir>/.zddc.html is a virtual URL
// served by the existing form-based editor (same handler that
// powers /.profile/zddc/edit?path=<dir>). Routed BEFORE the
// dot-prefix guard so the leaf segment isn't 404'd. The handler
// itself gates on hasAnyAdminScope; non-admins see 404.
if handler.IsZddcEditorRequest(urlPath) {
handler.ServeZddcEditorAtPath(cfg, w, r)
return
}
// Raw .zddc YAML view: <dir>/.zddc is reachable at every depth
// and returns the on-disk file's bytes (Content-Type: application/yaml)
// or — when no file exists — a synthetic placeholder body with a
// cascade summary so the user can see what's effective here.
// GET/HEAD only; writes go through the admin-gated .zddc.html
// form. Also carved out of the dot-prefix guard.
if handler.IsZddcFileRequest(urlPath) {
handler.ServeZddcFile(cfg, w, r)
return
}
// Reserve dot-prefixed path segments. The listing pipeline already hides
// hidden entries (internal/listing/listing.go:17, projectshandler.go:40),
// but direct URL access would still serve them. 404 here so hidden trees
// like /srv/.devshell (the in-image dev-shell's persistent home dir on
// the same Azure Files PVC as served data) cannot be fetched. The
// recognized virtual prefixes (.profile handled above, cfg.IndexPath
// handled below) are explicitly allowed through.
//
// Also reserve the apps cache directory (`_app`): the cached HTML files
// there must be served via the apps resolver (with proper headers and
// ACL), never raw at /_app/...html.
for _, seg := range segments {
if seg == "" {
continue
}
if seg == apps.CacheDirName {
http.NotFound(w, r)
return
}
if !strings.HasPrefix(seg, ".") {
continue
}
if seg == cfg.IndexPath {
continue
}
http.NotFound(w, r)
return
}
// Check for .archive segment in the path. .archive is project-scoped
// and addressed at exactly one depth — /<project>/.archive/... — even
// though offline-built HTML files reference siblings via
// "../.archive/<tracking>.html" from arbitrary depths. Any deeper form
// (/<project>/<sub>/.../.archive/...) gets a 302 to the project-rooted
// canonical so anchored links and bookmarks normalize to a single
// stable URL per tracking number. The redirect target preserves the
// path tail after .archive/ verbatim and the query string; browsers
// preserve the fragment automatically across redirects.
//
// .archive is read-only: only GET/HEAD reach the handler. Anything
// else (PUT/POST/DELETE) returns 405 here, before the file API would
// otherwise see the request. This avoids the 302→GET silent-method-
// downgrade trap and makes the contract explicit.
for i, seg := range segments {
if seg != cfg.IndexPath {
continue
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// segments[0] is the project; segments[i] is .archive. i==0
// means /.archive/... at the very root, with no project to
// scope by — 404 (a tracking-number reference must be project-
// rooted; cross-project tracking-number collisions otherwise
// silently pick a winner).
if i == 0 {
http.NotFound(w, r)
return
}
project := segments[0]
var filename string
if i+1 < len(segments) {
filename = strings.Join(segments[i+1:], "/")
}
// Canonicalize anything below /<project>/.archive/. Building
// the target by hand (rather than re-encoding) keeps any
// already-encoded characters in the original URL.RawPath
// trailing segments intact for the browser to follow.
if i > 1 {
target := "/" + project + "/" + cfg.IndexPath + "/" + filename
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusFound)
return
}
handler.ServeArchive(cfg, idx, w, r, project, filename)
return
}
// Tables-system intercept: *.table.html is a virtual URL that the
// table handler renders inline, reading rows from a directory of
// *.yaml files declared in the directory's .zddc tables: map.
// Discovery is .zddc-declarative — no auto-mount on file presence —
// so RecognizeTableRequest returns nil whenever there's no matching
// declaration and the URL falls through to the static-file path
// (or to the form intercept below for *.form.html / *.yaml.html).
//
// One exception: archive/<party>/mdl.table.html falls back to the
// embedded default MDL spec when no operator declaration exists.
// RecognizeTableRequest implements that fallback internally.
if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil {
handler.ServeTable(cfg, tableReq, w, r)
return
}
// Form-system intercept: *.form.html and *.yaml.html under a sibling form
// folder are virtual URLs that the form handler renders inline, reading
// the underlying *.form.yaml spec (and, for re-edit, the *.yaml data) from
// disk. RecognizeFormRequest returns nil when the spec doesn't exist, so
// non-form .html URLs fall through to the static-file path below.
if formReq := handler.RecognizeFormRequest(cfg.Root, r.Method, urlPath); formReq != nil {
handler.ServeForm(cfg, formReq, w, r)
return
}
// File API — authenticated CRUD over the served tree. Catches PUT,
// DELETE, and POST on any non-reserved path. Read methods (GET/HEAD)
// fall through to the static / apps / directory pipeline below.
// Forms and .profile/.archive POSTs are already routed above this
// point so they take precedence.
if handler.IsWriteMethod(r.Method) {
handler.ServeFileAPI(cfg, w, r)
return
}
// Apps resolution for the root landing path: GET / or /index.html with
// no real index.html on disk → serve via apps.Serve("landing"). The
// other four apps are caught by the "stat fails → app HTML?" branch
// below, which only triggers when no concrete file is at the URL path.
//
// The landing page is intentionally public (no ACL gate). It's a
// project picker — the per-project ACL filtering done by
// fs.ListDirectory still hides projects an anonymous (or unauthorized)
// caller can't reach. See also handler.ServeDirectory's matching
// root-path bypass.
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") {
realIndex := filepath.Join(cfg.Root, "index.html")
if _, err := os.Stat(realIndex); os.IsNotExist(err) {
chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root)
if apps.AppAvailableAt(cfg.Root, cfg.Root, "landing") {
appsSrv.Serve(w, r, "landing", chain, cfg.Root)
return
}
}
}
// Resolve the physical path
cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))
absPath := filepath.Join(cfg.Root, cleanPath)
// Guard against path traversal
if !strings.HasPrefix(absPath, cfg.Root+string(filepath.Separator)) && absPath != cfg.Root {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// Check filesystem
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
// Default MDL spec fallback: archive/<party>/mdl.table.yaml
// and archive/<party>/mdl.form.yaml are served from embedded
// bytes when no operator file exists on disk. The table app
// fetches these client-side; the fallback lets a fresh
// project work out of the box.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-ZDDC-Source", "default-mdl-spec")
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(bytes)
return
}
}
// File doesn't exist at this path. If the URL matches one of
// the canonical app HTML names AND the request directory is
// one where that app is available (working/staging/incoming
// for classifier, working for mdedit, staging for
// transmittal, anywhere for archive, root only for landing),
// resolve via the apps subsystem.
if appsSrv != nil {
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
appsSrv.Serve(w, r, app, chain, requestDir)
return
}
}
}
// Reviewing aggregator. <project>/reviewing/[<tracking>/] is
// a virtual view. The shape rule mirrors the other canonical
// folders (slash → browse, no-slash → default tool):
// - JSON request, any depth → aggregator listing (handler.ServeReviewing)
// - HTML, no slash → mdedit (default tool, via DefaultAppAt)
// - HTML, with slash → browse.html (via ServeDirectory).
// browse fetches JSON which routes back
// through here to ServeReviewing.
// Depth-3 no-slash (reviewing/<tracking>) 302s to the slash form.
// Depth-2 no-slash (reviewing) falls through to the canonical-
// folder block below where DefaultAppAt routes to mdedit.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
if !strings.HasSuffix(urlPath, "/") {
if tracking != "" {
http.Redirect(w, r, urlPath+"/", http.StatusFound)
return
}
// Depth-2 no-slash falls through to canonical-folder block.
} else if strings.Contains(r.Header.Get("Accept"), "application/json") {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, proj))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeReviewing(cfg, w, r, proj, tracking, sidePath)
return
}
// HTML trailing-slash falls through to canonical-folder
// block → ServeDirectory → embedded browse.html.
}
}
// Per-party canonical folders at archive/<party>/{mdl,incoming,
// received,issued}[/]. The on-disk folder may not exist yet —
// dispatch lands on a usable view regardless:
// - no-slash mdl → tables app (default-MDL fallback)
// - no-slash else → not yet a registered tool → falls
// through to 302 (slash form)
// - slash, all → browse (ServeDirectory → fs.ListDirectory
// empty-listing fallback)
if r.Method == http.MethodGet || r.Method == http.MethodHead {
rel := strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")
if !strings.HasSuffix(urlPath, "/") {
synth := urlPath + "/table.html"
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeTable(cfg, tr, w, r)
return
}
// No-slash forms for non-mdl party folders 302 to
// the slash form so they consistently land on the
// browse listing.
if zddc.IsArchivePartyFolder(rel) {
http.Redirect(w, r, urlPath+"/", http.StatusFound)
return
}
} else if zddc.IsArchivePartyFolder(rel) {
handler.ServeDirectory(cfg, appsSrv, w, r)
return
}
}
// Canonical project-root folder fallback. <project>/{archive,
// working,staging,reviewing}[/] should land on a usable view
// (default tool or empty listing) rather than 404, so the
// stage-strip nav works on a fresh project that hasn't yet
// been written to. Two shapes:
//
// <project>/working → mdedit rooted at working/
// (matches the existing IsDir branch
// for an existing folder)
// <project>/working/ → ServeDirectory → fs.ListDirectory
// returns 200 + [] for the empty case
//
// reviewing/ has no default app, so the no-slash form 302s
// to the slash form.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
zddc.IsProjectRootFolder(strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) {
if !strings.HasSuffix(urlPath, "/") {
if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil {
if apps.AppAvailableAt(cfg.Root, absPath, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
appsSrv.Serve(w, r, app, chain, absPath)
return
}
}
// No default app (reviewing/) — redirect to slash form.
http.Redirect(w, r, urlPath+"/", http.StatusFound)
return
}
handler.ServeDirectory(cfg, appsSrv, w, r)
return
}
http.Error(w, "Not Found", http.StatusNotFound)
} else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if info.IsDir() {
// ACL check — bypassed at the root path so the landing page (the
// project picker) is reachable by anyone, including anonymous.
// Per-project filtering happens inside ServeDirectory →
// fs.ListDirectory, which hides directories the caller can't
// reach. Subdirectory requests still hit this gate.
isRoot := urlPath == "/"
if !isRoot {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
// URL convention: trailing slash → browse (handled by
// ServeDirectory, which serves browse.html for HTML requests
// and JSON for application/json). No trailing slash → the
// canonical default tool for this directory's context, if any
// (mdedit under working/, transmittal under staging/, archive
// under archive/, tables under archive/<party>/mdl/). When no
// default applies, fall back to the historical redirect-to-
// trailing-slash behaviour.
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
switch apps.DefaultAppAt(cfg.Root, absPath) {
case "tables":
// Tables aren't an apps-subsystem app — the table
// handler responds to /<dir>/table.html. Serve the
// equivalent table view inline at the bare URL by
// synthesizing the canonical /table.html suffix.
synth := urlPath + "/table.html"
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
handler.ServeTable(cfg, tr, w, r)
return
}
case "archive", "transmittal", "mdedit":
if appsSrv != nil {
app := apps.DefaultAppAt(cfg.Root, absPath)
if apps.AppAvailableAt(cfg.Root, absPath, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
appsSrv.Serve(w, r, app, chain, absPath)
return
}
}
}
}
// Project root (depth-1 dir, no trailing slash) serves the
// landing tool, which detects mode='project' from
// location.pathname and renders the lifecycle-stage cards +
// MDL section. Same single-file SPA as the deployment-root
// project picker — one tool, two URL shapes. With trailing
// slash, the project falls through to the regular browse
// listing.
if !strings.HasSuffix(urlPath, "/") &&
(r.Method == http.MethodGet || r.Method == http.MethodHead) &&
handler.IsProjectRootURL(urlPath) {
if appsSrv != nil {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
appsSrv.Serve(w, r, "landing", chain, absPath)
return
}
}
if !strings.HasSuffix(urlPath, "/") {
http.Redirect(w, r, urlPath+"/", http.StatusFound)
return
}
handler.ServeDirectory(cfg, appsSrv, w, r)
return
}
// Regular file: ACL on parent directory
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeFile(w, r, absPath)
}
// runPeriodicRescan calls idx.Rebuild on `interval` until ctx is cancelled.
// Each tick walks fsRoot from scratch and atomically replaces the live index;
// concurrent reads are safe via the index's RWMutex. Errors are logged but do
// not stop the loop — a transient walk failure shouldn't disable rescans.
func runPeriodicRescan(ctx context.Context, fsRoot string, idx *archive.Index, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
slog.Info("archive periodic rescan started", "interval", interval)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
dur, projects, tracking, err := idx.Rebuild(fsRoot)
if err != nil {
slog.Warn("archive rescan failed", "err", err, "duration", dur)
continue
}
slog.Debug("archive rescan ok", "duration", dur, "projects", projects, "tracking", tracking)
}
}
}
// setupLogger installs a slog default that fans every record out to stderr
// (the existing TextHandler — user-visible logging is unchanged) AND to an
// in-memory ring buffer that backs the /.profile/logs endpoint. Returns
// the ring so handlers can read it.
func setupLogger(level string) *handler.LogRing {
var l slog.Level
switch strings.ToLower(level) {
case "debug":
l = slog.LevelDebug
case "warn":
l = slog.LevelWarn
case "error":
l = slog.LevelError
default:
l = slog.LevelInfo
}
ring := handler.NewLogRing(500)
text := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l})
rh := handler.NewRingHandler(ring, l)
slog.SetDefault(slog.New(handler.NewMultiHandler(text, rh)))
return ring
}