package firewall import ( "net" "testing" "github.com/google/nftables" "github.com/google/nftables/binaryutil" "github.com/google/nftables/expr" "github.com/stretchr/testify/require" ) // nftEncodeRule marshals a rule, failing the test if it cannot be expressed. func nftEncodeRule(t *testing.T, f *NFT, r *Rule) *nftEncoded { t.Helper() enc, err := f.MarshalRule(r) require.NoError(t, err, "failed to marshal %+v", *r) return enc } // nftDecodeRule decodes an encoding back into a rule, resolving whatever anonymous // sets the encoding staged rather than reaching for a live ruleset. func nftDecodeRule(t *testing.T, f *NFT, enc *nftEncoded) *Rule { t.Helper() got, err := f.UnmarshalRule( &nftables.Rule{Exprs: enc.exprs, UserData: enc.userData}, enc.chain, newStagedSetReader(enc), f.tableRef()) require.NoError(t, err, "failed to decode encoding") return got } // nftRoundTrip encodes a rule and decodes the result, which is the shape every // read-after-write path depends on: a rule that does not survive this makes Sync // re-add it on every pass. func nftRoundTrip(t *testing.T, f *NFT, r *Rule) *Rule { t.Helper() return nftDecodeRule(t, f, nftEncodeRule(t, f, r)) } // nftExprKinds names the expressions an encoding produced, in order, so a test can // assert the shape of an encoding without pinning every field. func nftExprKinds(exprs []expr.Any) []string { var out []string for _, e := range exprs { switch e.(type) { case *expr.Meta: out = append(out, "meta") case *expr.Cmp: out = append(out, "cmp") case *expr.Payload: out = append(out, "payload") case *expr.Bitwise: out = append(out, "bitwise") case *expr.Lookup: out = append(out, "lookup") case *expr.Range: out = append(out, "range") case *expr.Ct: out = append(out, "ct") case *expr.Connlimit: out = append(out, "connlimit") case *expr.Dynset: out = append(out, "dynset") case *expr.Limit: out = append(out, "limit") case *expr.Log: out = append(out, "log") case *expr.Counter: out = append(out, "counter") case *expr.Verdict: out = append(out, "verdict") case *expr.Reject: out = append(out, "reject") case *expr.Immediate: out = append(out, "immediate") case *expr.NAT: out = append(out, "nat") case *expr.Masq: out = append(out, "masq") case *expr.Redir: out = append(out, "redir") default: out = append(out, "unknown") } } return out } // Every rule shape the backend can express must survive an encode/decode round // trip, across both directions, both families and each match axis. func TestNFTRuleRoundTrip(t *testing.T) { f := &NFT{table: "go_firewall"} rules := []*Rule{ // Addresses and families. {Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept}, {Family: IPv4, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept}, {Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept}, {Family: IPv4, Source: "10.0.0.0/12", Action: Drop}, {Family: IPv4, Destination: "203.0.113.10", Port: 4791, Proto: TCP, Action: Reject}, {Family: IPv6, Source: "2001:db8::1", Action: Drop}, {Family: IPv6, Source: "2001:db8::/32", Action: Drop}, {Family: IPv6, Destination: "2001:db8::/48", Proto: TCP, Port: 80, Action: Accept}, // Negation. {Family: IPv6, Source: "!2001:db8::1", Action: Drop}, {Family: IPv4, Destination: "!10.0.0.0/8", Action: Drop}, // Named set references. {Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop}, {Direction: DirOutput, Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept}, // Family pinned with no address at all. {Family: IPv4, Port: 4789, Proto: UDP, Action: Accept}, {Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept}, // Ports: single, span, list, mixed list, source ports. {Proto: TCP, Port: 22, Action: Accept}, {Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, {Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, {Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}, {Start: 8000, End: 8100}}, Action: Accept}, {Proto: TCP, SourcePort: 1024, Action: Accept}, {Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 65535}}, Action: Accept}, {Proto: TCP, Port: 22, SourcePort: 1024, Action: Accept}, // Protocols with no ports. {Proto: SCTP, Action: Accept}, {Proto: GRE, Action: Accept}, {Proto: ESP, Action: Accept}, {Proto: AH, Action: Accept}, // ICMP. {Proto: ICMP, Action: Accept}, {Proto: ICMPv6, Action: Accept}, {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, {Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, // Both transports in a single row. {Proto: TCPUDP, Port: 53, Action: Accept}, {Proto: TCPUDP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, // Connection state. {Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}, {Proto: TCP, Port: 22, State: StateNew | StateEstablished, Action: Accept}, {State: StateEstablished | StateRelated, Action: Accept}, {State: StateInvalid, Action: Drop}, // Interfaces, including a wildcard and a forward rule matching both. {InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, {InInterface: "eth*", Action: Accept}, {Direction: DirOutput, OutInterface: "eth1", Proto: UDP, Port: 53, Action: Accept}, {Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Proto: TCP, Port: 22, Action: Accept}, // Rate and connection limits. {Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute}, Action: Accept}, {Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 100, Unit: PerSecond, Burst: 20}, Action: Accept}, {Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10}, Action: Drop}, {Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}, {Family: IPv6, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 3, PerSource: true}, Action: Drop}, // Logging and comments. {Proto: TCP, Port: 22, Log: true, Action: Accept}, {Proto: TCP, Port: 22, Log: true, LogPrefix: "ssh drop ", Action: Drop}, {Proto: TCP, Port: 22, Comment: "managed by go-firewall", Action: Accept}, } for _, r := range rules { got := nftRoundTrip(t, f, r) require.True(t, got.Equal(r, true), "round-trip mismatch: input %+v, output %+v", *r, *got) require.Equal(t, r.Comment, got.Comment, "comment must round-trip for %+v", *r) } } // In an inet table a network-header offset names a different field in each // family, so every rule that resolves to a concrete family must carry the // nfproto guard — without it an IPv4 source-address match also matches inside an // IPv6 source address. func TestNFTFamilyGuardAlwaysEmitted(t *testing.T) { f := &NFT{table: "go_firewall"} for _, r := range []*Rule{ {Family: IPv4, Source: "1.2.3.4", Action: Drop}, {Family: IPv6, Destination: "2001:db8::1", Action: Drop}, {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, {Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 2, PerSource: true}, Action: Drop}, } { enc := nftEncodeRule(t, f, r) require.Equal(t, "meta", nftExprKinds(enc.exprs)[0], "expected a leading family guard for %+v", *r) meta := enc.exprs[0].(*expr.Meta) require.Equal(t, expr.MetaKeyNFPROTO, meta.Key, "expected an nfproto guard for %+v", *r) cmp := enc.exprs[1].(*expr.Cmp) require.Equal(t, []byte{f.nfprotoByte(r.impliedFamily())}, cmp.Data) } // A rule with no family to pin carries no guard. enc := nftEncodeRule(t, f, &Rule{Proto: TCP, Port: 22, Action: Accept}) meta := enc.exprs[0].(*expr.Meta) require.Equal(t, expr.MetaKeyL4PROTO, meta.Key, "an unpinned rule must not claim a family") } // A per-source connection limit counts in a named dynamic set keyed on the // source address. The set is family-typed, so its key must name the family's own // address field and the set must be created alongside the rule. func TestNFTPerSourceConnLimit(t *testing.T) { f := &NFT{table: "go_firewall"} r := &Rule{Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop} enc := nftEncodeRule(t, f, r) require.NotNil(t, enc.meterSet, "a per-source limit must create its counting set") require.True(t, enc.meterSet.Dynamic, "the counting set must be dynamic") require.Equal(t, nftables.TypeIPAddr, enc.meterSet.KeyType) var ds *expr.Dynset for _, e := range enc.exprs { if v, ok := e.(*expr.Dynset); ok { ds = v } } require.NotNil(t, ds, "expected a dynset statement") require.Equal(t, enc.meterSet.Name, ds.SetName) require.Len(t, ds.Exprs, 1) cl, ok := ds.Exprs[0].(*expr.Connlimit) require.True(t, ok, "the dynset must carry a connlimit") require.Equal(t, uint32(10), cl.Count) require.Equal(t, uint32(expr.NFT_CONNLIMIT_F_INV), cl.Flags, "the count must be an over-limit test") // The IPv6 form keys on the v6 source address instead. enc6 := nftEncodeRule(t, f, &Rule{Family: IPv6, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}) require.Equal(t, nftables.TypeIP6Addr, enc6.meterSet.KeyType) require.NotEqual(t, enc.meterSet.Name, enc6.meterSet.Name, "each family counts in its own set") // The set name is derived from rule identity, so re-adding the same rule // reuses its counting state while a different rule gets its own. require.Equal(t, enc.meterSet.Name, nftEncodeRule(t, f, r).meterSet.Name) other := &Rule{Family: IPv4, Proto: TCP, Port: 443, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop} require.NotEqual(t, enc.meterSet.Name, nftEncodeRule(t, f, other).meterSet.Name) // A family-agnostic per-source limit has no single row: the caller must fan // it out first, so validateRule rejects it. require.Error(t, f.validateRule(&Rule{Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}), "a FamilyAny per-source limit must be expanded first") require.True(t, f.perSourceFamilySplit(&Rule{Proto: TCP, ConnLimit: &ConnLimit{Count: 1, PerSource: true}, Action: Drop})) } // A both-transports rule stays a single row: the protocol is an anonymous set of // the two transport numbers, and the ports match through the shared offsets. func TestNFTTCPUDPSingleRow(t *testing.T) { f := &NFT{table: "go_firewall"} enc := nftEncodeRule(t, f, &Rule{Proto: TCPUDP, Port: 53, Action: Accept}) require.Len(t, enc.anonSets, 1, "the protocol pair rides one anonymous set") set := enc.anonSets[0] require.Equal(t, nftables.TypeInetProto, set.set.KeyType) require.True(t, set.set.Anonymous && set.set.Constant) require.ElementsMatch(t, [][]byte{{6}, {17}}, [][]byte{set.elements[0].Key, set.elements[1].Key}, "the set must hold tcp and udp") require.Equal(t, []string{"meta", "lookup", "payload", "cmp", "counter", "verdict"}, nftExprKinds(enc.exprs)) } // The library's connection-state bits and the kernel's do not share an ordering, // so the mapping between them is explicit and must stay symmetric. func TestNFTConnStateMask(t *testing.T) { f := new(NFT) for _, c := range []struct { state ConnState mask uint32 }{ {StateNew, 0x08}, {StateEstablished, 0x02}, {StateRelated, 0x04}, {StateInvalid, 0x01}, {StateEstablished | StateRelated, 0x06}, {StateNew | StateEstablished | StateRelated | StateInvalid, 0x0f}, } { require.Equal(t, c.mask, f.ctStateMask(c.state), "encoding %v", c.state.Strings()) got, ok := f.connStateForMask(c.mask) require.True(t, ok, "decoding mask %#x", c.mask) require.Equal(t, c.state, got, "decoding mask %#x", c.mask) } // A mask carrying a state the model cannot hold (untracked) is rejected // rather than narrowed to the states that did map, so the row stays opaque. _, ok := f.connStateForMask(0x40) require.False(t, ok, "an unmodelled ct state must not decode") _, ok = f.connStateForMask(0x02 | 0x40) require.False(t, ok, "a partly unmodelled ct state mask must not decode") } // nft shortens a byte-aligned prefix to a narrower payload load rather than // masking, so the decoder must accept that form as well as the masked one this // backend writes. func TestNFTShortenedPrefixDecodes(t *testing.T) { f := &NFT{table: "go_firewall"} for _, c := range []struct { fam Family offset uint32 length uint32 data []byte want string }{ {IPv4, 12, 1, []byte{10}, "10.0.0.0/8"}, {IPv4, 12, 2, []byte{192, 168}, "192.168.0.0/16"}, {IPv6, 8, 4, []byte{0x20, 0x01, 0x0d, 0xb8}, "2001:db8::/32"}, } { nr := &nftables.Rule{Exprs: []expr.Any{ &expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{f.nfprotoByte(c.fam)}}, &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: c.offset, Len: c.length}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: c.data}, &expr.Verdict{Kind: expr.VerdictDrop}, }} got, err := f.UnmarshalRule(nr, "input", newStagedSetReader(), f.tableRef()) require.NoError(t, err) require.Equal(t, c.want, got.Source) } } // A row carrying a construct the Rule model cannot hold must fail to decode, so // the caller keeps it as an opaque slot instead of misrepresenting it. func TestNFTUnmodelledRowsRejected(t *testing.T) { f := &NFT{table: "go_firewall"} sets := newStagedSetReader() cases := map[string][]expr.Any{ "unknown l4proto": { &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{2}}, &expr.Verdict{Kind: expr.VerdictAccept}, }, "jump verdict": { &expr.Verdict{Kind: expr.VerdictJump, Chain: "other"}, }, "unmodelled expression": { &expr.Quota{Bytes: 100}, &expr.Verdict{Kind: expr.VerdictAccept}, }, "address without a family guard": { &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{1, 2, 3, 4}}, &expr.Verdict{Kind: expr.VerdictAccept}, }, "under-limit connection count": { &expr.Connlimit{Count: 5}, &expr.Verdict{Kind: expr.VerdictAccept}, }, "no verdict at all": { &expr.Counter{}, }, } for name, exprs := range cases { _, err := f.UnmarshalRule(&nftables.Rule{Exprs: exprs}, "input", sets, f.tableRef()) require.Error(t, err, "expected %s to stay opaque", name) } } // An ip or ip6 table is itself the family qualifier, so an operator's rules in // one carry no nfproto match. Reading those rows against the table's family is // what keeps a foreign address match — a literal or an address set — out of the // opaque bucket, and what reports the one family the row can ever match. func TestNFTForeignTableFamilyFromTable(t *testing.T) { f := &NFT{table: "go_firewall"} sets := newStagedSetReader() for _, c := range []struct { name string tbl *nftables.Table offset uint32 length uint32 data []byte want *Rule }{ { name: "ip table address match", offset: 12, length: 4, data: []byte{192, 0, 2, 10}, tbl: &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"}, want: &Rule{Family: IPv4, Source: "192.0.2.10", Action: Accept}, }, { name: "ip6 table address match", offset: 8, length: 16, data: net.ParseIP("2001:db8::1").To16(), tbl: &nftables.Table{Family: nftables.TableFamilyIPv6, Name: "filter"}, want: &Rule{Family: IPv6, Source: "2001:db8::1", Action: Accept}, }, } { nr := &nftables.Rule{Exprs: []expr.Any{ &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: c.offset, Len: c.length}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: c.data}, &expr.Verdict{Kind: expr.VerdictAccept}, }} got, err := f.UnmarshalRule(nr, "input", sets, c.tbl) require.NoError(t, err, c.name) require.Equal(t, c.want.Family, got.Family, c.name) require.Equal(t, c.want.Source, got.Source, c.name) } // A set reference is the same case: the set's own family is not needed to read // the row, because the table already pinned it. nr := &nftables.Rule{Exprs: []expr.Any{ &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, &expr.Lookup{SourceRegister: 1, SetName: "allowlist"}, &expr.Verdict{Kind: expr.VerdictAccept}, }} got, err := f.UnmarshalRule(nr, "input", sets, &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"}) require.NoError(t, err) require.Equal(t, IPv4, got.Family) require.Equal(t, "allowlist", got.Source) // A row with no family evidence at all still takes the table's family: an ip // table can only ever match IPv4. nr = &nftables.Rule{Exprs: []expr.Any{ &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{6}}, &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(8123)}, &expr.Verdict{Kind: expr.VerdictAccept}, }} got, err = f.UnmarshalRule(nr, "input", sets, &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"}) require.NoError(t, err) require.Equal(t, IPv4, got.Family) // The backend's own inet table settles nothing, so an unguarded address match // there stays opaque (see TestNFTUnmodelledRowsRejected). fam, ok := f.familyForTable(f.tableRef()) require.False(t, ok) require.Equal(t, FamilyAny, fam) } // nftables reports the default burst of 5 on every limit even when none was // asked for, so the default must read back as unset or a rule never matches its // own read-back and Sync re-adds it forever. func TestNFTRateBurstDefaultNormalized(t *testing.T) { f := &NFT{table: "go_firewall"} got := nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute}, Action: Accept}) require.NotNil(t, got.RateLimit) require.Zero(t, got.RateLimit.Burst, "the netfilter default burst must read as unset") // An explicit burst of something other than the default survives intact. got = nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerHour, Burst: 20}, Action: Accept}) require.Equal(t, uint(20), got.RateLimit.Burst) require.Equal(t, PerHour, got.RateLimit.Unit) } // The counters a listed rule carries are reported onto the rule but are not part // of its identity. func TestNFTCounters(t *testing.T) { f := &NFT{table: "go_firewall"} nr := &nftables.Rule{Exprs: []expr.Any{ &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{6}}, &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(22)}, &expr.Counter{Packets: 42, Bytes: 336}, &expr.Verdict{Kind: expr.VerdictAccept}, }} r, err := f.UnmarshalRule(nr, "input", newStagedSetReader(), f.tableRef()) require.NoError(t, err) require.Equal(t, uint64(42), r.Packets) require.Equal(t, uint64(336), r.Bytes) require.True(t, r.EqualBase(&Rule{Proto: TCP, Port: 22, Action: Accept}, true), "counters must not be part of rule identity: %+v", r) } // A comment and a log prefix ride in user data and a log expression rather than // a quoted string, so characters the textual interface could never carry now // round-trip verbatim. func TestNFTCommentAndPrefixVerbatim(t *testing.T) { f := &NFT{table: "go_firewall"} for _, s := range []string{`has "quotes"`, "has # hash", " leading and trailing ", `back\slash`} { got := nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, Comment: s, Action: Accept}) require.Equal(t, s, got.Comment, "comment must survive verbatim") got = nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, Log: true, LogPrefix: s, Action: Accept}) require.Equal(t, s, got.LogPrefix, "log prefix must survive verbatim") } // Both are length-capped by nftables, so an over-long value is rejected up // front rather than being silently truncated by the kernel. require.Error(t, f.validateRule(&Rule{Action: Accept, Comment: string(make([]byte, nftCommentMax+1))})) require.Error(t, f.validateRule(&Rule{Action: Accept, Log: true, LogPrefix: string(make([]byte, nftLogPrefixMax+1))})) } // Shapes nftables cannot express must be rejected by validateRule, which the // encoding entry points run, rather than producing a rule that would not read // back as written. func TestNFTMarshalRejections(t *testing.T) { f := &NFT{table: "go_firewall"} cases := map[string]*Rule{ "port without a protocol": {Port: 80, Proto: ProtocolAny, Action: Accept}, "input interface on an output rule": {Direction: DirOutput, InInterface: "eth0", Action: Accept}, "output interface on an input rule": {OutInterface: "eth0", Action: Accept}, "no action": {Proto: TCP, Port: 22}, } for name, r := range cases { require.Error(t, f.validateRule(r), "expected %s to be rejected", name) } } // Every NAT kind must survive an encode/decode round trip. func TestNFTNATRoundTrip(t *testing.T) { f := &NFT{table: "go_firewall"} rules := []*NATRule{ {Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2"}, {Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2", ToPort: 8080}, {Kind: DNAT, Proto: UDP, Ports: []PortRange{{Start: 5000, End: 5100}}, ToAddress: "192.168.1.2"}, {Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080}, {Kind: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"}, {Kind: SNAT, Interface: "eth0", ToAddress: "2001:db8::1"}, {Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080}, {Kind: Masquerade, Interface: "eth0"}, {Kind: Masquerade}, {Kind: DNAT, Family: IPv4, Source: "10.0.0.0/8", Proto: TCP, Port: 80, ToAddress: "192.168.1.2"}, {Kind: SNAT, Proto: SCTP, Interface: "eth0", ToAddress: "1.2.3.4"}, } for _, r := range rules { enc, err := f.MarshalNATRule(r) require.NoError(t, err, "failed to marshal %+v", *r) got, err := f.UnmarshalNATRule( &nftables.Rule{Exprs: enc.exprs}, newStagedSetReader(enc), f.tableRef()) require.NoError(t, err, "failed to decode %+v", *r) require.True(t, got.EqualBase(r), "round-trip mismatch: input %+v, output %+v", *r, *got) } // DNAT lands in prerouting, SNAT in postrouting. enc, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "1.2.3.4"}) require.NoError(t, err) require.Equal(t, "prerouting", enc.chain) enc, err = f.MarshalNATRule(&NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}) require.NoError(t, err) require.Equal(t, "postrouting", enc.chain) } // A family-agnostic NAT rule is one unpinned row covering both families, so it // must not acquire a family guard on the way out. func TestNFTFamilyAnyNATIsDualStack(t *testing.T) { f := &NFT{table: "go_firewall"} enc, err := f.MarshalNATRule(&NATRule{Kind: Masquerade, Interface: "eth0"}) require.NoError(t, err) for _, e := range enc.exprs { if m, ok := e.(*expr.Meta); ok { require.NotEqual(t, expr.MetaKeyNFPROTO, m.Key, "a family-agnostic masquerade must stay unpinned") } } } // nftables' snat expression carries no port, so a source-port translation is a // shape this backend genuinely cannot express and must report as unsupported // rather than silently dropping the port. func TestNFTNATRejections(t *testing.T) { f := &NFT{table: "go_firewall"} err := f.validateNAT(&NATRule{Kind: SNAT, Proto: TCP, ToAddress: "1.2.3.4", ToPort: 8080}) require.ErrorIs(t, err, ErrUnsupportedNAT, "snat cannot translate the source port") // A CIDR translation target has no single address to rewrite to; the encoder // rejects it while building the nat expression. _, err = f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.0/8"}) require.Error(t, err, "a translation target must be a single address") } // An address set's entries are stored as element keys — discrete addresses in a // plain set, boundary markers in an interval set — and must be rendered back as // the CIDR or range they came from. func TestNFTAddressSetElements(t *testing.T) { f := new(NFT) // A discrete set holds one element per address. elems, err := f.setElements("1.2.3.4", false) require.NoError(t, err) require.Len(t, elems, 1) require.Equal(t, []byte{1, 2, 3, 4}, elems[0].Key) // A range cannot be stored in a discrete set. _, err = f.setElements("10.0.0.0/8", false) require.Error(t, err) // An interval set stores the inclusive start and an exclusive end marker. elems, err = f.setElements("10.0.0.0/8", true) require.NoError(t, err) require.Len(t, elems, 2) require.Equal(t, []byte{10, 0, 0, 0}, elems[0].Key) require.Equal(t, []byte{11, 0, 0, 0}, elems[1].Key) require.True(t, elems[1].IntervalEnd) // Round-trip each entry form through the element encoding and back. for _, c := range []struct { entry string interval bool }{ {"1.2.3.4", false}, {"2001:db8::1", false}, {"10.0.0.0/8", true}, {"192.168.1.0/24", true}, {"2001:db8::/32", true}, {"10.0.0.1-10.0.0.9", true}, } { els, eerr := f.setElements(c.entry, c.interval) require.NoError(t, eerr, "encoding %q", c.entry) got := f.addressSetEntries(&nftSetContents{ set: &nftables.Set{Interval: c.interval}, elements: els, }) require.Equal(t, []string{c.entry}, got, "round-trip of %q", c.entry) } } // A dynamic set is connection-limit counting state and an anonymous set is a // rule's own inline literal; neither is a caller-managed address set. func TestNFTAddressSetKeyTypes(t *testing.T) { f := new(NFT) kt, err := f.setKeyType(IPv4) require.NoError(t, err) require.Equal(t, nftables.TypeIPAddr, kt) require.Equal(t, IPv4, f.familyForKeyType(kt)) kt, err = f.setKeyType(IPv6) require.NoError(t, err) require.Equal(t, nftables.TypeIP6Addr, kt) require.Equal(t, IPv6, f.familyForKeyType(kt)) // An nftables set carries a single address type, so an unspecified family // resolves to IPv4 rather than failing. kt, err = f.setKeyType(FamilyAny) require.NoError(t, err) require.Equal(t, nftables.TypeIPAddr, kt) } // An interface name is compared against a fixed-width NUL-padded buffer, while a // trailing '*' makes it a prefix match against just the leading characters. func TestNFTInterfaceEncoding(t *testing.T) { f := new(NFT) exact := f.ifnameBytes("eth0") require.Len(t, exact, 16, "an exact interface match is fixed width") require.Equal(t, "eth0", f.ifnameString(exact)) wild := f.ifnameBytes("eth*") require.Equal(t, []byte("eth"), wild, "a wildcard compares only the prefix") require.Equal(t, "eth*", f.ifnameString(wild)) } // The table name is derived from the rule prefix and must be a valid nftables // identifier, which cannot begin with a digit. func TestNFTSanitizeName(t *testing.T) { require.Equal(t, "fw_1fw", sanitizeNFTName("1fw")) require.Equal(t, "go_firewall", sanitizeNFTName("")) require.Equal(t, "go_firewall", sanitizeNFTName("!!!")) require.Equal(t, "my_fw", sanitizeNFTName("my-fw")) require.Equal(t, "my_fw", sanitizeNFTName("my.fw")) } // A concrete-family removal of a merged row must keep the coverage the caller // never named, across both the family and the transport axis. func TestNFTSplitMergedRowTwoAxes(t *testing.T) { f := &NFT{table: "go_firewall"} // A single row covering both families and both transports. merged := &Rule{Proto: TCPUDP, Port: 53, Action: Accept} // Removing only the IPv4 TCP half leaves three cells behind. target := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept} remainder := splitMergedRow(merged, target) require.NotEmpty(t, remainder, "removing one cell must leave the rest in place") // Every remainder must still be expressible, or the removal would fail // halfway through and drop coverage it meant to keep. for _, r := range remainder { _, err := f.MarshalRule(r) require.NoError(t, err, "remainder %+v must be expressible", *r) } }