2119 lines
65 KiB
Go
2119 lines
65 KiB
Go
//go:build darwin || freebsd
|
|
|
|
package firewall
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// 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 makes sure pf.conf references our nat and rdr anchors so that
|
|
// translation rules loaded into the anchor are evaluated. pf requires
|
|
// translation rules (and their anchors) to appear before any filtering
|
|
// statement, so the references are inserted at that boundary rather than
|
|
// appended. Missing references are added and the main ruleset reloaded.
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, " ") + " }"
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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) -----------------------------------------------
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|