819 lines
38 KiB
Go
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")
|
|
}
|