diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edd210c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/bin/ +*.out +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3d17912 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + + - repo: local + hooks: + - id: gofmt + name: gofmt + entry: gofmt -l -d + language: system + types: [go] + pass_filenames: true + - id: go-vet + name: go vet + entry: go vet ./... + language: system + types: [go] + pass_filenames: false diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..82e6671 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,19 @@ +when: + - event: pull_request + +steps: + - name: docker-build + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.unkin.net/unkin/tomswallapi + dry_run: true + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..80e74fe --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,28 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: docker-tomswallapi + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/tomswallapi + build_args: + VERSION: ${CI_COMMIT_TAG} + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..d7bc573 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,34 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golang:1.25 + commands: + - make lint + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: test + image: golang:1.25 + commands: + # Container-backed DB tests self-skip when Docker is unavailable in CI. + - make test-short + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae96367 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o tomswallapi ./cmd/tomswallapi + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/tomswallapi /usr/local/bin/tomswallapi + +EXPOSE 8000 + +ENTRYPOINT ["tomswallapi"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..11bf482 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +VERSION ?= dev +LDFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" + +.PHONY: build test test-short lint fmt vet tidy run docker clean + +build: + CGO_ENABLED=0 go build $(LDFLAGS) -o bin/tomswallapi ./cmd/tomswallapi + +test: + go test ./... + +# Tests that self-skip container-backed DB cases when Docker is unavailable. +test-short: + go test -short ./... + +lint: vet + gofmt -l -d . + +fmt: + gofmt -w . + +vet: + go vet ./... + +tidy: + go mod tidy + +run: build + ./bin/tomswallapi + +docker: + docker build --build-arg VERSION=$(VERSION) -t tomswallapi:$(VERSION) . + +clean: + rm -rf bin + +# Version-bump targets: compute the next semver tag from the latest v* tag, +# then create and push it. The v* tag triggers the docker release pipeline. +.PHONY: patch minor major +patch: ; @$(MAKE) bump PART=patch +minor: ; @$(MAKE) bump PART=minor +major: ; @$(MAKE) bump PART=major + +.PHONY: bump +bump: + @current=$$(git tag -l 'v*' --sort=-v:refname | head -1); \ + current=$${current:-v0.0.0}; \ + v=$${current#v}; \ + major=$$(echo $$v | cut -d. -f1); \ + minor=$$(echo $$v | cut -d. -f2); \ + patch=$$(echo $$v | cut -d. -f3); \ + case "$(PART)" in \ + major) major=$$((major+1)); minor=0; patch=0;; \ + minor) minor=$$((minor+1)); patch=0;; \ + patch) patch=$$((patch+1));; \ + *) echo "PART must be major|minor|patch"; exit 1;; \ + esac; \ + next="v$$major.$$minor.$$patch"; \ + echo "Tagging $$next (was $$current)"; \ + git tag -a "$$next" -m "Release $$next"; \ + git push origin "$$next" diff --git a/README.md b/README.md index a343771..a3d3a5b 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +Fleet control plane for [tomswall](https://git.unkin.net/unkin/tomswall). + +Declare zones, address groups, and firewall policy **once**; the API compiles each +intent into the concrete per-device rules every firewall and router on the path +needs, and serves each device its rendered `tomswall.yaml`. A connection that +crosses several firewalls — `src → rt1 → rt2 → rt3 → dest` — is expressed as a +single rule. + +The full design (data model, compile algorithm, invariants, agent protocol) lives +in [`DESIGN.md`](https://git.unkin.net/unkin/tomswall/src/branch/main/DESIGN.md) in +the tomswall repo. + +## Architecture + +- **tomswallapi** (this repo) — Terraform-managed HTTP API. Stores the fleet model + in Postgres, peers with FRR for reachability, compiles intents into per-device + configs, and serves them. +- **tomswall agent** — pulls its rendered config, runs the existing differential + `apply`, maintains dns-backed ipsets via an on-device resolver, and reports the + config generation it has applied. Does **not** fail closed on API-unreachable. + +## Running locally + +```sh +docker compose up --build +# API on :8000, Postgres on :5432 +curl -s localhost:8000/healthz +``` + +## Configuration + +All configuration is via environment variables (`TOMSWALLAPI_*`): + +| var | default | purpose | +|---|---|---| +| `TOMSWALLAPI_LISTEN_ADDR` | `:8000` | HTTP listen address | +| `TOMSWALLAPI_DB_HOST` | `localhost` | Postgres host | +| `TOMSWALLAPI_DB_PORT` | `5432` | Postgres port | +| `TOMSWALLAPI_DB_USER` | `tomswallapi` | Postgres user | +| `TOMSWALLAPI_DB_PASSWORD` | — | Postgres password | +| `TOMSWALLAPI_DB_NAME` | `tomswallapi` | Postgres database | +| `TOMSWALLAPI_DB_SSLMODE` | `disable` | Postgres sslmode | +| `TOMSWALLAPI_WRITE_TOKEN` | — | bearer token guarding mutating endpoints (Terraform) | +| `TOMSWALLAPI_AGENT_TOKEN` | — | bearer token guarding the per-device config endpoint (agents) | +| `TOMSWALLAPI_IPLOCATE_API_KEY` | — | iplocate key for ASN address-group expansion | + +Migrations are embedded and applied automatically on startup. + +## Development + +```sh +make build # build the binary +make test # run tests +make lint # gofmt + go vet +make run # build and run +``` + +## Releases + +`make patch|minor|major` computes and pushes the next `v*` tag, which triggers the +Woodpecker docker pipeline to build and push the image to +`git.unkin.net/unkin/tomswallapi`. diff --git a/cmd/tomswallapi/main.go b/cmd/tomswallapi/main.go new file mode 100644 index 0000000..a0ee1fe --- /dev/null +++ b/cmd/tomswallapi/main.go @@ -0,0 +1,66 @@ +// Command tomswallapi is the fleet control-plane HTTP server for tomswall. +// +// It stores the fleet-global model (zones, address groups, portgroups, policies, +// rules, fabrics) and the per-device layer (devices, zone->interface bindings), +// compiles intents into per-device tomswall configs, and serves those configs to +// tomswall agents. The read/write API backs a Terraform provider and the agents. +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "git.unkin.net/unkin/tomswallapi/internal/config" + "git.unkin.net/unkin/tomswallapi/internal/database" + "git.unkin.net/unkin/tomswallapi/internal/server" +) + +var version = "dev" + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + slog.Info("starting tomswallapi", "version", version) + + cfg, err := config.Load() + if err != nil { + slog.Error("load config", "err", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + db, err := database.New(ctx, cfg.DatabaseDSN()) + if err != nil { + slog.Error("connect database", "err", err) + os.Exit(1) + } + defer db.Close() + + if err := db.Migrate(ctx); err != nil { + slog.Error("migrate database", "err", err) + os.Exit(1) + } + + if cfg.WriteToken == "" { + slog.Warn("TOMSWALLAPI_WRITE_TOKEN is not set; write endpoints are disabled") + } + if cfg.AgentToken == "" { + slog.Warn("TOMSWALLAPI_AGENT_TOKEN is not set; agent config endpoint is disabled") + } + + srv := server.New(server.Options{ + DB: db, + WriteToken: cfg.WriteToken, + AgentToken: cfg.AgentToken, + Version: version, + }) + + if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil { + slog.Error("server", "err", err) + os.Exit(1) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3c4212 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: tomswallapi + POSTGRES_PASSWORD: tomswallapi + POSTGRES_DB: tomswallapi + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tomswallapi"] + interval: 5s + timeout: 3s + retries: 5 + + api: + build: + context: . + args: + VERSION: dev + depends_on: + db: + condition: service_healthy + environment: + TOMSWALLAPI_DB_HOST: db + TOMSWALLAPI_DB_PASSWORD: tomswallapi + TOMSWALLAPI_WRITE_TOKEN: dev-write-token + TOMSWALLAPI_AGENT_TOKEN: dev-agent-token + ports: + - "8000:8000" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d9f0f7a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..df514e9 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go new file mode 100644 index 0000000..da439fd --- /dev/null +++ b/internal/compiler/compiler.go @@ -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) +} diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go new file mode 100644 index 0000000..b0b2602 --- /dev/null +++ b/internal/compiler/compiler_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d5140ec --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,69 @@ +// Package config loads tomswallapi runtime configuration from the environment. +package config + +import ( + "fmt" + "net/url" + "os" +) + +// Config holds all runtime configuration, sourced from environment variables. +type Config struct { + ListenAddr string + + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string + DBSSLMode string + + // WriteToken guards all mutating API endpoints (used by the Terraform provider). + WriteToken string + // AgentToken guards the per-device config endpoint (used by tomswall agents). + AgentToken string + + // IPLocateAPIKey is used to expand ASN address groups into prefixes. + IPLocateAPIKey string +} + +// Load reads configuration from the environment, applying defaults. +func Load() (*Config, error) { + c := &Config{ + ListenAddr: env("TOMSWALLAPI_LISTEN_ADDR", ":8000"), + DBHost: env("TOMSWALLAPI_DB_HOST", "localhost"), + DBPort: env("TOMSWALLAPI_DB_PORT", "5432"), + DBUser: env("TOMSWALLAPI_DB_USER", "tomswallapi"), + DBPassword: os.Getenv("TOMSWALLAPI_DB_PASSWORD"), + DBName: env("TOMSWALLAPI_DB_NAME", "tomswallapi"), + DBSSLMode: env("TOMSWALLAPI_DB_SSLMODE", "disable"), + WriteToken: os.Getenv("TOMSWALLAPI_WRITE_TOKEN"), + AgentToken: os.Getenv("TOMSWALLAPI_AGENT_TOKEN"), + IPLocateAPIKey: os.Getenv("TOMSWALLAPI_IPLOCATE_API_KEY"), + } + if c.DBName == "" { + return nil, fmt.Errorf("TOMSWALLAPI_DB_NAME must not be empty") + } + return c, nil +} + +// DatabaseDSN builds a libpq-style connection string. +func (c *Config) DatabaseDSN() string { + u := url.URL{ + Scheme: "postgres", + User: url.UserPassword(c.DBUser, c.DBPassword), + Host: fmt.Sprintf("%s:%s", c.DBHost, c.DBPort), + Path: c.DBName, + } + q := u.Query() + q.Set("sslmode", c.DBSSLMode) + u.RawQuery = q.Encode() + return u.String() +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..98f9acc --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,90 @@ +// Package database provides the Postgres connection pool and schema migrations +// for tomswallapi. +package database + +import ( + "context" + "embed" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// DB wraps a pgx connection pool. +type DB struct { + Pool *pgxpool.Pool +} + +// New opens a connection pool and verifies connectivity. +func New(ctx context.Context, dsn string) (*DB, error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("creating pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + return &DB{Pool: pool}, nil +} + +// Close releases the pool. +func (db *DB) Close() { db.Pool.Close() } + +// Migrate applies any pending embedded SQL migrations in lexical order. Each +// migration file is recorded in schema_migrations and applied at most once. +func (db *DB) Migrate(ctx context.Context) error { + if _, err := db.Pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + return fmt.Errorf("creating schema_migrations: %w", err) + } + + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("reading migrations: %w", err) + } + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + files = append(files, e.Name()) + } + } + sort.Strings(files) + + for _, name := range files { + var exists bool + if err := db.Pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name, + ).Scan(&exists); err != nil { + return fmt.Errorf("checking migration %s: %w", name, err) + } + if exists { + continue + } + + body, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("reading migration %s: %w", name, err) + } + + if err := pgx.BeginFunc(ctx, db.Pool, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, string(body)); err != nil { + return fmt.Errorf("applying %s: %w", name, err) + } + _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name) + return err + }); err != nil { + return err + } + } + return nil +} diff --git a/internal/database/migrations/0001_init.sql b/internal/database/migrations/0001_init.sql new file mode 100644 index 0000000..0523f1e --- /dev/null +++ b/internal/database/migrations/0001_init.sql @@ -0,0 +1,95 @@ +-- Initial tomswallapi schema: fleet-global objects + per-device layer. +-- See DESIGN.md (tomswall repo) for the model this implements. + +-- Global settings: a single row of fleet-wide defaults. +CREATE TABLE settings ( + id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), -- singleton + address_family TEXT NOT NULL DEFAULT 'inet', + log_level TEXT NOT NULL DEFAULT 'info', + ip_forwarding BOOLEAN NOT NULL DEFAULT true, + table_name TEXT NOT NULL DEFAULT 'tomswall', + default_resolver JSONB NOT NULL DEFAULT '[]'::jsonb -- ["10.0.0.53"] or "system" +); +INSERT INTO settings (id) VALUES (true); + +-- Routing domains. enforce_on_routers toggles defense-in-depth vs transparent transit. +CREATE TABLE fabrics ( + name TEXT PRIMARY KEY, + enforce_on_routers BOOLEAN NOT NULL DEFAULT false, + description TEXT NOT NULL DEFAULT '' +); + +-- Fleet-global zones. subnets is a list of CIDRs. parent gives subzone nesting. +CREATE TABLE zones ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'ip', -- ip | ip6 | firewall + subnets JSONB NOT NULL DEFAULT '[]'::jsonb, + parent TEXT REFERENCES zones(name) ON DELETE RESTRICT +); + +-- Address groups materialize nftables named sets. type drives population source. +CREATE TABLE address_groups ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK (type IN ('static', 'dns', 'asn')), + members JSONB NOT NULL DEFAULT '[]'::jsonb, -- static: CIDRs; dns: FQDNs; asn: ASN numbers + refresh TEXT NOT NULL DEFAULT '', -- asn: cache TTL (e.g. 24h); dns: honor_ttl + description TEXT NOT NULL DEFAULT '' +); + +-- Reusable port+proto combos. +CREATE TABLE portgroups ( + name TEXT PRIMARY KEY, + proto TEXT NOT NULL, + ports JSONB NOT NULL DEFAULT '[]'::jsonb +); + +-- Default zone-to-zone policies. priority orders evaluation (first match wins). +CREATE TABLE policies ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + priority INT NOT NULL DEFAULT 0, + source TEXT NOT NULL, + dest TEXT NOT NULL, + action TEXT NOT NULL, + log TEXT NOT NULL DEFAULT '' +); + +-- Fleet-global intents. source/dest use the shorewall-style element list +-- (bare zone, or zone:+ipset / zone:&fqdn). Stored as JSONB element arrays. +CREATE TABLE rules ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + priority INT NOT NULL DEFAULT 0, + action TEXT NOT NULL, + source JSONB NOT NULL DEFAULT '[]'::jsonb, -- ["loc", "net:+asn_cloudflare"] + dest JSONB NOT NULL DEFAULT '[]'::jsonb, + proto TEXT NOT NULL DEFAULT '', + portgroup TEXT REFERENCES portgroups(name) ON DELETE RESTRICT, + ports JSONB NOT NULL DEFAULT '[]'::jsonb, + log TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '' +); + +-- Devices in the fleet. +CREATE TABLE devices ( + name TEXT PRIMARY KEY, + class TEXT NOT NULL CHECK (class IN ('router', 'firewall')), + fabric TEXT REFERENCES fabrics(name) ON DELETE SET NULL, + resolver JSONB NOT NULL DEFAULT '[]'::jsonb, -- per-device DNS resolver override + settings JSONB NOT NULL DEFAULT '{}'::jsonb, -- per-device settings overrides + reported_generation BIGINT NOT NULL DEFAULT 0, -- last generation the agent applied + last_seen TIMESTAMPTZ +); + +-- The per-device zone->interface binding table (the only host-specific object). +CREATE TABLE bindings ( + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + zone TEXT NOT NULL REFERENCES zones(name) ON DELETE CASCADE, + interfaces JSONB NOT NULL DEFAULT '[]'::jsonb, -- ["eth1"] or ["bond0.40"] + PRIMARY KEY (device, zone) +); + +-- Monotonic generation counter bumped on any config-affecting change. +CREATE TABLE generation ( + id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), -- singleton + current BIGINT NOT NULL DEFAULT 1 +); +INSERT INTO generation (id) VALUES (true); diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..d32e462 --- /dev/null +++ b/internal/model/model.go @@ -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 +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..5e3bf64 --- /dev/null +++ b/internal/model/model_test.go @@ -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) + } + } +} diff --git a/internal/server/resources.go b/internal/server/resources.go new file mode 100644 index 0000000..8b1c38e --- /dev/null +++ b/internal/server/resources.go @@ -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) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..fd01a3f --- /dev/null +++ b/internal/server/server.go @@ -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}) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e8bb6f0 --- /dev/null +++ b/internal/store/store.go @@ -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) + }) +}