go-firewall/nft_linux_test.go
2026-07-08 15:54:48 -05:00

661 lines
31 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)
}
func TestNFTSetSpec(t *testing.T) {
f := new(NFT)
spec, err := f.setSpec(IPv4, SetHashIP)
require.NoError(t, err)
require.Equal(t, "{ type ipv4_addr ; }", spec)
spec, err = f.setSpec(IPv6, SetHashNet)
require.NoError(t, err)
require.Equal(t, "{ type ipv6_addr ; flags interval ; }", spec)
// FamilyAny resolves to IPv4.
spec, err = f.setSpec(FamilyAny, SetHashIP)
require.NoError(t, err)
require.Equal(t, "{ type ipv4_addr ; }", spec)
}
// 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 interval sets store a non-CIDR span as a range object; GetAddressSet must
// report it as "lo-hi" rather than silently dropping the entry.
func TestNFTDecodeRangeElem(t *testing.T) {
f := new(NFT)
require.Equal(t, "10.0.0.1-10.0.0.9", f.decodeElem([]byte(`{"range":["10.0.0.1","10.0.0.9"]}`)))
// Real nft renders a CIDR element as a prefix object with addr/len fields.
require.Equal(t, "10.0.0.0/24", f.decodeElem([]byte(`{"prefix":{"addr":"10.0.0.0","len":24}}`)))
require.Equal(t, "192.0.2.1", f.decodeElem([]byte(`"192.0.2.1"`)))
}
// `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 rule that names the netfilter default burst (5) must round-trip Equal to
// itself. nft reads the default burst back as 0, so without folding 5==0 the rule
// would churn on every Sync. Regression for eqRateLimit + the nft read path.
func TestNFTBurst5RoundTrip(t *testing.T) {
fw := &NFT{table: "go_firewall"}
in := &Rule{Proto: TCP, Port: 25, Action: Drop,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}}
chain, expr, err := fw.MarshalRule(in)
require.NoError(t, err)
got, _, err := fw.UnmarshalRule(expr, chain)
require.NoError(t, err)
require.True(t, in.Equal(got, true), "a Burst=5 rule must round-trip Equal to itself (expr %q)", expr)
}
// 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)
}
}