csf and apf both back a native config with a raw-iptables pre-hook, but a rule their config could not express was reported as unsupported even when iptables applies it directly. Route those shapes to the hook, and share the routing predicates between the two backends. Shared hook layer (hooks_linux.go): extract parseAddrFamily, listRow/listRows, bareHostShape, hostNeedsHook, advRuleNeedsHook and bareProtoNeedsHook so both backends route on the same predicates. MarshalAdvRule no longer returns an error for shapes its caller has already routed away. csf: replace checkConnLimit, checkICMP and checkPortProto with a single needsHook gate mirroring apf's. A connection limit CONNLIMIT cannot express, an ICMP rule the advanced-line format cannot carry (see nativeICMP), and a port on a protocol its TCP_IN/UDP_IN lists cannot hold now reach the hook. A port on ProtocolAny has no form in iptables either, so it stays on the native path and iptablesRuleValid rejects it with ErrUnsupported. csf: remove a deny from csf.deny whatever action the caller names, as apf does. The file encodes no action of its own, so the deny of an address is a single entry; the old early return reported success while csf kept enforcing it. IPv6: ipv6Unavailable only rejects concrete-IPv6 rules, so a FamilyAny rule passed the gate and then fanned out into an IPv6 row. With csf.conf's IPV6 or conf.apf's USE_IPV6 off that row is never enforced, and in the hook it is worse than inert: the pre-hook runs on every reload while neither backend flushes ip6tables, so an injected ip6tables line is re-appended each time and outlives its removal from the hook. Narrow both write-side fan-outs to the families the backend enforces (hookScript.writeFamilies, writeFamilyRows). Removals stay wide so a v6 line written while IPv6 was on is still swept once it is off. Integration: the shared probes take the first variant a backend accepts, so widening csf's accepted shapes silently moved icmp/icmptype/connlimit onto its hook path. Add csf-gated subtests to keep the native csf.conf CONNLIMIT and csf.allow advanced-rule paths covered, alongside one for action-agnostic csf.deny removal.
927 lines
43 KiB
Go
927 lines
43 KiB
Go
package firewall
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// A TCPUDP 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 TestCSFTCPUDPRejectFansOut(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: TCPUDP, 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 TCPUDP 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 TCPUDP rule, and be fully
|
|
// removed by one RemoveRule. Before the fix the add/remove matcher compared
|
|
// TCPUDP against the concrete-protocol lines exactly, so re-adds duplicated
|
|
// the pair and removal was a silent no-op.
|
|
func TestCSFTCPUDPPortDenyRoundTrip(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: TCPUDP, Port: 80, Action: Drop}
|
|
|
|
// Add fans the TCPUDP 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 TCPUDP 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 TCPUDP deny must not duplicate its udp line")
|
|
|
|
// The fanned lines read back as their own rules and cover the TCPUDP deny.
|
|
parsed, err := fw.ParseIPList(path, Drop)
|
|
require.NoError(t, err)
|
|
require.True(t, deny.CoveredBy(parsed), "the tcp+udp deny lines must cover the TCPUDP rule")
|
|
for _, g := range parsed {
|
|
require.True(t, deny.Covers(g), "a fanned line must not widen the rule: %+v", g)
|
|
}
|
|
|
|
// 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 TCPUDP 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()
|
|
// IPv6 enabled, so the deny fans out across families and the missing v6 line heals.
|
|
fw := &CSF{ipv6Enabled: true}
|
|
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 TCPUDP 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: TCPUDP, 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 TCPUDP cannot be expressed as a
|
|
// single line: csf.pl defaults a protocol-less line to tcp, so udp would be silently
|
|
// left open. AddRule must therefore fan the rule into a tcp rule and a udp rule
|
|
// before it reaches MarshalAdvRule, each of which marshals to its own line.
|
|
func TestCSFAdvRuleTCPUDPWithAddressFansOut(t *testing.T) {
|
|
fw := new(CSF)
|
|
both := &Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Source: "192.0.2.10", Action: Drop}
|
|
subs := expandProtocols(both)
|
|
require.Len(t, subs, 2, "a TCPUDP rule must fan out before it is marshalled")
|
|
|
|
var lines []string
|
|
for _, sub := range subs {
|
|
lines = append(lines, fw.MarshalAdvRule(sub))
|
|
}
|
|
require.Equal(t, []string{"tcp|in|d=443|s=192.0.2.10", "udp|in|d=443|s=192.0.2.10"}, lines,
|
|
"each transport must get its own line so udp is not left open")
|
|
}
|
|
|
|
// 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). AddRule must route this
|
|
// shape to the raw-iptables hook rather than let MarshalAdvRule emit an
|
|
// unenforceable line.
|
|
func TestCSFAdvRuleAddressWithoutPortRouted(t *testing.T) {
|
|
fw := new(CSF)
|
|
require.True(t, hostNeedsHook(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop}),
|
|
"a tcp host with no port must go to the hook, not an advanced line")
|
|
require.True(t, hostNeedsHook(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept}),
|
|
"a udp host with no port must go to the hook, not an advanced line")
|
|
|
|
// An ICMP rule with an address and no type takes its own route: csf's linefilter
|
|
// would consume the address as the icmp-type field, so it goes to the hook, whose
|
|
// iptables rule needs no type.
|
|
icmp := &Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept}
|
|
require.False(t, hostNeedsHook(icmp), "icmp keeps its own handling")
|
|
require.True(t, fw.needsHook(icmp), "a typeless icmp host must be routed to the hook")
|
|
}
|
|
|
|
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 := fw.MarshalAdvRule(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")
|
|
|
|
// An ICMPv6 rule has no advanced-line form at all and never reaches the
|
|
// marshaller: ruleNeedsHook routes it to the raw-iptables hook. (An address-less
|
|
// port rule has no advanced form either; it lands in a csf.conf port list or the
|
|
// csf.deny fan-out, covered by TestCSFTCPUDPRejectFansOut.)
|
|
require.True(t, ruleNeedsHook(&Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept}),
|
|
"an icmpv6 rule must be routed to the hook, never marshalled as an advanced line")
|
|
}
|
|
|
|
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 := fw.MarshalAdvRule(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)
|
|
}
|
|
|
|
// The single port-flow field holds one port match, so a source port matched with
|
|
// a destination port has no advanced-line form; neither does an address-less
|
|
// source port, since an advanced line requires an address. AddRule routes both to
|
|
// the raw-iptables hook rather than marshalling them.
|
|
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}),
|
|
"a dual-port rule must be routed to the hook")
|
|
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}),
|
|
"an address-less source-port rule must be routed to the hook")
|
|
}
|
|
|
|
// Only an ICMP rule with exactly one address and a concrete type is a csf advanced
|
|
// rule; every other ICMP shape is routed to the raw-iptables hook, which needs
|
|
// neither. csf's linefilter would otherwise consume the address as the icmp-type
|
|
// field and silently drop the rule, while the library reported it enforced.
|
|
func TestCSFICMPRouting(t *testing.T) {
|
|
fw := new(CSF)
|
|
|
|
// With one address and 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.True(t, fw.nativeICMP(typed))
|
|
require.False(t, fw.needsHook(typed), "a typed icmp host is a native advanced rule")
|
|
line := fw.MarshalAdvRule(typed)
|
|
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)
|
|
|
|
// Every other ICMP shape has no advanced-line form, so it is written to the hook
|
|
// rather than rejected: iptables expresses each directly.
|
|
hooked := []*Rule{
|
|
{Proto: ICMP, Source: "1.2.3.4", Action: Accept}, // address, no type
|
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, // type, no address
|
|
{Proto: ICMP, Action: Drop}, // neither
|
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}, // both addresses
|
|
}
|
|
h := &hookScript{}
|
|
for _, r := range hooked {
|
|
require.False(t, fw.nativeICMP(r), "expected non-native icmp for %+v", *r)
|
|
require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r)
|
|
lines, err := h.rulesToLines(r)
|
|
require.NoError(t, err, "the hook must be able to render %+v", *r)
|
|
require.Len(t, lines, 1)
|
|
require.Contains(t, lines[0], "iptables -A ")
|
|
require.Contains(t, lines[0], "-p icmp")
|
|
}
|
|
}
|
|
|
|
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 onto
|
|
// CONNLIMIT. Every other connection limit is written to the hook's `-m connlimit`
|
|
// match instead of being rejected, since iptables expresses each directly.
|
|
native := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}
|
|
require.True(t, fw.isConnLimitRule(native))
|
|
require.False(t, fw.needsHook(native), "a native connlimit belongs in csf.conf")
|
|
|
|
hooked := []*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
|
|
{Proto: TCP, Port: 80, Direction: DirOutput, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // outbound
|
|
}
|
|
h := &hookScript{}
|
|
for _, r := range hooked {
|
|
require.False(t, fw.isConnLimitRule(r), "expected non-native connlimit for %+v", *r)
|
|
require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r)
|
|
lines, err := h.rulesToLines(r)
|
|
require.NoError(t, err, "the hook must be able to render %+v", *r)
|
|
require.NotEmpty(t, lines)
|
|
for _, line := range lines {
|
|
require.Contains(t, line, "-m connlimit --connlimit-above 5")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.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), so AddRule
|
|
// routes it to the raw-iptables hook rather than silently dropping the destination.
|
|
// The portless bare form takes the same path one predicate earlier (hostNeedsHook).
|
|
func TestCSFDualAddressRouted(t *testing.T) {
|
|
require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}),
|
|
"a ported dual-address rule must be routed to the hook, never marshalled as an advanced line")
|
|
}
|
|
|
|
// csf's port lists are TCP_IN/UDP_IN. A port on a concrete protocol they cannot hold
|
|
// (sctp) is written to the hook, whose iptables rule matches it directly. A port on
|
|
// ProtocolAny is expressible nowhere — it matches every IP protocol, and iptables has
|
|
// no protocol-less port match — so it is the one shape csf still rejects.
|
|
func TestCSFPortProtoGuard(t *testing.T) {
|
|
fw := new(CSF)
|
|
|
|
// An sctp port goes to the hook rather than into both csf.conf lists.
|
|
sctp := &Rule{Proto: SCTP, Port: 80, Action: Accept}
|
|
require.True(t, fw.needsHook(sctp), "an sctp port must be routed to the hook")
|
|
lines, err := (&hookScript{}).rulesToLines(sctp)
|
|
require.NoError(t, err)
|
|
for _, line := range lines {
|
|
require.Contains(t, line, "-p sctp")
|
|
require.Contains(t, line, "--dport 80")
|
|
}
|
|
|
|
// An any-protocol port has no form in csf's config nor in iptables, so the native
|
|
// path rejects it as unsupported.
|
|
anyProto := &Rule{Port: 80, Action: Accept}
|
|
require.False(t, fw.needsHook(anyProto), "an any-protocol port has no hook form either")
|
|
require.ErrorIs(t, fw.AddRule(context.Background(), "", anyProto), ErrUnsupported,
|
|
"an any-protocol port must be rejected")
|
|
|
|
// The shapes csf's own config carries stay off the hook.
|
|
require.False(t, fw.needsHook(&Rule{Proto: TCP, Port: 80, Action: Accept}))
|
|
require.False(t, fw.needsHook(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept}))
|
|
}
|
|
|
|
// csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6
|
|
// connections; there is no separate v6 variant), 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.
|
|
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 a family-neutral rule
|
|
// writes one line per family, which cover it between them.
|
|
func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
|
|
// IPv6 enabled, so a family-neutral reject writes a line per family.
|
|
fw := &CSF{ipv6Enabled: true}
|
|
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)
|
|
|
|
// A concrete-family rule is one line; a family-neutral one is a line per family.
|
|
wantRows := 1
|
|
if rule.impliedFamily() == FamilyAny {
|
|
wantRows = 2
|
|
}
|
|
got, err := fw.ParseIPList(deny, Reject)
|
|
require.NoError(t, err)
|
|
require.Len(t, got, wantRows, "port-only reject (%s) must round-trip to %d row(s)", rule.Family, wantRows)
|
|
require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule: %+v", got)
|
|
for _, g := range got {
|
|
require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g)
|
|
}
|
|
|
|
// 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 — EditIPList matches an existing line 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 — EditIPList's EqualForDedup/
|
|
// EqualForRemoval gate must not disturb the FamilyAny case.
|
|
func TestCSFFamilyAnyAdvDenyRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
// IPv6 enabled, so a FamilyAny deny fans out to both placeholder lines.
|
|
fw := &CSF{ipv6Enabled: true}
|
|
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")
|
|
}
|
|
|
|
// 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 neither a TCPUDP/IPv4 rule nor
|
|
// its IPv6 twin may be reported as present against them — treating the pair as one
|
|
// both-transports rule drops a family's coverage and makes Sync churn forever.
|
|
func TestCSFCrossFamilyPairCoversNeitherTransportPair(t *testing.T) {
|
|
stored := []*Rule{
|
|
{Family: IPv4, Proto: TCP, Port: 53, Action: Accept},
|
|
{Family: IPv6, Proto: UDP, Port: 53, Action: Accept},
|
|
}
|
|
|
|
require.False(t, (&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored),
|
|
"udp/IPv6 must not stand in for the missing udp/IPv4 open")
|
|
require.False(t, (&Rule{Family: IPv6, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored),
|
|
"tcp/IPv4 must not stand in for the missing tcp/IPv6 open")
|
|
require.False(t, (&Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored))
|
|
|
|
// Each stored rule still covers exactly its own cell.
|
|
require.True(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}).CoveredBy(stored))
|
|
require.True(t, (&Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}).CoveredBy(stored))
|
|
}
|
|
|
|
// A same-family tcp/udp pair — the fanned-out form csf writes for one TCPUDP port
|
|
// rule — does cover that rule.
|
|
func TestCSFSameFamilyPairCoversTCPUDP(t *testing.T) {
|
|
stored := []*Rule{
|
|
{Family: IPv4, Proto: TCP, Port: 53, Action: Accept},
|
|
{Family: IPv4, Proto: UDP, Port: 53, Action: Accept},
|
|
}
|
|
require.True(t, (&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored))
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// With csf.conf's IPV6 off, csf installs no IPv6 rule from its config, so a
|
|
// family-neutral port-only deny must be written as the IPv4 line alone. An IPv6
|
|
// placeholder line would sit inert in csf.deny and read back as an IPv6 rule csf does
|
|
// not enforce and AddRule would reject. Removal still matches the target against every
|
|
// line, so a v6 line written while IPv6 was on is swept regardless.
|
|
func TestCSFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
off := new(CSF)
|
|
|
|
path := filepath.Join(dir, "csf.deny")
|
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
|
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
|
|
require.NoError(t, off.EditIPList(ctx, path, Drop, deny, false))
|
|
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), "tcp|in|d=80|s=0.0.0.0/0", "the IPv4 line must be written")
|
|
require.NotContains(t, string(data), "::/0",
|
|
"no IPv6 line may be written while csf's IPv6 handling is off")
|
|
|
|
// The rows read back cover the rule, and only for the family csf enforces.
|
|
got, err := off.ParseIPList(path, Drop)
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
require.Equal(t, IPv4, got[0].impliedFamily())
|
|
|
|
// A rule pinned to IPv6 still writes its line: the ipv6Unavailable gate stops a
|
|
// fresh add, and Restore bypasses that gate on purpose to reproduce a snapshot.
|
|
v6path := filepath.Join(dir, "csf.deny.v6")
|
|
require.NoError(t, os.WriteFile(v6path, nil, 0644))
|
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 80, Action: Drop}
|
|
require.NoError(t, off.EditIPList(ctx, v6path, Drop, v6, false))
|
|
data, err = os.ReadFile(v6path)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), "tcp|in|d=80|s=::/0")
|
|
|
|
// Switching IPv6 off must not strand the v6 line written while it was on.
|
|
on := &CSF{ipv6Enabled: true}
|
|
bothPath := filepath.Join(dir, "csf.deny.both")
|
|
require.NoError(t, os.WriteFile(bothPath, nil, 0644))
|
|
require.NoError(t, on.EditIPList(ctx, bothPath, Drop, deny, false))
|
|
data, err = os.ReadFile(bothPath)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), "::/0")
|
|
require.NoError(t, off.EditIPList(ctx, bothPath, Drop, deny, true))
|
|
data, err = os.ReadFile(bothPath)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(data), "d=80",
|
|
"removal must sweep the stale IPv6 line even with IPv6 off")
|
|
}
|