661 lines
31 KiB
Go
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)
|
|
}
|
|
}
|