go-firewall/csf_linux_test.go
2026-08-10 17:17:03 -05:00

863 lines
39 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, so a Drop deny is written rather than skipped while AddRule
// reports success and leaves the port 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. The add/remove matcher must treat a TCPUDP rule as
// covering its tcp and udp lines, not compare it exactly and let re-adds
// duplicate the pair while removal is 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. The gate must check every fan-out
// line, not skip the whole fan-out on a single "exists" match.
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")
}
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, DirInput)
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")
}
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)
}
}
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))
}
// 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 := fw.MarshalNATRule(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)
}
// 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")
}
// 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")
}
// 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))
}
// 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 AddRule IPv6 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")
}
// 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 the AddRule IPv6 gate, 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")
}
// csfLiveSave is a trimmed `iptables-save -c -t filter` capture from a host
// running csf, covering each shape it generates: a config port-list accept (its
// interface frame plus a NEW state match), an allow-list address in the
// per-direction ALLOW chains, a deny-list address whose outbound half jumps into
// csf's logging drop chain, and a raw pre-hook rule carrying no frame at all.
var csfLiveSave = []string{
"*filter",
":INPUT DROP [0:0]",
"[9:540] -A INPUT ! -i lo -j LOCALINPUT",
"[3:180] -A INPUT ! -i lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 22 -j ACCEPT",
"[5:300] -A INPUT ! -i lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 80 -j ACCEPT",
"[7:420] -A INPUT -p tcp -m conntrack --ctstate NEW -m tcp --dport 9100 -m comment --comment gofw -j ACCEPT",
"[2:120] -A OUTPUT ! -o lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 25 -j ACCEPT",
"[1:60] -A ALLOWIN -s 203.0.113.5/32 ! -i lo -j ACCEPT",
"[1:40] -A ALLOWOUT -d 203.0.113.5/32 ! -o lo -j ACCEPT",
"[4:240] -A DENYIN -s 203.0.113.9/32 ! -i lo -j DROP",
"[6:360] -A DENYOUT -d 203.0.113.9/32 ! -o lo -j LOGDROPOUT",
"[6:360] -A LOGDROPOUT -j REJECT --reject-with icmp-port-unreachable",
"COMMIT",
}
// TestCSFParseLiveRules verifies csf's own framing is undone on read: the
// interface match and the NEW state it stamps on a config rule are dropped, a
// jump into its logging drop chain is restored to the action that chain applies,
// and a pre-hook rule (which carries no frame) keeps the state it was written
// with. csf's internal chains contribute no rules of their own.
func TestCSFParseLiveRules(t *testing.T) {
fw := new(CSF)
rules := fw.parseLiveRules(csfLiveSave, IPv4)
require.Len(t, rules, 8, "the LOCALINPUT jump and the LOGDROPOUT chain body model no rule")
// A config port-list accept: framing gone, counters kept.
require.EqualValues(t, 22, rules[0].Port)
require.Equal(t, DirInput, rules[0].Direction)
require.Empty(t, rules[0].InInterface, "csf's interface frame is not part of the rule")
require.Zero(t, rules[0].State, "csf's NEW state frame is not part of the rule")
require.EqualValues(t, 3, rules[0].Packets)
// A pre-hook rule carries no frame, so its own state match survives.
require.EqualValues(t, 9100, rules[2].Port)
require.Equal(t, StateNew, rules[2].State, "a rule csf did not frame keeps the state it matches on")
// The deny list's outbound half reads back as the action its chain ends in.
require.Equal(t, Drop, rules[6].Action)
require.Equal(t, Reject, rules[7].Action, "LOGDROPOUT applies csf.conf's DROP_OUT")
require.EqualValues(t, 6, rules[7].Packets)
}
// TestCSFApplyCountersLists verifies the allow and deny lists count both halves
// of a bidirectional entry: the allow entry sums its two rows through ordinary
// coverage, and the deny entry's outbound row — which carries DROP_OUT while the
// entry reads back as DROP — is claimed by claimDenyOutRows.
func TestCSFApplyCountersLists(t *testing.T) {
fw := new(CSF)
live := fw.parseLiveRules(csfLiveSave, IPv4)
allow := &Rule{Direction: DirAny, Family: IPv4, Source: "203.0.113.5", Action: Accept}
deny := &Rule{Direction: DirAny, Family: IPv4, Source: "203.0.113.9", Action: Drop}
port := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
targets := []*Rule{allow, deny, port}
leftover := applyLiveCounters(targets, live)
fw.claimDenyOutRows(targets, leftover)
require.EqualValues(t, 3, port.Packets, "a config port rule counts its framed row")
require.EqualValues(t, 2, allow.Packets, "a bidirectional allow sums its ALLOWIN and ALLOWOUT rows")
require.EqualValues(t, 100, allow.Bytes)
require.EqualValues(t, 10, deny.Packets, "a bidirectional deny sums its DENYIN and DENYOUT rows")
require.EqualValues(t, 600, deny.Bytes)
}
// TestCSFClaimDenyOutRowsIsScoped verifies the outbound-deny claim does not
// absorb a row belonging to a different entry: it must still match every field
// but the action, and it only applies to a bidirectional rule.
func TestCSFClaimDenyOutRowsIsScoped(t *testing.T) {
fw := new(CSF)
other := &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.1", Action: Drop}
oneWay := &Rule{Direction: DirOutput, Family: IPv4, Destination: "203.0.113.9", Action: Reject}
leftover := []*Rule{
{Direction: DirOutput, Family: IPv4, Destination: "203.0.113.9", Action: Reject, Packets: 6, Bytes: 360},
}
fw.claimDenyOutRows([]*Rule{other, oneWay}, leftover)
require.Zero(t, other.Packets, "an unrelated deny entry must not absorb the row")
require.Zero(t, oneWay.Packets, "a one-way deny matches on identity and is not claimed here")
}