// Package server wires the tomswallapi HTTP API: health, the Terraform-facing // read/write endpoints, and the per-device config endpoint agents pull from. package server import ( "context" "encoding/json" "errors" "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "git.unkin.net/unkin/tomswallapi/internal/database" ) // Options configures a Server. type Options struct { DB *database.DB WriteToken string AgentToken string Version string } // Server serves the tomswallapi HTTP API. type Server struct { db *database.DB writeToken string agentToken string version string } // New constructs a Server. func New(o Options) *Server { return &Server{ db: o.DB, writeToken: o.WriteToken, agentToken: o.AgentToken, version: o.Version, } } // ListenAndServe starts the HTTP server and blocks until ctx is cancelled, then // shuts down gracefully. func (s *Server) ListenAndServe(ctx context.Context, addr string) error { srv := &http.Server{ Addr: addr, Handler: s.routes(), ReadHeaderTimeout: 10 * time.Second, } errCh := make(chan error, 1) go func() { slog.Info("listening", "addr", addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() select { case err := <-errCh: return err case <-ctx.Done(): slog.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) } } func (s *Server) routes() http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Get("/healthz", s.handleHealth) r.Get("/version", s.handleVersion) // Terraform-facing read/write API. Mutations require the write token. r.Route("/api/v1", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(s.requireToken(s.writeToken)) s.mountResources(r) }) // Per-device config endpoint the tomswall agents pull from. r.Group(func(r chi.Router) { r.Use(s.requireToken(s.agentToken)) r.Get("/devices/{name}/config", s.handleDeviceConfig) r.Post("/devices/{name}/status", s.handleDeviceStatus) }) }) return r } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if err := s.db.Pool.Ping(r.Context()); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "db_unavailable"}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"version": s.version}) } // requireToken returns middleware enforcing a bearer token. An empty configured // token disables the guarded group (returns 503) so a misconfigured deploy fails // closed on writes rather than serving them unauthenticated. func (s *Server) requireToken(want string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if want == "" { writeError(w, http.StatusServiceUnavailable, "endpoint disabled: token not configured") return } const prefix = "Bearer " auth := r.Header.Get("Authorization") if len(auth) <= len(prefix) || auth[:len(prefix)] != prefix || auth[len(prefix):] != want { writeError(w, http.StatusUnauthorized, "invalid or missing token") return } next.ServeHTTP(w, r) }) } } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) }