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) } }