2196 lines
81 KiB
Go
2196 lines
81 KiB
Go
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.
|