package firewall import ( "bufio" "context" "fmt" "net" "os" "strconv" "strings" dbus "github.com/coreos/go-systemd/dbus" ) const ( APFType = "apf" APFConf = "/etc/apf/conf.apf" APFAllow = "/etc/apf/allow_hosts.rules" APFDeny = "/etc/apf/deny_hosts.rules" // APFPreroute and APFPostroute are apf's user routing-rule files, sourced as // shell during a (re)start. Like the pre-hook below, this backend uses them as a // raw-iptables fallback for rules apf's native config and allow/deny files cannot // express — here the routing-stage cases, which in practice are NAT: destination // NAT (DNAT/Redirect) is written into preroute.rules and source NAT // (SNAT/Masquerade) into postroute.rules. APFPreroute = "/etc/apf/preroute.rules" APFPostroute = "/etc/apf/postroute.rules" // APFHook is apf's pre-hook, run after the flush but before apf adds its own // rules. This library writes the iptables rules for features apf's native // config cannot express directly into this hook. APFHook = "/etc/apf/hook_pre.sh" ) // APF manages the firewall through apf's config files and a managed pre-hook. type APF struct { ConfigChanged bool // rulePrefix is prepended to a rule's comment when it is written as a // full-line comment above a rule in allow_hosts.rules/deny_hosts.rules, so // rules this library creates can be told apart. conf.apf port/icmp-list // rules carry no per-rule comment and so ignore it. rulePrefix string // ipv6Enabled mirrors conf.apf's USE_IPV6. apf's own shell logic silently // no-ops a bare IPv6 host in allow_hosts.rules/deny_hosts.rules // (apf_trust.sh trust_hosts()) and the native IG_ICMPV6_TYPES/EG_ICMPV6_TYPES // lists (cports.common) whenever USE_IPV6 is not "1" (the shipped default), // so AddRule must reject those shapes rather than write a rule apf will // never enforce. ipv6Enabled bool } // NewAPF verifies apf is installed and active, then returns a manager bound to rulePrefix. func NewAPF(ctx context.Context, rulePrefix string) (*APF, error) { apf := new(APF) apf.rulePrefix = rulePrefix // Connect to systemd dbus interface. var prop *dbus.Property conn, err := dbus.NewWithContext(ctx) if err == nil { defer conn.Close() // Find the systemd service for apf and confirm it was loaded. prop, _ = conn.GetUnitPropertyContext(ctx, "apf.service", "ActiveState") } // If the service is not active in SystemD, check SysV status. if prop == nil || prop.Value.Value() != "active" { // Run SysV check to see if APF is enabled there. results, err := runCommand(ctx, "chkconfig", "--list", "apf") if err != nil { return nil, fmt.Errorf("the apf service is not active on this server") } // Parse the result to see if apf is on. foundOn := false for _, line := range results { fields := strings.Fields(line) if len(fields) == 0 { continue } // If this is not the apf line, skip. if fields[0] != "apf" { continue } // Parse the runlevel:status output to check if any are on. for _, f := range fields { _, status, found := strings.Cut(f, ":") if found && status == "on" { foundOn = true } } } // If APF is not on, return result. if !foundOn { return nil, fmt.Errorf("the apf service is not active or enabled on this server") } } // Confirm config files exist. files := []string{APFConf, APFAllow, APFDeny} for _, f := range files { if _, err := os.Stat(f); err != nil { return nil, fmt.Errorf("the config file %s is missing", f) } } // Read whether apf's own IPv6 handling is turned on. useIPv6, err := readConfValue(APFConf, "USE_IPV6") if err != nil { return nil, fmt.Errorf("error reading %s: %s", APFConf, err) } apf.ipv6Enabled = useIPv6 == "1" // Return the new apf object. 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{ Output: true, Forward: true, // ICMPv6 mirrors ipv6Enabled: apf's native IG_ICMPV6_TYPES/EG_ICMPV6_TYPES // lists (what a plain, qualifier-free ICMPv6 rule uses) are a silent no-op // on the real firewall whenever conf.apf's USE_IPV6 is not "1" (confirmed // against a real apf install: ip6tables carries no rule for a type in // IG_ICMPV6_TYPES when USE_IPV6=0). A rule that also needs state/interface/ // log/rate matching still works via the raw-iptables hook regardless. ICMPv6: f.ipv6Enabled, PortList: false, ConnState: true, InterfaceMatch: true, Logging: true, RateLimit: true, ConnLimit: true, NAT: true, RuleOrdering: false, DefaultPolicy: false, RuleCounters: false, AddressSets: false, Comments: true, } } // 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) { lo, hi, isRange := strings.Cut(strings.TrimSpace(tok), "_") start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16) if err != nil { return PortRange{}, fmt.Errorf("invalid port %q", lo) } pr := PortRange{Start: uint16(start), End: uint16(start)} if isRange { end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16) if err != nil { return PortRange{}, fmt.Errorf("invalid port %q", hi) } pr.End = uint16(end) if pr.End < pr.Start { return PortRange{}, fmt.Errorf("port range end below start") } } 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 // each entry becomes an inbound reject rule carrying a per-source ConnLimit. func (f *APF) ParseConnLimit(val string, proto Protocol) (rules []*Rule) { for _, entry := range strings.Split(val, ",") { entry = strings.TrimSpace(entry) if entry == "" { continue } portTok, limitTok, ok := strings.Cut(entry, ":") if !ok { continue } pr, err := f.parsePortToken(strings.TrimSpace(portTok)) if err != nil { continue } limit, err := strconv.ParseUint(strings.TrimSpace(limitTok), 10, 32) if err != nil { continue } // Like the CPORTS lists, IG_TCP_CLIMIT/IG_UDP_CLIMIT are dual-stack, so a // connection-limit entry carries no family of its own. Report FamilyAny so a // FamilyAny desired connlimit rule reconciles with its read-back. (APF has no // egress connection-limit config; connlimit applies only on the input chain.) rule := &Rule{ Family: FamilyAny, Proto: proto, Action: Reject, ConnLimit: &ConnLimit{Count: uint(limit), PerSource: true}, } portSpecsToRule(rule, []PortRange{pr}) rules = append(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 // ACCEPT`) becomes a rule with a nil ICMPType, matching every type. func (f *APF) ParseICMPTypes(val string, proto Protocol, out bool) (rules []*Rule) { fam := IPv4 if proto == ICMPv6 { fam = IPv6 } for _, tok := range strings.Split(val, ",") { tok = strings.TrimSpace(tok) if tok == "" { continue } rule := &Rule{Family: fam, Proto: proto, Direction: directionFromOutput(out), Action: Accept} if !strings.EqualFold(tok, "all") { n, ok := parseICMPTypeFamily(tok, proto == ICMPv6) if !ok { continue } rule.ICMPType = Ptr(n) } rules = append(rules, rule) } 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. func (f *APF) parseAddr(v string) (addr string, fam Family, ok bool) { if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { v = v[1 : len(v)-1] } _, network, err := net.ParseCIDR(v) ip := net.ParseIP(v) if err != nil && ip == nil { return "", FamilyAny, false } family := IPv4 if (network != nil && network.IP.To4() == nil) || (ip != nil && ip.To4() == nil) { family = IPv6 } // A zero network (0.0.0.0/0 or ::/0) means "any", represented as empty. if network != nil { ones, _ := network.Mask.Size() if ones == 0 && network.IP.IsUnspecified() { return "", family, true } } 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. // 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. func (f *APF) parseStopAction(val string) Action { switch strings.ToUpper(trimQuotes(strings.TrimSpace(val))) { case "REJECT", "PROHIBIT": return Reject default: return Drop } } // 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 // APFConf) so this can be exercised against a fixture file in tests. func (f *APF) readStopAction(path, key string) Action { action := Drop fd, err := os.Open(path) if err != nil { return action } defer func() { _ = fd.Close() }() scanner := bufio.NewScanner(fd) for scanner.Scan() { line := scanner.Text() if ci := strings.IndexByte(line, '#'); ci >= 0 { line = line[:ci] } k, val, found := strings.Cut(strings.TrimSpace(line), "=") if !found { continue } if strings.TrimSpace(k) != key { continue } action = f.parseStopAction(val) } 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 // 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. func (f *APF) stopKey(proto Protocol) string { switch proto { case TCP: return "TCP_STOP" case UDP: return "UDP_STOP" default: return "ALL_STOP" } } // 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 // 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. func (f *APF) resolveAction(base Action, proto Protocol) Action { if base == Accept { return base } switch proto { case TCP, UDP: return f.denyActionFor(proto) default: return base } } // 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 { var fields []string depth, start := 0, 0 for i := 0; i < len(s); i++ { switch s[i] { case '[': depth++ case ']': if depth > 0 { depth-- } case ':': if depth == 0 { fields = append(fields, s[start:i]) start = i + 1 } } } 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) { r = &Rule{} for _, fld := range f.splitAdvFields(val) { switch { case strings.EqualFold(fld, "tcp"): r.Proto = TCP case strings.EqualFold(fld, "udp"): r.Proto = UDP case strings.EqualFold(fld, "in"): r.Direction = DirInput case strings.EqualFold(fld, "out"): r.Direction = DirOutput case strings.HasPrefix(fld, "s="): // The source field is either an address or, when it is not, a source // port (a single port or an underscore range; apf's port field takes // no comma list). v := strings.TrimPrefix(fld, "s=") if addr, fam, ok := f.parseAddr(v); ok { r.Family = fam r.Source = addr continue } pr, err := f.parsePortToken(v) if err != nil { return nil } sourcePortSpecsToRule(r, []PortRange{pr}) case strings.HasPrefix(fld, "d="): // Mirror the s= branch: the destination field is either an address or a // single destination port (a single port or an underscore range; apf's // port field takes no comma list). v := strings.TrimPrefix(fld, "d=") if addr, fam, ok := f.parseAddr(v); ok { r.Family = fam r.Destination = addr continue } pr, err := f.parsePortToken(v) if err != nil { return nil } portSpecsToRule(r, []PortRange{pr}) } } // The action depends on the protocol just parsed (see resolveAction), so // it is resolved last rather than stamped up front. r.Action = f.resolveAction(action, r.Proto) 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. fd, err := os.Open(filePath) if err != nil { return nil, err } scanner := bufio.NewScanner(fd) // A full-line comment immediately above a rule is that rule's comment. // Consecutive comment lines accumulate (joined by a space); a blank line // detaches a comment from any rule that follows. var pendingComment string flushComment := func() (string, bool) { text, hasPrefix := prefixedComment(f.rulePrefix, pendingComment) pendingComment = "" return text, hasPrefix } for scanner.Scan() { raw := scanner.Text() trimmed := strings.TrimSpace(raw) // A full-line comment is held as a candidate rule comment. if trimmed != "" && strings.HasPrefix(trimmed, "#") { text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) // A prefix tag starts a fresh comment block for the rule that // follows, so header/section comments above it are not absorbed into // the rule's comment and prefix detection stays reliable. if f.rulePrefix != "" && (text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ")) { pendingComment = text } else if pendingComment != "" { pendingComment += " " + text } else { pendingComment = text } continue } // Strip an inline trailing comment (not a rule comment). if ci := strings.IndexByte(trimmed, '#'); ci >= 0 { trimmed = trimmed[:ci] } trimmed = strings.TrimSpace(trimmed) // A blank line detaches a pending comment from any later rule. if len(trimmed) == 0 { pendingComment = "" continue } comment, hasPrefix := flushComment() // An advanced rule carries s=/d= options; a bare line is a plain address // (which may itself be an IPv6 address containing colons). if strings.Contains(trimmed, "=") { rule := f.ParseAdvRule(trimmed, action) if rule == nil { continue } rule.Comment = comment rule.HasPrefix = hasPrefix rules = append(rules, rule) } else { // Try to parse IP. fam, ok := csfAddrFamily(trimmed) if !ok { continue } // A plain IP line matches the host in both directions, so it is one // bidirectional DirAny rule, authored in the inbound frame (Source=X). rules = append(rules, &Rule{ Direction: DirAny, Family: fam, Source: trimmed, Action: action, Comment: comment, HasPrefix: hasPrefix, }) } } _ = fd.Close() if serr := scanner.Err(); serr != nil { return nil, serr } 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, ",") { port = strings.TrimSpace(port) if port == "" { continue } pr, err := f.parsePortToken(port) if err != nil { continue } // apf's IG_*_CPORTS/EG_*_CPORTS lists are dual-stack — a single list applied // to both the ip and ip6 tables — so a port entry carries no family of its // own. Report FamilyAny (not IPv4) so a FamilyAny desired rule, which a bare // tcp/udp port carries by default, reconciles with its own read-back. rule := &Rule{ Family: FamilyAny, Proto: proto, Direction: directionFromOutput(out), Action: Accept, } portSpecsToRule(rule, []PortRange{pr}) rules = append(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 { return &hookScript{ rulePrefix: f.rulePrefix, hookPath: APFHook, hookPerm: 0750, } } // 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 fd, err := os.Open(APFConf) if err != nil { return nil, err } // denyAction is the action apf applies to a bare-address deny (conf.apf // ALL_STOP, default DROP). Capture it in this same pass so the deny list can // be stamped without re-reading conf.apf for that common case; a tcp/udp // advanced deny_hosts entry is re-resolved from TCP_STOP/UDP_STOP instead (see // resolveAction, used by ParseAdvRule as ParseIPList parses each line). denyAction := Drop // Scan each line. scanner := bufio.NewScanner(fd) for scanner.Scan() { // Get the line. line := scanner.Text() // Remove comments. ci := strings.IndexByte(line, '#') if ci >= 0 { line = line[:ci] } // Trim spaces. line = strings.TrimSpace(line) // Ignore zero lines. if len(line) == 0 { continue } // Parse key/value. key, val, found := strings.Cut(line, "=") if !found { continue } key = strings.TrimSpace(key) val = trimQuotes(strings.TrimSpace(val)) // Parse rules. switch key { case "ALL_STOP": denyAction = f.parseStopAction(val) case "IG_TCP_CPORTS": rules = append(rules, f.ParsePorts(val, TCP, false)...) case "IG_UDP_CPORTS": rules = append(rules, f.ParsePorts(val, UDP, false)...) case "EG_TCP_CPORTS": rules = append(rules, f.ParsePorts(val, TCP, true)...) case "EG_UDP_CPORTS": rules = append(rules, f.ParsePorts(val, UDP, true)...) case "IG_ICMP_TYPES": rules = append(rules, f.ParseICMPTypes(val, ICMP, false)...) case "EG_ICMP_TYPES": rules = append(rules, f.ParseICMPTypes(val, ICMP, true)...) case "IG_ICMPV6_TYPES": rules = append(rules, f.ParseICMPTypes(val, ICMPv6, false)...) case "EG_ICMPV6_TYPES": rules = append(rules, f.ParseICMPTypes(val, ICMPv6, true)...) case "IG_TCP_CLIMIT": rules = append(rules, f.ParseConnLimit(val, TCP)...) case "IG_UDP_CLIMIT": rules = append(rules, f.ParseConnLimit(val, UDP)...) } } _ = fd.Close() if err := scanner.Err(); err != nil { return nil, err } // Read the allowed IP rule list. ipRules, err := f.ParseIPList(APFAllow, Accept) if err != nil { return nil, err } rules = append(rules, ipRules...) // Read the denied IP rule list, stamped with the action apf actually applies // (captured above from conf.apf ALL_STOP, default DROP) rather than a fixed // Reject, so a managed Drop rule reads back as Drop and reconciles without churn. ipRules, err = f.ParseIPList(APFDeny, denyAction) if err != nil { return nil, err } rules = append(rules, ipRules...) // Read the iptables rules injected through the apf pre-hook (state, // interface, logging, rate-limit, icmpv6). hookRules, err := f.hook().getRules() if err != nil { return nil, err } rules = append(rules, hookRules...) // Merge rules across families so a v4/v6 hook pair collapses to one rule, then // collapse each input/output twin into one DirAny rule — a plain allow_hosts/ // deny_hosts IP, read as an inbound (source) rule plus an outbound (destination) // rule, reads back as the single bidirectional line it was written as. rules = mergeFamilies(rules) rules = mergeDirections(rules) 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 { pr = pr.normalized() if pr.Start == pr.End { return strconv.FormatUint(uint64(pr.Start), 10) } 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 { portTok := f.portToken(r.PortSpecs()[0]) var kept []string present := false for _, tok := range strings.Split(val, ",") { tok = strings.TrimSpace(tok) if tok == "" { continue } p, _, ok := strings.Cut(tok, ":") if ok && strings.TrimSpace(p) == portTok { present = true if remove { f.ConfigChanged = true continue } // Replace the entry in place. Record a config change only when the // count actually differs so Reload runs apf --restart to apply the new // limit; an unchanged count must not trigger a spurious restart. newTok := fmt.Sprintf("%s:%d", portTok, r.ConnLimit.Count) if strings.TrimSpace(tok) != newTok { f.ConfigChanged = true } kept = append(kept, newTok) continue } kept = append(kept, tok) } if !remove && !present { kept = append(kept, fmt.Sprintf("%s:%d", portTok, r.ConnLimit.Count)) f.ConfigChanged = true } 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`). func (f *APF) icmpTokens(r *Rule) []string { if r.ICMPType == nil { return []string{"all"} } 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 // excess. func (f *APF) isConnLimitRule(r *Rule) bool { return r.ConnLimit != nil && r.ConnLimit.PerSource && !r.IsOutput() && (r.Proto == TCP || r.Proto == UDP) && r.Source == "" && r.Destination == "" && 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() tokens := make([]string, len(specs)) for i, sp := range specs { tokens[i] = f.portToken(sp) } 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 // must never also add or remove its port from an accept port list, or // RemoveRule would close a port the caller never opened and a round-trip // would report a spurious accept rule alongside the connlimit. if r.ConnLimit != nil && key != "IG_TCP_CLIMIT" && key != "IG_UDP_CLIMIT" { return orig } // Determine which config list this key manages and the tokens the rule // contributes to it. Non-matching keys are returned untouched. var wantTokens []string switch key { case "IG_TCP_CPORTS": if r.IsOutput() || r.Proto != TCP { return orig } wantTokens = f.portTokens(r) case "IG_UDP_CPORTS": if r.IsOutput() || r.Proto != UDP { return orig } wantTokens = f.portTokens(r) case "EG_TCP_CPORTS": if !r.IsOutput() || r.Proto != TCP { return orig } wantTokens = f.portTokens(r) case "EG_UDP_CPORTS": if !r.IsOutput() || r.Proto != UDP { return orig } wantTokens = f.portTokens(r) case "IG_ICMP_TYPES": if r.IsOutput() || r.Proto != ICMP { return orig } wantTokens = f.icmpTokens(r) case "EG_ICMP_TYPES": if !r.IsOutput() || r.Proto != ICMP { return orig } wantTokens = f.icmpTokens(r) case "IG_ICMPV6_TYPES": if r.IsOutput() || r.Proto != ICMPv6 { return orig } wantTokens = f.icmpTokens(r) case "EG_ICMPV6_TYPES": if !r.IsOutput() || r.Proto != ICMPv6 { return orig } wantTokens = f.icmpTokens(r) case "IG_TCP_CLIMIT": // CLIMIT tokens are "port:limit", edited independently of the port lists. if !f.isConnLimitRule(r) || r.Proto != TCP { return orig } return f.editConnLimit(key, val, r, remove) case "IG_UDP_CLIMIT": if !f.isConnLimitRule(r) || r.Proto != UDP { return orig } return f.editConnLimit(key, val, r, remove) default: return orig } // Canonicalize tokens for comparison. An ICMP-type list may hold either a // numeric type or a name (e.g. "echo-request"), and the read path resolves names // to numbers; comparing the list's raw token against the numeric token this code // emits would never match a name-based (foreign) entry, so a remove would keep it // and an add would append a numeric duplicate. Fold each token to its resolved // number, using the family the key implies; other keys // compare verbatim. canon := func(tok string) string { return tok } switch key { case "IG_ICMP_TYPES", "EG_ICMP_TYPES", "IG_ICMPV6_TYPES", "EG_ICMPV6_TYPES": v6 := key == "IG_ICMPV6_TYPES" || key == "EG_ICMPV6_TYPES" canon = func(tok string) string { if n, ok := parseICMPTypeFamily(tok, v6); ok { return strconv.Itoa(int(n)) } return tok } } // Add or remove the rule's tokens from the comma list, preserving any others. // Three collections cooperate here: // - want: the canonical form of every token this rule contributes, so a // remove can recognize an existing token as "ours" regardless of // how it was spelled in the file. // - present: the canonical form of every token we decide to keep, so the // add pass can tell whether one of the rule's tokens is already // in the list and must not be appended a second time. // - kept: the tokens, in original spelling, that survive into the // rewritten list. This is the actual output. // want and present are keyed by canonical form (comparison identity); kept // holds the verbatim tokens (what we write back). want := make(map[string]bool, len(wantTokens)) for _, w := range wantTokens { want[canon(w)] = true } present := make(map[string]bool) var kept []string // Pass 1: walk the existing config list. Each token is either dropped (only // on a remove, and only when it is one of the rule's own tokens) or kept. // We keep the original spelling but track it by canonical form so pass 2 can // dedupe against it. for _, tok := range strings.Split(val, ",") { tok = strings.TrimSpace(tok) if tok == "" { continue } c := canon(tok) // On a remove, an existing token that matches one of the rule's tokens is // the one we are deleting: skip it and flag the file as changed. if remove && want[c] { f.ConfigChanged = true continue } // Otherwise the token stays. Record its canonical form so the add pass // below treats it as already present. kept = append(kept, tok) present[c] = true } // Pass 2 (add only): append any of the rule's tokens that the existing list // did not already contain. present carries forward from pass 1, so a token // the file already had is skipped and we never write a duplicate. if !remove { for _, w := range wantTokens { if cw := canon(w); !present[cw] { kept = append(kept, w) present[cw] = true f.ConfigChanged = true } } } // Re-create the configuration with the new list. 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. fd, err := os.Open(APFConf) if err != nil { return err } // Stage the rewrite, preserving conf.apf's mode and ownership. af, err := newAtomicFile(APFConf, 0644) if err != nil { _ = fd.Close() return err } defer af.Abort() // Parse config one line at a time, adding the port rule. scanner := bufio.NewScanner(fd) for scanner.Scan() { // Get the line. orig := scanner.Text() line := orig // Remove comments. ci := strings.IndexByte(line, '#') if ci >= 0 { line = line[:ci] } // Trim spaces. line = strings.TrimSpace(line) // Ignore zero lines. if len(line) == 0 { _, _ = fmt.Fprintln(af, orig) continue } // Parse key/value. key, val, found := strings.Cut(line, "=") if !found { _, _ = fmt.Fprintln(af, orig) continue } key = strings.TrimSpace(key) val = trimQuotes(strings.TrimSpace(val)) // Parse rules. orig = f.EditRulePort(orig, key, val, r, remove) _, _ = fmt.Fprintln(af, orig) } _ = fd.Close() // A read error means the rewritten file is truncated; discard it. if serr := scanner.Err(); serr != nil { return serr } // Move new file into place, preserving mode and ownership. 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 { if strings.Contains(addr, ":") { return "[" + addr + "]" } 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) { if r.Proto.IsICMP() { return "", fmt.Errorf("apf advanced rules do not support icmp") } if r.Source == "" && r.Destination == "" { return "", fmt.Errorf("an apf advanced rule requires a source or destination address") } // apf's advanced rule holds a single address field, so a rule matching both a // source and a destination cannot be expressed; reject it rather than silently // dropping the destination (which would install a broader rule than asked and // leave the rule unremovable). Mirrors the dual-port guard below. if r.Source != "" && r.Destination != "" { return "", fmt.Errorf("apf advanced rules cannot match both a source and destination address") } // apf's port field takes a single port or an underscore range (no comma list), // and there is a single port-flow field, so a rule cannot match both a source // and a destination port at once. if len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1 { return "", fmt.Errorf("apf advanced rules do not support a port list in this model") } if r.HasPorts() && r.HasSourcePorts() { return "", fmt.Errorf("apf advanced rules cannot match both a source and destination port") } var parts []string switch r.Proto { case TCP: parts = append(parts, "tcp") case UDP: parts = append(parts, "udp") } if r.IsOutput() { parts = append(parts, "out") } else { parts = append(parts, "in") } // The port-flow field: a source port or a destination port. if specs := r.SourcePortSpecs(); len(specs) == 1 { parts = append(parts, "s="+f.portToken(specs[0])) } else if specs := r.PortSpecs(); len(specs) == 1 { parts = append(parts, "d="+f.portToken(specs[0])) } if r.Source != "" { parts = append(parts, "s="+f.addrField(r.Source)) } else if r.Destination != "" { parts = append(parts, "d="+f.addrField(r.Destination)) } 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. fd, err := os.Open(filePath) if err != nil { return err } // Stage the rewrite, preserving the list file's mode and ownership. af, err := newAtomicFile(filePath, 0644) if err != nil { _ = fd.Close() return err } defer af.Abort() scanner := bufio.NewScanner(fd) exists := false // A deny_hosts entry takes on the drop/reject action set in apf.conf (and // allow_hosts is accept), so a rule read back is stamped with the file's // action. Resolve the incoming rule's action the same way via resolveAction // before matching. match := *r match.Action = f.resolveAction(action, r.Proto) // pending holds the full-line comments seen immediately above a rule, so they // can be dropped together with a removed rule (they are its comment) or written // ahead of a kept one. A blank line detaches them. var pending []string flush := func() { for _, c := range pending { _, _ = fmt.Fprintln(af, c) } pending = nil } drop := func() { pending = nil } // Read the file line by line. for scanner.Scan() { orig := scanner.Text() trimmed := strings.TrimSpace(orig) // A full-line comment is held as a candidate rule comment. if trimmed != "" && strings.HasPrefix(trimmed, "#") { // Mirror ParseIPList: a prefix tag starts a fresh comment block, so // any header/section comments above it are not part of the rule's comment // and must survive its removal. Flush them now and begin the rule's block // at the tag, so drop() only discards the tag and the rule's own comment. if f.rulePrefix != "" { if text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")); text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ") { flush() } } pending = append(pending, orig) continue } // Strip an inline trailing comment for matching, but preserve the // original line (with its inline note) when copying it through. line := trimmed if ci := strings.IndexByte(line, '#'); ci >= 0 { line = line[:ci] } line = strings.TrimSpace(line) // A blank line detaches a pending comment; write it and the blank. if len(line) == 0 { flush() _, _ = fmt.Fprintln(af, orig) continue } if strings.Contains(line, "=") { rule := f.ParseAdvRule(line, action) if rule == nil { flush() _, _ = fmt.Fprintln(af, orig) continue } // Match family-aware: the address-less "port-only deny" form parses to // Source="" for both families, so a bare EqualBase would let an IPv4 line // stand in for its IPv6 twin. EqualForDedup/EqualForRemoval fold in the // family coverage the add and remove paths respectively need. var famMatch bool if remove { famMatch = rule.EqualForRemoval(&match, true) } else { famMatch = rule.EqualForDedup(&match, true) } if famMatch { exists = true if !remove { flush() _, _ = fmt.Fprintln(af, orig) } else { drop() f.ConfigChanged = true } } else { flush() _, _ = fmt.Fprintln(af, orig) } } else { // Try to parse IP. fam, ok := csfAddrFamily(line) if !ok { flush() _, _ = fmt.Fprintln(af, orig) continue } // A plain IP line is one bidirectional DirAny rule; match the target // against it in the inbound frame (canonicalMatch), so a DirAny or a // concrete-direction input/output target that names this host lines up. plainRule := &Rule{ Direction: DirAny, Family: fam, Source: line, Action: action, } if plainRule.EqualBase(match.canonicalMatch(), false) { exists = true if !remove { flush() _, _ = fmt.Fprintln(af, orig) } else { drop() f.ConfigChanged = true } } else { flush() _, _ = fmt.Fprintln(af, orig) } } } // Write any trailing comments that followed the last rule. flush() // If not exists and not remove, try adding the rule. if !exists && !remove { writeComment := func() { if c := combineComment(f.rulePrefix, r.Comment); c != "" { _, _ = fmt.Fprintln(af, "# "+c) } } hasIP := r.Source != "" || r.Destination != "" switch { case hasIP && (r.HasPorts() || r.HasSourcePorts()): // A port rule with an address is an advanced rule. line, err := f.MarshalAdvRule(r) if err != nil { _ = fd.Close() return err } f.ConfigChanged = true writeComment() _, _ = fmt.Fprintln(af, line) case hasIP: // A bare all-protocol host allow/deny: a single address matching every // protocol. apf's trust files hold no other portless address shape — a // concrete-protocol host or a source+destination pair — so AddRule diverts // those to the raw-iptables hook (hostNeedsHook) and never reaches here // with one. A direct caller of this exported writer that supplies such a // shape gets a best-effort single-address write, not a guard. f.ConfigChanged = true writeComment() if r.Source != "" { _, _ = fmt.Fprintln(af, r.Source) } else { _, _ = fmt.Fprintln(af, r.Destination) } case action != Accept && r.HasPorts(): // A port-only deny is written as an advanced rule against any address, // which apf requires in the field position. apf's port field holds a // single port or underscore range only; a multi-port list has no advanced- // rule form, so AddRule diverts it to the hook (needsHook) and this // branch only sees a single port/range. A direct caller supplying a list // gets a best-effort single-port write. specs := r.PortSpecs() // apf requires an address in the field position, so a port-only deny // uses the "any" network as a placeholder. That literal is family- // specific (0.0.0.0/0 vs ::/0) and read back with its family, so emit // the placeholder matching the rule's family. A family-neutral rule has // no single literal, so cover both families — mergeFamilies collapses // the v4/v6 pair back to FamilyAny on read, keeping the rule both // readable and removable rather than silently becoming IPv4-only. writeAny := func(placeholder string) { var tokens []string if r.Proto != ProtocolAny { tokens = append(tokens, r.Proto.String()) } if r.IsOutput() { tokens = append(tokens, "out") } else { tokens = append(tokens, "in") } tokens = append(tokens, "d="+f.portToken(specs[0])) if r.IsOutput() { tokens = append(tokens, "d="+placeholder) } else { tokens = append(tokens, "s="+placeholder) } writeComment() _, _ = fmt.Fprintln(af, strings.Join(tokens, ":")) } switch r.impliedFamily() { case IPv6: writeAny("[::/0]") case IPv4: writeAny("0.0.0.0/0") default: writeAny("0.0.0.0/0") writeAny("[::/0]") } f.ConfigChanged = true } } _ = fd.Close() // A read error means the rewritten file is truncated; discard it. if serr := scanner.Err(); serr != nil { return serr } // Move new file into place, preserving mode and ownership. 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). func (f *APF) isConfRule(r *Rule) bool { if r.Source != "" || r.Destination != "" || r.Action != Accept { return false } 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 // 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. func (f *APF) nativeICMPv6(r *Rule) bool { return r.Proto == ICMPv6 && r.State == 0 && r.InInterface == "" && r.OutInterface == "" && !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 // 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. func (f *APF) ipv6Unavailable(r *Rule) bool { if f.ipv6Enabled { return false } if f.nativeICMPv6(r) { return true } 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 // 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. func (f *APF) barePortAccept(r *Rule) bool { return (r.Proto == TCP || r.Proto == UDP) && r.HasPorts() && 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 // 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. 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 // 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. func (f *APF) needsHook(r *Rule) bool { // Features apf's native config cannot model — connection state, per-rule // interface, logging, rate limiting, forward-chain routing, ICMPv6, or a // transport apf does not carry (see ruleNeedsHook) — go to the hook. An ICMPv6 // type rule is the exception: apf carries it natively in IG_ICMPV6_TYPES/ // EG_ICMPV6_TYPES (see nativeICMPv6), so it stays out. if ruleNeedsHook(r) && !f.nativeICMPv6(r) { return true } // A one-way bare host has no trust-file form (a plain line is bidirectional, an // advanced rule needs a port), and a concrete-protocol host or a source+ // destination pair likewise has none (see hostNeedsHook); all go to the hook. if bareHostOneWay(r) || hostNeedsHook(r) { return true } // A multi-port tcp/udp list apf's config cannot carry: its advanced rule holds a // single port or one underscore range, and the only native multi-port shape is an // address-less accept (isConfRule, carried by the IG_*_CPORTS comma lists); // every other list goes to the hook's iptables multiport match. if (r.Proto == TCP || r.Proto == UDP) && (len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) && !f.isConfRule(r) { return true } // A source-port match with no address has no advanced-rule form (advanced rules // require an address); iptables matches --sport directly, so it goes to the hook. if r.HasSourcePorts() && r.Source == "" && r.Destination == "" && (r.Proto == TCP || r.Proto == UDP) { return true } // A connection limit conf.apf's IG_*_CLIMIT cannot express — anything but a // per-source cap on a single address-less inbound tcp/udp port rejecting the // excess (isConnLimitRule) — goes to the hook's `-m connlimit` match. if r.ConnLimit != nil && !f.isConnLimitRule(r) { return true } // An ICMPv4 rule apf's IG_ICMP_TYPES/EG_ICMP_TYPES lists cannot express (they // match a type on the whole zone, so only an address-less accept is native, and // one carrying an address or a non-accept action is not) goes to the hook's // `iptables -p icmp` match. ICMPv6 is routed by ruleNeedsHook above, not here. if r.Proto == ICMP && !f.isConfRule(r) { return true } // A bare protocol match with no address and no port has no native apf construct — // the trust files key on an address and conf.apf's lists on a port or icmp type — // but iptables expresses it directly, so it goes to the hook (see // bareProtoNeedsHook). A native address-less ICMP/ICMPv6 accept is excluded there; // connection limits and every other addressed/ported shape are routed above. if bareProtoNeedsHook(r) { return true } // A single-family bare tcp/udp port accept: apf's CPORTS lists are dual-stack, so // only a FamilyAny port is native; a single-family one is written per-family // through the hook (see dualStackPortNeedsHook, which RemoveRule also uses). 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 // writes. func (f *APF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error { // A DirAny rule maps to a single native construct only as a bare-host plain line; // every other DirAny shape fans out into a concrete input rule plus its swapped // output rule, each routed independently (a half may itself need the hook). if r.Direction == DirAny && !dirAnyPlainLine(r) { for _, sub := range expandDirections(r) { if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil { return err } } return nil } // Any shape apf's native config cannot express (a stateful/interface/logged/ // rate-limited rule, a one-way or concrete-protocol host, a source+destination // pair, a multi-port list, an address-less source-port match, a non-native // connection limit, a non-native ICMPv4 rule, or a single-family port accept) is // injected as a raw iptables rule through the apf pre-hook. See needsHook for // each clause; everything past this gate maps onto apf's own config files. if f.needsHook(r) { changed, err := f.hook().edit(r, false) f.ConfigChanged = f.ConfigChanged || changed return err } if enforceIPv6Gate && f.ipv6Unavailable(r) { return fmt.Errorf("apf's IPv6 handling is disabled (conf.apf USE_IPV6 is not \"1\"): %w", ErrUnsupported) } if err := iptablesRuleValid(r); err != nil { return fmt.Errorf("%v: %w", err, ErrUnsupported) } // A native connection-limit rule maps onto conf.apf's IG_*_CLIMIT lists (a // non-native one was diverted to the hook above by needsHook). if r.ConnLimit != nil { return f.EditConf(ctx, r, false) } // Address-less accept rules (dual-stack port lists, icmp types) live in conf.apf. if f.isConfRule(r) { return f.EditConf(ctx, r, false) } // Otherwise edit allow_hosts.rules for accepts, deny_hosts.rules for denies. A // bare protocol match (no address, no port) never reaches here — needsHook routed // it to the pre-hook above — so every rule at this point carries an address. if r.Action == Accept { return f.EditIPList(ctx, APFAllow, Accept, r, false) } // A deny_hosts entry carries no action of its own: apf applies conf.apf's ALL_STOP // action to a bare-address entry, or its TCP_STOP/UDP_STOP action to a tcp/udp // advanced one (see stopKey). A deny whose action matches that is written natively; // one that differs has no native form, so it is injected through the pre-hook // instead, whose iptables rule carries the exact action. A DirAny bare-host deny is // expanded to its two concrete directions first, since each hook line is one-way. denyAction := f.denyActionFor(r.Proto) if r.Action != denyAction { for _, sub := range expandDirections(r) { changed, err := f.hook().edit(sub, false) f.ConfigChanged = f.ConfigChanged || changed if err != nil { return err } } return nil } 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 { if e.Action == Accept { return f.EditIPList(ctx, APFAllow, Accept, e, true) } 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, // split it: drop the line and re-add the surviving opposite direction as a hook rule // so the untargeted direction keeps its coverage. func (f *APF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule) error { existing, err := f.GetRules(ctx, zoneName) if err != nil { return err } for _, e := range existing { if !e.IsAny() || !e.EqualForRemoval(r, true) { continue } // The host is stored as a bidirectional plain line; drop it, then re-add the // surviving direction as a hook rule. if err := f.removePlainHost(ctx, e); err != nil { return err } if s := splitDualRowDirection(e, r); s != nil { changed, err := f.hook().edit(s, false) f.ConfigChanged = f.ConfigChanged || changed return err } return nil } // Not stored as a plain line; remove the one-way hook rule. changed, err := f.hook().edit(r, true) f.ConfigChanged = f.ConfigChanged || changed 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 { dir := "IG" if output { dir = "EG" } switch proto { case TCP: return dir + "_TCP_CPORTS" case UDP: return dir + "_UDP_CPORTS" } 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 // 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. func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error { val, err := readConfValue(APFConf, f.cPortsKey(r.Proto, r.IsOutput())) if err != nil { return err } inCPorts := false for _, e := range f.ParsePorts(val, r.Proto, r.IsOutput()) { if e.EqualForRemoval(r, true) { inCPorts = true break } } if inCPorts { // Drop the dual-stack CPORTS entry (family-agnostic), then re-add the // surviving opposite family through the hook. if err := f.EditConf(ctx, r, true); err != nil { return err } surviving := *r surviving.Family = oppositeFamily(r.impliedFamily()) changed, err := f.hook().edit(&surviving, false) f.ConfigChanged = f.ConfigChanged || changed return err } // Not stored in CPORTS; remove the per-family hook rule. changed, err := f.hook().edit(r, true) f.ConfigChanged = f.ConfigChanged || changed return err } // 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. // 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 } changed, err := f.hook().edit(r, true) f.ConfigChanged = f.ConfigChanged || changed 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, // mirroring addRule, so each half is removed from wherever it was written. if r.Direction == DirAny && !dirAnyPlainLine(r) { for _, sub := range expandDirections(r) { if err := f.RemoveRule(ctx, zoneName, sub); err != nil { return err } } return nil } // Clear any hook copy of the rule first, no matter how apf stores it. A rule apf // carries only in the hook (stateful/interface/logged/rate-limited/icmpv6/ // hook-only-proto) lives nowhere else, so this is its entire removal; a natively- // expressible rule may still have a stray hook copy — the library's own // differing-action deny (see AddRule) or a hand-added duplicate for a shape apf can // also express natively — that must be cleared before the native entry below. DirAny // is expanded so both one-way hook lines are matched; a rule with no hook copy makes // this a harmless no-op. var err error for _, sub := range expandDirections(r) { changed, e := f.hook().edit(sub, true) f.ConfigChanged = f.ConfigChanged || changed if e != nil { err = e break } } // A rule apf carries only in the hook has no native entry to fall through to, so // return once its hook copy is cleared (or on any hook error). Returning here also // keeps such a rule out of the split scans below, whose plain-line/CPORTS checks // could wrongly split an unrelated coexisting native entry. A native ICMPv6 type // rule lives in conf.apf, not the hook, so it is excluded and routed there below. if (ruleNeedsHook(r) && !f.nativeICMPv6(r)) || err != nil { return err } // A one-way bare host rule is stored either as its own hook rule or as one // direction of a bidirectional plain line; removing it may need to split the // plain line (see removeBareHostOneWay). if bareHostOneWay(r) { return f.removeBareHostOneWay(ctx, zoneName, r) } // A single-family bare tcp/udp port accept is stored either as its own per-family // hook rule or as one family of a dual-stack CPORTS entry; removing it may need to // split that entry (see removeDualStackPort). if f.dualStackPortNeedsHook(r) { return f.removeDualStackPort(ctx, r) } // A FamilyAny bare tcp/udp port accept is stored in a dual-stack CPORTS entry, in a // v4+v6 hook pair (separate concrete-family adds GetRules merged back), or split // across both; removeFamilyAnyPort clears it from every backing (see there). if f.barePortAccept(r) { return f.removeFamilyAnyPort(ctx, r) } // Every other shape apf's native config cannot express (see needsHook) has already // had its hook copy cleared above and has no native entry to split, so it is done. if f.needsHook(r) { return nil } if err := iptablesRuleValid(r); err != nil { return fmt.Errorf("%v: %w", err, ErrUnsupported) } // A native connection-limit rule maps onto conf.apf's IG_*_CLIMIT lists. if r.ConnLimit != nil { return f.EditConf(ctx, r, true) } // Address-less accept rules (dual-stack port lists, icmp types) live in conf.apf. if f.isConfRule(r) { return f.EditConf(ctx, r, true) } // Otherwise edit allow_hosts.rules for accepts, deny_hosts.rules for denies. if r.Action == Accept { return f.EditIPList(ctx, APFAllow, Accept, r, true) } 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) { line = strings.TrimSpace(line) var fam Family var rest string switch { case strings.HasPrefix(line, "iptables -t nat "): fam, rest = IPv4, strings.TrimPrefix(line, "iptables -t nat ") case strings.HasPrefix(line, "ip6tables -t nat "): fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables -t nat ") default: return nil, false } ipt := &IPTables{rulePrefix: f.rulePrefix} r, err := ipt.UnmarshalNATRule(rest, fam) if err != nil { return nil, false } 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) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } defer func() { _ = fd.Close() }() var rules []*NATRule scanner := bufio.NewScanner(fd) for scanner.Scan() { line := scanner.Text() if ci := strings.IndexByte(line, '#'); ci >= 0 { line = line[:ci] } line = strings.TrimSpace(line) if line == "" { continue } if r, ok := f.parseNATLine(line); ok { rules = append(rules, r) } } if err := scanner.Err(); err != nil { return nil, err } 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 for _, path := range []string{APFPreroute, APFPostroute} { parsed, err := f.parseNATFile(path) if err != nil { return nil, err } rules = append(rules, parsed...) } // APF's routing files hold raw iptables nat commands, so a rule this library // added carries the configured prefix in its -m comment tag and UnmarshalNATRule // derives HasPrefix from it (an empty prefix writes no tag, so HasPrefix is // false; a rule hand-added without the tag likewise reports false). merged := mergeNATFamilies(rules) 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. func (f *APF) natFamilies(r *NATRule) []Family { switch r.impliedFamily() { case IPv4: return []Family{IPv4} case IPv6: return []Family{IPv6} default: return []Family{IPv4, IPv6} } } // 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). func (f *APF) natFile(r *NATRule) string { if r.Kind.isSource() { return APFPostroute } 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 { return "ip6tables" } 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) { ipt := &IPTables{rulePrefix: f.rulePrefix} rc := *r rc.Family = fam spec, err := ipt.MarshalNATRule(&rc) if err != nil { return "", err } 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. func (f *APF) editNATFile(r *NATRule, remove bool) error { path := f.natFile(r) // The line(s) this rule contributes, one per family it targets. want := make(map[string]bool) for _, fam := range f.natFamilies(r) { line, err := f.natLine(r, fam) if err != nil { return err } want[line] = true } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { if remove { return nil } data = nil } else { return err } } lines := strings.Split(string(data), "\n") // Drop the trailing empty element left by a final newline so repeated adds do // not accumulate blank lines. if len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } out := make([]string, 0, len(lines)+len(want)) present := make(map[string]bool) changed := false for _, raw := range lines { body := raw if ci := strings.IndexByte(body, '#'); ci >= 0 { body = body[:ci] } body = strings.TrimSpace(body) // Match either the exact line we would write or an equivalent NAT rule. On a // match, record which of our want-lines this existing line satisfies so the // add path below does not append a duplicate: an exact match satisfies its own // text; a fuzzy (equivalent) match satisfies the want-line for the same family. matched := false satisfied := "" if want[body] { matched, satisfied = true, body } else if body != "" { // The fuzzy fallback (an equivalent line whose text differs from ours) // must stay family-aware (EqualForRemoval): a family-agnostic rule fans // into a v4 and a v6 line in the same direction file, so without the gate // a family-scoped removal would also drop the opposite family's twin. if existing, ok := f.parseNATLine(body); ok && existing.EqualForRemoval(r) { matched = true // The equivalent want-line is the one for this existing line's family; // mark it satisfied so its duplicate is not appended below. Recompute it // (rather than reuse body) since body is the existing spelling, not ours. if line, lerr := f.natLine(r, existing.impliedFamily()); lerr == nil { satisfied = line } } } if matched { if remove { changed = true continue } if satisfied != "" { present[satisfied] = true } } out = append(out, raw) } if remove { if !changed { return nil } } else { added := false for line := range want { if !present[line] { out = append(out, line) added = true } } if !added { return nil } } content := strings.Join(out, "\n") if !strings.HasSuffix(content, "\n") { content += "\n" } if err := writeConfigFile(path, []byte(content), 0600); err != nil { return err } f.ConfigChanged = true 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 { return err } 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 { return err } 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) if err != nil { return nil, err } natRules, err := f.GetNATRules(ctx, zoneName) if err != nil { return nil, err } // Backup captures the full filter and NAT rule state; Restore removes the // current rules and re-adds these, so every rule read is preserved. 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 { return fmt.Errorf("backup cannot be nil") } // Remove existing rules. existing, err := f.GetRules(ctx, zoneName) if err != nil { return err } for _, r := range existing { if err := f.RemoveRule(ctx, zoneName, r); err != nil { return err } } existingNAT, err := f.GetNATRules(ctx, zoneName) if err != nil { return err } for _, r := range existingNAT { if err := f.RemoveNATRule(ctx, zoneName, r); err != nil { return err } } // Re-add rules from backup. for _, r := range backup.Rules { if err := f.addRule(ctx, zoneName, r, false); err != nil { return err } } for _, r := range backup.NATRules { if err := f.AddNATRule(ctx, zoneName, r); err != nil { return err } } 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()) } // Reload restarts apf to apply config changes, but only when a mutation changed its files. func (f *APF) Reload(ctx context.Context) error { // apf --restart rewrites and reloads the whole ruleset, which is disruptive, so // only restart when a mutation actually changed apf's config files. if f.ConfigChanged { _, err := runCommand(ctx, "/etc/apf/apf", "--restart") if err != nil { return err } } 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.