Files
pf-linux-port/docs/en/debian13.md
T
lgcosta 928322edf7 feat(debian13): add host prep script and implementation manual
The manual is the from-zero path on Debian 13 (trixie): packages, the
7.3.4 kernel tree, pf.ko, the userland, the libvirt lab, the gate suite,
the stress test and running PF on the host itself. Every command in it was
executed on the lab's Debian 13.7 host, including the toolchain report
and the kernel.org checksum.

Trixie ships gcc 14 and no gcc-15; the kernel and pf.ko build with gcc
14 unpatched, so the manual documents that instead of the gcc >= 15 the
generic install guide asks for.
2026-11-12 20:11:09 -03:00

18 KiB
Raw Blame History

PF on Linux — implementing it from scratch on Debian 13 (trixie)

This document is the complete path, from zero, to run PF-on-Linux (the OpenBSD 8.0 PF ported to Linux as an out-of-tree kernel module) on a Debian 13 (trixie) machine: prepare the host, build the 7.3.4 kernel, build pf.ko and pfctl, bring up the libvirt lab, run the 16-gate suite and the stress test.

Every command below was executed on the lab's Debian 13.7 (trixie) host and the real output is shown. Where a number matters, it is the measured number — not an estimate.


0. What has to be clear before you start

Fact Practical consequence
pf.ko is built against the Linux 7.3.4 tree and carries that kernel's vermagic It does not load on the Debian 13 kernel (6.12). Do not try insmod pf.ko on 6.12: the vermagic differs and the load is refused.
The module registers netfilter hooks in init_net (IPv4 and IPv6, 5 points each) PF coexists with nftables/iptables; it does not replace the Debian firewall.
PF runs in one network namespace only (init_net) There is no per-netns PF instance.
The OpenBSD pfctl talks to the module through the /dev/pf ioctl ABI The pfctl in this tree is OpenBSD 8.0 compiled for Linux, not Debian's.

Three roles fit on one Debian 13 machine — and that is how the lab was validated:

Role What it does Where
build host builds the 7.3.4 kernel, pf.ko and the userland ~/lab/linux_kernel/pf_port
hypervisor hosts the 4 test VMs (libvirt/KVM) same host, libvirtd
PF host (optional) runs PF on Debian 13 itself, after booting the 7.3.4 kernel same host — see the warning in §9

1. Step 1 — prepare the host

git clone <repo-url> ~/lab/linux_kernel/pf_port   # or: cp -a /path/pf_port ~/lab/linux_kernel/
cd ~/lab/linux_kernel/pf_port
sudo ./scripts/debian13/prepare-host.sh

The script is idempotent: it installs the packages and prints the toolchain state. Real output on Debian 13.7:

== toolchain
gcc          14
make         GNU Make 4.4.1
bison        bison (GNU Bison) 3.8.2
flex         flex 2.6.4
bc           bc 1.07.1
rsync        rsync  version 3.5.0  protocol version 32
libvirt      11.3.0
qemu         QEMU emulator version 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1)
geniso       genisoimage 1.1.11 (Linux)

PREPARE_HOST_OK

About the compiler. Debian 13 ships gcc-14 as the default and has no gcc-15 in trixie (apt-cache policy gcc-15 → no candidate). The docs/en/install.md asks for "gcc ≥ 15" because the original development host is Fedora. On Debian 13 the build uses gcc 14.2.0 — the 7.3.4 kernel and pf.ko compile without a patch (verified, §4).

To split the roles, use --build-only or --lab-only.


2. Step 2 — get the sources

2.1 The 7.3.4 kernel tree

The repository does not ship the kernel: it expects the tree at ~/lab/linux_kernel/linux-7.3.4 (sibling of the repository). From zero:

cd ~/lab/linux_kernel
curl -fLO https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.3.4.tar.xz
curl -fLO https://cdn.kernel.org/pub/linux/kernel/v7.x/sha256sums.asc
sha256sum -c --ignore-missing sha256sums.asc     # checks only the tarball you fetched
tar xf linux-7.3.4.tar.xz

The hash matches what kernel.org publishes:

b11b6df8295e8e991ff040b71aaa4994a8b55caa23b6b092907f195b5840fd1f  linux-7.3.4.tar.xz

2.2 The OpenBSD 8.0 sources (PF itself)

cd ~/lab/linux_kernel/pf_port
./scripts/fetch-sources.sh     # fetches sys.tar.gz + src.tar.gz, verifies sha256 (pins in VERSIONS)
./scripts/vendorize.sh         # extracts only the ported files -> _vendor/upstream + IDS.txt

Wait for the OK sys.tar.gz and OK src.tar.gz lines. fetch-sources.sh tries the openbsd.c3sl.ufpr.br mirror first and falls back to cdn.openbsd.org.


3. Step 3 — build the 7.3.4 kernel

cd ~/lab/linux_kernel/pf_port
KERNELDIR=~/lab/linux_kernel/linux-7.3.4 ./scripts/build-kernel.sh

What the script does, in order:

  1. scripts/kernel-config.sh — starts from make defconfig and enables VIRTIO_BLK/PCI/NET=y, EXT4_FS=y, NETFILTER=y, NET_SCHED=y, INET=y, IPV6=y, SERIAL_8250(+CONSOLE)=y (this is what lets the pf0 VM boot the kernel directly, with no initrd) and runs make olddefconfig;
  2. make -j$(nproc-2) bzImage modules_prepare — compiles the kernel and prepares the tree for out-of-tree modules;
  3. copies vmlinux.symvers → Module.symvers (kbuild does not write the former) and makes sure scripts/module.lds exists;
  4. publishes into out/: linux-7.3.4-bzImage, System.map, .config, kernelversion, bzImage.sha256.

It ends with:

KERNEL_BUILD_OK 7.3.4

Reference time on the lab host (Debian 13, 12 threads): ~4 min for the kernel build itself (bzImage + modules_prepare) on a tree that already carried objects, and the whole script — configure, build, publish the artifacts — in under 3 minutes. A tree straight from the tarball starts empty; expect the make step to dominate there.


4. Step 4 — build the pf.ko module

cd ~/lab/linux_kernel/pf_port
./scripts/build-module.sh          # == make -C $KERNELDIR M=$PWD/module

Output: MODULE_BUILD_OK and the artifact module/pf.ko.

Check the vermagic — it decides where the module can load:

modinfo module/pf.ko | grep vermagic
# vermagic:       7.3.4 SMP preempt mod_unload

This is why kernel and module must come from the same tree: a pf.ko built against another 7.3.4 tree can have a subtly different vermagic (SMP preempt mod_unload modversions plus the build's CONFIG_*) and the load is refused.

4.1 Userspace ↔ kernel ABI check

pfctl talks to the kernel through shared structs. The abitest harness compares the layout on both sides:

make -C tools abi      # builds abitest (userspace) and abitest-module (kernel),
                       # dumps both size/offset tables and diffs them

No diff output = the ABI is intact.


5. Step 5 — build the userland

make -C tools          # -> tools/out/{pfctl,pdivert,pflogd} + abitest

pfctl is produced statically (-static) so it can be copied into the Alpine VMs without dependencies. Offline check (needs no /dev/pf):

(cd tools && ./out/pfctl -nf tests/01_basic.conf)    # parse-only: no output = ok
make -C tools test                                   # runs every test .conf
make -C tools abi                                    # userspace vs kernel ABI tables

5.1 What the Debian 13 build produced (verified)

Everything below was built and run on Debian 13.7 with gcc 14.2.0 — no patch, no backport:

$ ./scripts/build-module.sh
...
MODULE_BUILD_OK
$ modinfo module/pf.ko | grep vermagic
vermagic:       7.3.4 SMP preempt mod_unload
$ make -C tools test
PASS tests/01_basic.conf (exit 0)
PASS tests/02_translations.conf (exit 0)
PASS tests/03_queues_scrub.conf (exit 0)
PASS tests/broken.conf rejected (non-zero exit)
$ make -C tools abi
ABI OK: identical tables

The vermagic printed by the Debian build is identical to the one the lab VM accepts. Verified end to end: the module built on Debian 13 was copied into the lab VM and loaded there —

INSMOD_DEBIAN_MODULE_OK
Status: Enabled
crw-------    1 root     root       10, 259 /dev/pf
[  119.057040] pfk: netfilter hooks registered (10, netisr wq data path)

— as long as it was built from the same tree as the kernel the VM boots.


6. Step 6 — the libvirt lab on Debian 13

The lab is 4 Alpine VMs (pf0 = the firewall under test, plus cli, srv, gw2), a DHCP management network and three isolated bridges pf-lan/pf-wan/pf-wan2. The topology lives in lab/ips.env.

cd ~/lab/linux_kernel/pf_port
./scripts/vm-create.sh --pf0-kernel=out/linux-7.3.4-bzImage

The command is idempotent: it tears down and recreates the pftest-* domains, generates the cloud-init seeds, syncs the shared Alpine base image and starts everything. Without --pf0-kernel=, pf0 boots the kernel from its own Alpine image — and then pf.ko does not load (vermagic). Always pass --pf0-kernel= when the intent is to test PF.

Deploy the artifacts into the VM:

./scripts/vm-deploy.sh       # build host -> deb ($DEPLOY_DIR) -> pf0:/opt/pf/

Check the VM:

ssh pf0 'uname -r; lsmod | grep -w pf; ls -l /opt/pf/'

Expected: 7.3.4, the pf module loaded, and pf.ko/pfctl/pdivert/ pflogd in /opt/pf/.


7. Step 7 — the 16-gate suite

./scripts/vm-run-tests.sh          # or: make test

The runner restarts pf0 before every gate (clean state), re-deploys the module and the tools, and runs the gate from the build host. Expected output:

PASS=16 FAIL=0

Per-gate logs land in out/test-logs/<gate>.sh.log.


8. Step 8 — the stress test

The project had no performance gate (docs/en/vm-lab.md recorded that gap explicitly). The stress test is a new gate, tests/vm/16-stress.sh, which asks different questions from the functional gates: "does the engine stay correct, bounded and leak-free while traffic, states, rules and tables churn?"

./scripts/stress/run-stress.sh                 # default profile
./scripts/stress/run-stress.sh --quick         # short profile (smoke)
./scripts/stress/run-stress.sh --seconds 30 --connections 2000 --reloads 500

Options: --seconds, --parallel, --rate, --connections, --reloads, --rules, --table-entries, --max-overhead. The runner resets pf0, deploys, runs the gate and writes out/stress/<timestamp>/report.md.

The traffic phases run at a bounded rate (--rate, default 100 Mbit/s per stream = 400 Mbit/s total). This is not a convenience: PF steals every skb onto one ordered workqueue and runs the engine in process context, so the backlog — and the memory it takes — grows with the offered rate. On the 1 GB lab guest, 400 Mbit/s is stable and ~1 Gbit/s OOM-panics the kernel ("System is deadlocked on memory"). The stress test therefore works in the regime the port survives and reports the ceiling separately instead of killing the firewall under test. Details in caveats.md §13.

8.1 What each phase measures

Phase What it does How it is judged
0 — baseline throughput cli -> srv at the bounded rate with the module out of the path (rmmod pf) must be > 0 Mbit/s (otherwise the rest is meaningless)
1 — overhead the same traffic with PF enabled and stateful rules overhead % = (baseline − with PF) / baseline, against the limit (30% by default) — at the capped rate this says "PF sustains the offered rate"
2 — state churn 300 concurrent TCP connections to a dedicated listener on the server states appear in the table (pfctl -s states) and vanish after pfctl -sr → no leak
3 — rule churn 200 reloads of a 200-rule ruleset zero load failures, reload time measured inside the VM (min/avg/max), rule count stable
4 — table churn 2000 entries in a pfr table entries are gone after the flush → no table leak
5 — integrity 2 MB crossing nat-to while a stream saturates the link sha256 of the received file = sha256 of the source
6 — health dmesg, PF counters, and a final ping no BUG/WARNING/Oops; counters move; the firewall still routes

8.2 Reading the report

A real run on the lab (full profile, out/stress/<timestamp>/report.md):

STRESS phase=baseline baseline_mbit=400 seconds=10 parallel=4 rate_per_stream=100M
STRESS phase=overhead pf_mbit=400 overhead_pct=0 limit_pct=30 rate_per_stream=100M
STRESS phase=state_churn connections=300 states_before=0 states_created=300 states_after_reset=0 established_on_srv=214 rules_loaded=3 pf_status=Enabled
STRESS phase=rule_churn reloads=200 rules_per_load=200 reload_fails=0 reload_ms_avg=11 reload_ms_max=20 rules_final=202
STRESS phase=table_churn entries_requested=2000 entries_added=2000 add_errors=0 entries_after_flush=0
STRESS phase=integrity bytes=2000000 sha256_match=yes
STRESS phase=health dmesg_anomalies=0 match=5855 state_entries=7

Reading it: PF sustains the capped 400 Mbit/s with 0% overhead against the no-module baseline, tracks 300/300 concurrent flows as states and drains the table back to 0, reloads a 202-rule ruleset 200 times with no failure (11 ms average, 20 ms worst), flushes 2000 table entries back to 0, moves 2 MB through NAT byte-identically while a stream runs, and leaves no BUG/WARNING/Oops behind.

The runner prints STRESS RESULT: PASS or FAIL and exits with the gate's status.

Honesty note: the throughput numbers come from a virtualized lab (KVM, 2 vCPU per VM, virtio NICs bridged on the host). They are meant to compare baseline against PF in the same environment, not to predict production. The measured overhead includes the packet copy and PF's software path — the price of a stateful filter.


9. Step 9 (optional) — running PF on Debian 13 itself

This is not required for the lab, and it has a cost: the lab host is also the hypervisor — rebooting into another kernel takes the VMs down.

cd ~/lab/linux_kernel/linux-7.3.4
sudo make modules_install                       # pf.ko -> /lib/modules/7.3.4/
sudo cp arch/x86/boot/bzImage /boot/vmlinuz-7.3.4
sudo update-initramfs -u -k 7.3.4               # or: update-grub
sudo reboot                                     # ⚠ takes the lab VMs down

After the boot (with 7.3.4 active):

uname -r                                        # 7.3.4
sudo insmod /lib/modules/7.3.4/pf.ko            # creates /dev/pf (0600) and registers the hooks
ls -l /dev/pf
sudo cp ~/lab/linux_kernel/pf_port/tools/out/pfctl /usr/local/sbin/
sudo pfctl -e
sudo pfctl -f /etc/pf.conf
sudo pfctl -s info

A minimal /etc/pf.conf (OpenBSD 8.0 syntax):

set skip on lo
block return
pass on egress

To keep PF up across reboots, a simple unit:

# /etc/systemd/system/pf.service
[Unit]
Description=PF-on-Linux engine
After=network-pre.target
Before=multi-user.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/sbin/modprobe pf
ExecStart=/usr/local/sbin/pfctl -e
ExecStop=/usr/local/sbin/pfctl -d
ExecStop=/sbin/modprobe -r pf

[Install]
WantedBy=multi-user.target

And pflogd (writes a DLT_PFLOG pcap with the OpenBSD pflog(4) record layout):

# /etc/systemd/system/pflogd.service
[Unit]
Description=PF log daemon
After=pf.service

[Service]
ExecStart=/usr/local/sbin/pflogd /var/log/pf/pflog.pcap

[Install]
WantedBy=multi-user.target

pflogd reads /dev/pflog (created by the module, 0600). This pfctl has no -s pflog modifier: logging is turned on per rule with the OpenBSD keyword (pass log on egress ..., block log ...). The on-disk pcap lives wherever you point pflogd.


10. Verification checklist

# 1. module loaded and device created
lsmod | grep -w pf && ls -l /dev/pf

# 2. PF enabled
pfctl -s info | head -2                     # Status: Enabled

# 3. the OpenBSD 8.0 parser in the Debian binary
pfctl -nf /etc/pf.conf

# 4. hooks registered
dmesg | grep pfk                           # "pfk: netfilter hooks registered (10, netisr wq data path)"

# 5. the firewall really filters
pfctl -sr; pfctl -s states

# 6. the full suite
./scripts/vm-run-tests.sh                  # PASS=16 FAIL=0

# 7. stress
./scripts/stress/run-stress.sh             # STRESS RESULT: PASS

11. Troubleshooting

Symptom Cause Action
insmod: ERROR: could not insert 'pf.ko': Invalid module format vermagic differs from the running kernel kernel and module must come from the same tree; rebuild both
insmod fails on the Debian kernel (6.12) the module targets 7.3.4 boot the 7.3.4 kernel (§9) or test inside the lab VM
pfctl: Permission denied /dev/pf is 0600 root run as root
pfctl: Device not configured module loaded but pfctl -e never ran sudo pfctl -e
virsh: cannot connect to qemu:///system libvirt is down sudo systemctl enable --now libvirtd
Cannot find device 'eth2' interface names changed resolve with ip route get <ip> | grep -o 'dev [a-z0-9]*' (gate 15 does this)
a gate fails right after the deploy pf.ko was not copied the runner checks test -s /opt/pf/pf.ko; see out/test-logs/<gate>.sh.log
stress: baseline throughput unavailable iperf3 is not running on the server srv 'pkill -x iperf3; iperf3 -s -D' and check netstat -lnt | grep 5201
stress: state churn fails the guest's bash has no /dev/tcp the gate falls back to nc; check bash --version in the VM
the guest reboots during a stress run the offered rate exhausted the guest's memory (workqueue backlog) lower --rate; see caveats.md §13 — the ceiling measured on the lab is ~1 Gbit/s on a 1 GB guest
gcc-15 not found it does not exist in trixie use the default gcc 14 (§1)

12. Known limits (read before using this in production)

  • This port is not OpenBSD PF: the deviations are documented in docs/en/caveats.md and docs/en/feature-matrix.md.
  • No pfsync/CARP, IPsec/pfkey, ftp-proxy, per-netns PF, and none of the cbq/wfq/red schedulers (removed upstream before OpenBSD 8.0).
  • PF registers netfilter hooks in init_net — on a host that already runs nftables, traffic traverses both. PF's bypass only applies to traffic entering through PF's own hook.
  • IPv6, OS fingerprinting, set state-policy/allow-opts and syncookies compile but are not covered by a gate (known gap, §5 of docs/en/vm-lab.md).

13. References

Document Contents
docs/en/install.md the generic build recipe (without the Debian walk-through)
docs/en/vm-lab.md the lab, topology and gates
docs/en/feature-matrix.md what is verified and what is not
docs/en/caveats.md known deviations from OpenBSD 8.0
docs/en/pf-guide.md day-to-day rulesets and warnings
Portuguese version docs/pt-BR/debian13.md