Scaffold the tomswallapi control plane #1
@@ -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"
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user