Merge pull request 'Render the NAT tier into per-device configs' (#6) from benvin/render-nat into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-07-21 22:30:01 +10:00
2 changed files with 167 additions and 0 deletions
+112
View File
@@ -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)
}
+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"}}