chore: repo scaffold, READMEs (EN/pt-BR), version pins, source fetch+vendorize scripts

- pins OpenBSD 8.0 tarballs (sha256) and Linux 7.3.4 as the port targets
- scripts/fetch-sources.sh (C3SL primary, cdn fallback), scripts/vendorize.sh
- scripts/{kernel-config,build-kernel}.sh for the VM-lab kernel
This commit is contained in:
lgcosta committed 2026-11-11 11:26:43 -03:00
commit ba81284bd3
12 files changed
+724

No files matched your search

@@ -0,0 +1,160 @@
# PF-on-Linux Port Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a faithful OpenBSD 8.0 PF port for Linux 7.3.4 as out-of-tree modules (`pf.ko`, `pfq.ko`) + one documented core export patch + Linux `pfctl`, validated by a PASS/FAIL suite across a 5-VM Alpine libvirt lab.
**Architecture:** Ported 8.0 `sys/net/pf*.c`, `ifq/hfsc/fq_codel` files sit under an sk_buff-backed mbuf compatibility layer; netfilter hooks run PF at PREROUTING/FORWARD/OUTPUT/POSTROUTING; `/dev/pf` serves the OpenBSD ioctl ABI; queueing bridges to a classful `pfq` qdisc; divert/pflog use kernel netlink channels; userland `pfctl/pdivert/pflogd` built from 8.0 sources against a shared `pfvar.h`. See spec.
**Tech Stack:** OpenBSD 8.0 sources (C3SL mirror), Linux 7.3.4 build tree, gcc-16/bison/flex, libvirt/qemu on 192.168.1.242 ("deb"), Alpine 3.22 cloud qcow2 + cloud-init NoCloud, bash/scapy/iperf3 tests in-guest.
**Spec:** `docs/superpowers/specs/2026-11-11-pf-linux-port-design.md` (read it first; this plan argues from it).
## Global Constraints
- Kernel tree: `/home/lgcosta/lab/linux_kernel/linux-7.3.4` (Linux 7.3.4, vermagic `7.3.4`).
- PF sources base: **OpenBSD 8.0** release (`sys.tar.gz`, `src.tar.gz` from `https://openbsd.c3sl.ufpr.br/pub/OpenBSD/8.0/`, fallback `cdn.openbsd.org`), sha256-pinned in `scripts/fetch-sources.sh` (sys: `$(sha256)` — pin at T2; src: pin at T2).
- Module builds out-of-tree (`make -C <kernel> M=$PWD/module`); kernel core changes ONLY via `patches/kernel/000N-*.patch`, each documented in `docs/{en,pt-BR}/patches.md`.
- Every ported kernel file keeps the OpenBSD header block verbatim + our ` * Copyright (c) 2026 PF-on-Linux contributors (compat changes under ISC)` line.
- License of new code: ISC. Docs bilingual: EN primary + full pt-BR counterpart (`docs/en/`↔`docs/pt-BR/`, `README.md`↔`README.pt-BR.md`).
- `/dev/pf` mode 0600 root-only; all ioctl in-blobs length-validated as upstream (never relax upstream checks).
- Single global PF instance bound to `init_net` (faithful; netns = out of scope). PF hooks MUST run before conntrack (`NF_IP_PRI_CONNTRACK-1`-equivalent ordering, documented).
- Test gates are v4; v6: state/block/nat66/scrub smoke. No numeric perf gate (`bench-basic.sh` informational).
- Commit per task; branch `main`, push to `https://git.labexposed.com/lgcosta/pf-linux-port` each task.
- VM lab lives on **deb** (ssh root@192.168.1.242); never touch the laptop's `sms-vm`; laptop = build host only. Artifacts sync laptop→deb via rsync.
- pf0 VM boots the self-built kernel by direct kernel boot with virtio/ext4 **built-in** (no initrd).
## Review Focus
- Non-IPv4/IPv6 EtherTypes passing hooks unchanged (ARP etc. — expect `NF_ACCEPT`, tested).
- Fragment/extension-header packets into the mbuf shim (linearize path; crafted scapy inputs must not leak skbs or panic; tested via scrub suite).
- Multi-segment GSO/GRO skbs entering PF (must be handled: linearize fallback; tested by iperf through a passing ruleset).
- Malformed/oversized `pfioc_*` blobs from local root (length fields must be bounded-checked exactly as upstream; fuzz test T9).
- Module unload (`rmmod pf pfq`) under live traffic and with states present (must drain, no oops; tested by soak T23).
- NAT port-pool reuse after state expiry (no stale bindings; tested in T15 via rapid reconnect churn).
---
### Task 1: Repo scaffold + remote
**Files:** Create `pf_port/.gitignore` (`_vendor/`, `out/`, `*.ko`), `README.md`, `README.pt-BR.md` (project one-pager each), `LICENSE` (ISC + OpenBSD copyright notice block), `VERSIONS` (kernel 7.3.4; OpenBSD 8.0; date), `Makefile` (targets: module tools kernel-vm docs test).
**Interfaces:** none produced beyond layout in spec §6.
- [ ] Step 1: `git init` in `pf_port/`, identity lgcosta, remote origin = Gitea repo.
- [ ] Step 2: write the files above (READMEs mirror: what/why/status/features-matrix-table-empty/build-once-commands).
- [ ] Step 3: first commit, push. Verify `git ls-remote origin` shows `main`.
### Task 2: Source acquisition + vendorization
**Files:** Create `scripts/fetch-sources.sh`, `scripts/vendorize.sh`; `scripts/sha256-pin.txt`.
**Interfaces:** Produces `_vendor/openbsd/8.0-sys.tar.gz`, `8.0-src.tar.gz` (verified), and staged pristine copies at `_vendor/upstream/sys/net/*`, `_vendor/upstream/sbin/pfctl/*` used by all port tasks; prints per-file `$OpenBSD: id` lines to `_vendor/upstream/IDS.txt`.
- [ ] Step 1: fetch script with mirror→cdn fallback; verify against pin file populated from the two tarballs already downloaded + SHA256 from `.../8.0/SHA256`.
- [ ] Step 2: vendorize extracts exactly: sys: `pf.c pf_ioctl.c pf_norm.c pf_if.c pf_lb.c pf_ruleset.c pf_table.c pf_osfp.c pf_syncookies.c pfvar.h pfvar_priv.h if_pflog.c if_pflog.h ifq.c ifq.h hfsc.c hfsc.h fq_codel.c fq_codel.h ip_divert.c ip_divert.h tree.h queue.h` (sys/sys/) `radix.c radix.h` (sys/net/); src: `sbin/pfctl/*` (all .c/.h/.y/.l + Makefile), `include/`.
- [ ] Step 3: run both; `IDS.txt` non-empty; commit scripts only.
### Task 3: Kernel build pipeline (laptop)
**Files:** Create `scripts/build-kernel.sh`, `scripts/kernel-config.sh`.
**Interfaces:** Produces `out/linux-7.3.4-bzImage`, `out/System.map`, `out/.config`, module tree build-ready at `linux-7.3.4` (Module.symvers).
- [ ] Step 1: config = defconfig + `VIRTIO_BLK=y VIRTIO_PCI=y VIRTIO_NET=y EXT4_FS=y` + `NET_SCHED` `NET_EMATCH` off-safe, `KPROBES` off, `DEBUG_INFO_REDUCED` optional; script idempotent, diff-checked (`scripts/kernel-config.sh diff`).
- [ ] Step 2: full build `make -j$(nproc-2) bzImage modules`; verify `file bzImage`, `modpost` artifacts exist; record kernel `vermagic` string into `VERSIONS`.
- [ ] Step 3: commit (scripts + VERSIONS update only; out/ gitignored).
### Task 4: Core export patch + patch tooling
**Files:** Create `patches/kernel/0001-net-export-symbols-needed-by-pf.patch`, `patches/kernel/series`, `scripts/apply-patches.sh`, `scripts/unpatch.sh`; docs stubs `docs/en/patches.md`, `docs/pt-BR/patches.md`.
**Interfaces:** `apply-patches.sh <kerneldir>` → applies all series patches (git-am style `git apply`), prints per-patch rationale from `docs/*/patches.md` anchors; build tasks 3/6 consume it.
- [ ] Step 1: after M1 first build reveals the exact unexported list (start: `neigh_output`, `ip_route_output_key`, `ip6_route_output`, `netlink_kernel_create`, plus any `ip_neigh_for_gw`, `fib6_lookup_*` discovered by build/link errors), write 0001 adding `EXPORT_SYMBOL_GPL(...)` lines adjacent to each existing export cluster.
- [ ] Step 2: apply to `linux-7.3.4` (keep `git` clean via `unpatch.sh` = reverse apply; the tree is NOT a git repo → patches applied/reverted textually, never left dirty on final artifact builds without record), rebuild, verify `Module.symvers` contains them.
- [ ] Step 3: docs table entry for 0001 (what/why/impact/revert); commit.
### Task 5: VM lab provisioning on deb
**Files:** Create `scripts/vm-create.sh`, `scripts/vm-destroy.sh`, `scripts/lab-lib.sh`, `lab/{cloud-init/*.yaml.tmpl,domains/*.xml.tmpl}`.
**Interfaces:** `vm-create.sh` idempotent → domains `pftest-pf0` (3 NICs `pf-lan/pf-wan/pf-wan2` + mgmt default-net NIC), `pftest-cli`, `pftest-srv` (mgmt+data NICs), `pftest-gw2` (pf-wan2+pf-wan); static IPs per `lab/ips.env` (sourced by ALL test scripts): PF0_LAN=10.0.1.1 PF0_WAN=10.0.2.1 PF0_WAN2=10.0.3.1 CLI=10.0.1.10 SRV=10.0.2.20 GW2=10.0.3.2 GW2_WAN=10.0.2.2; ssh aliases `pf0/cli/srv/gw2` via `~/.ssh/config.d/pflab` (generated), root key auth from deb. Non-pf VMs boot Alpine stock kernel; pf0 boots direct kernel from `out/` later (T6).
- [ ] Step 1: seed ISO/dir per VM (NoCloud meta-data+user-data incl. `packages: [iperf3, tcpdump, python3, py3-scapy, bash]` — verify pkgs install needs mgmt NIC up: enable `virsh net-start default` on deb).
- [ ] Step 2: create networks XMLs (forward=none for pf-*); domains XMLs (q35, virtio disks, host passthrough? plain64 OK, 2 vCPU/1G each).
- [ ] Step 3: run; loop `ssh pf0 true` until OK (≤3min); `vm-destroy.sh` tears down cleanly both directions; commit + rsync verified.
### Task 6: Direct-boot pipeline for pf0 (custom kernel)
**Files:** Modify `scripts/vm-create.sh` (pf0 domain uses `--boot kernel=... `); Create `scripts/vm-deploy.sh` (rsync out/bzImage + module .kos + tools to deb `/var/lib/pflab/pf0/`; copies into pf0 over ssh to `/opt/pf/`).
**Interfaces:** `vm-deploy.sh pf0` leaves: `uname -r` == 7.3.4 in pf0.
- [ ] Step 1: define pf0 with direct kernel boot (`/var/lib/pflab/pf0/bzImage`, cmdline `root=/dev/vda1 ro console=ttyS0`, no initrd); serial console log capture file for tests.
- [ ] Step 2: deploy, boot, verify `uname -r`+`lsmod` clean; commit.
### Task 7: Compat layer core (`module/compat/`)
**Files:** Create `module/Makefile` (obj-m: `pf.o` `pfq.o`), `module/include/` (our `pfvar.h` staged: upstream + Linux compat block: OpenBSD-shaped `_IO/_IOR/_IOW/_IOWR`, `u_intXX_t` typedefs, `sockaddr`/`in_addr` shims), `module/compat/pfk.h` (master header), `compat/pfk_mbuf.{c,h}` (`struct mbuf{next,packet,len,cap_data,flags,type,sk_buff *skb}` — the skb wrapper + `m_copym/m_adj/m_pull/m_copydata/m_cat/m_clump/m_free/m_freem/m_dup/m_copypacket/m_devget/m_get/m_getptr` + `pfk_to_skb()/pfk_from_skb()`), `compat/pfk_kern.c` (malloc/free M_*, pools, `pfk_time` (jiffies→sec), timeout/evtimer→timer_list+work, rw/mutex wrappers, per-cpu counters, arc4random→get_random_u32), `compat/tree.h` (BSD RB via upstream with `container_of` swap), `compat/radix.{c,h}` (OpenBSD radix ported), `compat/pfk_mod.c` first skeleton: misc `/dev/pf` 0600, `DIOCGETSTATUS` only, netfilter hooks NOT yet registered.
**Interfaces (produced, used by every port task):** `struct pf_buf` ops above; `#include <net/pfvar.h>` resolves to our staged header; `pfk_ifname2dev()`, `pfk_addrofv6/v4`, per-file pattern `#ifdef PFLINUX` minimal.
- [ ] Step 1: headers + skeleton `.c` files compile clean: `make -C linux-7.3.4 M=pf_port/module` produces `pf.ko` (modpost warnings = 0).
- [ ] Step 2: deploy to pf0 VM, `insmod pf.ko` → `ls /dev/pf`, dmesg clean; commit.
### Task 8: pfctl Linux build
**Files:** Create `tools/Makefile`, `tools/compat-user/{err.c,strlcpy.c,strtonum.c,imsg-buffer.c?,pledge.c,sys-queue.h}`, stage 8.0 `sbin/pfctl/*.c|parse.y` into `tools/pfctl/` (+`pfctl_linux.c` for path/ioctl glue: `/dev/pf`, `pfctl.conf` optional), `tools/pdivert/pdivert.c` (reads pkts from NETLINK_USERSOCK proto channel: bind port=N ↔ divert id; reply write reinjects via kernel; modeled on OpenBSD `divert(8)`), `tools/pflogd/pflogd.c` (consumes NETLINK_PFLOG multicast → pcap file, rotation flags).
**Interfaces:** `tools/out/pfctl` (static-ish linked, glibc fine): supports `-n -f -s rules|states|tables|queue|info|src -t -T -sS -i -d` — same flags (same code). `pdivert -p <id>` used by test 11; `pflogd -f /var/log/pflog` used by test 08.
- [ ] Step 1: build on laptop against staged headers: `make -C tools` → flex/bison parse.y compiles without errors (only compat edits inside `#if defined(__linux__)` or small sed-style upstream changes recorded in `tools/UPSTREAM.md` diff-notes).
- [ ] Step 2: `pfctl -n -f tests/confs/min.conf` succeeds OFFLINE (parse-only).
- [ ] Step 3: deploy binary to pf0; `pfctl -s info` vs our T7 skeleton status → prints Status: Enabled; commit.
### Task 9: ioctl ABI parity harness
**Files:** Create `tools/abitest/{abitest.c,gen-sizecheck.sh}`, `tests/confs/fuzz-ioctls.py`.
**Interfaces:** `abitest` prints PASS/FAIL for every DIOC* struct: sizeof/offsetof computed by kernel (`DIOCGSTRUCTSIZE`-style custom ioctl we add under our own vendor-safe id? NO — via `DIOCGETSTATUS` roundtrip + userspace `sizeof` table generated from SAME `pfvar.h` → assert both sides) — concrete: kernel exposes `pfk_abi_hash` module param (CRC32 of packed struct sizes table); abitest recomputes from header → mismatch = FAIL. fuzz script throws oversize/garbage `pfioc_*` blobs.
- [ ] Step 1: implement, run on pf0: ABI CRC match; fuzz: no oops/leak (dmesg+kmem checks).
- [ ] Step 2: negative case: corrupt table → abitest FAILs (self-test of the harness); commit.
### Task 10: Ruleset engine port (pf_ruleset + pf_table)
**Files:** `module/pf/pf_ruleset.c`, `module/pf/pf_table.c` staged+ported; `module/pf/hooks.c` (first real netfilter registration: IPv4+IPv6 PRE/FWD/OUT/POST, verdict plumbing skeleton, pfk_mbuf ingress), `module/pf/pfk_if.c` (net_device/in_ifaddr walks replacing OpenBSD ifnet/ifa lists; interface anchor list).
**Interfaces:** `pfctl -f min.conf` with a `table <blk> { 10.0.1.99 }` + `block drop quick from <blk>` loads; `pfctl -s tables`, `pfctl -t blk -T add/test/del/kill` roundtrip; **no forwarding decisions yet** (match results not applied until T12).
- [ ] Step 1: port+compile the two files (their OpenBSD deps satisfied by T7).
- [ ] Step 2: VM: load ruleset, table ops incl. bulk add from file, `-T show`; `rmmod` after: kmemleak clean; commit.
### Task 11: pf_norm / scrub
**Files:** `module/pf/pf_norm.c` (+`pf_osfp.c` staged), `tests/vm/08-scrub.py` (scapy crafted: overlapping frags → drop, fragment TTL, `max-mss 1380`, `df-bit` set, `no-df` fragmentation, random-id reassembly correctness).
**Interfaces:** `pf_scrub()`/`pf_normalise()` wired into hooks ingress; `set reassemble yes|no` honored; OS-fp stored in states (`pass ... set os ...` matching via `pfctl -s states -v` shows osname if configured).
- [ ] Step 1: port; compile; wire.
- [ ] Step 2: run 08-scrub on lab (cli scapy → pf0 via srv sink): all asserts green incl. no skb leak; commit.
### Task 12: pf.c core (matching + states) + verdicts
**Files:** `module/pf/pf.c` staged+ported (state hash: `pfk_hashkey()` = Linux `jhash2()` over the `pf_state_key` bytes; state table = OpenBSD 8.0 open-hashing layout kept upstream-shaped via pfk wrappers — no rhashtable), `module/pf/pfk_state_timer.c` (purger→delayed_work), syncookies (`pf_syncookies.c`).
**Interfaces:** default policy pass + rules; `keep state` (icmp/tcp/udp), state insert direction (INBOUND/OUTBOUND semantics as 8.0), `block drop|return|return-rst|drop-rst` actual verdicts + return packet generation via `icmp_send`/`tcp_v4_send_reset`-like in-pf path `pfk_send_icmp_error()`; `pfctl -s states -v` full fields; `match-to state`/`sync` no-op fields fine (documented NG).
- [ ] Step 1: port pf.c in passes: (a) structs/globals, (b) `pf_test`/`pf_match_key_all`/`pf_state_insert`, (c) verdict application at hooks, (d) returns, (e) syncookies.
- [ ] Step 2: VM tests `01-state.sh` (icmp: reply accepted only with state; `block drop in` before state creation kills ping; tcp 3-way via nc; udp pseudo state; `keep state` interfaces), `02-block.sh` (return-rst payload/TCP seq asserts via tcpdump on cli), `03-anchors.sh` (include anchor quick precedence), `04-table-rules.sh` (block from table live-updated), **non-IPv4 EtherTypes (ARP frame) pass hooks untouched (tcpdump asserts ARP still served on pf0)**; all green; commit.
### Task 13: NAT (nat-to / binat-to) + pf_lb sticky/bitmap
**Files:** `module/pf/pf_lb.c` ported; NAT branches in pf.c wired at POSTROUTING (outbound) + reverse xlate on inbound; port-pool allocator using pfk (random port from range + bitmap state-attached, 8.0 semantics incl. `portrange high` bitmap persisting on rule? sticky-port yes, static-port yes).
**Interfaces:** transit `nat-to` rewrites seen on wan with correct checksums + reply xlate + state tracking; `binat-to` static 1:1; `rdr-to` (inbound DNAT incl. `route-to` combo basic); `pfctl -s states -v` shows translated keys; port exhaustion behavior = `block-rst`+ICMP as upstream? (match 8.0: no nat rule → normal block path; test asserts drop).
- [ ] Step 1: port+wire; tests `05-nat.sh` (cli→srv with SNAT: srv sees PF0_WAN; reply flows; no state → reply dropped), `06-rdr.sh` (srv httpd:80 via PF0_LAN:8080; sticky-port: 2nd client gets distinct PF0 src port? sticky: same), `07-binat.sh`; churn test rapid reconnect ×50 → no stale port reuse before timeout (Review Focus #6); commit.
### Task 14: route-to / dup-to / fail-to / reply-to
**Files:** `module/pf/pf_route.c` (new: Linux implementation of `pf_route()/pf_route6()` bodies: gw resolve `ip_route_output_key`+`neigh_lookup(&arp_tbl/nd_tbl)`+`neigh_output`, TTL-- , loop guard via `skb->mark` magic + 8.0 `pf_rt_tblid` equivalent — DECISION: loop guard = per-cpu re-entry counter like OpenBSD's `pfroute` depth check; dup via `skb_clone`+original-path re-entry), pf_lb pool selection wired (rr/round-robin-source/hash-state as upstream).
**Interfaces:** `pass out route-to (GW2 10.0.3.2) ...` → traffic lands on wan2 and gw2 delivers; pools with 2 gw rotate (rr) / hash-source stable per client; `reply-to` uses state's answer route; `dup-to` copy seen on other leg; `fail-to` when primary gw down (gw2 arp-stopped test); IPv6 route-to smoke.
- [ ] Step 1: implement + port `pf_route/pf_route6` glue; compile.
- [ ] Step 2: tests `09-routeto.sh` (tcpdump counts wan vs wan2: rr alternation exact, hash-source deterministic per src; loop-guard: pf0->pf0 route-to with gw=pf0 must not livelock: bounded pkts then block `block loop drop`? use upstream `pf_route` loop detection → drop; assert), `10-dup-fail.sh`; commit.
### Task 15: divert
**Files:** `module/pf/pf_divert_nl.c` (kernel: `netlink_kernel_create(NETLINK_USERSOCK, proto=31? use NETLINK_USERSOCK group id per divert port)`, table port→socket, `divert-to` path: copy skb → nl msg `{cookie, divport, af, ip hdr+payload}`; reinjection path on user `send`: parse → route per original semantics `ip_divert.c` reference (divert-to: re-route to original dst via gw if needed; plain divert tag: re-enter `netif_receive_skb`/local_xmit per 8.0 `pf_send` behavior; drop-after-timeout via per-packet timer default 5s? match upstream: no accept → packet never delivered (copy consumed? original held until accept/timeout then free — mirror OpenBSD: keep, timeout drop).
**Interfaces:** `pdivert -p 10000` + rule `pass in divert-to 127.0.0.1 10000`? upstream model: `divert-to` sends to divert socket at **host:port**; ours: netlink port id; document mapping in `docs/en/divert.md`; stats `DIOCBUSYSTATS` unchanged.
- [ ] Step 1: implement + tool link already from T8.
- [ ] Step 2: test `11-divert.sh`: pdivert on pf0 flips a byte of ping payload → cli receives modified echo-replies; kill pdivert → traffic stops (timeout) not leaks; commit.
### Task 16: ifq + pfq queueing
**Files:** `module/ifq/ifq.c ifq.h hfsc.c fq_codel.c` ported (skb-backed via pfk_mbuf), `module/pfq/pfq_qdisc.c` (classful qdisc `pfq`: root=ifq attach; ops: enqueue→`ifq_enqueue(ifq_for_class)`, dequeue loop; classid = queue index from pf rule queue tag), `module/pf/pf_ifq_bridge.c` (DIOCADDQUEUE/GETQUEUES/GETQUEUE/QSTATS + `pf_test` queue assignment + `set queue` default-class + ifq per-iface registry via `pfq_q_enter`), scripts `pf-qdisc.sh` auto-attach on `queue on` detection at DIOCADDQUEUE.
**Interfaces:** `pfctl -s queue -v` counters (pkts/bytes/drops per queue + per scheduler), `queue on <dev> { ... }` semantics: default priq (link share/delay), `hfsc` (realtime/upperlimit/linkshare), `fq_codel` (flows; ECN mark? upstream `ifq_fq_codel` flags: ecn,quantum). Verdict: packets matching queued rules get `skb->priority`-tag (pfq) and are served under `pfq` root qdisc.
- [ ] Step 1: port files; qdisc registers (visible `tc qdisc show` as `pfq` when attached).
- [ ] Step 2: test `12-queues.sh`: hfsc 10M upperlimit → iperf cli→srv ≈10M ±15% while unqueued passthrough ≈ line-rate; counters grow + `-s queue -v` stable; detaching leaves net clean (`tc del qdisc` + rmmod); fq_codel smoke: 1 flow flows, drops counted; commit.
### Task 17: pflog
**Files:** `module/pf/pflog_nl.c` (kernel netlink socket on `NETLINK_USERSOCK`
protocol slot `PFLOG_PROTO=1` (divert uses slot `PFDIVERT_PROTO=2` — both slots
documented in `docs/en/divert-wireformat.md`): pass/block/drop/log-rule copies per
`log`/`log all`/`block log` rules incl. `if_pflog.c` wrapper header bytes exactly as
upstream DLT_PFLOG), `tools/pflogd` from T8.
**Interfaces:** `pflogd` writes `/var/log/pflog` pcap with linktype DLT_PFLOG(117) → readable by `tcpdump -r`; test asserts NAT'd/scrub'd headers logged per rules; `pfctl -s log` config.
- [ ] Step 1: implement + wire `pf_klog` call sites in pf.c port.
- [ ] Step 2: test `13-pflog.sh` (tcpdump parse: expected pkts/ifs/priority bits present, block-with-log entries); commit.
### Task 18: Soak, leak, reload-under-traffic + bench info
**Files:** `tests/vm/14-soak.sh` (30-min: continuous iperf cli→srv + ruleset `pfctl -f` reload loop every 2s + `rmmod`/`insmod` cycle at end), `scripts/bench-basic.sh` (pps via scapy TRAFFICGEN one-pkt/sec rules, iptables-empty vs pf-empty — informational CSV only).
**Interfaces:** PASS = zero oops in dmesg, kmemleak (config DEBUG_KMEMLEAK on for test kernel build variant via `kernel-config.sh test`), conntrack-coexist run (`iptables -A ... ACCEPT; iptables -F` before each run: no rule bleed; Review Focus #5/#3: GSO path exercised by iperf).
- [ ] Step 1: build test-config kernel with kmemleak; run soak; commit artifacts.
### Task 19: Docs EN/pt-BR + release
**Files:** `docs/en/{index,install,apply-patches,build,vm-lab,pf-guide,feature-matrix,divert-wireformat,patches}.md` + full `docs/pt-BR/` counterparts; READMEs final (build-once recipe `make all`, features + matrix with **all NG/FAIL rows stated**, OpenBSD→Linux behavioral differences: no `divert(4)` sockets, queues via pfq attach, `/dev/pf` root-only, coexist notes); `Makefile` targets wired; GitHub-release notes style; git tag `v0.1.0`.
**Interfaces:** `scripts/quickstart-check.sh`: fresh clone on deb → fetch/build/deploy/run tests → exit 0 (the release gate).
- [ ] Step 1: write docs from actual artifacts (no placeholders); run quickstart-check end-to-end from clean tree on a scratch dir; tag + push + `create_release` with suite logs; commit.
---
**Execution notes:** Tasks 7→12 are the serialized critical path (same `pf.c` file; one owner). 8/9 parallelize with 7 after interfaces freeze; 13→17 follow 12; 5/6 (lab) fully parallel from the start; 19 per-milestone increments + final pass. Every ported upstream file: changes grouped with `/* PFLINUX: reason */` markers for future rebases.
@@ -0,0 +1,268 @@
# 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
- In-pf action `divert-to host:port` / tag `divert port`: packet copied to
userspace divert channel; **channel = kernel netlink** (`NETLINK_USERSOCK` proto)
because Linux has no `AF_DIVERT` (in-tree reference logic ported from
`sys/netinet/ip_divert.c`; userland `pdivert` tool implements `divert(8)`-style
rewrite-loop over the netlink channel; wire format documented; `DIOCBUSYSTATS`
etc. unchanged). Diverted flow re-enters via the channel write →
`__dev_queue_xmit`/`netif_receive_skb` following ip_divert `divert_output`
semantics (re-route via gw neighbor for `divert-to`).
- Test parity anchored by upstream `regress/sys/net/pf_divert` semantics.
### 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).