go-firewall/nft_linux_test.go
James Coleman 5095d90fa4 Advertise capability flags and harden backend semantics
- New Capabilities: PortPair, Negation, RejectAction,
  FamilyWithoutAddress, DenyActionFromConfig, advertised per backend.
- coversDirection isolates DirForward even when output is unowned;
  add splitNATDualRow so a concrete-family removal re-adds the opposite
  family's NAT translation.
- Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the
  "invalid" sentinel as input while JSON round-trips it.
- Sync counts additions on mid-batch failure and uses RuleBatcher.
- NewManager runs a probe loop joining each backend's reason for
  diagnosability; services.go drops "generated" from enabled, handles it
  on enable, clears start-limit-hit on restart, and matches rc.local by
  token.
- nftables: per-source connection limits (meter set), quoted-token
  parsing preserving log-prefix spacing, digit-led prefix sanitizing.
- apf/csf: deny-action-from-config with cached STOP settings, port lists
  and inexpressible shapes routed through the pre-hook, confKeyApplies
  guard against a missing config line.
- atomic config writes fsync before rename and resolve symlinks;
  readConfValue is last-assignment-wins; runCommand preserves the exit
  code through the wrapped error.
- Move coreos/go-systemd to the maintained v22 module directly.
2026-07-13 17:50:43 -05:00

893 lines
43 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}},
// Per-source counting keys on the source address in a named meter set
// (TestNFTPerSourceConnLimit covers the listed-form parses).
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}},
{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)
}
}
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)
}
// A LogPrefix or comment with consecutive interior spaces must round-trip:
// LogPrefix is part of rule identity, so a collapsed read-back would never match
// its own rule and Sync would add a duplicate row each pass.
func TestNFTQuotedSpacingRoundTrip(t *testing.T) {
fw := &NFT{table: "go_firewall"}
orig := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop,
Log: true, LogPrefix: "FW DROP: ", Comment: "web block # one"}
chain, expr, err := fw.MarshalRule(orig)
require.NoError(t, err)
parsed, handle, err := fw.UnmarshalRule(expr+" # handle 9", chain)
require.NoError(t, err)
require.Equal(t, "9", handle)
require.Equal(t, "FW DROP: ", parsed.LogPrefix, "interior spacing must survive the round trip")
require.Equal(t, "web block # one", parsed.Comment)
require.True(t, parsed.Equal(orig, true), "want %+v got %+v", orig, parsed)
}
// An unrecognized single l4proto token is a foreign rule's protocol the model
// cannot hold; it must fail the parse (leaving the row opaque) rather than read
// back as a match-every-protocol rule.
func TestNFTUnknownL4ProtoRejected(t *testing.T) {
fw := &NFT{table: "go_firewall"}
_, _, err := fw.UnmarshalRule("meta l4proto igmp accept # handle 3", "input")
require.Error(t, err)
_, _, err = fw.UnmarshalRule("meta l4proto 253 accept # handle 4", "input")
require.Error(t, err)
// The one modeled set and the plain transports still parse.
r, _, err := fw.UnmarshalRule("meta l4proto { tcp, udp } th dport 53 accept # handle 5", "input")
require.NoError(t, err)
require.Equal(t, TCPUDP, r.Proto)
}
// sanitizeNFTName must never yield an identifier nftables rejects: identifiers
// begin with a letter.
func TestNFTSanitizeNameLeadingDigit(t *testing.T) {
require.Equal(t, "fw_9lives", sanitizeNFTName("9lives"))
require.Equal(t, "myapp", sanitizeNFTName("myapp"))
require.Equal(t, NFTDefaultTable, sanitizeNFTName("!!!"))
}
// lineHandle pulls the trailing handle marker off a rule line the model cannot
// parse, so the row still gets an opaque slot; a quoted comment containing the
// word "handle" cannot fool the end-anchored scan.
func TestNFTLineHandle(t *testing.T) {
fw := &NFT{table: "go_firewall"}
require.Equal(t, "12", fw.lineHandle(`meta mark 0x1 accept # handle 12`))
require.Equal(t, "7", fw.lineHandle(`tcp dport 22 accept comment "keep handle steady" # handle 7`))
require.Equal(t, "", fw.lineHandle("type filter hook input priority filter; policy accept;"))
}
// physicalIndex maps a 1-based modeled-row position to the physical index,
// keeping opaque rows pinned before the slot.
func TestNFTPhysicalIndex(t *testing.T) {
a, b := &Rule{Port: 1}, &Rule{Port: 2}
rows := []*Rule{nil, a, nil, b}
require.Equal(t, 1, physicalIndex(rows, 1))
require.Equal(t, 3, physicalIndex(rows, 2))
require.Equal(t, 4, physicalIndex(rows, 3), "past the last modeled row appends")
}
// A family-agnostic set-referencing rule cannot be rendered faithfully — a named
// set is family-typed — so the entry points pin it to the referenced set's own
// family (resolveSetRefFamily) before marshalling, and the marshaller guards
// against an unresolved one reaching it rather than silently pinning to IPv4.
func TestNFTSetRefRequiresConcreteFamily(t *testing.T) {
fw := &NFT{table: "go_firewall"}
_, _, err := fw.MarshalRule(&Rule{Source: "blocklist", Action: Drop})
require.Error(t, err, "an unresolved family-agnostic set reference must not marshal")
_, _, err = fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Action: Drop})
require.NoError(t, err)
// A NAT rule whose only family signal is the set reference is equally
// unrenderable; one with a concrete address is implied by it and passes.
_, _, err = fw.MarshalNATRule(&NATRule{Kind: Masquerade, Source: "natpool"})
require.Error(t, err)
_, _, err = fw.MarshalNATRule(&NATRule{Kind: SNAT, Source: "natpool", ToAddress: "192.0.2.1"})
require.NoError(t, err)
}
// A per-source connection limit marshals as a named meter keyed on the source
// address and reads back from nft's normalized `add @set { ... }` listing (and
// the written `meter`/`update` spellings). The meter key pins the family, so no
// nfproto match is emitted — nft would fold it away and the text would never
// round-trip.
func TestNFTPerSourceConnLimit(t *testing.T) {
fw := &NFT{table: "go_firewall"}
r := &Rule{Family: IPv4, Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}
chain, expr, err := fw.MarshalRule(r)
require.NoError(t, err)
require.Equal(t, "input", chain)
require.Contains(t, expr, "meter cl")
require.Contains(t, expr, "{ ip saddr ct count over 15 }")
require.NotContains(t, expr, "nfproto", "the meter key already pins the family; an nfproto match would not round-trip")
// The written form parses back to the same rule.
parsed, _, err := fw.UnmarshalRule(expr, chain)
require.NoError(t, err)
require.True(t, parsed.Equal(r, true), "written form mismatch: %+v vs %+v", parsed, r)
// nft lists the meter as an `add @set` statement; the parse captures the
// count, the per-source flag, the family from the key, and the set name for
// removal-time cleanup.
listed := "tcp dport 8081 add @cl4a5b { ip saddr ct count over 15 } counter packets 0 bytes 0 reject with icmp port-unreachable # handle 7"
parsed, handle, err := fw.UnmarshalRule(listed, "input")
require.NoError(t, err)
require.Equal(t, "7", handle)
require.True(t, parsed.Equal(r, true), "listed form mismatch: %+v vs %+v", parsed, r)
require.Equal(t, "cl4a5b", parsed.meterSet, "the set name must be captured for removal-time cleanup")
// The IPv6 key reads back as an IPv6 rule.
listed6 := "tcp dport 8081 add @cl6f { ip6 saddr ct count over 15 } counter packets 0 bytes 0 reject # handle 9"
parsed6, _, err := fw.UnmarshalRule(listed6, "input")
require.NoError(t, err)
require.Equal(t, IPv6, parsed6.Family, "the meter key must pin the family")
require.True(t, parsed6.ConnLimit != nil && parsed6.ConnLimit.PerSource && parsed6.ConnLimit.Count == 15)
// The `update` spelling (a timeout-refreshing meter) parses the same way.
updated := "tcp dport 8081 update @cl4a5b { ip saddr ct count over 15 } counter packets 0 bytes 0 drop # handle 11"
parsedU, _, err := fw.UnmarshalRule(updated, "input")
require.NoError(t, err)
require.True(t, parsedU.ConnLimit != nil && parsedU.ConnLimit.PerSource)
// nft 1.0.x echoes the written meter spelling back, with a size qualifier.
legacy := "tcp dport 8081 meter cl4a5b size 65535 { ip saddr ct count over 15 } counter packets 0 bytes 0 reject with icmp port-unreachable # handle 13"
parsedL, _, err := fw.UnmarshalRule(legacy, "input")
require.NoError(t, err)
require.True(t, parsedL.Equal(r, true), "nft 1.0.x meter listing mismatch: %+v vs %+v", parsedL, r)
require.Equal(t, "cl4a5b", parsedL.meterSet)
// An unmodeled dynamic-set body fails the parse (the row stays opaque)
// rather than reading back as a wrong rule.
_, _, err = fw.UnmarshalRule("tcp dport 80 add @tracker { ip saddr limit rate 10/second } counter drop # handle 12", "input")
require.Error(t, err, "a non-connlimit dynamic-set body is not modeled")
// A FamilyAny per-source rule has no single row; the entry points fan it out
// before marshalling, so the marshaller itself refuses it.
_, _, err = fw.MarshalRule(&Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
require.Error(t, err)
// The meter name is deterministic for one rule and distinct across rules.
r2 := &Rule{Family: IPv4, Proto: TCP, Port: 8082, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}
require.Equal(t, fw.meterName("input", r), fw.meterName("input", r), "same rule, same set")
require.NotEqual(t, fw.meterName("input", r), fw.meterName("input", r2), "distinct rules must not share counting state")
// A global (non-per-source) limit keeps the plain ct count form.
_, exprGlobal, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}})
require.NoError(t, err)
require.Contains(t, exprGlobal, "ct count over 20")
require.NotContains(t, exprGlobal, "meter")
}