Files
tomswallapi/internal/server/server.go
T
2026-07-19 13:31:14 +10:00

142 lines
3.8 KiB
Go

// 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})
}