From 190ff72643cf715018fe67dc13045f1a30ef28c9 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 9 Oct 2026 23:35:32 +1100 Subject: [PATCH 1/2] Exclude sub-zone hosts from wildcard parent interfaces --- internal/config/config_test.go | 14 ++++++++ internal/config/hosts.go | 4 ++- internal/nftables/compiler.go | 21 +++++++++-- internal/nftables/compiler_test.go | 56 ++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index aa27b32..95fb826 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -473,6 +473,20 @@ func TestValidateHosts(t *testing.T) { }, wantErr: "interface \"eth99\" not defined in interfaces", }, + { + name: "host interface matched by wildcard", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "lan": {Type: ZoneIP, Parents: []string{"net"}}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "enp+"}, + }, + hosts: []Host{ + {Zone: "lan", Interface: "enp2s0", Addresses: []string{"192.0.2.0/24"}}, + }, + }, { name: "zone not defined", zones: map[string]Zone{ diff --git a/internal/config/hosts.go b/internal/config/hosts.go index 37affa4..d5c0994 100644 --- a/internal/config/hosts.go +++ b/internal/config/hosts.go @@ -4,6 +4,7 @@ import ( "fmt" "net/netip" "slices" + "strings" ) type Host struct { @@ -44,7 +45,8 @@ func (c *Config) validateHosts() error { ifaceFound := false for _, iface := range c.Interfaces { - if iface.Interface == h.Interface || iface.PhysicalName() == h.Interface { + prefix, wild := strings.CutSuffix(iface.PhysicalName(), "+") + if iface.Interface == h.Interface || iface.PhysicalName() == h.Interface || (wild && strings.HasPrefix(h.Interface, prefix)) { ifaceFound = true break } diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go index 53d5b55..760d647 100644 --- a/internal/nftables/compiler.go +++ b/internal/nftables/compiler.go @@ -1351,11 +1351,11 @@ func (c *Compiler) resolveZone(zone, addr string) []zoneMatch { return nil } -// subZoneHosts lists the host addresses on iface that belong to sub-zones of zone, and those -// sub-zone hosts' exclusions, which fall back to zone. +// subZoneHosts lists the host addresses on interfaces overlapping iface that belong to sub-zones of +// zone, and those sub-zone hosts' exclusions, which fall back to zone. func (c *Compiler) subZoneHosts(zone, iface string) (sub, back []string) { for _, h := range c.cfg.Hosts { - if h.Interface == iface && c.cfg.IsSubZone(h.Zone, zone) { + if ifacesOverlap(h.Interface, iface) && c.cfg.IsSubZone(h.Zone, zone) { sub = append(sub, h.Addresses...) back = append(back, h.Exclusions...) } @@ -1363,6 +1363,21 @@ func (c *Compiler) subZoneHosts(zone, iface string) (sub, back []string) { return sub, back } +// ifacesOverlap reports whether two interface names (a "+" suffix being a prefix wildcard) can match the same interface. +func ifacesOverlap(a, b string) bool { + pa, wa := strings.CutSuffix(a, "+") + pb, wb := strings.CutSuffix(b, "+") + switch { + case wa && wb: + return strings.HasPrefix(pa, pb) || strings.HasPrefix(pb, pa) + case wa: + return strings.HasPrefix(pb, pa) + case wb: + return strings.HasPrefix(pa, pb) + } + return a == b +} + // addrsOverlap reports whether a host address can match a rule address; unparsable or negated rule addresses keep the host. func addrsOverlap(host, rule string) bool { if rule == "" || strings.HasPrefix(rule, "!") { diff --git a/internal/nftables/compiler_test.go b/internal/nftables/compiler_test.go index dccbc3a..4ff68a0 100644 --- a/internal/nftables/compiler_test.go +++ b/internal/nftables/compiler_test.go @@ -3663,3 +3663,59 @@ func TestCompile_HostsRouteBack(t *testing.T) { t.Errorf("routeback hosts lan lan policy = %q, want %q", got, want) } } + +func TestCompile_WildcardParentExcludesSubZoneHosts(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{TableName: "test", AddressFamily: config.FamilyINET}, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, "net": {Type: config.ZoneIP}, + "lan": {Type: config.ZoneIP, Parents: []string{"net"}}, "lxd": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "enp+"}, {Zone: "net", Interface: "wlo1"}, {Zone: "lxd", Interface: "lxdbr0"}, + }, + Hosts: []config.Host{ + {Zone: "lan", Interface: "enp2s0", Addresses: []string{"192.0.2.0/24"}}, + {Zone: "lan", Interface: "wlo1", Addresses: []string{"198.51.100.0/24"}}, + }, + Policy: []config.Policy{ + {Source: "lxd", Dest: "net", Action: config.PolicyAccept}, + {Source: "net", Dest: "all", Action: config.PolicyDrop}, + {Source: "all", Dest: "all", Action: config.PolicyReject}, + }, + PortGroups: make(map[string]config.PortGroup), + } + state := mustCompile(t, cfg) + want := []string{ + "iif=lxdbr0 oif=enp ip4 !daddr=192.0.2.0/24", "iif=lxdbr0 oif=enp ip6", + "iif=lxdbr0 oif=wlo1 ip4 !daddr=198.51.100.0/24", "iif=lxdbr0 oif=wlo1 ip6", + } + if got := describeTagged(state, "forward", "policy:0"); !reflect.DeepEqual(got, want) { + t.Errorf("lxd->net accept = %q, want %q", got, want) + } + want = []string{ + "iif=enp ip4 !saddr=192.0.2.0/24", "iif=enp ip6", + "iif=wlo1 ip4 !saddr=198.51.100.0/24", "iif=wlo1 ip6", + } + if got := describeTagged(state, "input", "policy:1"); !reflect.DeepEqual(got, want) { + t.Errorf("net->fw drop = %q, want %q", got, want) + } + if got := describeTagged(state, "input", "policy:2"); len(got) == 0 || got[0] != "iif=enp2s0 ip4 saddr=192.0.2.0/24" { + t.Errorf("lan->fw reject = %q, want lan on enp2s0 first", got) + } +} + +func TestIfacesOverlap(t *testing.T) { + for _, tt := range []struct { + a, b string + want bool + }{ + {"enp2s0", "enp2s0", true}, {"enp2s0", "enp3s0", false}, + {"enp+", "enp2s0", true}, {"enp2s0", "enp+", true}, {"enp+", "wlo1", false}, + {"en+", "enp+", true}, {"enp+", "eno+", false}, {"enp", "enp+", true}, + } { + if got := ifacesOverlap(tt.a, tt.b); got != tt.want { + t.Errorf("ifacesOverlap(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + } +} -- 2.47.3 From 460eb20db59a2cd80eaa01c60e724f11305a81b3 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 9 Oct 2026 23:41:34 +1100 Subject: [PATCH 2/2] Carve sub-zone host interfaces out of wildcard parent matches --- internal/nftables/compiler.go | 106 ++++++++++++++++++----------- internal/nftables/compiler_test.go | 63 +++++++++++------ 2 files changed, 106 insertions(+), 63 deletions(-) diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go index 760d647..f7d11ea 100644 --- a/internal/nftables/compiler.go +++ b/internal/nftables/compiler.go @@ -1278,9 +1278,11 @@ func (c *Compiler) selectChain(srcZone, dstZone, fwZone string) string { // zoneMatch classifies a packet into a zone: an interface (empty: any) and, for a hosts entry, one host // address; excl carves out hosts exclusions and the hosts of sub-zones, which shorewall matches first. // fam (an NFPROTO; addr's family when set, else 0: any) guards addr and excl so IPv4 offsets are -// never compared against IPv6 bytes. routeback marks a hosts entry with the routeback option. +// never compared against IPv6 bytes. routeback marks a hosts entry with the routeback option. notIface +// carves narrower sub-zone host interfaces out of a wildcard iface; they get entries of their own. type zoneMatch struct { iface, addr string + notIface []string excl []string fam byte routeback bool @@ -1304,17 +1306,18 @@ func (c *Compiler) resolveZone(zone, addr string) []zoneMatch { var out []zoneMatch hasHosts := false for _, iface := range c.cfg.ZoneInterfaces(zone) { - sub, back := c.subZoneHosts(zone, iface) - if len(sub) == 0 { - out = append(out, zoneMatch{iface: iface}) - } else { - v4, v6 := splitFamily(sub) - out = append(out, zoneMatch{iface: iface, excl: v4, fam: unix.NFPROTO_IPV4}, - zoneMatch{iface: iface, excl: v6, fam: unix.NFPROTO_IPV6}) - } - for _, b := range back { - if addrsOverlap(b, addr) { - out = append(out, zoneMatch{iface: iface, addr: b}) + for _, sp := range c.subZoneSplit(zone, iface) { + if len(sp.sub) == 0 { + out = append(out, zoneMatch{iface: sp.iface, notIface: sp.not}) + } else { + v4, v6 := splitFamily(sp.sub) + out = append(out, zoneMatch{iface: sp.iface, notIface: sp.not, excl: v4, fam: unix.NFPROTO_IPV4}, + zoneMatch{iface: sp.iface, notIface: sp.not, excl: v6, fam: unix.NFPROTO_IPV6}) + } + for _, b := range sp.back { + if addrsOverlap(b, addr) { + out = append(out, zoneMatch{iface: sp.iface, notIface: sp.not, addr: b}) + } } } } @@ -1323,15 +1326,16 @@ func (c *Compiler) resolveZone(zone, addr string) []zoneMatch { continue } hasHosts = true - sub, _ := c.subZoneHosts(zone, h.Interface) - v4, v6 := splitFamily(slices.Concat(h.Exclusions, sub)) - for _, a := range h.Addresses { - if addrsOverlap(a, addr) { - m := zoneMatch{iface: h.Interface, addr: a, excl: v6, routeback: h.Options.RouteBack} - if p, err := parsePrefix(a); err == nil && p.Addr().Is4() { - m.excl = v4 + for _, sp := range c.subZoneSplit(zone, h.Interface) { + v4, v6 := splitFamily(slices.Concat(h.Exclusions, sp.sub)) + for _, a := range h.Addresses { + if addrsOverlap(a, addr) { + m := zoneMatch{iface: sp.iface, notIface: sp.not, addr: a, excl: v6, routeback: h.Options.RouteBack} + if p, err := parsePrefix(a); err == nil && p.Addr().Is4() { + m.excl = v4 + } + out = append(out, m) } - out = append(out, m) } } } @@ -1351,29 +1355,44 @@ func (c *Compiler) resolveZone(zone, addr string) []zoneMatch { return nil } -// subZoneHosts lists the host addresses on interfaces overlapping iface that belong to sub-zones of -// zone, and those sub-zone hosts' exclusions, which fall back to zone. -func (c *Compiler) subZoneHosts(zone, iface string) (sub, back []string) { - for _, h := range c.cfg.Hosts { - if ifacesOverlap(h.Interface, iface) && c.cfg.IsSubZone(h.Zone, zone) { - sub = append(sub, h.Addresses...) - back = append(back, h.Exclusions...) - } - } - return sub, back +// subZoneSplit partitions iface for zone's sub-zone hosts, as shorewall matches them: the hosts on an +// interface covering iface exclude their addresses (sub) on all of it, while a host interface strictly +// inside a wildcard iface is carved out (not) into its own entry, so its addresses stay in zone on every +// other interface the wildcard matches. back lists the sub-zone hosts' exclusions, which fall back to zone. +type subZoneSplit struct { + iface string + not []string + sub, back []string } -// ifacesOverlap reports whether two interface names (a "+" suffix being a prefix wildcard) can match the same interface. -func ifacesOverlap(a, b string) bool { - pa, wa := strings.CutSuffix(a, "+") - pb, wb := strings.CutSuffix(b, "+") - switch { - case wa && wb: - return strings.HasPrefix(pa, pb) || strings.HasPrefix(pb, pa) - case wa: - return strings.HasPrefix(pb, pa) - case wb: - return strings.HasPrefix(pa, pb) +func (c *Compiler) subZoneSplit(zone, iface string) []subZoneSplit { + top := subZoneSplit{iface: iface} + var inner []string + for _, h := range c.cfg.Hosts { + switch { + case !c.cfg.IsSubZone(h.Zone, zone): + case ifaceCovers(h.Interface, iface): + top.sub = append(top.sub, h.Addresses...) + top.back = append(top.back, h.Exclusions...) + case ifaceCovers(iface, h.Interface) && !slices.Contains(inner, h.Interface): + inner = append(inner, h.Interface) + } + } + var rest []subZoneSplit + for _, h := range inner { + if slices.ContainsFunc(inner, func(o string) bool { return o != h && ifaceCovers(o, h) }) { + continue + } + top.not = append(top.not, h) + rest = append(rest, c.subZoneSplit(zone, h)...) + } + return append([]subZoneSplit{top}, rest...) +} + +// ifaceCovers reports whether every interface name b matches is also matched by a ("+" suffix: prefix wildcard). +func ifaceCovers(a, b string) bool { + if pa, ok := strings.CutSuffix(a, "+"); ok { + return strings.HasPrefix(strings.TrimSuffix(b, "+"), pa) } return a == b } @@ -1421,6 +1440,11 @@ func zoneMatchExprs(m zoneMatch, src bool) ([]expr.Any, error) { if m.iface != "" { out = matchIfaceName(src, m.iface) } + for _, n := range m.notIface { + e := matchIfaceName(src, n) + e[1].(*expr.Cmp).Op = expr.CmpOpNeq + out = append(out, e...) + } var addr []expr.Any if m.addr != "" { p, err := parsePrefix(m.addr) diff --git a/internal/nftables/compiler_test.go b/internal/nftables/compiler_test.go index 4ff68a0..85b77b4 100644 --- a/internal/nftables/compiler_test.go +++ b/internal/nftables/compiler_test.go @@ -1921,9 +1921,17 @@ func describeRule(r ManagedRule) string { case *expr.Meta: switch m.Key { case expr.MetaKeyIIFNAME: - parts = append(parts, "iif="+strings.TrimRight(string(cmp.Data), "\x00")) + op := "=" + if cmp.Op == expr.CmpOpNeq { + op = "!=" + } + parts = append(parts, "iif"+op+strings.TrimRight(string(cmp.Data), "\x00")) case expr.MetaKeyOIFNAME: - parts = append(parts, "oif="+strings.TrimRight(string(cmp.Data), "\x00")) + op := "=" + if cmp.Op == expr.CmpOpNeq { + op = "!=" + } + parts = append(parts, "oif"+op+strings.TrimRight(string(cmp.Data), "\x00")) case expr.MetaKeyNFPROTO: parts = append(parts, map[byte]string{unix.NFPROTO_IPV4: "ip4", unix.NFPROTO_IPV6: "ip6"}[cmp.Data[0]]) } @@ -3676,46 +3684,57 @@ func TestCompile_WildcardParentExcludesSubZoneHosts(t *testing.T) { }, Hosts: []config.Host{ {Zone: "lan", Interface: "enp2s0", Addresses: []string{"192.0.2.0/24"}}, + {Zone: "lan", Interface: "enp4+", Addresses: []string{"203.0.113.0/24"}}, {Zone: "lan", Interface: "wlo1", Addresses: []string{"198.51.100.0/24"}}, }, Policy: []config.Policy{ {Source: "lxd", Dest: "net", Action: config.PolicyAccept}, + {Source: "fw", Dest: "net", Action: config.PolicyDrop}, {Source: "net", Dest: "all", Action: config.PolicyDrop}, {Source: "all", Dest: "all", Action: config.PolicyReject}, }, PortGroups: make(map[string]config.PortGroup), } state := mustCompile(t, cfg) - want := []string{ - "iif=lxdbr0 oif=enp ip4 !daddr=192.0.2.0/24", "iif=lxdbr0 oif=enp ip6", - "iif=lxdbr0 oif=wlo1 ip4 !daddr=198.51.100.0/24", "iif=lxdbr0 oif=wlo1 ip6", + for _, tt := range []struct{ chain, tag, dir string }{ + {"forward", "policy:0", "iif=lxdbr0 oif"}, {"output", "policy:1", "oif"}, {"input", "policy:2", "iif"}, + } { + d, a := tt.dir, "daddr" + if tt.chain == "input" { + a = "saddr" + } + want := []string{ + d + "=enp " + d[len(d)-3:] + "!=enp2s0 " + d[len(d)-3:] + "!=enp4", + d + "=enp2s0 ip4 !" + a + "=192.0.2.0/24", d + "=enp2s0 ip6", + d + "=enp4 ip4 !" + a + "=203.0.113.0/24", d + "=enp4 ip6", + d + "=wlo1 ip4 !" + a + "=198.51.100.0/24", d + "=wlo1 ip6", + } + if got := describeTagged(state, tt.chain, tt.tag); !reflect.DeepEqual(got, want) { + t.Errorf("%s %s = %q, want %q", tt.chain, tt.tag, got, want) + } } - if got := describeTagged(state, "forward", "policy:0"); !reflect.DeepEqual(got, want) { - t.Errorf("lxd->net accept = %q, want %q", got, want) - } - want = []string{ - "iif=enp ip4 !saddr=192.0.2.0/24", "iif=enp ip6", - "iif=wlo1 ip4 !saddr=198.51.100.0/24", "iif=wlo1 ip6", - } - if got := describeTagged(state, "input", "policy:1"); !reflect.DeepEqual(got, want) { - t.Errorf("net->fw drop = %q, want %q", got, want) - } - if got := describeTagged(state, "input", "policy:2"); len(got) == 0 || got[0] != "iif=enp2s0 ip4 saddr=192.0.2.0/24" { - t.Errorf("lan->fw reject = %q, want lan on enp2s0 first", got) + for chain, want := range map[string]string{ + "forward": "iif=lxdbr0 oif=enp2s0 ip4 daddr=192.0.2.0/24", + "output": "oif=enp2s0 ip4 daddr=192.0.2.0/24", + "input": "iif=enp2s0 ip4 saddr=192.0.2.0/24", + } { + if got := describeTagged(state, chain, "policy:3"); !slices.Contains(got, want) { + t.Errorf("%s policy:3 (lan reject) = %q, want it to contain %q", chain, got, want) + } } } -func TestIfacesOverlap(t *testing.T) { +func TestIfaceCovers(t *testing.T) { for _, tt := range []struct { a, b string want bool }{ {"enp2s0", "enp2s0", true}, {"enp2s0", "enp3s0", false}, - {"enp+", "enp2s0", true}, {"enp2s0", "enp+", true}, {"enp+", "wlo1", false}, - {"en+", "enp+", true}, {"enp+", "eno+", false}, {"enp", "enp+", true}, + {"enp+", "enp2s0", true}, {"enp2s0", "enp+", false}, {"enp+", "wlo1", false}, + {"en+", "enp+", true}, {"enp+", "en+", false}, {"enp+", "eno+", false}, {"enp+", "enp", true}, } { - if got := ifacesOverlap(tt.a, tt.b); got != tt.want { - t.Errorf("ifacesOverlap(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + if got := ifaceCovers(tt.a, tt.b); got != tt.want { + t.Errorf("ifaceCovers(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) } } } -- 2.47.3