A Python simulator that parses real pf.conf syntax and evaluates packet flow
against the full ruleset — before you deploy to hardware.
Closes the gap between pfctl -nf (syntax check) and tcpdump (runtime debug).
Tests your firewall logic offline, catches rule ordering bugs, verifies VLAN
isolation, and simulates attack chains.
- Last-match-wins semantics (no
quick= keep evaluating) quickshort-circuit rules- Anchor ordering and evaluation
- Tag assignment via
match(last match wins) and tag carry through NAT - Antispoof expansion into real block rules
rdr-todestination rewriting with egress re-evaluation- State table simulation with
max-src-connrate limit enforcement - NAT tracking (
nat-to,rdr-to) route-tofor policy routing (VPN kill-switches)- Interface groups (
egressresolves to WAN) - Tables (
<tablename>with persist/const members) - Macros (full recursive resolution)
- Scrub / TCP reassembly — needs real packets and a TCP stack
pfctlsyntax edge cases — the parser uses regex, not PF's grammar. Always runpfctl -nf /etc/pf.confon the router before deploying.
your-repo/
pf.conf # your main config
pf.d/ # anchor files
trust.conf
iot.conf
...
pftest.py # the simulator engine
test_your_rules.py # your tests (see below)
Place your pf.conf and pf.d/ directory alongside pftest.py.
The engine auto-detects your topology:
- Pairs
*_ifmacros with*_netmacros by prefix (trust_if/trust_net,vpn_if/vpn_net, etc.) - Detects WAN from
wanmacro or NAT rules - Scans
match ... tagrules for any remaining interface-to-network bindings - Resolves
egressinterface group to your WAN
No configuration file needed.
python3 pftest.pyThis runs the engine's self-tests (rule parsing, evaluation, antispoof, rdr-to rewriting, and state table rate limits) against your config.
from pftest import Packet, load_all_rules, evaluate
rules = load_all_rules()
# test a single packet — use YOUR segment IPs and interfaces
result = evaluate(
Packet(src="192.0.2.50", dst="198.51.100.1", proto="tcp",
dport=443, iface="vlan10", direction="in"),
rules,
)
assert result.action == "pass"
assert result.tag == "MY_TAG"You can also use the class API directly for isolation:
from pftest import PFSimulator, Packet
sim = PFSimulator(pf_conf="/path/to/pf.conf", pf_d="/path/to/pf.d")
rules = sim.load()
result = sim.evaluate(Packet(src="192.0.2.50", dst="198.51.100.1",
proto="tcp", dport=443,
iface="vlan10", direction="in"))| Field | Type | Description |
|---|---|---|
src |
str | Source IP |
dst |
str | Destination IP |
proto |
str | tcp, udp, or icmp |
dport |
int | Destination port (0 = any) |
sport |
int | Source port (0 = any) |
iface |
str | Interface the packet arrives on / exits from |
direction |
str | in or out |
tag |
str | Pre-assigned tag (for egress tests) |
icmp_type |
str | echoreq, unreach, timex |
| Field | Type | Description |
|---|---|---|
action |
str | pass or block |
rule |
Rule | The matching rule object |
tag |
str | Tag assigned during evaluation |
nat_applied |
bool | NAT was applied |
rdr_applied |
bool | rdr-to rewrote the destination |
rdr_dst |
str | Rewritten destination IP |
rdr_dport |
str | Rewritten destination port |
route_to |
str | Policy routing target |
trace |
list | [(rule, action_str)] evaluation trace |
Test a full connection path — ingress on one interface, tag assignment, then egress on another. Use your own IPs and tags:
# ingress — packet enters on an internal interface
ingress = evaluate(
Packet(src="192.0.2.10", dst="198.51.100.1", proto="tcp",
dport=8883, iface="vlan30", direction="in"),
rules,
)
assert ingress.action == "pass"
assert ingress.tag == "IOT" # your tag name
# egress — carry tag from ingress to WAN
egress = evaluate(
Packet(src="192.0.2.10", dst="198.51.100.1", proto="tcp",
dport=8883, iface="igc1", direction="out",
tag=ingress.tag),
rules,
)
assert egress.action == "pass"from pftest import enable_state_tracking, disable_state_tracking
enable_state_tracking()
# send more connections than max-src-conn allows
for i in range(55):
result = evaluate(
Packet(src="192.0.2.20", dst="198.51.100.1", proto="tcp",
dport=443, iface="vlan30", direction="in"),
rules,
)
if i < 50:
assert result.action == "pass"
else:
assert result.action == "block" # max-src-conn exceeded
disable_state_tracking()Model multi-step adversary paths:
# Step 1: compromised device phones home (allowed — blends with cloud traffic)
c2 = evaluate(
Packet(src="192.0.2.50", dst="203.0.113.5", proto="tcp",
dport=8883, iface="vlan30", direction="in"),
rules,
)
assert c2.action == "pass" # MQTT is in allowlist
# Step 2: attacker pivots to another VLAN (blocked — isolation)
pivot = evaluate(
Packet(src="192.0.2.50", dst="198.51.100.211", proto="tcp",
dport=445, iface="vlan30", direction="in"),
rules,
)
assert pivot.action == "block" # chain stoppedImport PFSimulator and Packet, point at your config, build
packets that match your topology, and assert the results. See the
test patterns above for examples.
The engine reads your pf.conf and builds a network map automatically:
$ python3 -c "from pftest import *; load_all_rules(); \
[print(f' {k:10s} -> {v}') for k,v in sorted(IFACE_NETWORKS.items())]"
This drives antispoof expansion and interface group resolution. No hardcoded values in the engine — it derives everything from your macros and match rules.
The simulator faithfully models PF's rule evaluation semantics. The one gap (scrub/TCP reassembly) requires real packets and a kernel TCP stack. For everything else — rule ordering, tag propagation, antispoof, rdr-to rewriting, rate limits — the simulator matches PF behavior.
Always run pfctl -nf /etc/pf.conf on your actual router before deploying.
The simulator catches logic bugs; pfctl catches syntax bugs.
BSD 2-Clause (see LICENSE)