go-firewall/hooks_linux_test.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

625 lines
26 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},
}
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)
}
}
// 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)
}
}
// bareProtoNeedsHook routes a portless, addressless non-ICMP match to the hook —
// the shape CSF/APF cannot express natively but iptables applies directly — while
// leaving every rule that carries an address, a port, or an ICMP protocol on its
// own native/ICMP path.
func TestBareProtoNeedsHook(t *testing.T) {
// Bare protocol matches with no address and no port go to the hook.
needs := []*Rule{
{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, bareProtoNeedsHook(r), "expected %+v to route to the hook", *r)
}
// A port, an address, or an ICMP protocol keeps the rule off this path.
native := []*Rule{
{Proto: TCP, Port: 22, Action: Accept},
{Proto: TCP, SourcePort: 1234, Action: Accept},
{Proto: TCP, Source: "1.2.3.4", Action: Accept},
{Proto: ProtocolAny, Destination: "1.2.3.4", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: ICMPv6, Action: Accept},
}
for _, r := range native {
require.False(t, bareProtoNeedsHook(r), "expected %+v to stay off the hook route", *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,
}
// A family-agnostic rule is injected for both v4 and v6.
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)
}
}
// hostNeedsHook selects the portless address rules a csf/apf trust file cannot
// express — a concrete tcp/udp host or a source+destination pair — so AddRule
// diverts them to the raw-iptables hook instead of rejecting them. An all-
// protocol single-address host (a native plain line or a bareHostOneWay hook
// rule), a port-bearing rule, an address-less rule, and ICMP stay off this path.
func TestHostNeedsHook(t *testing.T) {
cases := []struct {
name string
rule *Rule
want bool
}{
{"tcp host no port", &Rule{Proto: TCP, Source: "1.2.3.4"}, true},
{"udp host no port outbound", &Rule{Proto: UDP, Destination: "1.2.3.4", Direction: DirOutput}, true},
{"source and destination", &Rule{Source: "1.2.3.4", Destination: "5.6.7.8"}, true},
{"source and destination with proto", &Rule{Proto: TCP, Source: "1.2.3.4", Destination: "5.6.7.8"}, true},
{"all-protocol single host", &Rule{Source: "1.2.3.4"}, false},
{"tcp host with port", &Rule{Proto: TCP, Port: 22, Source: "1.2.3.4"}, false},
{"tcp host with source port", &Rule{Proto: TCP, SourcePort: 22, Source: "1.2.3.4"}, false},
{"address-less port rule", &Rule{Proto: TCP, Port: 22}, false},
{"icmp host", &Rule{Proto: ICMP, Source: "1.2.3.4"}, false},
{"icmpv6 source and destination", &Rule{Proto: ICMPv6, Source: "2001:db8::1", Destination: "2001:db8::2"}, false},
}
for _, c := range cases {
require.Equal(t, c.want, hostNeedsHook(c.rule), c.name)
}
}
// 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")
}