1111 lines
45 KiB
Go
1111 lines
45 KiB
Go
package firewall
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Two rules that are Equal (port-set order is not part of rule identity) must
|
|
// dedup against each other, 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
|
|
// parsed rule, which the marshal/parse round trip normalizes.
|
|
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. A Backup may hold a TCPUDP rule, so
|
|
// Restore's hook-copy clear must marshal it rather than abort 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)
|
|
}
|
|
}
|
|
|
|
// A hook written by an older library version embeds the comment in the command
|
|
// line via `-m comment`. The embedded text takes read precedence and its prefix
|
|
// tag still marks the rule, a re-add is satisfied by the legacy line, and a
|
|
// removal clears it — so an existing hook migrates without duplicate lines.
|
|
func TestHookLegacyEmbeddedCommentMigrates(t *testing.T) {
|
|
h := newTestHook(t)
|
|
r := &Rule{Family: IPv4, Proto: TCP, Port: 8443, Action: Accept, State: StateNew, Comment: "web tier"}
|
|
|
|
// Recreate the legacy spelling: the current line with the comment tokens
|
|
// spliced back in ahead of the action, single-quoted as the old writer did.
|
|
lines, err := h.ruleLines(r, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, lines, 1)
|
|
legacy := strings.Replace(lines[0], "-j ACCEPT", "-m comment --comment 'go_firewall web tier' -j ACCEPT", 1)
|
|
require.NoError(t, os.WriteFile(h.hookPath, []byte("#!/bin/sh\n"+legacy+"\n"), 0700))
|
|
|
|
got, err := h.getRules()
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
require.Equal(t, "web tier", got[0].Comment)
|
|
require.True(t, got[0].HasPrefix, "the embedded prefix tag must still mark the rule")
|
|
|
|
changed, err := h.edit(r, false)
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "the legacy line must satisfy the add")
|
|
|
|
changed, err = h.edit(r, true)
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "the legacy line must be removable")
|
|
got, err = h.getRules()
|
|
require.NoError(t, err)
|
|
require.Empty(t, got)
|
|
}
|
|
|
|
// Comment attachment follows the trust-file scanners: a blank line detaches a
|
|
// comment from the rule below, a prefix tag starts a fresh block so a section
|
|
// header above it survives the rule's removal, and an embedded iptables comment
|
|
// keeps its text over the script comment while the prefix counts from either.
|
|
func TestHookScriptCommentAttachment(t *testing.T) {
|
|
h := newTestHook(t)
|
|
body := "#!/bin/sh\n" +
|
|
"# detached note\n" +
|
|
"\n" +
|
|
"iptables -A INPUT -p tcp -m tcp --dport 2020 -j ACCEPT\n" +
|
|
"# section header\n" +
|
|
"# go_firewall tagged\n" +
|
|
"iptables -A INPUT -p tcp -m tcp --dport 2021 -j ACCEPT\n" +
|
|
"# go_firewall script note\n" +
|
|
"iptables -A INPUT -p tcp -m tcp --dport 2022 -m comment --comment 'acme note' -j ACCEPT\n"
|
|
require.NoError(t, os.WriteFile(h.hookPath, []byte(body), 0700))
|
|
|
|
got, err := h.getRules()
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 3)
|
|
byPort := map[uint16]*Rule{}
|
|
for _, g := range got {
|
|
byPort[g.Port] = g
|
|
}
|
|
require.Empty(t, byPort[2020].Comment, "a blank line must detach a comment from the rule below")
|
|
require.False(t, byPort[2020].HasPrefix)
|
|
require.Equal(t, "tagged", byPort[2021].Comment, "the prefix tag starts the rule's comment block")
|
|
require.True(t, byPort[2021].HasPrefix)
|
|
require.Equal(t, "acme note", byPort[2022].Comment,
|
|
"an embedded iptables comment must take precedence over the script comment")
|
|
require.True(t, byPort[2022].HasPrefix, "the prefix must count from the script comment too")
|
|
|
|
// Removing the tagged rule drops its tag comment but keeps the section
|
|
// header above it and the detached note.
|
|
_, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 2021, Action: Accept}, true)
|
|
require.NoError(t, err)
|
|
data, err := os.ReadFile(h.hookPath)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), "# section header")
|
|
require.Contains(t, string(data), "# detached note")
|
|
require.NotContains(t, string(data), "# go_firewall tagged")
|
|
}
|
|
|
|
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.ruleLines(&Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew}, false)
|
|
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.ruleLines(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "drop $x"}, false)
|
|
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.ruleLines(orig, false)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
// AddRule IPv6 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.ruleLines(anyFam, false)
|
|
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 AddRule IPv6 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.ruleLines(v6, false)
|
|
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 does not report.
|
|
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.ruleLines(&Rule{Source: "v6drop", Action: Drop}, false)
|
|
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.ruleLines(&Rule{Source: "missing", Action: Drop}, false)
|
|
require.Error(t, err, "a reference to an unknown set must be an error")
|
|
|
|
h.ipv6Enabled = false
|
|
_, err = h.ruleLines(&Rule{Source: "v6drop", Action: Drop}, false)
|
|
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.
|
|
v6 := *masq
|
|
v6.Family = IPv6
|
|
v6line, err := h.natLine(&v6)
|
|
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)
|
|
}
|
|
|
|
// Set-family resolution asks the hook's own ipset lines first — sets are staged
|
|
// there and created when the firewall reloads, so the hook is what the rule will
|
|
// actually match against, and an unrelated live set of the same name must not
|
|
// shadow it — and falls back to the live kernel for a set that exists only
|
|
// there. A set found in neither place errors.
|
|
func TestHookScriptSetRefFamilyDeclaredFirst(t *testing.T) {
|
|
dir := t.TempDir()
|
|
h := &hookScript{
|
|
rulePrefix: "go_firewall",
|
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
|
hookPerm: 0700,
|
|
ipv6Enabled: true,
|
|
}
|
|
|
|
prev := ipsetLiveFamily
|
|
t.Cleanup(func() { ipsetLiveFamily = prev })
|
|
// The kernel reports inet6 for both names; only "liveset" is ever declared in
|
|
// the hook, so the two arms below are told apart by precedence alone.
|
|
ipsetLiveFamily = func(name string) (Family, bool, error) {
|
|
if name == "liveset" || name == "hookset" {
|
|
return IPv6, true, nil
|
|
}
|
|
return FamilyAny, false, nil
|
|
}
|
|
|
|
// Live only: resolved from the kernel, since the hook declares nothing.
|
|
lines, err := h.ruleLines(&Rule{Source: "liveset", Proto: TCP, Port: 22, Action: Accept}, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, lines, 1)
|
|
require.True(t, strings.HasPrefix(lines[0], "ip6tables "),
|
|
"a live inet6 set must pin the rule to ip6tables, got %q", lines[0])
|
|
|
|
// Declared in the hook (just written, firewall not reloaded yet): the hook
|
|
// supplies the family even though a live set of that name says otherwise.
|
|
set := &AddressSet{Name: "hookset", Family: IPv4, Type: SetHashIP, Entries: []string{"192.0.2.1"}}
|
|
changed, err := h.editAddressSet(set, false)
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
lines, err = h.ruleLines(&Rule{Source: "hookset", Proto: TCP, Port: 22, Action: Accept}, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, lines, 1)
|
|
require.True(t, strings.HasPrefix(lines[0], "iptables "),
|
|
"a set staged inet in the hook must pin the rule to iptables even when a live set of the same name is inet6, got %q", lines[0])
|
|
|
|
// Known nowhere: an error, not a guessed family.
|
|
_, err = h.ruleLines(&Rule{Source: "ghost", Proto: TCP, Port: 22, Action: Accept}, false)
|
|
require.ErrorContains(t, err, `"ghost"`)
|
|
}
|
|
|
|
// Every line the hook writes — rule, NAT and ipset — must invoke the resolved
|
|
// absolute path of its command, so the hook applies even when the firewall sources
|
|
// it with a PATH that omits the sbin directories. The lines still read back as the
|
|
// rules they encode.
|
|
func TestHookResolvedCommandPaths(t *testing.T) {
|
|
dir := t.TempDir()
|
|
h := &hookScript{
|
|
rulePrefix: "go_firewall",
|
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
|
hookPerm: 0700,
|
|
ipv6Enabled: true,
|
|
ip4Cmd: "/usr/sbin/iptables",
|
|
ip6Cmd: "/usr/sbin/ip6tables",
|
|
ipsetCmd: "/usr/sbin/ipset",
|
|
}
|
|
|
|
_, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false)
|
|
require.NoError(t, err)
|
|
rule := &Rule{Family: IPv6, Proto: TCP, Port: 443, Action: Accept, State: StateNew}
|
|
_, err = h.edit(rule, false)
|
|
require.NoError(t, err)
|
|
nat := &NATRule{Family: IPv4, Kind: Masquerade, Interface: "eth1"}
|
|
_, err = h.editNAT(nat, false)
|
|
require.NoError(t, err)
|
|
|
|
data, err := os.ReadFile(h.hookPath)
|
|
require.NoError(t, err)
|
|
body := string(data)
|
|
require.Contains(t, body, "/usr/sbin/ipset create blocklist")
|
|
require.Contains(t, body, "/usr/sbin/ip6tables -A INPUT")
|
|
require.Contains(t, body, "/usr/sbin/iptables -t nat -A POSTROUTING")
|
|
for _, line := range strings.Split(body, "\n") {
|
|
require.False(t, strings.HasPrefix(line, "iptables ") || strings.HasPrefix(line, "ip6tables ") ||
|
|
strings.HasPrefix(line, "ipset "), "line must invoke a resolved path, got %q", line)
|
|
}
|
|
|
|
// A path-spelled line parses back into the rule it encodes.
|
|
rules, err := h.getRules()
|
|
require.NoError(t, err)
|
|
require.Len(t, rules, 1)
|
|
require.True(t, rules[0].Equal(rule, true))
|
|
require.True(t, rules[0].HasPrefix)
|
|
nats, err := h.getNATRules()
|
|
require.NoError(t, err)
|
|
require.Len(t, nats, 1)
|
|
require.True(t, nats[0].EqualForRemoval(nat))
|
|
sets, err := h.getAddressSets()
|
|
require.NoError(t, err)
|
|
require.Len(t, sets, 1)
|
|
require.Equal(t, []string{"203.0.113.5"}, sets[0].Entries)
|
|
}
|
|
|
|
// A command may be spelled any way in an existing hook — bare, under a path that
|
|
// differs from the one this manager resolved, quoted, or as an
|
|
// update-alternatives variant — and must still read back as the rule it encodes
|
|
// and satisfy a resolved-path hook's adds and removals, so a reconcile neither
|
|
// duplicates the line nor churns the hook.
|
|
func TestHookCommandSpellingsSatisfyResolvedHook(t *testing.T) {
|
|
dir := t.TempDir()
|
|
hookPath := filepath.Join(dir, "csfpre.sh")
|
|
require.NoError(t, os.WriteFile(hookPath, []byte("#!/bin/sh\n"+
|
|
"iptables -A INPUT -p tcp -m tcp --dport 2222 -j ACCEPT\n"+
|
|
"/sbin/iptables -A INPUT -p tcp -m tcp --dport 2223 -j ACCEPT\n"+
|
|
"iptables-nft -A INPUT -p tcp -m tcp --dport 2224 -j ACCEPT\n"+
|
|
"'/sbin/ip6tables' -A INPUT -p tcp -m tcp --dport 2225 -j ACCEPT\n"+
|
|
"/sbin/ipset create blocklist hash:ip family inet -exist\n"+
|
|
"ipset flush blocklist\n"+
|
|
"/sbin/ipset add blocklist 203.0.113.5\n"+
|
|
"/sbin/iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE\n"), 0700))
|
|
h := &hookScript{
|
|
rulePrefix: "go_firewall",
|
|
hookPath: hookPath,
|
|
hookPerm: 0700,
|
|
ip4Cmd: "/usr/sbin/iptables",
|
|
ip6Cmd: "/usr/sbin/ip6tables",
|
|
ipsetCmd: "/usr/sbin/ipset",
|
|
}
|
|
|
|
// Every spelling reads back, with the family its command selects.
|
|
rules, err := h.getRules()
|
|
require.NoError(t, err)
|
|
byPort := map[uint16]*Rule{}
|
|
for _, r := range rules {
|
|
byPort[r.Port] = r
|
|
}
|
|
require.Len(t, byPort, 4)
|
|
for _, port := range []uint16{2222, 2223, 2224} {
|
|
require.Equal(t, IPv4, byPort[port].Family, "port %d", port)
|
|
}
|
|
require.Equal(t, IPv6, byPort[2225].Family, "a quoted ip6tables path selects IPv6")
|
|
nats, err := h.getNATRules()
|
|
require.NoError(t, err)
|
|
require.Len(t, nats, 1)
|
|
sets, err := h.getAddressSets()
|
|
require.NoError(t, err)
|
|
require.Len(t, sets, 1)
|
|
require.Equal(t, []string{"203.0.113.5"}, sets[0].Entries)
|
|
|
|
// Re-adding those rules is a no-op: matching is on the parsed rule, not the
|
|
// command spelling, so the hook is not rewritten with duplicate lines.
|
|
for _, port := range []uint16{2222, 2223, 2224} {
|
|
changed, err := h.edit(&Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}, false)
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "an existing line for port %d must satisfy the add", port)
|
|
}
|
|
changed, err := h.editNAT(&NATRule{Family: IPv4, Kind: Masquerade, Interface: "eth1"}, false)
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "an existing path-spelled nat line must satisfy the add")
|
|
|
|
// Removal finds a differently spelled line too.
|
|
changed, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 2223, Action: Accept}, true)
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
data, err := os.ReadFile(hookPath)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(data), "--dport 2223")
|
|
require.Contains(t, string(data), "--dport 2222", "an unrelated line must survive")
|
|
}
|
|
|
|
// resolveBinary — shared by the hook lines and every command the backends run —
|
|
// prefers PATH, falls back to the standard install directories when PATH misses
|
|
// the tool, and leaves a tool it cannot find bare (reporting not-found) so exec or
|
|
// the shell resolves it.
|
|
func TestResolveBinary(t *testing.T) {
|
|
dir := t.TempDir()
|
|
onPath := filepath.Join(dir, "gofwpathbin")
|
|
require.NoError(t, os.WriteFile(onPath, []byte("#!/bin/sh\n"), 0755))
|
|
sbin := t.TempDir()
|
|
offPath := filepath.Join(sbin, "gofwsbinbin")
|
|
require.NoError(t, os.WriteFile(offPath, []byte("#!/bin/sh\n"), 0755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(sbin, "gofwnoexecbin"), []byte("#!/bin/sh\n"), 0644))
|
|
|
|
saved := binSearchDirs
|
|
t.Cleanup(func() { binSearchDirs = saved })
|
|
binSearchDirs = []string{sbin}
|
|
t.Setenv("PATH", dir)
|
|
|
|
got, ok := resolveBinary("gofwpathbin")
|
|
require.True(t, ok)
|
|
require.Equal(t, onPath, got)
|
|
got, ok = resolveBinary("gofwsbinbin")
|
|
require.True(t, ok, "a tool off PATH resolves from the install directories")
|
|
require.Equal(t, offPath, got)
|
|
got, ok = resolveBinary("gofwnoexecbin")
|
|
require.False(t, ok, "a non-executable file is not a tool")
|
|
require.Equal(t, "gofwnoexecbin", got)
|
|
got, ok = resolveBinary("gofwabsentbin")
|
|
require.False(t, ok, "an unresolvable tool stays bare")
|
|
require.Equal(t, "gofwabsentbin", got)
|
|
|
|
// An explicit path is taken as given, so a caller can pin a tool.
|
|
got, ok = resolveBinary("/opt/sbin/gofwabsentbin")
|
|
require.True(t, ok)
|
|
require.Equal(t, "/opt/sbin/gofwabsentbin", got)
|
|
|
|
// The hook wrapper reports the same path, bare when unresolvable.
|
|
require.Equal(t, offPath, resolveHookBinary("gofwsbinbin"))
|
|
require.Equal(t, "gofwabsentbin", resolveHookBinary("gofwabsentbin"))
|
|
}
|
|
|
|
// TestHookStaysExecutable verifies a hook the firewall already ships stays
|
|
// runnable after the library edits it. apf installs hook_pre.sh non-executable
|
|
// and only runs it when the execute bit is set, and an atomic write otherwise
|
|
// preserves the existing mode — so without this the injected lines read back
|
|
// from the file correctly while never reaching the kernel. The rest of the mode
|
|
// is left alone.
|
|
func TestHookStaysExecutable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "hook_pre.sh")
|
|
// The shipped file: readable by owner and group, executable by neither.
|
|
require.NoError(t, os.WriteFile(path, []byte("#!/bin/bash\n"), 0640))
|
|
|
|
h := &hookScript{rulePrefix: "go_firewall", hookPath: path, hookPerm: 0750}
|
|
r := &Rule{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept}
|
|
changed, err := h.edit(r, false)
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
|
|
fi, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 0740, fi.Mode().Perm(),
|
|
"only the owner-execute bit is added; the rest of the shipped mode is kept")
|
|
|
|
// A hook already executable keeps exactly the mode it had.
|
|
require.NoError(t, os.Chmod(path, 0755))
|
|
_, err = h.edit(&Rule{Family: IPv4, Proto: SCTP, Port: 9001, Action: Accept}, false)
|
|
require.NoError(t, err)
|
|
fi, err = os.Stat(path)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 0755, fi.Mode().Perm())
|
|
}
|
|
|
|
// TestHookNewFileUsesBackendMode verifies a hook the library creates itself still
|
|
// gets the backend's own mode (csf's csfpre.sh does not ship with the product).
|
|
func TestHookNewFileUsesBackendMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "csfpre.sh")
|
|
h := &hookScript{rulePrefix: "go_firewall", hookPath: path, hookPerm: 0700}
|
|
|
|
changed, err := h.edit(&Rule{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept}, false)
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
|
|
fi, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 0700, fi.Mode().Perm())
|
|
}
|