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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
Reference in New Issue
Block a user