Render the NAT tier into per-device configs
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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).
This commit is contained in:
benvin
2026-07-21 22:19:20 +10:00
parent a5957d0e98
commit 335c61383a
2 changed files with 167 additions and 0 deletions
+55
View File
@@ -141,6 +141,61 @@ func TestReportedFIBDoesNotLimitRules(t *testing.T) {
}
}
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"}}