package firewall import ( "context" "os" "path/filepath" "strings" "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 := fw.MarshalAdvRule(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) // The shapes an advanced line cannot hold never reach MarshalAdvRule: needsHook // routes an addressed ICMP rule and a multi-port list to the raw-iptables hook, // and an address-less port accept lands in conf.apf's CPORTS lists instead. routed := []*Rule{ {Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept}, {Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Action: Accept}, } for _, r := range routed { require.True(t, fw.needsHook(r), "expected %+v to be routed to the hook, not marshalled", *r) } require.True(t, fw.isConfRule(&Rule{Proto: TCP, Port: 80, Action: Accept}), "an address-less port accept belongs in conf.apf, not an advanced line") } // 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 := fw.MarshalAdvRule(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) } // The single port-flow field holds one port match, so a source port matched with // a destination port has no advanced-line form and routes to the hook instead. require.True(t, fw.needsHook(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}), "a dual-port rule must route to the hook") // A source-port rule with no address has no advanced-rule form either (an advanced // line requires an address), 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), so AddRule // routes it to the raw-iptables hook rather than silently dropping the destination. // The portless bare form takes the same path one predicate earlier (hostNeedsHook). func TestAPFDualAddressRouted(t *testing.T) { fw := new(APF) require.True(t, fw.needsHook(&Rule{Proto: TCP, Port: 22, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}), "a ported dual-address rule must be routed to the hook, never marshalled as an advanced line") } // 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 covers a bare protocol match (no port, no // address): it 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) { // IPv6 enabled, so a family-neutral deny fans out to both families (see writeFamilyRows). fw := &APF{ipv6Enabled: true} 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 port-only deny fans out across family, but the file may already hold a subset of // those lines — a prior single-family add, a manual edit, or the same rule added // twice by a reconcile. The add must note each fan-out line present and write only // the rest. Regression for the single-"exists" gate: no one line covers a // family-neutral target, so re-adding it duplicated the whole fan-out, and a target // whose IPv4 line already existed had its IPv6 twin left unwritten. func TestAPFPortOnlyDenyHealsAndDoesNotDuplicate(t *testing.T) { ctx := context.Background() // IPv6 enabled, so the deny fans out to an IPv4 and an IPv6 line. fw := &APF{ipv6Enabled: true} dir := t.TempDir() // Adding the same family-neutral deny twice must leave one line per family. path := filepath.Join(dir, "deny_hosts.rules") require.NoError(t, os.WriteFile(path, nil, 0o644)) deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop} require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false)) require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false)) data, err := os.ReadFile(path) require.NoError(t, err) text := string(data) require.Equal(t, 1, strings.Count(text, "tcp:in:d=80:s=0.0.0.0/0"), "re-adding the rule must not duplicate the IPv4 line") require.Equal(t, 1, strings.Count(text, "tcp:in:d=80:s=[::/0]"), "re-adding the rule must not duplicate the IPv6 line") // A file holding only the IPv4 line must gain the missing IPv6 one, so IPv6:80 is // actually blocked rather than reported blocked while open. path2 := filepath.Join(dir, "deny_hosts2.rules") require.NoError(t, os.WriteFile(path2, []byte("tcp:in:d=80:s=0.0.0.0/0\n"), 0o644)) require.NoError(t, fw.EditIPList(ctx, path2, Drop, deny, false)) data2, err := os.ReadFile(path2) require.NoError(t, err) text2 := string(data2) require.Equal(t, 1, strings.Count(text2, "tcp:in:d=80:s=0.0.0.0/0"), "the pre-existing IPv4 line must be preserved, not duplicated") require.Equal(t, 1, strings.Count(text2, "tcp:in:d=80:s=[::/0]"), "the missing IPv6 line must be added") } // 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 := fw.MarshalAdvRule(r) 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 = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "192.0.2.1", Action: Accept}) 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. It never reaches the // marshaller — a port on ProtocolAny is inexpressible in iptables too, so addRule // rejects it (iptablesRuleValid) before EditIPList runs, and needsHook leaves it // alone rather than sending an unmarshallable rule to the hook. anyProto := &Rule{Proto: ProtocolAny, Port: 80, Source: "192.0.2.1", Action: Accept} require.False(t, fw.needsHook(anyProto), "a port on ProtocolAny must not reach the hook") require.Error(t, iptablesRuleValid(anyProto), "a port on ProtocolAny must be rejected before marshalling") } // 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})) } // With conf.apf's USE_IPV6 off, apf installs no IPv6 rule from its config, so a // family-neutral port-only deny must be written as the IPv4 line alone (see // writeFamilyRows). Removal still sweeps both families. func TestAPFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) { ctx := context.Background() dir := t.TempDir() off := new(APF) path := filepath.Join(dir, "deny_hosts.rules") require.NoError(t, os.WriteFile(path, nil, 0o644)) deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop} require.NoError(t, off.EditIPList(ctx, path, Drop, deny, false)) data, err := os.ReadFile(path) require.NoError(t, err) require.Contains(t, string(data), "tcp:in:d=80:s=0.0.0.0/0", "the IPv4 line must be written") require.NotContains(t, string(data), "[::/0]", "no IPv6 line may be written while apf's IPv6 handling is off") got, err := off.ParseIPList(path, Drop) require.NoError(t, err) require.Len(t, got, 1) require.Equal(t, IPv4, got[0].impliedFamily()) // Switching IPv6 off must not strand the v6 line written while it was on. on := &APF{ipv6Enabled: true} bothPath := filepath.Join(dir, "deny_hosts.both.rules") require.NoError(t, os.WriteFile(bothPath, nil, 0o644)) require.NoError(t, on.EditIPList(ctx, bothPath, Drop, deny, false)) data, err = os.ReadFile(bothPath) require.NoError(t, err) require.Contains(t, string(data), "[::/0]") require.NoError(t, off.EditIPList(ctx, bothPath, Drop, deny, true)) data, err = os.ReadFile(bothPath) require.NoError(t, err) require.NotContains(t, string(data), "d=80", "removal must sweep the stale IPv6 line even with IPv6 off") }