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

819 lines
38 KiB
Go

package firewall
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestAPFAdvRules(t *testing.T) {
fw := new(APF)
// Advanced-rule encodings, including underscore ranges and bracketed IPv6.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, Port: 22, Source: "192.168.2.1", Family: IPv4, Action: Accept}, "tcp:in:d=22:s=192.168.2.1"},
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 6000, End: 7000}}, Source: "192.168.5.0/24", Family: IPv4, Action: Accept}, "tcp:in:d=6000_7000:s=192.168.5.0/24"},
{&Rule{Proto: TCP, Port: 443, Source: "2001:db8::/32", Family: IPv6, Action: Accept}, "tcp:in:d=443:s=[2001:db8::/32]"},
{&Rule{Direction: DirOutput, Proto: UDP, Port: 53, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "udp:out:d=53:d=192.0.2.1"},
}
for _, c := range cases {
got, err := fw.MarshalAdvRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed := fw.ParseAdvRule(got, c.rule.Action)
require.NotNil(t, parsed, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, true),
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
}
// A bracketed IPv6 host address parses back without brackets.
r := fw.ParseAdvRule("tcp:in:d=443:s=[2001:db8::1]", Accept)
require.NotNil(t, r)
require.Equal(t, "2001:db8::1", r.Source)
require.Equal(t, IPv6, r.Family)
require.EqualValues(t, 443, r.Port)
// MarshalAdvRule rejects rules apf cannot express as advanced rules.
bad := []*Rule{
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept},
{Proto: TCP, Port: 80, Action: Accept},
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Action: Accept},
}
for _, r := range bad {
_, err := fw.MarshalAdvRule(r)
require.Error(t, err, "expected error marshalling %+v", *r)
}
}
// A port-only deny whose action is Drop (conf.apf's default ALL_STOP=DROP) must
// still be written to deny_hosts: the placeholder branch keys on "not an accept",
// not on Reject. Before the fix it keyed on Reject, so a Drop deny wrote nothing
// while AddRule reported success — the port stayed open.
func TestAPFPortOnlyDropDenyIsWritten(t *testing.T) {
ctx := context.Background()
fw := new(APF)
dir := t.TempDir()
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, nil, 0644))
drop := &Rule{Family: IPv4, Proto: TCP, Port: 3306, Action: Drop}
require.NoError(t, fw.EditIPList(ctx, path, Drop, drop, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "tcp:in:d=3306:s=0.0.0.0/0",
"a port-only Drop deny must be written with the any-network placeholder")
}
func TestAPFSourcePorts(t *testing.T) {
fw := new(APF)
// Source ports round-trip through the s= port-flow field (single port and
// underscore range).
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, SourcePort: 1234, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "tcp:in:s=1234:d=192.0.2.1"},
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Source: "192.0.2.1", Family: IPv4, Action: Accept}, "udp:in:s=1024_2048:s=192.0.2.1"},
}
for _, c := range cases {
got, err := fw.MarshalAdvRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed := fw.ParseAdvRule(got, c.rule.Action)
require.NotNil(t, parsed, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, true),
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
}
// Matching both a source and a destination port is not representable.
_, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept})
require.Error(t, err, "expected error matching both source and destination ports")
// A source-port rule with no address has no advanced-rule form, so it routes to
// the hook; one carrying an address stays on the native advanced-rule path.
require.True(t, fw.needsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}),
"an address-less source-port rule must route to the hook")
require.False(t, fw.needsHook(&Rule{Proto: TCP, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}))
}
// TestAPFDualStackPort locks in that apf routes a single-family bare port accept to
// the hook: its CPORTS lists are dual-stack and express only a FamilyAny port, so a
// concrete-family one is written per-family through the hook. A FamilyAny port stays
// native, and a port with an address or an ICMP type takes another path.
func TestAPFDualStackPort(t *testing.T) {
fw := new(APF)
// A concrete-family bare TCP/UDP port accept goes to the hook.
require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCP, Port: 3492, Action: Accept}))
require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv6, Proto: UDP, Port: 3492, Action: Accept}))
// A dual-stack (FamilyAny) port is what the shared CPORTS list represents.
require.False(t, fw.dualStackPortNeedsHook(&Rule{Proto: TCP, Port: 3492, Action: Accept}))
// A port with an address is a host rule, and ICMP has no port — neither is a
// dual-stack CPORTS accept.
require.False(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCP, Port: 3492, Source: "192.0.2.1", Action: Accept}))
require.False(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}))
}
// TestAPFBarePortAccept locks in the removal routing shared by single-family and
// FamilyAny bare tcp/udp port accepts. A FamilyAny port merged back from a v4+v6 hook
// pair reads as impliedFamily FamilyAny, so dualStackPortNeedsHook (single-family
// only) does not match it; barePortAccept must, so RemoveRule routes it to the
// family-agnostic removeFamilyAnyPort instead of the native-only EditConf path that
// cannot clear the hook rows. Regression for mergedfamilyremove leaving the port open.
func TestAPFBarePortAccept(t *testing.T) {
fw := new(APF)
// Both a concrete-family and a FamilyAny bare port accept are bare-port accepts;
// only the concrete-family one additionally forces the add-time hook.
v4 := &Rule{Family: IPv4, Proto: TCP, Port: 3492, Action: Accept}
any := &Rule{Family: FamilyAny, Proto: TCP, Port: 3492, Action: Accept}
require.True(t, fw.barePortAccept(v4))
require.True(t, fw.barePortAccept(any))
require.True(t, fw.dualStackPortNeedsHook(v4))
require.False(t, fw.dualStackPortNeedsHook(any), "a FamilyAny port must route to removeFamilyAnyPort, not the single-family split")
// A deny, an address-bearing rule, a source-port-only match (no CPORTS form), and
// a non-tcp/udp match are not bare-port accepts and take other removal paths.
require.False(t, fw.barePortAccept(&Rule{Proto: TCP, Port: 3492, Action: Drop}))
require.False(t, fw.barePortAccept(&Rule{Proto: TCP, Port: 3492, Source: "192.0.2.1", Action: Accept}))
require.False(t, fw.barePortAccept(&Rule{Proto: TCP, SourcePort: 3492, Action: Accept}))
require.False(t, fw.barePortAccept(&Rule{Proto: ICMP, Action: Accept}))
}
func TestAPFConnLimit(t *testing.T) {
fw := new(APF)
// IG_TCP_CLIMIT parses into per-port reject rules with a per-source cap; an
// underscore port range is preserved.
rules := fw.ParseConnLimit("80:50,8080_8090:25", TCP)
require.Len(t, rules, 2)
require.Equal(t, Reject, rules[0].Action)
require.EqualValues(t, 80, rules[0].Port)
require.NotNil(t, rules[0].ConnLimit)
require.EqualValues(t, 50, rules[0].ConnLimit.Count)
require.True(t, rules[0].ConnLimit.PerSource)
require.Len(t, rules[1].Ports, 1)
require.Equal(t, PortRange{Start: 8080, End: 8090}, rules[1].Ports[0])
// Editing adds a range entry and removes a port entry.
added := fw.editConnLimit("IG_TCP_CLIMIT", "80:50",
&Rule{Proto: TCP, Ports: []PortRange{{Start: 8080, End: 8090}}, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
require.Equal(t, `IG_TCP_CLIMIT="80:50,8080_8090:25"`, added)
removed := fw.editConnLimit("IG_TCP_CLIMIT", "80:50,443:100",
&Rule{Proto: TCP, Port: 443, Action: Reject, ConnLimit: &ConnLimit{Count: 100, PerSource: true}}, true)
require.Equal(t, `IG_TCP_CLIMIT="80:50"`, removed)
// Only a single inbound tcp/udp port|range, address-less, per-source, reject
// rule maps onto conf.apf natively; every other connection limit routes to the
// hook rather than being rejected.
native := &Rule{Proto: UDP, Port: 53, Action: Reject, ConnLimit: &ConnLimit{Count: 100, PerSource: true}}
require.True(t, fw.isConnLimitRule(native))
require.False(t, fw.needsHook(native), "a native connlimit stays in conf.apf")
hooked := []*Rule{
{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // non-reject action
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // proto
{Proto: TCP, Port: 80, Source: "1.2.3.4", Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // address
{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}, // global
}
for _, r := range hooked {
require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r)
}
}
func TestAPFNATRoundTrip(t *testing.T) {
fw := new(APF)
cases := []*NATRule{
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080},
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080},
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"},
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
}
for _, orig := range cases {
for _, fam := range fw.natFamilies(orig) {
line, err := fw.natLine(orig, fam)
require.NoError(t, err, "marshal %+v", *orig)
got, ok := fw.parseNATLine(line)
require.True(t, ok, "failed to parse %q", line)
require.True(t, got.EqualBase(orig), "line %q: want %+v got %+v", line, orig, got)
}
}
// The command line targets the right binary and nat chain.
dnat, err := fw.natLine(&NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}, IPv4)
require.NoError(t, err)
require.Contains(t, dnat, "iptables -t nat -A PREROUTING")
require.Equal(t, APFPreroute, fw.natFile(&NATRule{Kind: DNAT}))
require.Equal(t, APFPostroute, fw.natFile(&NATRule{Kind: Masquerade}))
masq6, err := fw.natLine(&NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"}, IPv6)
require.NoError(t, err)
require.Contains(t, masq6, "ip6tables -t nat -A POSTROUTING")
// A non-NAT line is ignored by the parser.
_, ok := fw.parseNATLine("# place your custom routing rules below")
require.False(t, ok)
}
func TestAPFNATHasPrefixFlag(t *testing.T) {
r := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}
// With a prefix configured, the emitted iptables line carries the prefix
// comment tag, and parsing it back reports HasPrefix — APF's NAT rules are
// real iptables commands, so the same comment mechanism as the filter rules
// applies.
withPrefix := &APF{rulePrefix: "myapp"}
line, err := withPrefix.natLine(r, IPv4)
require.NoError(t, err)
require.Contains(t, line, `-m comment --comment "myapp"`)
got, ok := withPrefix.parseNATLine(line)
require.True(t, ok)
require.True(t, got.HasPrefix, "an APF NAT rule tagged with the prefix reports HasPrefix")
// With no prefix no tag is written, so HasPrefix stays false.
noPrefix := &APF{}
line, err = noPrefix.natLine(r, IPv4)
require.NoError(t, err)
got, ok = noPrefix.parseNATLine(line)
require.True(t, ok)
require.False(t, got.HasPrefix, "with no prefix an APF NAT rule reports no HasPrefix")
}
func TestAPFPortAndICMPConfig(t *testing.T) {
fw := new(APF)
// Port lists parse single ports and underscore ranges.
rules := fw.ParsePorts("21,22,6000_7000", TCP, false)
require.Len(t, rules, 3, "expected 3 port rules")
require.Len(t, rules[2].Ports, 1)
require.Equal(t, PortRange{Start: 6000, End: 7000}, rules[2].Ports[0],
"expected a 6000-7000 range rule")
// ICMP type lists become ICMP rules, one per type.
icmp := fw.ParseICMPTypes("3,5,8", ICMP, false)
require.Len(t, icmp, 3, "expected 3 icmp rules")
require.Equal(t, ICMP, icmp[2].Proto)
require.NotNil(t, icmp[2].ICMPType, "expected icmp type 8 rule, got %+v", *icmp[2])
require.EqualValues(t, 8, *icmp[2].ICMPType, "expected icmp type 8 rule")
// EditRulePort adds a range token to the matching port list.
got := fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22",
&Rule{Proto: TCP, Ports: []PortRange{{Start: 6000, End: 7000}}, Action: Accept}, false)
require.Equal(t, `IG_TCP_CPORTS="22,6000_7000"`, got, "unexpected port edit")
// EditRulePort adds an ICMP type to the icmp type list.
got = fw.EditRulePort(`IG_ICMP_TYPES="3,5"`, "IG_ICMP_TYPES", "3,5",
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, false)
require.Equal(t, `IG_ICMP_TYPES="3,5,8"`, got, "unexpected icmp edit")
}
// TestAPFICMPNamedTypeReconcile guards ICMP-type reconciliation against a conf.apf
// list that spells a type by name (e.g. "echo-request") rather than its number.
// The read path resolves names to numbers, so removal/add must compare by resolved
// number or a foreign name-based entry can never be removed (Sync never converges)
// and an add would append a numeric duplicate.
func TestAPFICMPNamedTypeReconcile(t *testing.T) {
// Removing ICMP type 8 must clear a name-based "echo-request" entry.
fw := new(APF)
got := fw.EditRulePort(`IG_ICMP_TYPES="echo-request"`, "IG_ICMP_TYPES", "echo-request",
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, true)
require.Equal(t, `IG_ICMP_TYPES=""`, got, "a name-based echo-request entry must be removed for type 8")
require.True(t, fw.ConfigChanged, "the config must be marked changed when the entry is removed")
// Adding type 8 when "echo-request" is already present must not duplicate it.
fw = new(APF)
got = fw.EditRulePort(`IG_ICMP_TYPES="echo-request"`, "IG_ICMP_TYPES", "echo-request",
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, false)
require.Equal(t, `IG_ICMP_TYPES="echo-request"`, got, "adding a type already present by name must not duplicate")
require.False(t, fw.ConfigChanged, "no change when the type is already present by name")
// The same holds for ICMPv6, resolved through the ICMPv6 name table (128).
fw = new(APF)
got = fw.EditRulePort(`IG_ICMPV6_TYPES="echo-request"`, "IG_ICMPV6_TYPES", "echo-request",
&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, true)
require.Equal(t, `IG_ICMPV6_TYPES=""`, got, "a name-based ICMPv6 echo-request entry must be removed for type 128")
}
func TestAPFICMPRouting(t *testing.T) {
fw := new(APF)
// An ICMPv4 rule apf's IG_ICMP_TYPES list cannot express — one carrying an
// address or a non-accept action — routes to the hook.
require.True(t, fw.needsHook(&Rule{Proto: ICMP, Source: "1.2.3.4", ICMPType: Ptr[uint8](8), Action: Accept}))
require.True(t, fw.needsHook(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Drop}))
// The ICMPv6 equivalents route to the hook via ruleNeedsHook (every ICMPv6 rule
// does) since they are not a native type list entry (nativeICMPv6).
for _, r := range []*Rule{
{Proto: ICMPv6, Source: "2001:db8::1", ICMPType: Ptr[uint8](128), Action: Accept},
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Drop},
} {
require.True(t, ruleNeedsHook(r) && !fw.nativeICMPv6(r), "expected hook routing for %+v", *r)
}
// Valid inbound allows stay native: an ICMP type, an ICMPv6 type, and a typeless
// "all" allow are address-less accepts (isConfRule), not hook rules.
for _, r := range []*Rule{
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
{Proto: ICMP, Action: Accept},
{Proto: ICMPv6, Action: Accept},
} {
require.False(t, fw.needsHook(r), "%+v must stay native", *r)
require.True(t, fw.isConfRule(r), "%+v must be a conf.apf rule", *r)
if r.Proto == ICMPv6 {
require.True(t, fw.nativeICMPv6(r), "%+v must be a native ICMPv6 type", *r)
}
}
}
// apf carries ICMPv6 types and the "all" wildcard natively; both must round-trip
// through the conf.apf type lists and stay off the raw-iptables hook.
func TestAPFICMPv6AndAllWildcard(t *testing.T) {
fw := new(APF)
// IG_ICMPV6_TYPES parses to IPv6 ICMPv6 accepts, one per type.
v6 := fw.ParseICMPTypes("1,2,128,129", ICMPv6, false)
require.Len(t, v6, 4)
require.Equal(t, ICMPv6, v6[3].Proto)
require.Equal(t, IPv6, v6[3].Family)
require.EqualValues(t, 129, *v6[3].ICMPType)
// The "all" wildcard parses to a typeless (all-types) accept.
all := fw.ParseICMPTypes("all", ICMP, true)
require.Len(t, all, 1)
require.Nil(t, all[0].ICMPType, "'all' must be an all-types rule")
require.True(t, all[0].IsOutput())
// Writing an ICMPv6 type into its native list.
got := fw.EditRulePort(`IG_ICMPV6_TYPES="1,2"`, "IG_ICMPV6_TYPES", "1,2",
&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, false)
require.Equal(t, `IG_ICMPV6_TYPES="1,2,128"`, got)
// Writing the "all" wildcard for a typeless ICMP accept.
got = fw.EditRulePort(`EG_ICMP_TYPES=""`, "EG_ICMP_TYPES", "",
&Rule{Proto: ICMP, Direction: DirOutput, Action: Accept}, false)
require.Equal(t, `EG_ICMP_TYPES="all"`, got)
// A native ICMPv6 accept is routed to conf.apf, not the hook.
require.True(t, fw.nativeICMPv6(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}))
require.True(t, fw.isConfRule(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}))
// An ICMPv6 rule that also needs state matching stays on the hook path.
require.False(t, fw.nativeICMPv6(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept}))
}
// apf's own shell logic (apf_trust.sh trust_hosts(), cports.common) silently
// no-ops a bare IPv6 host and the native ICMPv6 type lists whenever conf.apf's
// USE_IPV6 is not "1" (the shipped default). ipv6Unavailable must flag
// exactly those two native shapes, and only when ipv6Enabled is false; a rule
// diverted to the raw-iptables hook (which bypasses USE_IPV6 entirely) must
// never be flagged.
func TestAPFIPv6UnavailableGate(t *testing.T) {
disabled := &APF{ipv6Enabled: false}
enabled := &APF{ipv6Enabled: true}
nativeICMPv6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}
require.True(t, disabled.ipv6Unavailable(nativeICMPv6),
"a native ICMPv6 type rule must be blocked when USE_IPV6 is off")
require.False(t, enabled.ipv6Unavailable(nativeICMPv6),
"a native ICMPv6 type rule must be allowed when USE_IPV6 is on")
bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept}
require.True(t, disabled.ipv6Unavailable(bareV6Host),
"a bare IPv6 host rule must be blocked when USE_IPV6 is off")
require.False(t, enabled.ipv6Unavailable(bareV6Host),
"a bare IPv6 host rule must be allowed when USE_IPV6 is on")
bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept}
require.False(t, disabled.ipv6Unavailable(bareV4Host),
"an IPv4 rule must never be blocked by the IPv6 gate")
// An ICMPv6 rule that also needs state matching is diverted to the
// raw-iptables hook (see nativeICMPv6), which runs outside apf's
// USE_IPV6-gated shell logic, so it must not be blocked either way.
hookRoutedICMPv6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept}
require.False(t, disabled.ipv6Unavailable(hookRoutedICMPv6),
"a hook-routed ICMPv6 rule must not be blocked by the IPv6 gate")
}
func TestAPFIPListComment(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "allow_hosts.rules")
fw := &APF{rulePrefix: "myapp"}
ctx := context.Background()
require.NoError(t, os.WriteFile(path, []byte(
"# myapp trusted office\n"+
"tcp:in:d=22:s=10.0.0.0/24\n"+
"\n"+
"# unrelated note\n"+
"# separated by blank\n"+
"192.0.2.5\n"+
"2001:db8::1 # inline ignored\n",
), 0644))
rules, err := fw.ParseIPList(path, Accept)
require.NoError(t, err)
// Advanced rule keeps the comment immediately above it.
adv := rules[0]
require.Equal(t, "trusted office", adv.Comment)
require.Equal(t, "10.0.0.0/24", adv.Source)
require.EqualValues(t, 22, adv.Port)
// A bare IPv4 line is one bidirectional DirAny rule carrying the accumulated comment.
host := rules[1]
require.Equal(t, DirAny, host.Direction)
require.Equal(t, "192.0.2.5", host.Source)
require.Equal(t, "unrelated note separated by blank", host.Comment)
// Inline comment is ignored, not treated as a rule comment.
v6 := rules[2]
require.Equal(t, DirAny, v6.Direction)
require.Equal(t, "", v6.Comment)
require.Equal(t, "2001:db8::1", v6.Source)
// Add a rule with a comment: a prefixed full-line comment is written above it.
add := &Rule{Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Accept, Comment: "web"}
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "# myapp web\n")
require.Contains(t, string(data), "tcp:in:d=443:s=192.0.2.10")
// Removing the rule drops the comment line above it as well.
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, true))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "# myapp web")
require.NotContains(t, string(data), "192.0.2.10")
// A port-only rule has nowhere to go in an IP-list file; no dangling
// comment line should be written even when a comment is supplied.
portOnly := &Rule{Proto: TCP, Port: 8080, Action: Accept, Comment: "not-stored"}
require.NoError(t, fw.EditIPList(ctx, path, Accept, portOnly, false))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "not-stored")
// A rule appended after instructional header comments must still report
// HasPrefix: the prefix tag starts a fresh comment block so header
// comments are not absorbed into the rule's comment.
headerPath := filepath.Join(dir, "header_allow_hosts.rules")
require.NoError(t, os.WriteFile(headerPath, []byte(
"# This is the apf allow_hosts.rules file.\n"+
"# Add hosts/rules below, one per line.\n"+
"# Format: proto:flow:port:ip\n",
), 0644))
appendRule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept}
require.NoError(t, fw.EditIPList(ctx, headerPath, Accept, appendRule, false))
parsed, err := fw.ParseIPList(headerPath, Accept)
require.NoError(t, err)
require.Len(t, parsed, 1)
require.True(t, parsed[0].HasPrefix, "rule after header comments must be flagged with the prefix")
require.Equal(t, "", parsed[0].Comment)
}
// TestAPFRemovePreservesForeignHeader verifies that removing a managed rule keeps
// a foreign section header sitting directly above its prefix tag. ParseIPList
// treats the tag as starting a fresh comment block, so the header is not part of
// the rule's comment; removal must mirror that and not delete it.
func TestAPFRemovePreservesForeignHeader(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "allow_hosts.rules")
fw := &APF{rulePrefix: "myapp"}
ctx := context.Background()
require.NoError(t, os.WriteFile(path, []byte(
"# Section: web servers\n"+
"# myapp trusted\n"+
"tcp:in:d=22:s=192.0.2.50/32\n",
), 0644))
require.NoError(t, fw.EditIPList(ctx, path, Accept, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.50/32", Action: Accept}, true))
data, err := os.ReadFile(path)
require.NoError(t, err)
got := string(data)
require.NotContains(t, got, "tcp:in:d=22", "the managed rule must be removed")
require.NotContains(t, got, "# myapp trusted", "the rule's own tag comment is removed with it")
require.Contains(t, got, "# Section: web servers", "the foreign section header must be preserved")
}
// conf.apf's ALL_STOP accepts DROP, REJECT and PROHIBIT (upstream apf builds a
// dedicated PROHIBIT chain that rejects with an ICMP prohibited response). This
// model has no third action, so PROHIBIT must map to Reject like REJECT does,
// not silently fall into the DROP default alongside genuinely unrecognized
// values.
func TestAPFParseStopAction(t *testing.T) {
fw := new(APF)
cases := []struct {
val string
want Action
}{
{"DROP", Drop},
{"REJECT", Reject},
{"PROHIBIT", Reject},
{"prohibit", Reject},
{`"PROHIBIT"`, Reject},
{"", Drop},
{"BOGUS", Drop},
}
for _, c := range cases {
require.Equal(t, c.want, fw.parseStopAction(c.val), "ALL_STOP=%q", c.val)
}
}
// conf.apf's ALL_STOP, TCP_STOP and UDP_STOP are independent settings (upstream
// apf_validate.sh validates each separately with no equality constraint, and
// they may be set differently on a real host). readStopAction/stopKey must
// read the setting matching a deny's protocol, not conflate them, so a fixture
// where they diverge is read back correctly.
func TestAPFReadStopActionIndependentSettings(t *testing.T) {
fw := new(APF)
dir := t.TempDir()
conf := filepath.Join(dir, "conf.apf")
require.NoError(t, os.WriteFile(conf, []byte("ALL_STOP=\"DROP\"\nTCP_STOP=\"REJECT\"\nUDP_STOP=\"DROP\"\n"), 0o644))
require.Equal(t, Drop, fw.readStopAction(conf, "ALL_STOP"))
require.Equal(t, Reject, fw.readStopAction(conf, "TCP_STOP"))
require.Equal(t, Drop, fw.readStopAction(conf, "UDP_STOP"))
require.Equal(t, "ALL_STOP", fw.stopKey(ProtocolAny))
require.Equal(t, "TCP_STOP", fw.stopKey(TCP))
require.Equal(t, "UDP_STOP", fw.stopKey(UDP))
}
// Before this fix, a tcp/udp advanced deny_hosts entry (a port-carrying rule) was
// stamped/matched with the ALL_STOP-derived action for every entry, including
// advanced ones. Upstream apf actually routes an advanced entry through
// trust_entry_rule, which ignores ALL_STOP entirely and applies TCP_STOP/UDP_STOP
// instead (files/internals/apf_trust.sh) — invisible on a stock install where all
// three default to DROP, but wrong whenever an operator sets them differently.
// resolveAction must re-derive the action for a tcp/udp rule rather than
// passing the ALL_STOP-derived base straight through; a bare (protocol-less)
// rule has no such override and keeps the base unchanged.
func TestAPFResolveActionDoesNotConflateAllStopWithProtocolStops(t *testing.T) {
fw := new(APF)
// A bare-address deny has no protocol; ALL_STOP governs it directly.
require.Equal(t, Reject, fw.resolveAction(Reject, ProtocolAny))
// A tcp/udp advanced deny must NOT simply inherit an ALL_STOP-derived base.
// With no real conf.apf in this test environment, TCP_STOP/UDP_STOP resolve
// to their stock default (Drop) — which must differ from the Reject base
// passed in to prove the value was actually re-derived, not passed through.
require.Equal(t, Drop, fw.resolveAction(Reject, TCP))
require.Equal(t, Drop, fw.resolveAction(Reject, UDP))
// Accept (allow_hosts) has no per-protocol distinction.
require.Equal(t, Accept, fw.resolveAction(Accept, TCP))
}
// AddRule's deny-action validation must check a tcp/udp advanced rule against
// TCP_STOP/UDP_STOP, not ALL_STOP: before this fix, a Reject deny with a tcp port
// was rejected as "unsupported" whenever the (irrelevant) ALL_STOP-derived action
// differed from Reject, even though nothing about ALL_STOP governs this entry.
func TestAPFAddRuleValidatesDenyActionPerProtocol(t *testing.T) {
ctx := context.Background()
fw := &APF{}
dir := t.TempDir()
deny := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(deny, nil, 0o644))
// AddRule itself always targets the real APFDeny path, so exercise the same
// validation + write path it uses (denyActionFor + EditIPList) directly
// against a fixture, mirroring AddRule's own logic.
r := &Rule{Action: Drop, Proto: TCP, Port: 8080, Source: "192.0.2.1", Family: IPv4}
denyAction := fw.denyActionFor(r.Proto)
require.Equal(t, Drop, denyAction, "stock/no-conf.apf default for TCP_STOP is Drop")
require.Equal(t, r.Action, denyAction, "a Drop tcp deny must be accepted (matches the TCP_STOP-derived default)")
require.NoError(t, fw.EditIPList(ctx, deny, denyAction, r, false))
got, err := fw.ParseIPList(deny, denyAction)
require.NoError(t, err)
require.Len(t, got, 1)
require.True(t, r.Equal(got[0], false))
}
// apf's deny_hosts list encodes no action of its own, so a rule added with
// Action Drop (routed to deny_hosts) must still be found and removed by the same
// Drop rule — previously the removal matched against the file's deny action and
// silently left the rule in place (an unremovable, leaking rule).
func TestAPFDropRuleRemovable(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, nil, 0644))
fw := new(APF)
drop := &Rule{Family: IPv4, Source: "1.2.3.4", Action: Drop}
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
// A second add of the same rule must be idempotent (no duplicate line).
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, true))
data, _ := os.ReadFile(path)
require.NotContains(t, string(data), "1.2.3.4", "a Drop rule must be removable by the same Drop rule")
}
// A ported apf advanced rule that matches both a source and a destination address
// cannot be expressed (apf's advanced rule holds a single address field) and must
// be rejected rather than silently dropping the destination. The portless bare
// form is not tested here: AddRule diverts it to the raw-iptables hook
// (hostNeedsHook), so the writer never sees it.
func TestAPFDualAddressRejected(t *testing.T) {
fw := new(APF)
_, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept})
require.Error(t, err, "a dual-address apf advanced rule must be rejected")
}
// APF's IG_*_CPORTS lists are dual-stack (one list applied to both v4 and v6), so
// a port rule read from them must be FamilyAny, not IPv4. Otherwise a FamilyAny
// desired rule (the default) never matches its own read-back and Sync churns.
func TestAPFPortListFamilyIsAny(t *testing.T) {
f := new(APF)
rules := f.ParsePorts("22", TCP, false)
require.Len(t, rules, 1)
require.Equal(t, FamilyAny, rules[0].Family,
"a dual-stack CPORTS entry must read back as FamilyAny")
// End to end: a default (FamilyAny) desired rule equals its read-back.
desired := &Rule{Proto: TCP, Port: 22, Action: Accept}
require.True(t, desired.Equal(rules[0], true),
"FamilyAny tcp/22 must equal the APF read-back or Sync churns")
}
// APF's IG_*_CLIMIT lists are likewise dual-stack, so a connection-limit rule
// read from them must be FamilyAny to reconcile with a FamilyAny desired rule.
func TestAPFConnLimitFamilyIsAny(t *testing.T) {
f := new(APF)
rules := f.ParseConnLimit("80:50", TCP)
require.Len(t, rules, 1)
require.Equal(t, FamilyAny, rules[0].Family,
"a dual-stack CLIMIT entry must read back as FamilyAny")
desired := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 50, PerSource: true}}
require.True(t, desired.Equal(rules[0], true),
"FamilyAny connlimit must equal the APF read-back or Sync churns")
}
// TestAPFBareProtocolRoutesToHook is the apf analogue of the csf case: a bare
// protocol match (no port, no address) has no native apf construct but iptables
// expresses it directly, so AddRule diverts it to the pre-hook via needsHook
// rather than rejecting it. A native address-less ICMP accept stays out of the
// hook (it lives in conf.apf's type lists).
func TestAPFBareProtocolRoutesToHook(t *testing.T) {
fw := new(APF)
for _, r := range []*Rule{
{Proto: TCP, Action: Accept},
{Proto: ProtocolAny, Action: Accept},
{Proto: UDP, Action: Drop},
} {
require.True(t, fw.needsHook(r),
"a portless, addressless rule must route to the hook, not be rejected: %+v", r)
}
require.False(t, fw.needsHook(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}),
"a native address-less ICMP accept stays in conf.apf, not the hook")
}
// A port-only deny must not corrupt the rule's family. apf requires an address
// field, so it writes an "any" placeholder — previously the IPv4 literal
// 0.0.0.0/0 regardless of family, so a family-neutral rule read back as IPv4
// (Sync churn) and an IPv6 rule became IPv4-only. The placeholder now matches the
// rule's family (a family-neutral rule covers both, merging back on read).
//
// The rule action is Drop, not Reject: a tcp port-carrying deny_hosts entry is an
// apf "advanced" entry, which apf routes through TCP_STOP (not ALL_STOP) — Drop is
// the stock default for both, and is the value denyActionFor actually resolves
// to here since no real conf.apf exists in this test environment. Using an action
// apf would not actually apply to this entry would make EditIPList reject it.
func TestAPFPortOnlyRejectFamily(t *testing.T) {
fw := new(APF)
ctx := context.Background()
for _, rule := range []*Rule{
{Action: Drop, Proto: TCP, Port: 80},
{Action: Drop, Proto: TCP, Port: 80, Family: IPv4},
{Action: Drop, Proto: TCP, Port: 8080, Family: IPv6},
} {
deny := filepath.Join(t.TempDir(), "deny_hosts.rules")
require.NoError(t, os.WriteFile(deny, nil, 0o644))
require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, false))
// GetRules applies mergeFamilies to the parsed list; mirror it here.
got, err := fw.ParseIPList(deny, Drop)
require.NoError(t, err)
got = mergeFamilies(got)
require.Len(t, got, 1, "port-only deny (%s) must round-trip to one rule", rule.Family)
require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one; want family=%s got family=%s", rule.Family, got[0].Family)
// It must also be removable (matched back on delete).
require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, true))
got, err = fw.ParseIPList(deny, Drop)
require.NoError(t, err)
require.Len(t, got, 0, "rule (%s) must be fully removed", rule.Family)
}
}
// A bare all-protocol host rule (address, no port) is the one portless address
// shape apf's trust files express, written as the plain address line. The
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
// are diverted to the hook by AddRule (hostNeedsHook) and never reach this
// writer, so only the legitimate write is exercised here.
func TestAPFBareHostWritten(t *testing.T) {
fw := new(APF)
ctx := context.Background()
list := filepath.Join(t.TempDir(), "allow_hosts.rules")
require.NoError(t, os.WriteFile(list, nil, 0o644))
require.NoError(t, fw.EditIPList(ctx, list, Accept, &Rule{Source: "1.2.3.4", Action: Accept}, false))
got, err := os.ReadFile(list)
require.NoError(t, err)
require.Contains(t, string(got), "1.2.3.4", "an any-protocol host rule must be written as a plain address")
}
// needsHook selects the multi-port lists apf's config cannot express, so
// AddRule diverts them to the hook. A single port or one range is a valid apf
// port token (stays native); an address-less tcp/udp accept list lives in
// conf.apf's CPORTS lists; a non-tcp/udp match is left to iptablesRuleValid.
func TestAPFPortNeedsHook(t *testing.T) {
fw := new(APF)
list := []PortRange{{Start: 1000, End: 1000}, {Start: 2000, End: 2000}}
cases := []struct {
name string
rule *Rule
want bool
}{
{"multi-port deny", &Rule{Proto: TCP, Ports: list, Action: Reject}, true},
{"multi-port host accept", &Rule{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, true},
{"multi-source-port host", &Rule{Proto: UDP, SourcePorts: list, Source: "1.2.3.4", Action: Accept}, true},
{"address-less accept list", &Rule{Proto: TCP, Ports: list, Action: Accept}, false},
{"single port deny", &Rule{Proto: TCP, Port: 1000, Action: Reject}, false},
{"single range host", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Source: "1.2.3.4", Action: Accept}, false},
{"multi-port any-proto", &Rule{Ports: list, Action: Reject}, false},
}
for _, c := range cases {
require.Equal(t, c.want, fw.needsHook(c.rule), c.name)
}
}
// APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an
// existing IPv4 port-only deny; the family-agnostic EqualBase check previously
// treated the IPv4 line as covering IPv6 and wrote nothing, leaving IPv6 open.
func TestAPFCrossFamilyDenyAddsMissingFamily(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, []byte("tcp:in:d=80:s=0.0.0.0/0\n"), 0o644))
f := &APF{}
r := &Rule{Proto: TCP, Port: 80, Direction: DirInput, Family: IPv6, Action: Drop}
require.NoError(t, f.EditIPList(context.Background(), path, Drop, r, false))
out, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(out), "::", "an IPv6 (::/0) deny line must be written so IPv6 port 80 is blocked")
require.Contains(t, string(out), "0.0.0.0/0", "the existing IPv4 deny must be preserved")
}
// APF RemoveRule of an IPv4-pinned port-only deny must not take out the IPv6 twin:
// EqualForRemoval gates the family so removing one family keeps the other.
func TestAPFCrossFamilyRemoveKeepsOppositeFamily(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, []byte("tcp:in:d=80:s=0.0.0.0/0\ntcp:in:d=80:s=[::/0]\n"), 0o644))
f := &APF{}
// Remove only the IPv4 port-80 deny.
r := &Rule{Proto: TCP, Port: 80, Direction: DirInput, Family: IPv4, Action: Drop}
require.NoError(t, f.EditIPList(context.Background(), path, Drop, r, true))
out, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(out), "[::/0]", "the IPv6 deny must survive removing the IPv4 twin")
require.NotContains(t, string(out), "0.0.0.0/0", "the IPv4 deny must be removed")
}
// Editing an existing connection-limit entry's count must record a config change
// so Reload runs apf --restart and the new limit is applied; an unchanged count
// must not trigger a spurious restart.
func TestAPFConnLimitCountChangeReloads(t *testing.T) {
fw := new(APF)
// Changing the count from 50 to 25 must flag a config change.
fw.ConfigChanged = false
out := fw.editConnLimit("IG_TCP_CLIMIT", "80:50",
&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
require.Equal(t, `IG_TCP_CLIMIT="80:25"`, out)
require.True(t, fw.ConfigChanged, "a changed connlimit count must set ConfigChanged")
// Re-applying the same count must not flag a change.
fw.ConfigChanged = false
out = fw.editConnLimit("IG_TCP_CLIMIT", "80:25",
&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
require.Equal(t, `IG_TCP_CLIMIT="80:25"`, out)
require.False(t, fw.ConfigChanged, "an unchanged connlimit count must not set ConfigChanged")
}