Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged value spanning both transports, distinct from ProtocolAny (which matches every IP protocol and carries no port). Backends whose native syntax holds both transports in one row (nftables, ufw, apf) store and read it as one rule; the rest fan it out with expandProtocols. Removing one transport of a merged row splits it via splitMergedRow, which composes the family and protocol splits so an nftables row merged on both axes leaves a correct, non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT. Remove read-side merging. GetRules now reports the firewall's actual rows and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up separately-stored ones, so mergeFamilies, mergeDirections and their helpers are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the NATRule pair) expand a rule across family, transport and direction and decide containment cell by cell, which is what lets Sync stay a no-op against its own output whichever representation a backend chose. Extract the systemd/SysV service helpers out of the iptables backend into services.go so every Linux backend shares one implementation, and document the multi-state rule model and the coverage helpers in the README.
597 lines
28 KiB
Go
597 lines
28 KiB
Go
package firewall
|
|
|
|
import (
|
|
"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")
|
|
}
|
|
|
|
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 family-agnostic port opened per family becomes two rich
|
|
// rules. Removing it with a FamilyAny target must locate both 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 family-agnostic
|
|
// remove no-op surfaced by the integration suite.
|
|
func TestFirewallDFamilyAnyRichRuleRemovable(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)
|
|
|
|
// The two stored rich rules cover the family-agnostic rule between them.
|
|
target := &Rule{Family: FamilyAny, Proto: TCP, Port: 3492, Action: Accept}
|
|
require.True(t, target.CoveredBy([]*Rule{v4, v6}))
|
|
|
|
// The family-strict matcher finds neither stored rich rule.
|
|
require.False(t, target.Equal(v4, false))
|
|
require.False(t, target.Equal(v6, false))
|
|
|
|
// EqualForRemoval finds both, so RemoveRule clears every rich rule the target
|
|
// covers.
|
|
require.True(t, v4.EqualForRemoval(target, false))
|
|
require.True(t, v6.EqualForRemoval(target, false))
|
|
}
|
|
|
|
// A merged TCPUDP rule has no single rich-rule form (a rich rule port element
|
|
// carries one protocol), so the row-level marshaller must reject it: AddRule and
|
|
// RemoveRule fan it into a tcp row and a udp row with expandProtocols before
|
|
// marshalling. ProtocolAny+ports stays rejected too, since it is a distinct value.
|
|
func TestFirewallDMarshalRejectsMergedProtocol(t *testing.T) {
|
|
fw := new(FirewallD)
|
|
|
|
// A TCPUDP port rule reaching the row marshaller means the fan-out was skipped.
|
|
_, err := fw.MarshalRichRule(&Rule{Port: 80, Proto: TCPUDP, Action: Accept})
|
|
require.Error(t, err, "a TCPUDP rule must not marshal to a single rich rule")
|
|
|
|
// expandProtocols yields a tcp row and a udp row, each of which marshals cleanly.
|
|
subs := expandProtocols(&Rule{Port: 80, Proto: TCPUDP, Action: Accept})
|
|
require.Len(t, subs, 2, "a TCPUDP rule fans into a tcp row and a udp row")
|
|
require.Equal(t, TCP, subs[0].Proto)
|
|
require.Equal(t, UDP, subs[1].Proto)
|
|
for _, sub := range subs {
|
|
rich, merr := fw.MarshalRichRule(sub)
|
|
require.NoError(t, merr, "an expanded transport row must marshal: %+v", *sub)
|
|
require.Contains(t, rich, `protocol="`+sub.Proto.String()+`"`)
|
|
}
|
|
}
|
|
|
|
// A rich rule's port element carries one protocol, so a port opened for both tcp and
|
|
// udp in the same zone is two rich rules. Each reads back as its own rule (both
|
|
// family-agnostic, since neither names a family), and together they cover the TCPUDP
|
|
// rule that was written. A TCPUDP target reaches both on removal.
|
|
func TestFirewallDTCPUDPRichRulePair(t *testing.T) {
|
|
fw := new(FirewallD)
|
|
tcp, err := fw.UnmarshalRichRule(`rule port port="3492" protocol="tcp" accept`)
|
|
require.NoError(t, err)
|
|
udp, err := fw.UnmarshalRichRule(`rule port port="3492" protocol="udp" accept`)
|
|
require.NoError(t, err)
|
|
require.Equal(t, FamilyAny, tcp.impliedFamily(), "a familyless rich rule covers both families")
|
|
|
|
target := &Rule{Proto: TCPUDP, Port: 3492, Action: Accept}
|
|
require.True(t, target.CoveredBy([]*Rule{tcp, udp}), "the tcp/udp rich-rule pair covers the TCPUDP rule")
|
|
require.False(t, target.CoveredBy([]*Rule{tcp}), "the tcp rich rule alone leaves udp uncovered")
|
|
|
|
// The TCPUDP target reaches each concrete stored row on removal.
|
|
require.True(t, tcp.EqualForRemoval(target, false))
|
|
require.True(t, udp.EqualForRemoval(target, 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")
|
|
}
|
|
|
|
// 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)
|
|
}
|