Skip to content

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
  1. Objects. Host, network and group objects become const tables. FQDN objects become persist tables, which internal/fqdn fills with the resolved addresses on a timer (a const table 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.
  2. Options. See below.
  3. NAT (nat-to, binat-to, rdr-to) comes before filtering. That is pf's own order.
  4. 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.
  5. 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:

set skip on { lo enc0 }

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.

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.