Merge pull request 'Scaffold the tomswallapi control plane' (#1) from benvin/scaffold-control-plane into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-19 21:39:28 +10:00
23 changed files with 2274 additions and 1 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"
+62 -1
View File
@@ -1,3 +1,64 @@
# tomswallapi
Fleet control plane for tomswall firewalls. Terraform-managed API that compiles fleet-wide zones, address groups, and firewall policy into per-device tomswall configs; agents pull and differentially apply.
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"
+19
View File
@@ -0,0 +1,19 @@
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
gopkg.in/yaml.v3 v3.0.1
)
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
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+37
View File
@@ -0,0 +1,37 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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=
+313
View File
@@ -0,0 +1,313 @@
// Package compiler projects the fleet-global model through a device's binding
// table into a rendered, interface-agnostic config the tomswall agent applies.
//
// Rules are compiled to address-matched (saddr/daddr) forward rules with no
// iif/oif, which is what makes them correct under FRR/ECMP: any device on any
// path permits the 5-tuple and each device's own conntrack handles the return.
// Firewalls always enforce; routers enforce only when their fabric opts in.
package compiler
import (
"context"
"fmt"
"sort"
"gopkg.in/yaml.v3"
"git.unkin.net/unkin/tomswallapi/internal/model"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// Input is the fully-resolved model needed to render one device. Keeping Render
// pure (no store access) makes it unit-testable without a database.
type Input struct {
Generation int64
Settings model.Settings
Device model.Device
Fabric *model.Fabric
Zones map[string]model.Zone
Groups map[string]model.AddressGroup
PortGroups map[string]model.PortGroup
Rules []model.Rule
Policies []model.Policy
Bindings []model.Binding
}
// RenderedConfig is the per-device output served to the agent.
type RenderedConfig struct {
Generation int64 `yaml:"generation" json:"generation"`
Device string `yaml:"device" json:"device"`
Class model.DeviceClass `yaml:"class" json:"class"`
Enforcing bool `yaml:"enforcing" json:"enforcing"`
Settings RenderedSettings `yaml:"settings" json:"settings"`
Resolver []string `yaml:"resolver,omitempty" json:"resolver,omitempty"`
Bindings map[string][]string `yaml:"bindings,omitempty" json:"bindings,omitempty"` // zone -> interfaces
Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"`
Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"`
Policies []model.Policy `yaml:"policies,omitempty" json:"policies,omitempty"`
}
// RenderedSettings is the effective settings after per-device overrides.
type RenderedSettings struct {
AddressFamily string `yaml:"address_family" json:"address_family"`
LogLevel string `yaml:"log_level" json:"log_level"`
IPForwarding bool `yaml:"ip_forwarding" json:"ip_forwarding"`
TableName string `yaml:"table_name" json:"table_name"`
}
// RenderedSet is an nftables named set the agent must materialize. Members carry
// the concrete elements when the API knows them (static, or asn once expanded);
// dns and unexpanded asn sets carry their source so the agent/expander can
// populate them out-of-band without a rule reload.
type RenderedSet struct {
Name string `yaml:"name" json:"name"`
Kind model.AddressGroupType `yaml:"kind" json:"kind"`
Members []string `yaml:"members,omitempty" json:"members,omitempty"` // static CIDRs / expanded prefixes
FQDNs []string `yaml:"fqdns,omitempty" json:"fqdns,omitempty"` // dns: names to resolve on-device
ASNs []string `yaml:"asns,omitempty" json:"asns,omitempty"` // asn: source ASNs
Refresh string `yaml:"refresh,omitempty" json:"refresh,omitempty"`
}
// RenderedMatch is one OR'd element of a rule direction: the zone's subnets
// AND, optionally, a named set to intersect with.
type RenderedMatch struct {
Zone string `yaml:"zone" json:"zone"`
Subnets []string `yaml:"subnets,omitempty" json:"subnets,omitempty"`
Set string `yaml:"set,omitempty" json:"set,omitempty"`
}
// RenderedRule is an interface-agnostic forward rule.
type RenderedRule struct {
Action string `yaml:"action" json:"action"`
Source []RenderedMatch `yaml:"source" json:"source"`
Dest []RenderedMatch `yaml:"dest" json:"dest"`
Proto string `yaml:"proto,omitempty" json:"proto,omitempty"`
Ports []string `yaml:"ports,omitempty" json:"ports,omitempty"`
Log string `yaml:"log,omitempty" json:"log,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
// Marshal serializes the rendered config to YAML.
func (c *RenderedConfig) Marshal() ([]byte, error) { return yaml.Marshal(c) }
// enforces reports whether the device applies rules: firewalls always do; routers
// only when their fabric opts into defense-in-depth.
func enforces(dev model.Device, fabric *model.Fabric) bool {
if dev.Class == model.ClassFirewall {
return true
}
return dev.Class == model.ClassRouter && fabric != nil && fabric.EnforceOnRouters
}
// setNameFor resolves a rule's selector reference (as written after + or &) to a
// concrete nft set name. A reference may be a group's bare name or its computed
// set name (e.g. an asn group "cloudflare" whose set is "asn_cloudflare").
func setNameFor(groups map[string]model.AddressGroup, ref string) (model.AddressGroup, bool) {
if g, ok := groups[ref]; ok {
return g, true
}
for _, g := range groups {
if g.SetName() == ref {
return g, true
}
}
return model.AddressGroup{}, false
}
// Render projects the model into a device config. It is pure and deterministic.
func Render(in Input) (*RenderedConfig, error) {
out := &RenderedConfig{
Generation: in.Generation,
Device: in.Device.Name,
Class: in.Device.Class,
Enforcing: enforces(in.Device, in.Fabric),
Settings: renderSettings(in),
Resolver: effectiveResolver(in),
Bindings: map[string][]string{},
}
for _, b := range in.Bindings {
out.Bindings[b.Zone] = b.Interfaces
}
usedSets := map[string]model.AddressGroup{}
if out.Enforcing {
for _, rule := range in.Rules {
rr, err := renderRule(in, rule, usedSets)
if err != nil {
return nil, fmt.Errorf("rule %d: %w", rule.ID, err)
}
out.Rules = append(out.Rules, rr)
}
out.Policies = in.Policies
}
// Emit a set definition for every address group any rule referenced.
names := make([]string, 0, len(usedSets))
for n := range usedSets {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
out.Sets = append(out.Sets, renderSet(usedSets[n]))
}
return out, nil
}
func renderSettings(in Input) RenderedSettings {
s := RenderedSettings{
AddressFamily: in.Settings.AddressFamily,
LogLevel: in.Settings.LogLevel,
IPForwarding: in.Settings.IPForwarding,
TableName: in.Settings.TableName,
}
// Per-device string overrides.
if v, ok := in.Device.Settings["address_family"]; ok {
s.AddressFamily = v
}
if v, ok := in.Device.Settings["log_level"]; ok {
s.LogLevel = v
}
if v, ok := in.Device.Settings["table_name"]; ok {
s.TableName = v
}
return s
}
func effectiveResolver(in Input) []string {
if len(in.Device.Resolver) > 0 {
return in.Device.Resolver
}
return in.Settings.DefaultResolver
}
func renderRule(in Input, rule model.Rule, usedSets map[string]model.AddressGroup) (RenderedRule, error) {
src, err := renderMatches(in, rule.Source, usedSets)
if err != nil {
return RenderedRule{}, fmt.Errorf("source: %w", err)
}
dst, err := renderMatches(in, rule.Dest, usedSets)
if err != nil {
return RenderedRule{}, fmt.Errorf("dest: %w", err)
}
proto, ports := resolvePorts(in, rule)
return RenderedRule{
Action: rule.Action,
Source: src,
Dest: dst,
Proto: proto,
Ports: ports,
Log: rule.Log,
Comment: rule.Comment,
}, nil
}
func renderMatches(in Input, list []string, usedSets map[string]model.AddressGroup) ([]RenderedMatch, error) {
elems, err := model.ParseElements(list)
if err != nil {
return nil, err
}
out := make([]RenderedMatch, 0, len(elems))
for _, e := range elems {
m := RenderedMatch{Zone: e.Zone}
if z, ok := in.Zones[e.Zone]; ok {
m.Subnets = z.Subnets
}
if e.Selector != model.SelNone {
g, ok := setNameFor(in.Groups, e.Ref)
if !ok {
return nil, fmt.Errorf("unknown address group %q", e.Ref)
}
m.Set = g.SetName()
usedSets[g.SetName()] = g
}
out = append(out, m)
}
return out, nil
}
func resolvePorts(in Input, rule model.Rule) (proto string, ports []string) {
if rule.PortGroup != "" {
if pg, ok := in.PortGroups[rule.PortGroup]; ok {
return pg.Proto, pg.Ports
}
}
return rule.Proto, rule.Ports
}
func renderSet(g model.AddressGroup) RenderedSet {
rs := RenderedSet{Name: g.SetName(), Kind: g.Type, Refresh: g.Refresh}
switch g.Type {
case model.GroupStatic:
rs.Members = g.Members
case model.GroupDNS:
rs.FQDNs = g.Members
case model.GroupASN:
rs.ASNs = g.Members // expanded prefixes are attached out-of-band by the ASN expander
}
return rs
}
// Compile fetches the model for a device from the store and renders its config.
func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfig, error) {
dev, err := s.GetDevice(ctx, device)
if err != nil {
return nil, err
}
gen, err := s.Generation(ctx)
if err != nil {
return nil, err
}
settings, err := s.GetSettings(ctx)
if err != nil {
return nil, err
}
in := Input{Generation: gen, Settings: settings, Device: dev}
if dev.Fabric != "" {
f, err := s.GetFabric(ctx, dev.Fabric)
if err == nil {
in.Fabric = &f
} else if err != store.ErrNotFound {
return nil, err
}
}
zones, err := s.ListZones(ctx)
if err != nil {
return nil, err
}
in.Zones = make(map[string]model.Zone, len(zones))
for _, z := range zones {
in.Zones[z.Name] = z
}
groups, err := s.ListAddressGroups(ctx)
if err != nil {
return nil, err
}
in.Groups = make(map[string]model.AddressGroup, len(groups))
for _, g := range groups {
in.Groups[g.Name] = g
}
pgs, err := s.ListPortGroups(ctx)
if err != nil {
return nil, err
}
in.PortGroups = make(map[string]model.PortGroup, len(pgs))
for _, p := range pgs {
in.PortGroups[p.Name] = p
}
if in.Rules, err = s.ListRules(ctx); err != nil {
return nil, err
}
if in.Policies, err = s.ListPolicies(ctx); err != nil {
return nil, err
}
if in.Bindings, err = s.ListBindings(ctx, device); err != nil {
return nil, err
}
return Render(in)
}
+129
View File
@@ -0,0 +1,129 @@
package compiler
import "testing"
import "git.unkin.net/unkin/tomswallapi/internal/model"
func baseInput() Input {
return Input{
Generation: 7,
Settings: model.Settings{AddressFamily: "inet", LogLevel: "info", IPForwarding: true, TableName: "tomswall", DefaultResolver: []string{"10.0.0.53"}},
Zones: map[string]model.Zone{
"zone-a": {Name: "zone-a", Type: "ip", Subnets: []string{"10.1.0.0/24"}},
"net": {Name: "net", Type: "ip"}, // no subnets: internet-facing
},
Groups: map[string]model.AddressGroup{
"cloudflare": {Name: "cloudflare", Type: model.GroupASN, Members: []string{"13335"}, Refresh: "24h"},
},
PortGroups: map[string]model.PortGroup{
"https": {Name: "https", Proto: "tcp", Ports: []string{"443"}},
},
Rules: []model.Rule{
{ID: 1, Action: "accept", Source: []string{"zone-a"}, Dest: []string{"net:+asn_cloudflare"}, PortGroup: "https"},
},
}
}
func TestRenderFirewallEnforcesAndEmitsSet(t *testing.T) {
in := baseInput()
in.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall}
in.Bindings = []model.Binding{{Device: "fw-a", Zone: "zone-a", Interfaces: []string{"eth1"}}}
cfg, err := Render(in)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !cfg.Enforcing {
t.Fatal("firewall should enforce")
}
if cfg.Generation != 7 {
t.Errorf("generation = %d, want 7", cfg.Generation)
}
if len(cfg.Rules) != 1 {
t.Fatalf("want 1 rule, got %d", len(cfg.Rules))
}
r := cfg.Rules[0]
// Interface-agnostic: source resolves to zone-a's subnets, no iif/oif.
if len(r.Source) != 1 || r.Source[0].Zone != "zone-a" || len(r.Source[0].Subnets) != 1 || r.Source[0].Subnets[0] != "10.1.0.0/24" {
t.Errorf("unexpected source match: %+v", r.Source)
}
// Dest is the no-subnet `net` zone gated by the asn set.
if len(r.Dest) != 1 || r.Dest[0].Zone != "net" || r.Dest[0].Set != "asn_cloudflare" {
t.Errorf("unexpected dest match: %+v", r.Dest)
}
if len(r.Dest[0].Subnets) != 0 {
t.Errorf("net should carry no subnets, got %v", r.Dest[0].Subnets)
}
if r.Proto != "tcp" || len(r.Ports) != 1 || r.Ports[0] != "443" {
t.Errorf("portgroup not resolved: proto=%q ports=%v", r.Proto, r.Ports)
}
// The referenced asn group must be emitted as a set carrying its source ASNs.
if len(cfg.Sets) != 1 {
t.Fatalf("want 1 set, got %d", len(cfg.Sets))
}
set := cfg.Sets[0]
if set.Name != "asn_cloudflare" || set.Kind != model.GroupASN || len(set.ASNs) != 1 || set.ASNs[0] != "13335" {
t.Errorf("unexpected set: %+v", set)
}
if set.Members != nil {
t.Errorf("asn set should not carry inline members before expansion, got %v", set.Members)
}
// Binding surfaced for the agent.
if got := cfg.Bindings["zone-a"]; len(got) != 1 || got[0] != "eth1" {
t.Errorf("binding not surfaced: %v", cfg.Bindings)
}
}
func TestRenderTransparentRouterHasNoRules(t *testing.T) {
in := baseInput()
in.Device = model.Device{Name: "rt1", Class: model.ClassRouter, Fabric: "core"}
in.Fabric = &model.Fabric{Name: "core", EnforceOnRouters: false}
cfg, err := Render(in)
if err != nil {
t.Fatalf("Render: %v", err)
}
if cfg.Enforcing {
t.Fatal("transparent router should not enforce")
}
if len(cfg.Rules) != 0 || len(cfg.Sets) != 0 {
t.Errorf("transparent router should emit no rules/sets, got %d rules %d sets", len(cfg.Rules), len(cfg.Sets))
}
}
func TestRenderEnforcingRouter(t *testing.T) {
in := baseInput()
in.Device = model.Device{Name: "rt1", Class: model.ClassRouter, Fabric: "core"}
in.Fabric = &model.Fabric{Name: "core", EnforceOnRouters: true}
cfg, err := Render(in)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !cfg.Enforcing || len(cfg.Rules) != 1 {
t.Errorf("defense-in-depth router should enforce the rule: enforcing=%v rules=%d", cfg.Enforcing, len(cfg.Rules))
}
}
func TestRenderUnknownGroupIsError(t *testing.T) {
in := baseInput()
in.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall}
in.Rules = []model.Rule{{ID: 9, Action: "accept", Source: []string{"zone-a"}, Dest: []string{"net:+nope"}}}
if _, err := Render(in); err == nil {
t.Fatal("expected error for unknown address group")
}
}
func TestEffectiveResolverPrefersDevice(t *testing.T) {
in := baseInput()
in.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall, Resolver: []string{"10.9.9.9"}}
cfg, err := Render(in)
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(cfg.Resolver) != 1 || cfg.Resolver[0] != "10.9.9.9" {
t.Errorf("device resolver should win: %v", cfg.Resolver)
}
}
+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);
+193
View File
@@ -0,0 +1,193 @@
// 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
)
// Settings holds fleet-wide defaults. Individual devices may override a subset
// via their per-device settings.
type Settings struct {
AddressFamily string `json:"address_family"`
LogLevel string `json:"log_level"`
IPForwarding bool `json:"ip_forwarding"`
TableName string `json:"table_name"`
DefaultResolver []string `json:"default_resolver"`
}
// PortGroup is a reusable proto+ports combo referenced by rules.
type PortGroup struct {
Name string `json:"name"`
Proto string `json:"proto"`
Ports []string `json:"ports"`
}
// Fabric is a routing domain. EnforceOnRouters toggles defense-in-depth (every
// router carries the intent) vs transparent transit (only boundary firewalls do).
type Fabric struct {
Name string `json:"name"`
EnforceOnRouters bool `json:"enforce_on_routers"`
Description string `json:"description,omitempty"`
}
// Policy is a fleet-global default zone-to-zone posture. Lower priority evaluates
// first (first match wins).
type Policy struct {
ID int64 `json:"id"`
Priority int `json:"priority"`
Source string `json:"source"`
Dest string `json:"dest"`
Action string `json:"action"`
Log string `json:"log,omitempty"`
}
// 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
}
+63
View File
@@ -0,0 +1,63 @@
package model
import "testing"
func TestParseElement(t *testing.T) {
tests := []struct {
in string
wantZone string
wantSel SelectorKind
wantRef string
wantErr bool
}{
{in: "loc", wantZone: "loc", wantSel: SelNone},
{in: " net ", wantZone: "net", wantSel: SelNone},
{in: "net:+asn_cloudflare", wantZone: "net", wantSel: SelIPSet, wantRef: "asn_cloudflare"},
{in: "dmz:&api.partner", wantZone: "dmz", wantSel: SelFQDN, wantRef: "api.partner"},
{in: "net: +office", wantZone: "net", wantSel: SelIPSet, wantRef: "office"},
// Bare selectors must be rejected: a selector always needs a zone.
{in: "+office", wantErr: true},
{in: "&host", wantErr: true},
{in: "asn:13335", wantErr: true}, // no + or & sigil -> invalid selector
{in: "", wantErr: true},
{in: "net:", wantErr: true},
{in: ":+office", wantErr: true},
{in: "net:+", wantErr: true},
}
for _, tt := range tests {
got, err := ParseElement(tt.in)
if tt.wantErr {
if err == nil {
t.Errorf("ParseElement(%q): expected error, got %+v", tt.in, got)
}
continue
}
if err != nil {
t.Errorf("ParseElement(%q): unexpected error: %v", tt.in, err)
continue
}
if got.Zone != tt.wantZone || got.Selector != tt.wantSel || got.Ref != tt.wantRef {
t.Errorf("ParseElement(%q) = %+v, want zone=%q sel=%q ref=%q",
tt.in, got, tt.wantZone, tt.wantSel, tt.wantRef)
}
}
}
func TestAddressGroupSetName(t *testing.T) {
cases := []struct {
group AddressGroup
want string
}{
{AddressGroup{Name: "asn_cloudflare", Type: GroupASN}, "asn_cloudflare"},
{AddressGroup{Name: "cloudflare", Type: GroupASN}, "asn_cloudflare"},
{AddressGroup{Name: "office", Type: GroupStatic}, "office"},
{AddressGroup{Name: "vpn", Type: GroupDNS}, "vpn"},
}
for _, c := range cases {
if got := c.group.SetName(); got != c.want {
t.Errorf("SetName(%+v) = %q, want %q", c.group, got, c.want)
}
}
}
+289
View File
@@ -0,0 +1,289 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/tomswallapi/internal/compiler"
"git.unkin.net/unkin/tomswallapi/internal/model"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// mountResources wires the Terraform-facing CRUD endpoints. Resources with a
// dedicated repository method are wired here; the long-tail per-device sections
// (providers, tc, etc.) are added as their storage lands.
func (s *Server) mountResources(r chi.Router) {
r.Get("/generation", s.handleGeneration)
r.Route("/fabrics", func(r chi.Router) {
r.Get("/", s.listFabrics)
r.Put("/{name}", s.putFabric)
})
r.Route("/zones", func(r chi.Router) {
r.Get("/", s.listZones)
r.Put("/{name}", s.putZone)
})
r.Route("/address-groups", func(r chi.Router) {
r.Get("/", s.listAddressGroups)
r.Put("/{name}", s.putAddressGroup)
})
r.Route("/devices", func(r chi.Router) {
r.Get("/", s.listDevices)
r.Put("/{name}", s.putDevice)
r.Get("/{name}/bindings", s.listBindings)
r.Put("/{name}/bindings/{zone}", s.putBinding)
})
r.Route("/portgroups", func(r chi.Router) {
r.Get("/", s.listPortGroups)
r.Put("/{name}", s.putPortGroup)
})
r.Route("/rules", func(r chi.Router) {
r.Get("/", s.listRules)
r.Post("/", s.createRule)
r.Delete("/{id}", s.deleteRule)
})
}
func (s *Server) listPortGroups(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListPortGroups(r.Context())
respondList(w, list, err)
}
func (s *Server) putPortGroup(w http.ResponseWriter, r *http.Request) {
var p model.PortGroup
if !decode(w, r, &p) {
return
}
p.Name = chi.URLParam(r, "name")
if err := s.store.UpsertPortGroup(r.Context(), p); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleGeneration(w http.ResponseWriter, r *http.Request) {
g, err := s.store.Generation(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"generation": g})
}
// ---- Fabrics ---------------------------------------------------------------
func (s *Server) listFabrics(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListFabrics(r.Context())
respondList(w, list, err)
}
func (s *Server) putFabric(w http.ResponseWriter, r *http.Request) {
var f model.Fabric
if !decode(w, r, &f) {
return
}
f.Name = chi.URLParam(r, "name")
if err := s.store.UpsertFabric(r.Context(), f); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, f)
}
// ---- Zones -----------------------------------------------------------------
func (s *Server) listZones(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListZones(r.Context())
respondList(w, list, err)
}
func (s *Server) putZone(w http.ResponseWriter, r *http.Request) {
var z model.Zone
if !decode(w, r, &z) {
return
}
z.Name = chi.URLParam(r, "name")
if err := s.store.UpsertZone(r.Context(), z); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, z)
}
// ---- Address groups --------------------------------------------------------
func (s *Server) listAddressGroups(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListAddressGroups(r.Context())
respondList(w, list, err)
}
func (s *Server) putAddressGroup(w http.ResponseWriter, r *http.Request) {
var g model.AddressGroup
if !decode(w, r, &g) {
return
}
g.Name = chi.URLParam(r, "name")
if g.Type != model.GroupStatic && g.Type != model.GroupDNS && g.Type != model.GroupASN {
writeError(w, http.StatusBadRequest, "type must be one of: static, dns, asn")
return
}
if err := s.store.UpsertAddressGroup(r.Context(), g); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, g)
}
// ---- Devices & bindings ----------------------------------------------------
func (s *Server) listDevices(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListDevices(r.Context())
respondList(w, list, err)
}
func (s *Server) putDevice(w http.ResponseWriter, r *http.Request) {
var d model.Device
if !decode(w, r, &d) {
return
}
d.Name = chi.URLParam(r, "name")
if d.Class != model.ClassRouter && d.Class != model.ClassFirewall {
writeError(w, http.StatusBadRequest, "class must be one of: router, firewall")
return
}
if err := s.store.UpsertDevice(r.Context(), d); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) listBindings(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListBindings(r.Context(), chi.URLParam(r, "name"))
respondList(w, list, err)
}
func (s *Server) putBinding(w http.ResponseWriter, r *http.Request) {
var b model.Binding
if !decode(w, r, &b) {
return
}
b.Device = chi.URLParam(r, "name")
b.Zone = chi.URLParam(r, "zone")
if err := s.store.UpsertBinding(r.Context(), b); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, b)
}
// ---- Rules -----------------------------------------------------------------
func (s *Server) listRules(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListRules(r.Context())
respondList(w, list, err)
}
func (s *Server) createRule(w http.ResponseWriter, r *http.Request) {
var rule model.Rule
if !decode(w, r, &rule) {
return
}
id, err := s.store.CreateRule(r.Context(), rule)
if err != nil {
// Grammar/validation failures are client errors.
writeError(w, http.StatusBadRequest, err.Error())
return
}
rule.ID = id
writeJSON(w, http.StatusCreated, rule)
}
func (s *Server) deleteRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return
}
if err := s.store.DeleteRule(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "rule not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---- Agent endpoints -------------------------------------------------------
func (s *Server) handleDeviceConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := compiler.Compile(r.Context(), s.store, chi.URLParam(r, "name"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "device not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
body, err := cfg.Marshal()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/yaml")
w.Header().Set("X-Tomswall-Generation", strconv.FormatInt(cfg.Generation, 10))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) {
var body struct {
Generation int64 `json:"generation"`
}
if !decode(w, r, &body) {
return
}
if err := s.store.RecordDeviceStatus(r.Context(), chi.URLParam(r, "name"), body.Generation); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "device not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---- helpers ---------------------------------------------------------------
// decode reads a JSON request body into v, writing a 400 on failure. It returns
// false when the caller should stop.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return false
}
return true
}
// respondList writes a list result or a 500, normalizing a nil slice to [].
func respondList[T any](w http.ResponseWriter, list []T, err error) {
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if list == nil {
list = []T{}
}
writeJSON(w, http.StatusOK, list)
}
+144
View File
@@ -0,0 +1,144 @@
// 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"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// 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
store *store.Store
writeToken string
agentToken string
version string
}
// New constructs a Server.
func New(o Options) *Server {
return &Server{
db: o.DB,
store: store.New(o.DB.Pool),
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})
}
+467
View File
@@ -0,0 +1,467 @@
// Package store is the Postgres-backed repository for the fleet model. Every
// mutating method bumps the global config generation so agents can detect drift.
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// ErrNotFound is returned when a lookup by key matches no row.
var ErrNotFound = errors.New("not found")
// Store provides CRUD over the fleet model.
type Store struct {
pool *pgxpool.Pool
}
// New constructs a Store over the given pool.
func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// Generation returns the current global config generation.
func (s *Store) Generation(ctx context.Context) (int64, error) {
var g int64
err := s.pool.QueryRow(ctx, `SELECT current FROM generation WHERE id = true`).Scan(&g)
return g, err
}
// bump increments the generation within tx and returns the new value.
func bump(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE generation SET current = current + 1 WHERE id = true`)
return err
}
// jsonb marshals a value for a JSONB column, defaulting nil slices to "[]".
func jsonb(v any) ([]byte, error) {
if v == nil {
return []byte("[]"), nil
}
return json.Marshal(v)
}
// ---- Fabrics ---------------------------------------------------------------
func (s *Store) ListFabrics(ctx context.Context) ([]model.Fabric, error) {
rows, err := s.pool.Query(ctx, `SELECT name, enforce_on_routers, description FROM fabrics ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Fabric
for rows.Next() {
var f model.Fabric
if err := rows.Scan(&f.Name, &f.EnforceOnRouters, &f.Description); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func (s *Store) GetFabric(ctx context.Context, name string) (model.Fabric, error) {
var f model.Fabric
err := s.pool.QueryRow(ctx,
`SELECT name, enforce_on_routers, description FROM fabrics WHERE name = $1`, name,
).Scan(&f.Name, &f.EnforceOnRouters, &f.Description)
if errors.Is(err, pgx.ErrNoRows) {
return f, ErrNotFound
}
return f, err
}
func (s *Store) UpsertFabric(ctx context.Context, f model.Fabric) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO fabrics (name, enforce_on_routers, description)
VALUES ($1, $2, $3)
ON CONFLICT (name) DO UPDATE SET
enforce_on_routers = EXCLUDED.enforce_on_routers,
description = EXCLUDED.description`,
f.Name, f.EnforceOnRouters, f.Description); err != nil {
return err
}
return bump(ctx, tx)
})
}
// ---- Zones -----------------------------------------------------------------
func (s *Store) ListZones(ctx context.Context) ([]model.Zone, error) {
rows, err := s.pool.Query(ctx, `SELECT name, type, subnets, COALESCE(parent, '') FROM zones ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Zone
for rows.Next() {
var z model.Zone
var subnets []byte
if err := rows.Scan(&z.Name, &z.Type, &subnets, &z.Parent); err != nil {
return nil, err
}
if err := json.Unmarshal(subnets, &z.Subnets); err != nil {
return nil, err
}
out = append(out, z)
}
return out, rows.Err()
}
func (s *Store) GetZone(ctx context.Context, name string) (model.Zone, error) {
var z model.Zone
var subnets []byte
err := s.pool.QueryRow(ctx,
`SELECT name, type, subnets, COALESCE(parent, '') FROM zones WHERE name = $1`, name,
).Scan(&z.Name, &z.Type, &subnets, &z.Parent)
if errors.Is(err, pgx.ErrNoRows) {
return z, ErrNotFound
}
if err != nil {
return z, err
}
return z, json.Unmarshal(subnets, &z.Subnets)
}
func (s *Store) UpsertZone(ctx context.Context, z model.Zone) error {
subnets, err := jsonb(z.Subnets)
if err != nil {
return err
}
if z.Type == "" {
z.Type = "ip"
}
var parent any
if z.Parent != "" {
parent = z.Parent
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO zones (name, type, subnets, parent)
VALUES ($1, $2, $3, $4)
ON CONFLICT (name) DO UPDATE SET
type = EXCLUDED.type, subnets = EXCLUDED.subnets, parent = EXCLUDED.parent`,
z.Name, z.Type, subnets, parent); err != nil {
return err
}
return bump(ctx, tx)
})
}
// ---- Address groups --------------------------------------------------------
func (s *Store) ListAddressGroups(ctx context.Context) ([]model.AddressGroup, error) {
rows, err := s.pool.Query(ctx,
`SELECT name, type, members, refresh, description FROM address_groups ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.AddressGroup
for rows.Next() {
var g model.AddressGroup
var members []byte
if err := rows.Scan(&g.Name, &g.Type, &members, &g.Refresh, &g.Description); err != nil {
return nil, err
}
if err := json.Unmarshal(members, &g.Members); err != nil {
return nil, err
}
out = append(out, g)
}
return out, rows.Err()
}
func (s *Store) UpsertAddressGroup(ctx context.Context, g model.AddressGroup) error {
members, err := jsonb(g.Members)
if err != nil {
return err
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO address_groups (name, type, members, refresh, description)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (name) DO UPDATE SET
type = EXCLUDED.type, members = EXCLUDED.members,
refresh = EXCLUDED.refresh, description = EXCLUDED.description`,
g.Name, g.Type, members, g.Refresh, g.Description); err != nil {
return err
}
return bump(ctx, tx)
})
}
// ---- Devices ---------------------------------------------------------------
func (s *Store) ListDevices(ctx context.Context) ([]model.Device, error) {
rows, err := s.pool.Query(ctx,
`SELECT name, class, COALESCE(fabric, ''), resolver FROM devices ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Device
for rows.Next() {
var d model.Device
var resolver []byte
if err := rows.Scan(&d.Name, &d.Class, &d.Fabric, &resolver); err != nil {
return nil, err
}
if err := json.Unmarshal(resolver, &d.Resolver); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
func (s *Store) UpsertDevice(ctx context.Context, d model.Device) error {
resolver, err := jsonb(d.Resolver)
if err != nil {
return err
}
settings, err := jsonb(d.Settings)
if err != nil {
return err
}
var fabric any
if d.Fabric != "" {
fabric = d.Fabric
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO devices (name, class, fabric, resolver, settings)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (name) DO UPDATE SET
class = EXCLUDED.class, fabric = EXCLUDED.fabric,
resolver = EXCLUDED.resolver, settings = EXCLUDED.settings`,
d.Name, d.Class, fabric, resolver, settings); err != nil {
return err
}
return bump(ctx, tx)
})
}
// RecordDeviceStatus stores the generation an agent reports as applied.
func (s *Store) RecordDeviceStatus(ctx context.Context, name string, generation int64) error {
tag, err := s.pool.Exec(ctx,
`UPDATE devices SET reported_generation = $2, last_seen = now() WHERE name = $1`,
name, generation)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) GetDevice(ctx context.Context, name string) (model.Device, error) {
var d model.Device
var resolver, settings []byte
err := s.pool.QueryRow(ctx,
`SELECT name, class, COALESCE(fabric, ''), resolver, settings FROM devices WHERE name = $1`, name,
).Scan(&d.Name, &d.Class, &d.Fabric, &resolver, &settings)
if errors.Is(err, pgx.ErrNoRows) {
return d, ErrNotFound
}
if err != nil {
return d, err
}
if err := json.Unmarshal(resolver, &d.Resolver); err != nil {
return d, err
}
return d, json.Unmarshal(settings, &d.Settings)
}
// ---- Settings, portgroups, policies ----------------------------------------
func (s *Store) GetSettings(ctx context.Context) (model.Settings, error) {
var st model.Settings
var resolver []byte
err := s.pool.QueryRow(ctx, `
SELECT address_family, log_level, ip_forwarding, table_name, default_resolver
FROM settings WHERE id = true`,
).Scan(&st.AddressFamily, &st.LogLevel, &st.IPForwarding, &st.TableName, &resolver)
if err != nil {
return st, err
}
return st, json.Unmarshal(resolver, &st.DefaultResolver)
}
func (s *Store) ListPortGroups(ctx context.Context) ([]model.PortGroup, error) {
rows, err := s.pool.Query(ctx, `SELECT name, proto, ports FROM portgroups ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.PortGroup
for rows.Next() {
var p model.PortGroup
var ports []byte
if err := rows.Scan(&p.Name, &p.Proto, &ports); err != nil {
return nil, err
}
if err := json.Unmarshal(ports, &p.Ports); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) UpsertPortGroup(ctx context.Context, p model.PortGroup) error {
ports, err := jsonb(p.Ports)
if err != nil {
return err
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO portgroups (name, proto, ports) VALUES ($1, $2, $3)
ON CONFLICT (name) DO UPDATE SET proto = EXCLUDED.proto, ports = EXCLUDED.ports`,
p.Name, p.Proto, ports); err != nil {
return err
}
return bump(ctx, tx)
})
}
func (s *Store) ListPolicies(ctx context.Context) ([]model.Policy, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, priority, source, dest, action, log FROM policies ORDER BY priority, id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Policy
for rows.Next() {
var p model.Policy
if err := rows.Scan(&p.ID, &p.Priority, &p.Source, &p.Dest, &p.Action, &p.Log); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ---- Bindings --------------------------------------------------------------
func (s *Store) ListBindings(ctx context.Context, device string) ([]model.Binding, error) {
rows, err := s.pool.Query(ctx,
`SELECT device, zone, interfaces FROM bindings WHERE device = $1 ORDER BY zone`, device)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Binding
for rows.Next() {
var b model.Binding
var ifaces []byte
if err := rows.Scan(&b.Device, &b.Zone, &ifaces); err != nil {
return nil, err
}
if err := json.Unmarshal(ifaces, &b.Interfaces); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *Store) UpsertBinding(ctx context.Context, b model.Binding) error {
ifaces, err := jsonb(b.Interfaces)
if err != nil {
return err
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO bindings (device, zone, interfaces)
VALUES ($1, $2, $3)
ON CONFLICT (device, zone) DO UPDATE SET interfaces = EXCLUDED.interfaces`,
b.Device, b.Zone, ifaces); err != nil {
return err
}
return bump(ctx, tx)
})
}
// ---- Rules -----------------------------------------------------------------
func (s *Store) ListRules(ctx context.Context) ([]model.Rule, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, priority, action, source, dest, proto, COALESCE(portgroup, ''), ports, log, comment
FROM rules ORDER BY priority, id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Rule
for rows.Next() {
var r model.Rule
var source, dest, ports []byte
if err := rows.Scan(&r.ID, &r.Priority, &r.Action, &source, &dest,
&r.Proto, &r.PortGroup, &ports, &r.Log, &r.Comment); err != nil {
return nil, err
}
if err := json.Unmarshal(source, &r.Source); err != nil {
return nil, err
}
if err := json.Unmarshal(dest, &r.Dest); err != nil {
return nil, err
}
if err := json.Unmarshal(ports, &r.Ports); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// CreateRule inserts a rule after validating its source/dest grammar, returning
// the assigned id.
func (s *Store) CreateRule(ctx context.Context, r model.Rule) (int64, error) {
if _, err := model.ParseElements(r.Source); err != nil {
return 0, fmt.Errorf("source: %w", err)
}
if _, err := model.ParseElements(r.Dest); err != nil {
return 0, fmt.Errorf("dest: %w", err)
}
source, _ := jsonb(r.Source)
dest, _ := jsonb(r.Dest)
ports, _ := jsonb(r.Ports)
var portgroup any
if r.PortGroup != "" {
portgroup = r.PortGroup
}
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO rules (priority, action, source, dest, proto, portgroup, ports, log, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
r.Priority, r.Action, source, dest, r.Proto, portgroup, ports, r.Log, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteRule(ctx context.Context, id int64) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `DELETE FROM rules WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return bump(ctx, tx)
})
}