ZDDC/zddc/cmd/zddc-server/main.go
ZDDC 6cc0d2ae27 feat(zddc-server): /.auth/admin forward_auth endpoint
A machine-only HTTP endpoint that returns 200 if the request's
X-Auth-Request-Email is in the root .zddc admins: list, 403 otherwise.
No body, no redirect — pure authorization decision intended to be
polled by an upstream proxy's forward_auth directive.

The motivating use case is gating /devshell/* (code-server) in the
dev-shell pod on root-admin status before the request ever reaches
code-server, which has no built-in ACL of its own. zddc-server's
own routes keep the existing .zddc cascade ACL and don't go through
this endpoint.

Reuses zddc.IsAdmin (one cached map lookup) so the check is cheap
enough to call on every request. Edits to /srv/.zddc propagate via
the existing fsnotify watcher's policy-cache invalidation.

Tests cover empty email, non-admin, admin, and the bootstrap state
where no root .zddc exists (deny everyone — the safe default).

Docs: zddc/README.md "Forward-auth target for upstream proxies"
section + AGENTS.md notes bullet.

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

390 lines
12 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/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// 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() {
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))
// 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
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)
}
}()
}
// 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.
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r)
})))))
srv := &http.Server{
Addr: cfg.Addr,
Handler: mux,
TLSConfig: tlsCfg,
}
// 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")
}
// 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())
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, w http.ResponseWriter, r *http.Request) {
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, 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, "/"), "/")
// 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
for i, seg := range segments {
if seg == cfg.IndexPath {
// contextPath is everything before .archive
contextPath := "/" + strings.Join(segments[:i], "/")
var filename string
if i+1 < len(segments) {
filename = strings.Join(segments[i+1:], "/")
}
handler.ServeArchive(cfg, idx, w, r, contextPath, filename)
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.
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 !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
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) {
// File doesn't exist at this path. If the URL matches one of
// the five canonical app HTML names AND the request directory
// is one where that app is available (Incoming/Working/Staging
// for classifier/mdedit/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 !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
appsSrv.Serve(w, r, app, chain, requestDir)
return
}
}
}
http.Error(w, "Not Found", http.StatusNotFound)
} else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if info.IsDir() {
// ACL check
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !strings.HasSuffix(urlPath, "/") {
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
return
}
handler.ServeDirectory(cfg, w, r)
return
}
// Regular file: ACL on parent directory
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeFile(w, r, absPath)
}
// 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
}