ZDDC/zddc/cmd/zddc-server/main.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

231 lines
6.4 KiB
Go

package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"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"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "configuration error: %v\n", err)
os.Exit(1)
}
logRing := setupLogger(cfg.LogLevel)
slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr)
// 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))
// 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()
mux.Handle("/", handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, handler.ACLMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, 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")
}
// dispatch routes a request to the appropriate handler.
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
email := handler.EmailFromContext(r)
// Admin debug routes — gated by IsAdmin allowlist in <root>/.zddc.
// Non-admins receive 404 (not 403) so the existence of the admin page
// is invisible to unauthorized callers.
if urlPath == handler.AdminPathPrefix || strings.HasPrefix(urlPath, handler.AdminPathPrefix+"/") {
handler.ServeAdmin(cfg, ring, 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 (.admin handled above, cfg.IndexPath
// handled below) are explicitly allowed through.
for _, seg := range segments {
if seg == "" || !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
}
}
// 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) {
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 /.admin/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
}