go-firewall/pf.go
James Coleman 073c9ad7f0 Add address-set support to APF/CSF and ipset reboot persistence to iptables
iptables: persist ipsets across reboot to match rule persistence.
- Detect the ipset save-file + restore unit (RHEL ipset.service /
  /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets),
  non-fatally.
- After each set mutation, `ipset save` into the layout's save-file and
  auto-enable a present-but-disabled restore unit; warn when no mechanism
  exists (sets stay live-only).
- Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230;
  CentOS 7 ships 219).

APF/CSF: gain address sets by persisting ipset commands in the pre-hook.
- The hook carries an `ipset create/flush/add` block ordered ahead of the
  `-m set --match-set` rule lines, so the firewall recreates the set on every
  (re)start before any rule references it.
- Route set-referencing rules (Source/Destination names an ipset) through the
  hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape).
- Implement the six address-set methods, advertise AddressSets, and wire sets
  into Backup/Restore via captureBackupState/restoreBackupSets.

Validated live: reboot simulation for iptables; generated-hook source for
APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and
set-ref routing; the capability-gated integration subtest now covers APF/CSF.
2026-07-08 17:20:38 -05:00

2119 lines
65 KiB
Go

//go:build darwin || freebsd
package firewall
import (
"bufio"
"context"
"fmt"
"net"
"os"
"strconv"
"strings"
)
// sanitizePFName reduces an arbitrary prefix to a safe pf anchor name, falling
// back to the default when nothing usable remains.
func sanitizePFName(prefix string) string {
var b strings.Builder
for _, r := range prefix {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
b.WriteRune(r)
case r == ' ' || r == '.':
b.WriteRune('_')
}
}
name := strings.Trim(b.String(), "_-")
if name == "" {
return PFDefaultAnchor
}
return name
}
// NewPF constructs a PF backend scoped to an anchor derived from rulePrefix,
// verifying pfctl is available and pf is enabled.
func NewPF(ctx context.Context, rulePrefix string) (*PF, error) {
pf := &PF{anchor: sanitizePFName(rulePrefix)}
// Confirm pfctl is available and pf is enabled; otherwise our rules would
// never take effect and we should let another manager (or none) be chosen.
out, err := runCommand(ctx, "pfctl", "-s", "info")
if err != nil {
return nil, fmt.Errorf("pfctl is not available: %s", err)
}
enabled := false
for _, line := range out {
if strings.HasPrefix(strings.TrimSpace(line), "Status: Enabled") {
enabled = true
break
}
}
if !enabled {
return nil, fmt.Errorf("pf is not enabled on this server")
}
return pf, nil
}
// Type returns the backend type string for pf.
func (f *PF) Type() string {
return PFType
}
// Capabilities returns the set of features the pf backend can express.
func (f *PF) Capabilities() Capabilities {
return Capabilities{
Output: true,
ICMPv6: true,
// pfctl expands a port list (`port { 80 443 }`) into one rule per port when
// it lists the ruleset, so a discrete multi-port rule does not round-trip as
// a single rule (a range, kept as one token, does). Report PortList as
// unsupported to reflect that; callers open several ports with a rule each.
PortList: false,
ConnState: false,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: false,
RuleCounters: true,
AddressSets: true,
Comments: true,
}
}
// GetZone reports no zone; pf has no interface-to-zone mapping in the model we expose.
func (f *PF) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
return "", nil
}
// parseAddr reads an address operand starting at index i, honoring an optional
// leading '!' negation (pf allows `! host` or `!host`). It returns the address,
// the negation prefix to prepend ("" or "!"), and the index of the last token
// consumed.
func (f *PF) parseAddr(tokens []string, i int) (val string, neg string, next int, err error) {
if i >= len(tokens) {
return "", "", 0, fmt.Errorf("missing address value")
}
tok := tokens[i]
if tok == "!" {
neg = "!"
i++
if i >= len(tokens) {
return "", "", 0, fmt.Errorf("missing address value")
}
tok = tokens[i]
} else if strings.HasPrefix(tok, "!") {
neg = "!"
// Strip the negation from a local copy; the caller's slice must not be
// mutated in place.
tok = strings.TrimPrefix(tok, "!")
}
return tok, neg, i, nil
}
// parseICMPType resolves a pf icmp-type token: a number, or a name known to
// the ICMP name tables. When v6 is true the token is an icmp6-type and is
// resolved through the ICMPv6 tables (standard names and pfctl's own spellings);
// otherwise the ICMPv4 tables are used. Numbers parse the same in either family.
func (f *PF) parseICMPType(tok string, v6 bool) (uint8, bool) {
if n, ok := parseICMPTypeFamily(tok, v6); ok {
return n, true
}
if v6 {
n, ok := pfICMP6TypeNames[tok]
return n, ok
}
n, ok := pfICMPTypeNames[tok]
return n, ok
}
// lookupPort resolves a pf port token to its number. pfctl prints a well-known
// port by its /etc/services name (22 -> ssh, 80 -> http, ...), so a non-numeric
// token is looked up as a service name. pf does not record the protocol alongside
// the name, so both tcp and udp are tried.
func (f *PF) lookupPort(tok string) (uint16, error) {
tok = strings.TrimSpace(tok)
if n, err := strconv.ParseUint(tok, 10, 16); err == nil {
return uint16(n), nil
}
for _, netw := range []string{"tcp", "udp"} {
if p, err := net.LookupPort(netw, tok); err == nil {
return uint16(p), nil
}
}
return 0, fmt.Errorf("invalid port %q", tok)
}
// parsePortRange parses a pf port token that may be a service name, a number, or
// a "lo:hi" range with either endpoint named.
func (f *PF) parsePortRange(tok string) (PortRange, error) {
lo, hi, isRange := strings.Cut(tok, ":")
start, err := f.lookupPort(lo)
if err != nil {
return PortRange{}, err
}
pr := PortRange{Start: start, End: start}
if isRange {
end, err := f.lookupPort(hi)
if err != nil {
return PortRange{}, err
}
pr.End = end
}
return pr, nil
}
// parsePorts reads a pf port operand starting at index i: a single value, a
// range, or a `{ ... }` list. It returns the parsed specs and the index of the
// last token consumed.
func (f *PF) parsePorts(tokens []string, i int) (specs []PortRange, next int, err error) {
if i >= len(tokens) {
return nil, 0, fmt.Errorf("missing port value")
}
if tokens[i] == "{" {
for i++; i < len(tokens); i++ {
if tokens[i] == "}" {
return specs, i, nil
}
m := strings.Trim(tokens[i], ",")
if m == "" {
continue
}
pr, perr := f.parsePortRange(m)
if perr != nil {
return nil, 0, perr
}
specs = append(specs, pr)
}
return nil, 0, fmt.Errorf("unterminated port set")
}
pr, perr := f.parsePortRange(tokens[i])
if perr != nil {
return nil, 0, perr
}
return []PortRange{pr}, i, nil
}
// rateUnitFromSeconds maps a pf rate window in seconds back to a RateUnit,
// falling back to PerSecond for a window that matches no named unit.
func (f *PF) rateUnitFromSeconds(s int) RateUnit {
switch s {
case 60:
return PerMinute
case 3600:
return PerHour
case 86400:
return PerDay
}
return PerSecond
}
// parseStateOpts parses a pf state-option group `( ... )` starting at
// tokens[i] (strings.Fields has split it on spaces, so the members are
// reassembled) and records any rate/connection limits on r. It returns the
// index of the token that closed the group.
func (f *PF) parseStateOpts(tokens []string, i int, r *Rule) (int, error) {
var b strings.Builder
for ; i < len(tokens); i++ {
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(tokens[i])
if strings.HasSuffix(tokens[i], ")") {
break
}
}
group := strings.TrimSpace(b.String())
group = strings.TrimSuffix(strings.TrimPrefix(group, "("), ")")
for _, opt := range strings.Split(group, ",") {
fields := strings.Fields(strings.TrimSpace(opt))
if len(fields) < 2 {
continue
}
switch fields[0] {
case "max-src-conn":
n, err := strconv.ParseUint(fields[1], 10, 32)
if err != nil {
return 0, fmt.Errorf("invalid max-src-conn %q", fields[1])
}
r.ConnLimit = &ConnLimit{Count: uint(n), PerSource: true}
case "max-src-conn-rate":
cnt, secs, ok := strings.Cut(fields[1], "/")
if !ok {
return 0, fmt.Errorf("invalid max-src-conn-rate %q", fields[1])
}
n, err := strconv.ParseUint(cnt, 10, 32)
if err != nil {
return 0, fmt.Errorf("invalid rate %q", fields[1])
}
s, err := strconv.Atoi(secs)
if err != nil {
return 0, fmt.Errorf("invalid rate window %q", fields[1])
}
r.RateLimit = &RateLimit{Rate: uint(n), Unit: f.rateUnitFromSeconds(s)}
}
}
return i, nil
}
// stripTable removes the angle brackets pf prints around a table reference,
// yielding the bare set name stored in Source/Destination.
func (f *PF) stripTable(v string) string {
if strings.HasPrefix(v, "<") && strings.HasSuffix(v, ">") {
return v[1 : len(v)-1]
}
return v
}
// UnmarshalRule decodes a single pf rule line as produced by `pfctl -sr`. pfctl
// normalizes rules (e.g. `port = 23`, trailing `flags S/SA keep state`), so the
// parser is tolerant of the extra tokens it emits.
func (f *PF) UnmarshalRule(line string) (*Rule, error) {
r := new(Rule)
tokens := strings.Fields(line)
if len(tokens) == 0 {
return nil, fmt.Errorf("empty rule")
}
i := 0
// Action.
switch tokens[i] {
case "pass":
r.Action = Accept
i++
case "block":
i++
// Optional block variant: drop / return / return-* .
if i < len(tokens) {
switch {
case tokens[i] == "drop":
r.Action = Drop
i++
case tokens[i] == "return" || strings.HasPrefix(tokens[i], "return-"):
r.Action = Reject
i++
default:
// A bare `block` defaults to drop in pf.
r.Action = Drop
}
} else {
r.Action = Drop
}
default:
return nil, fmt.Errorf("unsupported action: %s", tokens[i])
}
// Direction.
if i >= len(tokens) {
return nil, fmt.Errorf("missing direction")
}
switch tokens[i] {
case "in":
r.Direction = DirInput
i++
case "out":
r.Direction = DirOutput
i++
default:
return nil, fmt.Errorf("unsupported direction: %s", tokens[i])
}
for ; i < len(tokens); i++ {
switch tokens[i] {
case "quick", "all", "flags":
// Tokens with no bearing on our rule model. `flags S/SA` trails
// stateful pass rules; skip the qualifier that follows flags.
if tokens[i] == "flags" && i+1 < len(tokens) {
i++
}
case "log":
r.Log = true
// pf may print a parenthesized option group (e.g. `log (all)`);
// skip it.
if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "(") {
i++
for i < len(tokens) && !strings.HasSuffix(tokens[i], ")") {
i++
}
}
case "keep", "modulate", "synproxy":
// State tracking: `keep state [(opts)]`. Consume the `state` keyword
// and parse any parenthesized options for rate/connection limits.
if i+1 < len(tokens) && tokens[i+1] == "state" {
i++
}
if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "(") {
next, perr := f.parseStateOpts(tokens, i+1, r)
if perr != nil {
return nil, perr
}
i = next
}
case "label":
// A user comment, carried as a pf label emitted last as
// `label "<quoted>"`. Recover it from the original line rather than the
// whitespace-collapsed tokens so a run of spaces inside the label is not
// folded, slicing from the keyword's quote to end of line and reversing the
// marshal-time strconv.Quote with strconv.Unquote (falling back to a plain
// trim if the token is not a well-formed quoted string).
var joined string
if idx := strings.Index(line, `label "`); idx >= 0 {
joined = line[idx+len("label "):]
} else {
joined = strings.Join(tokens[i+1:], " ")
}
if unq, uerr := strconv.Unquote(joined); uerr == nil {
r.Comment = unq
} else {
r.Comment = trimQuotes(joined)
}
i = len(tokens)
case "state":
// A bare state keyword with no preceding `keep`; nothing to record.
case "on":
// Interface binding, tied to the rule direction.
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing interface value")
}
if r.IsOutput() {
r.OutInterface = tokens[i]
} else {
r.InInterface = tokens[i]
}
case "inet":
r.Family = IPv4
case "inet6":
r.Family = IPv6
case "proto":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing protocol value")
}
r.Proto = GetProtocol(tokens[i])
if r.Proto == ProtocolAny {
return nil, fmt.Errorf("unsupported protocol: %s", tokens[i])
}
case "icmp-type", "icmp6-type":
v6 := tokens[i] == "icmp6-type"
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing icmp type value")
}
n, ok := f.parseICMPType(tokens[i], v6)
if !ok {
return nil, fmt.Errorf("invalid icmp type %q", tokens[i])
}
r.ICMPType = Ptr(n)
case "from":
val, neg, next, err := f.parseAddr(tokens, i+1)
if err != nil {
return nil, err
}
i = next
if val != "any" {
r.Source = neg + f.stripTable(val)
}
// A source port may follow: `from any port 80` (pfctl normalizes it to
// `from any port = 80`, so skip the operator as the destination case does).
if i+1 < len(tokens) && tokens[i+1] == "port" {
i += 2
if i >= len(tokens) {
return nil, fmt.Errorf("missing source port value")
}
if tokens[i] == "=" {
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing source port value")
}
}
specs, next, perr := f.parsePorts(tokens, i)
if perr != nil {
return nil, perr
}
i = next
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.SourcePort = specs[0].Start
} else {
r.SourcePorts = specs
}
}
case "to":
val, neg, next, err := f.parseAddr(tokens, i+1)
if err != nil {
return nil, err
}
i = next
if val != "any" {
r.Destination = neg + f.stripTable(val)
}
case "port":
// May be `port 23`, normalized `port = 23`, a range `port 1000:2000`
// or a list `port { 80 443 }`. This appears after `to any` and refers
// to the destination port.
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing port value")
}
if tokens[i] == "=" {
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing port value")
}
}
specs, next, err := f.parsePorts(tokens, i)
if err != nil {
return nil, err
}
i = next
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.Port = specs[0].Start
} else {
r.Ports = specs
}
default:
return nil, fmt.Errorf("unsupported token: %s", tokens[i])
}
}
// Infer the family from an address when pf did not print one.
if r.Family == FamilyAny {
addr := r.Source
if addr == "" {
addr = r.Destination
}
addr = strings.TrimPrefix(addr, "!")
if addr != "" {
ip, _, err := net.ParseCIDR(addr)
if err != nil {
ip = net.ParseIP(addr)
}
if ip != nil {
if ip.To4() == nil {
r.Family = IPv6
} else {
r.Family = IPv4
}
}
}
}
if r.Action == ActionInvalid {
return nil, fmt.Errorf("no valid action was provided")
}
return r, nil
}
// parseRuleCounters extracts the Packets and Bytes counts from a pfctl -vsr
// statistics continuation line, e.g.
// "[ Evaluations: 5 Packets: 10 Bytes: 600 States: 0 ]". It returns ok=false
// for a continuation line that carries no counters, so a non-statistics line is
// ignored rather than mistaken for a zeroed counter.
func (f *PF) parseRuleCounters(line string) (packets, bytes uint64, ok bool) {
fields := strings.Fields(strings.Trim(line, "[] "))
var haveP, haveB bool
for i := 0; i+1 < len(fields); i++ {
switch fields[i] {
case "Packets:":
if v, err := strconv.ParseUint(fields[i+1], 10, 64); err == nil {
packets, haveP = v, true
}
case "Bytes:":
if v, err := strconv.ParseUint(fields[i+1], 10, 64); err == nil {
bytes, haveB = v, true
}
}
}
return packets, bytes, haveP && haveB
}
// parseAnchorRules decodes the lines of a `pfctl -a <anchor> -vsr` listing into
// rules and their raw rule text. A rule spans one rule line plus one or more
// indented `[ ... ]` continuation lines; the `[ ... Packets: N Bytes: N ... ]`
// line carries the rule's counters, which are attached to the rule it follows.
func (f *PF) parseAnchorRules(out []string) (rules []*Rule, raw []string) {
for _, line := range out {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// A verbose listing prefixes each rule with its "@N" ruleset index (pfctl
// prints it under -vv, and some pf versions under -v). It is not part of the
// rule and is invalid in a rules file, so strip it before parsing and before
// recording the raw line — loadAnchor feeds raw straight back to `pfctl -f`,
// which would reject a stray "@N". Only strip when the token after '@' is a
// number so a genuine rule never loses content.
if strings.HasPrefix(line, "@") {
if tok, rest, ok := strings.Cut(line, " "); ok {
if _, perr := strconv.ParseUint(tok[1:], 10, 64); perr == nil {
line = strings.TrimSpace(rest)
}
}
}
// A continuation line annotates the rule just parsed rather than starting a
// new one; pull its Packets/Bytes into that rule when present.
if strings.HasPrefix(line, "[") {
if len(rules) > 0 {
if p, b, ok := f.parseRuleCounters(line); ok {
rules[len(rules)-1].Packets = p
rules[len(rules)-1].Bytes = b
}
}
continue
}
rule, perr := f.UnmarshalRule(line)
if perr != nil {
// A line we cannot model is preserved as an opaque row: a nil rule with
// its raw text kept, so a read-modify-write rewrite of our anchor does
// not silently drop a foreign rule loaded into it. The nil keeps rules
// 1:1 with raw so the physical-row edits never misalign; GetRules, Backup
// and the position math skip the nil entries.
rules = append(rules, nil)
raw = append(raw, line)
continue
}
// Rules loaded in this backend's own anchor: membership in the library's
// private anchor is what sets HasPrefix, so record the anchor and flag it
// as carrying the prefix.
rule.table = f.anchor
rule.HasPrefix = true
rules = append(rules, rule)
raw = append(raw, line)
}
return rules, raw
}
// anchorRules returns the filter rules currently loaded in our anchor.
func (f *PF) anchorRules(ctx context.Context) (rules []*Rule, raw []string, err error) {
// Read with -vsr so pfctl prints each rule's per-rule counters on a following
// `[ Evaluations: N Packets: N Bytes: N States: N ]` continuation line,
// which parseAnchorRules attaches to the preceding rule (RuleCounters).
out, err := runCommand(ctx, "pfctl", "-a", f.anchor, "-vsr")
if err != nil {
// Propagate a genuine pfctl failure rather than reporting an empty anchor: a
// referenced anchor lists nothing with a zero exit when empty, so an error
// here is a real read failure.
return nil, nil, err
}
rules, raw = f.parseAnchorRules(out)
return rules, raw, nil
}
// compactRules drops the opaque (nil) placeholder rows parseAnchorRules keeps
// for lines it cannot model, leaving only the rules the library represents. The
// read/merge/number and backup paths use it, since those operate on the modeled
// rule set (a []*Rule cannot carry an unparseable line).
func (f *PF) compactRules(rules []*Rule) []*Rule {
out := make([]*Rule, 0, len(rules))
for _, r := range rules {
if r != nil {
out = append(out, r)
}
}
return out
}
// listForeignRules returns best-effort filter rules loaded outside this backend's
// own anchor — the main ruleset and any other anchors. pf has no JSON mode and
// foreign rules may use constructs the library's Rule model cannot represent, so
// any line that fails to parse is skipped rather than erroring the read. Callers
// gain visibility of rules in other anchors alongside the library's own.
func (f *PF) listForeignRules(ctx context.Context) []*Rule {
var rules []*Rule
// table records where each foreign rule came from ("" for the main ruleset, the
// anchor name otherwise); it is not ours, so HasPrefix stays false.
parse := func(out []string, table string) {
for _, line := range out {
line = strings.TrimSpace(line)
if line == "" {
continue
}
rule, perr := f.UnmarshalRule(line)
if perr != nil || rule == nil {
continue
}
rule.table = table
rules = append(rules, rule)
}
}
// The main ruleset (our anchor appears only as an `anchor "..."` placeholder
// line here, which does not parse as a rule, so there is no overlap).
if out, err := runCommand(ctx, "pfctl", "-sr"); err == nil {
parse(out, "")
}
// Every other anchor; our own is already read precisely by anchorRules.
if names, err := runCommand(ctx, "pfctl", "-s", "Anchors"); err == nil {
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" || name == f.anchor {
continue
}
if out, err := runCommand(ctx, "pfctl", "-a", name, "-sr"); err == nil {
parse(out, name)
}
}
}
return rules
}
// GetRules returns the existing filter rules from the zone.
func (f *PF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
rules, _, err = f.anchorRules(ctx)
if err != nil {
return nil, err
}
// Drop the opaque placeholder rows kept for unmodeled anchor lines; GetRules
// reports only the rules the library can represent.
rules = f.compactRules(rules)
// Merge the library's own IPv4/IPv6 pairs, then number the anchor's rules as one
// ordered list (pf evaluates a single filter list, so its position spans
// directions). Numbering after the merge keeps a collapsed pair from leaving a
// gap. Foreign rules appended below live outside this anchor and keep Number 0.
rules = mergeFamilies(rules)
numberSequential(rules)
// Collapse each input/output twin into one DirAny rule. Numbering first keeps
// the surviving rows' sequential position intact.
rules = mergeDirections(rules)
rules = append(rules, f.listForeignRules(ctx)...)
return rules, nil
}
// addrToken renders a source/destination value for a pf rule: a bare address, or
// a table reference `<name>` when the token names an address set. The caller emits
// any leading "!" negation separately.
func (f *PF) addrToken(bare string) string {
if _, ok := canonAddr(bare); !ok {
return "<" + bare + ">"
}
return bare
}
// portMember renders one port spec in pf syntax: "80" for a single port or
// "1000:2000" for a range.
func (f *PF) portMember(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)
}
// portExpr renders a destination port match: a bare value for a single spec or
// a pf list `{ 80 443 1000:2000 }` for several.
func (f *PF) portExpr(specs []PortRange) string {
if len(specs) == 1 {
return f.portMember(specs[0])
}
members := make([]string, len(specs))
for i, pr := range specs {
members[i] = f.portMember(pr)
}
return "{ " + strings.Join(members, " ") + " }"
}
// protoName returns the protocol keyword pf uses; pf spells ICMPv6 as
// `icmp6`.
func (f *PF) protoName(p Protocol) string {
if p == ICMPv6 {
return "icmp6"
}
return p.String()
}
// pfICMPTypeNames maps the icmp-type names pfctl prints (which differ from the
// hyphenated aliases in icmpNameToNum, e.g. `echoreq` vs `echo-request`) to their
// numeric type, so an icmp-type match round-trips whether pfctl emits a number or
// a name.
var pfICMPTypeNames = map[string]uint8{
"echorep": 0, "unreach": 3, "squench": 4, "redir": 5, "althost": 6,
"echoreq": 8, "routeradv": 9, "routersol": 10, "timex": 11,
"paramprob": 12, "timereq": 13, "timerep": 14, "inforeq": 15,
"inforep": 16, "maskreq": 17, "maskrep": 18, "trace": 30,
"dataconv": 31, "mobredir": 32, "ipv6-where": 33, "ipv6-here": 34,
"mobregreq": 35, "mobregrep": 36, "skip": 39, "photuris": 40,
}
// pfICMP6TypeNames maps the icmp6-type names pfctl prints to their numeric
// ICMPv6 type. Several spellings collide with the ICMPv4 names in
// pfICMPTypeNames but mean a different number (e.g. `unreach` is 3 for ICMPv4
// but 1 for ICMPv6, `echoreq` is 8 vs 128), so an icmp6-type match must be
// resolved through this table rather than the ICMPv4 one.
var pfICMP6TypeNames = map[string]uint8{
"unreach": 1, "toobig": 2, "timex": 3, "paramprob": 4,
"echoreq": 128, "echorep": 129,
"groupqry": 130, "listqry": 130, "grouprep": 131, "listenrep": 131,
"groupterm": 132, "listendone": 132,
"routersol": 133, "routeradv": 134, "neighbrsol": 135, "neighbradv": 136,
"redir": 137, "routrrenum": 138,
"fqdnreq": 139, "niqry": 139, "fqdnrep": 140, "nirep": 140,
}
// rateUnitSeconds converts a RateUnit to the number of seconds pf expresses a
// rate over (pf writes max-src-conn-rate as <count>/<seconds>).
func (f *PF) rateUnitSeconds(u RateUnit) int {
switch u {
case PerMinute:
return 60
case PerHour:
return 3600
case PerDay:
return 86400
}
return 1
}
// MarshalRule encodes a rule as a pf rule line suitable for loading into our
// anchor. Rules are marked `quick` so the first match wins, matching the
// allow/deny-list semantics of the other backends.
func (f *PF) MarshalRule(r *Rule) (string, error) {
// pf filters by the interface a packet passes on (`pass in`/`pass out`) and has
// no distinct forward chain, so a forward rule cannot be expressed in this model.
if r.IsForward() {
return "", unsupportedForward("pf")
}
// pf can only match a port alongside a concrete transport protocol.
if r.PortNeedsConcreteProtocol() {
return "", fmt.Errorf("a port requires a tcp, udp or sctp protocol")
}
if err := r.checkICMPType(); err != nil {
return "", err
}
// pfctl expands a discrete source-port list into one rule per port on read, so it
// would not round-trip as a single rule; reject it (a contiguous range is one
// token and does round-trip).
if len(r.SourcePortSpecs()) > 1 {
return "", fmt.Errorf("pf cannot express a source-port list as a single rule: %w", ErrUnsupportedSourcePort)
}
// pfctl expands a discrete destination-port list the same way (the PortList
// capability is false), so reject a genuine list of more than one spec.
if len(r.PortSpecs()) > 1 {
return "", fmt.Errorf("pf cannot express a destination-port list as a single rule: %w", ErrUnsupported)
}
// pf keeps state on pass rules automatically; it has no equivalent of the
// conntrack-state match the model exposes, so reject rather than drop it.
if r.State != 0 {
return "", fmt.Errorf("pf does not support connection-state matching: %w", ErrUnsupportedState)
}
// pf's `log` keyword carries no text prefix, so a LogPrefix cannot be
// represented; reject rather than silently drop the label.
if r.LogPrefix != "" {
return "", fmt.Errorf("pf does not support a log prefix: %w", ErrUnsupportedLog)
}
// A pf rule binds a single interface via `on`, tied to the rule direction.
if r.IsOutput() && r.InInterface != "" {
return "", fmt.Errorf("an input interface cannot be matched on an output rule")
}
if !r.IsOutput() && r.OutInterface != "" {
return "", fmt.Errorf("an output interface cannot be matched on an input rule")
}
var parts []string
// Action.
switch r.Action {
case Accept:
parts = append(parts, "pass")
case Drop:
parts = append(parts, "block", "drop")
case Reject:
parts = append(parts, "block", "return")
default:
return "", fmt.Errorf("no valid action was provided")
}
// Direction.
if r.IsOutput() {
parts = append(parts, "out")
} else {
parts = append(parts, "in")
}
// Logging, emitted right after the direction as pfctl normalizes it. Packet
// capture requires a pflog interface; the rule syntax is valid regardless.
if r.Log {
parts = append(parts, "log")
}
// First match wins.
parts = append(parts, "quick")
// Interface, bound to the rule's direction.
iface := r.InInterface
if r.IsOutput() {
iface = r.OutInterface
}
if iface != "" {
parts = append(parts, "on", iface)
}
// Address family. pf requires an explicit family for an icmp-type/icmp6-type
// match, and an ICMP protocol implies one (ICMP => inet, ICMPv6 => inet6), so
// resolve it rather than emitting the family only when set explicitly.
switch r.impliedFamily() {
case IPv4:
parts = append(parts, "inet")
case IPv6:
parts = append(parts, "inet6")
}
// Protocol.
if r.Proto != ProtocolAny {
parts = append(parts, "proto", f.protoName(r.Proto))
}
// Source and optional source port(s).
srcSpecs := r.SourcePortSpecs()
if r.Source != "" {
// A non-address token names a pf table, referenced as `<name>`.
neg, bare := splitAddrNeg(r.Source)
if neg {
parts = append(parts, "from", "!", f.addrToken(bare))
} else {
parts = append(parts, "from", f.addrToken(bare))
}
if len(srcSpecs) > 0 {
parts = append(parts, "port", f.portExpr(srcSpecs))
}
} else if len(srcSpecs) > 0 {
parts = append(parts, "from", "any", "port", f.portExpr(srcSpecs))
} else {
parts = append(parts, "from", "any")
}
// Destination and optional destination port(s).
dstSpecs := r.PortSpecs()
if r.Destination != "" {
neg, bare := splitAddrNeg(r.Destination)
if neg {
parts = append(parts, "to", "!", f.addrToken(bare))
} else {
parts = append(parts, "to", f.addrToken(bare))
}
if len(dstSpecs) > 0 {
parts = append(parts, "port", f.portExpr(dstSpecs))
}
} else if len(dstSpecs) > 0 {
parts = append(parts, "to", "any", "port", f.portExpr(dstSpecs))
} else {
parts = append(parts, "to", "any")
}
// An ICMP type match. pf places it after the from/to addresses (and requires
// the address family emitted above); it spells the ICMPv6 keyword icmp6-type.
if r.Proto.IsICMP() && r.ICMPType != nil {
kw := "icmp-type"
if r.Proto == ICMPv6 {
kw = "icmp6-type"
}
parts = append(parts, kw, strconv.FormatUint(uint64(*r.ICMPType), 10))
}
// Rate / connection limits. pf expresses these as per-source state-tracking
// options, valid only on stateful pass rules.
if r.RateLimit != nil || r.ConnLimit != nil {
if r.Action != Accept {
return "", fmt.Errorf("pf rate/connection limiting is only supported on accept rules: %w", ErrUnsupported)
}
var opts []string
if r.ConnLimit != nil {
if !r.ConnLimit.PerSource {
return "", fmt.Errorf("pf connection limiting is per-source only: %w", ErrUnsupportedConnLimit)
}
opts = append(opts, fmt.Sprintf("max-src-conn %d", r.ConnLimit.Count))
}
if r.RateLimit != nil {
// pf's max-src-conn-rate has no burst term, so a requested burst cannot
// be honored. Reject it rather than emit a rule that reads back with
// Burst 0 and fails rule-identity comparison.
if r.RateLimit.Burst != 0 {
return "", fmt.Errorf("pf does not support a rate-limit burst: %w", ErrUnsupported)
}
opts = append(opts, fmt.Sprintf("max-src-conn-rate %d/%d", r.RateLimit.Rate, f.rateUnitSeconds(r.RateLimit.Unit)))
}
parts = append(parts, "keep", "state", "("+strings.Join(opts, ", ")+")")
}
// An optional user comment, carried as a pf rule label. It has no effect on
// matching and is ignored when comparing rules.
if r.Comment != "" {
parts = append(parts, "label", strconv.Quote(r.Comment))
}
return strings.Join(parts, " "), nil
}
// UnmarshalNATRule decodes a single pf nat/rdr rule line as produced by
// `pfctl -a <anchor> -sn`.
func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) {
tokens := strings.Fields(line)
if len(tokens) == 0 {
return nil, fmt.Errorf("empty rule")
}
r := new(NATRule)
i := 0
switch tokens[i] {
case "rdr":
r.Kind = DNAT
case "nat":
r.Kind = SNAT // Refined to Masquerade below if the target is dynamic.
default:
return nil, fmt.Errorf("unsupported nat action: %s", tokens[i])
}
i++
for ; i < len(tokens); i++ {
switch tokens[i] {
case "pass", "quick", "log":
// Qualifiers with no bearing on our model.
case "all":
// pfctl prints `all` for `from any to any`.
case "round-robin", "random", "source-hash", "bitmask", "static-port", "sticky-address":
// Address-pool / port options pfctl appends to a nat rule; ignored.
case "on":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing interface value")
}
r.Interface = tokens[i]
case "inet":
r.Family = IPv4
case "inet6":
r.Family = IPv6
case "proto":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing protocol value")
}
r.Proto = GetProtocol(tokens[i])
if r.Proto == ProtocolAny {
return nil, fmt.Errorf("unsupported protocol: %s", tokens[i])
}
case "from":
val, neg, next, err := f.parseAddr(tokens, i+1)
if err != nil {
return nil, err
}
i = next
if val != "any" {
r.Source = neg + f.stripTable(val)
}
case "to":
val, neg, next, err := f.parseAddr(tokens, i+1)
if err != nil {
return nil, err
}
i = next
if val != "any" {
r.Destination = neg + f.stripTable(val)
}
case "port":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing port value")
}
if tokens[i] == "=" {
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing port value")
}
}
specs, next, err := f.parsePorts(tokens, i)
if err != nil {
return nil, err
}
i = next
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.Port = specs[0].Start
} else {
r.Ports = specs
}
case "->":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("missing nat target")
}
target := tokens[i]
if strings.HasPrefix(target, "(") {
// A dynamic interface address is masquerade.
r.Kind = Masquerade
} else {
r.ToAddress = target
// An optional `port N` gives the translation port. pfctl prints a
// well-known target port by its /etc/services name (80 -> http), just
// like a match port, so resolve it through lookupPort rather than a
// number-only parse — otherwise a named target port fails to parse and
// the whole rule is dropped from the snapshot.
if i+2 < len(tokens) && tokens[i+1] == "port" {
p, err := f.lookupPort(tokens[i+2])
if err != nil {
return nil, fmt.Errorf("invalid nat target port %q", tokens[i+2])
}
r.ToPort = p
i += 2
}
}
default:
return nil, fmt.Errorf("unsupported token: %s", tokens[i])
}
}
if r.Family == FamilyAny {
r.Family = r.impliedFamily()
}
if r.Kind == NATInvalid {
return nil, fmt.Errorf("no nat action was provided")
}
return r, nil
}
// anchorNATRules returns the nat/rdr rules currently loaded in our anchor.
func (f *PF) anchorNATRules(ctx context.Context) (rules []*NATRule, raw []string, err error) {
out, err := runCommand(ctx, "pfctl", "-a", f.anchor, "-sn")
if err != nil {
// Propagate a genuine failure rather than a false-empty snapshot; see
// anchorRules for why swallowing it risks silently dropping loaded rules.
return nil, nil, err
}
for _, line := range out {
line = strings.TrimSpace(line)
if line == "" {
continue
}
rule, perr := f.UnmarshalNATRule(line)
if perr != nil {
// Preserve a line we cannot model as an opaque row; see parseAnchorRules
// for why nil keeps rules 1:1 with raw so a rewrite does not drop it.
rules = append(rules, nil)
raw = append(raw, line)
continue
}
// NAT rules loaded in this backend's own anchor: membership is what sets
// HasPrefix, so record the anchor and flag it as carrying the prefix.
rule.table = f.anchor
rule.HasPrefix = true
rules = append(rules, rule)
raw = append(raw, line)
}
return rules, raw, nil
}
// readFileLines reads a file and returns its lines with trailing newlines
// stripped.
func (f *PF) readFileLines(path string) ([]string, error) {
fd, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = fd.Close() }()
var lines []string
scanner := bufio.NewScanner(fd)
// pf.conf can carry a very long single line (e.g. a large table macro); raise
// the token cap well above the default 64 KB so such a line is not rejected.
scanner.Buffer(make([]byte, 0, 64*1024), 64*1024*1024)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
// writeFileLines atomically replaces path with the provided lines by writing to
// a uniquely-named temp file in the same directory and renaming it into place.
// The original file's mode and ownership are preserved (defaulting to 0600 for a
// new file) so a rewrite never loosens restrictive permissions.
func (f *PF) writeFileLines(path string, lines []string) error {
af, err := newAtomicFile(path, 0600)
if err != nil {
return err
}
defer af.Abort()
w := bufio.NewWriter(af)
for _, line := range lines {
_, _ = fmt.Fprintln(w, line)
}
if err := w.Flush(); err != nil {
return err
}
return af.Commit()
}
const (
// PFType is the backend type string reported by PF.Type.
PFType = "pf"
// PFDefaultAnchor is the pf anchor name used when no rule prefix is supplied.
PFDefaultAnchor = "go_firewall"
// PFConf is the main pf configuration file.
PFConf = "/etc/pf.conf"
)
// PF manages firewall rules through OpenBSD's Packet Filter (pf), used on both
// macOS and FreeBSD. To avoid disturbing rules owned by the base system, this
// backend keeps every rule it creates inside a private anchor (named after the
// rule prefix). The anchor is referenced from the main pf.conf so its rules are
// evaluated, but the rules themselves are loaded and read through pfctl scoped
// to the anchor.
type PF struct {
// anchor is the pf anchor this backend owns.
anchor string
// ensured records whether the anchor reference has been added to pf.conf
// this session so we only check/patch it once.
ensured bool
// natEnsured records the same for the nat/rdr anchor references, added
// lazily only when a NAT rule is first written.
natEnsured bool
}
// ensureAnchor makes sure pf.conf references our anchor so that rules loaded
// into it are evaluated. If the reference is missing it is appended and pf.conf
// is reloaded. Filter anchors are evaluated in place, so appending keeps our
// rules after the base ruleset.
func (f *PF) ensureAnchor(ctx context.Context) error {
if f.ensured {
return nil
}
data, err := f.readFileLines(PFConf)
if err != nil {
return err
}
anchorRef := fmt.Sprintf(`anchor "%s"`, f.anchor)
for _, line := range data {
if strings.TrimSpace(line) == anchorRef {
f.ensured = true
return nil
}
}
// Append the anchor reference and reload the main ruleset.
data = append(data, anchorRef)
if err := f.writeFileLines(PFConf, data); err != nil {
return err
}
if _, err := runCommand(ctx, "pfctl", "-f", PFConf); err != nil {
return fmt.Errorf("failed to reload pf.conf after adding anchor: %s", err)
}
f.ensured = true
return nil
}
// pfFilterKeywords are the tokens that begin a pf filtering-section statement.
// Translation anchors (nat/rdr) must be declared before the first of these, so
// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly
// ordered options → normalization → queueing → translation → filtering, so the
// queueing keywords (altq/queue) are deliberately NOT included: they precede the
// translation section, and treating one as the boundary would splice the nat/rdr
// anchors ahead of the queueing statements, which pfctl -f rejects.
var pfFilterKeywords = map[string]bool{
"pass": true, "block": true, "match": true, "anchor": true,
"antispoof": true,
}
// loadAnchor replaces the anchor's ruleset with the provided rules. pf requires
// nat/rdr (translation) rules to precede filter rules in the ruleset, so they
// are written first. An empty combined set flushes the anchor.
func (f *PF) loadAnchor(ctx context.Context, natLines, filterLines []string) error {
all := make([]string, 0, len(natLines)+len(filterLines))
all = append(all, natLines...)
all = append(all, filterLines...)
stdin := strings.Join(all, "\n")
if stdin != "" {
stdin += "\n"
}
_, err := runCommandStdin(ctx, stdin, "pfctl", "-a", f.anchor, "-f", "-")
return err
}
// AddRule adds a rule to the zone.
func (f *PF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
// A DirAny rule fans out into an inbound rule plus its role-swapped outbound
// rule; add each as a concrete-direction anchor rule.
if r.Direction == DirAny {
for _, sub := range expandDirections(r) {
if err := f.AddRule(ctx, zoneName, sub); err != nil {
return err
}
}
return nil
}
line, err := f.MarshalRule(r)
if err != nil {
return err
}
rules, filterRaw, err := f.anchorRules(ctx)
if err != nil {
return err
}
// Skip if an equivalent rule already exists. Equal is family-aware (via
// impliedFamily): pf keeps inet and inet6 filter rules as separate objects and
// echoes a family-agnostic rule back without an af, so an IPv6 rule must not be
// treated as a duplicate of its IPv4 twin. (NAT differs — see RemoveNATRule.)
for _, e := range rules {
if e != nil && e.Equal(r, true) {
return nil
}
}
// Preserve any translation rules that share the anchor.
_, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
filterRaw = append(filterRaw, line)
return f.loadAnchor(ctx, natRaw, filterRaw)
}
// filterAnchors maps each logical (merged) filter rule to its physical row index
// in the anchor, skipping opaque (nil) rows so an unmodeled foreign line occupying
// a physical slot does not consume a logical position. With no opaque rows it
// equals mergedFamilyAnchors. It backs the merged-position insert/move mapping.
func (f *PF) filterAnchors(rules []*Rule) []int {
phys := make([]int, 0, len(rules))
modeled := make([]*Rule, 0, len(rules))
for i, r := range rules {
if r == nil {
continue
}
phys = append(phys, i)
modeled = append(modeled, r)
}
anchors := mergedFamilyAnchors(modeled)
for k := range anchors {
anchors[k] = phys[anchors[k]]
}
return anchors
}
// InsertRule inserts rule before the given 1-based position. position <= 0 is
// treated as 1; a position larger than the current rule count appends the rule.
func (f *PF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
// A DirAny rule occupies a row for each direction; insert its inbound row and
// its swapped outbound row, each at the requested position.
if r.Direction == DirAny {
for _, sub := range expandDirections(r) {
if err := f.InsertRule(ctx, zoneName, position, sub); err != nil {
return err
}
}
return nil
}
line, err := f.MarshalRule(r)
if err != nil {
return err
}
rules, filterRaw, err := f.anchorRules(ctx)
if err != nil {
return err
}
for _, e := range rules {
if e != nil && e.Equal(r, true) {
return nil
}
}
if position <= 0 {
position = 1
}
// position is a merged Number: GetRules collapses IPv4/IPv6 pairs and numbers
// the result, while the anchor holds one physical row per rule. Map through the
// merged anchors so a collapsed pair earlier in the list does not skew the
// placement (and does not split a family pair). rules is 1:1 with filterRaw, and
// filterAnchors skips any opaque (unmodeled) row so it does not consume a
// logical position.
idx := mergedInsertIndex(f.filterAnchors(rules), len(filterRaw), position)
filterRaw = append(filterRaw[:idx], append([]string{line}, filterRaw[idx:]...)...)
_, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
return f.loadAnchor(ctx, natRaw, filterRaw)
}
// reorderRows returns the anchor's filter rows with every physical row matching r
// relocated to the merged 1-based position, and whether any row moved. A rule read
// back by GetRules can be a collapsed IPv4/IPv6 pair spanning two physical rows, so
// both twin rows are relocated together. rules is 1:1 with filterRaw. The target
// position lives in the merged (post-mergeFamilies) index space, so it is mapped to
// a physical index within the reduced row set.
func (f *PF) reorderRows(rules []*Rule, filterRaw []string, r *Rule, position int) ([]string, bool) {
if position <= 0 {
position = 1
}
// Split the rows into the ones being moved and the ones staying, keeping the
// kept rules 1:1 with the kept rows for the anchor mapping. Match with
// EqualForRemoval (as RemoveRule does), not the family-strict Equal, which could
// never match a merged rule the caller read back; a concrete-family target still
// moves only its own family row.
moved := make([]string, 0, 2)
kept := make([]string, 0, len(filterRaw))
keptRules := make([]*Rule, 0, len(rules))
for i, e := range rules {
// An opaque (nil) row is never a match target, so it is always kept in place.
if e != nil && e.EqualForRemoval(r, true) {
moved = append(moved, filterRaw[i])
continue
}
kept = append(kept, filterRaw[i])
keptRules = append(keptRules, rules[i])
}
if len(moved) == 0 {
return nil, false
}
newIdx := mergedInsertIndex(f.filterAnchors(keptRules), len(kept), position)
out := make([]string, 0, len(filterRaw))
out = append(out, kept[:newIdx]...)
out = append(out, moved...)
out = append(out, kept[newIdx:]...)
return out, true
}
// MoveRule moves an existing rule to the given 1-based position.
func (f *PF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
rules, filterRaw, err := f.anchorRules(ctx)
if err != nil {
return err
}
newRaw, moved := f.reorderRows(rules, filterRaw, r, position)
if !moved {
return nil
}
_, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
return f.loadAnchor(ctx, natRaw, newRaw)
}
// RemoveRule removes a rule from the zone.
func (f *PF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
rules, filterRaw, err := f.anchorRules(ctx)
if err != nil {
return err
}
// Rebuild the filter ruleset without the matching rule(s). GetRules collapses an
// IPv4/IPv6 twin into one FamilyAny rule, so removing that read-back rule must
// clear both underlying anchor rows; match with EqualForRemoval so a concrete-
// family target still removes only its own family and never the twin's row.
kept := make([]string, 0, len(filterRaw))
removed := false
var reAdd *Rule
reAddIdx := -1
for i, e := range rules {
// An opaque (nil) row is never a match target, so it is always preserved.
if e != nil && e.EqualForRemoval(r, true) {
removed = true
// A concrete-family target that matched a genuine dual-family row (an
// anchor rule with no af, covering both) would drop both families; re-add
// the untargeted family in the dual row's own slot so the surviving family
// keeps both its coverage and its place in the anchor.
if s := splitDualRow(e, r); s != nil {
reAdd = s
reAddIdx = len(kept)
}
continue
}
kept = append(kept, filterRaw[i])
}
if !removed {
return nil
}
// Splice the surviving family's marshaled line into the kept rows at the dual
// row's position, so a single reload both removes the dual row and preserves
// ordering.
if reAdd != nil {
line, err := f.MarshalRule(reAdd)
if err != nil {
return err
}
kept = append(kept[:reAddIdx], append([]string{line}, kept[reAddIdx:]...)...)
}
_, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
return f.loadAnchor(ctx, natRaw, kept)
}
// compactNATRules is compactRules for NAT rules: it drops the opaque (nil)
// placeholder rows anchorNATRules keeps for unmodeled lines.
func (f *PF) compactNATRules(rules []*NATRule) []*NATRule {
out := make([]*NATRule, 0, len(rules))
for _, r := range rules {
if r != nil {
out = append(out, r)
}
}
return out
}
// listForeignNATRules returns best-effort nat/rdr rules loaded outside this
// backend's own anchor — the main ruleset and any other anchors. Unparseable
// lines are skipped, as in listForeignRules.
func (f *PF) listForeignNATRules(ctx context.Context) []*NATRule {
var rules []*NATRule
// table records where each foreign NAT rule came from ("" for the main ruleset,
// the anchor name otherwise); not ours, so HasPrefix stays false.
parse := func(out []string, table string) {
for _, line := range out {
line = strings.TrimSpace(line)
if line == "" {
continue
}
rule, perr := f.UnmarshalNATRule(line)
if perr != nil || rule == nil {
continue
}
rule.table = table
rules = append(rules, rule)
}
}
if out, err := runCommand(ctx, "pfctl", "-sn"); err == nil {
parse(out, "")
}
if names, err := runCommand(ctx, "pfctl", "-s", "Anchors"); err == nil {
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" || name == f.anchor {
continue
}
if out, err := runCommand(ctx, "pfctl", "-a", name, "-sn"); err == nil {
parse(out, name)
}
}
}
return rules
}
// GetNATRules returns the existing NAT rules from the zone.
func (f *PF) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
rules, _, err = f.anchorNATRules(ctx)
if err != nil {
return nil, err
}
// Drop the opaque placeholder rows kept for unmodeled anchor lines.
rules = f.compactNATRules(rules)
// Merge families, then number the anchor's NAT rules as one ordered list so a
// collapsed pair leaves no gap; foreign NAT rules appended below keep Number 0.
rules = mergeNATFamilies(rules)
numberNATSequential(rules)
rules = append(rules, f.listForeignNATRules(ctx)...)
return rules, nil
}
// MarshalNATRule encodes a NAT rule as a pf rdr/nat rule line for our anchor.
func (f *PF) MarshalNATRule(r *NATRule) (string, error) {
if err := r.validate(); err != nil {
return "", err
}
// pfctl expands a discrete match-port list into one rule per port on read, so a
// multi-port match would not round-trip as a single NAT rule (mirroring the
// filter-rule guard). A contiguous range is one token and is allowed.
if len(r.PortSpecs()) > 1 {
return "", fmt.Errorf("pf cannot express a NAT match-port list as a single rule: %w", ErrUnsupported)
}
var parts []string
switch r.Kind {
case DNAT:
parts = append(parts, "rdr")
case SNAT, Masquerade:
parts = append(parts, "nat")
case Redirect:
return "", fmt.Errorf("pf does not support a portless redirect; use dnat to a local address: %w", ErrUnsupportedNAT)
default:
return "", fmt.Errorf("invalid nat kind")
}
// Interface, bound to the translation direction.
if r.Interface != "" {
parts = append(parts, "on", r.Interface)
}
switch r.impliedFamily() {
case IPv4:
parts = append(parts, "inet")
case IPv6:
parts = append(parts, "inet6")
}
if r.Proto != ProtocolAny {
parts = append(parts, "proto", f.protoName(r.Proto))
}
if r.Source != "" {
neg, bare := splitAddrNeg(r.Source)
if neg {
parts = append(parts, "from", "!", f.addrToken(bare))
} else {
parts = append(parts, "from", f.addrToken(bare))
}
} else {
parts = append(parts, "from", "any")
}
if r.Destination != "" {
neg, bare := splitAddrNeg(r.Destination)
if neg {
parts = append(parts, "to", "!", f.addrToken(bare))
} else {
parts = append(parts, "to", f.addrToken(bare))
}
} else {
parts = append(parts, "to", "any")
}
if specs := r.PortSpecs(); len(specs) > 0 {
parts = append(parts, "port", f.portExpr(specs))
}
// Translation target.
switch r.Kind {
case DNAT:
parts = append(parts, "->", r.ToAddress)
if r.ToPort != 0 {
parts = append(parts, "port", strconv.FormatUint(uint64(r.ToPort), 10))
}
case SNAT:
parts = append(parts, "->", r.ToAddress)
case Masquerade:
if r.Interface == "" {
return "", fmt.Errorf("pf masquerade requires an interface")
}
parts = append(parts, "->", "("+r.Interface+")")
}
return strings.Join(parts, " "), nil
}
// translationBoundary returns the index of the first filtering statement in a
// pf.conf, which is where the nat/rdr translation anchors must be inserted (see
// pfFilterKeywords for why queueing keywords are excluded). When there is no
// filtering statement the boundary is the end of the file, so anchors are appended.
func (f *PF) translationBoundary(data []string) int {
for i, line := range data {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) > 0 && pfFilterKeywords[fields[0]] {
return i
}
}
return len(data)
}
// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly
// ordered options → normalization → queueing → translation → filtering, so the
// queueing keywords (altq/queue) are deliberately NOT included: they precede the
// translation section, and treating one as the boundary would splice the nat/rdr
// anchors ahead of the queueing statements, which pfctl -f rejects.
func (f *PF) ensureNATAnchors(ctx context.Context) error {
if f.natEnsured {
return nil
}
if err := f.ensureAnchor(ctx); err != nil {
return err
}
data, err := f.readFileLines(PFConf)
if err != nil {
return err
}
natRef := fmt.Sprintf(`nat-anchor "%s"`, f.anchor)
rdrRef := fmt.Sprintf(`rdr-anchor "%s"`, f.anchor)
haveNat, haveRdr := false, false
for _, line := range data {
trimmed := strings.TrimSpace(line)
if trimmed == natRef {
haveNat = true
}
if trimmed == rdrRef {
haveRdr = true
}
}
insertAt := f.translationBoundary(data)
if haveNat && haveRdr {
f.natEnsured = true
return nil
}
var add []string
if !haveRdr {
add = append(add, rdrRef)
}
if !haveNat {
add = append(add, natRef)
}
updated := make([]string, 0, len(data)+len(add))
updated = append(updated, data[:insertAt]...)
updated = append(updated, add...)
updated = append(updated, data[insertAt:]...)
if err := f.writeFileLines(PFConf, updated); err != nil {
return err
}
if _, err := runCommand(ctx, "pfctl", "-f", PFConf); err != nil {
return fmt.Errorf("failed to reload pf.conf after adding nat anchors: %s", err)
}
f.natEnsured = true
return nil
}
// AddNATRule adds a NAT rule to the zone.
func (f *PF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := f.ensureNATAnchors(ctx); err != nil {
return err
}
line, err := f.MarshalNATRule(r)
if err != nil {
return err
}
rules, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
// Dedup only against a rule that also covers this rule's family (EqualForDedup):
// without the coverage gate, adding an IPv6 NAT rule whose otherwise-identical
// IPv4 twin already exists (e.g. a per-interface masquerade) would be silently
// dropped, leaving that family un-NATed.
for _, e := range rules {
if e != nil && e.EqualForDedup(r) {
return nil
}
}
// Preserve the filter rules that share the anchor.
_, filterRaw, err := f.anchorRules(ctx)
if err != nil {
return err
}
natRaw = append(natRaw, line)
return f.loadAnchor(ctx, natRaw, filterRaw)
}
// natAnchors is filterAnchors for NAT rules: it maps each logical (merged) NAT
// rule to its physical row index, skipping opaque (nil) rows.
func (f *PF) natAnchors(rules []*NATRule) []int {
phys := make([]int, 0, len(rules))
modeled := make([]*NATRule, 0, len(rules))
for i, r := range rules {
if r == nil {
continue
}
phys = append(phys, i)
modeled = append(modeled, r)
}
anchors := mergedNATFamilyAnchors(modeled)
for k := range anchors {
anchors[k] = phys[anchors[k]]
}
return anchors
}
// InsertNATRule inserts a NAT rule at the given 1-based position within the
// anchor's NAT ruleset. position <= 0 is treated as 1; a position larger than the
// current NAT rule count appends the rule.
func (f *PF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
if err := f.ensureNATAnchors(ctx); err != nil {
return err
}
line, err := f.MarshalNATRule(r)
if err != nil {
return err
}
rules, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
// Dedup only against a rule that also covers this rule's family (EqualForDedup,
// see AddNATRule) so an opposite-family twin is not mistaken for a duplicate.
for _, e := range rules {
if e != nil && e.EqualForDedup(r) {
return nil
}
}
if position <= 0 {
position = 1
}
// position is a merged Number: GetNATRules collapses IPv4/IPv6 pairs and numbers
// the result, while the anchor holds one physical row per rule. Map through the
// merged NAT anchors so a collapsed pair earlier in the list does not skew the
// placement (and does not split a family pair). rules is 1:1 with natRaw, and
// natAnchors skips any opaque (unmodeled) row so it does not consume a
// logical position.
idx := mergedInsertIndex(f.natAnchors(rules), len(natRaw), position)
natRaw = append(natRaw[:idx], append([]string{line}, natRaw[idx:]...)...)
// Preserve the filter rules that share the anchor.
_, filterRaw, err := f.anchorRules(ctx)
if err != nil {
return err
}
return f.loadAnchor(ctx, natRaw, filterRaw)
}
// RemoveNATRule removes a NAT rule from the zone.
func (f *PF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := f.ensureNATAnchors(ctx); err != nil {
return err
}
rules, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
// Rebuild the NAT ruleset without the matching row(s). GetNATRules collapses an
// IPv4/IPv6 twin into one FamilyAny rule (mergeNATFamilies), so removing that
// read-back rule must clear both underlying anchor rows — mirror RemoveRule:
// match with EqualForRemoval so a concrete-family target still removes only its
// own family and never the twin's row.
kept := make([]string, 0, len(natRaw))
removed := false
for i, e := range rules {
// An opaque (nil) row is never a match target, so it is always preserved.
if e != nil && e.EqualForRemoval(r) {
removed = true
continue
}
kept = append(kept, natRaw[i])
}
if !removed {
return nil
}
_, filterRaw, err := f.anchorRules(ctx)
if err != nil {
return err
}
return f.loadAnchor(ctx, kept, filterRaw)
}
// Backup captures the current filter and NAT rules managed by this backend.
func (f *PF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
// Read the private anchor directly rather than GetRules: Restore refills only
// this anchor, so the backup must not pull in rules from the main ruleset or
// other anchors (they would be re-loaded into the wrong anchor on Restore).
rules, _, err := f.anchorRules(ctx)
if err != nil {
return nil, err
}
natRules, _, err := f.anchorNATRules(ctx)
if err != nil {
return nil, err
}
// A Backup holds modeled rules ([]*Rule / []*NATRule), which cannot carry an
// unparseable anchor line, so drop the opaque placeholder rows here.
backup := &Backup{Rules: f.compactRules(rules), NATRules: f.compactNATRules(natRules)}
// pf has no default policy to capture (DefaultPolicy is false), so this only
// adds the pf tables a rule may reference.
if err := captureBackupState(ctx, f, zoneName, backup); err != nil {
return nil, err
}
return backup, nil
}
// Restore replaces the managed rules with the contents of a Backup.
func (f *PF) Restore(ctx context.Context, zoneName string, backup *Backup) error {
if backup == nil {
return fmt.Errorf("backup cannot be nil")
}
// Ensure the pf.conf anchor references exist before loading. When the backup
// carries NAT rules, the nat-anchor/rdr-anchor references must be present too
// (ensureNATAnchors also ensures the filter anchor); without them pf loads the
// translation rules into the anchor but never evaluates them, mirroring the
// AddNATRule/InsertNATRule paths.
if len(backup.NATRules) > 0 {
if err := f.ensureNATAnchors(ctx); err != nil {
return err
}
} else if err := f.ensureAnchor(ctx); err != nil {
return err
}
// Recreate the pf tables a rule may reference (`<table>`) before loading the
// anchor. pf tables are global and independent of the anchor ruleset, so this
// creates or repopulates them (pfctl -T add) without disturbing the anchor.
if err := restoreBackupSets(ctx, f, backup, false); err != nil {
return err
}
filterLines := make([]string, 0, len(backup.Rules))
for _, r := range backup.Rules {
line, err := f.MarshalRule(r)
if err != nil {
return err
}
filterLines = append(filterLines, line)
}
natLines := make([]string, 0, len(backup.NATRules))
for _, r := range backup.NATRules {
line, err := f.MarshalNATRule(r)
if err != nil {
return err
}
natLines = append(natLines, line)
}
return f.loadAnchor(ctx, natLines, filterLines)
}
// GetDefaultPolicy is unsupported; pf exposes no default policy in this model.
func (f *PF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
return nil, unsupportedPolicy(f.Type())
}
// SetDefaultPolicy is unsupported; pf exposes no default policy in this model.
func (f *PF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
return unsupportedPolicy(f.Type())
}
// --- address sets (pf tables) -----------------------------------------------
// isMissingTableErr reports whether a pfctl error means the table does not
// exist. pfctl prints "pfctl: Table does not exist." for a missing table across
// its -T subcommands (show/kill/delete), so every idempotent table operation keys
// on this same string rather than each guessing at the wording.
func (f *PF) isMissingTableErr(err error) bool {
return err != nil && strings.Contains(err.Error(), "does not exist")
}
// tableFamily infers a table's family from its entries (defaulting to IPv4).
func (f *PF) tableFamily(entries []string) Family {
for _, e := range entries {
if strings.Contains(e, ":") {
return IPv6
}
}
return IPv4
}
func (f *PF) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
out, err := runCommand(ctx, "pfctl", "-t", name, "-T", "show")
if err != nil {
// pfctl reports an unknown table distinctly; only that is a genuine
// "not found" (nil, nil). Any other failure (permission, pf disabled) is a
// real error the caller must see rather than a silent miss.
if f.isMissingTableErr(err) {
return nil, nil
}
return nil, err
}
var entries []string
for _, line := range out {
e := strings.TrimSpace(line)
if e != "" {
entries = append(entries, e)
}
}
return &AddressSet{Name: name, Family: f.tableFamily(entries), Entries: entries}, nil
}
// GetAddressSets returns the address sets (pf tables) managed by this backend.
func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
out, err := runCommand(ctx, "pfctl", "-s", "Tables")
if err != nil {
return nil, nil
}
var result []*AddressSet
for _, line := range out {
name := strings.TrimSpace(line)
if name == "" {
continue
}
set, err := f.getAddressSet(ctx, name)
if err != nil || set == nil {
continue
}
result = append(result, set)
}
return result, nil
}
// GetAddressSet returns a single address set by name, or an error if it does not exist.
func (f *PF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
set, err := f.getAddressSet(ctx, name)
if err != nil {
return nil, err
}
if set == nil {
return nil, fmt.Errorf("address set %q not found", name)
}
return set, nil
}
// AddAddressSet creates an address set (pf table), or empties an existing one.
func (f *PF) AddAddressSet(ctx context.Context, set *AddressSet) error {
if set == nil || set.Name == "" {
return fmt.Errorf("an address set requires a name")
}
if err := f.ensureAnchor(ctx); err != nil {
return err
}
if len(set.Entries) == 0 {
// pf does not lazily create tables, so a later filter rule referencing
// <name> would fail to load. `-T replace` with no addresses creates the
// table empty (or empties an existing one) so it exists for the caller.
_, err := runCommand(ctx, "pfctl", "-t", set.Name, "-T", "replace")
return err
}
args := []string{"-t", set.Name, "-T", "add"}
args = append(args, set.Entries...)
_, err := runCommand(ctx, "pfctl", args...)
return err
}
// RemoveAddressSet removes an address set (pf table) by name; an absent table is a no-op.
func (f *PF) RemoveAddressSet(ctx context.Context, name string) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
_, err := runCommand(ctx, "pfctl", "-t", name, "-T", "kill")
// Removing an absent table is a no-op success: pfctl fails with "Table does
// not exist", which must not surface as an error (matching getAddressSet).
if f.isMissingTableErr(err) {
return nil
}
return err
}
// AddAddressSetEntry adds an entry to the named address set (pf table).
func (f *PF) AddAddressSetEntry(ctx context.Context, name, entry string) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
_, err := runCommand(ctx, "pfctl", "-t", name, "-T", "add", entry)
return err
}
// RemoveAddressSetEntry removes an entry from the named address set (pf table); an absent table is a no-op.
func (f *PF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
_, err := runCommand(ctx, "pfctl", "-t", name, "-T", "delete", entry)
// Deleting from an absent table is a no-op success (see RemoveAddressSet).
if f.isMissingTableErr(err) {
return nil
}
return err
}
// Reload is a no-op; pf applies anchor changes immediately.
func (f *PF) Reload(ctx context.Context) error {
return nil
}
// Close releases any resources held by the backend; pf holds none.
func (f *PF) Close(ctx context.Context) error {
return nil
}
// AddRulesBatch appends every rule and reloads the anchor once, rather than one
// pfctl reload per rule. Rules that already exist are skipped. The reload is a
// single atomic pfctl transaction. It implements RuleBatcher.
func (f *PF) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
existing, filterRaw, err := f.anchorRules(ctx)
if err != nil {
return err
}
_, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
for _, top := range rules {
// A DirAny rule fans out into an inbound rule plus its swapped outbound rule.
for _, r := range expandDirections(top) {
line, err := f.MarshalRule(r)
if err != nil {
return err
}
dup := false
for _, e := range existing {
if e != nil && e.Equal(r, true) {
dup = true
break
}
}
if dup {
continue
}
filterRaw = append(filterRaw, line)
existing = append(existing, r)
}
}
return f.loadAnchor(ctx, natRaw, filterRaw)
}
// ReplaceRulesBatch reloads the anchor so its filter rules are exactly rules,
// preserving any translation (nat/rdr) rules, in one pfctl transaction. It
// implements RuleBatcher.
func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
if err := f.ensureAnchor(ctx); err != nil {
return err
}
_, natRaw, err := f.anchorNATRules(ctx)
if err != nil {
return err
}
var filterRaw []string
for _, top := range rules {
// A DirAny rule fans out into an inbound rule plus its swapped outbound rule.
for _, r := range expandDirections(top) {
line, err := f.MarshalRule(r)
if err != nil {
return err
}
filterRaw = append(filterRaw, line)
}
}
return f.loadAnchor(ctx, natRaw, filterRaw)
}