- New Capabilities: PortPair, Negation, RejectAction, FamilyWithoutAddress, DenyActionFromConfig, advertised per backend. - coversDirection isolates DirForward even when output is unowned; add splitNATDualRow so a concrete-family removal re-adds the opposite family's NAT translation. - Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the "invalid" sentinel as input while JSON round-trips it. - Sync counts additions on mid-batch failure and uses RuleBatcher. - NewManager runs a probe loop joining each backend's reason for diagnosability; services.go drops "generated" from enabled, handles it on enable, clears start-limit-hit on restart, and matches rc.local by token. - nftables: per-source connection limits (meter set), quoted-token parsing preserving log-prefix spacing, digit-led prefix sanitizing. - apf/csf: deny-action-from-config with cached STOP settings, port lists and inexpressible shapes routed through the pre-hook, confKeyApplies guard against a missing config line. - atomic config writes fsync before rename and resolve symlinks; readConfValue is last-assignment-wins; runCommand preserves the exit code through the wrapped error. - Move coreos/go-systemd to the maintained v22 module directly.
1074 lines
50 KiB
Go
1074 lines
50 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, shapeNeedsHook(&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, shapeNeedsHook(&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, shapeNeedsHook(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, shapeNeedsHook(&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, shapeNeedsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}),
|
|
"an address-less source-port rule must be routed to the hook")
|
|
}
|
|
|
|
// TestCSFMultiPortRouting pins where a discrete multi-port list goes. An
|
|
// address-less multi-port accept has no single native form — a csf.conf port
|
|
// list stores each port as an independent token that reads back as its own rule
|
|
// — so it is injected through the hook's multiport match and round-trips whole;
|
|
// single-token shapes, addressed lists (an advanced line's port field is a
|
|
// comma list), and the port-only deny fan-out stay native.
|
|
func TestCSFMultiPortRouting(t *testing.T) {
|
|
fw := new(CSF)
|
|
list := []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}
|
|
|
|
hooked := &Rule{Proto: TCP, Ports: list, Action: Accept}
|
|
require.True(t, fw.multiPortConfAccept(hooked), "an address-less multi-port accept is the conf-list overflow shape")
|
|
require.True(t, fw.needsHook(hooked), "an address-less multi-port accept must route to the hook")
|
|
lines, err := (&hookScript{}).rulesToLines(hooked)
|
|
require.NoError(t, err, "the hook must be able to render the multi-port rule")
|
|
require.Len(t, lines, 1)
|
|
require.Contains(t, lines[0], "-m multiport --dports 80,443")
|
|
|
|
native := []*Rule{
|
|
{Proto: TCP, Port: 80, Action: Accept}, // single port token
|
|
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, // one range token
|
|
{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, // advanced line comma list
|
|
{Proto: TCP, Ports: list, Action: Drop}, // port-only deny placeholder rows
|
|
}
|
|
for _, r := range native {
|
|
require.False(t, fw.needsHook(r), "expected native routing for %+v", *r)
|
|
}
|
|
}
|
|
|
|
// TestCSFMultiPortRemovalSweepsConfTokens covers removal of a multi-port accept
|
|
// against a csf.conf port list: the rule lives in the hook now, but an earlier
|
|
// per-port add (or a manual edit) may hold the same ports as list tokens, so
|
|
// RemoveRule falls through to the port-list sweep, which strips exactly the
|
|
// target's tokens and no others.
|
|
func TestCSFMultiPortRemovalSweepsConfTokens(t *testing.T) {
|
|
fw := new(CSF)
|
|
target := &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
|
|
require.Equal(t, `TCP_IN = "22"`,
|
|
fw.EditRulePort(`TCP_IN = "22,80,443"`, "TCP_IN", "22,80,443", target, true),
|
|
"a multi-port removal must strip each of its own port tokens and keep the rest")
|
|
}
|
|
|
|
// 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.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.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 (shapeNeedsHook routes both).
|
|
func TestCSFDualAddressRouted(t *testing.T) {
|
|
require.True(t, shapeNeedsHook(&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 (shapeNeedsHook). 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, shapeNeedsHook(r),
|
|
"a portless, addressless rule must route to the hook, not be rejected: %+v", r)
|
|
}
|
|
require.False(t, shapeNeedsHook(&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 (shapeNeedsHook) 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")
|
|
}
|
|
|
|
// A protocol-less advanced line is enforced by csf.pl as `-p tcp` (its protocol
|
|
// default), so it must read back as TCP: a ProtocolAny read-back would report an
|
|
// all-protocol rule csf does not enforce, and one whose removal the iptables
|
|
// validity check rejects.
|
|
func TestCSFAdvRuleProtocolDefaultsTCP(t *testing.T) {
|
|
fw := new(CSF)
|
|
r := fw.ParseAdvRule("in|d=80|s=192.0.2.1", Drop)
|
|
require.NotNil(t, r)
|
|
require.Equal(t, TCP, r.Proto, "csf enforces a protocol-less advanced line as tcp")
|
|
require.EqualValues(t, 80, r.Port)
|
|
require.Equal(t, "192.0.2.1", r.Source)
|
|
}
|
|
|
|
// csf.pl accepts a colon-delimited advanced line (converting `:` to `|` when the
|
|
// line has no pipe), so the parser must read it; an IPv6 literal keeps parsing as
|
|
// a plain address, never as a colon-delimited rule.
|
|
func TestCSFColonDelimitedAdvRule(t *testing.T) {
|
|
fw := new(CSF)
|
|
r := fw.parseListLine("tcp:in:d=22:s=192.0.2.1", Drop)
|
|
require.NotNil(t, r, "a colon-delimited advanced line must parse")
|
|
require.Equal(t, TCP, r.Proto)
|
|
require.EqualValues(t, 22, r.Port)
|
|
require.Equal(t, "192.0.2.1", r.Source)
|
|
|
|
v6 := fw.parseListLine("2001:db8::7", Drop)
|
|
require.NotNil(t, v6)
|
|
require.Equal(t, "2001:db8::7", v6.Source, "an IPv6 literal is a plain address line")
|
|
require.Equal(t, DirAny, v6.Direction)
|
|
}
|
|
|
|
// With csf.conf's IPV6 off, a family-agnostic port add must not touch the *6_*
|
|
// lists (their entries would be inert), a removal must still sweep them, and a
|
|
// concrete-IPv6 row written by Restore keeps its family.
|
|
func TestCSFEditRulePortIPv6Disabled(t *testing.T) {
|
|
off := &CSF{}
|
|
anyFam := &Rule{Proto: TCP, Port: 8080, Action: Accept}
|
|
|
|
got := off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
|
|
require.Equal(t, `TCP6_IN = "22"`, got, "a family-agnostic add must not touch TCP6_IN while IPv6 is off")
|
|
got = off.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22", anyFam, false)
|
|
require.Contains(t, got, "8080", "the IPv4 list still takes the add")
|
|
|
|
// Removal sweeps the inert v6 entry so it does not outlive the rule.
|
|
got = off.EditRulePort(`TCP6_IN = "22,8080"`, "TCP6_IN", "22,8080", anyFam, true)
|
|
require.NotContains(t, got, "8080", "a removal must sweep the v6 list even with IPv6 off")
|
|
|
|
// A concrete-IPv6 rule (Restore reproducing a snapshot) keeps its family.
|
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Accept}
|
|
got = off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", v6, false)
|
|
require.Contains(t, got, "8080", "a concrete-IPv6 write keeps its family")
|
|
|
|
on := &CSF{ipv6Enabled: true}
|
|
got = on.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
|
|
require.Contains(t, got, "8080", "with IPv6 on the v6 list takes the add")
|
|
}
|
|
|
|
// RemoveRule of an iptables-inexpressible shape must reject with the sentinel
|
|
// before sweeping the hook, mirroring AddRule, so callers and the integration
|
|
// harness can tell "unsupported" from a real failure.
|
|
func TestCSFRemoveRuleUnsupportedShapeSentinel(t *testing.T) {
|
|
fw := new(CSF)
|
|
err := fw.RemoveRule(context.Background(), "", &Rule{Proto: ProtocolAny, Port: 80, Action: Accept})
|
|
require.ErrorIs(t, err, ErrUnsupported, "an inexpressible removal must carry the sentinel")
|
|
}
|
|
|
|
// natNeedsHook routes every shape csf.redirect cannot hold to the pre-hook:
|
|
// source NAT, non-tcp/udp, port sets, source/interface matches, an address-less
|
|
// family pin, and the DNAT pairings csf.pl aborts on. The two native shapes stay
|
|
// on csf.redirect.
|
|
func TestCSFNATNeedsHook(t *testing.T) {
|
|
fw := new(CSF)
|
|
native := []*NATRule{
|
|
{Kind: Redirect, Proto: TCP, Port: 666, ToPort: 25},
|
|
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25},
|
|
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", ToAddress: "10.0.0.1"},
|
|
}
|
|
for _, r := range native {
|
|
require.False(t, fw.natNeedsHook(r), "csf.redirect holds %+v natively", *r)
|
|
}
|
|
hooked := []*NATRule{
|
|
{Kind: SNAT, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"},
|
|
{Kind: Masquerade, Interface: "eth1"},
|
|
{Kind: DNAT, Proto: ICMP, ToAddress: "10.0.0.1"},
|
|
{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "10.0.0.1"},
|
|
{Kind: Redirect, Proto: TCP, Port: 8080, Source: "192.0.2.0/24", ToPort: 80},
|
|
{Kind: DNAT, Proto: TCP, Interface: "eth0", Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25},
|
|
// A family pinned by nothing an address carries reads back agnostic from a
|
|
// csf.redirect line, so it must live in the hook to reconcile.
|
|
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 8080, ToPort: 80},
|
|
// csf.pl aborts the redirect load on a half-set port pairing.
|
|
{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1"},
|
|
{Kind: DNAT, Proto: TCP, ToAddress: "10.0.0.1"},
|
|
}
|
|
for _, r := range hooked {
|
|
require.True(t, fw.natNeedsHook(r), "%+v has no csf.redirect form and must hook", *r)
|
|
}
|
|
}
|
|
|
|
// With IPV6 off a concrete-IPv6 NAT rule is rejected outright: csf neither
|
|
// applies a v6 redirect nor flushes the v6 nat table, so neither store could
|
|
// keep the rule in sync (the NAT analog of ipv6Unavailable, mirroring apf).
|
|
func TestCSFNATIPv6Gating(t *testing.T) {
|
|
off := new(CSF)
|
|
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
|
|
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPV6 is off")
|
|
}
|