Add NAT-tier resources and testcontainers integration tests
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

- Add snat/masquerade, netmap, and 1:1 nat as stored, terraformable resources:
  migration 0003, model types, store CRUD (id-keyed, generation-bumping), and
  REST handlers. These are the global-intent/device-resolved NAT tier; compiler
  rendering of NAT into per-device configs is a tracked follow-up.
- Add a testcontainers-backed store integration suite exercising the CRUD
  lifecycle, generation bumping, source/dest grammar validation, and FK cascade
  against a real Postgres. It self-skips under 'go test -short' (the CI path) so
  a container runtime is only needed for the full run.
This commit is contained in:
benvin
2026-07-20 22:25:36 +10:00
parent de8ce4b4ec
commit 5060804359
8 changed files with 733 additions and 10 deletions
@@ -0,0 +1,37 @@
-- NAT-tier resources: SNAT/masquerade, netmap, and 1:1 static NAT. These are the
-- "global-intent, device-resolved" tier — declared centrally, resolved per device
-- via bindings. Stored here so they are terraformable; compiler rendering of the
-- NAT tier into per-device configs is a tracked follow-up.
-- SNAT / masquerade. source is a zone name or CIDR; egress is a zone (resolved to
-- the device's egress interface via its binding). address+probability support
-- load-balanced SNAT.
CREATE TABLE snat (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action TEXT NOT NULL CHECK (action IN ('masquerade', 'snat')),
source TEXT NOT NULL,
egress TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '',
probability REAL,
comment TEXT NOT NULL DEFAULT ''
);
-- Network-to-network mapping (netmap). Anchored at a device+binding or interface.
CREATE TABLE netmap (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('dnat', 'snat')),
from_net TEXT NOT NULL,
to_net TEXT NOT NULL,
anchor TEXT NOT NULL, -- device:zone or device:interface
comment TEXT NOT NULL DEFAULT ''
);
-- One-to-one static NAT, bound to the device that owns the external IP.
CREATE TABLE nat (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
external TEXT NOT NULL,
internal TEXT NOT NULL,
interface TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT ''
);
+34
View File
@@ -0,0 +1,34 @@
package model
// SNATRule is a source-NAT / masquerade intent. Source is a zone or CIDR; Egress
// is a zone (resolved per device to its egress interface via bindings). Address
// and Probability support load-balanced SNAT.
type SNATRule struct {
ID int64 `json:"id"`
Action string `json:"action"` // masquerade | snat
Source string `json:"source"`
Egress string `json:"egress"`
Address string `json:"address,omitempty"`
Probability *float64 `json:"probability,omitempty"`
Comment string `json:"comment,omitempty"`
}
// NetmapRule maps one network to another, anchored at a device+zone or interface.
type NetmapRule struct {
ID int64 `json:"id"`
Type string `json:"type"` // dnat | snat
FromNet string `json:"from_net"`
ToNet string `json:"to_net"`
Anchor string `json:"anchor"`
Comment string `json:"comment,omitempty"`
}
// NATRule is a one-to-one static NAT bound to the device owning the external IP.
type NATRule struct {
ID int64 `json:"id"`
Device string `json:"device"`
External string `json:"external"`
Internal string `json:"internal"`
Interface string `json:"interface,omitempty"`
Comment string `json:"comment,omitempty"`
}
+167
View File
@@ -0,0 +1,167 @@
package server
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// mountNAT wires the NAT-tier resources (snat/masquerade, netmap, 1:1 nat).
func (s *Server) mountNAT(r chi.Router) {
r.Route("/snat", func(r chi.Router) {
r.Get("/", s.listSNAT)
r.Post("/", s.createSNAT)
r.Get("/{id}", s.getSNAT)
r.Delete("/{id}", s.deleteSNAT)
})
r.Route("/netmap", func(r chi.Router) {
r.Get("/", s.listNetmap)
r.Post("/", s.createNetmap)
r.Get("/{id}", s.getNetmap)
r.Delete("/{id}", s.deleteNetmap)
})
r.Route("/nat", func(r chi.Router) {
r.Get("/", s.listNAT)
r.Post("/", s.createNAT)
r.Get("/{id}", s.getNAT)
r.Delete("/{id}", s.deleteNAT)
})
}
func idParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return 0, false
}
return id, true
}
// ---- SNAT ------------------------------------------------------------------
func (s *Server) listSNAT(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListSNAT(r.Context())
respondList(w, list, err)
}
func (s *Server) createSNAT(w http.ResponseWriter, r *http.Request) {
var v model.SNATRule
if !decode(w, r, &v) {
return
}
if v.Action != "masquerade" && v.Action != "snat" {
writeError(w, http.StatusBadRequest, "action must be masquerade or snat")
return
}
id, err := s.store.CreateSNAT(r.Context(), v)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v.ID = id
writeJSON(w, http.StatusCreated, v)
}
func (s *Server) getSNAT(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetSNAT(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteSNAT(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteSNAT(r.Context(), id))
}
// ---- Netmap ----------------------------------------------------------------
func (s *Server) listNetmap(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListNetmap(r.Context())
respondList(w, list, err)
}
func (s *Server) createNetmap(w http.ResponseWriter, r *http.Request) {
var v model.NetmapRule
if !decode(w, r, &v) {
return
}
if v.Type != "dnat" && v.Type != "snat" {
writeError(w, http.StatusBadRequest, "type must be dnat or snat")
return
}
id, err := s.store.CreateNetmap(r.Context(), v)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v.ID = id
writeJSON(w, http.StatusCreated, v)
}
func (s *Server) getNetmap(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetNetmap(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteNetmap(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteNetmap(r.Context(), id))
}
// ---- 1:1 NAT ---------------------------------------------------------------
func (s *Server) listNAT(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListNAT(r.Context())
respondList(w, list, err)
}
func (s *Server) createNAT(w http.ResponseWriter, r *http.Request) {
var v model.NATRule
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.External == "" || v.Internal == "" {
writeError(w, http.StatusBadRequest, "device, external, and internal are required")
return
}
id, err := s.store.CreateNAT(r.Context(), v)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v.ID = id
writeJSON(w, http.StatusCreated, v)
}
func (s *Server) getNAT(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetNAT(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteNAT(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteNAT(r.Context(), id))
}
+1
View File
@@ -59,6 +59,7 @@ func (s *Server) mountResources(r chi.Router) {
r.Get("/{id}", s.getRule)
r.Delete("/{id}", s.deleteRule)
})
s.mountNAT(r)
}
// respondOne writes a single resource, mapping ErrNotFound to 404.
+160
View File
@@ -0,0 +1,160 @@
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// ---- SNAT / masquerade -----------------------------------------------------
func (s *Store) ListSNAT(ctx context.Context) ([]model.SNATRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, action, source, egress, address, probability, comment FROM snat ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.SNATRule
for rows.Next() {
var r model.SNATRule
if err := rows.Scan(&r.ID, &r.Action, &r.Source, &r.Egress, &r.Address, &r.Probability, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetSNAT(ctx context.Context, id int64) (model.SNATRule, error) {
var r model.SNATRule
err := s.pool.QueryRow(ctx,
`SELECT id, action, source, egress, address, probability, comment FROM snat WHERE id = $1`, id,
).Scan(&r.ID, &r.Action, &r.Source, &r.Egress, &r.Address, &r.Probability, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateSNAT(ctx context.Context, r model.SNATRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO snat (action, source, egress, address, probability, comment)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
r.Action, r.Source, r.Egress, r.Address, r.Probability, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteSNAT(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM snat WHERE id = $1`, id)
}
// ---- Netmap ----------------------------------------------------------------
func (s *Store) ListNetmap(ctx context.Context) ([]model.NetmapRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, type, from_net, to_net, anchor, comment FROM netmap ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.NetmapRule
for rows.Next() {
var r model.NetmapRule
if err := rows.Scan(&r.ID, &r.Type, &r.FromNet, &r.ToNet, &r.Anchor, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetNetmap(ctx context.Context, id int64) (model.NetmapRule, error) {
var r model.NetmapRule
err := s.pool.QueryRow(ctx,
`SELECT id, type, from_net, to_net, anchor, comment FROM netmap WHERE id = $1`, id,
).Scan(&r.ID, &r.Type, &r.FromNet, &r.ToNet, &r.Anchor, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateNetmap(ctx context.Context, r model.NetmapRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO netmap (type, from_net, to_net, anchor, comment)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
r.Type, r.FromNet, r.ToNet, r.Anchor, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteNetmap(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM netmap WHERE id = $1`, id)
}
// ---- 1:1 static NAT --------------------------------------------------------
func (s *Store) ListNAT(ctx context.Context) ([]model.NATRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, external, internal, interface, comment FROM nat ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.NATRule
for rows.Next() {
var r model.NATRule
if err := rows.Scan(&r.ID, &r.Device, &r.External, &r.Internal, &r.Interface, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetNAT(ctx context.Context, id int64) (model.NATRule, error) {
var r model.NATRule
err := s.pool.QueryRow(ctx,
`SELECT id, device, external, internal, interface, comment FROM nat WHERE id = $1`, id,
).Scan(&r.ID, &r.Device, &r.External, &r.Internal, &r.Interface, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateNAT(ctx context.Context, r model.NATRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO nat (device, external, internal, interface, comment)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
r.Device, r.External, r.Internal, r.Interface, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteNAT(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM nat WHERE id = $1`, id)
}
+151
View File
@@ -0,0 +1,151 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"git.unkin.net/unkin/tomswallapi/internal/database"
"git.unkin.net/unkin/tomswallapi/internal/model"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// newTestStore spins up a throwaway Postgres, applies migrations, and returns a
// Store. It skips (rather than fails) when Docker is unavailable, so the default
// `go test -short` run in CI passes without a container runtime.
func newTestStore(t *testing.T) *store.Store {
t.Helper()
if testing.Short() {
t.Skip("skipping container-backed test in -short mode")
}
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:17-alpine",
postgres.WithDatabase("tomswallapi"),
postgres.WithUsername("tomswallapi"),
postgres.WithPassword("tomswallapi"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).WithStartupTimeout(60*time.Second)),
)
if err != nil {
t.Skipf("skipping: cannot start postgres container (Docker unavailable?): %v", err)
}
t.Cleanup(func() { _ = pg.Terminate(ctx) })
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
db, err := database.New(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(db.Close)
if err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
return store.New(db.Pool)
}
func TestZoneLifecycleAndGeneration(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
gen0, err := s.Generation(ctx)
if err != nil {
t.Fatalf("generation: %v", err)
}
if err := s.UpsertZone(ctx, model.Zone{Name: "zone-a", Type: "ip", Subnets: []string{"10.1.0.0/24"}}); err != nil {
t.Fatalf("upsert zone: %v", err)
}
gen1, _ := s.Generation(ctx)
if gen1 <= gen0 {
t.Errorf("generation should bump on write: %d -> %d", gen0, gen1)
}
got, err := s.GetZone(ctx, "zone-a")
if err != nil {
t.Fatalf("get zone: %v", err)
}
if len(got.Subnets) != 1 || got.Subnets[0] != "10.1.0.0/24" {
t.Errorf("subnets round-trip failed: %+v", got)
}
zones, err := s.ListZones(ctx)
if err != nil || len(zones) != 1 {
t.Fatalf("list zones = %d (%v)", len(zones), err)
}
if err := s.DeleteZone(ctx, "zone-a"); err != nil {
t.Fatalf("delete zone: %v", err)
}
if _, err := s.GetZone(ctx, "zone-a"); err != store.ErrNotFound {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
gen2, _ := s.Generation(ctx)
if gen2 <= gen1 {
t.Errorf("generation should bump on delete: %d -> %d", gen1, gen2)
}
}
func TestRuleGrammarValidationAtStore(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// Valid rule with a paired selector.
if _, err := s.CreateRule(ctx, model.Rule{
Action: "accept", Source: []string{"loc"}, Dest: []string{"net:+cloudflare"},
}); err != nil {
t.Fatalf("valid rule rejected: %v", err)
}
// Bare selector must be rejected before it reaches the DB.
if _, err := s.CreateRule(ctx, model.Rule{
Action: "accept", Source: []string{"+cloudflare"}, Dest: []string{"loc"},
}); err == nil {
t.Fatal("bare selector rule should be rejected")
}
}
func TestNATTierRoundTrip(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// nat references a device via FK.
if err := s.UpsertDevice(ctx, model.Device{Name: "fw-a", Class: model.ClassFirewall}); err != nil {
t.Fatalf("upsert device: %v", err)
}
sid, err := s.CreateSNAT(ctx, model.SNATRule{Action: "masquerade", Source: "loc", Egress: "net"})
if err != nil {
t.Fatalf("create snat: %v", err)
}
if got, err := s.GetSNAT(ctx, sid); err != nil || got.Action != "masquerade" {
t.Errorf("snat round-trip: %+v (%v)", got, err)
}
nid, err := s.CreateNAT(ctx, model.NATRule{Device: "fw-a", External: "203.0.113.10", Internal: "10.1.0.10"})
if err != nil {
t.Fatalf("create nat: %v", err)
}
if err := s.DeleteNAT(ctx, nid); err != nil {
t.Fatalf("delete nat: %v", err)
}
if err := s.DeleteNAT(ctx, nid); err != store.ErrNotFound {
t.Errorf("second delete should be ErrNotFound, got %v", err)
}
// Deleting the device cascades to its nat rows.
_, _ = s.CreateNAT(ctx, model.NATRule{Device: "fw-a", External: "203.0.113.11", Internal: "10.1.0.11"})
if err := s.DeleteDevice(ctx, "fw-a"); err != nil {
t.Fatalf("delete device: %v", err)
}
nats, _ := s.ListNAT(ctx)
if len(nats) != 0 {
t.Errorf("expected nat rows to cascade-delete with device, got %d", len(nats))
}
}