commit 6de48552fbd19cac47dd76708c73c5c74edee3b9 Author: benvin Date: Sun Jul 19 13:31:14 2026 +1000 Scaffold tomswallapi control-plane service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edd210c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/bin/ +*.out +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3d17912 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..82e6671 --- /dev/null +++ b/.woodpecker/build.yaml @@ -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 diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..80e74fe --- /dev/null +++ b/.woodpecker/docker.yaml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..d7bc573 --- /dev/null +++ b/.woodpecker/test.yaml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae96367 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..11bf482 --- /dev/null +++ b/Makefile @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3d3a5b --- /dev/null +++ b/README.md @@ -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`. diff --git a/cmd/tomswallapi/main.go b/cmd/tomswallapi/main.go new file mode 100644 index 0000000..a0ee1fe --- /dev/null +++ b/cmd/tomswallapi/main.go @@ -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) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3c4212 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8741e89 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f80318d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d5140ec --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..98f9acc --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/database/migrations/0001_init.sql b/internal/database/migrations/0001_init.sql new file mode 100644 index 0000000..0523f1e --- /dev/null +++ b/internal/database/migrations/0001_init.sql @@ -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); diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..37a710f --- /dev/null +++ b/internal/model/model.go @@ -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 +} diff --git a/internal/server/resources.go b/internal/server/resources.go new file mode 100644 index 0000000..b852e4c --- /dev/null +++ b/internal/server/resources.go @@ -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") +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..3be834f --- /dev/null +++ b/internal/server/server.go @@ -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}) +}