go-firewall/hooks_linux_test.go
2026-07-08 15:54:48 -05:00

448 lines
18 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},
}
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)
}
}