go-firewall/nft_linux_test.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

742 lines
35 KiB
Go

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 <proto> <type>` (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)
}