From 335c61383acc22f16265d50f0354e34ff7c66644 Mon Sep 17 00:00:00 2001 From: benvin Date: Tue, 21 Jul 2026 22:19:20 +1000 Subject: [PATCH] Render the NAT tier into per-device configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/compiler/compiler.go | 112 +++++++++++++++++++++++++++++ internal/compiler/compiler_test.go | 55 ++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 00d70d4..18053fe 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -11,6 +11,7 @@ import ( "context" "fmt" "sort" + "strings" "gopkg.in/yaml.v3" @@ -31,6 +32,9 @@ type Input struct { Rules []model.Rule Policies []model.Policy Bindings []model.Binding + SNAT []model.SNATRule + Netmap []model.NetmapRule + NAT []model.NATRule } // RenderedConfig is the per-device output served to the agent. @@ -45,6 +49,37 @@ type RenderedConfig struct { Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"` Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"` Policies []model.Policy `yaml:"policies,omitempty" json:"policies,omitempty"` + SNAT []RenderedSNAT `yaml:"snat,omitempty" json:"snat,omitempty"` + Netmap []RenderedNetmap `yaml:"netmap,omitempty" json:"netmap,omitempty"` + NAT []RenderedNAT `yaml:"nat,omitempty" json:"nat,omitempty"` +} + +// RenderedSNAT is a resolved SNAT/masquerade rule: source addresses masqueraded +// (or SNATed to Address) as they leave via the resolved egress interfaces. +type RenderedSNAT struct { + Action string `yaml:"action" json:"action"` + Source []string `yaml:"source,omitempty" json:"source,omitempty"` // source CIDRs + Egress []string `yaml:"egress" json:"egress"` // egress interface names + Address string `yaml:"address,omitempty" json:"address,omitempty"` + Probability *float64 `yaml:"probability,omitempty" json:"probability,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +// RenderedNetmap is a resolved network-to-network mapping on one interface. +type RenderedNetmap struct { + Type string `yaml:"type" json:"type"` + FromNet string `yaml:"from_net" json:"from_net"` + ToNet string `yaml:"to_net" json:"to_net"` + Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +// RenderedNAT is a resolved one-to-one static NAT on one interface. +type RenderedNAT struct { + External string `yaml:"external" json:"external"` + Internal string `yaml:"internal" json:"internal"` + Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` } // RenderedSettings is the effective settings after per-device overrides. @@ -155,9 +190,77 @@ func Render(in Input) (*RenderedConfig, error) { for _, n := range names { out.Sets = append(out.Sets, renderSet(usedSets[n])) } + + // NAT tier: resolve the global NAT intents against this device's bindings. + // These are binding-scoped, independent of the forward-rule enforce flag. + out.SNAT = renderSNATRules(in, out.Bindings) + out.Netmap = renderNetmapRules(in, out.Bindings) + out.NAT = renderNATRules(in) + return out, nil } +// renderSNATRules resolves masquerade/SNAT intents that egress via this device. +// A SNAT lands here only if the device binds the egress zone (and, when the +// source is a zone, that zone too) — which auto-scopes masquerade to edges. +func renderSNATRules(in Input, bindings map[string][]string) []RenderedSNAT { + var out []RenderedSNAT + for _, s := range in.SNAT { + egress := bindings[s.Egress] + if len(egress) == 0 { + continue // device is not an egress for this SNAT + } + var source []string + if z, ok := in.Zones[s.Source]; ok { + if _, bound := bindings[s.Source]; !bound { + continue // device does not attach the source zone + } + source = z.Subnets + } else { + source = []string{s.Source} // literal CIDR + } + out = append(out, RenderedSNAT{ + Action: s.Action, Source: source, Egress: egress, + Address: s.Address, Probability: s.Probability, Comment: s.Comment, + }) + } + return out +} + +// renderNetmapRules resolves netmaps anchored (device:zone or device:interface) +// at this device. +func renderNetmapRules(in Input, bindings map[string][]string) []RenderedNetmap { + var out []RenderedNetmap + for _, n := range in.Netmap { + dev, sel, ok := strings.Cut(n.Anchor, ":") + if !ok || dev != in.Device.Name { + continue + } + iface := sel + if ifaces, bound := bindings[sel]; bound && len(ifaces) > 0 { + iface = ifaces[0] // anchor named a zone: use its bound interface + } + out = append(out, RenderedNetmap{ + Type: n.Type, FromNet: n.FromNet, ToNet: n.ToNet, Interface: iface, Comment: n.Comment, + }) + } + return out +} + +// renderNATRules resolves 1:1 static NATs bound to this device. +func renderNATRules(in Input) []RenderedNAT { + var out []RenderedNAT + for _, n := range in.NAT { + if n.Device != in.Device.Name { + continue + } + out = append(out, RenderedNAT{ + External: n.External, Internal: n.Internal, Interface: n.Interface, Comment: n.Comment, + }) + } + return out +} + func renderSettings(in Input) RenderedSettings { s := RenderedSettings{ AddressFamily: in.Settings.AddressFamily, @@ -314,5 +417,14 @@ func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfi if in.Bindings, err = s.ListBindings(ctx, device); err != nil { return nil, err } + if in.SNAT, err = s.ListSNAT(ctx); err != nil { + return nil, err + } + if in.Netmap, err = s.ListNetmap(ctx); err != nil { + return nil, err + } + if in.NAT, err = s.ListNAT(ctx); err != nil { + return nil, err + } return Render(in) } diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go index 117fdcb..c1fce1a 100644 --- a/internal/compiler/compiler_test.go +++ b/internal/compiler/compiler_test.go @@ -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"}}