7.3 KiB
Caveats — divergences from OpenBSD behavior
Português: docs/pt-BR/caveats.md.
These are the ways this port differs from running the same ruleset on
OpenBSD 8.0, or is not yet production-shaped. Everything here is grounded in
the code (module/pfsrc/pfk_hooks.c, module/pfsrc/pfk_mod.c,
docs/porting-notes.md) and in lab observations; nothing is speculative.
Data path
1. PF runs in process context via a workqueue (netisr model), not in the packet fast path
OpenBSD evaluates pf_test() on the CPU that received the packet, at
IPL_NET, with a non-sleeping NET read lock. Linux gives us softirq context
and sleepable compat locks (rw-semaphores), so the hook never calls
pf_test() inline: it steals the skb (NF_STOLEN) onto an ordered
workqueue (pfk_netisr, WQ_MEM_RECLAIM) and runs the engine in process
context, then re-injects (netif_rx() for input, dst->output() for
output/transit).
Consequences, all observable:
- Verdicts are asynchronous. Anything chained after our hook on the same netfilter chain (other iptables/nft rules at higher priorities) does not see the packet until the reinjected pass comes through. Counters and log interleaving differ accordingly.
- Latency + throughput: every packet takes a work-queue round trip
serialized on one ordered worker. Functional gates are the current bar;
the planned
bench-basic.shis informational only. - Reordering: the ordered queue preserves arrival order of queued work items, but the stack around it (RPS, multi-NAPI) can still hand us packets whose original completion order differs from reinjection order.
2. Re-entry protection is a per-direction tag in skb->mark
The reinjected survivor carries skb->mark |= PFK_DONE_IN (0x50460010) or
PFK_DONE_OUT (0x50460020); our hooks accept any packet already tagged for
that direction instead of re-queuing it (otherwise PF_IN → netif_rx →
PF_IN… would loop). skb->mark is a shared, writable 32-bit field: any
subsystem that overwrites it — nft meta mark set, CONNMARK --restore,
tc filters, some VPN stacks — can either (a) clear our tag and cause the
packet to be re-queued through PF again, or (b) coincidentally set those
bits and let a packet skip a PF pass. Practical rule on a PF-on-Linux
host: do not use fwmark mangling on the same traffic; if you must, mask the
PF tag bits out of your mark space.
3. No NET-lock on the data path (M2 interim)
pf_test() currently runs without the compat NET read lock (comment in
pfk_hooks.c): with the workqueue model, a parked control writer (ruleset
change, purge ioctl) starved the packet side badly enough to be
counter-productive. Rule-set mutation and packet walk therefore run
concurrently, protected by what upstream pf.c itself uses (PF_LOCK/RCU-ish
patterns) — a deliberate interim deviation being reworked with the lock
model in M5+. Until then: expect correctness under steady state, not under
aggressive concurrent reload; the reload-under-traffic soak is an open gate.
4. Fail-open under memory pressure
The queue item is kmalloc(GFP_ATOMIC) in softirq. If it fails, the hook
returns NF_ACCEPT — the packet bypasses PF. This is a deliberate
availability choice (mirrors dropping into a full queue bypassing the qdisc)
but it is a security trade-off worth knowing: PF verdicts are best-effort
exactly when the host is out of atomic memory.
5. Hook ordering vs conntrack differs by direction
- Ingress (
PREROUTING,NF_IP_PRI_FIRST): PF runs before defrag and conntrack — as designed (spec §4.2). - Output (
LOCAL_OUTatNF_IP_PRI_CONNTRACK+1): PF sees locally generated traffic after conntrack has already committed the entry. - Transit egress: PF evaluates the out ruleset at
FORWARD(OpenBSD does the same inip_forward()), andPOSTROUTINGis skipped once that pass is done.nat-to/rdr-towere validated against this ordering (gates 05/06). - The "overlapping policy with conntrack" warning from the design still applies: if iptables/nft SNAT also runs, PF's translation happens at the layer it is hooked into, and double translation is yours to avoid.
Control plane / lifecycle
6. Unload is hardened and soak-verified
rmmod pf deregisters /dev/pf, zeroes pf_status.running under the NET
lock, deletes and syncs both purge timers (they self-rearm — a real rmmod
hang was observed and fixed by stopping sources before flushing the
workqueue), unregisters hooks and flushes pfk_netisr. Gate 14-soak.sh
drives ICMP+TCP traffic, checks dmesg for faults, then runs
rmmod → insmod → rmmod and compares the slab footprint before and
after: clean unload, successful reload, no growth. Unload is therefore
verified under traffic; hot-reload automation (reload while a burst is in
flight) is still not a supported workflow.
Behavioral notes verified today
7. pfctl -s states lists real states, including translations
The earlier anomaly was a use-after-free: refcnt_rele() had inverted
polarity, so live state keys were freed while still linked in the state
RBT — that both hid states and crashed the tree walk (pfctl -s states
hanging the box). After the fix, gates 01/04/05/06/07 assert on the listed
states, and translated flows print both tuples (10.0.2.1:59335 (10.0.1.10:4380) -> 10.0.2.20:8).
8. Consumers still required for two features
divert-to needs a process bound to the divert port (tools/pdivert); with
no listener the packet is dropped, exactly like BSD's divs_noport, and the
kernel prints pfk: divert port N: no socket bound. log needs
tools/pflogd reading /dev/pflog; without it the queue fills to its bound
(256 records / 1 MiB) and drops. Neither is a stub any more, but neither does
anything until its consumer runs (see pf-guide.md).
Compat-layer deviations (from docs/porting-notes.md, M1 record)
These are deliberate, marked-in-code divergences of the compat layer; they matter when reasoning about rule semantics:
- Socket-owner matching (
user/pidon rules): uid only — Linuxstruct sockcarries no egid/cpid, so gid degrades to(gid_t)-1, pid toNO_PID. - Checksums: driver offload is not trusted on ingress (M1 decision) — TCP checksums are recomputed in software; output pseudo-checksums are done in the port's own path.
- IP options / record-route / timestamps: not processed on the PF output path; ICMP redirects are not generated by PF's route code (the Linux FIB emits those).
- Routing labels: non-goal — the ioctl layer rejects labeled rules;
rtable_iterate()always returns NULL (no multipath uRPF iteration). - Interface groups: Linux has none;
group …matches behave like a missing group on OpenBSD. - GSO/multi-segment skbs: handled by linearization on entry to the mbuf shim (perf cost documented in spec §10; gates will quantify).
- Single instance: one PF world in
init_net, like OpenBSD has one kernel. Containers/namespaces are not visible to PF and cannot be filtered with it in this design.
What is not a caveat
ARP and other non-IPv4/IPv6 EtherTypes never enter the netfilter IPv4/IPv6
hooks — they pass through the box untouched, exactly as PF on OpenBSD ignores
non-IP. /dev/pf is 0600 root-only by design (matches OpenBSD's
securelevel-era default for pfctl write access being root).