// Package server wires repospawner's HTTP surface: the request API, the health // probes and the embedded UI, plus the reconcile loop that drives requests // forward. package server import ( "context" "encoding/json" "errors" "io" "io/fs" "log/slog" "net/http" "os" "strings" "time" "git.unkin.net/unkin/repospawner/internal/auth" "git.unkin.net/unkin/repospawner/internal/config" "git.unkin.net/unkin/repospawner/internal/gitea" "git.unkin.net/unkin/repospawner/internal/jobs" "git.unkin.net/unkin/repospawner/internal/repospec" "git.unkin.net/unkin/repospawner/internal/store" ) // maxBodyBytes caps a submitted request body; the payload is four small fields. const maxBodyBytes = 64 << 10 // Server holds the resolved dependencies of the app. type Server struct { cfg *config.Config store *store.Store forge *gitea.Client cluster Cluster gate *auth.Middleware assets fs.FS log *slog.Logger } // New constructs a Server. func New(cfg *config.Config, st *store.Store, forge *gitea.Client, cluster Cluster, assets fs.FS, log *slog.Logger) *Server { if log == nil { log = slog.Default() } return &Server{ cfg: cfg, store: st, forge: forge, cluster: cluster, gate: auth.New(cfg.GroupsHeader, cfg.AllowedGroups), assets: assets, log: log, } } // Handler returns the root handler. Health probes are ungated (kubelet sends no // identity header); everything else — API and UI alike — sits behind the group // gate, so an unauthorized user cannot even load the page shell. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) mux.HandleFunc("GET /readyz", s.readyz) gated := http.NewServeMux() gated.HandleFunc("POST /api/requests", s.handleCreate) gated.HandleFunc("GET /api/requests", s.handleList) gated.HandleFunc("GET /api/requests/{id}", s.handleGet) gated.HandleFunc("GET /api/capabilities", s.handleCapabilities) gated.HandleFunc("/", s.handleUI) mux.Handle("/", s.gate.Wrap(gated)) return secureHeaders(mux) } // cspPolicy locks the page to same-origin code. The UI carries no inline script // or style, so no unsafe-inline escape hatch is needed; data: is in img-src // solely for the inline SVG favicon. const cspPolicy = "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'" // secureHeaders stamps the browser-facing hardening headers onto every // response — API, UI and probes alike — before the handler writes. func secureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", cspPolicy) h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") next.ServeHTTP(w, r) }) } func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() if err := s.cluster.Ping(ctx); err != nil { s.log.Warn("readyz: kubernetes api unreachable", "err", err) http.Error(w, "kubernetes api unreachable", http.StatusServiceUnavailable) return } _, _ = w.Write([]byte("ok")) } // woodpeckerAvailable reports whether a Woodpecker API token is mounted. It is // read per call so a later secret mount needs no restart. func (s *Server) woodpeckerAvailable() bool { if s.cfg.WoodpeckerTokenFile == "" { return false } b, err := os.ReadFile(s.cfg.WoodpeckerTokenFile) return err == nil && strings.TrimSpace(string(b)) != "" } func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "woodpecker": s.woodpeckerAvailable(), "tfgit_repo": s.cfg.TFGitRepo, }) } func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { var spec repospec.Request dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes)) dec.DisallowUnknownFields() if err := dec.Decode(&spec); err != nil { writeErr(w, http.StatusBadRequest, "request body is not the expected JSON object") return } spec = spec.Normalize() if err := spec.Validate(); err != nil { var fe repospec.FieldErrors if errors.As(err, &fe) { writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid request", "fields": fe}) return } writeErr(w, http.StatusBadRequest, "invalid request") return } if spec.Woodpecker && !s.woodpeckerAvailable() { writeErr(w, http.StatusServiceUnavailable, "woodpecker enablement is unavailable: no woodpecker API token is mounted; resubmit with woodpecker disabled") return } if s.store.HasActiveName(spec.Name) { writeErr(w, http.StatusConflict, "a request for that repository name is already in flight") return } exists, err := s.forge.FileExists(r.Context(), s.cfg.TFGitRepo, spec.ConfigPath(), "main") if err != nil { s.log.Error("terraform-git name check failed", "name", spec.Name, "err", err) writeErr(w, http.StatusBadGateway, "cannot check the repository name against terraform-git") return } if exists { writeErr(w, http.StatusConflict, "that repository is already defined in terraform-git") return } req := store.NewRequest(store.NewID(), spec, s.store.Now()) if err := s.cluster.CreateJob(r.Context(), jobs.PR(s.cfg, req)); err != nil { s.log.Error("create pull request job", "request", req.ID, "err", err) writeErr(w, http.StatusInternalServerError, "cannot start the pull request job") return } s.store.Put(req) s.log.Info("accepted repo request", "request", req.ID, "name", req.Name, "woodpecker", req.Woodpecker) w.Header().Set("Location", "/api/requests/"+req.ID) writeJSON(w, http.StatusAccepted, map[string]any{ "id": req.ID, "status_url": "/api/requests/" + req.ID, "state": req.State, }) } func (s *Server) handleList(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"requests": s.store.List()}) } func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) { req, ok := s.store.Get(r.PathValue("id")) if !ok { writeErr(w, http.StatusNotFound, "no such request") return } writeJSON(w, http.StatusOK, req) } // handleUI serves the embedded assets, falling back to index.html so a reload // on any path lands on the app. func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/") if strings.HasPrefix(p, "api/") { writeErr(w, http.StatusNotFound, "not found") return } if p != "" { if st, err := fs.Stat(s.assets, p); err == nil && !st.IsDir() { http.FileServerFS(s.assets).ServeHTTP(w, r) return } } b, err := fs.ReadFile(s.assets, "index.html") if err != nil { http.Error(w, "index missing", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(b) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) }