OPNsenseIV · OPNsense ArchitectureOPNsense architecture
PF and the kernel interface — how packets actually move
What you'll learn
- Trace a packet through the FreeBSD network stack on an OPNsense firewall
- Identify where PF hooks the stack and what tunables govern its behaviour
- Read the sysctl variables that affect PF and network forwarding
- Diagnose the failures that live in the kernel interface, not in the GUI
Prerequisites
Verified against OPNsense 25.x · FreeBSD 14.x · PF (FreeBSD packet filter) FreeBSD 14.x · Unbound 1.20+ · Kea DHCP OPNsense 25.x plugin · WireGuard in-kernel + OPNsense plugin · strongSwan (IPsec plugin) OPNsense 25.x plugin · OpenVPN 2.6.x · Suricata 7.x · 2026-08-14
PF is not a user-space daemon. PF is a kernel module — pf.ko —
that hooks the FreeBSD packet processing pipeline at well-defined
points. The GUI produces a ruleset, the template resolver writes
it to a file, pfctl -f loads it into the kernel, and from that
moment the kernel itself is making the forwarding decision on
every packet. There is no user-space hop in the data path.
This lesson walks through the kernel interface: where PF hooks the stack, what sysctl tunables govern its behaviour, and the production adjustments an operator makes at the kernel layer when the GUI does not expose what is needed.
The FreeBSD packet pipeline (the model)
A packet arriving on a NIC on an OPNsense firewall follows a predictable path. The path is the same for IPv4 and IPv6 with small differences; the model is the same.
NIC hardware → if_input → ether_input → if_l2routing →
→ ip_input (or ip6_input) → PF → ip_forward (or ip6_forward) →
→ ip_output (or ip6_output) → if_output → NIC egress
Each step has a name and a job:
| Step | What it does |
|---|---|
if_input | Driver places the frame on the input queue |
ether_input | Strips the Ethernet header, classifies by ethertype |
if_l2routing | Bridge forwarding, VLAN tagging, netgraph |
ip_input / ip6_input | IP layer: TTL, checksum, reassembly |
| PF | Packet filter decision: pass, block, match state |
ip_forward / ip6_forward | Route lookup, fragment if needed |
ip_output / ip6_output | Build outgoing IP header |
if_output | Hand to the egress NIC driver |
PF runs after ip_input has already done Layer 2 work (the frame
is gone; PF sees only the IP packet) and before ip_forward
makes the route lookup. This positioning is what makes stateful
filtering work: PF sees the packet, decides based on rules and
state, and either consumes the packet (block) or hands it back
to the stack for forwarding.
PF also runs on the egress path on packets the firewall itself originates (e.g. monitoring probes, syslog forwards). The same ruleset applies — packets from the firewall to the LAN go through PF before they hit the wire.
$ sysctl -a | grep -E '^net\\.inet\\.ip\\.(forwarding|altq|maxfrags)'net.inet.ip.forwarding: 1
net.inet.ip.maxfrags: 4096
net.inet.ip.maxfragpackets: 1024
net.inet.altq.enable: 0Illustrative output
Where PF hooks the stack
PF registers with pfil(9), the packet filter hook framework,
in both directions on every interface:
- Input hook. On the ingress interface, before the kernel makes a forwarding decision. PF runs on packets being forwarded and on packets destined to the firewall itself. PF may consume the packet (block) or return it to the stack.
- Output hook. On the egress interface, after the routing decision, before the frame is handed to the NIC. PF runs on packets the firewall originates and on packets it is forwarding. Same ruleset, same state table.
A packet that traverses the firewall is therefore filtered
twice: once inbound on the interface it arrived on, once
outbound on the interface it leaves by. Both passes consult
the same ruleset; a rule matches a given pass only if its
direction and interface match. This is why a pass out
rule exists at all, and why OPNsense’s generated ruleset
ends with pass out on the interfaces it does not police
outbound.
PF’s decision is a function of:
- the ruleset (loaded by
pfctl -f), - the state table (
pfctl -s state), - the tables (
pfctl -s tables), - a small amount of per-rule state (packet counters, byte counters, last-match timestamp).
PF does not read the routing table, does not perform ARP, does not touch the NIC. PF is a layer-3 and layer-4 decision engine. The kernel handles everything around it.
The sysctl tunables the operator actually uses
A long list of net.inet.* sysctls affect PF and the network
stack. The ones the operator adjusts most often:
$ sysctl net.inet.ip.forwarding net.inet.tcp.mssdflt net.inet.ip.redirect net.inet.ip.sourceroute net.pf.states_hashsizenet.inet.ip.forwarding: 1
net.inet.tcp.mssdflt: 1460
net.inet.ip.redirect: 1
net.inet.ip.sourceroute: 0
net.pf.states_hashsize: 32768Illustrative output
| Variable | What it does | Production guidance |
|---|---|---|
net.pf.states_hashsize | Width of the state hash table | boot-time tunable, leave default |
net.pf.source_nodes_hashsize | Width of the source-node hash table | boot-time tunable, leave default |
net.pf.request_maxcount | Cap on entries in a single ioctl request | raised with the table-entries limit |
net.inet.ip.forwarding | IP forwarding toggle | must be 1 for a router |
net.inet.ip.redirect | Send ICMP redirects | set to 0 on a firewall |
net.inet.ip.sourceroute | Honour source-routed packets | 0 on a firewall |
net.inet.tcp.mssdflt | Default TCP MSS | 1460 for Ethernet |
net.inet.tcp.nolocaltimewait | Fast TIME-WAIT recycling | leave default |
net.inet.icmp.icmplim | ICMP error rate limit | 200/s default |
net.inet.tcp.tso | TCP segmentation offload | 1 on supported NICs |
net.inet.ip.rtexpire | Route cache expiry | 1800s default |
OPNsense exposes many of these through System → Settings → Firewall → Advanced and System → Settings → Networking. The
ones not exposed are still settable via System → Advanced → System → System tunables (a managed sysctl table that persists
across reboots and applies).
Live inspection commands
What the operator runs to inspect the kernel interface:
| Command | What it shows |
|---|---|
pfctl -s state | Active state table entries |
pfctl -s state -v | States with verbose counters and ages |
pfctl -s info | State table totals, limits, counters |
pfctl -s rules | Loaded ruleset |
pfctl -s tables | Tables and their contents |
pfctl -s Anchors | Active anchors (sub-rulesets) |
pfctl -s nat | Active NAT translations |
pfctl -s osfp | Passive OS fingerprint table |
pfctl -sm | pf runtime limits, including the state table hard limit |
pfctl -st | State timeouts in force |
netstat -m | mbuf usage (kernel network buffers) |
vmstat 1 | CPU, memory, paging, interrupts |
The course uses these in context throughout. The lesson on state
table sizing uses pfctl -s info; the lesson on packet capture
uses pfctl -s state to verify the flow being captured.
Common production failures at the kernel layer
Three failure modes that are not visible in the GUI:
-
State table exhaustion. New connections fail or are silently dropped.
pfctl -sishowscurrent entriesat thestates hard limitreported bypfctl -sm, and thememorycounter climbing. The fix is to raiseFirewall → Settings → Advanced → Firewall Maximum Statesand apply; the apply regenerates the ruleset with a newset limit states. There is no sysctl for this. -
mbuf exhaustion. The kernel runs out of network buffer memory. Symptoms are dropped packets, slow throughput, “no buffer space available” errors in
dmesg. The fix is often hardware-related (NIC with insufficient buffers, or a CPU bottleneck on the softirq path), but the operator can adjustkern.ipc.nmbclustersand related tunables via the GUI. -
Source-routed packets bypassing PF. A misconfigured
net.inet.ip.sourceroute=1lets a remote host specify a source route that PF does not see, because PF hooks after the source-route processing in the older FreeBSD stack. The fix isnet.inet.ip.sourceroute=0. The OPNsense default is correct; an operator who has set it to 1 for a debugging session and not reset it has created a security gap.
Why this matters for production
Two production disciplines follow from understanding the kernel interface:
-
Sizing the state table is a ruleset decision. The GUI’s “Firewall Maximum States” field becomes
set limit statesin the generated ruleset. The operator chooses it knowing the peak concurrent connection count (multiplied by headroom for bursts) — one concurrent flow is one entry. The state table is sized once and rarely changes; the lesson on state table sizing covers the measurement and sizing discipline. -
NIC offload matters. TCP segmentation offload (TSO), large receive offload (LRO), checksum offload, and RSS (receive-side scaling) are all kernel decisions. OPNsense exposes some of them; the operator learns when to disable offload (typically for packet capture and for VPN paths where offload breaks encapsulation) and when to enable it (for raw throughput).
Summary
- PF is a kernel module. There is no user-space hop on the data path. The kernel makes the forwarding decision on every packet.
- PF registers pfil hooks in both directions on every interface, so a forwarded packet is filtered inbound on the ingress interface and again outbound on the egress interface. PF sees Layer 3 and Layer 4 only.
net.inet.ip.forwardingand thenet.pf.*_hashsizetunables are sysctls; the state limit and the state timeouts are not — they are ruleset settings, read withpfctl -smandpfctl -st.- State table exhaustion, mbuf exhaustion, and source-route
bypass are kernel-layer failures the GUI does not show
directly. The operator reads
pfctl -si,pfctl -sm,netstat -m, andsysctlto detect them.
Knowledge check · 3 questions
Q1. A production firewall stops accepting new TCP connections but existing connections still work. pfctl -si shows current entries equal to the states hard limit from pfctl -sm. What is the most likely cause and fix?
Q2. PF runs in the FreeBSD kernel; there is no user-space hop on the data path of a packet.
Q3. Which of the following sysctl values are typical defaults on a correctly configured OPNsense firewall? Select all that apply.
Passing score: 75%. Answers are checked in this browser.