Scaffold tomswallapi control-plane service

This commit is contained in:
benvin
2026-07-19 13:31:14 +10:00
commit 6de48552fb
19 changed files with 1021 additions and 0 deletions
+69
View File
@@ -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
}
+90
View File
@@ -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);
+157
View File
@@ -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
}
+57
View File
@@ -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")
}
+141
View File
@@ -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})
}