335c61383a
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).
210 lines
7.6 KiB
Go
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)
|
|
}
|
|
}
|