142 lines
3.8 KiB
Go
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})
|
|
}
|