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

118 lines
5 KiB
Go

package firewall
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestCSFAPFStillUnsupported(t *testing.T) {
ctx := context.Background()
csf := &CSF{}
apf := &APF{}
// Logging, rate limiting, connection-state and interface matching are now
// expressed by injecting iptables rules through the pre-hook (see
// TestHookScriptRoundTrip), and address sets by persisting ipset commands in
// that same hook, so those route to the hook rather than being rejected. What
// remains genuinely unsupported: explicit rule ordering on both backends, plus
// source NAT on CSF.
for _, m := range []Manager{csf, apf} {
require.ErrorIs(t, m.InsertRule(ctx, "", 1, &Rule{Port: 22, Proto: TCP, Action: Accept}), ErrUnsupportedOrdering,
"%s should reject explicit ordering", m.Type())
// NAT ordering is unsupported on both even though they store NAT rules.
dnat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}
require.ErrorIs(t, m.InsertNATRule(ctx, "", 1, dnat), ErrUnsupportedOrdering,
"%s should reject NAT ordering", m.Type())
}
// csf.redirect expresses only destination NAT, so a source NAT is rejected
// with the NAT sentinel before any file is touched.
snat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}
require.ErrorIs(t, csf.AddNATRule(ctx, "", snat), ErrUnsupportedNAT,
"csf should reject source NAT")
}
// Every unsupported-feature path wraps a sentinel error that callers can match
// with errors.Is. The umbrella ErrUnsupported matches them all.
func TestSentinelErrors(t *testing.T) {
ctx := context.Background()
csf := &CSF{}
// NAT and policy rejections carry their specific sentinel. CSF supports
// destination NAT (csf.redirect) but not source NAT, so a SNAT rule carries the
// NAT sentinel.
nat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}
err := csf.AddNATRule(ctx, "", nat)
require.ErrorIs(t, err, ErrUnsupportedNAT)
require.ErrorIs(t, err, ErrUnsupported)
_, err = csf.GetDefaultPolicy(ctx, "")
require.ErrorIs(t, err, ErrUnsupportedPolicy)
// The shared per-rule reject helper wraps sentinels too (used by the wf backend).
err = (&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}).rejectLogAndLimit("csf")
require.ErrorIs(t, err, ErrUnsupportedLog)
require.ErrorIs(t, err, ErrUnsupported)
}
// Capabilities advertise each backend's supported features consistently with
// its actual behavior.
func TestCapabilities(t *testing.T) {
nft := &NFT{}
ipt := &IPTables{}
csf := &CSF{}
apf := &APF{}
require.True(t, nft.Capabilities().RuleCounters, "nftables exposes rule counters")
require.True(t, nft.Capabilities().DefaultPolicy, "nftables manages a default policy")
require.True(t, nft.Capabilities().AddressSets, "nftables manages address sets")
require.True(t, nft.Capabilities().NAT)
require.True(t, ipt.Capabilities().RuleCounters, "iptables exposes rule counters")
require.True(t, ipt.Capabilities().DefaultPolicy)
require.True(t, ipt.Capabilities().AddressSets, "iptables manages ipsets")
// CSF is a deliberately minimal backend: no counters, no policy.
require.False(t, csf.Capabilities().RuleCounters)
require.False(t, csf.Capabilities().DefaultPolicy)
// CSF does express (destination) NAT through csf.redirect and per-port
// connection limiting through CONNLIMIT.
require.True(t, csf.Capabilities().NAT)
require.True(t, csf.Capabilities().ConnLimit)
// CSF gains address sets by persisting ipset commands in its pre-hook.
require.True(t, csf.Capabilities().AddressSets)
// APF likewise gains NAT (routing files) and connection limiting
// (IG_*_CLIMIT) from its native config, and address sets from ipset commands
// persisted in its pre-hook.
require.True(t, apf.Capabilities().NAT)
require.True(t, apf.Capabilities().ConnLimit)
require.True(t, apf.Capabilities().AddressSets)
// Both gain logging, rate limiting, connection-state, interface matching and
// forward-chain rules by injecting iptables rules through the pre-hook.
for _, c := range []Capabilities{csf.Capabilities(), apf.Capabilities()} {
require.True(t, c.Logging)
require.True(t, c.RateLimit)
require.True(t, c.ConnState)
require.True(t, c.InterfaceMatch)
require.True(t, c.Forward)
}
// nftables, iptables and ufw express forward-chain rules natively.
require.True(t, nft.Capabilities().Forward, "nftables expresses forward rules")
require.True(t, ipt.Capabilities().Forward, "iptables expresses forward rules")
// CSF's ICMPv6 always goes through that same pre-hook (raw ip6tables), so it
// is unconditionally supported regardless of csf.conf's IPV6 setting.
require.True(t, csf.Capabilities().ICMPv6)
// APF's plain ICMPv6 rules instead use its native IG_ICMPV6_TYPES/
// EG_ICMPV6_TYPES lists, which apf itself silently no-ops unless conf.apf's
// USE_IPV6 is "1" — so the capability mirrors ipv6Enabled.
require.False(t, apf.Capabilities().ICMPv6, "USE_IPV6 not confirmed enabled, so ICMPv6 must not be advertised")
apfV6 := &APF{ipv6Enabled: true}
require.True(t, apfV6.Capabilities().ICMPv6)
}