576 lines
27 KiB
Go
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)
|
|
}
|