go-firewall/apf_linux_test.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

939 lines
44 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 target has 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 a family-agnostic removal 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}))
}
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, and a family-neutral rule writes one line per family: two rows that
// cover the rule between them.
//
// 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))
// A concrete-family rule is one line; a family-neutral one is a line per family.
// Either way the rows read back cover exactly the rule that was written.
wantRows := 1
if rule.impliedFamily() == FamilyAny {
wantRows = 2
}
got, err := fw.ParseIPList(deny, Drop)
require.NoError(t, err)
require.Len(t, got, wantRows, "port-only deny (%s) must round-trip to %d row(s)", rule.Family, wantRows)
require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule; want family=%s", rule.Family)
for _, g := range got {
require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g)
}
// 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")
}
// TestAPFTCPUDPCPortsFanOut covers the write half of the both-transports port accept:
// conf.apf's CPORTS lists are per-transport, so a TCPUDP port must be added to (and
// removed from) both. Regression: EditRulePort keyed on `r.Proto != TCP` / `!= UDP`,
// so a both-transports rule matched neither list and the port was never opened.
func TestAPFTCPUDPCPortsFanOut(t *testing.T) {
fw := new(APF)
r := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirInput}
require.Equal(t, `IG_TCP_CPORTS="22,80"`,
fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22", r, false),
"a tcpudp port must be added to the tcp list")
require.Equal(t, `IG_UDP_CPORTS="53,80"`,
fw.EditRulePort(`IG_UDP_CPORTS="53"`, "IG_UDP_CPORTS", "53", r, false),
"a tcpudp port must be added to the udp list")
// Removal clears it from both lists.
require.Equal(t, `IG_TCP_CPORTS="22"`,
fw.EditRulePort(`IG_TCP_CPORTS="22,80"`, "IG_TCP_CPORTS", "22,80", r, true))
require.Equal(t, `IG_UDP_CPORTS="53"`,
fw.EditRulePort(`IG_UDP_CPORTS="53,80"`, "IG_UDP_CPORTS", "53,80", r, true))
// A concrete transport still touches only its own list.
tcp := &Rule{Proto: TCP, Port: 80, Action: Accept, Direction: DirInput}
require.Equal(t, `IG_UDP_CPORTS="53"`,
fw.EditRulePort(`IG_UDP_CPORTS="53"`, "IG_UDP_CPORTS", "53", tcp, false),
"a tcp rule must not open the udp port")
// An outbound rule touches only the egress lists.
out := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirOutput}
require.Equal(t, `IG_TCP_CPORTS="22"`,
fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22", out, false))
require.Equal(t, `EG_TCP_CPORTS="22,80"`,
fw.EditRulePort(`EG_TCP_CPORTS="22"`, "EG_TCP_CPORTS", "22", out, false))
}
// TestAPFTCPUDPCPortsReadBack covers the read half: apf's CPORTS lists are keyed per
// transport, so a TCPUDP port is one entry in each and reads back as one rule per
// list — two dual-stack rules that together cover the TCPUDP rule that was written.
// A port in only one list covers only its own transport.
func TestAPFTCPUDPCPortsReadBack(t *testing.T) {
fw := new(APF)
rules := append(fw.ParsePorts("80", TCP, false), fw.ParsePorts("80", UDP, false)...)
require.Len(t, rules, 2, "the two lists parse independently")
for _, r := range rules {
require.Equal(t, FamilyAny, r.Family, "a CPORTS entry is dual-stack")
}
both := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirInput}
require.True(t, both.CoveredBy(rules), "the tcp+udp CPORTS entries cover the TCPUDP rule")
// A port in only one list leaves the other transport uncovered.
tcpOnly := fw.ParsePorts("80", TCP, false)
require.Len(t, tcpOnly, 1)
require.Equal(t, TCP, tcpOnly[0].Proto)
require.False(t, both.CoveredBy(tcpOnly), "the tcp entry alone must not cover a TCPUDP rule")
require.True(t, (&Rule{Proto: TCP, Port: 80, Action: Accept, Direction: DirInput}).CoveredBy(tcpOnly))
}
// TestAPFTCPUDPHookRoundTrip covers the rule shape that routes to the pre-hook (a
// port match that also carries connection state). The hook has no both-transports
// iptables form, so it fans the rule into a tcp line and a udp line, which read back
// as two rules covering the one that was written. One remove clears both.
func TestAPFTCPUDPHookRoundTrip(t *testing.T) {
fw := new(APF)
r := &Rule{Family: IPv4, Proto: TCPUDP, Port: 80, State: StateEstablished, Action: Accept, Direction: DirInput}
require.True(t, fw.needsHook(r), "a stateful port match has no native apf form")
h := &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(t.TempDir(), "apfpre.sh"),
hookPerm: 0700,
}
changed, err := h.edit(r, false)
require.NoError(t, err)
require.True(t, changed)
raw, err := h.getRules()
require.NoError(t, err)
require.Len(t, raw, 2, "the hook fans a tcpudp rule into a tcp and a udp line")
require.True(t, r.CoveredBy(raw), "the fanned pair must cover the rule that was written")
for _, g := range raw {
require.True(t, r.Covers(g), "a fanned line must not widen the rule: %+v", g)
}
// One remove clears both lines.
changed, err = h.edit(r, true)
require.NoError(t, err)
require.True(t, changed)
raw, err = h.getRules()
require.NoError(t, err)
require.Empty(t, raw)
}
// TestAPFTCPUDPAdvRule: apf's advanced rule treats a missing protocol field as both
// transports (its trust parser derives a -p tcp and a -p udp rule from it), so TCPUDP
// is written by omitting the field and must read back as TCPUDP — never ProtocolAny,
// which would claim every IP protocol is matched.
func TestAPFTCPUDPAdvRule(t *testing.T) {
fw := new(APF)
r := &Rule{Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Action: Accept, Direction: DirInput}
line, err := fw.MarshalAdvRule(r)
require.NoError(t, err)
require.Equal(t, "in:d=80:s=192.0.2.1", line, "the protocol field is omitted for both transports")
back := fw.ParseAdvRule(line, Accept)
require.NotNil(t, back)
require.Equal(t, TCPUDP, back.Proto, "a protocol-less advanced line is tcp+udp, not every protocol")
require.True(t, back.EqualBase(r, true))
// A concrete transport names itself and round-trips unchanged.
line, err = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "192.0.2.1", Action: Accept})
require.NoError(t, err)
require.Equal(t, "tcp:in:d=80:s=192.0.2.1", line)
require.Equal(t, TCP, fw.ParseAdvRule(line, Accept).Proto)
// ProtocolAny has no advanced-rule form: the omitted field means tcp+udp, not
// every protocol, so emitting it would under-apply the rule.
_, err = fw.MarshalAdvRule(&Rule{Proto: ProtocolAny, Port: 80, Source: "192.0.2.1", Action: Accept})
require.ErrorIs(t, err, ErrUnsupported)
}
// TestAPFTCPUDPRouting pins where a both-transports rule goes. A FamilyAny bare port
// accept is native (both CPORTS lists are dual-stack); a single-family one, a
// multi-port one, and an address-less source-port match have no native form.
func TestAPFTCPUDPRouting(t *testing.T) {
fw := new(APF)
require.True(t, fw.barePortAccept(&Rule{Proto: TCPUDP, Port: 80, Action: Accept}))
require.False(t, fw.needsHook(&Rule{Proto: TCPUDP, Port: 80, Action: Accept}),
"a dual-stack tcpudp port accept lives in the CPORTS lists")
require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Accept}),
"a single-family tcpudp port has no dual-stack CPORTS form")
// An address-less multi-port accept is native: the CPORTS lists are comma lists.
// The same ports against an address are not — apf's advanced rule holds one port.
require.False(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}),
"a dual-stack tcpudp port list lives in the CPORTS lists")
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}},
Source: "192.0.2.1", Action: Accept}),
"apf's advanced rule carries no port list")
require.True(t, fw.needsHook(&Rule{Proto: TCPUDP, SourcePort: 80, Action: Accept}),
"an address-less source-port match has no advanced-rule form")
// A portless tcpudp host has no plain-line form (a plain line is all-protocol).
require.True(t, hostNeedsHook(&Rule{Proto: TCPUDP, Source: "192.0.2.1", Action: Accept}))
}