go-firewall/csf_linux_test.go
James Coleman 5095d90fa4 Advertise capability flags and harden backend semantics
- 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.
2026-07-13 17:50:43 -05:00

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")
}