PF Config Generation¶
pf.Generate(cfg) in internal/pf/generate.go turns a DeviceConfig into
/etc/pf.conf.
Order of the generated file¶
pf is order-sensitive, so the section order is part of the contract.
# pf.conf - managed by digilan-agent
# DO NOT EDIT - changes will be overwritten.
table <lan_net> const { 192.168.1.0/24 } # 1. objects
table <updates> persist # (FQDN objects, filled at runtime)
web_ports = "{ 80, 443 }" # port objects and services
set skip on lo # 2. options
set block-policy drop
set loginterface egress
match in all scrub (no-df random-id max-mss 1440)
match out on egress from <lan_net> to any nat-to (egress) # 3. NAT
# System rules # 4. filter
block log all
block drop to { 255.255.255.255 224.0.0.0/4 }
pass quick on lo0
... # DHCP client, resolver, IPsec,
... # published services, presets
# Custom rules
pass in on igc1 ... # the user's rules
# Schedule anchors
anchor "sched_office" # 5. schedules
- Objects. Host, network and group objects become
consttables. FQDN objects becomepersisttables, whichinternal/fqdnfills with the resolved addresses on a timer (aconsttable cannot be updated at runtime). Port objects and services become macros, because a pf table holds addresses, not ports. A group of groups is emitted as the union of its leaves, since pf tables cannot nest. - Options. See below.
- NAT (
nat-to,binat-to,rdr-to) comes before filtering. That is pf's own order. - Filter rules, in this order: the system rules (from config, by default
sys-block-all,sys-quiet-broadcast,sys-pass-lo,sys-ipv6-ndp,sys-stateful,sys-mgmt-ssh,sys-nexus-access, each written as its raw pf text); the DHCP client passes; DNS-to-self passes for networks using the gateway as resolver; DNS enforcement when enabled; IKE/ESP passes when IPsec is configured; published-service passes; preset rules; then custom rules. - Schedule anchors, one
anchor "sched_<name>"per schedule, loaded and flushed by cron; see Config Generation.
Options¶
set skip on lo, except on a device with IPsec tunnels, where it is:
pf sees IPsec traffic twice: as ESP on the physical interface, and again as the
decrypted inner packet on enc0. The default-deny baseline would drop the
second pass, so a tunnel would negotiate and carry nothing. Inner tunnel traffic
is therefore not filtered, which is the usual OpenBSD arrangement for a
site-to-site link to a peer you configured.
set state-defaults pflow is added when flow export is enabled and no rule opts
out of it; otherwise flow export is set per rule.
NAT follows the carrying uplink¶
Outbound NAT is written against the egress interface group:
nat-to (egress). The failover helper keeps exactly one interface in egress,
the one carrying traffic, so NAT follows a failover. A rule naming a physical
port stops matching when traffic moves to another uplink. See
Uplink Failover.
pfctl -sr displays nat-to (egress) round-robin. pf adds round-robin to any
interface-group translation; with one interface in the group it has no effect.
No interface macros¶
Rules render interface names directly; no <name>_if = "igc0" macros are
emitted. A description that sanitised to an invalid macro name would make pfctl
reject the whole file, so generation avoids user text in identifiers.
sanitizeMacro remains for the sched_<name> anchor names.
One bad rule rejects the whole file
pfctl does not apply a ruleset partially. One invalid line and the entire
file is refused, and the device keeps running its previous ruleset, which
from the outside looks like a successful apply. Generation is conservative
about anything that reaches pfctl as an identifier, and every construct is
checked against the real parser by dev/bench-check.sh.
Keyword order¶
pf requires
action [direction] [log] [quick] [on interface] [af] [proto] [from] [to] [port].
Any other order is a syntax error that rejects the file;
internal/pf/clause_order_test.go pins it.