ZDDC/zddc/internal/config/config.go
ZDDC 9ef90800b1 feat(zddc-server): admin debug page + X-Auth-Request-Email default + hidden-segment guard
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>
2026-04-28 14:02:06 -05:00

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
}