Add NAT-tier resources and testcontainers integration tests
- 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:
@@ -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 ''
|
||||
);
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user