Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged value spanning both transports, distinct from ProtocolAny (which matches every IP protocol and carries no port). Backends whose native syntax holds both transports in one row (nftables, ufw, apf) store and read it as one rule; the rest fan it out with expandProtocols. Removing one transport of a merged row splits it via splitMergedRow, which composes the family and protocol splits so an nftables row merged on both axes leaves a correct, non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT. Remove read-side merging. GetRules now reports the firewall's actual rows and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up separately-stored ones, so mergeFamilies, mergeDirections and their helpers are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the NATRule pair) expand a rule across family, transport and direction and decide containment cell by cell, which is what lets Sync stay a no-op against its own output whichever representation a backend chose. Extract the systemd/SysV service helpers out of the iptables backend into services.go so every Linux backend shares one implementation, and document the multi-state rule model and the coverage helpers in the README.
2139 lines
66 KiB
Go
2139 lines
66 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/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)
|
|
// Report one rule per anchor row. A pf rule written without `inet`/`inet6` matches
|
|
// both families, and UnmarshalRule reports it as FamilyAny from that single row;
|
|
// the transport and direction axes have no both-states form in pf's grammar (this
|
|
// backend fans TCPUDP and DirAny out on write), so those rows read back concrete.
|
|
// Number the anchor's rules as one ordered list — pf evaluates a single filter
|
|
// list, so its position spans directions. Foreign rules appended below live
|
|
// outside this anchor and keep Number 0.
|
|
numberSequential(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 has no both-transports rule form; pfctl expands a `{ tcp udp }` list into
|
|
// separate rows on load, so a TCPUDP rule must be fanned into a tcp row and a udp
|
|
// row by expandProtocols before it reaches this row-level marshaller. Reaching
|
|
// here with TCPUDP means that fan-out was skipped.
|
|
if err := r.CheckExpandedProtocol(); err != nil {
|
|
return "", err
|
|
}
|
|
// 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 (
|
|
// 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
|
|
}
|
|
|
|
// A TCPUDP rule fans out into a tcp rule and a udp rule; pfctl expands a
|
|
// `{ tcp udp }` list into separate rows on load, so it cannot be stored as one.
|
|
if r.Proto == TCPUDP {
|
|
for _, sub := range expandProtocols(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 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. Every modeled row is its own
|
|
// rule — GetRules reports the anchor row for row — so with no opaque rows this is
|
|
// the identity. It backs the logical-position insert/move mapping.
|
|
func (f *PF) filterAnchors(rules []*Rule) []int {
|
|
anchors := make([]int, 0, len(rules))
|
|
for i, r := range rules {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
anchors = append(anchors, i)
|
|
}
|
|
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
|
|
}
|
|
|
|
// A TCPUDP rule occupies a row for each transport; insert its tcp row and its udp
|
|
// row at the requested position, since pfctl stores the two transports separately.
|
|
if r.Proto == TCPUDP {
|
|
for _, sub := range expandProtocols(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 Number GetRules reported, which counts only the rules it can
|
|
// model. rules is 1:1 with filterRaw, and filterAnchors skips any opaque
|
|
// (unmodeled) row so a foreign anchor line does not consume a logical position.
|
|
idx := logicalInsertIndex(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 1-based position, and whether any row moved. A FamilyAny or
|
|
// TCPUDP target spans rows the anchor may hold separately, so every matching row is
|
|
// relocated together. rules is 1:1 with filterRaw. The target position counts only
|
|
// modeled rules, 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, so a
|
|
// FamilyAny target relocates the family-agnostic row it names; 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 := logicalInsertIndex(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)
|
|
// Report one rule per anchor row: a nat/rdr line written without `inet`/`inet6`
|
|
// matches both families and reads back as FamilyAny on its own. Number the
|
|
// anchor's NAT rules as one ordered list; foreign NAT rules appended below keep
|
|
// Number 0.
|
|
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
|
|
}
|
|
// A TCPUDP nat rule is rejected by validate above: no backend expresses NAT on
|
|
// both transports as one rule, so such a rule could never round-trip.
|
|
//
|
|
// 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 NAT rule to its
|
|
// physical row index, skipping opaque (nil) rows.
|
|
func (f *PF) natAnchors(rules []*NATRule) []int {
|
|
anchors := make([]int, 0, len(rules))
|
|
for i, r := range rules {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
anchors = append(anchors, i)
|
|
}
|
|
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 Number GetNATRules reported, which counts only the rules it can
|
|
// model. rules is 1:1 with natRaw, and natAnchors skips any opaque (unmodeled)
|
|
// row so a foreign anchor line does not consume a logical position.
|
|
idx := logicalInsertIndex(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). A FamilyAny target spans
|
|
// rows the anchor may hold separately (an IPv4 row and an IPv6 row added one at a
|
|
// time), so removing it must clear all of them — 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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,
|
|
// and a TCPUDP rule into a tcp rule and a udp rule; pfctl stores each
|
|
// direction/transport as its own row.
|
|
for _, dir := range expandDirections(top) {
|
|
for _, r := range expandProtocols(dir) {
|
|
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,
|
|
// and a TCPUDP rule into a tcp rule and a udp rule; pfctl stores each
|
|
// direction/transport as its own row.
|
|
for _, dir := range expandDirections(top) {
|
|
for _, r := range expandProtocols(dir) {
|
|
line, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
filterRaw = append(filterRaw, line)
|
|
}
|
|
}
|
|
}
|
|
return f.loadAnchor(ctx, natRaw, filterRaw)
|
|
}
|