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

576 lines
27 KiB
Go

package firewall
import (
"errors"
"testing"
firewalld "github.com/grmrgecko/go-firewalld"
"github.com/stretchr/testify/require"
)
// TestFirewallDZonePortRulesSkipsUnmodeledProto verifies a zone port on an
// unmodeled protocol (dccp) is not surfaced. Such a rule would read back as
// ProtocolAny, which RemoveRule and MarshalRichRule reject, so Sync could never
// reconcile it away. Modeled protocols (tcp/udp/sctp) still surface normally.
func TestFirewallDZonePortRulesSkipsUnmodeledProto(t *testing.T) {
fw := new(FirewallD)
ports := []firewalld.Port{
{Port: "443", Protocol: "dccp"},
{Port: "22", Protocol: "tcp"},
{Port: "49152-49215", Protocol: "udp"},
}
rules := fw.zonePortRules(ports, false)
require.Len(t, rules, 2, "the dccp port must be skipped, only tcp/udp surface")
require.Equal(t, TCP, rules[0].Proto)
require.EqualValues(t, 22, rules[0].Port)
require.Equal(t, UDP, rules[1].Proto)
require.Equal(t, []PortRange{{Start: 49152, End: 49215}}, rules[1].Ports)
// Source ports bind to the source-port fields and likewise skip dccp.
src := fw.zonePortRules([]firewalld.Port{{Port: "53", Protocol: "dccp"}, {Port: "53", Protocol: "udp"}}, true)
require.Len(t, src, 1, "the dccp source port must be skipped")
require.EqualValues(t, 53, src[0].SourcePort)
require.EqualValues(t, 0, src[0].Port, "a source-port rule must not set the destination port")
}
func TestFirewallDRichRules(t *testing.T) {
fw := new(FirewallD)
// Parse a rule that is expected to parse right.
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`)
require.NoError(t, err)
// Re-encode the rule which should result in expected rich rule. The log and
// rate limit now round-trip (audit is still not modeled and is dropped).
richRule, err := fw.MarshalRichRule(rule)
require.NoError(t, err)
require.Equal(t, `rule family="ipv4" source address="192.168.0.0/24" port port="23" protocol="udp" log level="info" accept limit value="1/m"`, richRule,
"the rich rule did not encode as expected")
// Try encoding a bunch of invalid rules.
invalidRules := []string{
`rule family=ipv4 source address="192.168.0.0/24" service name=ftp reject`,
`family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp accept`,
`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp`,
`rule family="ipv6" source address="1:2:3:4:6::" forward-port to-addr="1::2:3:4:7" to-port="4012" protocol="tcp" port="4011"`,
// A port on a protocol this library does not model (dccp) reads back as
// ProtocolAny, which MarshalRichRule cannot re-emit, so parsing must reject
// it rather than surface a rule Restore would choke on. The same applies to a
// port on a modeled but portless protocol (gre) and to a source-port element.
`rule family="ipv4" port port="80" protocol="dccp" accept`,
`rule family="ipv4" port port="80" protocol="gre" accept`,
`rule family="ipv4" source-port port="1024" protocol="dccp" accept`,
}
for _, richRule := range invalidRules {
_, err := fw.UnmarshalRichRule(richRule)
require.Error(t, err, "this rich rule was parsed when it should be invalid: %s", richRule)
}
// Test rules we typically set.
validRules := []string{
`rule priority="10" family="ipv6" port port="4789" protocol="udp" accept`,
`rule priority="10" family="ipv4" source address="67.227.233.116" port port="4789" protocol="tcp" accept`,
`rule priority="10" family="ipv4" destination address="67.227.233.116" port port="4791" protocol="tcp" accept`,
}
for _, richRule := range validRules {
_, err := fw.UnmarshalRichRule(richRule)
require.NoError(t, err, "this rich rule was not parsed when it should be valid: %s", richRule)
}
// A port without a concrete protocol cannot be expressed as a rich rule
// (protocol="any" is invalid), so marshalling must error rather than emit a
// rule firewalld will refuse.
_, err = fw.MarshalRichRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept})
require.Error(t, err, "expected error marshalling a port with no protocol")
// firewalld's zone/rich-rule model has no forward chain, so a forward rule is
// rejected with the ErrUnsupportedForward sentinel.
_, err = fw.MarshalRichRule(&Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected")
require.False(t, fw.Capabilities().Forward, "firewalld does not advertise forward support")
}
func TestFirewallDFeatureRules(t *testing.T) {
fw := new(FirewallD)
// Confirm representative encodings.
cases := []struct {
rule *Rule
want string
}{
// A bare (untyped) ICMP/ICMPv6 protocol match needs no family qualifier:
// firewalld accepts a familyless `protocol value="ipv6-icmp"` rule just
// like any other protocol, and the protocol value string alone tells the
// read path ICMP from ICMPv6. An explicit Family is still honored verbatim.
{&Rule{Proto: ICMP, Action: Accept}, `rule protocol value="icmp" accept`},
{&Rule{Proto: ICMPv6, Action: Accept}, `rule protocol value="ipv6-icmp" accept`},
{&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}, `rule family="ipv6" protocol value="ipv6-icmp" accept`},
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, `rule port port="1000-2000" protocol="tcp" accept`},
}
for _, c := range cases {
got, err := fw.MarshalRichRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
}
// Round-trip ICMP and port-range rules.
rules := []*Rule{
{Proto: ICMP, Action: Accept},
{Family: IPv6, Proto: ICMPv6, Action: Drop},
{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
{Family: IPv4, Source: "192.168.0.0/24", Proto: UDP, Port: 23, Action: Accept},
}
for _, r := range rules {
rich, err := fw.MarshalRichRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed, err := fw.UnmarshalRichRule(rich)
require.NoError(t, err, "failed to parse %q", rich)
require.True(t, parsed.Equal(r, false),
"round-trip mismatch: input %+v, rich %q, output %+v", *r, rich, parsed)
}
// Features a rich rule cannot express are rejected.
unsupported := []*Rule{
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept},
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
}
for _, r := range unsupported {
_, err := fw.MarshalRichRule(r)
require.Error(t, err, "expected error marshalling unsupported rule %+v", *r)
}
}
func TestFirewallDICMPType(t *testing.T) {
fw := new(FirewallD)
// A specific ICMP type encodes to an icmp-type element, resolved by family:
// echo-request is type 8 for IPv4 and 128 for IPv6, but the same firewalld
// name is used for both.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
`rule family="ipv4" icmp-type name="echo-request" accept`},
{&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
`rule family="ipv6" icmp-type name="echo-request" accept`},
{&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](136), Action: Drop},
`rule family="ipv6" icmp-type name="neighbour-advertisement" drop`},
// The ICMP protocol pins the family, so an unset Family is derived from it
// (ICMP => ipv4) rather than rejected.
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
`rule family="ipv4" icmp-type name="echo-request" accept`},
// ICMPv6 => ipv6.
{&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
`rule family="ipv6" icmp-type name="echo-request" accept`},
}
for _, c := range cases {
got, err := fw.MarshalRichRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, false),
"round-trip mismatch: input %+v, rich %q, output %+v", *c.rule, got, parsed)
}
// Rules a firewalld icmp-type element cannot express are rejected.
unsupported := []*Rule{
// A type with no firewalld name in that family cannot be expressed.
{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](200), Action: Accept},
// echo-request is 128 in IPv6; the IPv4 number 8 has no IPv6 name.
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](8), Action: Accept},
// An ICMP type only applies to an ICMP/ICMPv6 protocol.
{Family: IPv4, Proto: TCP, Port: 80, ICMPType: Ptr[uint8](8), Action: Accept},
}
for _, r := range unsupported {
_, err := fw.MarshalRichRule(r)
require.Error(t, err, "expected error marshalling %+v", *r)
}
}
// firewalld's destination grammar accepts an ipset (like the source), so a rule
// matching a destination ipset must marshal to `destination ipset="..."` and
// round-trip, rather than being rejected or read back as invisible. Unlike a
// source ipset, firewalld rejects a familyless destination ipset with
// MISSING_FAMILY (verified against the real firewalld.core.rich parser), so the
// caller must supply a concrete Family.
func TestFirewallDDestinationIPSet(t *testing.T) {
fw := new(FirewallD)
rule := &Rule{Family: IPv4, Destination: "myset", Proto: TCP, Port: 443, Action: Accept}
got, err := fw.MarshalRichRule(rule)
require.NoError(t, err)
require.Contains(t, got, `destination ipset="myset"`, "unexpected marshal: %q", got)
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err)
require.Equal(t, "myset", parsed.Destination)
require.True(t, parsed.IsOutput(), "a destination match is an output rule")
// A negated destination ipset round-trips too.
neg := &Rule{Family: IPv4, Destination: "!badset", Proto: TCP, Port: 443, Action: Drop}
got, err = fw.MarshalRichRule(neg)
require.NoError(t, err)
require.Contains(t, got, `destination NOT ipset="badset"`, "unexpected marshal: %q", got)
parsed, err = fw.UnmarshalRichRule(got)
require.NoError(t, err)
require.Equal(t, "!badset", parsed.Destination)
}
// A destination ipset with no explicit Family is rejected rather than marshaled
// into a rule firewalld refuses at apply time. A source ipset, by contrast,
// needs no family.
func TestFirewallDDestinationIPSetRequiresFamily(t *testing.T) {
fw := new(FirewallD)
_, err := fw.MarshalRichRule(&Rule{Destination: "myset", Proto: TCP, Port: 443, Action: Accept})
require.Error(t, err, "a familyless destination ipset must be rejected")
got, err := fw.MarshalRichRule(&Rule{Source: "myset", Proto: TCP, Port: 443, Action: Accept})
require.NoError(t, err, "a familyless source ipset is valid and must not be rejected")
require.Contains(t, got, `source ipset="myset"`, "unexpected marshal: %q", got)
require.NotContains(t, got, "family=", "a familyless source ipset must not gain a family attribute")
}
func TestFirewallDSourcePort(t *testing.T) {
fw := new(FirewallD)
// Source-port matches encode to the source-port rich-rule element.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, SourcePort: 1024, Action: Accept},
`rule source-port port="1024" protocol="tcp" accept`},
{&Rule{Family: IPv4, Proto: TCP, SourcePort: 1024, Action: Accept},
`rule family="ipv4" source-port port="1024" protocol="tcp" accept`},
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
`rule source-port port="1000-2000" protocol="udp" accept`},
}
for _, c := range cases {
got, err := fw.MarshalRichRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, false),
"round-trip mismatch: input %+v, rich %q, output %+v", *c.rule, got, parsed)
}
// firewalld rich rules carry a single port element, and a source-port takes a
// single port or range — so these cannot be expressed.
unsupported := []*Rule{
// A destination port and a source port cannot coexist in one rule.
{Proto: TCP, Port: 80, SourcePort: 1024, Action: Accept},
// A source-port list is not expressible.
{Proto: TCP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
// A source port needs a concrete tcp/udp protocol.
{SourcePort: 1024, Action: Accept},
}
for _, r := range unsupported {
_, err := fw.MarshalRichRule(r)
require.Error(t, err, "expected error marshalling %+v", *r)
}
// The two source-port-specific rejections report the source-port sentinel.
_, err := fw.MarshalRichRule(&Rule{Proto: TCP, Port: 80, SourcePort: 1024, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedSourcePort)
_, err = fw.MarshalRichRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedSourcePort)
}
// A concrete-family bare-port accept is stored as a rich rule (zoneEntryEligible
// requires FamilyAny), so a v4/v6 pair becomes two rich rules that GetRules merges
// into one FamilyAny rule. RemoveRule must locate that merged rule against the
// stored rich rules with EqualForRemoval — the family-strict Equal it used
// before matched neither, so the port stayed open. Regression for the firewalld
// merged-family remove no-op surfaced by the integration suite.
func TestFirewallDMergedRichRuleRemovable(t *testing.T) {
fw := new(FirewallD)
v4, err := fw.UnmarshalRichRule(`rule family="ipv4" port port="3492" protocol="tcp" accept`)
require.NoError(t, err)
v6, err := fw.UnmarshalRichRule(`rule family="ipv6" port port="3492" protocol="tcp" accept`)
require.NoError(t, err)
merged := mergeFamiliesCopy([]*Rule{v4, v6})
require.Len(t, merged, 1, "the v4/v6 rich-rule twin must collapse into one rule")
m := merged[0]
require.Equal(t, FamilyAny, m.impliedFamily())
// The old family-strict matcher found neither stored rich rule.
require.False(t, m.Equal(v4, false))
require.False(t, m.Equal(v6, false))
// The new matcher (EqualForRemoval) finds both, so RemoveRule clears both rich
// rules for a merged read-back rule.
require.True(t, v4.EqualForRemoval(m, false))
require.True(t, v6.EqualForRemoval(m, false))
}
// A rich rule cannot express a specific rate-limit burst, but the netfilter default
// burst (5) is treated as "unset" everywhere else in the library and reads back as 0
// from nft/iptables. MarshalRichRule must therefore accept a Burst=5 rule (rendering
// the bare rate) instead of rejecting it — otherwise a desired set that is portable
// across nft/iptables/firewalld aborts Sync on firewalld alone. Regression for the
// raw-burst guard that ignored normBurst.
func TestFirewallDRateLimitDefaultBurstAccepted(t *testing.T) {
fw := new(FirewallD)
// Burst=5 (the netfilter default) must marshal to the bare rate and round-trip.
def := &Rule{Proto: TCP, Port: 22, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: netfilterDefaultBurst}}
got, err := fw.MarshalRichRule(def)
require.NoError(t, err, "a default-burst rate limit must be accepted")
require.Contains(t, got, `limit value="10/m"`)
// It round-trips: the read-back rule (Burst 0) still equals the desired rule, so
// Sync does not churn.
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err)
require.True(t, parsed.Equal(def, false),
"a default-burst rule must equal its read-back so Sync is stable")
// A genuinely non-default burst is still unexpressible and rejected.
_, err = fw.MarshalRichRule(&Rule{Proto: TCP, Port: 22, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 20}})
require.ErrorIs(t, err, ErrUnsupportedRateLimit)
}
// A FamilyAny rule that matches a concrete IP address must be marshaled with a
// family attribute: firewalld requires one whenever a rich rule uses an address
// (it rejects a familyless address rule) and stores the rule under that family.
// The rule must then still reconcile against firewalld's family-normalized
// read-back under the family-sensitive Equal that Sync/RemoveRule use.
func TestFirewallDFamilyAnyAddressGetsFamily(t *testing.T) {
fw := new(FirewallD)
orig := &Rule{Source: "10.0.0.1", Proto: TCP, Port: 22, Action: Accept}
rich, err := fw.MarshalRichRule(orig)
require.NoError(t, err)
require.Contains(t, rich, `family="ipv4"`, "a rich rule with an IP address must declare its family")
// firewalld lists the rule back with the family it stored it under.
canon := `rule family="ipv4" source address="10.0.0.1" port port="22" protocol="tcp" accept`
got, err := fw.UnmarshalRichRule(canon)
require.NoError(t, err)
require.True(t, orig.Equal(got, false),
"a FamilyAny address rule must reconcile with its family-normalized read-back")
// A bare (addressless) rule stays unqualified — firewalld applies it to both
// families and needs no family attribute.
bare, err := fw.MarshalRichRule(&Rule{Proto: TCP, Port: 22, Action: Accept})
require.NoError(t, err)
require.NotContains(t, bare, "family=", "an addressless rule must not be pinned to a family")
}
// A log prefix (or address) containing a space must survive the rich-rule parse,
// which needs a quote-aware tokenizer.
func TestFirewallDLogPrefixWithSpaces(t *testing.T) {
fw := new(FirewallD)
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "drop ssh"}
rich, err := fw.MarshalRichRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalRichRule(rich)
require.NoError(t, err)
require.True(t, got.Log)
require.Equal(t, "drop ssh", got.LogPrefix)
}
// lone range must take the zone-entry (AddPort/RemovePort) path — only a genuine
// multi-element list forces the rich-rule path. Gating on HasPortSet (true for a
// single range) left a foreign zone-port range unremovable, so Sync could never
// converge on it.
func TestFirewalldZoneEntryEligibleRange(t *testing.T) {
fw := new(FirewallD)
eligible := []struct {
name string
rule *Rule
}{
{"single port", &Rule{Proto: TCP, Port: 22, Action: Accept}},
{"single port in slice", &Rule{Proto: TCP, Ports: []PortRange{{Start: 22, End: 22}}, Action: Accept}},
{"single contiguous range", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}},
{"single source-port range", &Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}},
{"bare source", &Rule{Source: "10.0.0.0/24", Action: Accept}},
}
for _, c := range eligible {
require.Truef(t, fw.zoneEntryEligible(c.rule), "%s must use the zone-entry path", c.name)
}
ineligible := []struct {
name string
rule *Rule
}{
{"multi-port list", &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}},
{"port list with a range", &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 1000, End: 2000}}, Action: Accept}},
{"multi source-port list", &Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 90, End: 90}}, Action: Accept}},
{"drop action", &Rule{Proto: TCP, Port: 22, Action: Drop}},
{"logging", &Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}},
{"rate limit", &Rule{Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}},
{"concrete family", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}},
{"icmp", &Rule{Proto: ICMP, Action: Accept}},
}
for _, c := range ineligible {
require.Falsef(t, fw.zoneEntryEligible(c.rule), "%s must use the rich-rule path", c.name)
}
}
// TestFirewallDBareICMPFamilyPinned guards that a bare (untyped) ICMP/ICMPv6 rule
// is left unqualified by family and still round-trips correctly. Live testing
// against firewalld 2.4.3 confirmed it accepts a familyless rich rule carrying
// `protocol value="ipv6-icmp"` just like any other protocol (its rich.py check()
// only requires a family for a source/destination address, never for a bare
// protocol match), and the protocol value string alone ("icmp" vs "ipv6-icmp")
// tells the read path ICMP from ICMPv6 without needing a family qualifier.
func TestFirewallDBareICMPFamilyPinned(t *testing.T) {
fw := new(FirewallD)
v6, err := fw.MarshalRichRule(&Rule{Proto: ICMPv6, Action: Accept})
require.NoError(t, err)
require.NotContains(t, v6, `family=`, "a bare icmpv6 rule needs no family qualifier")
require.Contains(t, v6, `value="ipv6-icmp"`)
v4, err := fw.MarshalRichRule(&Rule{Proto: ICMP, Action: Accept})
require.NoError(t, err)
require.NotContains(t, v4, `family=`, "a bare icmp rule needs no family qualifier")
// The rich rule must round-trip back to an equal rule.
parsed, err := fw.UnmarshalRichRule(v6)
require.NoError(t, err)
require.True(t, parsed.Equal(&Rule{Proto: ICMPv6, Action: Accept}, false),
"the familyless icmpv6 rich rule should round-trip to the original rule")
// An explicit Family is still honored verbatim.
explicit, err := fw.MarshalRichRule(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})
require.NoError(t, err)
require.Contains(t, explicit, `family="ipv6"`, "an explicit Family must still be emitted")
// A *typed* ICMP match still needs the family qualifier: firewalld resolves an
// icmp-type name without a protocol element, so this library's own read path
// depends on the family to disambiguate an ICMPv4 name from the identically
// named ICMPv6 one.
typed, err := fw.MarshalRichRule(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept})
require.NoError(t, err)
require.Contains(t, typed, `family="ipv6"`, "a typed icmpv6 rule must still pin family=ipv6")
}
// TestFirewallDSourceProtoNotZoneSource guards that a source combined with a
// concrete protocol is NOT routed to a bare zone source (which would drop the
// protocol and widen the rule to every protocol). Such a rule must take the
// rich-rule path, which preserves the protocol match.
func TestFirewallDSourceProtoNotZoneSource(t *testing.T) {
fw := new(FirewallD)
// A plain source with no protocol is a zone source.
require.True(t, fw.sourceZoneShape(&Rule{Source: "1.2.3.4", Action: Accept}),
"a bare source with no protocol should map to a zone source")
// A source with a concrete protocol must not: it is a rich rule.
require.False(t, fw.sourceZoneShape(&Rule{Source: "1.2.3.4", Proto: TCP, Action: Accept}),
"a source+protocol rule must not be encoded as a bare zone source")
// A negated source is a rich rule too.
require.False(t, fw.sourceZoneShape(&Rule{Source: "!1.2.3.4", Action: Accept}))
// The rich rule that AddRule now falls through to keeps the protocol match.
rich, err := fw.MarshalRichRule(&Rule{Source: "1.2.3.4", Proto: TCP, Action: Accept})
require.NoError(t, err)
require.Contains(t, rich, `source address="1.2.3.4"`)
require.Contains(t, rich, `protocol value="tcp"`, "the protocol match must survive on the rich-rule path")
}
// A firewalld rich rule limit is a bare rate with no burst allowance. A non-zero
// Burst must be rejected rather than silently dropped: a dropped burst reads back
// as 0, so Rule.Equal never matches the desired rule and Sync churns forever. A
// zero Burst (backend default) round-trips.
func TestFirewalldRateBurstRejected(t *testing.T) {
fw := new(FirewallD)
r0 := &Rule{Action: Accept, Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}}
rr, err := fw.MarshalRichRule(r0)
require.NoError(t, err)
back, err := fw.UnmarshalRichRule(rr)
require.NoError(t, err)
require.True(t, r0.Equal(back, false), "burst-0 rate limit must round-trip; got %+v", back.RateLimit)
rb := &Rule{Action: Accept, Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 20}}
_, err = fw.MarshalRichRule(rb)
require.Error(t, err, "a non-zero burst must be rejected, not silently dropped")
require.True(t, errors.Is(err, ErrUnsupportedRateLimit), "error should wrap ErrUnsupportedRateLimit, got: %v", err)
}
// firewalld expresses the portless protocols as a bare protocol element and
// SCTP as a port protocol; both round-trip through a rich rule.
func TestFirewallDProtocolExtras(t *testing.T) {
fw := new(FirewallD)
cases := []*Rule{
{Proto: GRE, Action: Accept},
{Proto: ESP, Action: Accept},
{Family: IPv4, Source: "192.168.0.0/24", Proto: SCTP, Port: 9000, Action: Accept},
}
for _, orig := range cases {
rich, err := fw.MarshalRichRule(orig)
require.NoError(t, err, "%+v", orig)
got, err := fw.UnmarshalRichRule(rich)
require.NoError(t, err, "rich %q", rich)
require.True(t, got.EqualBase(orig, true), "rich %q: want %+v got %+v", rich, orig, got)
}
}
// forwardPort builds a firewalld ForwardPort from a NAT rule and rejects the
// shapes firewalld's forward-port model cannot express.
func TestFWForwardPort(t *testing.T) {
fw := new(FirewallD)
// DNAT to another host: ToAddr is set.
fp, err := fw.forwardPort(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080})
require.NoError(t, err)
require.Equal(t, firewalld.ForwardPort{Port: "80", Protocol: "tcp", ToAddr: "10.0.0.5", ToPort: "8080"}, fp)
// Redirect to a local port: ToAddr is empty.
fp, err = fw.forwardPort(&NATRule{Kind: Redirect, Proto: UDP, Port: 53, ToPort: 5353})
require.NoError(t, err)
require.Equal(t, firewalld.ForwardPort{Port: "53", Protocol: "udp", ToAddr: "", ToPort: "5353"}, fp)
// A single contiguous range is allowed (a list is not, below).
fp, err = fw.forwardPort(&NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, ToAddress: "10.0.0.5", ToPort: 3000})
require.NoError(t, err)
require.Equal(t, "1000-2000", fp.Port)
bad := []struct {
name string
rule *NATRule
}{
{"non tcp/udp", &NATRule{Kind: DNAT, Proto: ICMP, Port: 80, ToAddress: "10.0.0.5"}},
{"no port", &NATRule{Kind: DNAT, Proto: TCP, ToAddress: "10.0.0.5"}},
{"port list", &NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, ToAddress: "10.0.0.5", ToPort: 8080}},
{"interface", &NATRule{Kind: DNAT, Proto: TCP, Port: 80, Interface: "eth0", ToAddress: "10.0.0.5", ToPort: 8080}},
{"source match", &NATRule{Kind: DNAT, Proto: TCP, Port: 80, Source: "1.2.3.4", ToAddress: "10.0.0.5", ToPort: 8080}},
}
for _, c := range bad {
_, err := fw.forwardPort(c.rule)
require.Errorf(t, err, "forwardPort should reject %s", c.name)
}
}
// firewalld's ICMPv6 icmp-type table was missing destination-unreachable (type
// 1), which real firewalld defines for both ipv4 and ipv6. A rich rule using that
// name on IPv6 failed to resolve on read and was silently dropped from
// GetRules/Backup (and unmarshalling failed). It must round-trip.
func TestFirewalldICMPv6DestinationUnreachable(t *testing.T) {
fw := new(FirewallD)
n, ok := fw.icmpTypeNumber(true, "destination-unreachable")
require.True(t, ok, "destination-unreachable must be a known ICMPv6 type")
require.Equal(t, uint8(1), n, "ICMPv6 destination-unreachable is type 1")
r := &Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](1), Action: Reject}
rr, err := fw.MarshalRichRule(r)
require.NoError(t, err)
require.Contains(t, rr, "destination-unreachable", "rich rule should carry the type name")
back, err := fw.UnmarshalRichRule(rr)
require.NoError(t, err, "an icmpv6 destination-unreachable rich rule must parse")
require.True(t, r.Equal(back, false), "icmpv6 destination-unreachable must round-trip: got %+v", back)
}