From 3c5f1cacd4af88a43be03f8c195915cfd345e19b Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 22:56:26 +1000 Subject: [PATCH 1/3] Match source/dest zones and addresses on conntrack rules --- internal/nftables/compiler.go | 62 ++++++++++++++++++++++-------- internal/nftables/compiler_test.go | 53 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go index 0d3cadf..c5e4f7f 100644 --- a/internal/nftables/compiler.go +++ b/internal/nftables/compiler.go @@ -213,35 +213,65 @@ func (c *Compiler) compileBlrules(state *FirewallState) error { } func (c *Compiler) compileConntrack(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() for i, ct := range c.cfg.Conntrack { tag := fmt.Sprintf("conntrack:%d", i) - - chains := []string{"raw_prerouting"} - switch ct.Chain { - case config.ConntrackOutput: - chains = []string{"raw_output"} - case config.ConntrackBoth: - chains = []string{"raw_prerouting", "raw_output"} + srcs, dsts := zoneSpecs(ct.Source), zoneSpecs(ct.Dest) + if len(srcs) == 0 { + srcs = []config.ZoneSpec{{}} + } + if len(dsts) == 0 { + dsts = []config.ZoneSpec{{}} } - matches, err := l4Matches(ct.Proto, ct.DPort, nil) - if err != nil { - return fmt.Errorf("conntrack[%d]: %w", i, err) + for _, src := range srcs { + chains := []string{"raw_prerouting"} + switch { + case ct.Chain == config.ConntrackOutput, ct.Chain == "" && src.Zone == fwZone: + chains = []string{"raw_output"} + case ct.Chain == config.ConntrackBoth: + chains = []string{"raw_prerouting", "raw_output"} + } + for _, srcAddr := range splitAddrs(src.Addr) { + for _, dst := range dsts { + for _, dstAddr := range splitAddrs(dst.Addr) { + for _, chain := range chains { + if err := c.compileConntrackPair(state, tag, chain, ct, src.Zone, srcAddr, dst.Zone, dstAddr); err != nil { + return fmt.Errorf("conntrack[%d]: %w", i, err) + } + } + } + } + } } + } + return nil +} - for _, chain := range chains { +// compileConntrackPair matches iif of the source zone in raw_prerouting and oif of the dest zone in raw_output. +func (c *Compiler) compileConntrackPair(state *FirewallState, tag, chain string, ct config.ConntrackRule, + srcZone, srcAddr, dstZone, dstAddr string) error { + if ct.Action == config.ConntrackHelper { + return nil + } + srcIfaces, dstIfaces := c.resolveZoneInterfaces(srcZone, srcAddr), []string{""} + if chain == "raw_output" { + srcIfaces, dstIfaces = []string{""}, c.resolveZoneInterfaces(dstZone, dstAddr) + } + for _, srcIface := range srcIfaces { + for _, dstIface := range dstIfaces { + matches, err := c.buildMatchExprs(srcIface, dstIface, chain, ct.Proto, ct.DPort, ct.SPort, srcAddr, dstAddr) + if err != nil { + return err + } for _, m := range matches { - exprs := append([]expr.Any{}, m.exprs...) - + exprs := m.exprs switch ct.Action { case config.ConntrackNoTrack: exprs = append(exprs, &expr.Notrack{}) - case config.ConntrackHelper: - continue case config.ConntrackDrop: exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictDrop}) } - state.Rules[chain] = append(state.Rules[chain], ManagedRule{ Chain: chain, Exprs: exprs, diff --git a/internal/nftables/compiler_test.go b/internal/nftables/compiler_test.go index d955972..8ea5562 100644 --- a/internal/nftables/compiler_test.go +++ b/internal/nftables/compiler_test.go @@ -2416,3 +2416,56 @@ func TestCompile_OrigDestForwardRejected(t *testing.T) { t.Fatalf("Compile() error = %v, want forwarded ORIGDEST rejection", err) } } + +func TestCompile_ConntrackZones(t *testing.T) { + tests := []struct { + name string + ct config.ConntrackRule + want map[string][]string + }{ + { + name: "source zone matches iif", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "net", Dest: "fw", Proto: "udp", DPort: config.PortSpec{"53"}}, + want: map[string][]string{"raw_prerouting": {"iif=eth0"}}, + }, + { + name: "source and dest addresses", + ct: config.ConntrackRule{Action: config.ConntrackDrop, Source: "net:192.0.2.1,198.51.100.1", Dest: "fw:203.0.113.1"}, + want: map[string][]string{"raw_prerouting": {"iif=eth0 saddr=192.0.2.1 daddr=203.0.113.1", "iif=eth0 saddr=198.51.100.1 daddr=203.0.113.1"}}, + }, + { + name: "fw source goes to raw_output with dest oif", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "fw", Dest: "net,lan"}, + want: map[string][]string{"raw_output": {"oif=eth0", "oif=eth1"}}, + }, + { + name: "all matches no interface", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "all", Dest: "fw:192.0.2.53"}, + want: map[string][]string{"raw_prerouting": {"daddr=192.0.2.53"}}, + }, + { + name: "interface-less zone fails closed", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "dmz", Dest: "fw"}, + want: map[string][]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := mustCompile(t, listCfg(func(cfg *config.Config) { + cfg.Zones["lan"] = config.Zone{Type: config.ZoneIP} + cfg.Zones["dmz"] = config.Zone{Type: config.ZoneIP} + cfg.Interfaces = append(cfg.Interfaces, config.Interface{Zone: "lan", Interface: "eth1"}) + cfg.Conntrack = []config.ConntrackRule{tt.ct} + })) + got := map[string][]string{} + for _, chain := range []string{"raw_prerouting", "raw_output"} { + for _, r := range taggedRules(state, chain, "conntrack:0:"+chain) { + got[chain] = append(got[chain], describeRule(r)) + } + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("rules = %v, want %v", got, tt.want) + } + }) + } +} -- 2.47.3 From 6af17a4c02f9cdd717104be2ec81d160bb842004 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 22:59:14 +1000 Subject: [PATCH 2/3] Restrict raw_output to fw sources and reject unmatched prerouting dest zones --- internal/nftables/compiler.go | 11 +++++- internal/nftables/compiler_test.go | 61 +++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go index c5e4f7f..660c872 100644 --- a/internal/nftables/compiler.go +++ b/internal/nftables/compiler.go @@ -225,11 +225,14 @@ func (c *Compiler) compileConntrack(state *FirewallState) error { } for _, src := range srcs { + // raw_output only sees locally generated traffic, so it applies to an fw (or omitted) source only. chains := []string{"raw_prerouting"} switch { - case ct.Chain == config.ConntrackOutput, ct.Chain == "" && src.Zone == fwZone: + case ct.Chain == config.ConntrackOutput && src.Zone != fwZone && src.Zone != "": + return fmt.Errorf("conntrack[%d]: chain output needs SOURCE %s, got %q", i, fwZone, src.Zone) + case ct.Chain != config.ConntrackPrerouting && src.Zone == fwZone, ct.Chain == config.ConntrackOutput: chains = []string{"raw_output"} - case ct.Chain == config.ConntrackBoth: + case ct.Chain == config.ConntrackBoth && src.Zone == "": chains = []string{"raw_prerouting", "raw_output"} } for _, srcAddr := range splitAddrs(src.Addr) { @@ -254,6 +257,10 @@ func (c *Compiler) compileConntrackPair(state *FirewallState, tag, chain string, if ct.Action == config.ConntrackHelper { return nil } + if z, ok := c.cfg.Zones[dstZone]; ok && z.Type != config.ZoneFirewall && chain == "raw_prerouting" && + (dstAddr == "" || strings.HasPrefix(dstAddr, "!")) { + return fmt.Errorf("conntrack DEST zone %q needs an address in prerouting", dstZone) + } srcIfaces, dstIfaces := c.resolveZoneInterfaces(srcZone, srcAddr), []string{""} if chain == "raw_output" { srcIfaces, dstIfaces = []string{""}, c.resolveZoneInterfaces(dstZone, dstAddr) diff --git a/internal/nftables/compiler_test.go b/internal/nftables/compiler_test.go index 8ea5562..7a2cb6f 100644 --- a/internal/nftables/compiler_test.go +++ b/internal/nftables/compiler_test.go @@ -1871,6 +1871,9 @@ func describeRule(r ManagedRule) string { } parts = append(parts, name+"="+net.IP(cmp.Data).String()) } + if m.Base == expr.PayloadBaseTransportHeader && m.Offset == 0 && m.Len == 2 && cmp.Op == expr.CmpOpEq { + parts = append(parts, fmt.Sprintf("sport=%d", binary.BigEndian.Uint16(cmp.Data))) + } } } return strings.Join(parts, " ") @@ -2419,9 +2422,10 @@ func TestCompile_OrigDestForwardRejected(t *testing.T) { func TestCompile_ConntrackZones(t *testing.T) { tests := []struct { - name string - ct config.ConntrackRule - want map[string][]string + name string + ct config.ConntrackRule + want map[string][]string + wantErr string }{ { name: "source zone matches iif", @@ -2448,15 +2452,62 @@ func TestCompile_ConntrackZones(t *testing.T) { ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "dmz", Dest: "fw"}, want: map[string][]string{}, }, + { + name: "chain output with fw source matches dest oif", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "fw", Dest: "net", Chain: config.ConntrackOutput}, + want: map[string][]string{"raw_output": {"oif=eth0"}}, + }, + { + name: "chain output with non-fw source is rejected", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "net", Dest: "fw", Chain: config.ConntrackOutput}, + wantErr: "chain output needs SOURCE fw", + }, + { + name: "chain both with non-fw source emits prerouting only", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "net", Dest: "fw", Proto: "udp", DPort: config.PortSpec{"53"}, Chain: config.ConntrackBoth}, + want: map[string][]string{"raw_prerouting": {"iif=eth0"}}, + }, + { + name: "chain both with fw source emits output only", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "fw", Dest: "lan", Chain: config.ConntrackBoth}, + want: map[string][]string{"raw_output": {"oif=eth1"}}, + }, + { + name: "negated addresses stay one AND-ed match", + ct: config.ConntrackRule{Action: config.ConntrackDrop, Source: "net:!192.0.2.1,198.51.100.1", Dest: "fw"}, + want: map[string][]string{"raw_prerouting": {"iif=eth0 !saddr=192.0.2.1 !saddr=198.51.100.1"}}, + }, + { + name: "sport", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "net", Dest: "fw", Proto: "udp", SPort: config.PortSpec{"123"}}, + want: map[string][]string{"raw_prerouting": {"iif=eth0 sport=123"}}, + }, + { + name: "dest zone without address is rejected in prerouting", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "net", Dest: "lan"}, + wantErr: `conntrack DEST zone "lan" needs an address in prerouting`, + }, + { + name: "dest zone with address matches daddr", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "net", Dest: "lan:203.0.113.10"}, + want: map[string][]string{"raw_prerouting": {"iif=eth0 daddr=203.0.113.10"}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - state := mustCompile(t, listCfg(func(cfg *config.Config) { + cfg := listCfg(func(cfg *config.Config) { cfg.Zones["lan"] = config.Zone{Type: config.ZoneIP} cfg.Zones["dmz"] = config.Zone{Type: config.ZoneIP} cfg.Interfaces = append(cfg.Interfaces, config.Interface{Zone: "lan", Interface: "eth1"}) cfg.Conntrack = []config.ConntrackRule{tt.ct} - })) + }) + if tt.wantErr != "" { + if _, err := NewCompiler(cfg).Compile(); err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Compile() error = %v, want %q", err, tt.wantErr) + } + return + } + state := mustCompile(t, cfg) got := map[string][]string{} for _, chain := range []string{"raw_prerouting", "raw_output"} { for _, r := range taggedRules(state, chain, "conntrack:0:"+chain) { -- 2.47.3 From 337490d9959deba63f96c3b944c8158ca4cec971 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 23:01:29 +1000 Subject: [PATCH 3/3] Reject fw source on prerouting conntrack and pin global omitted-zone entries --- internal/nftables/compiler.go | 2 ++ internal/nftables/compiler_test.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go index 660c872..c8170e1 100644 --- a/internal/nftables/compiler.go +++ b/internal/nftables/compiler.go @@ -230,6 +230,8 @@ func (c *Compiler) compileConntrack(state *FirewallState) error { switch { case ct.Chain == config.ConntrackOutput && src.Zone != fwZone && src.Zone != "": return fmt.Errorf("conntrack[%d]: chain output needs SOURCE %s, got %q", i, fwZone, src.Zone) + case ct.Chain == config.ConntrackPrerouting && src.Zone == fwZone: + return fmt.Errorf("conntrack[%d]: SOURCE %s cannot use chain prerouting", i, fwZone) case ct.Chain != config.ConntrackPrerouting && src.Zone == fwZone, ct.Chain == config.ConntrackOutput: chains = []string{"raw_output"} case ct.Chain == config.ConntrackBoth && src.Zone == "": diff --git a/internal/nftables/compiler_test.go b/internal/nftables/compiler_test.go index 7a2cb6f..6ba6086 100644 --- a/internal/nftables/compiler_test.go +++ b/internal/nftables/compiler_test.go @@ -2462,6 +2462,16 @@ func TestCompile_ConntrackZones(t *testing.T) { ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "net", Dest: "fw", Chain: config.ConntrackOutput}, wantErr: "chain output needs SOURCE fw", }, + { + name: "chain prerouting with fw source is rejected", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "fw", Dest: "net", Chain: config.ConntrackPrerouting}, + wantErr: "SOURCE fw cannot use chain prerouting", + }, + { + name: "omitted source and dest is global in both chains", + ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Proto: "udp", DPort: config.PortSpec{"53"}, Chain: config.ConntrackBoth}, + want: map[string][]string{"raw_prerouting": {""}, "raw_output": {""}}, + }, { name: "chain both with non-fw source emits prerouting only", ct: config.ConntrackRule{Action: config.ConntrackNoTrack, Source: "net", Dest: "fw", Proto: "udp", DPort: config.PortSpec{"53"}, Chain: config.ConntrackBoth}, -- 2.47.3