Three improvements bundled because they all ship as zddc-server v0.0.2: * /.admin/ debug dashboard with /whoami, /config, /logs sub-routes. Authorization via a top-level `admins:` glob list in <ZDDC_ROOT>/.zddc (root-only — subdir entries deliberately ignored to prevent privilege escalation via subtree write access). Non-admin requests get 404 so the page is invisible. Recent logs surface via a 500-entry slog ring buffer teed off the existing TextHandler. Lets operators debug without kubectl exec. * Default ZDDC_EMAIL_HEADER changes from `X-Email` to `X-Auth-Request-Email` — the oauth2-proxy / nginx auth-request convention that the TND helm chart already sets explicitly. Operators who set the env var explicitly are unaffected; deployments relying on the previous default need to set ZDDC_EMAIL_HEADER=X-Email or update their proxy. * dispatch() rejects any URL whose segments contain a dot prefix other than the recognized virtual prefixes (.admin, cfg.IndexPath / .archive). Matches the existing listing-pipeline filter so hidden subtrees on the served PVC (e.g. /srv/.devshell — used by the in-cluster dev-shell for persistent home-dir state) become unreachable via direct HTTP fetch, not just hidden in listings. Refreshes the X-Email reference in website/index.html accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
126 lines
4.2 KiB
Go
126 lines
4.2 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
|
|
}
|
|
|
|
// 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(),
|
|
}
|
|
|
|
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
|
|
}
|