go-firewall/hooks_linux_test.go
James Coleman 073c9ad7f0 Add address-set support to APF/CSF and ipset reboot persistence to iptables
iptables: persist ipsets across reboot to match rule persistence.
- Detect the ipset save-file + restore unit (RHEL ipset.service /
  /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets),
  non-fatally.
- After each set mutation, `ipset save` into the layout's save-file and
  auto-enable a present-but-disabled restore unit; warn when no mechanism
  exists (sets stay live-only).
- Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230;
  CentOS 7 ships 219).

APF/CSF: gain address sets by persisting ipset commands in the pre-hook.
- The hook carries an `ipset create/flush/add` block ordered ahead of the
  `-m set --match-set` rule lines, so the firewall recreates the set on every
  (re)start before any rule references it.
- Route set-referencing rules (Source/Destination names an ipset) through the
  hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape).
- Implement the six address-set methods, advertise AddressSets, and wire sets
  into Backup/Restore via captureBackupState/restoreBackupSets.

Validated live: reboot simulation for iptables; generated-hook source for
APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and
set-ref routing; the capability-gated integration subtest now covers APF/CSF.
2026-07-08 17:20:38 -05:00

583 lines
24 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)
}
}
// 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 ProtocolAny rule that carries a port has no single iptables form — a --dport
// match requires a concrete -p tcp/udp — 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: csf's GetRules merges a same-port TCP_IN/UDP_IN pair back
// into one ProtocolAny rule, so a Backup captured it and Restore's hook-copy clear
// then failed to marshal it, breaking the whole restore.
func TestHookScriptProtocolAnyPortFansOut(t *testing.T) {
dir := t.TempDir()
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(dir, "csfpre.sh"),
hookPerm: 0700,
}
// Adding a ProtocolAny port rule injects a tcp line and a udp line.
any := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 20, Action: Accept}
changed, err := h.edit(any, false)
require.NoError(t, err, "a ProtocolAny 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 ProtocolAny 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 ProtocolAny 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 ProtocolAny port rule must not fail to marshal")
require.True(t, changed, "the ProtocolAny 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 ProtocolAny 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")
}