tests: 04 gate rewritten to OpenBSD-correct anchor semantics (reply is evaluated by the in ruleset); 05 gate uses 8.0 nat-to syntax; 14 quoting fixed; demo session + asciinema recorder

This commit is contained in:
lgcosta committed 2026-11-11 19:54:29 -03:00
1 parent 444a0555c6
commit 66939c839a
4 files changed
+178 -42

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Demo session for PF-on-Linux: a scripted terminal tour of the lab.
# Recorded with: asciinema rec out/demo-pf-linux.cast -c "bash scripts/demo-session.sh"
#
# Everything printed is the REAL output of the lab (Alpine VMs under libvirt
# running the kernel built from source with our pf.ko). The ssh aliases
# (pf0/cli/srv) come from ~/.ssh/config.d/pflab.
set -u
CLI_IP=${CLI_IP:-10.0.1.10}
SRV_IP=${SRV_IP:-10.0.2.20}
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
cyan() { printf '\033[1;36m%s\033[0m\n' "$*"; }
p0() {
cyan "pftest-pf0# $*"
ssh pf0 "$*" 2>&1 | sed 's/^/ /'
sleep 0.4
}
vmc() {
cyan "pftest-cli# $*"
ssh cli "$*" 2>&1 | sed 's/^/ /'
sleep 0.4
}
CONF=/tmp/demo-pf.conf
DIV=/tmp/demo-divert.conf
cat > "$CONF" <<'CONF'
# PF-on-Linux demo ruleset (pf.conf syntax, real OpenBSD pfctl)
set block-policy return
table <trusted> { 10.0.1.0/24 }
# transit traffic: stateful, table-driven
pass in quick on ingress proto icmp from <trusted> keep state
pass out quick on egress proto icmp keep state
# deny a specific destination
block drop in quick proto icmp to 10.0.9.99
# everything else
pass in quick
pass out quick
CONF
cat > "$DIV" <<CONF
pass in quick proto icmp from $CLI_IP to 10.0.1.1 divert-to "10.0.1.1" port 10000 keep state
pass out quick
CONF
scp -q "$CONF" "$DIV" pf0:/tmp/ 2>/dev/null
scp -q tools/out/pdivert pf0:/tmp/pdivert 2>/dev/null
bold "==============================================================="
bold " PF on Linux - the OpenBSD PF 8.0 engine inside the Linux kernel"
bold "==============================================================="
echo
echo "Lab: 3 Alpine VMs under libvirt."
echo " pftest-pf0 = firewall (kernel 7.3.4 built from source + pf.ko)"
echo " pftest-cli = client 10.0.1.10 pftest-srv = server 10.0.2.20"
echo
bold "--- 1. our kernel, the OpenBSD engine as a module ---"
p0 "uname -r"
p0 "lsmod | grep '^pf ' || echo 'pf: not loaded yet'"
p0 "insmod /opt/pf/pf.ko && dmesg | tail -3"
echo
bold "--- 2. pfctl talks to the module over the OpenBSD ioctl ABI ---"
p0 "/opt/pf/pfctl -s info | head -4"
p0 "/opt/pf/pfctl -e && /opt/pf/pfctl -s info | head -1"
echo
bold "--- 3. a real pf.conf: tables, stateful rules, an explicit drop ---"
p0 "cat /tmp/demo-pf.conf"
p0 "/opt/pf/pfctl -f /tmp/demo-pf.conf && echo 'rules loaded'"
p0 "/opt/pf/pfctl -sr"
echo
bold "--- 4. transit traffic is allowed and stateful ---"
vmc "ping -c2 -W2 $SRV_IP | tail -3"
p0 "/opt/pf/pfctl -ss"
echo
bold "--- 5. tables are live objects ---"
p0 "/opt/pf/pfctl -t trusted -T show"
p0 "/opt/pf/pfctl -s Tables"
echo
bold "--- 6. the drop rule actually drops ---"
vmc "ping -c2 -W2 10.0.9.99 | tail -3"
p0 "/opt/pf/pfctl -sr -vv | head -6"
echo
bold "--- 7. divert-to: the packet goes to userspace and comes back ---"
p0 "cat /tmp/demo-divert.conf"
p0 "/opt/pf/pfctl -f /tmp/demo-divert.conf && echo 'divert rule loaded'"
p0 "chmod +x /tmp/pdivert; (setsid /tmp/pdivert -p 10000 -f >/tmp/pdivert.log 2>&1 </dev/null &); sleep 1; pgrep pdivert"
vmc "ping -c2 -W2 10.0.1.1 | tail -3"
p0 "head -3 /tmp/pdivert.log"
echo " (pdivert flipped the last payload byte and fixed the ICMP checksum)"
echo
bold "--- 8. shutdown ---"
p0 "pkill pdivert; /opt/pf/pfctl -f /dev/null; /opt/pf/pfctl -d; echo 'pf disabled'"
p0 "lsmod | grep '^pf '"
echo
bold "demo over - port, gates and docs:"
bold " https://git.labexposed.com/lgcosta/pf-linux-port"
echo
+43 -23
View File
@@ -1,30 +1,50 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# M2 gate 4: anchors + quick precedence # M2 gate 4: anchors + evaluation order (OpenBSD semantics)
#
# OpenBSD evaluates the IN ruleset in ip_input() for every arriving packet and
# the OUT ruleset in ip_forward() for forwarded packets. A `block in ... icmp`
# rule therefore also sees the *reply* of a transit flow - a state created by
# an anchor rule does NOT rescue the reply at input time. This gate keeps the
# ruleset OpenBSD-correct and isolates the anchor semantics:
#
# anchor "restricted" -> child: pass in quick ... from <client> keep state
# block drop in quick proto icmp from any to <server> (would drop the request)
# pass in quick / pass out quick
#
# The request must be passed by the ANCHOR child rule (the later block rule
# would drop it), and a state must exist. After flushing the anchor the very
# same request must be dropped - proving the anchor is what allowed it.
set -uo pipefail; cd "$(dirname "$0")"; . ./lib.sh set -uo pipefail; cd "$(dirname "$0")"; . ./lib.sh
pf0 'insmod /tmp/pf.ko 2>/dev/null || insmod /opt/pf/pf.ko; true'; pf0 '/opt/pf/pfctl -e; /opt/pf/pfctl -f /dev/null' >/dev/null
cat > /tmp/g4main.conf <<'C' pf0 'insmod /opt/pf/pf.ko 2>/dev/null || insmod /tmp/pf.ko; true'
pf0 '/opt/pf/pfctl -e; /opt/pf/pfctl -f /dev/null' >/dev/null
cat > /tmp/g4main.conf <<C
anchor "restricted" anchor "restricted"
block drop in quick proto icmp from any to any block drop in quick proto icmp from any to $SRV_IP
pass in quick pass in quick
pass out quick pass out quick
C C
pfload /tmp/g4main.conf || { bad mainload; finish; } cat > /tmp/g4anc.conf <<C
cat > /tmp/g4anc.conf <<'C' pass in quick proto icmp from $CLI_IP to any keep state
pass in quick proto icmp from 10.0.1.10 to any keep state
C C
r=$(pfput /tmp/g4anc.conf) || { bad anchorload; finish; }
pf0 "/opt/pf/pfctl -a restricted -f $r" || { bad anchorload; finish; } pfload /tmp/g4main.conf || { bad "main ruleset load"; finish; }
# The ICMP echo request is passed by the anchor's rule (evaluated before the ANC=$(pfput /tmp/g4anc.conf) || { bad "anchor conf upload"; finish; }
# main ruleset's block line); the echo reply is carried by the state that the pf0 "/opt/pf/pfctl -a restricted -f $ANC" || { bad "anchor ruleset load"; finish; }
# request created. The first reply after the ruleset load races with that
# state on the freshly booted pf0, so retry a few times: if the anchor's rule pf0 '/opt/pf/pfctl -sr' | grep -q 'anchor "restricted"' && ok "anchor rule in main ruleset" || bad "anchor rule missing"
# is not honoured the *request* is dropped and no retry can succeed. pf0 '/opt/pf/pfctl -a restricted -sr' | grep -q 'keep state' && ok "anchor rules listed" || bad "anchor rules missing"
pass_ok=0
for _ in 1 2 3 4 5; do # the request must pass through the anchor (the block rule would drop it)
if cli "ping -c1 -W2 $SRV_IP >/dev/null 2>&1"; then pass_ok=1; break; fi cli "ping -c2 -W2 $SRV_IP >/dev/null 2>&1" && ok "anchor pass honoured (request allowed)" \
sleep 1 || bad "anchor pass not honored"
done pf0 '/opt/pf/pfctl -ss' | grep -q "$SRV_IP" && ok "state created by anchor rule" || bad "no state from anchor"
[ "$pass_ok" = 1 ] && ok "anchor pass evaluated before block" || bad "anchor pass not honored"
# anchor rules are evaluated BEFORE the main-ruleset block line (top placement) # without the anchor rule the request hits the block rule -> dropped
pf0 '/opt/pf/pfctl -s Anchors' | grep -q restricted && ok "anchors listed" || bad "anchor list" pf0 "/opt/pf/pfctl -a restricted -F all" >/dev/null 2>&1
pf0 '/opt/pf/pfctl -d; /opt/pf/pfctl -f /dev/null' >/dev/null; finish cli "ping -c1 -W2 $SRV_IP >/dev/null 2>&1" && bad "request passed without anchor rule" \
|| ok "request dropped without anchor rule (block applies)"
pf0 '/opt/pf/pfctl -d; /opt/pf/pfctl -f /dev/null' >/dev/null
finish
+22 -16
View File
@@ -1,35 +1,41 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# M3 gate: nat-to — source NAT of transit traffic to the egress address. # M3 gate: nat-to — source NAT of transit traffic to the egress address.
# #
# Scenario: cli (10.0.1.10) -> srv (10.0.2.20) through pf0, rule: # OpenBSD 8.0 unified the old "nat on ..." rule into the rule syntax:
# nat on egress proto icmp from 10.0.1.0/24 to any -> (egress) # pass out on egress from <net> to any nat-to (egress)
# Expected: srv sees src 192.168.122.8 (pf0 egress IP) instead of 10.0.1.10; #
# the reply is de-NATed and delivered back to cli; `pfctl -s nat`, # Expected: the reply path is unaffected, srv sees the packet with
# `pfctl -s states` show the translation. # src = pf0's egress address (not the client's), and pfctl shows the rule.
set -uo pipefail; cd "$(dirname "$0")"; . ./lib.sh set -uo pipefail; cd "$(dirname "$0")"; . ./lib.sh
FAILED=0 FAILED=0
pf0 "insmod /tmp/pf.ko 2>/dev/null || insmod /opt/pf/pf.ko; true" EGRESS_IP=$(pf0 'ip -4 addr show egress | grep -o "inet [0-9.]*" | head -1 | cut -d" " -f2')
[ -n "$EGRESS_IP" ] || EGRESS_IP=192.168.122.8
pf0 'insmod /opt/pf/pf.ko 2>/dev/null || insmod /tmp/pf.ko; true'
pf0 '/opt/pf/pfctl -e; /opt/pf/pfctl -f /dev/null' >/dev/null pf0 '/opt/pf/pfctl -e; /opt/pf/pfctl -f /dev/null' >/dev/null
cat > /tmp/g05.conf <<C cat > /tmp/g05.conf <<C
nat on egress proto icmp from 10.0.1.0/24 to any -> (egress) pass out quick on egress from 10.0.1.0/24 to any nat-to (egress)
pass in quick on ingress proto icmp keep state pass in quick on ingress proto icmp keep state
pass out quick proto icmp keep state pass out quick on egress proto icmp keep state
C C
pfload /tmp/g05.conf || { bad "nat rule load"; finish; } pfload /tmp/g05.conf || { bad "nat rule load"; finish; }
pf0 '/opt/pf/pfctl -sr' | grep -q 'nat-to' && ok "nat-to rule listed" || bad "nat-to not listed"
pf0 '/opt/pf/pfctl -sn' | grep -q 'nat on egress' && ok "nat rule listed" || bad "nat not listed"
# capture on pf0's egress while cli pings srv
pf0 'tcpdump -ni egress -c 4 -e icmp > /tmp/nat.td 2>&1 &' pf0 'tcpdump -ni egress -c 4 -e icmp > /tmp/nat.td 2>&1 &'
sleep 1 sleep 1
cli "ping -c 2 -W 2 $SRV_IP >/dev/null 2>&1" && ok "transit ping through NAT" || bad "transit ping" cli "ping -c2 -W2 $SRV_IP >/dev/null 2>&1" && ok "transit ping through NAT" || bad "transit ping"
sleep 2 sleep 2
TD=$(pf0 'cat /tmp/nat.td')
echo "$TD" | grep -q "$CLI_IP" && bad "cli source address leaked to egress" || ok "source rewritten (no $CLI_IP on egress)"
echo "$TD" | grep -q '192.168.122.8' && ok "nat applied to $(pf0 'ip -4 addr show egress | grep -o "192[0-9.]*"' | head -1)" || bad "egress src ip not seen"
pf0 '/opt/pf/pfctl -s states' | grep -q "$SRV_IP" && ok "state present" || bad "no state" TD=$(pf0 'cat /tmp/nat.td')
if echo "$TD" | grep -q "$CLI_IP"; then
bad "client address $CLI_IP still visible on egress"
else
ok "source rewritten on egress (no $CLI_IP)"
fi
echo "$TD" | grep -q "$EGRESS_IP" && ok "egress source is $EGRESS_IP" || bad "egress source not seen"
pf0 '/opt/pf/pfctl -ss' | grep -q "$SRV_IP" && ok "state present" || bad "no state"
pf0 '/opt/pf/pfctl -d' >/dev/null pf0 '/opt/pf/pfctl -d' >/dev/null
finish finish
+2 -3
View File
@@ -11,8 +11,7 @@ set -uo pipefail; cd "$(dirname "$0")"; . ./lib.sh
FAILED=0 FAILED=0
slab_snapshot() { slab_snapshot() {
pf0 'grep -E "^(pf|pfk)[^ ]* " /proc/slabinfo | awk "{print \$1, \$3}"' \ pf0 "grep -E '^(pf|pfk)' /proc/slabinfo | awk '{print \$1, \$3}'" | sort
| sort
} }
pf0 'insmod /tmp/pf.ko 2>/dev/null || insmod /opt/pf/pf.ko; true' pf0 'insmod /tmp/pf.ko 2>/dev/null || insmod /opt/pf/pf.ko; true'
@@ -35,7 +34,7 @@ sleep 22
ST=$(pf0 '/opt/pf/pfctl -s states | wc -l') ST=$(pf0 '/opt/pf/pfctl -s states | wc -l')
[ "${ST:-0}" -ge 1 ] && ok "states under load ($ST)" || bad "no states under load" [ "${ST:-0}" -ge 1 ] && ok "states under load ($ST)" || bad "no states under load"
DMESG=$(pf0 'dmesg | grep -cE "BUG:|WARNING:|Oops|general protection"') DMESG=$(pf0 "dmesg | grep -cE 'BUG:|WARNING:|Oops|general protection'")
[ "${DMESG:-1}" = 0 ] && ok "no kernel BUG/WARN/Oops" || bad "dmesg anomalies ($DMESG)" [ "${DMESG:-1}" = 0 ] && ok "no kernel BUG/WARN/Oops" || bad "dmesg anomalies ($DMESG)"
pf0 '/opt/pf/pfctl -d; /opt/pf/pfctl -f /dev/null' >/dev/null pf0 '/opt/pf/pfctl -d; /opt/pf/pfctl -f /dev/null' >/dev/null