docs: divert verified (matrix + porting notes); FORWARD hook evaluates the out ruleset for transit traffic

This commit is contained in:
lgcosta committed 2026-11-11 19:50:14 -03:00
1 parent bdf9db7a21
commit 444a0555c6
5 files changed
+160 -107

No files matched your search

+123 -103
View File
@@ -1,141 +1,161 @@
#!/usr/bin/env python3
"""
PF-on-Linux demo recorder — produces an asciicast v2 (.cast) from a REAL
session driven against the lab VMs (no simulated output: every byte shown
comes from the live systems; only pacing is scripted).
PF-on-Linux demo recorder -> asciicast v2 (.cast)
Usage: ./scripts/demo-record.py [--out out/demo-pf-linux.cast]
Requires: lab running (pftest-pf0/cli/srv), repo built, ssh alias `pf0`
(ProxyJump) and lib.sh-style nested access working.
Every byte shown comes from the REAL lab (Alpine VMs under libvirt, kernel
7.3.4 built from source with our pf.ko). Only the pacing is scripted.
Edit DEMO below to change the narration script. Timing: each line is a
(real command, real output) pair; delays are cosmetic only.
Usage:
./scripts/demo-record.py [--out out/demo-pf-linux.cast] [--quick]
Play back with: asciinema play out/demo-pf-linux.cast
Upload with: asciinema upload out/demo-pf-linux.cast
"""
import argparse, json, os, pty, re, shlex, subprocess, sys, time
import argparse
import json
import os
import shlex
import subprocess
import sys
import time
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DEFAULT = os.path.join(REPO, "out", "demo-pf-linux.cast")
DEB = os.environ.get("DEB", "root@192.168.1.242")
PF0_IP = os.environ.get("PF0_IP", "192.168.122.8")
PF0 = os.environ.get("PF0_IP", "192.168.122.8")
CLI = os.environ.get("CLI_IP", "10.0.1.10")
SRV = os.environ.get("SRV_IP", "10.0.2.20")
KEY = os.path.join(os.environ.get("HOME", "/root"), ".ssh/pflab_ed")
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
"""
DIVERT_CONF = """pass in quick proto icmp from 10.0.1.10 to 10.0.1.1 divert-to "10.0.1.1" port 10000 keep state
pass out quick
"""
def deb_run(cmd: str, timeout=30) -> str:
"""run a command on the libvirt host (which has direct keys to VMs)"""
p = subprocess.run(["ssh", "-o", "ConnectTimeout=10", DEB, cmd],
capture_output=True, text=True, timeout=timeout)
def sh(cmd, timeout=40):
p = subprocess.run(cmd, shell=True, capture_output=True, text=True,
timeout=timeout)
return (p.stdout or "") + (p.stderr or "")
def vm0(cmd: str, timeout=30) -> str:
return deb_run(
f"ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 "
f"-i /root/.ssh/pflab_ed root@{PF0_IP} {shlex.quote(cmd)}",
timeout)
def on_pf0(cmd, timeout=40):
return sh(f"ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new "
f"-i {KEY} root@{PF0} {shlex.quote(cmd)}", timeout)
def vm_ip(ip: str, cmd: str, timeout=30) -> str:
return deb_run(
f"ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 "
f"-i /root/.ssh/pflab_ed root@{ip} {shlex.quote(cmd)}",
timeout)
def on_vm(ip, cmd, timeout=40):
return sh(f"ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new "
f"-i {KEY} root@{ip} {shlex.quote(cmd)}", timeout)
CLI, SRV = "10.0.1.10", "10.0.2.20"
def on_deb(cmd, timeout=40):
return sh(f"ssh -o ConnectTimeout=8 {DEB} {shlex.quote(cmd)}", timeout)
def scene(title):
return ("#banner", title)
def stage():
"""put the demo ruleset + pdivert on pf0 (through the lab host)"""
with open("/tmp/demo-pf.conf", "w") as f:
f.write(CONF)
with open("/tmp/demo-divert.conf", "w") as f:
f.write(DIVERT_CONF)
sh(f"scp -q /tmp/demo-pf.conf /tmp/demo-divert.conf "
f"{os.path.join(REPO, 'tools/out/pdivert')} {DEB}:/tmp/", timeout=90)
on_deb(f"scp -q -o StrictHostKeyChecking=accept-new -i {KEY} "
f"/tmp/demo-pf.conf /tmp/demo-divert.conf /tmp/pdivert "
f"root@{PF0}:/tmp/ 2>&1", timeout=90)
# (shell-line, runner, command) — runners: pf0 / cli / srv / local
DEMO = [
("local", "echo 'PF-on-Linux — OpenBSD PF engine on Linux 7.3.4 (lab: Alpine VMs)'"),
("local", "uname -r | sed 's/^/build host kernel: /'"),
("pf0", "uname -r"),
("pf0", "lsmod | grep -E '^pf ' || echo 'pf: not loaded yet'"),
("pf0", "insmod /opt/pf/pf.ko && dmesg | tail -2"),
("pf0", "/opt/pf/pfctl -s info | head -3"),
("pf0", "/opt/pf/pfctl -e && /opt/pf/pfctl -s info | head -1"),
("pf0", "cat /tmp/pfdemo.conf"),
("pf0", "/opt/pf/pfctl -f /tmp/pfdemo.conf && /opt/pf/pfctl -sr"),
("cli", f"ping -c 2 -W 2 {SRV} 2>&1 | tail -2"),
("pf0", "/opt/pf/pfctl -s states"),
("pf0", "/opt/pf/pfctl -s Tables"),
("pf0", "/opt/pf/pfctl -t demo -T show"),
("cli", "ping -c 2 -W 2 10.0.9.99 2>&1 | tail -2"),
("pf0", "/opt/pf/pfctl -s info | grep -E 'match|current'"),
("pf0", "/opt/pf/pfctl -ss | head -2"),
SCENARIO = [
("local", "echo '=== PF on Linux — OpenBSD PF 8.0 engine running inside the Linux 7.3.4 kernel ==='"),
("local", "echo 'lab: 3 Alpine VMs under libvirt (pf0 = firewall, cli/srv = transit endpoints)'"),
("pf0", "uname -r"),
("pf0", "lsmod | grep '^pf ' || echo 'pf: not loaded'"),
("pf0", "insmod /opt/pf/pf.ko && dmesg | tail -3"),
("pf0", "/opt/pf/pfctl -s info | head -4"),
("pf0", "/opt/pf/pfctl -e && /opt/pf/pfctl -s info | head -1"),
("pf0", "cat /tmp/demo-pf.conf"),
("pf0", "/opt/pf/pfctl -f /tmp/demo-pf.conf && echo 'rules loaded' && /opt/pf/pfctl -sr"),
("pf0", "/opt/pf/pfctl -sr -vv | head -8"),
("cli", f"ping -c2 -W2 {SRV} | tail -3"),
("pf0", "/opt/pf/pfctl -ss"),
("pf0", "/opt/pf/pfctl -t trusted -T show"),
("pf0", "/opt/pf/pfctl -s Tables"),
("cli", "ping -c2 -W2 10.0.9.99 | tail -3"),
("pf0", "/opt/pf/pfctl -sr -vv | head -6"),
("pf0", "/opt/pf/pfctl -f /tmp/demo-divert.conf && echo 'divert rule loaded'"),
("pf0", "(setsid /tmp/pdivert -p 10000 -f >/tmp/pdivert.log 2>&1 </dev/null &); sleep 1; pgrep pdivert"),
("cli", "ping -c2 -W2 10.0.1.1 | tail -3"),
("pf0", "head -3 /tmp/pdivert.log"),
("pf0", "pkill pdivert; /opt/pf/pfctl -f /dev/null; /opt/pf/pfctl -d; echo 'pf disabled'"),
]
CONF = """set block-policy return
set loginterface egress
table <demo> { 10.0.1.0/24 }
match in quick on ingress proto icmp from <demo> tag DEMO
pass in quick on ingress proto icmp keep state
pass out quick proto icmp keep state
block drop in quick proto icmp from any to 10.0.9.99
"""
def prep():
# stage the demo ruleset into pf0
import base64
b64 = base64.b64encode(CONF.encode()).decode()
vm0(f"echo {b64} | base64 -d > /tmp/pfdemo.conf; echo staged")
def record(out_path: str, fps_pad=0.4):
os.makedirs(os.path.dirname(out_path), exist_ok=True)
casts = []
def record(out_path, quick=False):
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
events = []
t = 0.0
def emit(text, delay):
nonlocal t
t += delay
casts.append([round(t, 3), "o", text])
events.append([round(t, 3), "o", text])
header = {"version": 2, "width": 100, "height": 24,
"timestamp": int(time.time()), "title":
"PF on Linux — port demo (OpenBSD PF 8.0 engine on Linux 7.3.4, Alpine libvirt lab)",
"env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}}
header = {
"version": 2, "width": 100, "height": 26,
"timestamp": int(time.time()),
"title": "PF on Linux — OpenBSD PF engine inside the Linux kernel (lab demo)",
"env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"},
}
runners = {
"pf0": lambda c: on_pf0(c),
"cli": lambda c: on_vm(CLI, c),
"srv": lambda c: on_vm(SRV, c),
"local": lambda c: sh(c),
}
labels = {"pf0": "pftest-pf0", "cli": "pftest-cli", "srv": "pftest-srv",
"local": "lab-host"}
stage()
with open(out_path, "w") as f:
f.write(json.dumps(header) + "\n")
def show_prompt(host):
emit(f"\x1b[1;36m{host}$\x1b[0m ", 0.15)
runners = {"pf0": vm0, "cli": lambda c: vm_ip(CLI, c),
"srv": lambda c: vm_ip(SRV, c), "local":
lambda c: subprocess.run(["sh", "-c", c],
capture_output=True, text=True,
timeout=30).stdout +
subprocess.run(["sh", "-c", c], capture_output=True,
text=True).stderr}
prep()
for kind, cmd in DEMO:
label = {"pf0": "pftest-pf0", "cli": "pftest-cli",
"srv": "pftest-srv", "local": "build-host"}[kind]
show_prompt(label)
emit(cmd + "\r\n", 0.25)
for kind, cmd in SCENARIO:
emit(f"\x1b[1;36m{labels[kind]}\x1b[0m:\x1b[1;37m {cmd}\x1b[0m\r\n",
0.35)
try:
out = runners[kind](cmd)
except Exception as e:
out = f"[runner error: {e}]\n"
# strip ANSI-safe passthrough; small human-ish delay
delay = min(1.2, 0.25 + len(out) / 4000.0)
emit(out.replace("\n", "\r\n") or "(no output)\r\n", delay)
time.sleep(0.1)
emit("\x1b[1;36mpftest-pf0$\x1b[0m /opt/pf/pfctl -d\r\n", 0.4)
runners["pf0"]("/opt/pf/pfctl -d")
for i, ev in enumerate(casts):
f.write(json.dumps(ev) + "\n")
print(f"WROTE {out_path} ({len(casts)} events, {t:.1f}s)")
print("play: asciinema play " + out_path +
" (or: script-replay/pf-in-play fallback: cat with 'less -R')")
except subprocess.TimeoutExpired:
out = "[timeout]\r\n"
except Exception as e: # noqa: BLE001
out = f"[error: {e}]\r\n"
out = out.replace("\r\n", "\n").replace("\n", "\r\n")
emit(out or "(no output)\r\n", min(1.4, 0.3 + len(out) / 5000.0))
with open(out_path, "a") as f:
for ev in events:
f.write(json.dumps(ev) + "\n")
print(f"WROTE {out_path}: {len(events)} events, {t:.1f}s")
print("play: asciinema play " + out_path)
print("upload: asciinema upload " + out_path)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=OUT_DEFAULT)
ap.add_argument("--out", default=os.path.join(REPO, "out",
"demo-pf-linux.cast"))
ap.add_argument("--quick", action="store_true")
a = ap.parse_args()
record(a.out)
record(a.out, a.quick)