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
+3
View File
@@ -0,0 +1,3 @@
/bin/
*.out
.env
+24
View File
@@ -0,0 +1,24 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- repo: local
hooks:
- id: gofmt
name: gofmt
entry: gofmt -l -d
language: system
types: [go]
pass_filenames: true
- id: go-vet
name: go vet
entry: go vet ./...
language: system
types: [go]
pass_filenames: false
+19
View File
@@ -0,0 +1,19 @@
when:
- event: pull_request
steps:
- name: docker-build
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.unkin.net/unkin/tomswallapi
dry_run: true
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+28
View File
@@ -0,0 +1,28 @@
when:
- event: tag
ref: refs/tags/v*
steps:
- name: docker-tomswallapi
image: woodpeckerci/plugin-docker-buildx
settings:
registry: git.unkin.net
repo: git.unkin.net/unkin/tomswallapi
build_args:
VERSION: ${CI_COMMIT_TAG}
username: droneci
password:
from_secret: DRONECI_PASSWORD
tags:
- ${CI_COMMIT_TAG}
- latest
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+34
View File
@@ -0,0 +1,34 @@
when:
- event: pull_request
steps:
- name: lint
image: golang:1.25
commands:
- make lint
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
# Container-backed DB tests self-skip when Docker is unavailable in CI.
- make test-short
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+21
View File
@@ -0,0 +1,21 @@
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o tomswallapi ./cmd/tomswallapi
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/tomswallapi /usr/local/bin/tomswallapi
EXPOSE 8000
ENTRYPOINT ["tomswallapi"]
+61
View File
@@ -0,0 +1,61 @@
VERSION ?= dev
LDFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
.PHONY: build test test-short lint fmt vet tidy run docker clean
build:
CGO_ENABLED=0 go build $(LDFLAGS) -o bin/tomswallapi ./cmd/tomswallapi
test:
go test ./...
# Tests that self-skip container-backed DB cases when Docker is unavailable.
test-short:
go test -short ./...
lint: vet
gofmt -l -d .
fmt:
gofmt -w .
vet:
go vet ./...
tidy:
go mod tidy
run: build
./bin/tomswallapi
docker:
docker build --build-arg VERSION=$(VERSION) -t tomswallapi:$(VERSION) .
clean:
rm -rf bin
# Version-bump targets: compute the next semver tag from the latest v* tag,
# then create and push it. The v* tag triggers the docker release pipeline.
.PHONY: patch minor major
patch: ; @$(MAKE) bump PART=patch
minor: ; @$(MAKE) bump PART=minor
major: ; @$(MAKE) bump PART=major
.PHONY: bump
bump:
@current=$$(git tag -l 'v*' --sort=-v:refname | head -1); \
current=$${current:-v0.0.0}; \
v=$${current#v}; \
major=$$(echo $$v | cut -d. -f1); \
minor=$$(echo $$v | cut -d. -f2); \
patch=$$(echo $$v | cut -d. -f3); \
case "$(PART)" in \
major) major=$$((major+1)); minor=0; patch=0;; \
minor) minor=$$((minor+1)); patch=0;; \
patch) patch=$$((patch+1));; \
*) echo "PART must be major|minor|patch"; exit 1;; \
esac; \
next="v$$major.$$minor.$$patch"; \
echo "Tagging $$next (was $$current)"; \
git tag -a "$$next" -m "Release $$next"; \
git push origin "$$next"
+64
View File
@@ -0,0 +1,64 @@
# tomswallapi
Fleet control plane for [tomswall](https://git.unkin.net/unkin/tomswall).
Declare zones, address groups, and firewall policy **once**; the API compiles each
intent into the concrete per-device rules every firewall and router on the path
needs, and serves each device its rendered `tomswall.yaml`. A connection that
crosses several firewalls — `src → rt1 → rt2 → rt3 → dest` — is expressed as a
single rule.
The full design (data model, compile algorithm, invariants, agent protocol) lives
in [`DESIGN.md`](https://git.unkin.net/unkin/tomswall/src/branch/main/DESIGN.md) in
the tomswall repo.
## Architecture
- **tomswallapi** (this repo) — Terraform-managed HTTP API. Stores the fleet model
in Postgres, peers with FRR for reachability, compiles intents into per-device
configs, and serves them.
- **tomswall agent** — pulls its rendered config, runs the existing differential
`apply`, maintains dns-backed ipsets via an on-device resolver, and reports the
config generation it has applied. Does **not** fail closed on API-unreachable.
## Running locally
```sh
docker compose up --build
# API on :8000, Postgres on :5432
curl -s localhost:8000/healthz
```
## Configuration
All configuration is via environment variables (`TOMSWALLAPI_*`):
| var | default | purpose |
|---|---|---|
| `TOMSWALLAPI_LISTEN_ADDR` | `:8000` | HTTP listen address |
| `TOMSWALLAPI_DB_HOST` | `localhost` | Postgres host |
| `TOMSWALLAPI_DB_PORT` | `5432` | Postgres port |
| `TOMSWALLAPI_DB_USER` | `tomswallapi` | Postgres user |
| `TOMSWALLAPI_DB_PASSWORD` | — | Postgres password |
| `TOMSWALLAPI_DB_NAME` | `tomswallapi` | Postgres database |
| `TOMSWALLAPI_DB_SSLMODE` | `disable` | Postgres sslmode |
| `TOMSWALLAPI_WRITE_TOKEN` | — | bearer token guarding mutating endpoints (Terraform) |
| `TOMSWALLAPI_AGENT_TOKEN` | — | bearer token guarding the per-device config endpoint (agents) |
| `TOMSWALLAPI_IPLOCATE_API_KEY` | — | iplocate key for ASN address-group expansion |
Migrations are embedded and applied automatically on startup.
## Development
```sh
make build # build the binary
make test # run tests
make lint # gofmt + go vet
make run # build and run
```
## Releases
`make patch|minor|major` computes and pushes the next `v*` tag, which triggers the
Woodpecker docker pipeline to build and push the image to
`git.unkin.net/unkin/tomswallapi`.
+66
View File
@@ -0,0 +1,66 @@
// Command tomswallapi is the fleet control-plane HTTP server for tomswall.
//
// It stores the fleet-global model (zones, address groups, portgroups, policies,
// rules, fabrics) and the per-device layer (devices, zone->interface bindings),
// compiles intents into per-device tomswall configs, and serves those configs to
// tomswall agents. The read/write API backs a Terraform provider and the agents.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"git.unkin.net/unkin/tomswallapi/internal/config"
"git.unkin.net/unkin/tomswallapi/internal/database"
"git.unkin.net/unkin/tomswallapi/internal/server"
)
var version = "dev"
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting tomswallapi", "version", version)
cfg, err := config.Load()
if err != nil {
slog.Error("load config", "err", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := database.New(ctx, cfg.DatabaseDSN())
if err != nil {
slog.Error("connect database", "err", err)
os.Exit(1)
}
defer db.Close()
if err := db.Migrate(ctx); err != nil {
slog.Error("migrate database", "err", err)
os.Exit(1)
}
if cfg.WriteToken == "" {
slog.Warn("TOMSWALLAPI_WRITE_TOKEN is not set; write endpoints are disabled")
}
if cfg.AgentToken == "" {
slog.Warn("TOMSWALLAPI_AGENT_TOKEN is not set; agent config endpoint is disabled")
}
srv := server.New(server.Options{
DB: db,
WriteToken: cfg.WriteToken,
AgentToken: cfg.AgentToken,
Version: version,
})
if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}
+30
View File
@@ -0,0 +1,30 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: tomswallapi
POSTGRES_PASSWORD: tomswallapi
POSTGRES_DB: tomswallapi
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tomswallapi"]
interval: 5s
timeout: 3s
retries: 5
api:
build:
context: .
args:
VERSION: dev
depends_on:
db:
condition: service_healthy
environment:
TOMSWALLAPI_DB_HOST: db
TOMSWALLAPI_DB_PASSWORD: tomswallapi
TOMSWALLAPI_WRITE_TOKEN: dev-write-token
TOMSWALLAPI_AGENT_TOKEN: dev-agent-token
ports:
- "8000:8000"
+16
View File
@@ -0,0 +1,16 @@
module git.unkin.net/unkin/tomswallapi
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/jackc/pgx/v5 v5.10.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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})
}