Files
tomswallapi/internal/compiler/compiler_test.go
T
benvin 335c61383a
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Render the NAT tier into per-device configs
The compiler now projects the global NAT intents through each device's bindings
into the rendered config:
- snat/masquerade: renders on a device that binds the egress zone (and, when the
  source is a zone, that zone too), resolving the egress interface — this
  auto-scopes masquerade to edge devices. A literal-CIDR source needs only the
  egress binding.
- netmap: renders on the device its anchor (device:zone|interface) names,
  resolving a zone anchor to its bound interface.
- 1:1 nat: renders on the device it is bound to.
Adds RenderedSNAT/RenderedNetmap/RenderedNAT to the rendered config, fetches the
tiers in Compile, and unit-tests binding-scoping (edge vs interior/other device).
2026-07-21 22:19:20 +10:00

210 lines
7.6 KiB
Go

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 TestReportedFIBDoesNotLimitRules(t *testing.T) {
// A router with a narrow FIB must still carry every applicable rule: reported
// reachability is observability data, not a rule filter (over-approximation is
// safe and intended under ECMP).
in := Input{
Fabric: &model.Fabric{Name: "core", EnforceOnRouters: true},
Device: model.Device{Name: "rt1", Class: model.ClassRouter, Fabric: "core",
ReachablePrefixes: []string{"192.168.0.0/16"}},
Zones: map[string]model.Zone{
"zone-a": {Name: "zone-a", Subnets: []string{"10.1.0.0/24"}},
"zone-b": {Name: "zone-b", Subnets: []string{"10.4.0.0/24"}},
},
Rules: []model.Rule{
{ID: 1, Action: "accept", Source: []string{"zone-a"}, Dest: []string{"zone-b"}, Proto: "tcp", Ports: []string{"22"}},
},
}
cfg, err := Render(in)
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(cfg.Rules) != 1 {
t.Errorf("reported FIB must not limit rules, got %d", len(cfg.Rules))
}
}
func natInput() Input {
return Input{
Zones: map[string]model.Zone{
"loc": {Name: "loc", Subnets: []string{"10.1.0.0/24"}},
"net": {Name: "net"},
},
SNAT: []model.SNATRule{{ID: 1, Action: "masquerade", Source: "loc", Egress: "net"}},
Netmap: []model.NetmapRule{{ID: 1, Type: "dnat", FromNet: "10.0.0.0/24", ToNet: "192.168.1.0/24", Anchor: "fw-a:net"}},
NAT: []model.NATRule{{ID: 1, Device: "fw-a", External: "203.0.113.10", Internal: "10.1.0.10", Interface: "eth0"}},
}
}
func TestRenderNATScopesToBindings(t *testing.T) {
// fw-a binds both loc and net (an edge) → masquerade + its netmap + its nat.
edge := natInput()
edge.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall}
edge.Bindings = []model.Binding{
{Device: "fw-a", Zone: "loc", Interfaces: []string{"eth1"}},
{Device: "fw-a", Zone: "net", Interfaces: []string{"eth0"}},
}
cfg, err := Render(edge)
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(cfg.SNAT) != 1 || cfg.SNAT[0].Action != "masquerade" ||
len(cfg.SNAT[0].Source) != 1 || cfg.SNAT[0].Source[0] != "10.1.0.0/24" ||
len(cfg.SNAT[0].Egress) != 1 || cfg.SNAT[0].Egress[0] != "eth0" {
t.Errorf("edge masquerade not rendered correctly: %+v", cfg.SNAT)
}
if len(cfg.Netmap) != 1 || cfg.Netmap[0].Interface != "eth0" {
t.Errorf("netmap anchor not resolved to eth0: %+v", cfg.Netmap)
}
if len(cfg.NAT) != 1 || cfg.NAT[0].External != "203.0.113.10" {
t.Errorf("1:1 nat not rendered on its device: %+v", cfg.NAT)
}
}
func TestRenderNATSkipsNonEgressAndOtherDevices(t *testing.T) {
// rt1 binds only net (not loc): masquerade requires both, so it's skipped;
// the netmap/nat are anchored/bound to fw-a, so they don't render here either.
interior := natInput()
interior.Device = model.Device{Name: "rt1", Class: model.ClassRouter}
interior.Bindings = []model.Binding{{Device: "rt1", Zone: "net", Interfaces: []string{"eth0"}}}
cfg, err := Render(interior)
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(cfg.SNAT) != 0 {
t.Errorf("masquerade should not render without the source-zone binding: %+v", cfg.SNAT)
}
if len(cfg.Netmap) != 0 || len(cfg.NAT) != 0 {
t.Errorf("netmap/nat must not render on a device they aren't bound to: %+v %+v", cfg.Netmap, cfg.NAT)
}
}
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)
}
}