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>
132 lines
4.4 KiB
Go
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
|
|
}
|