888 lines
41 KiB
Go
888 lines
41 KiB
Go
package firewall
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// A port-only IPv6 deny carries no address (its ::/0 is synthesized on write),
|
|
// so the IPV6-disabled gate must key on the implied family alone. Otherwise the
|
|
// line is written, csf.pl drops it as a v6-resolving line under IPV6!=1, and the
|
|
// port stays open while GetRules reports it blocked.
|
|
func TestCSFIPv6UnavailablePortOnlyDeny(t *testing.T) {
|
|
fw := new(CSF) // ipv6Enabled defaults to false — the IPV6-disabled case.
|
|
|
|
// The regression: a port-only IPv6 deny (no Source/Destination) must be caught.
|
|
require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop}),
|
|
"port-only IPv6 deny slipped past the IPV6-disabled gate")
|
|
|
|
// A v6-address rule was already caught and must stay caught.
|
|
require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Drop}))
|
|
|
|
// Not caught: a family-agnostic port-only deny (its 0.0.0.0/0 twin is enforced),
|
|
// a plain IPv4 rule, and ICMPv6 (which routes through the raw-iptables hook).
|
|
require.False(t, fw.ipv6Unavailable(&Rule{Proto: TCP, Port: 8080, Action: Drop}))
|
|
require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop}))
|
|
require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}))
|
|
|
|
// With IPv6 enabled the gate never fires.
|
|
fw.ipv6Enabled = true
|
|
require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop}))
|
|
}
|
|
|
|
// A ProtocolAny port-only reject must be written to csf.deny as explicit tcp and
|
|
// udp advanced lines: csf's linefilter defaults a protocol-less line to -p tcp,
|
|
// so a single protocol-less line would leave udp open while the library reported
|
|
// the port blocked for all protocols.
|
|
func TestCSFProtocolAnyRejectFansOut(t *testing.T) {
|
|
ctx := context.Background()
|
|
fw := new(CSF)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.deny")
|
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
|
|
|
reject := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 80, Action: Reject}
|
|
require.NoError(t, fw.EditIPList(ctx, path, Reject, reject, false))
|
|
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
text := string(data)
|
|
require.Contains(t, text, "tcp|in|d=80|s=0.0.0.0/0", "tcp line must be present")
|
|
require.Contains(t, text, "udp|in|d=80|s=0.0.0.0/0", "udp line must be present so udp is actually blocked")
|
|
// No protocol-less line (which csf would silently treat as tcp only).
|
|
for _, line := range strings.Split(text, "\n") {
|
|
require.False(t, strings.HasPrefix(strings.TrimSpace(line), "in|"),
|
|
"a protocol-less advanced line silently means tcp-only in csf: %q", line)
|
|
}
|
|
}
|
|
|
|
// A port-only deny whose action is Drop (csf.conf's default DROP for an inbound
|
|
// deny) must still be written: the placeholder branch keys on "not an accept",
|
|
// not on Reject specifically. Before the fix it keyed on Reject, so a Drop deny
|
|
// wrote nothing while AddRule reported success — the port stayed open.
|
|
func TestCSFPortOnlyDropDenyIsWritten(t *testing.T) {
|
|
ctx := context.Background()
|
|
fw := new(CSF)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.deny")
|
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
|
|
|
drop := &Rule{Family: IPv4, Proto: TCP, Port: 3306, Action: Drop}
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, drop, false))
|
|
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), "tcp|in|d=3306|s=0.0.0.0/0",
|
|
"a port-only Drop deny must be written with the any-network placeholder")
|
|
}
|
|
|
|
// A ProtocolAny port deny is written as a tcp line and a udp line, so it must be
|
|
// idempotent on re-add, read back as a single ProtocolAny rule, and be fully
|
|
// removed by one RemoveRule. Before the fix the add/remove matcher compared
|
|
// ProtocolAny against the concrete-protocol lines exactly, so re-adds duplicated
|
|
// the pair and removal was a silent no-op.
|
|
func TestCSFProtocolAnyPortDenyRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
fw := new(CSF)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.deny")
|
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
|
|
|
deny := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 80, Action: Drop}
|
|
|
|
// Add fans the ProtocolAny deny out to a tcp and a udp line.
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"))
|
|
require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0"))
|
|
|
|
// Re-adding is idempotent: neither line is duplicated.
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
|
data, err = os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"),
|
|
"re-adding a ProtocolAny deny must not duplicate its tcp line")
|
|
require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0"),
|
|
"re-adding a ProtocolAny deny must not duplicate its udp line")
|
|
|
|
// The fanned lines read back and collapse to one ProtocolAny rule.
|
|
parsed, err := fw.ParseIPList(path, Drop)
|
|
require.NoError(t, err)
|
|
merged := fw.mergeProtocols(mergeFamilies(parsed))
|
|
require.Len(t, merged, 1, "the tcp+udp deny lines must merge to one ProtocolAny rule")
|
|
require.Equal(t, ProtocolAny, merged[0].Proto)
|
|
require.True(t, merged[0].EqualBase(deny, true), "the merged rule must equal the ProtocolAny deny that was added")
|
|
|
|
// A single RemoveRule must drop every fanned line.
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true))
|
|
data, err = os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(data), "d=80",
|
|
"removing a ProtocolAny deny must delete all of its fanned lines")
|
|
}
|
|
|
|
// A port-only deny fans out across family (and protocol), but the file may already
|
|
// hold a subset of those lines — a prior single-family add, or a manual edit. The
|
|
// add must heal the missing lines rather than treat the rule as fully present the
|
|
// moment one fan-out line matches: otherwise the other family/protocol stays open
|
|
// while the library reports the port blocked. Regression for the single-"exists"
|
|
// gate that skipped the whole fan-out.
|
|
func TestCSFPortOnlyDenyHealsMissingFamily(t *testing.T) {
|
|
ctx := context.Background()
|
|
fw := new(CSF)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.deny")
|
|
|
|
// The file already has only the IPv4 fan-out line.
|
|
require.NoError(t, os.WriteFile(path, []byte("tcp|in|d=80|s=0.0.0.0/0\n"), 0644))
|
|
|
|
// Adding a FamilyAny port-80 TCP deny must add the missing IPv6 line (and not
|
|
// duplicate the existing IPv4 one).
|
|
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
|
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
text := string(data)
|
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"),
|
|
"the pre-existing IPv4 line must be preserved, not duplicated")
|
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"),
|
|
"the missing IPv6 line must be added so IPv6:80 is actually blocked")
|
|
|
|
// A ProtocolAny deny whose udp line already exists must add the missing tcp line.
|
|
path2 := filepath.Join(dir, "csf.deny2")
|
|
require.NoError(t, os.WriteFile(path2, []byte("udp|in|d=53|s=0.0.0.0/0\n"), 0644))
|
|
anyDeny := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 53, Action: Drop}
|
|
require.NoError(t, fw.EditIPList(ctx, path2, Drop, anyDeny, false))
|
|
data2, err := os.ReadFile(path2)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, strings.Count(string(data2), "udp|in|d=53|s=0.0.0.0/0"),
|
|
"the pre-existing udp line must be preserved")
|
|
require.Equal(t, 1, strings.Count(string(data2), "tcp|in|d=53|s=0.0.0.0/0"),
|
|
"the missing tcp line must be added so tcp:53 is actually blocked")
|
|
}
|
|
|
|
// A csf advanced rule with an address, a port, and ProtocolAny cannot be
|
|
// expressed as a single line: csf.pl defaults a protocol-less line to tcp, so
|
|
// udp would be silently left open. MarshalAdvRule must reject it rather than
|
|
// under-apply it (the port-only form fans out instead, but that path has no
|
|
// address to place).
|
|
func TestCSFAdvRuleProtocolAnyWithAddressRejected(t *testing.T) {
|
|
fw := new(CSF)
|
|
_, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ProtocolAny, Port: 443, Source: "192.0.2.10", Action: Drop})
|
|
require.ErrorIs(t, err, ErrUnsupported,
|
|
"an address+port rule with ProtocolAny must be rejected, not written tcp-only")
|
|
|
|
// A concrete protocol on the same shape is fine.
|
|
_, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Drop})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// A csf tcp/udp advanced rule with an address but no port cannot be expressed:
|
|
// csf.pl's linefilter reads the port-flow field by position before the address
|
|
// field, so the address shifts into the port slot, gets parsed as a garbage
|
|
// --sport/--dport value, and the rule is silently dropped (linefilter requires
|
|
// both an address and a port match to install anything). MarshalAdvRule must
|
|
// reject this shape rather than emit an unenforceable line.
|
|
func TestCSFAdvRuleAddressWithoutPortRejected(t *testing.T) {
|
|
fw := new(CSF)
|
|
_, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop})
|
|
require.ErrorIs(t, err, ErrUnsupported,
|
|
"a tcp advanced rule with an address and no port must be rejected")
|
|
_, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept})
|
|
require.ErrorIs(t, err, ErrUnsupported,
|
|
"a udp advanced rule with an address and no port must be rejected")
|
|
|
|
// An ICMP rule with an address and no port is unaffected by this guard (it
|
|
// already requires a concrete ICMP type via a separate check above).
|
|
_, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept})
|
|
require.Error(t, err, "expected the existing icmp-without-type guard to fire, not this one")
|
|
}
|
|
|
|
func TestCSFParseAdvRuleIPv6(t *testing.T) {
|
|
fw := new(CSF)
|
|
|
|
// An IPv6 source with a port must parse (the field separator is '|', so a
|
|
// colon in the value is an IPv6 address, not a range/list separator).
|
|
r := fw.ParseAdvRule("tcp|in|d=22|s=2001:db8::1", Accept)
|
|
require.NotNil(t, r, "expected IPv6 advanced rule to parse")
|
|
require.Equal(t, IPv6, r.Family, "expected IPv6 family")
|
|
require.Equal(t, "2001:db8::1", r.Source)
|
|
require.EqualValues(t, 22, r.Port)
|
|
require.Equal(t, TCP, r.Proto)
|
|
require.False(t, r.IsOutput())
|
|
|
|
// An IPv4 destination with a port still parses.
|
|
r = fw.ParseAdvRule("tcp|out|d=80|d=192.0.2.1", Accept)
|
|
require.NotNil(t, r, "expected IPv4 advanced rule to parse")
|
|
require.Equal(t, IPv4, r.Family)
|
|
require.Equal(t, "192.0.2.1", r.Destination)
|
|
require.EqualValues(t, 80, r.Port)
|
|
|
|
// A destination port range is neither a valid IP nor a single port, so it
|
|
// must still be rejected.
|
|
require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000", Accept),
|
|
"expected a port range to be rejected")
|
|
|
|
// A comma-separated address list is still rejected.
|
|
require.Nil(t, fw.ParseAdvRule("tcp|in|s=192.0.2.1,192.0.2.2", Accept),
|
|
"expected a multi-address rule to be rejected")
|
|
}
|
|
|
|
func TestCSFFeatureRules(t *testing.T) {
|
|
fw := new(CSF)
|
|
|
|
// Advanced-rule encodings.
|
|
cases := []struct {
|
|
rule *Rule
|
|
want string
|
|
}{
|
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|d=80,443|s=1.2.3.4"},
|
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 2000, End: 3000}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|d=2000_3000|s=1.2.3.4"},
|
|
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "44.33.22.11", Family: IPv4, Action: Accept}, "icmp|in|d=8|s=44.33.22.11"},
|
|
{&Rule{Direction: DirOutput, Proto: UDP, Port: 53, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "udp|out|d=53|d=192.0.2.1"},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := fw.MarshalAdvRule(c.rule)
|
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
|
|
|
parsed := fw.ParseAdvRule(got, c.rule.Action)
|
|
require.NotNil(t, parsed, "failed to parse %q", got)
|
|
require.True(t, parsed.Equal(c.rule, true),
|
|
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
|
|
}
|
|
|
|
// An ICMP type given by name resolves to its number.
|
|
r := fw.ParseAdvRule("icmp|in|d=ping|s=44.33.22.11", Accept)
|
|
require.NotNil(t, r, "expected icmp type 8 from name ping")
|
|
require.NotNil(t, r.ICMPType, "expected icmp type 8 from name ping")
|
|
require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from name ping")
|
|
|
|
// csf reuses the port position for the ICMP type in BOTH the s= and d= fields
|
|
// (csf.pl maps `s=<n>` to `--icmp-type <n>` for an icmp rule, just like `d=`).
|
|
// A foreign rule that carries the type in s= must therefore read back as the
|
|
// ICMP type, not as a nonsensical source port.
|
|
r = fw.ParseAdvRule("icmp|in|s=8|d=44.33.22.11", Accept)
|
|
require.NotNil(t, r, "expected icmp rule with type in s= to parse")
|
|
require.NotNil(t, r.ICMPType, "expected s=8 to be read as icmp type 8")
|
|
require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from s=8")
|
|
require.False(t, r.HasSourcePorts(), "an icmp type must not be read as a source port")
|
|
require.Equal(t, "44.33.22.11", r.Destination)
|
|
|
|
// A colon range in an advanced rule is invalid (csf uses underscores there).
|
|
require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000|s=1.2.3.4", Accept),
|
|
"expected colon range in advanced rule to be rejected")
|
|
|
|
// csf.conf port lists parse single ports and colon ranges.
|
|
rules := fw.ParsePorts("20,21,30000:35000", IPv4, TCP, false)
|
|
require.Len(t, rules, 3, "expected 3 port rules")
|
|
require.Len(t, rules[2].Ports, 1)
|
|
require.Equal(t, PortRange{Start: 30000, End: 35000}, rules[2].Ports[0],
|
|
"expected a 30000-35000 range rule")
|
|
|
|
// EditRulePort adds a colon range token to the matching csf.conf port list.
|
|
require.Equal(t, `TCP_IN = "22,2000:3000"`,
|
|
fw.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22",
|
|
&Rule{Proto: TCP, Ports: []PortRange{{Start: 2000, End: 3000}}, Action: Accept}, false),
|
|
"unexpected csf.conf port edit")
|
|
|
|
// MarshalAdvRule rejects rules csf cannot express as advanced rules.
|
|
_, err := fw.MarshalAdvRule(&Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept})
|
|
require.Error(t, err, "expected error marshalling an icmpv6 advanced rule")
|
|
_, err = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Action: Accept})
|
|
require.Error(t, err, "expected error marshalling an advanced rule without an address")
|
|
}
|
|
|
|
func TestCSFSourcePorts(t *testing.T) {
|
|
fw := new(CSF)
|
|
|
|
// Source ports round-trip through the s= port-flow field, including a
|
|
// multiport list and an underscore range.
|
|
cases := []struct {
|
|
rule *Rule
|
|
want string
|
|
}{
|
|
{&Rule{Proto: TCP, SourcePort: 1234, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "tcp|in|s=1234|d=192.0.2.1"},
|
|
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "udp|in|s=80,443|s=1.2.3.4"},
|
|
{&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 2000, End: 3000}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|s=2000_3000|s=1.2.3.4"},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := fw.MarshalAdvRule(c.rule)
|
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
|
|
|
parsed := fw.ParseAdvRule(got, c.rule.Action)
|
|
require.NotNil(t, parsed, "failed to parse %q", got)
|
|
require.True(t, parsed.Equal(c.rule, true),
|
|
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
|
|
}
|
|
|
|
// Matching both a source and a destination port is not representable.
|
|
_, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept})
|
|
require.Error(t, err, "expected error matching both source and destination ports")
|
|
|
|
// A source-port rule with no address has nowhere to go.
|
|
require.Error(t, fw.checkSourcePort(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}))
|
|
}
|
|
|
|
// An ICMP advanced rule that carries an address but no concrete type must be
|
|
// rejected: csf's linefilter would consume the address as the icmp-type field
|
|
// and silently drop the rule, while the library reported it enforced.
|
|
func TestCSFICMPRequiresType(t *testing.T) {
|
|
fw := new(CSF)
|
|
|
|
// Address but no type: rejected by both the validator and the marshaller.
|
|
noType := &Rule{Proto: ICMP, Source: "1.2.3.4", Action: Accept}
|
|
require.Error(t, fw.checkICMP(noType))
|
|
_, err := fw.MarshalAdvRule(noType)
|
|
require.Error(t, err, "must not emit an ICMP advanced line with the address in the type field")
|
|
|
|
// With a concrete type it is a valid advanced rule and round-trips.
|
|
typed := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept}
|
|
require.NoError(t, fw.checkICMP(typed))
|
|
line, err := fw.MarshalAdvRule(typed)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line)
|
|
parsed := fw.ParseAdvRule(line, Accept)
|
|
require.NotNil(t, parsed)
|
|
require.True(t, parsed.Equal(typed, true), "round-trip mismatch: %q -> %+v", line, parsed)
|
|
|
|
// An address-less ICMP rule is still rejected (advanced rules need an address).
|
|
require.Error(t, fw.checkICMP(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}))
|
|
}
|
|
|
|
func TestCSFConnLimit(t *testing.T) {
|
|
fw := new(CSF)
|
|
|
|
// A csf.conf CONNLIMIT value parses into per-port reject rules carrying a
|
|
// per-source connection cap. csf's CONNLIMIT chain rejects the excess with a
|
|
// TCP reset (-j REJECT --reject-with tcp-reset), so the action is Reject.
|
|
rules := fw.ParseConnLimit("22;5,80;20")
|
|
require.Len(t, rules, 2)
|
|
require.Equal(t, TCP, rules[1].Proto)
|
|
require.EqualValues(t, 80, rules[1].Port)
|
|
require.Equal(t, Reject, rules[1].Action)
|
|
require.NotNil(t, rules[1].ConnLimit)
|
|
require.EqualValues(t, 20, rules[1].ConnLimit.Count)
|
|
require.True(t, rules[1].ConnLimit.PerSource)
|
|
|
|
// Editing the CONNLIMIT list adds, removes, and updates a port's entry.
|
|
require.Equal(t, `CONNLIMIT = "22;5,80;20"`, fw.editConnLimit("22;5", 80, 20, false))
|
|
require.Equal(t, `CONNLIMIT = "80;20"`, fw.editConnLimit("22;5,80;20", 22, 5, true))
|
|
require.Equal(t, `CONNLIMIT = "80;50"`, fw.editConnLimit("80;20", 80, 50, false))
|
|
|
|
// Only a single inbound tcp port, address-less, per-source, reject rule maps.
|
|
require.True(t, fw.isConnLimitRule(&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}))
|
|
bad := []*Rule{
|
|
{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // wrong action (csf rejects, not drops)
|
|
{Proto: UDP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // udp
|
|
{Proto: TCP, Port: 80, Source: "1.2.3.4", Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // address
|
|
{Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // range
|
|
{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}, // global
|
|
}
|
|
for _, r := range bad {
|
|
require.Error(t, fw.checkConnLimit(r), "expected reject for %+v", *r)
|
|
}
|
|
}
|
|
|
|
// ParseConnLimit's reported Family must follow csf.conf's IPV6 setting: csf.pl
|
|
// only installs the ip6tables CONNLIMIT rule when IPV6 is enabled, so on the
|
|
// shipped default (IPV6="0") CONNLIMIT protects IPv4 only, not both families.
|
|
func TestCSFConnLimitFamily(t *testing.T) {
|
|
disabled := &CSF{ipv6Enabled: false}
|
|
rules := disabled.ParseConnLimit("22;5")
|
|
require.Len(t, rules, 1)
|
|
require.Equal(t, IPv4, rules[0].Family,
|
|
"CONNLIMIT must report IPv4-only when csf.conf IPV6 is off")
|
|
|
|
enabled := &CSF{ipv6Enabled: true}
|
|
rules = enabled.ParseConnLimit("22;5")
|
|
require.Len(t, rules, 1)
|
|
require.Equal(t, FamilyAny, rules[0].Family,
|
|
"CONNLIMIT must report dual-stack (FamilyAny) when csf.conf IPV6 is on")
|
|
}
|
|
|
|
func TestCSFRedirectNAT(t *testing.T) {
|
|
fw := new(CSF)
|
|
|
|
cases := []struct {
|
|
rule *NATRule
|
|
want string
|
|
}{
|
|
// A local port redirect (IPy = "*").
|
|
{&NATRule{Kind: Redirect, Proto: TCP, Port: 666, ToPort: 25}, "*|666|*|25|tcp"},
|
|
// A forward to another host with a fixed destination address.
|
|
{&NATRule{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25, Family: IPv4}, "192.168.254.62|666|10.0.0.1|25|tcp"},
|
|
// A full-IP forward, all ports (portA/portB unset).
|
|
{&NATRule{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", ToAddress: "10.0.0.1", Family: IPv4}, "192.168.254.62|*|10.0.0.1|*|tcp"},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := fw.MarshalNATRule(c.rule)
|
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
|
|
|
parsed := fw.UnmarshalNATRule(got)
|
|
require.NotNil(t, parsed, "failed to parse %q", got)
|
|
require.True(t, parsed.EqualBase(c.rule), "round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
|
|
}
|
|
|
|
// csf.redirect cannot express source NAT, port ranges, source matching, or
|
|
// non-tcp/udp protocols.
|
|
bad := []*NATRule{
|
|
{Kind: SNAT, ToAddress: "1.2.3.4"},
|
|
{Kind: Masquerade},
|
|
{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "1.2.3.4"},
|
|
{Kind: DNAT, Proto: ICMP, ToAddress: "1.2.3.4"},
|
|
{Kind: DNAT, Proto: TCP, Source: "1.2.3.4", ToAddress: "5.6.7.8"},
|
|
}
|
|
for _, r := range bad {
|
|
_, err := fw.MarshalNATRule(r)
|
|
require.Error(t, err, "expected error marshalling %+v", *r)
|
|
}
|
|
|
|
// A malformed csf.redirect line is ignored by the parser.
|
|
require.Nil(t, fw.UnmarshalNATRule("nonsense|line"))
|
|
}
|
|
|
|
func TestCSFIPListComment(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.allow")
|
|
fw := &CSF{rulePrefix: "myapp"}
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, os.WriteFile(path, []byte(
|
|
"# myapp trusted office\n"+
|
|
"tcp|in|d=22|s=10.0.0.0/24\n"+
|
|
"\n"+
|
|
"# unrelated note\n"+
|
|
"# separated by blank\n"+
|
|
"192.0.2.5\n"+
|
|
"2001:db8::1 # inline ignored\n",
|
|
), 0644))
|
|
|
|
rules, err := fw.ParseIPList(path, Accept)
|
|
require.NoError(t, err)
|
|
|
|
// Advanced rule keeps the comment immediately above it.
|
|
adv := rules[0]
|
|
require.Equal(t, "trusted office", adv.Comment)
|
|
require.Equal(t, "10.0.0.0/24", adv.Source)
|
|
require.EqualValues(t, 22, adv.Port)
|
|
|
|
// A bare IPv4 line is one bidirectional DirAny rule carrying the accumulated comment.
|
|
host := rules[1]
|
|
require.Equal(t, DirAny, host.Direction)
|
|
require.Equal(t, "192.0.2.5", host.Source)
|
|
require.Equal(t, "unrelated note separated by blank", host.Comment)
|
|
|
|
// Inline comment is ignored, not treated as a rule comment.
|
|
v6 := rules[2]
|
|
require.Equal(t, DirAny, v6.Direction)
|
|
require.Equal(t, "", v6.Comment)
|
|
require.Equal(t, "2001:db8::1", v6.Source)
|
|
|
|
// Add a rule with a comment: a prefixed full-line comment is written above it.
|
|
add := &Rule{Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Accept, Comment: "web"}
|
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, false))
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), "# myapp web\n")
|
|
require.Contains(t, string(data), "tcp|in|d=443|s=192.0.2.10")
|
|
|
|
// Removing the rule drops the comment line above it as well.
|
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, true))
|
|
data, err = os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(data), "# myapp web")
|
|
require.NotContains(t, string(data), "192.0.2.10")
|
|
|
|
// A port-only rule has nowhere to go in an IP-list file; no dangling
|
|
// comment line should be written even when a comment is supplied.
|
|
portOnly := &Rule{Proto: TCP, Port: 8080, Action: Accept, Comment: "not-stored"}
|
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, portOnly, false))
|
|
data, err = os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(data), "not-stored")
|
|
|
|
// A rule appended after instructional header comments must still report
|
|
// HasPrefix: the prefix tag starts a fresh comment block so header
|
|
// comments are not absorbed into the rule's comment.
|
|
headerPath := filepath.Join(dir, "header_csf.allow")
|
|
require.NoError(t, os.WriteFile(headerPath, []byte(
|
|
"# This is the csf.allow file.\n"+
|
|
"# Add hosts/rules below, one per line.\n"+
|
|
"# Format: proto|flow|port|ip\n",
|
|
), 0644))
|
|
appendRule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept}
|
|
require.NoError(t, fw.EditIPList(ctx, headerPath, Accept, appendRule, false))
|
|
parsed, err := fw.ParseIPList(headerPath, Accept)
|
|
require.NoError(t, err)
|
|
require.Len(t, parsed, 1)
|
|
require.True(t, parsed[0].HasPrefix, "rule after header comments must be flagged with the prefix")
|
|
require.Equal(t, "", parsed[0].Comment)
|
|
}
|
|
|
|
// TestCSFRemovePreservesForeignHeader verifies that removing a managed rule keeps
|
|
// a foreign section header sitting directly above its prefix tag. ParseIPList
|
|
// treats the tag as starting a fresh comment block, so the header is not part of
|
|
// the rule's comment; removal must mirror that and not delete it.
|
|
func TestCSFRemovePreservesForeignHeader(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.allow")
|
|
fw := &CSF{rulePrefix: "myapp"}
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, os.WriteFile(path, []byte(
|
|
"# Section: web servers\n"+
|
|
"# myapp trusted\n"+
|
|
"192.0.2.50\n",
|
|
), 0644))
|
|
|
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, &Rule{Source: "192.0.2.50", Action: Accept}, true))
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
got := string(data)
|
|
require.NotContains(t, got, "192.0.2.50", "the managed rule must be removed")
|
|
require.NotContains(t, got, "# myapp trusted", "the rule's own tag comment is removed with it")
|
|
require.Contains(t, got, "# Section: web servers", "the foreign section header must be preserved")
|
|
}
|
|
|
|
// csf.pl's linefilter drops a csf.allow/csf.deny line (plain or advanced) that
|
|
// resolves to an IPv6 address whenever csf.conf's IPV6 is not "1" (the shipped
|
|
// default). ipv6Unavailable must flag exactly that shape, and only when
|
|
// ipv6Enabled is false; ICMPv6 (always hook-routed) must never be flagged.
|
|
func TestCSFIPv6UnavailableGate(t *testing.T) {
|
|
disabled := &CSF{ipv6Enabled: false}
|
|
enabled := &CSF{ipv6Enabled: true}
|
|
|
|
bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept}
|
|
require.True(t, disabled.ipv6Unavailable(bareV6Host),
|
|
"an IPv6 rule must be blocked when csf.conf IPV6 is off")
|
|
require.False(t, enabled.ipv6Unavailable(bareV6Host),
|
|
"an IPv6 rule must be allowed when csf.conf IPV6 is on")
|
|
|
|
bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept}
|
|
require.False(t, disabled.ipv6Unavailable(bareV4Host),
|
|
"an IPv4 rule must never be blocked by the IPv6 gate")
|
|
|
|
// ICMPv6 always routes through the raw-iptables hook, which runs outside
|
|
// csf's own IPV6-gated logic, so it must not be blocked either way.
|
|
icmpv6 := &Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept}
|
|
require.False(t, disabled.ipv6Unavailable(icmpv6),
|
|
"an icmpv6 rule must not be blocked by the IPv6 gate")
|
|
}
|
|
|
|
// csf.deny encodes no action of its own, so a rule added with Action Drop must be
|
|
// found and removed by the same Drop rule rather than leaking.
|
|
func TestCSFDropRuleRemovable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.deny")
|
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
|
fw := new(CSF)
|
|
drop := &Rule{Proto: TCP, Port: 3306, Source: "1.2.3.4", Action: Drop}
|
|
|
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
|
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
|
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, true))
|
|
data, _ := os.ReadFile(path)
|
|
require.NotContains(t, string(data), "1.2.3.4", "a Drop rule must be removable by the same Drop rule")
|
|
}
|
|
|
|
// A ported csf advanced rule that matches both a source and a destination address
|
|
// cannot be expressed (a csf advanced rule holds a single address field) and must
|
|
// be rejected rather than silently dropping the destination. The portless bare
|
|
// form is not tested here: AddRule diverts it to the raw-iptables hook
|
|
// (hostNeedsHook), so the writer never sees it.
|
|
func TestCSFDualAddressRejected(t *testing.T) {
|
|
fw := new(CSF)
|
|
_, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept})
|
|
require.Error(t, err, "a dual-address csf advanced rule must be rejected")
|
|
}
|
|
|
|
// csf's port lists are TCP_IN/UDP_IN; a concrete non-tcp/udp protocol (sctp) would
|
|
// wrongly be written into both, so it must error. ProtocolAny is allowed (it maps
|
|
// to both lists as the faithful "any" expansion).
|
|
func TestCSFPortProtoGuard(t *testing.T) {
|
|
fw := new(CSF)
|
|
require.Error(t, fw.checkPortProto(&Rule{Proto: SCTP, Port: 80, Action: Accept}), "sctp port must be rejected")
|
|
require.NoError(t, fw.checkPortProto(&Rule{Port: 80, Action: Accept}), "ProtocolAny port is allowed for csf")
|
|
require.NoError(t, fw.checkPortProto(&Rule{Proto: TCP, Port: 80, Action: Accept}))
|
|
require.NoError(t, fw.checkPortProto(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}))
|
|
}
|
|
|
|
// csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6
|
|
// connections; there is no v6 variant to merge), so a connection-limit rule read
|
|
// from it must be FamilyAny — not IPv4 — or a FamilyAny desired connlimit rule
|
|
// (the natural shape: no address, so no family is implied) never matches its own
|
|
// read-back and Sync removes-and-re-adds it every reconcile, firing csf -r each
|
|
// time. This mirrors the APF behavior above.
|
|
func TestCSFConnLimitFamilyIsAny(t *testing.T) {
|
|
// csf.pl only installs the ip6tables CONNLIMIT rule when csf.conf's IPV6 is
|
|
// enabled; only then does a dual-stack FamilyAny read-back (and the
|
|
// FamilyAny-desired-rule match below) hold. See TestCSFConnLimitFamily for
|
|
// the IPV6-disabled (stock default) case, where CONNLIMIT is IPv4-only.
|
|
f := &CSF{ipv6Enabled: true}
|
|
rules := f.ParseConnLimit("80;20")
|
|
require.Len(t, rules, 1)
|
|
require.Equal(t, FamilyAny, rules[0].Family,
|
|
"a dual-stack CONNLIMIT entry must read back as FamilyAny when csf.conf IPV6 is on")
|
|
|
|
desired := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}}
|
|
require.True(t, desired.Equal(rules[0], true),
|
|
"FamilyAny connlimit must equal the CSF read-back or Sync churns")
|
|
}
|
|
|
|
// csf.redirect only expresses a DNAT with a concrete matched destination and
|
|
// either both a matched and target port or neither. MarshalNATRule must reject
|
|
// the shapes csf would refuse rather than emit a line that aborts the redirect
|
|
// load — a round-trip test alone would not catch it.
|
|
func TestCSFDNATMarshalRejectsUnexpressible(t *testing.T) {
|
|
f := new(CSF)
|
|
bad := []struct {
|
|
name string
|
|
rule *NATRule
|
|
}{
|
|
{"empty destination (ipx=*)", &NATRule{Kind: DNAT, Proto: TCP, Port: 666, ToAddress: "10.0.0.1", ToPort: 25}},
|
|
{"matched port but no target port", &NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", Port: 80, ToAddress: "5.6.7.8"}},
|
|
{"target port but no matched port", &NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", ToAddress: "5.6.7.8", ToPort: 8080}},
|
|
}
|
|
for _, c := range bad {
|
|
_, err := f.MarshalNATRule(c.rule)
|
|
require.Errorf(t, err, "csf.redirect should reject %s", c.name)
|
|
// The rejection must carry ErrUnsupportedNAT so callers (and the integration
|
|
// harness's roundTripNATVariants) treat the shape as unexpressible and skip
|
|
// it rather than seeing a hard error.
|
|
require.ErrorIsf(t, err, ErrUnsupportedNAT, "%s should be reported as unsupported NAT", c.name)
|
|
}
|
|
|
|
// The two shapes csf accepts still marshal: a full-IP forward (no ports) and a
|
|
// concrete port forward.
|
|
full, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", ToAddress: "5.6.7.8"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "1.2.3.4|*|5.6.7.8|*|tcp", full)
|
|
fwd, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", Port: 80, ToAddress: "5.6.7.8", ToPort: 8080})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "1.2.3.4|80|5.6.7.8|8080|tcp", fwd)
|
|
}
|
|
|
|
// TestCSFBareProtocolRoutesToHook guards that a rule with no port and no address
|
|
// (a bare protocol match) is injected through the pre-hook rather than rejected or
|
|
// silently written nowhere. csf keys every native rule on a port or an address, so
|
|
// such a rule maps to no csf construct; iptables expresses it directly, so addRule
|
|
// diverts it to the hook (bareProtoNeedsHook). ICMP keeps its own handling.
|
|
func TestCSFBareProtocolRoutesToHook(t *testing.T) {
|
|
for _, r := range []*Rule{
|
|
{Proto: TCP, Action: Accept},
|
|
{Proto: ProtocolAny, Action: Accept},
|
|
{Proto: UDP, Action: Drop},
|
|
} {
|
|
require.True(t, bareProtoNeedsHook(r),
|
|
"a portless, addressless rule must route to the hook, not be rejected: %+v", r)
|
|
}
|
|
require.False(t, bareProtoNeedsHook(&Rule{Proto: ICMP, Action: Accept}),
|
|
"an ICMP rule keeps its own handling and is excluded from the bare-protocol hook route")
|
|
}
|
|
|
|
// A port-only reject (no address) must be written so csf actually enforces it.
|
|
// csf's advanced-rule handler only emits an iptables rule when the line carries a
|
|
// source/destination IP alongside the port, so a bare "d=80" was parsed by csf
|
|
// and then silently never applied — the port stayed open while the library
|
|
// reported it blocked. The rule must be written with the "any" network as the
|
|
// address (so csf enforces it) and must still round-trip and remove: parseAddr
|
|
// normalizes the "any" network back to an empty address, and mergeFamilies
|
|
// collapses the v4/v6 pair a family-neutral rule writes.
|
|
func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
|
|
fw := new(CSF)
|
|
ctx := context.Background()
|
|
|
|
for _, rule := range []*Rule{
|
|
{Action: Reject, Proto: TCP, Port: 80},
|
|
{Action: Reject, Proto: TCP, Port: 80, Family: IPv4},
|
|
{Action: Reject, Proto: TCP, Port: 8080, Family: IPv6},
|
|
{Action: Reject, Proto: TCP, Port: 443, Direction: DirOutput},
|
|
} {
|
|
deny := filepath.Join(t.TempDir(), "csf.deny")
|
|
require.NoError(t, os.WriteFile(deny, nil, 0o644))
|
|
require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, false))
|
|
|
|
// The written line must carry an address, or csf never applies it.
|
|
raw, err := os.ReadFile(deny)
|
|
require.NoError(t, err)
|
|
require.True(t, strings.Contains(string(raw), "0.0.0.0/0") || strings.Contains(string(raw), "::/0"),
|
|
"port-only reject (%s) must be written with an address so csf enforces it; got:\n%s", rule.Family, raw)
|
|
|
|
// GetRules applies mergeFamilies to the parsed list; mirror it here.
|
|
got, err := fw.ParseIPList(deny, Reject)
|
|
require.NoError(t, err)
|
|
got = mergeFamilies(got)
|
|
require.Len(t, got, 1, "port-only reject (%s) must round-trip to one rule", rule.Family)
|
|
require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one: %+v", got[0])
|
|
|
|
// It must also be removable (matched back on delete).
|
|
require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, true))
|
|
got, err = fw.ParseIPList(deny, Reject)
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 0, "rule (%s) must be fully removed", rule.Family)
|
|
}
|
|
}
|
|
|
|
// A bare all-protocol host rule (address, no port) is the one portless address
|
|
// shape csf.allow/csf.deny express, written as the plain address line. The
|
|
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
|
|
// are diverted to the hook by AddRule (hostNeedsHook) and never reach this
|
|
// writer, so only the legitimate write is exercised here.
|
|
func TestCSFBareHostWritten(t *testing.T) {
|
|
fw := new(CSF)
|
|
ctx := context.Background()
|
|
|
|
list := filepath.Join(t.TempDir(), "csf.allow")
|
|
require.NoError(t, os.WriteFile(list, nil, 0o644))
|
|
require.NoError(t, fw.EditIPList(ctx, list, Accept, &Rule{Source: "1.2.3.4", Action: Accept}, false))
|
|
got, err := os.ReadFile(list)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(got), "1.2.3.4", "an any-protocol host rule must be written as a plain address")
|
|
}
|
|
|
|
// A port-only "any"-source deny is written to csf.deny as a family-specific
|
|
// placeholder line (0.0.0.0/0 for IPv4, ::/0 for IPv6). The two lines cover
|
|
// different families, so adding the IPv6 twin while the IPv4 line already exists
|
|
// must write it — advMatch matches with EqualForDedup, so without the family
|
|
// coverage gate the IPv6 add was silently dropped as a false duplicate, leaving
|
|
// IPv6 open and making Sync churn forever.
|
|
func TestCSFCrossFamilyAdvDenyBothWritten(t *testing.T) {
|
|
ctx := context.Background()
|
|
fw := new(CSF)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.deny")
|
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
|
|
|
v4 := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop}
|
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 80, Action: Drop}
|
|
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, v4, false))
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, v6, false))
|
|
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
text := string(data)
|
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "IPv4 deny line must be present")
|
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"), "IPv6 deny line must be present, not dropped as a false duplicate")
|
|
|
|
// Removing only the IPv6 twin must leave the IPv4 line intact (family-scoped
|
|
// removal must not delete the other family's line).
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, v6, true))
|
|
data, err = os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
text = string(data)
|
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "removing IPv6 must not drop the IPv4 line")
|
|
require.Equal(t, 0, strings.Count(text, "tcp|in|d=80|s=::/0"), "the IPv6 line must be removed")
|
|
}
|
|
|
|
// A FamilyAny port-only deny writes both placeholder lines and must still be
|
|
// idempotent on re-add and fully removable — the EqualForDedup/EqualForRemoval
|
|
// gate in advMatch must not disturb the FamilyAny case.
|
|
func TestCSFFamilyAnyAdvDenyRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
fw := new(CSF)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.deny")
|
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
|
|
|
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
|
// Re-add is idempotent.
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=22|s=0.0.0.0/0"))
|
|
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=22|s=::/0"))
|
|
// One removal clears both family lines.
|
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true))
|
|
data, err = os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(data), "d=22", "a FamilyAny removal must clear both placeholder lines")
|
|
}
|
|
|
|
// TestCSFMergeProtocolsRespectsFamily guards mergeProtocols against collapsing a
|
|
// tcp/udp pair that differ in IP family. CSF expresses IPv4 and IPv6 opens through
|
|
// separate config keys (TCP_IN vs TCP6_IN), so a `TCP_IN="53"` + `UDP6_IN="53"`
|
|
// config produces a tcp/IPv4 rule and a udp/IPv6 rule. Those cover different
|
|
// families and must NOT merge into one ProtocolAny rule — doing so drops the IPv6
|
|
// coverage from the read-back and makes Sync churn forever.
|
|
func TestCSFMergeProtocolsRespectsFamily(t *testing.T) {
|
|
fw := new(CSF)
|
|
// tcp/IPv4 and udp/IPv6, both port 53 inbound — same in every field but proto
|
|
// and family.
|
|
tcpV4 := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
|
|
udpV6 := &Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}
|
|
|
|
out := fw.mergeProtocols([]*Rule{tcpV4, udpV6})
|
|
|
|
if len(out) != 2 {
|
|
t.Fatalf("a cross-family tcp/udp pair must not merge: got %d rules %+v, want 2", len(out), out)
|
|
}
|
|
// Both families must still be represented.
|
|
var haveV4, haveV6 bool
|
|
for _, r := range out {
|
|
switch r.impliedFamily() {
|
|
case IPv4:
|
|
haveV4 = true
|
|
case IPv6:
|
|
haveV6 = true
|
|
}
|
|
}
|
|
if !haveV4 || !haveV6 {
|
|
t.Fatalf("both families must survive: haveV4=%v haveV6=%v (%+v)", haveV4, haveV6, out)
|
|
}
|
|
}
|
|
|
|
// TestCSFMergeProtocolsSameFamily confirms the intended merge still happens for a
|
|
// same-family tcp/udp pair (the fanned-out form of one ProtocolAny port rule).
|
|
func TestCSFMergeProtocolsSameFamily(t *testing.T) {
|
|
fw := new(CSF)
|
|
tcp := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
|
|
udp := &Rule{Family: IPv4, Proto: UDP, Port: 53, Action: Accept}
|
|
|
|
out := fw.mergeProtocols([]*Rule{tcp, udp})
|
|
if len(out) != 1 {
|
|
t.Fatalf("a same-family tcp/udp pair should merge to one rule: got %d %+v", len(out), out)
|
|
}
|
|
if out[0].Proto != ProtocolAny {
|
|
t.Fatalf("merged rule should be ProtocolAny, got %v", out[0].Proto)
|
|
}
|
|
}
|
|
|
|
// GetRules reports both the library's own rules and foreign ones, each tagged
|
|
// with HasPrefix and with the configured prefix stripped from the surfaced comment.
|
|
func TestCSFHasPrefixFlag(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csf.allow")
|
|
fw := &CSF{rulePrefix: "myapp"}
|
|
|
|
require.NoError(t, os.WriteFile(path, []byte(
|
|
"# myapp web\n"+
|
|
"tcp|in|d=443|s=192.0.2.10\n"+
|
|
"\n"+
|
|
"# hand-added by an admin\n"+
|
|
"tcp|in|d=22|s=10.0.0.0/24\n",
|
|
), 0644))
|
|
|
|
rules, err := fw.ParseIPList(path, Accept)
|
|
require.NoError(t, err)
|
|
require.Len(t, rules, 2)
|
|
|
|
// Our rule: prefix stripped from the comment, flagged as carrying the prefix.
|
|
require.Equal(t, "web", rules[0].Comment)
|
|
require.True(t, rules[0].HasPrefix, "prefixed comment sets HasPrefix")
|
|
|
|
// The admin's rule: comment surfaces unchanged, no prefix.
|
|
require.Equal(t, "hand-added by an admin", rules[1].Comment)
|
|
require.False(t, rules[1].HasPrefix, "a comment without the prefix is not flagged")
|
|
}
|