Scaffold tomswallapi control-plane service
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/bin/
|
||||
*.out
|
||||
.env
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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