Files
pf-linux-port/docs/superpowers/specs/2026-11-11-pf-linux-port-design.md
T
lgcosta 1fcd7b3046 M1 progress: engine port compiles except pf.c; AF_DIVERT socket family; ifq framework
- compat layer extended through the full BSD API surface pf_ioctl/pf_lb/
  pf_if/pf_norm/pf_table/pf_ruleset/radix/md5/sha2 need (all compile):
  cpumem/counters model, ifaddr/ifg groups, cred view, endian+in6 names,
  NAT46/64, SipHash24, mbuf_list, frag6 helpers, ip6 forward/checksum,
  tcp options/FSM names, taskqueue & sysctl shims (documented in
  docs/porting-notes.md)
- pfsrc/divert_sock.c: real BSD divert(4) as runtime-registered PF_DIVERT=32
  socket family inside the module (sock_register; NPROTO=AF_MAX=45 -> zero
  core patches); divert_packet/divert6_packet wired for pf.c
- compat/pfk_ifq.c: OpenBSD 8.0 ifq framework with priq back end; hfsc and
  fq_codel aliases pending M5 (recorded in feature matrix as pending)
- design + plan updated for the divert decision (user-requested)
- stage-port.sh: single deterministic rename program for headers and .c
2026-11-11 13:35:53 -03:00

275 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PF on Linux — Port Design / Design do Port
Date/2026-11-11 · Status: awaiting approval/aguardando aprovação · Repo: https://git.labexposed.com/lgcosta/pf-linux-port (public)
## 1. Intent / Intenção
Run OpenBSD's PF packet filter inside Linux 7.3.4 — the real PF engine, driven by the
real `pfctl` speaking the real `/dev/pf` ioctl ABI — as an out-of-tree module with a
documented, minimal set of kernel core patches, validated end-to-end in a libvirt
Alpine VM. Docs bilingual (EN + pt-BR).
Rodar o PF do OpenBSD no Linux 7.3.4 — engine PF real, controlado pelo `pfctl` real
falando a ABI ioctl `/dev/pf` — como módulo out-of-tree com um conjunto mínimo e
documentado de patches no core, validado numa VM Alpine via libvirt.
## 2. Facts found / Fatos levantados
- OpenBSD **replaced classic ALTQ by ≥7.7** (verified: no `sys/net/altq` dir in
7.7/7.8/8.0) — **but PF queueing lives on in 8.0**, rearchitected into the core
`ifq` framework: `sys/net/ifq.c` (priq ops), `sys/net/hfsc.c`,
`sys/net/fq_codel.c`, ioctls `DIOCADDQUEUE/DIOCGETQUEUES/DIOCGETQUEUE/
DIOCGETQSTATS` (`pfvar.h:1707+`), `pf_rule.queue` glue all present; `pfctl`
still parses/installs `queue on … hfsc|fq_codel|default`. Older ALTQ releases
(≤7.6) are no longer mirrored anywhere (cdn/ftp/mirrors/GitHub-raw/Wayback all
404), making a 7.x base infeasible/undesirable anyway.
⇒ **port base = OpenBSD 8.0** (exactly matches the user's provided userland
tree, pfctl.c v1.406 2026-08-30; sources fetched from the user's C3SL mirror).
The ALTQ requirement is met as **OpenBSD-8.0 queueing (priq + HFSC + fq_codel)**
— the framework modern OpenBSD itself ships; classic cbq/wfq/red are
non-goals *because they don't exist in the base*, honestly matrixed.
- Kernel side ≈ 24.6k LOC (`pf.c` 9.2k, `pf_ioctl.c` 4.6k, `pf_norm/if/lb/table/
ruleset/osfp/syncookies`, `pfvar(_priv).h`) + queueing core (`ifq/hfsc/fq_codel`
≈ 2.4k LOC) + `ip_divert.c` reference (~300 LOC).
- Userland: single `pfctl` (~20k LOC incl. `parse.y`) — **libpfctl was folded back
into pfctl** in this era (Makefile `LDADD+= -lm` only; no libpfctl in tree).
- Linux 7.3.4 tree clean; toolchain gcc 16/flex/bison/bc; libvirt 12 + KVM usable
(existing `sms-vm` untouched); sudo works; Alpine cloud images available on dl-cdn.
- Module-visible APIs confirmed exported: `neigh_lookup`, `pskb_expand_head`,
`skb_clone`, `register_qdisc`, `qdisc_tree_reduce_backlog`, `tcf_classify`,
`__dev_queue_xmit`, `netif_rx`, `inet_addr_type`, `fib_table_lookup` (GPL).
- Confirmed **not exported** in 7.3.4: `neigh_output`, `dev_queue_xmit` (inline
wrapper over `__dev_queue_xmit`), `ip_route_output_key`, `ip6_route_output`,
`netlink_kernel_create` ⇒ core patch **P1 (mandatory): export symbols required
by the PF module** (additive `EXPORT_SYMBOL_*`, zero behavior change) — allowed
by user, fully documented in `patches/kernel/` + `docs/*/patches.md`.
## 3. Goals / Non-goals
**Goals (G)**
1. PF engine in-tree-equivalent quality, delivered as module `pf.ko` built from
`pf_port/` sources: rules (anchors, quick, match/block/pass/drop, return variants,
keep state), states + syncookies, tables (pfr: radix/hash, `pfctl -t`),
NAT (nat-to, rdr-to incl. sticky-port/bitmap basics, binat-to, route-to),
**scrub/normalize** (fragment reassembly, DF/TTL actions, min-ttl, max-mss),
**PF queues/ALTQ semantics of modern OpenBSD 8.0** (`queue`, `queue on` with
`default`(priq), `hfsc`, `fq_codel` schedulers from `sys/net/ifq.c`,
`hfsc.c`, `fq_codel.c`; `pfctl -s queue -v` stats), **divert** (`divert-to`,
`divert` tag; userspace channel modeled on `sys/netinet/ip_divert.c`),
**route-to** (pools + rr/rr-source/rr-any, hash-source/state;
`reply-to`, `dup-to`, `fail-to`).
2. Control plane = OpenBSD ioctl ABI on `/dev/pf`; Linux-built `pfctl` (8.0,
single binary, no libpfctl) + `pflogd`-lite + `pdivert` tool.
3. Patchset + scripts in the repo; kernel core patches only when unavoidable, each
documented (what/why/where/impact).
4. VM validation: kernel 7.3.4 built with patches, module + tools deployed into an
Alpine VM booted with that kernel; a scripted suite exercises every goal-1 area
with real traffic across a 4-VM libvirt lab (§7) and checks PASS/FAIL.
5. Full docs EN + pt-BR: apply patches, build, install, feature guide, VM validation
how-to, feature matrix incl. exclusions.
**Non-goals (NG, explicit to user)**: pfsync/carp (no failover), full IPsec/pfkey,
classic-ALTQ-only schedulers (cbq/wfq/red — **do not exist in the OpenBSD 8.0 base**,
matrixed as "removed upstream"), queues on interfaces whose root qdisc is not ours,
per-netns PF instances (faithful port = single global instance like OpenBSD; netns
isolation documented as out of scope), OS-fingerprinting beyond `pfctl -o` (osfp is
bonus if cheap), `ftp-proxy` and other userland daemons.
**Anti-goals**: silently faking a feature (stub verdicts), nftables-as-substitute
claims, tests that only check exit codes without inspecting packets/state.
## 4. Approach (chosen) / Abordagem
**B′ — faithful port with a compat shim** (alternative C rejected: user wants PF in
kernel; A rejected: infeasible). Port the actual OpenBSD 8.0 `pf*.c` + `ifq/hfsc/
fq_codel` + `ip_divert`-referenced divert under a compatibility layer, hook into
Linux via netfilter, keep upstream ABI for userland.
(Rejected variant: rewrite of PF semantics on nf APIs = not a port.)
### 4.1 Compat layer (`module/compat/`)
- **Packet object**: `struct mbuf` shim over `sk_buff` (single-segment: linearize on
entry to PF; `m_copym/m_adj/m_pull/m_copydata/m_cat/m_clump/m_free` → skb ops;
`m_pkthdr` ↔ `skb->protocol/tc/index`, address family, ifindex via
`skb->dev`). This mirrors how OpenBSD's own pf code treats mbufs and keeps diffs
localized to packet I/O, not to matching logic.
- **APIs**: `kern_*` glue: M_* malloc→`kmalloc/kfree` (pf's malloc flags → GFP),
`timeout()`→timer/workqueue (state purger), `rwlock_*`→RW-lock,
`pool`→kmem_cache, epoch→RCU read side + refcounts on rulesets (matching how
conntrack already models this), `time_second`→`ktime_get_seconds`,
`syncache` untouched, `radix.h`/`tree.h` (BSD) vendored with Linux tweaks,
`imsg` not needed in kernel.
- **Netlink/ioctl**: `misc_register` `/dev/pf`, all DIOC* handled exactly as
`pf_ioctl.c` (port file with shim includes). **ioctl number encoding**: define
`_IOR/_IOW/_IOWR(PFRDR,'n',type)` OpenBSD-style in our shared `pfvar.h` so kernel
and Linux-`pfctl` agree bit-for-bit.
- **IPv4/IPv6**: `in_proto`/`in6_proto` glue for checksums (`ip_sum_eq`,
pseudo-header sum via `csum_tcpudp_magic`, `tcp_input.c`'s in_cksum ported to
`pf_compat_cksum.c`); raw socket `sosend` paths replaced by
`icmp_send`/`icmp6_send` (RST via `tcp_v4_send_reset` equivalents kept in-pf to
preserve PF's own return logic).
- **Interface/addresses**: `ifnet`→`net_device` + `in_ifaddr`→`net_ifa` walks via
`net_if_FOREACH`/`in_ifa_lookup` helpers over `if_addr_list` + `fib` (replaces
`ifa_ifwithroute` for route-to); `pf_getifaddr` ported with Linux address lists.
### 4.2 Hooking
Register netfilter hooks per family:
- `PREROUTING (NF_IP_PRI_FIRST+)`: pf input path = scrub/normalize → state lookup →
nat/rdr (pf's own NAT, **independent of conntrack**; deployment docs warn about
overlap zones), filter INPUT.
- `FORWARD`: filter + route-to/dup-to execution.
- `LOCAL_OUT`: filter (OUTPUT) + nat-to (locally generated).
- `POSTROUTING (pri low)`: state insert, queue classify (skb→pfq class id),
route-to final xmit.
Verdict mapping: pass→`NF_ACCEPT`, block/normalization-drop→`NF_DROP` (with PF's
return-RST/ICMP generated by pf itself), `divert`→`NF_STOLEN` + divert channel,
`nat/rdr` rewrite → `NF_ACCEPT` after `skb_cow` + checksum fix. Coexistence with
iptables/nft: PF hooks run before conntrack (unregister-safe ordering); documented.
### 4.3 Queues (OpenBSD 8.0 `ifq`) ⇒ `pfq` qdisc shim
Port `sys/net/ifq.c/ifq.h` + `sys/net/hfsc.c` + `sys/net/fq_codel.c` (the real 8.0
queueing framework: priq glue, HFSC, FQ-CoDel as `ifq_ops`) into `module/ifq/`.
`struct ifqueue` instances are backed by a classful Linux qdisc **`pfq`**
(`register_qdisc`): `pfq_enqueue` → `ifq_enqueue` (matched class via pf tags),
`pfq_dequeue` → `ifq_deq_begin/commit` → `__dev_queue_xmit`. `tc qdisc replace dev X
root pfq` attaches; `scripts/pf-qdisc.sh` auto-attaches when a loaded ruleset
declares `queue on` (pfq handles both schedulers + default priq via `pf_qblocks`
priority bits). `DIOCADDQUEUE/DIOCGETQUEUES/DIOCGETQUEUE/DIOCGETQSTATS` (the real
8.0 names) served against the pfq/ifq registry so `pfctl -s queue -v` and
`pfctl -s ifq -v` work. Tested mandatory schedulers: **default(priq), hfsc,
fq_codel** with rate gates (§7); class visibility via `tc -s qdisc show` is a
stretch goal, counters via `pfctl` are the gate.
### 4.4 divert — real AF_DIVERT in the kernel (no netlink substitute)
OpenBSD's `divert(4)` socket family is ported as a **runtime-registered
socket family inside pf.ko**: Linux 7.3 registers protocol families with
`sock_register()` and `NPROTO = AF_MAX = 45`, so BSD's `PF_DIVERT = 32`
(the unused legacy PF_IUCV slot) fits with **zero core changes**. Implemented
in `module/pfsrc/divert_sock.c`: `socket(PF_DIVERT, SOCK_RAW, IPPROTO_DIVERT)`
+ `bind(port)` + `recvmsg/sendmsg`, packet metadata via cmsg (ifindex, dir,
divert type, original dst). `divert_packet()/divert6_packet()` (called by the
ported pf.c) queue to the bound socket; `sendmsg` re-injects with OpenBSD
semantics: `PF_DIVERT_TO` → local delivery (`netif_rx` on the recorded
interface), plain `divert` tag → re-route (`dst->output`), mirroring
`ip_divert.c:divert_output()`. The BSD `divert(8)` workflow (upstream
`regress/sys/net/pf_divert`) drives this socket unchanged; our shipped
`pdivert` is a thin Linux port. Wire format documented in
`docs/en/divert-wireformat.md`. pflog keeps its netlink multicast channel
(slot PFLOG_PROTO=1 on `NETLINK_USERSOCK`).
### 4.5 route-to / dup-to / fail-to
`pf_route6`/`pf_route` port: pool selection via `pf_lb.c` (as-is), next-hop
resolution `ifa_ifwithroute`→`pf_route4gw()`: build `flowi4/flowi6`,
`ip_route_output_key` to **gw**, then L2-redirect via `neigh_lookup(arp_tbl/nd_tbl,
&gw, dev)` + `neigh_output` (or `dev_queue_xmit` after `dev_hard_header`); TTL--
before xmit, state key = translated, `reply-to` via reverse state's stored answer
route. `dup-to` = `skb_clone` to original path. If a needed route/neigh helper
isn't exported → kernel core export patch, documented.
### 4.6 pflog (small, included)
`if_pflog.c` → netlink multicast channel (`NETLINK_PF` group) + `pflogd` userland
that writes pcap file (OpenBSD pflogd logic), so `tcpdump -r` verifies passes;
enables state/queue tests to assert headers (e.g., NAT rewrites, scrub).
### 4.7 Userland build (`tools/`)
Linux build of OpenBSD 8.0 `sbin/pfctl` (single binary — libpfctl is folded in),
`parse.y` via flex/bison, plus `pdivert` + `pflogd`, with a `compat-user/` shim
(err/warn, strlcpy, pledge stub, arc4random, sysctl subset, OpenBSD-shaped
`_IOR/_IOW/_IOWR`). Shared ABI header `include/net/pfvar.h` (ours, identical to
the kernel's) guarantees ioctl bit-parity; static asserts on every ioctl struct.
## 5. Kernel core patches
Target **zero behavior changes** to Linux; the patchset ships:
- **P1 (mandatory)** `net: export symbols needed by the PF module`
(`neigh_output`, `ip_route_output_key`, `ip6_route_output`,
`netlink_kernel_create`, + any added during M1–M5 builds) — additive
`EXPORT_SYMBOL*()` lines, no logic touched. Confirmed missing in 7.3.4.
- No other core changes by default: module builds out-of-tree (`make -C
linux-7.3.4 M=pf_port/module`), tree otherwise untouched; if a future step
needs more (e.g., a netlink protocol reservation), each lands as its own
`patches/kernel/000N-*.patch` appended by `scripts/apply-patches.sh`, with a
per-patch entry in `docs/{en,pt-BR}/patches.md`: what/why/impact/how to revert.
## 6. Repo layout
```
pf_port/ (= repo root, git → pf-linux-port)
README.md / README.pt-BR.md
docs/{en,pt-BR}/ guide, install, apply-patches, feature-matrix, validation,
divert-wireformat, patches/*.md
docs/superpowers/specs/ this file
patches/kernel/ 000N-*.patch + series + README
module/ pf.ko + pfq.ko sources: pf/, ifq/, compat/, include/
tools/ pfctl/, pdivert/, pflogd/, compat-user/
scripts/ fetch-sources.sh apply-patches.sh unpatch.sh
build-module.sh build-tools.sh vm-create.sh
vm-deploy.sh vm-run-tests.sh bench-basic.sh
tests/vm/ 01-state.sh … 14-altq.sh, scapy/python helpers, expect-files
_vendor/ OpenBSD 7.8/8.0 tarballs (gitignored; refetch script)
```
## 7. Validation (libvirt on the 192.168.1.242 host, Alpine) — "done" definition
`scripts/vm-create.sh` clones the Alpine 3.22 cloud qcow2 (verified: 840 MiB,
sha256 e83786b1…), NoCloud seed (user/network config), and defines **4 libvirt
domains on an isolated 192.168.122.x-free bridge lab (`pf-lan`, `pf-wan`, `pf-wan2`
networks, no NAT to the outside needed for gates)**:
`pftest-pf0` (**boots the self-built 7.3.4 kernel + initrd via direct kernel boot**;
3 NICs: lan/wan/wan2), `pftest-cli`, `pftest-srv`, `pftest-gw2`.
Decision (builder-check #1): PF stays a **single global instance bound to
`init_net`**, exactly like OpenBSD — no per-netns rulesets; hence endpoints must be
real hosts (VMs), not namespaces. The `deb` host (12 cores, libvirt 11.3 validated)
runs the lab; the laptop keeps building sources.
Suite (`vm-run-tests.sh`, ssh-driven, PASS/FAIL per feature): stateful icmp/tcp/udp
(+IPv6 smoke), block drop/return, tables (`pfctl -t -T add/del/test`),
anchors+quick, nat-to (asserted on srv: src=pf0's addr), rdr-to (srv httpd reached
via pf0 DNAT), binat-to, scrub (crafted frags via scapy on cli: overlap drop,
DF-set, max-mss clamp), **route-to pools rr + hash-source (asserted: wan vs wan2
tcpdump counts; fail-to when gw2 down; reply-to symmetry; dup-to capture)**,
**divert (`pdivert` rewrites payload end-to-end; timeout drop without server)**,
**queues: `queue on … { hfsc … }` and `fq_codel` and default priq — iperf
throughput within ±15% of rate on the bottleneck link + `pfctl -s queue -v`
counter growth + pfq attaches/detaches cleanly**, pflog capture sanity (passes
appear in pcap), ruleset reload under traffic (soak loop), rmmod/load leak check
(kmemleak + module refcounts). IPv6 policy (builder-check #4): engine is v6-complete
by construction (single pf.c); **gates are v4 for everything + v6 smoke for
state/block/nat66/scrub**; v6-only landmines (route-to6 pool failover) matrixed
"ported, smoke-validated". Performance (builder-check #2): **functional gates are
the bar; `bench-basic.sh` is informational** (reports pps vs iptables baseline, no
numeric gate) — a perf contract would force a non-faithful rewrite-first shim.
Artifacts: run log + per-test pcap attached to the Gitea release. **All gates must
pass**; anything failing ships as explicit FAIL in the feature matrix, not silently.
## 8. Milestones & delivery
M0 infra: repo+CI-ish scripts, kernel baseline build OK, VM boots custom kernel.
M1 engine-boot: compat layer + module loads, `/dev/pf`, pfctl `-f` empty + `-s info`.
M2 filter core: rules/states/tables/anchors pass VM tests 1–4.
M3 NAT suite (nat/rdr/binat) + scrub + pflog.
M4 route-to/dup-to/fail-to + divert.
M5 queues: ifq/hfsc/fq_codel port + pfq qdisc + `queue` semantics + counters.
M6 docs bilingual complete, patchset tidy, full suite re-run green, tags v0.x in repo.
Commits per milestone to Gitea (public), final release with validation artifacts.
## 9. Builder-decisions (my calls, reversible)
- Base **OpenBSD 8.0** (matches the provided userland tree exactly; ALTQ delivered
as 8.0's real queue framework ifq{priq,hfsc,fq_codel}; older ALTQ releases are
unrecoverable from public mirrors anyway).
- **Single global PF instance on `init_net`** (faithful to OpenBSD); validation via
multi-VM lab, not netns (builder-check #1).
- sk_buff-backed mbuf shim instead of rewriting pf.c to skb (keeps upstream logic
line-proximate; enables future rebase to newer OpenBSD).
- divert via netlink channel instead of inventing AF_DIVERT (no core socket-family
patch).
- pflog via netlink not pseudo-if tap (keeps "no extra interface" promise to users).
- Module name `pf.ko` (no Linux collision).
- **No numeric perf gate** (bench informational) and **v4-gated + v6-smoked** suite
(builder-checks #2/#4).
## 10. Risks
- `pf.c` porting surface largest; queue scheduler coverage limited to what exists
upstream in 8.0 (mitigation: 3 schedulers gated, matrix explicit).
- ioctl-ABI bit parity between two toolchains (mitigation: single shared header,
struct-size static asserts both sides).
- Kernel 7.3 symbol drift (mitigation: P1 export patches allowed+documented).
- Netfilter ordering vs conntrack for transit flows (mitigation: docs + test with
conntrack module loaded/unloaded).
- Multi-segment/GSO packets through mbuf shim (mitigation: linearize-on-entry for
v1, document perf cost; bench script quantifies).