go-firewall/hooks_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

997 lines
43 KiB
Go

package firewall
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestRuleNeedsHook(t *testing.T) {
// Features with no native CSF/APF config path route through the hook.
needs := []*Rule{
{Proto: TCP, Port: 22, Action: Accept, State: StateNew},
{Proto: TCP, Port: 80, Action: Accept, Log: true},
{Proto: TCP, Port: 80, Action: Accept, InInterface: "eth0"},
{Direction: DirOutput, Proto: TCP, Port: 80, Action: Accept, OutInterface: "eth0"},
{Proto: TCP, Port: 25, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}},
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
// A forward rule has no native CSF/APF config path, so it routes through the
// raw-iptables hook (which emits an -A FORWARD rule).
{Direction: DirForward, Proto: TCP, Port: 8080, Action: Accept},
// A set-referencing rule (Source names an ipset, not an address) has no
// literal trust-file form, so it routes through the hook beside the ipset
// commands that create the set.
{Family: IPv4, Source: "blocklist", Action: Drop},
// A negated address has no native form either: csf.pl passes an advanced
// line's s=/d= value verbatim to iptables and skips a plain "!"-prefixed
// line, so the hook's `! -s`/`! -d` is the only reliable spelling.
{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept},
{Proto: TCP, Port: 22, Destination: "!192.0.2.51/32", Action: Accept},
{Source: "!192.0.2.52", Action: Accept},
}
for _, r := range needs {
require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r)
}
// Natively expressible rules do not.
native := []*Rule{
{Proto: TCP, Port: 22, Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: TCP, Port: 22, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}},
}
for _, r := range native {
require.False(t, ruleNeedsHook(r), "expected %+v to stay native", *r)
}
}
// shapeNeedsHook routes every rule shape the csf/apf native forms cannot hold —
// a one-way bare host, a portless source+destination pair or transport-pinned
// host, an advanced-line address/port-flow overflow, and a bare protocol match —
// to the raw-iptables hook, while the shapes a plain line, an advanced line, or a
// conf list expresses (and the ICMP shapes each backend routes itself) stay off it.
func TestShapeNeedsHook(t *testing.T) {
// Shapes with no native csf/apf form.
needs := []*Rule{
// A one-way bare host: a plain line is bidirectional, an advanced rule needs a
// port (an unset direction is DirInput, so the first is one-way too).
{Source: "1.2.3.4", Action: Accept},
{Direction: DirInput, Source: "1.2.3.4", Action: Drop},
{Direction: DirOutput, Destination: "1.2.3.4", Action: Accept},
// A portless source+destination pair: no single-address advanced/plain form.
{Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept, Direction: DirAny},
// A portless host pinned to a transport: the plain line is all-protocol and the
// advanced rule requires a port. TCPUDP names transports, so it counts.
{Proto: TCP, Source: "1.2.3.4", Action: Accept, Direction: DirAny},
{Proto: UDP, Destination: "1.2.3.4", Direction: DirOutput, Action: Accept},
{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept, Direction: DirAny},
// A source+destination pair carrying a port or an ICMP match: the second
// address overflows the advanced line's single address field.
{Proto: TCP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
{Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
{Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
{Proto: ICMP, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
{Proto: ICMPv6, Source: "2001:db8::1", Destination: "2001:db8::2", Action: Accept},
// A source port matched together with a destination port, addressed or not:
// they cannot share the advanced line's single port-flow field.
{Proto: TCP, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
{Proto: UDP, Port: 53, SourcePort: 53, Destination: "192.0.2.1", Action: Accept},
{Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
// An advanced rule requires an address, so a bare source-port match has no
// advanced form of any kind.
{Proto: TCP, SourcePort: 1234, Action: Accept},
{Proto: TCPUDP, SourcePort: 1234, Action: Drop},
// A bare protocol match: no address, no port, non-ICMP.
{Proto: TCP, Action: Accept},
{Proto: UDP, Action: Drop},
{Proto: ProtocolAny, Action: Accept},
{Proto: TCP, Direction: DirOutput, Action: Accept},
}
for _, r := range needs {
require.True(t, shapeNeedsHook(r), "expected %+v to need the hook", *r)
}
// Shapes a native form holds, or that another route owns.
native := []*Rule{
// A bidirectional bare host: the plain trust-file line itself.
{Direction: DirAny, Source: "1.2.3.4", Action: Accept},
// One address, one port-flow field: the advanced rule itself.
{Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
{Proto: TCP, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.1", Action: Accept},
// An address-less port rule: a native conf port list.
{Proto: TCP, Port: 22, Action: Accept},
// ICMP shapes with at most one address are routed by each backend's own
// needsHook (csf's typed advanced rule, apf's conf type lists), not here.
{Proto: ICMP, Source: "1.2.3.4", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: ICMPv6, Action: Accept},
// A port on ProtocolAny is inexpressible in iptables too, so it must keep its
// own rejection rather than reach the hook and fail there.
{Proto: ProtocolAny, Port: 80, SourcePort: 1234, Source: "192.0.2.1", Action: Accept},
{Proto: ProtocolAny, Port: 80, Source: "192.0.2.1", Destination: "198.51.100.1", Action: Accept},
}
for _, r := range native {
require.False(t, shapeNeedsHook(r), "expected %+v not to route to the hook", *r)
}
}
// With csf.conf's IPV6 or conf.apf's USE_IPV6 off (the shipped default in both),
// neither backend enforces any IPv6, and the raw-iptables hook is no escape hatch:
// neither firewall flushes ip6tables on reload, so a hook-injected v6 line is
// re-appended on every reload and a removed one lives on in the kernel.
// ipv6Unavailable must therefore flag every concrete-IPv6 rule — including the
// shapes each backend would otherwise route to the hook (ICMPv6, a stateful match,
// a single-family bare port) — and only when the backend's IPv6 handling is off.
func TestIPv6UnavailableGate(t *testing.T) {
// Every concrete-IPv6 shape is blocked with the backend's IPv6 handling off, and
// allowed with it on. Family is implied by a v6 address or the ICMPv6 protocol
// where it is not set outright.
blocked := []*Rule{
// A v6 address in a trust-file line (plain) or an advanced rule (with a port).
{Proto: ProtocolAny, Source: "2001:db8::1", Action: Accept},
{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept},
// A port-only v6 deny carries no address (csf synthesizes ::/0 on write), so
// the gate must key on the implied family alone.
{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop},
// apf's native ICMPv6 type list, and the ICMPv6 shapes both backends hook.
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept},
// A single-family bare port accept, which apf routes to the hook.
{Family: IPv6, Proto: TCP, Port: 8090, Action: Accept},
}
for _, r := range blocked {
require.True(t, ipv6Unavailable(false, r), "expected %+v to be blocked with IPv6 off", *r)
require.False(t, ipv6Unavailable(true, r), "expected %+v to be allowed with IPv6 on", *r)
}
// A rule that resolves to IPv4, or to neither family, is never blocked: a
// FamilyAny rule is written for whichever family the backend enforces.
allowed := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: TCP, Port: 8080, Action: Drop},
{Proto: TCP, Port: 22, Action: Accept, State: StateNew},
}
for _, r := range allowed {
require.False(t, ipv6Unavailable(false, r), "expected %+v to pass the IPv6 gate", *r)
require.False(t, ipv6Unavailable(true, r), "expected %+v to pass the IPv6 gate", *r)
}
}
// Two rules that are Equal (port-set order is not part of rule identity) must
// inject the same command line, so a second add is a no-op and a remove using a
// reordered port set still finds the rule. The hook script matches on the exact
// marshalled line, so the marshaller must render Equal port sets identically.
func TestHookScriptPortOrderIdempotent(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
}
// SCTP has no native CSF/APF config path, so a multi-port SCTP rule routes
// through the hook. These two differ only in port order, so they are Equal.
a := &Rule{Family: IPv4, Proto: SCTP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}
b := &Rule{Family: IPv4, Proto: SCTP, Ports: []PortRange{{Start: 443}, {Start: 80}}, Action: Accept}
require.True(t, a.Equal(b, true), "the two rules must be Equal (order-independent)")
changed, err := h.edit(a, false)
require.NoError(t, err)
require.True(t, changed)
changed, err = h.edit(b, false)
require.NoError(t, err)
require.False(t, changed, "an Equal rule with reordered ports must not inject a duplicate")
// Removing via the reordered form must still find and drop the rule.
changed, err = h.edit(b, true)
require.NoError(t, err)
require.True(t, changed, "removing an Equal rule with reordered ports must drop it")
got, err := h.getRules()
require.NoError(t, err)
require.Empty(t, got, "the rule must be gone after removal")
}
// A TCPUDP rule has no single iptables form — one line matches one -p — so the hook
// fans it out into a tcp line and a udp line, mirroring the tcp+udp fan-out csf/apf
// write in their native config. Both add and remove must fan out and never reject
// the rule for want of a concrete protocol. Regression: a Backup could hold a TCPUDP
// rule, and Restore's hook-copy clear then failed to marshal it, breaking the whole
// restore.
func TestHookScriptTCPUDPPortFansOut(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
}
// Adding a TCPUDP port rule injects a tcp line and a udp line.
any := &Rule{Family: IPv4, Proto: TCPUDP, Port: 20, Action: Accept}
changed, err := h.edit(any, false)
require.NoError(t, err, "a TCPUDP port rule must marshal, not be rejected")
require.True(t, changed)
got, err := h.getRules()
require.NoError(t, err)
require.Len(t, got, 2, "a TCPUDP port rule fans out into a tcp and a udp hook line")
protos := map[Protocol]bool{}
for _, g := range got {
protos[g.Proto] = true
}
require.True(t, protos[TCP] && protos[UDP], "the fan-out must cover both tcp and udp: %+v", got)
// Removing the TCPUDP form clears both concrete copies in one call, without
// erroring on the port-without-concrete-protocol shape.
changed, err = h.edit(any, true)
require.NoError(t, err, "removing a TCPUDP port rule must not fail to marshal")
require.True(t, changed, "the TCPUDP remove must clear the tcp and udp copies")
got, err = h.getRules()
require.NoError(t, err)
require.Empty(t, got, "both fanned-out copies must be gone after the TCPUDP remove")
}
// A deny whose action differs from the CSF/APF config's STOP action has no native
// form (deny_hosts/csf.deny encode no action of their own), so those backends
// inject it through the hook, whose iptables rule carries the exact action. The
// hook must marshal and read back the precise action, not coerce it — otherwise a
// Reject deny would read back as Drop and churn on every Sync.
func TestHookScriptCarriesExactDenyAction(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
}
for _, deny := range []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.31/32", Action: Reject},
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.32/32", Action: Drop},
} {
changed, err := h.edit(deny, false)
require.NoError(t, err)
require.True(t, changed, "the deny must be injected: %+v", deny)
got, err := h.getRules()
require.NoError(t, err)
var match *Rule
for _, g := range got {
if g.Equal(deny, true) {
match = g
}
}
require.NotNil(t, match, "the deny must read back from the hook: %+v", deny)
require.Equal(t, deny.Action, match.Action,
"the hook must carry the deny's exact action, not coerce it: %+v", deny)
changed, err = h.edit(deny, true)
require.NoError(t, err)
require.True(t, changed, "the deny must be removable: %+v", deny)
}
}
// Hook removal matches on the underlying rule, not the exact command line: a copy
// of a rule a customer added under a different comment (or a differently spelled
// address) must still be removed, since the comment is not part of rule identity.
func TestHookScriptRemoveIgnoresComment(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
}
// Plant a rule the way a customer would: same underlying match, a foreign comment,
// and an un-normalized address (no /32). A hookScript with a different prefix marks
// it as not ours.
foreign := &hookScript{rulePrefix: "acme", hookPath: h.hookPath, hookPerm: 0700}
planted := &Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60", Action: Accept, Comment: "ticket-42"}
changed, err := foreign.edit(planted, false)
require.NoError(t, err)
require.True(t, changed)
// Remove the same underlying rule with no comment and the normalized address.
changed, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60/32", Action: Accept}, true)
require.NoError(t, err)
require.True(t, changed, "a rule with the same match but a different comment must still be removed")
got, err := h.getRules()
require.NoError(t, err)
require.Empty(t, got, "the customer's differently-commented copy must be gone")
}
func TestHookScriptRoundTrip(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
ipv6Enabled: true,
}
// A family-agnostic rule is injected for both v4 and v6 when the backend enforces
// IPv6.
lines, err := h.rulesToLines(&Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew})
require.NoError(t, err)
require.Len(t, lines, 2)
require.True(t, strings.HasPrefix(lines[0], "iptables "), "want iptables line, got %q", lines[0])
require.True(t, strings.HasPrefix(lines[1], "ip6tables "), "want ip6tables line, got %q", lines[1])
// Family-pinned rules covering each non-native feature round-trip through the
// hook.
rules := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, State: StateNew | StateEstablished},
{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"},
{Family: IPv4, Proto: TCP, Port: 443, Action: Accept, InInterface: "eth0"},
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
{Family: IPv4, Proto: TCP, Port: 25, Action: Drop, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute, Burst: 3}},
}
for _, r := range rules {
changed, err := h.edit(r, false)
require.NoError(t, err, "add %+v", *r)
require.True(t, changed, "expected add to change the script: %+v", *r)
}
// Adding again is idempotent.
changed, err := h.edit(rules[0], false)
require.NoError(t, err)
require.False(t, changed, "expected a duplicate add to be a no-op")
// The command lines live in the hook itself, under a single shebang.
hookData, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
require.Equal(t, 1, strings.Count(string(hookData), "#!/bin/sh"), "hook should carry one shebang")
require.Contains(t, string(hookData), "iptables ")
// Every rule reads back equal (family ignored, as the hook stores per-family).
got, err := h.getRules()
require.NoError(t, err)
require.Len(t, got, len(rules))
for _, want := range rules {
found := false
for _, g := range got {
if g.EqualBase(want, true) {
found = true
break
}
}
require.True(t, found, "rule not read back: %+v", *want)
}
// The logged rule round-trips with its prefix intact.
for _, g := range got {
if g.Port == 80 {
require.True(t, g.Log, "expected the port 80 rule to be logged")
require.Equal(t, "web", g.LogPrefix)
}
}
// Removing one drops it (both its LOG and action lines) and leaves the rest.
changed, err = h.edit(rules[1], true)
require.NoError(t, err)
require.True(t, changed)
got, err = h.getRules()
require.NoError(t, err)
require.Len(t, got, len(rules)-1)
for _, g := range got {
require.False(t, g.EqualBase(rules[1], true), "removed rule still present")
}
// Removing an absent rule is a no-op.
changed, err = h.edit(rules[1], true)
require.NoError(t, err)
require.False(t, changed, "expected removing an absent rule to be a no-op")
}
// Writing command lines into the existing hook must leave user-authored content
// untouched: arbitrary shell survives an add and a remove, and an iptables rule a
// user added by hand both survives edits and surfaces in getRules (the library
// reconciles the hook's actual state, not just the lines it wrote).
func TestHookPreservesUserContent(t *testing.T) {
dir := t.TempDir()
hookPath := filepath.Join(dir, "csfpre.sh")
userContent := "#!/bin/sh\n" +
"# operator's own pre-hook logic\n" +
"logger firewall reloading\n" +
"iptables -A INPUT -p tcp --dport 2222 -j ACCEPT\n"
require.NoError(t, os.WriteFile(hookPath, []byte(userContent), 0700))
h := &hookScript{rulePrefix: "go_firewall", hookPath: hookPath, hookPerm: 0700}
// A hand-added iptables rule the library never wrote surfaces in getRules,
// reported as foreign (no prefix tag).
got, err := h.getRules()
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, uint16(2222), got[0].Port)
require.False(t, got[0].HasPrefix, "a user-authored rule must read back as foreign")
// Adding our rule keeps every user line in place.
added := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, State: StateNew}
changed, err := h.edit(added, false)
require.NoError(t, err)
require.True(t, changed)
data, err := os.ReadFile(hookPath)
require.NoError(t, err)
require.Contains(t, string(data), "logger firewall reloading")
require.Contains(t, string(data), "iptables -A INPUT -p tcp --dport 2222 -j ACCEPT")
require.Equal(t, 1, strings.Count(string(data), "#!/bin/sh"), "must not add a second shebang")
// Removing our rule leaves the user's shell and rule behind.
changed, err = h.edit(added, true)
require.NoError(t, err)
require.True(t, changed)
data, err = os.ReadFile(hookPath)
require.NoError(t, err)
require.Contains(t, string(data), "logger firewall reloading")
require.Contains(t, string(data), "iptables -A INPUT -p tcp --dport 2222 -j ACCEPT")
// The user's rule still reads back after our churn.
got, err = h.getRules()
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, uint16(2222), got[0].Port)
}
// A hook line is sourced by /bin/sh, so a comment or log prefix containing $ or a
// backtick must be single-quoted (a literal), not left in strconv.Quote's double
// quotes where the shell would expand it. And it must still parse back intact.
func TestHookShellSafeLogPrefix(t *testing.T) {
require.Equal(t, "-A", shellSafeToken("-A"))
require.Equal(t, "INPUT", shellSafeToken("INPUT"))
require.Equal(t, `'web $USER'`, shellSafeToken("web $USER"))
require.Equal(t, `'a'\''b'`, shellSafeToken("a'b"))
h := &hookScript{rulePrefix: "myapp"}
lines, err := h.rulesToLines(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "drop $x"})
require.NoError(t, err)
joined := strings.Join(lines, "\n")
require.NotContains(t, joined, `"drop $x"`, "a $-bearing prefix must not stay double-quoted for the shell")
require.Contains(t, joined, `'drop $x'`)
found := false
for _, l := range lines {
if r, ok := h.parseLine(l); ok && r.Log {
require.Equal(t, "drop $x", r.LogPrefix)
found = true
}
}
require.True(t, found, "the log line must parse back to the original prefix")
}
// A protocol CSF/APF cannot express natively (SCTP and the portless IP
// protocols) is routed through the raw-iptables hook and round-trips there.
func TestHookProtocolExtras(t *testing.T) {
for _, p := range []Protocol{SCTP, GRE, ESP, AH} {
require.True(t, hookOnlyProto(p), "%s should route through the hook", p)
require.True(t, ruleNeedsHook(&Rule{Proto: p, Action: Accept}))
}
require.False(t, hookOnlyProto(TCP))
require.False(t, ruleNeedsHook(&Rule{Proto: TCP, Port: 22, Action: Accept}))
h := &hookScript{hookPath: "/tmp/unused", rulePrefix: "go_firewall"}
cases := []*Rule{
{Family: IPv4, Proto: GRE, Action: Accept},
{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept},
}
for _, orig := range cases {
lines, err := h.rulesToLines(orig)
require.NoError(t, err, "%+v", orig)
require.NotEmpty(t, lines)
got, ok := h.parseLine(lines[len(lines)-1])
require.True(t, ok, "line %q", lines[len(lines)-1])
require.True(t, got.EqualBase(orig, true), "want %+v got %+v", orig, got)
}
}
// bareHostOneWay classifies a one-way bare-address host rule — a single address,
// no ports, any-protocol, a concrete direction — which csf/apf must route to the
// hook because a plain line is bidirectional and an advanced rule needs a port. A
// DirAny bare host (the bidirectional plain line) and any ported or protocol-pinned
// rule are excluded.
func TestBareHostOneWay(t *testing.T) {
yes := []*Rule{
{Direction: DirInput, Source: "1.2.3.4", Action: Accept},
{Direction: DirOutput, Destination: "1.2.3.4", Action: Accept},
{Direction: DirInput, Source: "10.0.0.0/8", Action: Drop},
}
for _, r := range yes {
require.Truef(t, bareHostOneWay(r), "expected one-way bare host: %+v", r)
}
no := []*Rule{
{Direction: DirAny, Source: "1.2.3.4", Action: Accept}, // bidirectional plain line
{Direction: DirForward, Source: "1.2.3.4", Action: Accept}, // forward is hooked separately
{Direction: DirInput, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept}, // has a port (advanced)
{Direction: DirInput, Source: "1.2.3.4", Proto: TCP, Action: Accept}, // pins a protocol
{Direction: DirInput, Action: Accept}, // no address
{Direction: DirInput, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}, // both addresses
}
for _, r := range no {
require.Falsef(t, bareHostOneWay(r), "expected NOT one-way bare host: %+v", r)
}
}
// A set reference is not a literal host, so bareHostShape must reject it (else
// APF/CSF would write the set name into a trust file) while ruleNeedsHook routes
// it to the hook. A literal address keeps the opposite verdicts.
func TestSetRefIsNotBareHost(t *testing.T) {
setRef := &Rule{Family: IPv4, Source: "blocklist", Action: Drop}
require.False(t, bareHostShape(setRef), "a set reference is not a bare host")
require.True(t, ruleNeedsHook(setRef), "a set reference routes to the hook")
literal := &Rule{Family: IPv4, Source: "10.0.0.1", Action: Drop}
require.True(t, bareHostShape(literal), "a literal address is a bare host")
require.False(t, ruleNeedsHook(literal), "a literal-address host stays native")
}
func newTestHook(t *testing.T) *hookScript {
t.Helper()
return &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(t.TempDir(), "csfpre.sh"),
hookPerm: 0700,
}
}
// A set written to the hook round-trips through getAddressSets with its family,
// type and entries intact, for both IPv4 and IPv6, and re-adding an identical set
// is idempotent.
func TestHookAddressSetRoundTrip(t *testing.T) {
h := newTestHook(t)
v4 := &AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24", "198.51.100.7"}}
changed, err := h.editAddressSet(v4, false)
require.NoError(t, err)
require.True(t, changed)
// Re-adding the identical set does not rewrite the hook.
changed, err = h.editAddressSet(v4, false)
require.NoError(t, err)
require.False(t, changed, "re-adding an identical set must be idempotent")
v6 := &AddressSet{Name: "v6drop", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}}
_, err = h.editAddressSet(v6, false)
require.NoError(t, err)
sets, err := h.getAddressSets()
require.NoError(t, err)
require.Len(t, sets, 2)
byName := map[string]*AddressSet{}
for _, s := range sets {
byName[s.Name] = s
}
require.Equal(t, IPv4, byName["blocklist"].Family)
require.Equal(t, SetHashNet, byName["blocklist"].Type)
require.ElementsMatch(t, []string{"192.0.2.0/24", "198.51.100.7"}, byName["blocklist"].Entries)
require.Equal(t, IPv6, byName["v6drop"].Family)
require.Equal(t, SetHashIP, byName["v6drop"].Type)
require.Equal(t, []string{"2001:db8::1"}, byName["v6drop"].Entries)
}
// The ipset commands for a set must be written ahead of any rule that references
// it, even when the rule was added first, so the set exists when the hook runs.
func TestHookAddressSetOrderedBeforeRules(t *testing.T) {
h := newTestHook(t)
// Add the referencing rule first — edit appends it at the end of the hook.
_, err := h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, false)
require.NoError(t, err)
// Then add the set; its block must be spliced in before the rule line.
_, err = h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false)
require.NoError(t, err)
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
body := string(data)
ipsetAt := strings.Index(body, "ipset create blocklist")
ruleAt := strings.Index(body, "--match-set blocklist")
require.GreaterOrEqual(t, ipsetAt, 0, "the create command must be present")
require.GreaterOrEqual(t, ruleAt, 0, "the referencing rule must be present")
require.Less(t, ipsetAt, ruleAt, "ipset commands must precede the rule that references the set")
}
// Removing a set a rule still references is refused (the kernel enforces the same
// on a live destroy); once the rule is gone the removal succeeds.
func TestHookAddressSetInUseGuard(t *testing.T) {
h := newTestHook(t)
_, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false)
require.NoError(t, err)
_, err = h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, false)
require.NoError(t, err)
_, err = h.editAddressSet(&AddressSet{Name: "blocklist"}, true)
require.Error(t, err, "removing a set a rule references must fail")
_, err = h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, true)
require.NoError(t, err)
changed, err := h.editAddressSet(&AddressSet{Name: "blocklist"}, true)
require.NoError(t, err)
require.True(t, changed)
sets, err := h.getAddressSets()
require.NoError(t, err)
require.Empty(t, sets, "the set must be gone after removal")
}
// Entry edits add and remove a single address in an existing set idempotently,
// and editing a set that does not exist is an error.
func TestHookAddressSetEntryEdits(t *testing.T) {
h := newTestHook(t)
_, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false)
require.NoError(t, err)
changed, err := h.editAddressSetEntry("blocklist", "203.0.113.9", false)
require.NoError(t, err)
require.True(t, changed)
changed, err = h.editAddressSetEntry("blocklist", "203.0.113.9", false)
require.NoError(t, err)
require.False(t, changed, "adding an existing entry must be idempotent")
sets, err := h.getAddressSets()
require.NoError(t, err)
require.Len(t, sets, 1)
require.ElementsMatch(t, []string{"203.0.113.5", "203.0.113.9"}, sets[0].Entries)
changed, err = h.editAddressSetEntry("blocklist", "203.0.113.5", true)
require.NoError(t, err)
require.True(t, changed)
sets, err = h.getAddressSets()
require.NoError(t, err)
require.Equal(t, []string{"203.0.113.9"}, sets[0].Entries)
_, err = h.editAddressSetEntry("missing", "1.2.3.4", false)
require.Error(t, err, "editing an entry in a set that does not exist must fail")
}
// With the backend's own IPv6 handling off, a family-agnostic rule must be injected
// as an IPv4 line only. The pre-hook runs on every (re)load regardless, but csf/apf
// never flush ip6tables while IPv6 is disabled, so an injected ip6tables line would
// be re-appended on each reload and would outlive its removal from the hook. The
// ipv6Unavailable gate only stops a *concrete* IPv6 rule; a FamilyAny rule reaches
// the hook and must be narrowed here instead.
func TestHookScriptIPv6DisabledSkipsV6Family(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
}
anyFam := &Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew}
lines, err := h.rulesToLines(anyFam)
require.NoError(t, err)
require.Len(t, lines, 1, "a family-agnostic rule must not be written for ipv6 when ipv6 is off")
require.True(t, strings.HasPrefix(lines[0], "iptables "), "want an iptables line, got %q", lines[0])
// It is written to the hook the same way, so no ip6tables command is ever injected.
changed, err := h.edit(anyFam, false)
require.NoError(t, err)
require.True(t, changed)
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
require.NotContains(t, string(data), "ip6tables ",
"an ip6tables line csf/apf never flush must not be injected while ipv6 is off")
// A rule pinned to a concrete family keeps it: the ipv6Unavailable gate stops a
// fresh concrete-IPv6 add, and Restore bypasses that gate on purpose to reproduce a
// snapshot's entries verbatim, so the hook must still be able to render one.
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Accept, State: StateNew}
lines, err = h.rulesToLines(v6)
require.NoError(t, err)
require.Len(t, lines, 1)
require.True(t, strings.HasPrefix(lines[0], "ip6tables "), "want an ip6tables line, got %q", lines[0])
}
// Switching IPv6 off must not strand the ip6tables lines written while it was on:
// removal sweeps both families even though an add only writes the enforced one.
func TestHookScriptRemoveSweepsV6AfterIPv6Disabled(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "csfpre.sh")
// Written while the backend's IPv6 handling was on: an iptables and an ip6tables line.
on := &hookScript{rulePrefix: "go_firewall", hookPath: path, hookPerm: 0700, ipv6Enabled: true}
rule := &Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew}
changed, err := on.edit(rule, false)
require.NoError(t, err)
require.True(t, changed)
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "ip6tables ")
// IPv6 is now off. Removing the same rule must still clear the stale ip6tables line.
off := &hookScript{rulePrefix: "go_firewall", hookPath: path, hookPerm: 0700}
changed, err = off.edit(rule, true)
require.NoError(t, err)
require.True(t, changed)
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "ip6tables ",
"a stale ip6tables line must be swept on removal, not stranded in the hook")
require.NotContains(t, string(data), "iptables -A ")
}
// An unnamed set must be refused outright: hookIPSetName reports "" for every
// non-ipset line, so an empty name would match — and a rewrite would drop — every
// rule and user-authored line in the hook.
func TestHookAddressSetEmptyNamePreservesHook(t *testing.T) {
h := newTestHook(t)
_, err := h.editAddressSet(&AddressSet{Name: "keepme", Family: IPv4, Type: SetHashIP, Entries: []string{"192.0.2.1"}}, false)
require.NoError(t, err)
_, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 2299, Action: Accept, State: StateNew}, false)
require.NoError(t, err)
before, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
for _, remove := range []bool{true, false} {
changed, err := h.editAddressSet(&AddressSet{Name: ""}, remove)
require.Error(t, err, "an unnamed set must be refused (remove=%v)", remove)
require.False(t, changed)
}
after, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
require.Equal(t, string(before), string(after), "the hook must be untouched after a refused edit")
}
// A logged rule and its unlogged twin are distinct rules: removing one must not
// strip the other's lines. The LOG line and its action line are matched as the one
// logged rule they encode, so the unlogged target matches neither.
func TestHookRemoveLoggedAndUnloggedAreDistinct(t *testing.T) {
logged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"}
unlogged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, State: StateNew}
// Hook holds only the logged pair; removing the unlogged twin is a no-op.
h := newTestHook(t)
_, err := h.edit(logged, false)
require.NoError(t, err)
unloggedTwin := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept}
changed, err := h.edit(unloggedTwin, true)
require.NoError(t, err)
require.False(t, changed, "removing the unlogged twin must not touch the logged pair")
rules, err := h.getRules()
require.NoError(t, err)
require.Len(t, rules, 1)
require.True(t, rules[0].Log, "the logged rule must survive intact")
// Hook holds only the unlogged rule; removing the logged twin is a no-op.
h2 := newTestHook(t)
_, err = h2.edit(unlogged, false)
require.NoError(t, err)
changed, err = h2.edit(logged, true)
require.NoError(t, err)
require.False(t, changed, "removing the logged twin must not touch the unlogged rule")
// Removing the logged rule itself clears both of its lines.
changed, err = h.edit(logged, true)
require.NoError(t, err)
require.True(t, changed)
rules, err = h.getRules()
require.NoError(t, err)
require.Empty(t, rules)
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
require.NotContains(t, string(data), "-j LOG", "the LOG line must be removed with its action line")
}
// A stray LOG line whose action partner was hand-edited away still belongs to the
// logged rule, so removing that rule sweeps it rather than stranding a live kernel
// LOG rule the library no longer reports.
func TestHookRemoveSweepsOrphanLogLine(t *testing.T) {
h := newTestHook(t)
logged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"}
_, err := h.edit(logged, false)
require.NoError(t, err)
// Hand-remove the action line, leaving the LOG line orphaned.
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
var kept []string
for _, l := range strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") {
if strings.Contains(l, "-j ACCEPT") {
continue
}
kept = append(kept, l)
}
require.NoError(t, os.WriteFile(h.hookPath, []byte(strings.Join(kept, "\n")+"\n"), 0700))
changed, err := h.edit(logged, true)
require.NoError(t, err)
require.True(t, changed, "the orphan LOG line must be swept")
data, err = os.ReadFile(h.hookPath)
require.NoError(t, err)
require.NotContains(t, string(data), "-j LOG")
}
// A hand-authored add line may carry trailing options (`timeout 300`, `-exist`);
// the entry is still captured, so a rewrite of the set's block re-emits it instead
// of silently dropping the user's entry.
func TestHookAddressSetOptionedAddLineKeepsEntry(t *testing.T) {
h := newTestHook(t)
body := "#!/bin/sh\n" +
"ipset create blocklist hash:ip family inet -exist\n" +
"ipset flush blocklist\n" +
"ipset add blocklist 203.0.113.9 timeout 300\n"
require.NoError(t, os.WriteFile(h.hookPath, []byte(body), 0700))
sets, err := h.getAddressSets()
require.NoError(t, err)
require.Len(t, sets, 1)
require.Equal(t, []string{"203.0.113.9"}, sets[0].Entries)
// A rewrite of the block (adding a second entry) keeps the optioned entry.
changed, err := h.editAddressSetEntry("blocklist", "198.51.100.7", false)
require.NoError(t, err)
require.True(t, changed)
sets, err = h.getAddressSets()
require.NoError(t, err)
require.ElementsMatch(t, []string{"203.0.113.9", "198.51.100.7"}, sets[0].Entries)
}
// A family-agnostic set-referencing rule is pinned to the set's family: an ipset
// is single-family, so the opposite-family line would fail every time the firewall
// sources the hook. A reference to a set the hook does not carry is an error, and
// an IPv6 set is unusable while the backend's IPv6 handling is off.
func TestHookSetRefPinsFamilyAnyToSetFamily(t *testing.T) {
h := newTestHook(t)
h.ipv6Enabled = true
_, err := h.editAddressSet(&AddressSet{Name: "v6drop", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}}, false)
require.NoError(t, err)
lines, err := h.rulesToLines(&Rule{Source: "v6drop", Action: Drop})
require.NoError(t, err)
require.Len(t, lines, 1, "a family-agnostic set rule must be written for the set's family only")
require.True(t, strings.HasPrefix(lines[0], "ip6tables "), "want an ip6tables line, got %q", lines[0])
_, err = h.rulesToLines(&Rule{Source: "missing", Action: Drop})
require.Error(t, err, "a reference to an unknown set must be an error")
h.ipv6Enabled = false
_, err = h.rulesToLines(&Rule{Source: "v6drop", Action: Drop})
require.ErrorIs(t, err, ErrUnsupported, "an IPv6 set is unusable while IPv6 is off")
}
// The hook carries NAT rules csf.redirect cannot hold as raw `-t nat` command
// lines: an add round-trips through getNATRules, a re-add is idempotent, and a
// removal drops the line while leaving filter rules and user shell in place.
func TestHookScriptNATRoundTrip(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
ipv6Enabled: true,
}
cases := []*NATRule{
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"},
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
{Kind: DNAT, Family: IPv4, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "10.0.0.5"},
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 8080, Source: "192.0.2.0/24", ToPort: 80},
}
for _, r := range cases {
changed, err := h.editNAT(r, false)
require.NoError(t, err, "add %+v", *r)
require.True(t, changed)
changed, err = h.editNAT(r, false)
require.NoError(t, err)
require.False(t, changed, "re-adding %+v must be idempotent", *r)
got, err := h.getNATRules()
require.NoError(t, err)
require.Len(t, got, 1, "the added rule must read back exactly once")
require.True(t, got[0].Equal(r), "read-back mismatch: want %+v got %+v", *r, *got[0])
require.True(t, got[0].HasPrefix, "a hook NAT line this library wrote must report HasPrefix")
changed, err = h.editNAT(r, true)
require.NoError(t, err)
require.True(t, changed, "removal must drop %+v", *r)
got, err = h.getNATRules()
require.NoError(t, err)
require.Empty(t, got, "the rule must be gone after removal")
}
}
// A family-agnostic NAT rule fans out into an iptables and an ip6tables line
// with IPv6 on, narrows to IPv4 with it off, and a removal sweeps both families
// either way so a stale v6 line does not survive an IPv6 switch-off.
func TestHookScriptNATFamilyFanOut(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
ipv6Enabled: true,
}
masq := &NATRule{Kind: Masquerade, Interface: "eth1"}
_, err := h.editNAT(masq, false)
require.NoError(t, err)
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
require.Contains(t, string(data), "iptables -t nat -A POSTROUTING")
require.Contains(t, string(data), "ip6tables -t nat -A POSTROUTING")
// One family-agnostic removal clears both lines.
changed, err := h.editNAT(masq, true)
require.NoError(t, err)
require.True(t, changed)
got, err := h.getNATRules()
require.NoError(t, err)
require.Empty(t, got)
// With IPv6 off the write narrows to IPv4 only.
h.ipv6Enabled = false
_, err = h.editNAT(masq, false)
require.NoError(t, err)
data, err = os.ReadFile(h.hookPath)
require.NoError(t, err)
require.Contains(t, string(data), "iptables -t nat -A POSTROUTING")
require.NotContains(t, string(data), "ip6tables -t nat",
"a family-agnostic write must narrow to IPv4 while IPv6 is off")
// A stale v6 line (written while IPv6 was on, or by hand) is still swept.
v6line, err := h.natLine(masq, IPv6)
require.NoError(t, err)
require.NoError(t, os.WriteFile(h.hookPath, []byte("#!/bin/sh\n"+v6line+"\n"), 0700))
changed, err = h.editNAT(masq, true)
require.NoError(t, err)
require.True(t, changed, "removal must sweep the stale IPv6 line even with IPv6 off")
got, err = h.getNATRules()
require.NoError(t, err)
require.Empty(t, got)
}
// NAT lines share the hook with filter rules, ipset commands and user shell;
// each kind must be read by its own parser only and edits must leave the others
// byte-for-byte in place. A hand-added equivalent NAT line (different comment)
// satisfies an add and is cleared by a removal, mirroring filter-rule edits.
func TestHookScriptNATCoexistsWithFilterLines(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
ipv6Enabled: true,
}
filter := &Rule{Family: IPv4, Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}
_, err := h.edit(filter, false)
require.NoError(t, err)
snat := &NATRule{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"}
_, err = h.editNAT(snat, false)
require.NoError(t, err)
// Each parser sees only its own lines.
frules, err := h.getRules()
require.NoError(t, err)
require.Len(t, frules, 1, "the NAT line must not surface as a filter rule")
nrules, err := h.getNATRules()
require.NoError(t, err)
require.Len(t, nrules, 1, "the filter line must not surface as a NAT rule")
// Removing the NAT rule leaves the filter rule in place, and vice versa.
_, err = h.editNAT(snat, true)
require.NoError(t, err)
frules, err = h.getRules()
require.NoError(t, err)
require.Len(t, frules, 1, "a NAT removal must not touch filter lines")
// A hand-added equivalent line under a different comment dedups an add and is
// cleared by a removal: the comment is not part of rule identity.
foreign := &hookScript{rulePrefix: "acme", hookPath: h.hookPath, hookPerm: 0700, ipv6Enabled: true}
_, err = foreign.editNAT(snat, false)
require.NoError(t, err)
changed, err := h.editNAT(snat, false)
require.NoError(t, err)
require.False(t, changed, "an equivalent hand-added NAT line must satisfy the add")
changed, err = h.editNAT(snat, true)
require.NoError(t, err)
require.True(t, changed, "removal must clear the equivalent hand-added NAT line")
nrules, err = h.getNATRules()
require.NoError(t, err)
require.Empty(t, nrules)
}