// Package server provides the HTTP API for the foreman daemon. // // Why: foreman exposes a native Ollama-compatible API plus async job endpoints; // centralizing routing and middleware here keeps cmd/foreman thin. // What: creates a stdlib net/http server with health checks, optional bearer-token // auth, and an extensible mux for later phases. // Test: start the server with httptest, hit /healthz, verify 200; set a token, // verify 401 without it. package server import ( "encoding/json" "log/slog" "net/http" "strings" "gitea.stevedudenhoeffer.com/steve/foreman/internal/config" "gitea.stevedudenhoeffer.com/steve/foreman/internal/store" ) // Server holds the HTTP server and its dependencies. type Server struct { cfg config.Config store *store.Store mux *http.ServeMux logger *slog.Logger } // New creates a new Server with the given configuration and store. The mux is // populated with initial routes; callers can add more before calling ListenAndServe. // // Why: dependency injection makes the server testable and extensible. // What: wires config, store, and logger into the server, registers routes. // Test: create with New, use httptest to exercise routes. func New(cfg config.Config, st *store.Store, logger *slog.Logger) *Server { s := &Server{ cfg: cfg, store: st, mux: http.NewServeMux(), logger: logger, } s.routes() return s } // Handler returns the server's http.Handler, with auth middleware applied. // // Why: allows httptest usage in tests without starting a real listener. // What: wraps the mux with optional bearer-token middleware. // Test: call Handler(), use httptest.NewServer, exercise endpoints. func (s *Server) Handler() http.Handler { var h http.Handler = s.mux if s.cfg.Token != "" { h = s.authMiddleware(h) } return h } // ListenAndServe starts the HTTP server on the configured address. func (s *Server) ListenAndServe() error { s.logger.Info("starting server", "addr", s.cfg.Addr) return http.ListenAndServe(s.cfg.Addr, s.Handler()) } // routes registers all HTTP routes on the mux. func (s *Server) routes() { s.mux.HandleFunc("GET /healthz", s.handleHealthz) } // healthResponse is the JSON shape returned by /healthz. type healthResponse struct { Status string `json:"status"` Degraded bool `json:"degraded"` } // handleHealthz returns the daemon's health status. The degraded flag is a // placeholder for the model poller's connectivity state (Phase 2). func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(healthResponse{ Status: "ok", Degraded: false, }) } // authMiddleware validates the Authorization: Bearer header on all // requests except /healthz. Returns 401 if the token is missing or wrong. func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // /healthz is always public so load balancers and probes work without auth. if r.URL.Path == "/healthz" { next.ServeHTTP(w, r) return } auth := r.Header.Get("Authorization") if auth == "" { http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized) return } const prefix = "Bearer " if !strings.HasPrefix(auth, prefix) { http.Error(w, `{"error":"invalid authorization header"}`, http.StatusUnauthorized) return } token := strings.TrimPrefix(auth, prefix) if token != s.cfg.Token { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) }