package firewall import ( "encoding/json" "strconv" "strings" "testing" "github.com/stretchr/testify/require" ) func TestNFTRules(t *testing.T) { fw := &NFT{table: "go_firewall"} // Marshal a representative rule and confirm the expression. chain, expr, err := fw.MarshalRule(&Rule{ Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept, }) require.NoError(t, err) require.Equal(t, "input", chain, "expected input chain") require.Equal(t, "ip saddr 192.168.0.0/24 udp dport 23 counter accept", expr, "unexpected expression") // The listing form (as produced by `nft -a`) must parse back to an // equivalent rule, including the trailing handle. rule, handle, err := fw.UnmarshalRule("ip saddr 192.168.0.0/24 udp dport 23 accept # handle 4", "input") require.NoError(t, err) require.Equal(t, "4", handle, "expected handle 4") want := &Rule{Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept} require.True(t, rule.Equal(want, true), "parsed rule does not match: got %+v", rule) // Round-trip the rules we typically set across both directions and families. rules := []*Rule{ {Family: IPv4, Port: 4789, Proto: UDP, Action: Accept}, {Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept}, {Family: IPv4, Source: "67.227.233.116", Port: 4789, Proto: TCP, Action: Accept}, {Direction: DirOutput, Family: IPv4, Destination: "67.227.233.116", Port: 4791, Proto: TCP, Action: Reject}, {Family: IPv6, Source: "!2001:db8::1", Action: Drop}, // A non-address Source/Destination names a set, referenced as @name. {Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop}, {Direction: DirOutput, Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept}, } for _, r := range rules { chain, expr, err := fw.MarshalRule(r) require.NoError(t, err, "failed to marshal %+v", *r) parsed, _, err := fw.UnmarshalRule(expr, chain) require.NoError(t, err, "failed to parse %q", expr) require.True(t, parsed.Equal(r, true), "round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed) } } // A non-address Source/Destination is emitted as an nftables named-set reference // (@name), negation included, and parses back to the bare set name. func TestNFTSetReference(t *testing.T) { fw := &NFT{table: "go_firewall"} _, expr, err := fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop}) require.NoError(t, err) require.Contains(t, expr, "ip saddr @blocklist") _, expr6, err := fw.MarshalRule(&Rule{Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept}) require.NoError(t, err) require.Contains(t, expr6, "ip6 daddr != @allowlist") got, _, err := fw.UnmarshalRule("ip saddr @blocklist tcp dport 22 counter drop # handle 7", "input") require.NoError(t, err) require.Equal(t, "blocklist", got.Source) gotNeg, _, err := fw.UnmarshalRule("ip6 daddr != @allowlist tcp dport 80 counter accept # handle 8", "input") require.NoError(t, err) require.Equal(t, "!allowlist", gotNeg.Destination) // A port without a concrete protocol cannot be expressed in nftables. _, _, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}) require.Error(t, err, "expected error marshalling a port with no protocol") } // nft rejects an unqualified `dnat to`/`snat to` in an inet table unless the rule // already carries a same-family address match, so MarshalNATRule must emit the // `ip`/`ip6` qualifier the read path (UnmarshalNATRule) already consumes. func TestNFTNATFamilyQualifier(t *testing.T) { fw := &NFT{table: "go_firewall"} // A plain IPv4 port-forward: no address match, so the qualifier is required. chain, expr, err := fw.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2"}) require.NoError(t, err) require.Equal(t, "prerouting", chain) require.Contains(t, expr, "dnat ip to 192.168.1.2", "DNAT must carry the ip qualifier: %q", expr) // Interface-only IPv4 SNAT. _, expr, err = fw.MarshalNATRule(&NATRule{Kind: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"}) require.NoError(t, err) require.Contains(t, expr, "snat ip to 1.2.3.4", "SNAT must carry the ip qualifier: %q", expr) // IPv6 DNAT to an address:port emits ip6 and bracketed target. _, expr, err = fw.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080}) require.NoError(t, err) require.Contains(t, expr, "dnat ip6 to [2001:db8::1]:8080", "IPv6 DNAT must carry the ip6 qualifier: %q", expr) // Every kind must round-trip back to an equal rule through the list form. for _, r := range []*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: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"}, {Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080}, {Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080}, {Kind: Masquerade, Interface: "eth0"}, } { chain, expr, err := fw.MarshalNATRule(r) require.NoError(t, err, "failed to marshal %+v", *r) parsed, _, err := fw.UnmarshalNATRule(expr, chain) require.NoError(t, err, "failed to parse %q", expr) require.True(t, parsed.EqualBase(r), "round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed) } } func TestNFTFeatureRules(t *testing.T) { fw := &NFT{table: "go_firewall"} // Confirm a few representative encodings. cases := []struct { rule *Rule want string }{ {&Rule{Proto: ICMP, Action: Accept}, "meta l4proto icmp counter accept"}, {&Rule{Proto: ICMPv6, Action: Accept}, "meta l4proto icmpv6 counter accept"}, {&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, "icmp type 8 counter accept"}, {&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}, "icmpv6 type 135 counter accept"}, {&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, "tcp dport {80,443} counter accept"}, {&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "udp dport 1000-2000 counter accept"}, {&Rule{Proto: TCP, Port: 22, State: StateEstablished | StateRelated, Action: Accept}, "tcp dport 22 ct state {established,related} counter accept"}, {&Rule{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, `iifname "eth0" tcp dport 22 counter accept`}, {&Rule{Direction: DirOutput, OutInterface: "eth1", Action: Drop}, `oifname "eth1" counter drop`}, } for _, c := range cases { _, expr, err := fw.MarshalRule(c.rule) require.NoError(t, err, "failed to marshal %+v", *c.rule) require.Equal(t, c.want, expr, "marshal %+v", *c.rule) } // Round-trip every new-feature rule shape. rules := []*Rule{ {Proto: ICMP, Action: Accept}, {Proto: ICMPv6, Action: Accept}, {Family: IPv4, Proto: ICMP, Action: Accept}, {Family: IPv6, Proto: ICMPv6, Action: Drop}, {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, {Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, {Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}, {Start: 8000, End: 8100}}, Action: Accept}, {Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, {Proto: TCP, Port: 22, State: StateNew | StateEstablished, Action: Accept}, {Family: IPv4, Source: "10.0.0.0/8", Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}, {InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, {Direction: DirOutput, OutInterface: "eth1", Proto: UDP, Port: 53, Action: Accept}, // A forward rule may match both an ingress and an egress interface. {Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Proto: TCP, Port: 22, Action: Accept}, } for _, r := range rules { chain, expr, err := fw.MarshalRule(r) require.NoError(t, err, "failed to marshal %+v", *r) parsed, _, err := fw.UnmarshalRule(expr, chain) require.NoError(t, err, "failed to parse %q", expr) require.True(t, parsed.Equal(r, true), "round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed) } // A forward rule marshals into the forward base chain and matches both interfaces. chain, expr, err := fw.MarshalRule(&Rule{Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Action: Accept}) require.NoError(t, err) require.Equal(t, "forward", chain, "a forward rule targets the forward chain") require.Equal(t, `iifname "eth0" oifname "eth1" counter accept`, expr) // An input interface cannot be matched on an output rule and vice versa. _, _, err = fw.MarshalRule(&Rule{Direction: DirOutput, InInterface: "eth0", Action: Accept}) require.Error(t, err, "expected error matching an input interface on an output rule") _, _, err = fw.MarshalRule(&Rule{OutInterface: "eth0", Action: Accept}) require.Error(t, err, "expected error matching an output interface on an input rule") } // A listed rule carries `counter packets N bytes M`; the parser must capture the // counters onto the rule, while they remain outside rule identity. func TestNFTCounters(t *testing.T) { f := &NFT{table: "test"} r, _, err := f.UnmarshalRule("tcp dport 22 counter packets 42 bytes 336 accept # handle 7", "input") require.NoError(t, err) require.Equal(t, uint64(42), r.Packets) require.Equal(t, uint64(336), r.Bytes) require.Equal(t, Accept, r.Action) // Counters are not part of rule identity. require.True(t, r.EqualBase(&Rule{Proto: TCP, Port: 22, Action: Accept}, true), "counters must not be part of rule identity: %+v", r) // A rule without counters parses with zero counters. r2, _, err := f.UnmarshalRule("tcp dport 22 accept # handle 1", "input") require.NoError(t, err) require.Zero(t, r2.Packets) require.Zero(t, r2.Bytes) } // collapseSetSpaces must not collapse the spaces inside brace-like content // that lives inside a quoted comment (it is not a real anonymous set), while // still collapsing an actual anonymous set match outside any quote. nft's // quoting has no backslash-escape mechanism: a bare `"` always toggles the // quoted state. func TestNFTCollapseSetSpacesQuotedComment(t *testing.T) { f := new(NFT) in := `tcp dport { 22, 80 } counter accept comment "a b { c } d" # handle 5` // The real anonymous set collapses; the quoted comment (braces and spaces // included) is preserved verbatim. want := `tcp dport {22,80} counter accept comment "a b { c } d" # handle 5` require.Equal(t, want, f.collapseSetSpaces(in)) } // MarshalRule must reject a Comment or LogPrefix containing a double quote: nft's // string literals have no escape for an embedded quote, so such a value cannot be // written at all, and nft's own parser would otherwise fail with a confusing raw // syntax error instead of a clear validation error. func TestNFTMarshalRuleRejectsQuoteInComment(t *testing.T) { fw := &NFT{table: "go_firewall"} _, _, err := fw.MarshalRule(&Rule{Action: Accept, Comment: `has "quote"`}) require.Error(t, err) _, _, err = fw.MarshalRule(&Rule{Action: Accept, Log: true, LogPrefix: `has "quote"`}) require.Error(t, err) // A comment/log prefix with no quote is unaffected. _, expr, err := fw.MarshalRule(&Rule{Action: Accept, Comment: "plain comment"}) require.NoError(t, err) require.Contains(t, expr, `comment "plain comment"`) } // unquote must strip a double-quoted value literally rather than decoding it // as a Go string (strconv.Unquote): nft has no backslash-escape mechanism, so a // literal backslash sequence in the value (e.g. a Windows-style path) must come // back unchanged, not be reinterpreted as an escape. func TestNFTUnquoteNoEscapeDecoding(t *testing.T) { f := new(NFT) require.Equal(t, `C:\new`, f.unquote(`"C:\new"`)) require.Equal(t, "plain", f.unquote(`"plain"`)) require.Equal(t, "bare", f.unquote("bare")) } // setMatches must treat a matching family/type as safe to swallow as an // idempotent re-add, and any mismatch (or an unreadable/absent existing set) as // a genuine conflict that must not be silently treated as success. func TestNFTSetMatches(t *testing.T) { f := new(NFT) require.True(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashIP}, IPv4, SetHashIP)) // FamilyAny resolves to IPv4 for comparison, matching setSpec's own default. require.True(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashIP}, FamilyAny, SetHashIP)) require.False(t, f.setMatches(&AddressSet{Family: IPv6, Type: SetHashIP}, IPv4, SetHashIP)) require.False(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashNet}, IPv4, SetHashIP)) require.False(t, f.setMatches(nil, IPv4, SetHashIP)) } // A host address written with an explicit /32 (or /128) prefix must round-trip // even though nft lists it back without the prefix. Rule identity compares // addresses semantically (addrEqual/canonAddr), so the bare read-back matches. func TestNFTHostPrefixRoundTrip(t *testing.T) { f := &NFT{table: "test"} orig := &Rule{Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept} chain, expr, err := f.MarshalRule(orig) require.NoError(t, err) // nft strips the /32 from an exact host match when it lists the rule back. listed := strings.Replace(expr, "1.2.3.4/32", "1.2.3.4", 1) require.NotEqual(t, expr, listed) got, _, err := f.UnmarshalRule(listed, chain) require.NoError(t, err) require.True(t, got.EqualBase(orig, true), "a /32 host address must round-trip; got Source %q", got.Source) // The same holds for an IPv6 /128 host and its zero-compressed spelling. orig6 := &Rule{Family: IPv6, Source: "2001:0db8::1/128", Proto: TCP, Port: 22, Action: Accept} chain6, expr6, err := f.MarshalRule(orig6) require.NoError(t, err) listed6 := strings.Replace(expr6, "2001:0db8::1/128", "2001:db8::1", 1) got6, _, err := f.UnmarshalRule(listed6, chain6) require.NoError(t, err) require.True(t, got6.EqualBase(orig6, true), "a /128 host address must round-trip; got %q", got6.Source) } // nft lists ICMPv4 types 15 and 16 by their symbolic names info-request / // info-reply, which the ICMPv4 name table previously omitted; such a rule must // still parse back rather than being dropped from GetRules. func TestNFTICMPv4InfoTypeNameParse(t *testing.T) { f := &NFT{table: "test"} for _, tc := range []struct { num uint8 name string }{{15, "info-request"}, {16, "info-reply"}} { orig := &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](tc.num), Action: Accept} chain, expr, err := f.MarshalRule(orig) require.NoError(t, err) named := strings.Replace(expr, "type "+strconv.Itoa(int(tc.num)), "type "+tc.name, 1) require.NotEqual(t, expr, named, "marshaled rule should contain the numeric type") got, _, err := f.UnmarshalRule(named, chain) require.NoError(t, err) require.NotNil(t, got.ICMPType) require.Equal(t, tc.num, *got.ICMPType, "icmp %s is type %d", tc.name, tc.num) } } // nft lists an ICMPv6 type by its ICMPv6 name, and several of those spellings // (echo-request, destination-unreachable, ...) mean a different number under // ICMPv4. The parser must resolve an icmpv6 type through the ICMPv6 table. func TestNFTICMPv6TypeNameParse(t *testing.T) { f := &NFT{table: "test"} // echo-request is ICMPv6 type 128 (it is 8 under ICMPv4). chain, expr, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}) require.NoError(t, err) named := strings.Replace(expr, "type 128", "type echo-request", 1) require.NotEqual(t, expr, named, "marshaled rule should contain the numeric type") got, _, err := f.UnmarshalRule(named, chain) require.NoError(t, err) require.NotNil(t, got.ICMPType) require.Equal(t, uint8(128), *got.ICMPType, "echo-request must resolve to ICMPv6 type 128") // nd-neighbor-solicit (135) is a v6-only name absent from the ICMPv4 table; // it must still parse rather than dropping the rule. chain2, expr2, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}) require.NoError(t, err) named2 := strings.Replace(expr2, "type 135", "type nd-neighbor-solicit", 1) got2, _, err := f.UnmarshalRule(named2, chain2) require.NoError(t, err) require.NotNil(t, got2.ICMPType) require.Equal(t, uint8(135), *got2.ICMPType) } // nftables applies and prints a default burst of 5 packets on every limit // statement, even when none was requested. A rule marshaled with Burst 0 must // still compare equal to the one nft lists back with burst 5. func TestNFTRateBurstDefaultNormalized(t *testing.T) { f := &NFT{table: "test"} orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}} chain, expr, err := f.MarshalRule(orig) require.NoError(t, err) withBurst := strings.Replace(expr, "rate 10/minute", "rate 10/minute burst 5 packets", 1) require.NotEqual(t, expr, withBurst) got, _, err := f.UnmarshalRule(withBurst, chain) require.NoError(t, err) require.NotNil(t, got.RateLimit) require.Equal(t, uint(0), got.RateLimit.Burst, "nft's default burst of 5 must normalize to the unset 0") require.True(t, got.EqualBase(orig, true), "a burst-5 read-back must equal the burst-0 original") } // A comment containing a literal '#' must round-trip: strings.Fields would // otherwise mistake the inner '#' for nft's `# handle N` marker and drop the // rule. func TestNFTCommentWithHash(t *testing.T) { f := &NFT{table: "test"} orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "block # 5"} chain, expr, err := f.MarshalRule(orig) require.NoError(t, err) got, _, err := f.UnmarshalRule(expr, chain) require.NoError(t, err) require.Equal(t, "block # 5", got.Comment) // Also survive a trailing `# handle N` marker as printed by `nft -a`. got2, _, err := f.UnmarshalRule(expr+" # handle 2", chain) require.NoError(t, err) require.Equal(t, "block # 5", got2.Comment) } // A log prefix containing whitespace must round-trip. nft prints it as a quoted // string that strings.Fields splits on its internal spaces; the parser has to // reassemble it rather than reading only the first token (which dropped the rest // and made the whole rule unparseable, so it vanished from GetRules). func TestNFTLogPrefixWithSpaces(t *testing.T) { f := &NFT{table: "test"} orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "FW DROP"} chain, expr, err := f.MarshalRule(orig) require.NoError(t, err) got, _, err := f.UnmarshalRule(expr, chain) require.NoError(t, err) require.True(t, got.Log) require.Equal(t, "FW DROP", got.LogPrefix) } // A DNAT rule on SCTP must round-trip through the nft NAT parser, which had no // sctp case and so dropped the rule on read (re-adding a duplicate every call). func TestNFTSCTPNATRoundTrip(t *testing.T) { f := &NFT{table: "test"} orig := &NATRule{Kind: DNAT, Family: IPv4, Proto: SCTP, Port: 9999, ToAddress: "10.0.0.5", ToPort: 9999} chain, expr, err := f.MarshalNATRule(orig) require.NoError(t, err) got, _, err := f.UnmarshalNATRule(expr, chain) require.NoError(t, err) require.True(t, orig.EqualBase(got), "sctp nat rule must round-trip; got %+v", got) } // `nft -a list ruleset` appends `{ # handle N` (and sometimes ` progname ...`) // to table/chain header lines. headerName must return the bare object name so // listForeignRules skips our own table; a naive TrimSuffix(line, "{") left the // handle tail attached, so the skip missed and GetRules re-listed our own rules // as foreign (duplicates). func TestNFTHeaderName(t *testing.T) { f := new(NFT) require.Equal(t, "inet gofwit", f.headerName("table inet gofwit { # handle 1", "table")) require.Equal(t, "input", f.headerName("chain input { # handle 1", "chain")) require.Equal(t, "inet firewalld", f.headerName("table inet firewalld { # handle 215 progname firewalld", "table")) require.Equal(t, "ip nat", f.headerName("table ip nat { # handle 6", "table")) // A header without the -a handle suffix still parses. require.Equal(t, "inet filter", f.headerName("table inet filter {", "table")) } // A bare FamilyAny redirect/masquerade is emitted into the inet table with no // family qualifier, so it matches both IPv4 and IPv6 (it is NOT silently // IPv4-only) and round-trips as FamilyAny. func TestNFTFamilyAnyNATIsDualStack(t *testing.T) { f := &NFT{table: "test"} for _, orig := range []*NATRule{ {Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080}, {Kind: Masquerade, Family: FamilyAny, Interface: "eth0"}, } { chain, expr, err := f.MarshalNATRule(orig) require.NoError(t, err) require.NotContains(t, expr, "meta nfproto", "a FamilyAny NAT rule must not pin a family: %q", expr) got, _, err := f.UnmarshalNATRule(expr, chain) require.NoError(t, err, "expr %q", expr) require.Equal(t, FamilyAny, got.Family, "expr %q should read back FamilyAny", expr) require.True(t, got.EqualBase(orig), "expr %q: want %+v got %+v", expr, orig, got) } } // nft merges a contiguous/overlapping anonymous port set on read // ("{22,23,24-30}" -> "{22-30}"). The rule must still compare Equal to its own // read-back or Sync would remove and re-add it every pass. func TestNFTPortRangeCoalesceRoundTrip(t *testing.T) { f := &NFT{table: "test"} orig := &Rule{Family: IPv4, Proto: TCP, Ports: []PortRange{{Start: 22, End: 22}, {Start: 23, End: 23}, {Start: 24, End: 30}}, Action: Accept} chain, expr, err := f.MarshalRule(orig) require.NoError(t, err) require.Contains(t, expr, "22", "marshaled set should carry the ports") // Simulate nft's list normalization: the set is merged to a single range and // re-spelled with the spacing nft prints. listed := strings.Replace(expr, "{22,23,24-30}", "{ 22-30 }", 1) require.NotEqual(t, expr, listed, "expected the marshaled set to be re-spelled") got, _, err := f.UnmarshalRule(listed, chain) require.NoError(t, err) require.True(t, orig.Equal(got, true), "a coalesced port set must round-trip; got Ports %v", got.Ports) } // nft re-spells a bare `reject` on read as `reject with ` (e.g. // `reject with icmp port-unreachable`). The parser choked on the trailing `with // ...` tokens and returned an error, so listChain silently dropped every reject // rule — it read back as absent and could never be matched or removed. The // listing form must parse back to the same rule the library rendered. (The other // nft round-trip tests feed the marshalled form back in, which never carries the // `with ...` detail, so they missed this.) func TestNFTRejectRespelledRoundTrip(t *testing.T) { fw := &NFT{table: "gofwit"} rule := &Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Reject} chain, _, err := fw.MarshalRule(rule) require.NoError(t, err) // The exact form `nft -a list chain` emits for the rule above. listed := "ip saddr 192.0.2.20 tcp dport 3389 counter packets 0 bytes 0 reject with icmp port-unreachable # handle 2" parsed, handle, err := fw.UnmarshalRule(listed, chain) require.NoError(t, err, "re-spelled reject line must parse") require.Equal(t, "2", handle) require.Equal(t, Reject, parsed.Action) require.True(t, rule.Equal(parsed, true), "re-spelled reject must round-trip: got %+v", parsed) // A reject carrying a comment after the `with ...` detail must still parse the // comment (the detail-consuming loop stops at the comment marker). withComment := `tcp dport 22 counter reject with icmp port-unreachable comment "block ssh" # handle 3` parsed, _, err = fw.UnmarshalRule(withComment, "input") require.NoError(t, err) require.Equal(t, Reject, parsed.Action) require.Equal(t, "block ssh", parsed.Comment) // A tcp-reset reject (two-word detail) must also parse. tcpReset := "tcp dport 22 counter reject with tcp reset # handle 4" parsed, _, err = fw.UnmarshalRule(tcpReset, "input") require.NoError(t, err) require.Equal(t, Reject, parsed.Action) } // nft renders a set's prefix element as an object {"addr":..,"len":..}, not a // two-element array. The decoder previously expected an array, so every hash:net // (interval) set's CIDR entries were silently dropped on read. func TestNFTDecodePrefixElemObject(t *testing.T) { f := new(NFT) cases := map[string]string{ `{"prefix": {"addr": "10.0.0.0", "len": 8}}`: "10.0.0.0/8", `{"prefix": {"addr": "192.168.0.0", "len": 16}}`: "192.168.0.0/16", `{"prefix": {"addr": "2001:db8::", "len": 32}}`: "2001:db8::/32", `"1.2.3.4"`: "1.2.3.4", `{"range": ["10.0.0.1", "10.0.0.5"]}`: "10.0.0.1-10.0.0.5", } for in, want := range cases { require.Equal(t, want, f.decodeElem(json.RawMessage(in)), "f.decodeElem(%s)", in) } } // A LogPrefix ending in a single quote is an identity field that real nft stores // and lists back double-quoted (log prefix "block'"). The old trimQuotes stripped // leading/trailing ' as well as the surrounding ", corrupting it to "block" and // making the rule churn on every Sync. Parsing the exact nft list form asserts the // fix against what nft actually emits, not just MarshalRule's own output. func TestNFTLogPrefixEdgeQuoteRoundTrip(t *testing.T) { fw := &NFT{table: "go_firewall"} in := &Rule{Proto: TCP, Port: 25, Action: Drop, Log: true, LogPrefix: `block'`} chain, expr, err := fw.MarshalRule(in) require.NoError(t, err) require.Contains(t, expr, `log prefix "block'"`, "marshaled form must match nft's list output") got, _, err := fw.UnmarshalRule(expr, chain) require.NoError(t, err) require.Equal(t, in.LogPrefix, got.LogPrefix, "LogPrefix ending in a quote must round-trip (expr %q)", expr) require.True(t, in.Equal(got, true)) // Parse the literal line nft lists (with a handle marker) to cover the read path. parsed, _, err := fw.UnmarshalRule(`tcp dport 25 log prefix "block'" drop # handle 5`, "input") require.NoError(t, err) require.Equal(t, `block'`, parsed.LogPrefix) } // A Comment ending in a single quote must round-trip through nft (same unquote // fix); real nft lists it as comment "note'". func TestNFTCommentEdgeQuoteRoundTrip(t *testing.T) { fw := &NFT{table: "go_firewall"} in := &Rule{Proto: TCP, Port: 25, Action: Drop, Comment: `note'`} chain, expr, err := fw.MarshalRule(in) require.NoError(t, err) got, _, err := fw.UnmarshalRule(expr, chain) require.NoError(t, err) require.Equal(t, in.Comment, got.Comment, "Comment ending in a quote must round-trip (expr %q)", expr) } func TestNFTLogLimitRoundTrip(t *testing.T) { f := &NFT{table: "test"} cases := []*Rule{ {Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Log: true, LogPrefix: "ssh"}, // A non-default burst (nft's default is 5, which normalizes to the unset 0). {Family: IPv4, Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 10}}, {Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}}, {Family: IPv4, Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept}, {Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, } for _, orig := range cases { chain, expr, err := f.MarshalRule(orig) require.NoError(t, err) got, _, err := f.UnmarshalRule(expr, chain) require.NoError(t, err, "expr %q", expr) require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got) } // Per-source connection limiting is not expressible in this model. _, _, err := f.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}) require.Error(t, err) } func TestNFTNATRoundTrip(t *testing.T) { f := &NFT{table: "test"} cases := []*NATRule{ {Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Interface: "eth0"}, {Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}, {Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", Interface: "eth1"}, {Kind: Masquerade, Family: IPv4, Interface: "eth1"}, } for _, orig := range cases { chain, expr, err := f.MarshalNATRule(orig) require.NoError(t, err) got, _, err := f.UnmarshalNATRule(expr, chain) require.NoError(t, err, "expr %q", expr) require.True(t, got.EqualBase(orig), "expr %q: want %+v got %+v", expr, orig, got) } } func TestNFTProtocolAndComment(t *testing.T) { f := &NFT{table: "test"} cases := []*Rule{ {Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept}, {Family: IPv4, Proto: GRE, Action: Accept}, {Family: IPv6, Proto: ESP, Action: Accept}, {Family: IPv4, Proto: AH, Action: Drop}, {Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"}, {Family: IPv4, Proto: TCP, Port: 443, Action: Accept, Comment: "https"}, } for _, orig := range cases { chain, expr, err := f.MarshalRule(orig) require.NoError(t, err) got, _, err := f.UnmarshalRule(expr, chain) require.NoError(t, err, "expr %q", expr) require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got) require.Equal(t, orig.Comment, got.Comment, "expr %q comment", expr) } } // sanitizeNFTName reduces an arbitrary rule prefix to a valid nftables // identifier, falling back to the default table name when nothing usable // remains. This is the name every nft container is created under, so its // behavior is worth pinning. func TestSanitizeNFTName(t *testing.T) { cases := []struct{ in, want string }{ {"myapp", "myapp"}, {"my-app.v2", "my_app_v2"}, // '-' and '.' become '_' {"my app", "my_app"}, // space becomes '_' {"-lead-trail-", "lead_trail"}, // leading/trailing separators trimmed {"a/b:c*d", "abcd"}, // unsupported runes dropped {"", NFTDefaultTable}, // empty prefix -> default table {"***", NFTDefaultTable}, // all-dropped -> default table {"__", NFTDefaultTable}, // trims to empty -> default table } for _, c := range cases { require.Equalf(t, c.want, sanitizeNFTName(c.in), "sanitizeNFTName(%q)", c.in) } } // TestNFTTCPUDPNativeRow covers the both-transports rule nftables expresses as a // single row: `meta l4proto { tcp, udp }` pins the transports and `th dport` matches // the port, so the rule needs no fan-out and reads back as written. func TestNFTTCPUDPNativeRow(t *testing.T) { f := &NFT{table: "gofw"} _, expr, err := f.MarshalRule(&Rule{Proto: TCPUDP, Port: 53, Action: Accept}) require.NoError(t, err, "nftables expresses both transports in one row") require.Contains(t, expr, "meta l4proto { tcp, udp }") require.Contains(t, expr, "th dport 53") require.NotContains(t, expr, "tcpudp", "tcpudp is the model's name, never nft syntax") // A source port uses the same transport-header selector. _, expr, err = f.MarshalRule(&Rule{Proto: TCPUDP, SourcePort: 53, Action: Accept}) require.NoError(t, err) require.Contains(t, expr, "th sport 53") // Portless: the l4proto set alone pins both transports. _, expr, err = f.MarshalRule(&Rule{Proto: TCPUDP, Action: Accept}) require.NoError(t, err) require.Contains(t, expr, "meta l4proto { tcp, udp }") // ProtocolAny with a port is still rejected: it means every IP protocol, which // cannot carry a port. _, _, err = f.MarshalRule(&Rule{Proto: ProtocolAny, Port: 53, Action: Accept}) require.Error(t, err) } // TestNFTTCPUDPRoundTrip: what MarshalRule emits, UnmarshalRule must read back as // the same TCPUDP rule — nft lists the anonymous set verbatim. func TestNFTTCPUDPRoundTrip(t *testing.T) { f := &NFT{table: "gofw"} src := &Rule{Proto: TCPUDP, Port: 53, Action: Accept, Direction: DirInput} _, expr, err := f.MarshalRule(src) require.NoError(t, err) got, _, err := f.UnmarshalRule(expr, "input") require.NoError(t, err, "the emitted row must parse back") require.Equal(t, TCPUDP, got.Proto) require.EqualValues(t, 53, got.Port) require.True(t, got.EqualBase(src, true)) // A source-port row too. src = &Rule{Proto: TCPUDP, SourcePort: 8080, Action: Drop, Direction: DirInput} _, expr, err = f.MarshalRule(src) require.NoError(t, err) got, _, err = f.UnmarshalRule(expr, "input") require.NoError(t, err) require.Equal(t, TCPUDP, got.Proto) require.EqualValues(t, 8080, got.SourcePort) // An l4proto set this backend never writes names coverage a single Proto field // cannot hold, so it is rejected rather than silently narrowed or widened. _, _, err = f.UnmarshalRule("meta l4proto { tcp, sctp } th dport 53 accept", "input") require.Error(t, err, "an unsupported l4proto set must not parse") } // A tcp row and a udp row added separately stay two rows on read — nftables holds a // TCPUDP rule as one `meta l4proto { tcp, udp }` row, and a chain that instead holds // the two concrete rows is a different actual state. The pair still covers the TCPUDP // rule, so Sync does not re-add it. func TestNFTSeparateTransportRowsCoverTCPUDP(t *testing.T) { rows := []*Rule{ {Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept, Direction: DirInput}, {Family: FamilyAny, Proto: UDP, Port: 53, Action: Accept, Direction: DirInput}, } both := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept, Direction: DirInput} require.True(t, both.CoveredBy(rows)) require.False(t, both.CoveredBy(rows[:1]), "the tcp row alone leaves udp uncovered") // A removal targeting the TCPUDP rule reaches both rows. require.True(t, rows[0].EqualForRemoval(both, true)) require.True(t, rows[1].EqualForRemoval(both, true)) } // TestNFTSplitMergedRowTwoAxes: nftables is the only backend that stores a row merged // on both axes — an unpinned inet row covers both families, and an l4proto set both // transports. Removing one cell of that grid must re-add the rest, never dropping // coverage the caller did not target. func TestNFTSplitMergedRowTwoAxes(t *testing.T) { row := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept} // Removing v4/tcp leaves v6 across both transports, plus v4/udp. rest := splitMergedRow(row, &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}) require.Len(t, rest, 2) require.Equal(t, IPv6, rest[0].Family) require.Equal(t, TCPUDP, rest[0].Proto) require.Equal(t, IPv4, rest[1].Family, "the transport remainder is pinned to the targeted family") require.Equal(t, UDP, rest[1].Proto) // Removing tcp across both families leaves udp across both families. rest = splitMergedRow(row, &Rule{Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept}) require.Len(t, rest, 1) require.Equal(t, FamilyAny, rest[0].Family) require.Equal(t, UDP, rest[0].Proto) // Removing v4 across both transports leaves v6 across both transports. rest = splitMergedRow(row, &Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}) require.Len(t, rest, 1) require.Equal(t, IPv6, rest[0].Family) require.Equal(t, TCPUDP, rest[0].Proto) // Removing the whole row leaves nothing. require.Empty(t, splitMergedRow(row, &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept})) // A single-axis row splits on that axis only. require.Len(t, splitMergedRow(&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}, &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}), 1) } // TestNFTNATRejectsMergedProtocol: NAT has no both-transports form on any backend, so // validate rejects a TCPUDP NAT rule rather than emit a `tcpudp` protocol token. func TestNFTNATRejectsMergedProtocol(t *testing.T) { f := &NFT{table: "gofw"} _, _, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "192.0.2.1"}) require.ErrorIs(t, err, ErrUnsupportedNAT) }