Scaffold tomswallapi control-plane service
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// mountResources wires the Terraform-facing CRUD endpoints for every resource in
|
||||
// the model. Handlers are stubbed pending the storage layer (see task: domain
|
||||
// model + Postgres storage).
|
||||
func (s *Server) mountResources(r chi.Router) {
|
||||
for _, name := range resourceCollections {
|
||||
r.Route("/"+name, func(r chi.Router) {
|
||||
r.Get("/", s.notImplemented)
|
||||
r.Post("/", s.notImplemented)
|
||||
r.Get("/{id}", s.notImplemented)
|
||||
r.Put("/{id}", s.notImplemented)
|
||||
r.Delete("/{id}", s.notImplemented)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// resourceCollections are the REST collection paths exposed to Terraform, one per
|
||||
// model resource.
|
||||
var resourceCollections = []string{
|
||||
"fabrics",
|
||||
"zones",
|
||||
"address-groups",
|
||||
"portgroups",
|
||||
"policies",
|
||||
"rules",
|
||||
"devices",
|
||||
"bindings",
|
||||
"snat",
|
||||
"netmap",
|
||||
"nat",
|
||||
"blrules",
|
||||
"conntrack",
|
||||
"hosts",
|
||||
"providers",
|
||||
"routes",
|
||||
"routing-rules",
|
||||
"tunnels",
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceConfig(w http.ResponseWriter, r *http.Request) {
|
||||
s.notImplemented(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.notImplemented(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) notImplemented(w http.ResponseWriter, _ *http.Request) {
|
||||
writeError(w, http.StatusNotImplemented, "not implemented yet")
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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})
|
||||
}
|
||||
Reference in New Issue
Block a user