This commit is contained in:
James Coleman 2026-07-08 16:09:57 -05:00
parent 98aa68f7ed
commit 9b747f7acb
3 changed files with 135 additions and 224 deletions

View file

@ -117,14 +117,11 @@ func NewAPF(ctx context.Context, rulePrefix string) (*APF, error) {
return apf, nil
}
// Type returns the backend identifier for apf.
// Type returns the backend identifier for apf.
func (f *APF) Type() string {
return APFType
}
// hook returns the managed pre-hook script used to inject iptables rules for
// features apf's native config cannot express.
// Capabilities reports the firewall features apf supports.
func (f *APF) Capabilities() Capabilities {
return Capabilities{
@ -152,14 +149,11 @@ func (f *APF) Capabilities() Capabilities {
}
}
// GetDefaultPolicy is unsupported: apf has no managed default-policy control.
// GetZone returns no zone; apf has no zone support.
func (f *APF) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
return "", nil
}
// parsePortToken parses a single apf port token: a port or an underscore
// range (e.g. "6000_7000").
// parsePortToken parses a single apf port token: a port or an underscore
// range (e.g. "6000_7000").
func (f *APF) parsePortToken(tok string) (PortRange, error) {
@ -182,8 +176,6 @@ func (f *APF) parsePortToken(tok string) (PortRange, error) {
return pr, nil
}
// portToken renders a port spec in apf notation: a port or an underscore
// range.
// ParseConnLimit decodes a conf.apf IG_TCP_CLIMIT/IG_UDP_CLIMIT value
// ("port:limit,...", port may be an underscore range) into connection-limit
// rules: apf caps concurrent connections per source and rejects the excess, so
@ -222,8 +214,6 @@ func (f *APF) ParseConnLimit(val string, proto Protocol) (rules []*Rule) {
return
}
// editConnLimit renders a CLIMIT config line with a rule's "port:limit" entry
// added or removed, preserving the other entries, and records a config change.
// ParseICMPTypes decodes an apf ICMP type list (IG_ICMP_TYPES/EG_ICMP_TYPES for
// proto ICMP, IG_ICMPV6_TYPES/EG_ICMPV6_TYPES for proto ICMPv6) into accept rules,
// one per type. The "all" wildcard (which apf applies as a typeless `-p icmp -j
@ -251,8 +241,6 @@ func (f *APF) ParseICMPTypes(val string, proto Protocol, out bool) (rules []*Rul
return
}
// splitAdvFields splits an apf advanced rule on ':' while leaving colons inside
// bracketed IPv6 addresses (e.g. [2001:db8::1]) intact.
// parseAddr parses an apf address value, stripping the bracket notation used
// to protect IPv6 addresses, and normalizing a zero-network to the empty (any)
// address. It reports the family, or false when the value is not an address.
@ -279,8 +267,6 @@ func (f *APF) parseAddr(v string) (addr string, fam Family, ok bool) {
return v, family, true
}
// addrField renders an address for an advanced rule, wrapping an IPv6 address
// in brackets so it survives the colon-separated field format.
// parseStopAction maps a conf.apf ALL_STOP/TCP_STOP/UDP_STOP value to the
// action apf actually applies. "DROP", "REJECT" and "PROHIBIT" are valid;
// anything else (including empty) falls back to the stock default of DROP.
@ -297,15 +283,6 @@ func (f *APF) parseStopAction(val string) Action {
}
}
// stopKey names the conf.apf setting apf actually applies to a deny of the
// given protocol. A bare-address deny_hosts entry (no protocol/port) is dropped
// by trust_hosts's own bare-host branch, which applies ALL_STOP directly. An
// advanced entry (proto:flow:s/d=port:s/d=ip) is instead routed through
// trust_entry_rule, which ignores ALL_STOP entirely and applies TCP_STOP for a
// tcp entry or UDP_STOP for a udp one (files/internals/apf_trust.sh). The three
// settings default to DROP and are otherwise fully independent, so an entry read
// or matched under the wrong key can report or accept the wrong action whenever
// an operator sets them differently.
// readStopAction reads the named STOP setting (ALL_STOP/TCP_STOP/UDP_STOP)
// from a conf.apf-format file, defaulting to the stock DROP when the key is
// absent or the file cannot be read. path is a parameter (rather than always
@ -336,11 +313,6 @@ func (f *APF) readStopAction(path, key string) Action {
return action
}
// denyActionFor reads conf.apf's setting for the given protocol (see
// stopKey) and returns the action apf actually applies to a deny of that
// protocol. deny_hosts encodes no action of its own, so a rule read back must
// be stamped with what apf actually applies — otherwise a Drop rule reads back
// as Reject, never compares equal to the desired rule, and churns on every Sync.
// stopKey names the conf.apf setting apf actually applies to a deny of the
// given protocol. A bare-address deny_hosts entry (no protocol/port) is dropped
// by trust_hosts's own bare-host branch, which applies ALL_STOP directly. An
@ -361,26 +333,11 @@ func (f *APF) stopKey(proto Protocol) string {
}
}
// readStopAction reads the named STOP setting (ALL_STOP/TCP_STOP/UDP_STOP)
// from a conf.apf-format file, defaulting to the stock DROP when the key is
// absent or the file cannot be read. path is a parameter (rather than always
// APFConf) so this can be exercised against a fixture file in tests.
// denyActionFor reads conf.apf's setting for the given protocol (see
// stopKey) and returns the action apf actually applies to a deny of that
// protocol. deny_hosts encodes no action of its own, so a rule read back must
// be stamped with what apf actually applies — otherwise a Drop rule reads back
// as Reject, never compares equal to the desired rule, and churns on every Sync.
func (f *APF) denyActionFor(proto Protocol) Action {
return f.readStopAction(APFConf, f.stopKey(proto))
}
// resolveAction resolves the action to stamp on a rule parsed from — or
// matched against — allow_hosts/deny_hosts. base is Accept for allow_hosts
// (returned unchanged, since parseStopAction never yields Accept and
// allow_hosts has no per-protocol distinction) or the ALL_STOP-derived action
// for deny_hosts. A deny_hosts entry in apf's advanced syntax with a concrete
// tcp or udp protocol is not governed by ALL_STOP (see stopKey), so its
// action is re-derived from the matching TCP_STOP/UDP_STOP setting instead.
// resolveAction resolves the action to stamp on a rule parsed from — or
// matched against — allow_hosts/deny_hosts. base is Accept for allow_hosts
// (returned unchanged, since parseStopAction never yields Accept and
@ -400,8 +357,6 @@ func (f *APF) resolveAction(base Action, proto Protocol) Action {
}
}
// ParseAdvRule decodes an apf advanced allow/deny rule of the form
// proto:flow:s/d=port:s/d=ip. IPv6 addresses use bracket notation.
// splitAdvFields splits an apf advanced rule on ':' while leaving colons inside
// bracketed IPv6 addresses (e.g. [2001:db8::1]) intact.
func (f *APF) splitAdvFields(s string) []string {
@ -425,9 +380,6 @@ func (f *APF) splitAdvFields(s string) []string {
return append(fields, s[start:])
}
// parseAddr parses an apf address value, stripping the bracket notation used
// to protect IPv6 addresses, and normalizing a zero-network to the empty (any)
// address. It reports the family, or false when the value is not an address.
// ParseAdvRule decodes an apf advanced allow/deny rule of the form
// proto:flow:s/d=port:s/d=ip. IPv6 addresses use bracket notation.
func (f *APF) ParseAdvRule(val string, action Action) (r *Rule) {
@ -483,8 +435,6 @@ func (f *APF) ParseAdvRule(val string, action Action) (r *Rule) {
return
}
// MarshalAdvRule encodes a rule as an apf advanced allow/deny line. An apf
// advanced rule is tcp/udp only and must carry a source or destination address.
// ParseIPList reads an apf allow_hosts/deny_hosts file and returns the rules it holds.
func (f *APF) ParseIPList(filePath string, action Action) (rules []*Rule, err error) {
// Read the allow_hosts/deny_hosts rule list.
@ -574,8 +524,6 @@ func (f *APF) ParseIPList(filePath string, action Action) (rules []*Rule, err er
return
}
// cPortsKey returns the conf.apf CPORTS list a tcp/udp port rule of the given
// direction lives in, or "" for a protocol with no such list.
// ParsePorts decodes an apf comma-separated port list into accept rules, one per port entry.
func (f *APF) ParsePorts(val string, proto Protocol, out bool) (rules []*Rule) {
for _, port := range strings.Split(val, ",") {
@ -604,10 +552,6 @@ func (f *APF) ParsePorts(val string, proto Protocol, out bool) (rules []*Rule) {
return
}
// ParseICMPTypes decodes an apf ICMP type list (IG_ICMP_TYPES/EG_ICMP_TYPES for
// proto ICMP, IG_ICMPV6_TYPES/EG_ICMPV6_TYPES for proto ICMPv6) into accept rules,
// one per type. The "all" wildcard (which apf applies as a typeless `-p icmp -j
// ACCEPT`) becomes a rule with a nil ICMPType, matching every type.
// hook returns the managed pre-hook script used to inject iptables rules for
// features apf's native config cannot express.
func (f *APF) hook() *hookScript {
@ -618,7 +562,6 @@ func (f *APF) hook() *hookScript {
}
}
// GetZone returns no zone; apf has no zone support.
// GetRules returns the current filter rules read from conf.apf, the allow/deny lists, and the pre-hook.
func (f *APF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
// Read rules from conf.apf
@ -727,11 +670,6 @@ func (f *APF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err
return
}
// removeBareHostOneWay removes a one-way bare-address host rule. Such a rule is
// stored either as its own hook rule or as one direction of a bidirectional plain
// allow_hosts/deny_hosts line (a DirAny rule). When a matching plain line exists,
// split it: drop the line and re-add the surviving opposite direction as a hook rule
// so the untargeted direction keeps its coverage.
// portToken renders a port spec in apf notation: a port or an underscore
// range.
func (f *APF) portToken(pr PortRange) string {
@ -742,7 +680,6 @@ func (f *APF) portToken(pr PortRange) string {
return fmt.Sprintf("%d_%d", pr.Start, pr.End)
}
// ParsePorts decodes an apf comma-separated port list into accept rules, one per port entry.
// editConnLimit renders a CLIMIT config line with a rule's "port:limit" entry
// added or removed, preserving the other entries, and records a config change.
func (f *APF) editConnLimit(key, val string, r *Rule, remove bool) string {
@ -780,9 +717,6 @@ func (f *APF) editConnLimit(key, val string, r *Rule, remove bool) string {
return fmt.Sprintf(`%s="%s"`, key, strings.Join(kept, ","))
}
// icmpTokens returns the conf.apf type token(s) an icmp/icmpv6 accept rule
// contributes to its type list: the numeric type, or "all" when the rule matches
// every type (a nil ICMPType, which apf applies as a typeless `-p icmp -j ACCEPT`).
// icmpTokens returns the conf.apf type token(s) an icmp/icmpv6 accept rule
// contributes to its type list: the numeric type, or "all" when the rule matches
// every type (a nil ICMPType, which apf applies as a typeless `-p icmp -j ACCEPT`).
@ -793,7 +727,6 @@ func (f *APF) icmpTokens(r *Rule) []string {
return []string{strconv.Itoa(int(*r.ICMPType))}
}
// portTokens renders the rule's ports as apf config tokens.
// isConnLimitRule reports whether a rule maps onto conf.apf's
// IG_TCP_CLIMIT/IG_UDP_CLIMIT: a per-source cap on concurrent inbound
// connections to a single tcp/udp port (or range) with no address, rejecting the
@ -805,10 +738,6 @@ func (f *APF) isConnLimitRule(r *Rule) bool {
len(r.PortSpecs()) == 1 && r.Action == Reject
}
// ParseConnLimit decodes a conf.apf IG_TCP_CLIMIT/IG_UDP_CLIMIT value
// ("port:limit,...", port may be an underscore range) into connection-limit
// rules: apf caps concurrent connections per source and rejects the excess, so
// each entry becomes an inbound reject rule carrying a per-source ConnLimit.
// portTokens renders the rule's ports as apf config tokens.
func (f *APF) portTokens(r *Rule) []string {
specs := r.PortSpecs()
@ -819,7 +748,6 @@ func (f *APF) portTokens(r *Rule) []string {
return tokens
}
// EditRulePort returns the conf.apf line with the rule's tokens added to or removed from the list that key manages.
// EditRulePort returns the conf.apf line with the rule's tokens added to or removed from the list that key manages.
func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
// A connection-limit rule is expressed solely through the CLIMIT config; it
@ -966,7 +894,6 @@ func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
return fmt.Sprintf(`%s="%s"`, key, strings.Join(kept, ","))
}
// EditConf adds or removes a rule in conf.apf, rewriting the file in place.
// EditConf adds or removes a rule in conf.apf, rewriting the file in place.
func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
// For port only rules, open the standard config file.
@ -1030,13 +957,6 @@ func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error {
return af.Commit()
}
// nativeICMPv6 reports whether an ICMPv6 rule can be carried by apf's native
// IG_ICMPV6_TYPES/EG_ICMPV6_TYPES lists (an address-less accept, optionally typed)
// and so belongs in conf.apf rather than the raw-iptables hook. The shared
// ruleNeedsHook diverts every ICMPv6 rule to the hook — correct for csf, which has
// no native v6 type list — so apf overrides that only for the rules its config can
// actually express, leaving an ICMPv6 rule that also needs state/interface/log/
// rate matching (which conf.apf cannot carry) on the hook path.
// addrField renders an address for an advanced rule, wrapping an IPv6 address
// in brackets so it survives the colon-separated field format.
func (f *APF) addrField(addr string) string {
@ -1046,13 +966,6 @@ func (f *APF) addrField(addr string) string {
return addr
}
// parseStopAction maps a conf.apf ALL_STOP/TCP_STOP/UDP_STOP value to the
// action apf actually applies. "DROP", "REJECT" and "PROHIBIT" are valid;
// anything else (including empty) falls back to the stock default of DROP.
// PROHIBIT jumps to apf's own PROHIBIT chain, which rejects with an ICMP
// (un)reachable-prohibited response — the same reject-like semantics as
// REJECT, just a different ICMP code — so it maps to Reject rather than Drop;
// this model has no third action to give it.
// MarshalAdvRule encodes a rule as an apf advanced allow/deny line. An apf
// advanced rule is tcp/udp only and must carry a source or destination address.
func (f *APF) MarshalAdvRule(r *Rule) (string, error) {
@ -1105,7 +1018,6 @@ func (f *APF) MarshalAdvRule(r *Rule) (string, error) {
return strings.Join(parts, ":"), nil
}
// ParseIPList reads an apf allow_hosts/deny_hosts file and returns the rules it holds.
// EditIPList adds or removes a rule in an apf allow_hosts/deny_hosts file, rewriting it in place.
func (f *APF) EditIPList(ctx context.Context, filePath string, action Action, r *Rule, remove bool) error {
// Read the allow_hosts/deny_hosts rule list.
@ -1335,8 +1247,6 @@ func (f *APF) EditIPList(ctx context.Context, filePath string, action Action, r
return af.Commit()
}
// removePlainHost drops the bidirectional plain allow_hosts/deny_hosts line backing
// the DirAny rule e, choosing the list by the rule's action.
// isConfRule reports whether a rule is managed in conf.apf: an address-less
// accept rule of ports (TCP/UDP lists) or ICMP/ICMPv6 types (a nil type is the
// "all" wildcard).
@ -1347,10 +1257,6 @@ func (f *APF) isConfRule(r *Rule) bool {
return r.HasPorts() || r.Proto == ICMP || r.Proto == ICMPv6
}
// isConnLimitRule reports whether a rule maps onto conf.apf's
// IG_TCP_CLIMIT/IG_UDP_CLIMIT: a per-source cap on concurrent inbound
// connections to a single tcp/udp port (or range) with no address, rejecting the
// excess.
// nativeICMPv6 reports whether an ICMPv6 rule can be carried by apf's native
// IG_ICMPV6_TYPES/EG_ICMPV6_TYPES lists (an address-less accept, optionally typed)
// and so belongs in conf.apf rather than the raw-iptables hook. The shared
@ -1363,12 +1269,6 @@ func (f *APF) nativeICMPv6(r *Rule) bool {
!r.Log && r.RateLimit == nil && f.isConfRule(r)
}
// ipv6Unavailable reports whether adding r would silently write something
// apf itself never enforces: a bare IPv6 host in allow_hosts.rules/
// deny_hosts.rules, or a native ICMPv6 type, are both no-op'd by apf's own
// shell logic when conf.apf's USE_IPV6 is not "1". A rule diverted to the
// raw-iptables hook is unaffected — the hook runs ip6tables directly, outside
// apf's USE_IPV6-gated logic — so this only applies to the two native paths.
// ipv6Unavailable reports whether adding r would silently write something
// apf itself never enforces: a bare IPv6 host in allow_hosts.rules/
// deny_hosts.rules, or a native ICMPv6 type, are both no-op'd by apf's own
@ -1385,12 +1285,6 @@ func (f *APF) ipv6Unavailable(r *Rule) bool {
return r.impliedFamily() == IPv6 && (r.Source != "" || r.Destination != "")
}
// barePortAccept reports whether a rule is an address-less tcp/udp port accept —
// the shape apf stores either in a dual-stack conf.apf CPORTS list (a FamilyAny
// port) or, per family, through the raw-iptables hook (a single-family port, or a
// FamilyAny that GetRules merged back from a v4+v6 hook pair). Both the add-time hook
// decision (dualStackPortNeedsHook) and the remove-time split/clear
// (removeDualStackPort) build on it.
// barePortAccept reports whether a rule is an address-less tcp/udp port accept —
// the shape apf stores either in a dual-stack conf.apf CPORTS list (a FamilyAny
// port) or, per family, through the raw-iptables hook (a single-family port, or a
@ -1402,14 +1296,6 @@ func (f *APF) barePortAccept(r *Rule) bool {
r.Source == "" && r.Destination == "" && r.Action == Accept
}
// dualStackPortNeedsHook reports whether a bare tcp/udp port accept pinned to a
// single family must be injected through the hook. apf's IG_*_CPORTS/EG_*_CPORTS
// lists are dual-stack — one list applied to both the ip and ip6 tables — so they
// express only a FamilyAny port (unlike csf, whose TCP_IN/TCP6_IN split the
// families). A single-family one is written per-family through the hook instead,
// whose iptables (or ip6tables) rule carries just that family; removing one family
// of a FamilyAny CPORTS entry splits it (see removeDualStackPort). ICMP keeps its
// concrete family (its type lists are per-family), so this gates on a port match.
// dualStackPortNeedsHook reports whether a bare tcp/udp port accept pinned to a
// single family must be injected through the hook. apf's IG_*_CPORTS/EG_*_CPORTS
// lists are dual-stack — one list applied to both the ip and ip6 tables — so they
@ -1422,15 +1308,6 @@ func (f *APF) dualStackPortNeedsHook(r *Rule) bool {
return f.barePortAccept(r) && r.impliedFamily() != FamilyAny
}
// needsHook reports whether a rule must be injected through the apf pre-hook as a
// raw iptables rule because apf's native config cannot express it. It is the single
// gate between the hook path and apf's config files: everything it rejects (returns
// true) is written to the hook, everything it accepts (returns false) maps onto
// conf.apf or the allow_hosts/deny_hosts trust files. The shared shapes
// (ruleNeedsHook, bareHostOneWay, hostNeedsHook) and the two apf shapes RemoveRule
// reuses to route a split (dualStackPortNeedsHook, nativeICMPv6) keep their own
// predicates; the apf-only, single-use port/source-port/connlimit/icmp tests are
// inlined here as their sole caller.
// needsHook reports whether a rule must be injected through the apf pre-hook as a
// raw iptables rule because apf's native config cannot express it. It is the single
// gate between the hook path and apf's config files: everything it rejects (returns
@ -1496,7 +1373,6 @@ func (f *APF) needsHook(r *Rule) bool {
return f.dualStackPortNeedsHook(r)
}
// EditIPList adds or removes a rule in an apf allow_hosts/deny_hosts file, rewriting it in place.
// addRule is AddRule's implementation, with the IPv6 gate optional. Restore
// passes enforceIPv6Gate false so it can reproduce a Backup snapshot's exact
// prior state, including inert entries the gate would reject as fresh no-op
@ -1569,26 +1445,21 @@ func (f *APF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6
return f.EditIPList(ctx, APFDeny, denyAction, r, false)
}
// AddRule adds a rule to apf, routing it to conf.apf, the allow/deny lists, or the pre-hook.
// AddRule adds a rule to apf, routing it to conf.apf, the allow/deny lists, or the pre-hook.
func (f *APF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
return f.addRule(ctx, zoneName, r, true)
}
// RemoveRule removes a rule from apf, routing it to the config file or pre-hook that holds it.
// InsertRule is unsupported: APF organizes rules in config files, not an ordered list.
func (f *APF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
return unsupportedOrdering(f.Type())
}
// InsertNATRule is unsupported: APF stores NAT in a config file it applies as a
// whole, with no explicit ordering.
// MoveRule is unsupported for the same reason as InsertRule.
func (f *APF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
return unsupportedOrdering(f.Type())
}
// Capabilities reports the firewall features apf supports.
// removePlainHost drops the bidirectional plain allow_hosts/deny_hosts line backing
// the DirAny rule e, choosing the list by the rule's action.
func (f *APF) removePlainHost(ctx context.Context, e *Rule) error {
@ -1598,13 +1469,6 @@ func (f *APF) removePlainHost(ctx context.Context, e *Rule) error {
return f.EditIPList(ctx, APFDeny, f.denyActionFor(e.Proto), e, true)
}
// removeDualStackPort removes a single-family bare tcp/udp port accept. Such a rule
// is stored either as its own per-family hook rule or as one family of a dual-stack
// conf.apf CPORTS entry (a FamilyAny port). Read the CPORTS list the port would live
// in — not the merged rule view, which cannot tell a genuine CPORTS entry from a
// pair of single-family hook rules — and split it when present: drop the dual-stack
// entry and re-add the surviving opposite family as a hook rule, so the untargeted
// family keeps its coverage. Otherwise the rule is a per-family hook rule.
// removeBareHostOneWay removes a one-way bare-address host rule. Such a rule is
// stored either as its own hook rule or as one direction of a bidirectional plain
// allow_hosts/deny_hosts line (a DirAny rule). When a matching plain line exists,
@ -1637,10 +1501,6 @@ func (f *APF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule
return err
}
// addRule is AddRule's implementation, with the IPv6 gate optional. Restore
// passes enforceIPv6Gate false so it can reproduce a Backup snapshot's exact
// prior state, including inert entries the gate would reject as fresh no-op
// writes.
// cPortsKey returns the conf.apf CPORTS list a tcp/udp port rule of the given
// direction lives in, or "" for a protocol with no such list.
func (f *APF) cPortsKey(proto Protocol, output bool) string {
@ -1657,9 +1517,6 @@ func (f *APF) cPortsKey(proto Protocol, output bool) string {
return ""
}
// natFile returns the routing file a NAT rule belongs in: source NAT is
// applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING
// (preroute.rules).
// removeDualStackPort removes a single-family bare tcp/udp port accept. Such a rule
// is stored either as its own per-family hook rule or as one family of a dual-stack
// conf.apf CPORTS entry (a FamilyAny port). Read the CPORTS list the port would live
@ -1704,15 +1561,6 @@ func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error {
// which, so remove the rule from both backings — EditConf drops it from the CPORTS
// list and the hook edit drops both per-family rows, and each no-ops when the rule is
// absent — clearing the whole merged rule wherever it lives. Unlike the single-family
// removeDualStackPort there is no untargeted family to preserve, so nothing is re-added.
// removeFamilyAnyPort removes a FamilyAny address-less bare tcp/udp port accept. Its
// two families live in a dual-stack conf.apf CPORTS entry (a genuine FamilyAny add),
// in a v4+v6 pair in the hook (two separate concrete-family adds that GetRules merged
// back into one FamilyAny rule), or split across both. The merged read cannot tell
// which, so remove the rule from both backings — EditConf drops it from the CPORTS
// list and the hook edit drops both per-family rows, and each no-ops when the rule is
// absent — clearing the whole merged rule wherever it lives. Unlike the single-family
// removeDualStackPort there is no untargeted family to preserve, so nothing is re-added.
func (f *APF) removeFamilyAnyPort(ctx context.Context, r *Rule) error {
if err := f.EditConf(ctx, r, true); err != nil {
return err
@ -1722,7 +1570,6 @@ func (f *APF) removeFamilyAnyPort(ctx context.Context, r *Rule) error {
return err
}
// GetRules returns the current filter rules read from conf.apf, the allow/deny lists, and the pre-hook.
// RemoveRule removes a rule from apf, routing it to the config file or pre-hook that holds it.
func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
// A non-plain-line DirAny target fans out into its two concrete-direction rules,
@ -1805,7 +1652,6 @@ func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return f.EditIPList(ctx, APFDeny, f.denyActionFor(r.Proto), r, true)
}
// Backup captures the current filter and NAT rules managed by this backend.
// parseNATLine decodes a raw iptables nat command line back into a NATRule,
// reporting whether the line is one this backend recognizes.
func (f *APF) parseNATLine(line string) (*NATRule, bool) {
@ -1828,7 +1674,6 @@ func (f *APF) parseNATLine(line string) (*NATRule, bool) {
return r, true
}
// parseNATFile reads a routing-rules file and returns the NAT rules it holds.
// parseNATFile reads a routing-rules file and returns the NAT rules it holds.
func (f *APF) parseNATFile(path string) ([]*NATRule, error) {
fd, err := os.Open(path)
@ -1861,7 +1706,6 @@ func (f *APF) parseNATFile(path string) ([]*NATRule, error) {
return rules, nil
}
// GetNATRules returns the NAT rules held in apf's preroute and postroute routing files.
// GetNATRules returns the NAT rules held in apf's preroute and postroute routing files.
func (f *APF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
var rules []*NATRule
@ -1880,9 +1724,6 @@ func (f *APF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, err
return merged, nil
}
// editNATFile adds or removes a NAT rule's command line(s) in a routing file.
// A family-agnostic rule occupies one line per family; both are added or dropped
// together. It records whether a reload is needed.
// natFamilies lists the address families a NAT rule is written for: a rule
// pinned to a family touches only that command; a family-agnostic rule (e.g. a
// portless masquerade) is written for both v4 and v6.
@ -1897,8 +1738,6 @@ func (f *APF) natFamilies(r *NATRule) []Family {
}
}
// natLine encodes a NAT rule as a raw iptables/ip6tables nat-table command
// line for the given family, the form apf's shell-sourced routing files expect.
// natFile returns the routing file a NAT rule belongs in: source NAT is
// applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING
// (preroute.rules).
@ -1909,7 +1748,6 @@ func (f *APF) natFile(r *NATRule) string {
return APFPreroute
}
// natCommand returns the iptables command name for a family.
// natCommand returns the iptables command name for a family.
func (f *APF) natCommand(fam Family) string {
if fam == IPv6 {
@ -1918,9 +1756,6 @@ func (f *APF) natCommand(fam Family) string {
return "iptables"
}
// natFamilies lists the address families a NAT rule is written for: a rule
// pinned to a family touches only that command; a family-agnostic rule (e.g. a
// portless masquerade) is written for both v4 and v6.
// natLine encodes a NAT rule as a raw iptables/ip6tables nat-table command
// line for the given family, the form apf's shell-sourced routing files expect.
func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
@ -1934,8 +1769,6 @@ func (f *APF) natLine(r *NATRule, fam Family) (string, error) {
return f.natCommand(fam) + " -t nat " + spec, nil
}
// parseNATLine decodes a raw iptables nat command line back into a NATRule,
// reporting whether the line is one this backend recognizes.
// editNATFile adds or removes a NAT rule's command line(s) in a routing file.
// A family-agnostic rule occupies one line per family; both are added or dropped
// together. It records whether a reload is needed.
@ -2042,7 +1875,6 @@ func (f *APF) editNATFile(r *NATRule, remove bool) error {
return nil
}
// AddNATRule adds a NAT rule to apf's preroute or postroute routing file.
// AddNATRule adds a NAT rule to apf's preroute or postroute routing file.
func (f *APF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := r.validate(); err != nil {
@ -2051,14 +1883,12 @@ func (f *APF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error
return f.editNATFile(r, false)
}
// RemoveNATRule removes a NAT rule from apf's preroute or postroute routing file.
// InsertNATRule is unsupported: APF stores NAT in a config file it applies as a
// whole, with no explicit ordering.
func (f *APF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
return unsupportedOrdering(f.Type())
}
// MoveRule is unsupported for the same reason as InsertRule.
// RemoveNATRule removes a NAT rule from apf's preroute or postroute routing file.
func (f *APF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := r.validate(); err != nil {
@ -2067,9 +1897,6 @@ func (f *APF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) er
return f.editNATFile(r, true)
}
// isConfRule reports whether a rule is managed in conf.apf: an address-less
// accept rule of ports (TCP/UDP lists) or ICMP/ICMPv6 types (a nil type is the
// "all" wildcard).
// Backup captures the current filter and NAT rules managed by this backend.
func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName)
@ -2085,7 +1912,6 @@ func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
return &Backup{Rules: rules, NATRules: natRules}, nil
}
// Restore replaces the managed rules with the contents of a Backup.
// Restore replaces the managed rules with the contents of a Backup.
func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) error {
if backup == nil {
@ -2126,49 +1952,41 @@ func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) erro
return nil
}
// Reload restarts apf to apply config changes, but only when a mutation changed its files.
// GetDefaultPolicy is unsupported: apf has no managed default-policy control.
func (f *APF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
return nil, unsupportedPolicy(f.Type())
}
// SetDefaultPolicy is unsupported: apf has no managed default-policy control.
// SetDefaultPolicy is unsupported: apf has no managed default-policy control.
func (f *APF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
return unsupportedPolicy(f.Type())
}
// GetAddressSets is unsupported: apf has no address-set support.
// GetAddressSets is unsupported: apf has no address-set support.
func (f *APF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
return nil, unsupportedSet(f.Type())
}
// GetAddressSet is unsupported: apf has no address-set support.
// GetAddressSet is unsupported: apf has no address-set support.
func (f *APF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
return nil, unsupportedSet(f.Type())
}
// AddAddressSet is unsupported: apf has no address-set support.
// AddAddressSet is unsupported: apf has no address-set support.
func (f *APF) AddAddressSet(ctx context.Context, set *AddressSet) error {
return unsupportedSet(f.Type())
}
// RemoveAddressSet is unsupported: apf has no address-set support.
// RemoveAddressSet is unsupported: apf has no address-set support.
func (f *APF) RemoveAddressSet(ctx context.Context, name string) error {
return unsupportedSet(f.Type())
}
// AddAddressSetEntry is unsupported: apf has no address-set support.
// AddAddressSetEntry is unsupported: apf has no address-set support.
func (f *APF) AddAddressSetEntry(ctx context.Context, name, entry string) error {
return unsupportedSet(f.Type())
}
// RemoveAddressSetEntry is unsupported: apf has no address-set support.
// RemoveAddressSetEntry is unsupported: apf has no address-set support.
func (f *APF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
return unsupportedSet(f.Type())
@ -2187,10 +2005,7 @@ func (f *APF) Reload(ctx context.Context) error {
return nil
}
// Close releases resources held by the manager; apf holds none.
// Close releases resources held by the manager; apf holds none.
func (f *APF) Close(ctx context.Context) error {
return nil
}
// InsertRule is unsupported: APF organizes rules in config files, not an ordered list.

View file

@ -21,64 +21,160 @@ import re
import subprocess
import sys
# Matches a function declaration together with its immediately preceding
# column-0 `//` doc comments. Groups:
# 3: receiver variable (None for a plain function)
# 4: receiver type (None for a plain function)
# 5: function name
FUNC_RE = re.compile(
r"(?m)^((?://.*\n)*)^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\("
)
# Regex used to parse a column-0 `func` declaration line. Groups:
# 1: receiver variable (None for a plain function)
# 2: receiver type (None for a plain function)
# 3: function name
DECL_RE = re.compile(r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(")
def split_blocks(text):
"""Split source into (preamble, [block_dict, ...]).
Each block begins with the doc comments immediately preceding a `func` line
and ends at the start of the next block, so comments stay attached to their
function.
Each block begins with the doc comment for the function and ends at the
start of the next function's doc comment. This keeps comments attached to
the function they document, even when the file has been reordered before.
Doc comments are detected by their first line: a column-0 `//` line whose
leading word is the function name. The first function in the file is used
to delimit the preamble.
"""
lines = text.split("\n")
func_lines = [i for i, line in enumerate(lines) if line.startswith("func ")]
if not func_lines:
return text, []
decl_re = re.compile(r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(")
def comment_start(func_line):
i = func_line - 1
# Allow a single blank line between the previous block and the doc comment.
if i >= 0 and lines[i].strip() == "":
i -= 1
while i >= 0 and lines[i].startswith("//"):
i -= 1
return i + 1
comment_starts = [comment_start(fl) for fl in func_lines]
first_start = comment_starts[0]
preamble_lines = lines[:first_start]
preamble = "\n".join(preamble_lines)
if preamble_lines:
preamble += "\n"
blocks = []
# Parse all function declarations.
funcs = []
for idx, fl in enumerate(func_lines):
end_line = func_lines[idx + 1] if idx + 1 < len(func_lines) else len(lines)
block_lines = lines[comment_starts[idx] : end_line]
block_text = "\n".join(block_lines)
if block_text and not block_text.endswith("\n"):
block_text += "\n"
m = decl_re.match(lines[fl])
m = DECL_RE.match(lines[fl])
if not m:
die(f"could not parse declaration: {lines[fl]}")
assert m
blocks.append(
funcs.append(
{
"recv_var": m.group(1),
"recv_type": m.group(2),
"name": m.group(3),
"text": block_text,
"func_line": fl,
"body_end": func_lines[idx + 1]
if idx + 1 < len(func_lines)
else len(lines),
}
)
func_names = {f["name"] for f in funcs}
# Find all doc comment blocks in the file. A column-0 `//` sequence is split
# into separate doc comments at lines that start with another function name.
doc_re = re.compile(r"//\s+([a-zA-Z_][a-zA-Z0-9_]*)\b")
doc_comments = {} # name -> list of comment blocks (each block is a list of lines)
i = 0
while i < len(lines):
if not lines[i].startswith("//"):
i += 1
continue
seq = []
while i < len(lines) and lines[i].startswith("//"):
seq.append(lines[i])
i += 1
current = []
for line in seq:
mm = doc_re.match(line)
if mm and mm.group(1) in func_names and current:
# Line starts a new doc comment. Save the current one if valid.
first = current[0]
fm = doc_re.match(first)
if fm and fm.group(1) in func_names:
doc_comments.setdefault(fm.group(1), []).append(current)
current = [line]
else:
current.append(line)
if current:
first = current[0]
fm = doc_re.match(first)
if fm and fm.group(1) in func_names:
doc_comments.setdefault(fm.group(1), []).append(current)
# Deduplicate and choose the longest doc comment for each function.
func_comment = {}
for fname, blocks in doc_comments.items():
seen = set()
unique = []
for block in blocks:
key = "\n".join(block)
if key not in seen:
seen.add(key)
unique.append(block)
if unique:
func_comment[fname] = max(unique, key=len)
# Preamble is everything before the first function's doc comment or func line.
first_func = funcs[0]
if first_func["name"] in func_comment:
comment = func_comment[first_func["name"]]
# Find the earliest occurrence of this comment block before the function.
preamble_end = first_func["func_line"]
for pos in range(first_func["func_line"] - len(comment), -1, -1):
if lines[pos : pos + len(comment)] == comment:
preamble_end = pos
break
else:
preamble_end = first_func["func_line"]
preamble_lines = lines[:preamble_end]
preamble = "\n".join(preamble_lines)
if preamble_lines:
preamble += "\n"
# Build blocks. Body is from func_line to next_func_line, with the next
# function's doc comment stripped off. The last function also has trailing
# blank lines stripped so no orphan comments are left at the end of file.
blocks = []
for idx, f in enumerate(funcs):
name = f["name"]
is_last = idx == len(funcs) - 1
body_end = f["body_end"]
if is_last:
# Skip trailing blanks so orphan comments after the last function are
# reachable.
while body_end > f["func_line"] and lines[body_end - 1] == "":
body_end -= 1
# Strip trailing // lines (the next function's doc comment, or orphan
# comments after the last function).
while body_end > f["func_line"] and lines[body_end - 1].startswith("//"):
body_end -= 1
if is_last:
# Strip any remaining blanks after the orphan comments.
while body_end > f["func_line"] and lines[body_end - 1] == "":
body_end -= 1
body_lines = lines[f["func_line"] : body_end]
body_text = "\n".join(body_lines)
# Preserve a separator blank line that sits immediately before the next
# function's doc comment. Python's split/join drops the blank when it is
# the last element of the slice, so add it back explicitly.
if (
body_lines
and body_lines[-1] == ""
and any(line != "" for line in lines[body_end:])
):
body_text += "\n"
if body_text and not body_text.endswith("\n"):
body_text += "\n"
comment_text = ""
if name in func_comment:
comment_text = "\n".join(func_comment[name]) + "\n"
blocks.append(
{
"recv_var": f["recv_var"],
"recv_type": f["recv_type"],
"name": name,
"text": comment_text + body_text,
}
)