Scaffold tomswallapi control-plane service
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
/bin/
|
||||||
|
*.out
|
||||||
|
.env
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: check-merge-conflict
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: gofmt
|
||||||
|
name: gofmt
|
||||||
|
entry: gofmt -l -d
|
||||||
|
language: system
|
||||||
|
types: [go]
|
||||||
|
pass_filenames: true
|
||||||
|
- id: go-vet
|
||||||
|
name: go vet
|
||||||
|
entry: go vet ./...
|
||||||
|
language: system
|
||||||
|
types: [go]
|
||||||
|
pass_filenames: false
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: docker-build
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
repo: git.unkin.net/unkin/tomswallapi
|
||||||
|
dry_run: true
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
ref: refs/tags/v*
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: docker-tomswallapi
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
registry: git.unkin.net
|
||||||
|
repo: git.unkin.net/unkin/tomswallapi
|
||||||
|
build_args:
|
||||||
|
VERSION: ${CI_COMMIT_TAG}
|
||||||
|
username: droneci
|
||||||
|
password:
|
||||||
|
from_secret: DRONECI_PASSWORD
|
||||||
|
tags:
|
||||||
|
- ${CI_COMMIT_TAG}
|
||||||
|
- latest
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: pre-commit
|
||||||
|
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
||||||
|
commands:
|
||||||
|
- uvx pre-commit run --all-files
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: lint
|
||||||
|
image: golang:1.25
|
||||||
|
commands:
|
||||||
|
- make lint
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
image: golang:1.25
|
||||||
|
commands:
|
||||||
|
# Container-backed DB tests self-skip when Docker is unavailable in CI.
|
||||||
|
- make test-short
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o tomswallapi ./cmd/tomswallapi
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
COPY --from=builder /build/tomswallapi /usr/local/bin/tomswallapi
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["tomswallapi"]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
VERSION ?= dev
|
||||||
|
LDFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
|
||||||
|
|
||||||
|
.PHONY: build test test-short lint fmt vet tidy run docker clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
CGO_ENABLED=0 go build $(LDFLAGS) -o bin/tomswallapi ./cmd/tomswallapi
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Tests that self-skip container-backed DB cases when Docker is unavailable.
|
||||||
|
test-short:
|
||||||
|
go test -short ./...
|
||||||
|
|
||||||
|
lint: vet
|
||||||
|
gofmt -l -d .
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./bin/tomswallapi
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build --build-arg VERSION=$(VERSION) -t tomswallapi:$(VERSION) .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin
|
||||||
|
|
||||||
|
# Version-bump targets: compute the next semver tag from the latest v* tag,
|
||||||
|
# then create and push it. The v* tag triggers the docker release pipeline.
|
||||||
|
.PHONY: patch minor major
|
||||||
|
patch: ; @$(MAKE) bump PART=patch
|
||||||
|
minor: ; @$(MAKE) bump PART=minor
|
||||||
|
major: ; @$(MAKE) bump PART=major
|
||||||
|
|
||||||
|
.PHONY: bump
|
||||||
|
bump:
|
||||||
|
@current=$$(git tag -l 'v*' --sort=-v:refname | head -1); \
|
||||||
|
current=$${current:-v0.0.0}; \
|
||||||
|
v=$${current#v}; \
|
||||||
|
major=$$(echo $$v | cut -d. -f1); \
|
||||||
|
minor=$$(echo $$v | cut -d. -f2); \
|
||||||
|
patch=$$(echo $$v | cut -d. -f3); \
|
||||||
|
case "$(PART)" in \
|
||||||
|
major) major=$$((major+1)); minor=0; patch=0;; \
|
||||||
|
minor) minor=$$((minor+1)); patch=0;; \
|
||||||
|
patch) patch=$$((patch+1));; \
|
||||||
|
*) echo "PART must be major|minor|patch"; exit 1;; \
|
||||||
|
esac; \
|
||||||
|
next="v$$major.$$minor.$$patch"; \
|
||||||
|
echo "Tagging $$next (was $$current)"; \
|
||||||
|
git tag -a "$$next" -m "Release $$next"; \
|
||||||
|
git push origin "$$next"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# tomswallapi
|
||||||
|
|
||||||
|
Fleet control plane for [tomswall](https://git.unkin.net/unkin/tomswall).
|
||||||
|
|
||||||
|
Declare zones, address groups, and firewall policy **once**; the API compiles each
|
||||||
|
intent into the concrete per-device rules every firewall and router on the path
|
||||||
|
needs, and serves each device its rendered `tomswall.yaml`. A connection that
|
||||||
|
crosses several firewalls — `src → rt1 → rt2 → rt3 → dest` — is expressed as a
|
||||||
|
single rule.
|
||||||
|
|
||||||
|
The full design (data model, compile algorithm, invariants, agent protocol) lives
|
||||||
|
in [`DESIGN.md`](https://git.unkin.net/unkin/tomswall/src/branch/main/DESIGN.md) in
|
||||||
|
the tomswall repo.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **tomswallapi** (this repo) — Terraform-managed HTTP API. Stores the fleet model
|
||||||
|
in Postgres, peers with FRR for reachability, compiles intents into per-device
|
||||||
|
configs, and serves them.
|
||||||
|
- **tomswall agent** — pulls its rendered config, runs the existing differential
|
||||||
|
`apply`, maintains dns-backed ipsets via an on-device resolver, and reports the
|
||||||
|
config generation it has applied. Does **not** fail closed on API-unreachable.
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build
|
||||||
|
# API on :8000, Postgres on :5432
|
||||||
|
curl -s localhost:8000/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is via environment variables (`TOMSWALLAPI_*`):
|
||||||
|
|
||||||
|
| var | default | purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `TOMSWALLAPI_LISTEN_ADDR` | `:8000` | HTTP listen address |
|
||||||
|
| `TOMSWALLAPI_DB_HOST` | `localhost` | Postgres host |
|
||||||
|
| `TOMSWALLAPI_DB_PORT` | `5432` | Postgres port |
|
||||||
|
| `TOMSWALLAPI_DB_USER` | `tomswallapi` | Postgres user |
|
||||||
|
| `TOMSWALLAPI_DB_PASSWORD` | — | Postgres password |
|
||||||
|
| `TOMSWALLAPI_DB_NAME` | `tomswallapi` | Postgres database |
|
||||||
|
| `TOMSWALLAPI_DB_SSLMODE` | `disable` | Postgres sslmode |
|
||||||
|
| `TOMSWALLAPI_WRITE_TOKEN` | — | bearer token guarding mutating endpoints (Terraform) |
|
||||||
|
| `TOMSWALLAPI_AGENT_TOKEN` | — | bearer token guarding the per-device config endpoint (agents) |
|
||||||
|
| `TOMSWALLAPI_IPLOCATE_API_KEY` | — | iplocate key for ASN address-group expansion |
|
||||||
|
|
||||||
|
Migrations are embedded and applied automatically on startup.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make build # build the binary
|
||||||
|
make test # run tests
|
||||||
|
make lint # gofmt + go vet
|
||||||
|
make run # build and run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
`make patch|minor|major` computes and pushes the next `v*` tag, which triggers the
|
||||||
|
Woodpecker docker pipeline to build and push the image to
|
||||||
|
`git.unkin.net/unkin/tomswallapi`.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Command tomswallapi is the fleet control-plane HTTP server for tomswall.
|
||||||
|
//
|
||||||
|
// It stores the fleet-global model (zones, address groups, portgroups, policies,
|
||||||
|
// rules, fabrics) and the per-device layer (devices, zone->interface bindings),
|
||||||
|
// compiles intents into per-device tomswall configs, and serves those configs to
|
||||||
|
// tomswall agents. The read/write API backs a Terraform provider and the agents.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/tomswallapi/internal/config"
|
||||||
|
"git.unkin.net/unkin/tomswallapi/internal/database"
|
||||||
|
"git.unkin.net/unkin/tomswallapi/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||||
|
slog.Info("starting tomswallapi", "version", version)
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("load config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, cfg.DatabaseDSN())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("connect database", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.Migrate(ctx); err != nil {
|
||||||
|
slog.Error("migrate database", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.WriteToken == "" {
|
||||||
|
slog.Warn("TOMSWALLAPI_WRITE_TOKEN is not set; write endpoints are disabled")
|
||||||
|
}
|
||||||
|
if cfg.AgentToken == "" {
|
||||||
|
slog.Warn("TOMSWALLAPI_AGENT_TOKEN is not set; agent config endpoint is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := server.New(server.Options{
|
||||||
|
DB: db,
|
||||||
|
WriteToken: cfg.WriteToken,
|
||||||
|
AgentToken: cfg.AgentToken,
|
||||||
|
Version: version,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil {
|
||||||
|
slog.Error("server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: tomswallapi
|
||||||
|
POSTGRES_PASSWORD: tomswallapi
|
||||||
|
POSTGRES_DB: tomswallapi
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U tomswallapi"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VERSION: dev
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
TOMSWALLAPI_DB_HOST: db
|
||||||
|
TOMSWALLAPI_DB_PASSWORD: tomswallapi
|
||||||
|
TOMSWALLAPI_WRITE_TOKEN: dev-write-token
|
||||||
|
TOMSWALLAPI_AGENT_TOKEN: dev-agent-token
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module git.unkin.net/unkin/tomswallapi
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package config loads tomswallapi runtime configuration from the environment.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all runtime configuration, sourced from environment variables.
|
||||||
|
type Config struct {
|
||||||
|
ListenAddr string
|
||||||
|
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
DBSSLMode string
|
||||||
|
|
||||||
|
// WriteToken guards all mutating API endpoints (used by the Terraform provider).
|
||||||
|
WriteToken string
|
||||||
|
// AgentToken guards the per-device config endpoint (used by tomswall agents).
|
||||||
|
AgentToken string
|
||||||
|
|
||||||
|
// IPLocateAPIKey is used to expand ASN address groups into prefixes.
|
||||||
|
IPLocateAPIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from the environment, applying defaults.
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
c := &Config{
|
||||||
|
ListenAddr: env("TOMSWALLAPI_LISTEN_ADDR", ":8000"),
|
||||||
|
DBHost: env("TOMSWALLAPI_DB_HOST", "localhost"),
|
||||||
|
DBPort: env("TOMSWALLAPI_DB_PORT", "5432"),
|
||||||
|
DBUser: env("TOMSWALLAPI_DB_USER", "tomswallapi"),
|
||||||
|
DBPassword: os.Getenv("TOMSWALLAPI_DB_PASSWORD"),
|
||||||
|
DBName: env("TOMSWALLAPI_DB_NAME", "tomswallapi"),
|
||||||
|
DBSSLMode: env("TOMSWALLAPI_DB_SSLMODE", "disable"),
|
||||||
|
WriteToken: os.Getenv("TOMSWALLAPI_WRITE_TOKEN"),
|
||||||
|
AgentToken: os.Getenv("TOMSWALLAPI_AGENT_TOKEN"),
|
||||||
|
IPLocateAPIKey: os.Getenv("TOMSWALLAPI_IPLOCATE_API_KEY"),
|
||||||
|
}
|
||||||
|
if c.DBName == "" {
|
||||||
|
return nil, fmt.Errorf("TOMSWALLAPI_DB_NAME must not be empty")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseDSN builds a libpq-style connection string.
|
||||||
|
func (c *Config) DatabaseDSN() string {
|
||||||
|
u := url.URL{
|
||||||
|
Scheme: "postgres",
|
||||||
|
User: url.UserPassword(c.DBUser, c.DBPassword),
|
||||||
|
Host: fmt.Sprintf("%s:%s", c.DBHost, c.DBPort),
|
||||||
|
Path: c.DBName,
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("sslmode", c.DBSSLMode)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Package database provides the Postgres connection pool and schema migrations
|
||||||
|
// for tomswallapi.
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// DB wraps a pgx connection pool.
|
||||||
|
type DB struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New opens a connection pool and verifies connectivity.
|
||||||
|
func New(ctx context.Context, dsn string) (*DB, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating pool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("pinging database: %w", err)
|
||||||
|
}
|
||||||
|
return &DB{Pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the pool.
|
||||||
|
func (db *DB) Close() { db.Pool.Close() }
|
||||||
|
|
||||||
|
// Migrate applies any pending embedded SQL migrations in lexical order. Each
|
||||||
|
// migration file is recorded in schema_migrations and applied at most once.
|
||||||
|
func (db *DB) Migrate(ctx context.Context) error {
|
||||||
|
if _, err := db.Pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("creating schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading migrations: %w", err)
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
for _, name := range files {
|
||||||
|
var exists bool
|
||||||
|
if err := db.Pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name,
|
||||||
|
).Scan(&exists); err != nil {
|
||||||
|
return fmt.Errorf("checking migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pgx.BeginFunc(ctx, db.Pool, func(tx pgx.Tx) error {
|
||||||
|
if _, err := tx.Exec(ctx, string(body)); err != nil {
|
||||||
|
return fmt.Errorf("applying %s: %w", name, err)
|
||||||
|
}
|
||||||
|
_, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Initial tomswallapi schema: fleet-global objects + per-device layer.
|
||||||
|
-- See DESIGN.md (tomswall repo) for the model this implements.
|
||||||
|
|
||||||
|
-- Global settings: a single row of fleet-wide defaults.
|
||||||
|
CREATE TABLE settings (
|
||||||
|
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), -- singleton
|
||||||
|
address_family TEXT NOT NULL DEFAULT 'inet',
|
||||||
|
log_level TEXT NOT NULL DEFAULT 'info',
|
||||||
|
ip_forwarding BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
table_name TEXT NOT NULL DEFAULT 'tomswall',
|
||||||
|
default_resolver JSONB NOT NULL DEFAULT '[]'::jsonb -- ["10.0.0.53"] or "system"
|
||||||
|
);
|
||||||
|
INSERT INTO settings (id) VALUES (true);
|
||||||
|
|
||||||
|
-- Routing domains. enforce_on_routers toggles defense-in-depth vs transparent transit.
|
||||||
|
CREATE TABLE fabrics (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
enforce_on_routers BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
description TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fleet-global zones. subnets is a list of CIDRs. parent gives subzone nesting.
|
||||||
|
CREATE TABLE zones (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL DEFAULT 'ip', -- ip | ip6 | firewall
|
||||||
|
subnets JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
parent TEXT REFERENCES zones(name) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Address groups materialize nftables named sets. type drives population source.
|
||||||
|
CREATE TABLE address_groups (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('static', 'dns', 'asn')),
|
||||||
|
members JSONB NOT NULL DEFAULT '[]'::jsonb, -- static: CIDRs; dns: FQDNs; asn: ASN numbers
|
||||||
|
refresh TEXT NOT NULL DEFAULT '', -- asn: cache TTL (e.g. 24h); dns: honor_ttl
|
||||||
|
description TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reusable port+proto combos.
|
||||||
|
CREATE TABLE portgroups (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
proto TEXT NOT NULL,
|
||||||
|
ports JSONB NOT NULL DEFAULT '[]'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Default zone-to-zone policies. priority orders evaluation (first match wins).
|
||||||
|
CREATE TABLE policies (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
dest TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
log TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fleet-global intents. source/dest use the shorewall-style element list
|
||||||
|
-- (bare zone, or zone:+ipset / zone:&fqdn). Stored as JSONB element arrays.
|
||||||
|
CREATE TABLE rules (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
source JSONB NOT NULL DEFAULT '[]'::jsonb, -- ["loc", "net:+asn_cloudflare"]
|
||||||
|
dest JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
proto TEXT NOT NULL DEFAULT '',
|
||||||
|
portgroup TEXT REFERENCES portgroups(name) ON DELETE RESTRICT,
|
||||||
|
ports JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
log TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Devices in the fleet.
|
||||||
|
CREATE TABLE devices (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
class TEXT NOT NULL CHECK (class IN ('router', 'firewall')),
|
||||||
|
fabric TEXT REFERENCES fabrics(name) ON DELETE SET NULL,
|
||||||
|
resolver JSONB NOT NULL DEFAULT '[]'::jsonb, -- per-device DNS resolver override
|
||||||
|
settings JSONB NOT NULL DEFAULT '{}'::jsonb, -- per-device settings overrides
|
||||||
|
reported_generation BIGINT NOT NULL DEFAULT 0, -- last generation the agent applied
|
||||||
|
last_seen TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The per-device zone->interface binding table (the only host-specific object).
|
||||||
|
CREATE TABLE bindings (
|
||||||
|
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
|
||||||
|
zone TEXT NOT NULL REFERENCES zones(name) ON DELETE CASCADE,
|
||||||
|
interfaces JSONB NOT NULL DEFAULT '[]'::jsonb, -- ["eth1"] or ["bond0.40"]
|
||||||
|
PRIMARY KEY (device, zone)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Monotonic generation counter bumped on any config-affecting change.
|
||||||
|
CREATE TABLE generation (
|
||||||
|
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), -- singleton
|
||||||
|
current BIGINT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
INSERT INTO generation (id) VALUES (true);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Package model holds the fleet control-plane domain types and the
|
||||||
|
// shorewall-style source/dest element grammar shared by the API and compiler.
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceClass is either a routed-core member or a zone-boundary firewall.
|
||||||
|
type DeviceClass string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClassRouter DeviceClass = "router"
|
||||||
|
ClassFirewall DeviceClass = "firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddressGroupType selects how an address group's nftables set is populated.
|
||||||
|
type AddressGroupType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GroupStatic AddressGroupType = "static" // explicit CIDRs, populated by the API
|
||||||
|
GroupDNS AddressGroupType = "dns" // FQDNs, resolved on-device
|
||||||
|
GroupASN AddressGroupType = "asn" // ASNs, expanded centrally via iplocate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Zone is a fleet-global network segment.
|
||||||
|
type Zone struct {
|
||||||
|
Name string `json:"name" yaml:"-"`
|
||||||
|
Type string `json:"type" yaml:"type"`
|
||||||
|
Subnets []string `json:"subnets" yaml:"-"`
|
||||||
|
Parent string `json:"parent,omitempty" yaml:"parents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressGroup materializes an nftables named set.
|
||||||
|
type AddressGroup struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type AddressGroupType `json:"type"`
|
||||||
|
Members []string `json:"members"`
|
||||||
|
Refresh string `json:"refresh,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetName returns the nftables set name for this group. ASN groups get the
|
||||||
|
// reserved asn_ prefix; others use their bare name.
|
||||||
|
func (g AddressGroup) SetName() string {
|
||||||
|
if g.Type == GroupASN && !strings.HasPrefix(g.Name, "asn_") {
|
||||||
|
return "asn_" + g.Name
|
||||||
|
}
|
||||||
|
return g.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device is a fleet member.
|
||||||
|
type Device struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Class DeviceClass `json:"class"`
|
||||||
|
Fabric string `json:"fabric,omitempty"`
|
||||||
|
Resolver []string `json:"resolver,omitempty"`
|
||||||
|
Settings map[string]string `json:"settings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding maps a global zone to one device's local interface(s).
|
||||||
|
type Binding struct {
|
||||||
|
Device string `json:"device"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Interfaces []string `json:"interfaces"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule is a fleet-global intent. Source and Dest are element lists (OR'd).
|
||||||
|
type Rule struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Source []string `json:"source"`
|
||||||
|
Dest []string `json:"dest"`
|
||||||
|
Proto string `json:"proto,omitempty"`
|
||||||
|
PortGroup string `json:"portgroup,omitempty"`
|
||||||
|
Ports []string `json:"ports,omitempty"`
|
||||||
|
Log string `json:"log,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selector kinds within a source/dest element.
|
||||||
|
type SelectorKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SelIPSet SelectorKind = "ipset" // +name
|
||||||
|
SelFQDN SelectorKind = "fqdn" // &name
|
||||||
|
SelNone SelectorKind = "" // bare zone
|
||||||
|
)
|
||||||
|
|
||||||
|
// Element is one comma-separated token of a source/dest list. A zone is always
|
||||||
|
// present; the selector, when set, narrows within that zone (an AND).
|
||||||
|
type Element struct {
|
||||||
|
Zone string
|
||||||
|
Selector SelectorKind
|
||||||
|
Ref string // the ipset/fqdn-group name when Selector != SelNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseElement parses a single shorewall-style element:
|
||||||
|
//
|
||||||
|
// loc -> bare zone
|
||||||
|
// net:+asn_cloudflare -> zone gated by an ipset
|
||||||
|
// dmz:&api.partner -> zone gated by an fqdn group
|
||||||
|
//
|
||||||
|
// A bare selector (no zone) is rejected: every selector must be paired with a zone.
|
||||||
|
func ParseElement(s string) (Element, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return Element{}, fmt.Errorf("empty element")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject a leading selector sigil: bare selectors are not allowed.
|
||||||
|
if s[0] == '+' || s[0] == '&' {
|
||||||
|
return Element{}, fmt.Errorf("selector %q must be paired with a zone (write zone:%s)", s, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
zone, sel, hasSel := strings.Cut(s, ":")
|
||||||
|
zone = strings.TrimSpace(zone)
|
||||||
|
if zone == "" {
|
||||||
|
return Element{}, fmt.Errorf("element %q has an empty zone", s)
|
||||||
|
}
|
||||||
|
e := Element{Zone: zone, Selector: SelNone}
|
||||||
|
if !hasSel {
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sel = strings.TrimSpace(sel)
|
||||||
|
if sel == "" {
|
||||||
|
return Element{}, fmt.Errorf("element %q has a trailing colon with no selector", s)
|
||||||
|
}
|
||||||
|
switch sel[0] {
|
||||||
|
case '+':
|
||||||
|
e.Selector, e.Ref = SelIPSet, sel[1:]
|
||||||
|
case '&':
|
||||||
|
e.Selector, e.Ref = SelFQDN, sel[1:]
|
||||||
|
default:
|
||||||
|
return Element{}, fmt.Errorf("selector %q must start with + (ipset) or & (fqdn)", sel)
|
||||||
|
}
|
||||||
|
if e.Ref == "" {
|
||||||
|
return Element{}, fmt.Errorf("element %q has an empty selector reference", s)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseElements parses and validates a full source/dest element list.
|
||||||
|
func ParseElements(list []string) ([]Element, error) {
|
||||||
|
out := make([]Element, 0, len(list))
|
||||||
|
for _, s := range list {
|
||||||
|
e, err := ParseElement(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mountResources wires the Terraform-facing CRUD endpoints for every resource in
|
||||||
|
// the model. Handlers are stubbed pending the storage layer (see task: domain
|
||||||
|
// model + Postgres storage).
|
||||||
|
func (s *Server) mountResources(r chi.Router) {
|
||||||
|
for _, name := range resourceCollections {
|
||||||
|
r.Route("/"+name, func(r chi.Router) {
|
||||||
|
r.Get("/", s.notImplemented)
|
||||||
|
r.Post("/", s.notImplemented)
|
||||||
|
r.Get("/{id}", s.notImplemented)
|
||||||
|
r.Put("/{id}", s.notImplemented)
|
||||||
|
r.Delete("/{id}", s.notImplemented)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resourceCollections are the REST collection paths exposed to Terraform, one per
|
||||||
|
// model resource.
|
||||||
|
var resourceCollections = []string{
|
||||||
|
"fabrics",
|
||||||
|
"zones",
|
||||||
|
"address-groups",
|
||||||
|
"portgroups",
|
||||||
|
"policies",
|
||||||
|
"rules",
|
||||||
|
"devices",
|
||||||
|
"bindings",
|
||||||
|
"snat",
|
||||||
|
"netmap",
|
||||||
|
"nat",
|
||||||
|
"blrules",
|
||||||
|
"conntrack",
|
||||||
|
"hosts",
|
||||||
|
"providers",
|
||||||
|
"routes",
|
||||||
|
"routing-rules",
|
||||||
|
"tunnels",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.notImplemented(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.notImplemented(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) notImplemented(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeError(w, http.StatusNotImplemented, "not implemented yet")
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Package server wires the tomswallapi HTTP API: health, the Terraform-facing
|
||||||
|
// read/write endpoints, and the per-device config endpoint agents pull from.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/tomswallapi/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures a Server.
|
||||||
|
type Options struct {
|
||||||
|
DB *database.DB
|
||||||
|
WriteToken string
|
||||||
|
AgentToken string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server serves the tomswallapi HTTP API.
|
||||||
|
type Server struct {
|
||||||
|
db *database.DB
|
||||||
|
writeToken string
|
||||||
|
agentToken string
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Server.
|
||||||
|
func New(o Options) *Server {
|
||||||
|
return &Server{
|
||||||
|
db: o.DB,
|
||||||
|
writeToken: o.WriteToken,
|
||||||
|
agentToken: o.AgentToken,
|
||||||
|
version: o.Version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe starts the HTTP server and blocks until ctx is cancelled, then
|
||||||
|
// shuts down gracefully.
|
||||||
|
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: s.routes(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
slog.Info("listening", "addr", addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("shutting down")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return srv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) routes() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/healthz", s.handleHealth)
|
||||||
|
r.Get("/version", s.handleVersion)
|
||||||
|
|
||||||
|
// Terraform-facing read/write API. Mutations require the write token.
|
||||||
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.requireToken(s.writeToken))
|
||||||
|
s.mountResources(r)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Per-device config endpoint the tomswall agents pull from.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.requireToken(s.agentToken))
|
||||||
|
r.Get("/devices/{name}/config", s.handleDeviceConfig)
|
||||||
|
r.Post("/devices/{name}/status", s.handleDeviceStatus)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.db.Pool.Ping(r.Context()); err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "db_unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"version": s.version})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireToken returns middleware enforcing a bearer token. An empty configured
|
||||||
|
// token disables the guarded group (returns 503) so a misconfigured deploy fails
|
||||||
|
// closed on writes rather than serving them unauthenticated.
|
||||||
|
func (s *Server) requireToken(want string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if want == "" {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "endpoint disabled: token not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prefix = "Bearer "
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if len(auth) <= len(prefix) || auth[:len(prefix)] != prefix || auth[len(prefix):] != want {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid or missing token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user