Scaffold tomswallapi control-plane service
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Package config loads tomswallapi runtime configuration from the environment.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration, sourced from environment variables.
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBSSLMode string
|
||||
|
||||
// WriteToken guards all mutating API endpoints (used by the Terraform provider).
|
||||
WriteToken string
|
||||
// AgentToken guards the per-device config endpoint (used by tomswall agents).
|
||||
AgentToken string
|
||||
|
||||
// IPLocateAPIKey is used to expand ASN address groups into prefixes.
|
||||
IPLocateAPIKey string
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying defaults.
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
ListenAddr: env("TOMSWALLAPI_LISTEN_ADDR", ":8000"),
|
||||
DBHost: env("TOMSWALLAPI_DB_HOST", "localhost"),
|
||||
DBPort: env("TOMSWALLAPI_DB_PORT", "5432"),
|
||||
DBUser: env("TOMSWALLAPI_DB_USER", "tomswallapi"),
|
||||
DBPassword: os.Getenv("TOMSWALLAPI_DB_PASSWORD"),
|
||||
DBName: env("TOMSWALLAPI_DB_NAME", "tomswallapi"),
|
||||
DBSSLMode: env("TOMSWALLAPI_DB_SSLMODE", "disable"),
|
||||
WriteToken: os.Getenv("TOMSWALLAPI_WRITE_TOKEN"),
|
||||
AgentToken: os.Getenv("TOMSWALLAPI_AGENT_TOKEN"),
|
||||
IPLocateAPIKey: os.Getenv("TOMSWALLAPI_IPLOCATE_API_KEY"),
|
||||
}
|
||||
if c.DBName == "" {
|
||||
return nil, fmt.Errorf("TOMSWALLAPI_DB_NAME must not be empty")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DatabaseDSN builds a libpq-style connection string.
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(c.DBUser, c.DBPassword),
|
||||
Host: fmt.Sprintf("%s:%s", c.DBHost, c.DBPort),
|
||||
Path: c.DBName,
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("sslmode", c.DBSSLMode)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package database provides the Postgres connection pool and schema migrations
|
||||
// for tomswallapi.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// DB wraps a pgx connection pool.
|
||||
type DB struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New opens a connection pool and verifies connectivity.
|
||||
func New(ctx context.Context, dsn string) (*DB, error) {
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("pinging database: %w", err)
|
||||
}
|
||||
return &DB{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases the pool.
|
||||
func (db *DB) Close() { db.Pool.Close() }
|
||||
|
||||
// Migrate applies any pending embedded SQL migrations in lexical order. Each
|
||||
// migration file is recorded in schema_migrations and applied at most once.
|
||||
func (db *DB) Migrate(ctx context.Context) error {
|
||||
if _, err := db.Pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("creating schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading migrations: %w", err)
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, name := range files {
|
||||
var exists bool
|
||||
if err := db.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name,
|
||||
).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("checking migration %s: %w", name, err)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if err := pgx.BeginFunc(ctx, db.Pool, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, string(body)); err != nil {
|
||||
return fmt.Errorf("applying %s: %w", name, err)
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Initial tomswallapi schema: fleet-global objects + per-device layer.
|
||||
-- See DESIGN.md (tomswall repo) for the model this implements.
|
||||
|
||||
-- Global settings: a single row of fleet-wide defaults.
|
||||
CREATE TABLE settings (
|
||||
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), -- singleton
|
||||
address_family TEXT NOT NULL DEFAULT 'inet',
|
||||
log_level TEXT NOT NULL DEFAULT 'info',
|
||||
ip_forwarding BOOLEAN NOT NULL DEFAULT true,
|
||||
table_name TEXT NOT NULL DEFAULT 'tomswall',
|
||||
default_resolver JSONB NOT NULL DEFAULT '[]'::jsonb -- ["10.0.0.53"] or "system"
|
||||
);
|
||||
INSERT INTO settings (id) VALUES (true);
|
||||
|
||||
-- Routing domains. enforce_on_routers toggles defense-in-depth vs transparent transit.
|
||||
CREATE TABLE fabrics (
|
||||
name TEXT PRIMARY KEY,
|
||||
enforce_on_routers BOOLEAN NOT NULL DEFAULT false,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Fleet-global zones. subnets is a list of CIDRs. parent gives subzone nesting.
|
||||
CREATE TABLE zones (
|
||||
name TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'ip', -- ip | ip6 | firewall
|
||||
subnets JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
parent TEXT REFERENCES zones(name) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
-- Address groups materialize nftables named sets. type drives population source.
|
||||
CREATE TABLE address_groups (
|
||||
name TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK (type IN ('static', 'dns', 'asn')),
|
||||
members JSONB NOT NULL DEFAULT '[]'::jsonb, -- static: CIDRs; dns: FQDNs; asn: ASN numbers
|
||||
refresh TEXT NOT NULL DEFAULT '', -- asn: cache TTL (e.g. 24h); dns: honor_ttl
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Reusable port+proto combos.
|
||||
CREATE TABLE portgroups (
|
||||
name TEXT PRIMARY KEY,
|
||||
proto TEXT NOT NULL,
|
||||
ports JSONB NOT NULL DEFAULT '[]'::jsonb
|
||||
);
|
||||
|
||||
-- Default zone-to-zone policies. priority orders evaluation (first match wins).
|
||||
CREATE TABLE policies (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL,
|
||||
dest TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
log TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Fleet-global intents. source/dest use the shorewall-style element list
|
||||
-- (bare zone, or zone:+ipset / zone:&fqdn). Stored as JSONB element arrays.
|
||||
CREATE TABLE rules (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
action TEXT NOT NULL,
|
||||
source JSONB NOT NULL DEFAULT '[]'::jsonb, -- ["loc", "net:+asn_cloudflare"]
|
||||
dest JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
proto TEXT NOT NULL DEFAULT '',
|
||||
portgroup TEXT REFERENCES portgroups(name) ON DELETE RESTRICT,
|
||||
ports JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
log TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Devices in the fleet.
|
||||
CREATE TABLE devices (
|
||||
name TEXT PRIMARY KEY,
|
||||
class TEXT NOT NULL CHECK (class IN ('router', 'firewall')),
|
||||
fabric TEXT REFERENCES fabrics(name) ON DELETE SET NULL,
|
||||
resolver JSONB NOT NULL DEFAULT '[]'::jsonb, -- per-device DNS resolver override
|
||||
settings JSONB NOT NULL DEFAULT '{}'::jsonb, -- per-device settings overrides
|
||||
reported_generation BIGINT NOT NULL DEFAULT 0, -- last generation the agent applied
|
||||
last_seen TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- The per-device zone->interface binding table (the only host-specific object).
|
||||
CREATE TABLE bindings (
|
||||
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
|
||||
zone TEXT NOT NULL REFERENCES zones(name) ON DELETE CASCADE,
|
||||
interfaces JSONB NOT NULL DEFAULT '[]'::jsonb, -- ["eth1"] or ["bond0.40"]
|
||||
PRIMARY KEY (device, zone)
|
||||
);
|
||||
|
||||
-- Monotonic generation counter bumped on any config-affecting change.
|
||||
CREATE TABLE generation (
|
||||
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), -- singleton
|
||||
current BIGINT NOT NULL DEFAULT 1
|
||||
);
|
||||
INSERT INTO generation (id) VALUES (true);
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package model holds the fleet control-plane domain types and the
|
||||
// shorewall-style source/dest element grammar shared by the API and compiler.
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeviceClass is either a routed-core member or a zone-boundary firewall.
|
||||
type DeviceClass string
|
||||
|
||||
const (
|
||||
ClassRouter DeviceClass = "router"
|
||||
ClassFirewall DeviceClass = "firewall"
|
||||
)
|
||||
|
||||
// AddressGroupType selects how an address group's nftables set is populated.
|
||||
type AddressGroupType string
|
||||
|
||||
const (
|
||||
GroupStatic AddressGroupType = "static" // explicit CIDRs, populated by the API
|
||||
GroupDNS AddressGroupType = "dns" // FQDNs, resolved on-device
|
||||
GroupASN AddressGroupType = "asn" // ASNs, expanded centrally via iplocate
|
||||
)
|
||||
|
||||
// Zone is a fleet-global network segment.
|
||||
type Zone struct {
|
||||
Name string `json:"name" yaml:"-"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Subnets []string `json:"subnets" yaml:"-"`
|
||||
Parent string `json:"parent,omitempty" yaml:"parents,omitempty"`
|
||||
}
|
||||
|
||||
// AddressGroup materializes an nftables named set.
|
||||
type AddressGroup struct {
|
||||
Name string `json:"name"`
|
||||
Type AddressGroupType `json:"type"`
|
||||
Members []string `json:"members"`
|
||||
Refresh string `json:"refresh,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// SetName returns the nftables set name for this group. ASN groups get the
|
||||
// reserved asn_ prefix; others use their bare name.
|
||||
func (g AddressGroup) SetName() string {
|
||||
if g.Type == GroupASN && !strings.HasPrefix(g.Name, "asn_") {
|
||||
return "asn_" + g.Name
|
||||
}
|
||||
return g.Name
|
||||
}
|
||||
|
||||
// Device is a fleet member.
|
||||
type Device struct {
|
||||
Name string `json:"name"`
|
||||
Class DeviceClass `json:"class"`
|
||||
Fabric string `json:"fabric,omitempty"`
|
||||
Resolver []string `json:"resolver,omitempty"`
|
||||
Settings map[string]string `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// Binding maps a global zone to one device's local interface(s).
|
||||
type Binding struct {
|
||||
Device string `json:"device"`
|
||||
Zone string `json:"zone"`
|
||||
Interfaces []string `json:"interfaces"`
|
||||
}
|
||||
|
||||
// Rule is a fleet-global intent. Source and Dest are element lists (OR'd).
|
||||
type Rule struct {
|
||||
ID int64 `json:"id"`
|
||||
Priority int `json:"priority"`
|
||||
Action string `json:"action"`
|
||||
Source []string `json:"source"`
|
||||
Dest []string `json:"dest"`
|
||||
Proto string `json:"proto,omitempty"`
|
||||
PortGroup string `json:"portgroup,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Log string `json:"log,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Selector kinds within a source/dest element.
|
||||
type SelectorKind string
|
||||
|
||||
const (
|
||||
SelIPSet SelectorKind = "ipset" // +name
|
||||
SelFQDN SelectorKind = "fqdn" // &name
|
||||
SelNone SelectorKind = "" // bare zone
|
||||
)
|
||||
|
||||
// Element is one comma-separated token of a source/dest list. A zone is always
|
||||
// present; the selector, when set, narrows within that zone (an AND).
|
||||
type Element struct {
|
||||
Zone string
|
||||
Selector SelectorKind
|
||||
Ref string // the ipset/fqdn-group name when Selector != SelNone
|
||||
}
|
||||
|
||||
// ParseElement parses a single shorewall-style element:
|
||||
//
|
||||
// loc -> bare zone
|
||||
// net:+asn_cloudflare -> zone gated by an ipset
|
||||
// dmz:&api.partner -> zone gated by an fqdn group
|
||||
//
|
||||
// A bare selector (no zone) is rejected: every selector must be paired with a zone.
|
||||
func ParseElement(s string) (Element, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return Element{}, fmt.Errorf("empty element")
|
||||
}
|
||||
|
||||
// Reject a leading selector sigil: bare selectors are not allowed.
|
||||
if s[0] == '+' || s[0] == '&' {
|
||||
return Element{}, fmt.Errorf("selector %q must be paired with a zone (write zone:%s)", s, s)
|
||||
}
|
||||
|
||||
zone, sel, hasSel := strings.Cut(s, ":")
|
||||
zone = strings.TrimSpace(zone)
|
||||
if zone == "" {
|
||||
return Element{}, fmt.Errorf("element %q has an empty zone", s)
|
||||
}
|
||||
e := Element{Zone: zone, Selector: SelNone}
|
||||
if !hasSel {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
sel = strings.TrimSpace(sel)
|
||||
if sel == "" {
|
||||
return Element{}, fmt.Errorf("element %q has a trailing colon with no selector", s)
|
||||
}
|
||||
switch sel[0] {
|
||||
case '+':
|
||||
e.Selector, e.Ref = SelIPSet, sel[1:]
|
||||
case '&':
|
||||
e.Selector, e.Ref = SelFQDN, sel[1:]
|
||||
default:
|
||||
return Element{}, fmt.Errorf("selector %q must start with + (ipset) or & (fqdn)", sel)
|
||||
}
|
||||
if e.Ref == "" {
|
||||
return Element{}, fmt.Errorf("element %q has an empty selector reference", s)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ParseElements parses and validates a full source/dest element list.
|
||||
func ParseElements(list []string) ([]Element, error) {
|
||||
out := make([]Element, 0, len(list))
|
||||
for _, s := range list {
|
||||
e, err := ParseElement(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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