Docs: bring the documentation in line with the code

- Demo docs replaced by the screencast docs (single source of truth): the
  legacy demo-session.sh/demo-record.py and the v0.9 renderer were removed
  instead of being kept as a second convention.
- feature-matrix: stale 'M2-in-progress' vocabulary gone; the rows the 15
  gates prove now say VERIFIED-in-VM and name the gate; af-to is now an
  explicit non-goal row (INET6 0 in this build).
- guide: the queues/divert/pflog sections described a state that no longer
  exists (priq-only, divert dropping packets, pflog logging nothing); they
  now describe the shipped behaviour.
- vm-lab: the 15 real gates with what each proves, 15/15 green, and the
  pf0 -> cli/srv/gw2 ssh setup the screencasts rely on.
- install/build: pdivert and pflogd are built and ported (docs said
  otherwise); module object list now includes hfsc.c, fq_codel.c, pfk_pflog.c;
  the screencast pipeline is an optional build stage.
- pf-guide: the 'M2 scope only' framing is gone; honest gaps listed.
- caveats: three new sections about the screencast pipeline (MiniMax video
  generation blocked by the account tier, narration as a separate track, a
  video frame is not a verdict).
- porting-notes: what the recording taught us.
- README/README.pt-BR: status, milestones, layout and video section updated.

All internal doc links verified.
This commit is contained in:
lgcosta committed 2026-11-12 11:51:25 -03:00
1 parent cc67531602
commit dff2185b20
37 files changed
+677 -1616

No files matched your search

-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env python3
"""
PF-on-Linux demo recorder -> asciicast v2 (.cast)
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.
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
import json
import os
import shlex
import subprocess
import sys
import time
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEB = os.environ.get("DEB", "root@192.168.1.242")
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 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 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 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)
def on_deb(cmd, timeout=40):
return sh(f"ssh -o ConnectTimeout=8 {DEB} {shlex.quote(cmd)}", timeout)
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)
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'"),
]
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
events.append([round(t, 3), "o", text])
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")
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 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=os.path.join(REPO, "out",
"demo-pf-linux.cast"))
ap.add_argument("--quick", action="store_true")
a = ap.parse_args()
record(a.out, a.quick)
-182
View File
@@ -1,182 +0,0 @@
#!/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 VM management
# addresses are resolved from the hypervisor (they are DHCP), the lab
# topology (10.0.1.0/24, 10.0.2.0/24, ...) is fixed.
set -u
DEB=${DEB:-root@192.168.1.242}
CLI_IP=${CLI_IP:-10.0.1.10}
SRV_IP=${SRV_IP:-10.0.2.20}
KEY=${KEY:-$HOME/.ssh/pflab_ed}
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
cyan() { printf '\033[1;36m%s\033[0m\n' "$*"; }
# management address of a lab VM (DHCP, so ask the hypervisor)
mgmt() {
ssh -o ConnectTimeout=8 "$DEB" "virsh domifaddr $1 | awk '/vnet/{print \$4}' | cut -d/ -f1" \
2>/dev/null | tr -d '\r' | head -1
}
PF0_MGMT=$(mgmt pftest-pf0)
CLI_MGMT=$(mgmt pftest-cli)
[ -n "$PF0_MGMT" ] || PF0_MGMT=192.168.122.8
[ -n "$CLI_MGMT" ] || CLI_MGMT=192.168.122.80
# run a command on a lab VM (through the hypervisor)
onvm() {
local ip=$1; shift
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
-o ProxyJump=root@192.168.1.242 -i "$KEY" root@"$ip" "$@" 2>&1
}
p0() {
cyan "pftest-pf0# $*"
onvm "$PF0_MGMT" "$@"
sleep 0.4
}
vmc() {
cyan "pftest-cli# $*"
onvm "$CLI_MGMT" "$@"
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
# the pf0 interface towards the server (used by the NAT pool)
EGRESS_IF=$(onvm "$PF0_MGMT" "ip route get $SRV_IP | grep -o 'dev [a-z0-9]*' | cut -d' ' -f2" | tr -d '\r')
[ -n "$EGRESS_IF" ] || EGRESS_IF=eth2
scp -q -o ProxyJump=root@192.168.1.242 -i "$KEY" "$CONF" "$DIV" \
tools/out/pdivert tools/out/pflogd "root@$PF0_MGMT:/tmp/" 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 | grep -E 'icmp' | head -4"
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 ---"
p0 "cat > /tmp/demo-block.conf <<EOF
block drop in quick proto icmp to $SRV_IP
pass in quick
pass out quick
EOF"
p0 "/opt/pf/pfctl -f /tmp/demo-block.conf && echo 'block rules loaded'"
vmc "ping -c2 -W2 $SRV_IP | tail -3"
p0 "/opt/pf/pfctl -sr -vv | head -6"
p0 "/opt/pf/pfctl -f /tmp/demo-pf.conf && echo 'back to the stateful ruleset'"
echo
bold "--- 7. NAT: the source is translated on the way out, restored on the way back ---"
p0 "cat > /tmp/demo-nat.conf <<EOF
pass out quick on egress from 10.0.1.0/24 to any nat-to ($EGRESS_IF)
pass in quick on ingress proto icmp keep state
pass out quick on egress proto icmp keep state
EOF"
p0 "/opt/pf/pfctl -f /tmp/demo-nat.conf && echo 'nat rules loaded'"
vmc "ping -c2 -W2 $SRV_IP | tail -3"
p0 "/opt/pf/pfctl -ss | grep -E 'icmp' | head -4"
p0 "/opt/pf/pfctl -f /tmp/demo-pf.conf && echo 'back to the stateful ruleset'"
echo
bold "--- 8. pflog: logged packets reach userspace (/dev/pflog) ---"
p0 "cat > /tmp/demo-log.conf <<EOF
pass in quick log proto icmp from $CLI_IP keep state
pass out quick
EOF"
p0 "/opt/pf/pfctl -f /tmp/demo-log.conf && echo 'log rule loaded'"
p0 "chmod +x /tmp/pflogd; rm -f /tmp/pflog.pcap; (setsid /tmp/pflogd -c 2 /tmp/pflog.pcap >/tmp/pflogd.log 2>&1 </dev/null &); sleep 1; echo 'pflogd collecting 2 records'"
vmc "ping -c2 -W2 $SRV_IP | tail -2"
sleep 2
p0 "cat /tmp/pflogd.log; ls -l /tmp/pflog.pcap; echo 'pcap header (magic d4c3b2a1, linktype 0x75=DLT_PFLOG):'; od -An -tx1 -N24 /tmp/pflog.pcap"
p0 "pkill pflogd 2>/dev/null; /opt/pf/pfctl -f /tmp/demo-pf.conf >/dev/null"
echo
bold "--- 9. route-to: force the next hop away from the normal path ---"
p0 "cat > /tmp/demo-route.conf <<EOF
pass in quick proto icmp from $CLI_IP to $SRV_IP route-to { 10.0.3.2 } keep state
pass out quick
EOF"
p0 "/opt/pf/pfctl -f /tmp/demo-route.conf && echo 'route-to rule loaded'"
p0 "(tcpdump -ni eth3 -c 2 -e icmp > /tmp/route.td 2>&1 &); (tcpdump -ni eth2 -c 2 -e icmp > /tmp/norm.td 2>&1 &); sleep 1"
vmc "ping -c1 -W2 $SRV_IP | tail -2"
sleep 2
p0 "echo 'forced egress (eth3, toward the gateway):'; grep -v '^tcpdump\|^listening\|^$' /tmp/route.td | head -2; echo 'normal egress (eth2):'; grep -c 'echo request' /tmp/norm.td"
p0 "/opt/pf/pfctl -f /tmp/demo-pf.conf >/dev/null"
echo
bold "--- 10. 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 "--- 11. 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
-3
View File
@@ -1,3 +0,0 @@
@run clear
uname -r
lsmod | grep '^pf ' || echo 'pf: not loaded yet'
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
# Render every screencast phase into out/screencast/*.mp4 (silent, 1080p30).
#
# ./scripts/screencast/record-all.sh [phase ...]
#
# Each phase is a real session against the lab; the videos carry no audio.
set -u
cd "$(dirname "$0")/../.."
REPO=$(pwd)
OUT="$REPO/out/screencast"
mkdir -p "$OUT"
render() { # <file> <title> <subtitle> <phase-no> [panel]
local f=$1 title=$2 sub=$3 no=$4
local panel=${5:-"alpine (pf0) - lab libvirt - kernel 7.3.4 + pf.ko"}
echo "=== gravando $f ==="
python3 "$REPO/scripts/screencast/render.py" \
--script "$REPO/scripts/screencast/phases/$f" \
--out "$OUT/${f%.sh}.mp4" \
--title "$title" --subtitle "$sub" \
--phase-no "$no" --phase-total 6 --panel "$panel" --min-hold 1.1 \
--footer "PF on Linux - OpenBSD PF 8.0 no kernel Linux - lab libvirt" || return 1
}
declare -A TITLES=(
[01-implementacao-kernel]="Fase 1 - Implementacao no kernel"
[02-ativacao]="Fase 2 - Ativacao"
[03-regras-basicas]="Fase 3 - Regras basicas"
[04-roteamento]="Fase 4 - Roteamento"
[05-balanceamento]="Fase 5 - Balanceamento"
[06-qos]="Fase 6 - QOS"
)
declare -A SUBS=(
[01-implementacao-kernel]="Das fontes OpenBSD 8.0 ao modulo pf.ko"
[02-ativacao]="insmod pf.ko / pfctl -e / pfctl -s info"
[03-regras-basicas]="tabelas, anchors, keep state, block drop"
[04-roteamento]="route-to e reply-to: o proximo salto decidido pelo pf"
[05-balanceamento]="pools round-robin, source-hash e dup-to"
[06-qos]="filas hfsc do OpenBSD 8.0 no caminho de dados"
)
PHASES=("$@")
[ ${#PHASES[@]} -eq 0 ] && PHASES=(01-implementacao-kernel 02-ativacao 03-regras-basicas \
04-roteamento 05-balanceamento 06-qos)
for p in "${PHASES[@]}"; do
f="$p.sh"
[ -f "$REPO/scripts/screencast/phases/$f" ] || { echo "fase inexistente: $f"; exit 1; }
# the phase number comes from the phase's own name, not from its position
# in the list, so a partial re-render keeps the right "FASE x/6" label
n=$((10#${p%%-*}))
case "$p" in
01-*) panel="host de build - repositorio pf_port" ;;
*) panel="alpine (pf0) - lab libvirt - kernel 7.3.4 + pf.ko" ;;
esac
render "$f" "${TITLES[$p]}" "${SUBS[$p]}" "$n" "$panel" || echo "FALHOU: $f"
done
echo
echo "=== videos gerados ==="
for m in "$OUT"/*.mp4; do
[ -e "$m" ] || continue
d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$m")
printf '%-46s %6.1fs %5.1f MB\n' "$(basename "$m")" "$d" "$(echo "scale=1; $(stat -c%s "$m")/1000000" | bc)"
done
-336
View File
@@ -1,336 +0,0 @@
#!/usr/bin/env python3
"""
Render a shell session into a silent, YouTube-ready MP4 screencast.
Pipeline: pty -> raw bytes -> pyte (terminal emulation) -> PNG frames -> MP4
Every byte on screen is the real output of the command; only the pacing and
the framing are produced here. No audio track is written (the videos are
silent by design).
Usage:
./scripts/screencast/render.py --script scripts/screencast/phases/02-ativacao.sh \
--out out/screencast/02-ativacao.mp4 --title "Fase 2 - Ativacao" \
--subtitle "insmod pf.ko / pfctl -e / pfctl -s info"
"""
import argparse
import errno
import fcntl
import os
import pty
import select
import shutil
import signal
import struct
import subprocess
import sys
import termios
import time
import pyte
from PIL import Image, ImageDraw, ImageFont
# ---------------------------------------------------------------- palette ---
BG = (14, 17, 23)
PANEL = (20, 24, 31)
BORDER = (48, 54, 61)
FG = (201, 209, 217)
HEADER_BG = (24, 29, 38)
HEADER_FG = (240, 246, 252)
ACCENT = (88, 166, 255)
MUTED = (110, 118, 129)
TITLE_BG = (17, 21, 28)
# pyte colour names -> RGB (a calm, high-contrast terminal palette)
NAMED = {
"black": (48, 54, 61),
"red": (255, 123, 114),
"green": (63, 185, 80),
"yellow": (210, 153, 34),
"blue": (88, 166, 255),
"magenta": (188, 140, 255),
"cyan": (57, 197, 207),
"white": (201, 209, 217),
"brown": (168, 115, 66),
"default": None,
}
def color_of(spec, default):
if spec == "default":
return default
if spec in NAMED:
return NAMED[spec] or default
try:
if len(spec) == 6:
return tuple(int(spec[i:i + 2], 16) for i in (0, 2, 4))
except ValueError:
pass
return default
class Recorder:
"""Runs a command on a pty and keeps a timestamped snapshot per output event."""
def __init__(self, cols, rows, env=None):
self.cols, self.rows = cols, rows
self.screen = pyte.Screen(cols, rows)
self.screen.set_mode(pyte.modes.LNM)
self.stream = pyte.ByteStream(self.screen)
self.events = [] # (timestamp, rows_of_cells)
self.t0 = time.time()
def snapshot(self):
rows = []
for y in range(self.rows):
line = self.screen.buffer[y]
cells = []
for x in range(self.cols):
ch = line[x]
fg = color_of(ch.fg, FG)
bold = ch.bold
cells.append((ch.data or " ", fg, bold))
rows.append(cells)
return rows, (self.screen.cursor.y, self.screen.cursor.x)
def run(self, argv, env=None):
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ,
struct.pack("HHHH", self.rows, self.cols, 0, 0))
e = dict(os.environ)
e.update({"TERM": "xterm-256color", "COLUMNS": str(self.cols),
"LINES": str(self.rows), "LC_ALL": "C.UTF-8"})
if env:
e.update(env)
proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave,
env=e, start_new_session=True, close_fds=True)
os.close(slave)
while True:
try:
r, _, _ = select.select([master], [], [], 0.25)
except InterruptedError:
continue
if master in r:
try:
data = os.read(master, 65536)
except OSError as exc:
if exc.errno == errno.EIO:
break
raise
if not data:
break
self.stream.feed(data)
rows, cur = self.snapshot()
self.events.append((time.time() - self.t0, rows, cur))
elif proc.poll() is not None:
# drain whatever is left
try:
while True:
data = os.read(master, 65536)
if not data:
break
self.stream.feed(data)
rows, cur = self.snapshot()
self.events.append((time.time() - self.t0, rows, cur))
except OSError:
pass
break
proc.wait()
os.close(master)
if not self.events:
rows, cur = self.snapshot()
self.events.append((time.time() - self.t0, rows, cur))
return proc.returncode
# ------------------------------------------------------------- rendering ---
def load_font(path, size):
return ImageFont.truetype(path, size)
def render_frame(rows, title, subtitle, phase_no, phase_total, cols, font, bold_font,
small_font, footer, cursor=None, panel="alpine (pf0)"):
W, H = 1920, 1080
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
# header
d.rectangle([0, 0, W, 74], fill=HEADER_BG)
d.text((44, 26), "PF on Linux", font=bold_font, fill=HEADER_FG)
tw = d.textlength("PF on Linux", font=bold_font)
d.text((44 + tw + 26, 30), "-", font=font, fill=MUTED)
d.text((44 + tw + 46, 30), "OpenBSD PF 8.0 engine inside the Linux kernel",
font=font, fill=MUTED)
if phase_total:
label = f"FASE {phase_no}/{phase_total}"
lw = d.textlength(label, font=small_font)
d.text((W - 44 - lw, 32), label, font=small_font, fill=ACCENT)
# title block
d.text((44, 112), title, font=bold_font, fill=HEADER_FG)
if subtitle:
d.text((44, 162), subtitle, font=font, fill=MUTED)
# terminal panel, sized to the content
top = 212
left = 44
right = W - 44
lh = 30
used = 0
for i, line in enumerate(rows):
if any(c[0].strip() for c in line):
used = i + 1
bottom = min(H - 84, top + 44 + 28 + (max(used, 4) + 1) * lh)
d.rectangle([left, top, right, bottom], fill=PANEL, outline=BORDER, width=2)
d.rectangle([left, top, right, top + 44], fill=HEADER_BG)
for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
d.ellipse([left + 24 + i * 26, top + 15, left + 38 + i * 26, top + 29], fill=c)
d.text((left + 110, top + 13), panel, font=small_font, fill=MUTED)
# text
x0 = left + 28
y0 = top + 64
cw = d.textlength("M", font=font)
for i, line in enumerate(rows):
y = y0 + i * lh
if y > bottom - 10:
break
for x, (ch, fg, bold) in enumerate(line):
if ch == " ":
continue
d.text((x0 + x * cw, y), ch, font=bold_font if bold else font, fill=fg)
# cursor block
if cursor:
cy, cx = cursor
if 0 <= cy < len(rows) and 0 <= cx < cols:
d.rectangle([x0 + cx * cw, y0 + cy * lh, x0 + (cx + 1) * cw - 1,
y0 + (cy + 1) * lh - 1], fill=ACCENT)
# footer
d.text((44, H - 58), footer, font=small_font, fill=MUTED)
url = "git.labexposed.com/lgcosta/pf-linux-port"
uw = d.textlength(url, font=small_font)
d.text((W - 44 - uw, H - 58), url, font=small_font, fill=MUTED)
return img
def render_title(title, subtitle, phase_no, phase_total, bold_font, font, small_font):
W, H = 1920, 1080
img = Image.new("RGB", (W, H), TITLE_BG)
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W, 8], fill=ACCENT)
d.text((120, 400), title, font=bold_font, fill=HEADER_FG)
if subtitle:
d.text((120, 470), subtitle, font=font, fill=MUTED)
if phase_total:
d.text((120, 560), f"FASE {phase_no} DE {phase_total}", font=small_font, fill=ACCENT)
d.text((120, 620), "PF on Linux - OpenBSD PF 8.0 no kernel Linux",
font=small_font, fill=MUTED)
return img
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--script", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--title", required=True)
ap.add_argument("--subtitle", default="")
ap.add_argument("--footer", default="")
ap.add_argument("--phase-no", type=int, default=0)
ap.add_argument("--phase-total", type=int, default=0)
ap.add_argument("--cols", type=int, default=120)
ap.add_argument("--rows", type=int, default=32)
ap.add_argument("--fps", type=int, default=30)
ap.add_argument("--max-hold", type=float, default=2.0)
ap.add_argument("--min-hold", type=float, default=0.25)
ap.add_argument("--title-card", type=float, default=3.0)
ap.add_argument("--keep-frames", action="store_true")
ap.add_argument("--panel", default="alpine (pf0) - lab libvirt - kernel 7.3.4 + pf.ko")
args = ap.parse_args()
repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
mono = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf"
mono_b = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono-Bold.ttf"
font = load_font(mono, 21)
bold_font = load_font(mono_b, 21)
small_font = load_font(mono, 19)
rec = Recorder(args.cols, args.rows)
rc = rec.run(["bash", args.script])
sys.stderr.write(f"[screencast] script rc={rc}, events={len(rec.events)}\n")
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
frames_dir = os.path.join(os.path.dirname(os.path.abspath(args.out)), "_frames")
shutil.rmtree(frames_dir, ignore_errors=True)
os.makedirs(frames_dir, exist_ok=True)
def dump(img, idx):
img.save(os.path.join(frames_dir, f"{idx:05d}.png"))
n = 0
# title card
title_img = render_title(args.title, args.subtitle, args.phase_no,
args.phase_total, bold_font, font, small_font)
title_frames = int(args.title_card * args.fps)
for _ in range(title_frames):
dump(title_img, n)
n += 1
# session frames, paced by the real inter-event delays
prev_t = 0.0
for i, (ts, rows, cur) in enumerate(rec.events):
hold = ts - prev_t
prev_t = ts
if i + 1 < len(rec.events):
hold = rec.events[i + 1][0] - ts
hold = max(args.min_hold, min(args.max_hold, hold))
img = render_frame(rows, args.title, args.subtitle, args.phase_no,
args.phase_total, args.cols, font, bold_font,
small_font, args.footer, cursor=cur, panel=args.panel)
frames = max(1, int(round(hold * args.fps)))
for _ in range(frames):
dump(img, n)
n += 1
# closing card
end_img = render_frame(rec.events[-1][1], args.title, args.subtitle,
args.phase_no, args.phase_total, args.cols, font,
bold_font, small_font,
args.footer or "fim da fase",
cursor=rec.events[-1][2], panel=args.panel)
for _ in range(int(1.5 * args.fps)):
dump(end_img, n)
n += 1
# encoder: prefer x264, fall back to openh264 (both H.264 in MP4, which is
# what YouTube wants), then VP9/WebM.
have = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True, text=True).stdout
base = ["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(args.fps),
"-i", os.path.join(frames_dir, "%05d.png"),
"-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", str(args.fps)]
if "libx264" in have:
cmd = base + ["-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-profile:v", "high", "-level", "4.0", args.out]
elif "libopenh264" in have:
cmd = base + ["-c:v", "libopenh264", "-b:v", "6M",
"-profile:v", "high", args.out]
elif "libvpx-vp9" in have:
cmd = base + ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30",
"-row-mt", "1", "-cpu-used", "4", args.out]
else:
raise SystemExit("[screencast] no usable H.264/VP9 encoder in ffmpeg")
subprocess.run(cmd, check=True)
if not args.keep_frames:
shutil.rmtree(frames_dir, ignore_errors=True)
dur = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of", "csv=p=0", args.out],
capture_output=True, text=True).stdout.strip()
size = os.path.getsize(args.out)
sys.stderr.write(f"[screencast] wrote {args.out} frames={n} "
f"duration={dur}s size={size/1e6:.1f}MB\n")
if __name__ == "__main__":
main()
-331
View File
@@ -1,331 +0,0 @@
#!/usr/bin/env python3
"""
Record a *real* interactive shell session and render it as a video.
The difference to the earlier renderer: the shell is not a script whose output
is dumped at once. We open a real pty, type the commands **key by key** with
human-like timing, wait for the prompt to come back, and capture the byte
stream that the terminal actually produced. The result is a session that
looks like somebody sitting in front of a terminal - characters appearing one
by one, output scrolling in as the kernel answers.
./scripts/screencast/session.py --plan scripts/screencast/plans/04.sh \\
--out out/screencast/session-04.mp4 --title "..." --subtitle "..."
The plan file is a shell script executed *inside* the recording pty. Every
line the plan prints is real output from the lab.
"""
import argparse
import errno
import fcntl
import os
import pty
import select
import shlex
import struct
import subprocess
import sys
import termios
import time
import pyte
from PIL import Image, ImageDraw, ImageFont
# ---------------------------------------------------------------- palette ---
BG = (11, 14, 19)
PANEL = (18, 22, 28)
BORDER = (44, 50, 58)
FG = (203, 211, 219)
HEADER_BG = (21, 26, 33)
HEADER_FG = (240, 246, 252)
ACCENT = (86, 166, 255)
ACCENT2 = (57, 197, 207)
MUTED = (108, 116, 128)
GREEN = (63, 185, 80)
YELLOW = (210, 153, 34)
RED = (255, 123, 114)
TITLE_BG = (15, 19, 25)
NAMED = {
"black": (48, 54, 61), "red": RED, "green": GREEN, "yellow": YELLOW,
"blue": ACCENT, "magenta": (188, 140, 255), "cyan": ACCENT2,
"white": FG, "brown": (168, 115, 66), "default": None,
}
def color_of(spec, default):
if spec == "default":
return default
if spec in NAMED:
return NAMED[spec] or default
try:
if len(spec) == 6:
return tuple(int(spec[i:i + 2], 16) for i in (0, 2, 4))
except ValueError:
pass
return default
# ------------------------------------------------------------------ fonts ---
def load_fonts():
mono = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf"
monob = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono-Bold.ttf"
return (ImageFont.truetype(mono, 20), ImageFont.truetype(monob, 20),
ImageFont.truetype(mono, 18))
# ---------------------------------------------------------------- session ---
class Shell:
"""A real interactive shell on a pty; we type into it and read what it says."""
PROMPT = "__PS1__"
def __init__(self, argv, cols=118, rows=34, env=None):
self.cols, self.rows = cols, rows
self.master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ,
struct.pack("HHHH", rows, cols, 0, 0))
e = dict(os.environ)
e.update({"TERM": "xterm-256color", "COLUMNS": str(cols),
"LINES": str(rows), "LC_ALL": "C.UTF-8", "PS1": self.PROMPT + " "})
if env:
e.update(env)
self.proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave,
env=e, start_new_session=True, close_fds=True)
os.close(slave)
self.screen = pyte.Screen(cols, rows)
self.stream = pyte.ByteStream(self.screen)
self.events = [] # (t, snapshot)
self.t0 = time.time()
self.raw = bytearray()
self.idle = 0.0
def pump(self, duration):
"""Read from the pty for `duration` seconds, recording screen states."""
end = time.time() + duration
while True:
remaining = end - time.time()
if remaining <= 0:
return
try:
r, _, _ = select.select([self.master], [], [], min(remaining, 0.05))
except InterruptedError:
continue
if self.master in r:
try:
data = os.read(self.master, 65536)
except OSError as exc:
if exc.errno == errno.EIO:
return
raise
if not data:
return
self.raw += data
self.stream.feed(data)
self.events.append((time.time() - self.t0, self.snapshot()))
self.idle = 0.0
else:
self.idle += 0.05
if self.idle > 0.25:
# nothing new: keep the timeline going so the frame clock
# stays uniform
self.events.append((time.time() - self.t0, self.snapshot()))
def snapshot(self):
rows = []
for y in range(self.rows):
line = self.screen.buffer[y]
cells = []
for x in range(self.cols):
ch = line[x]
cells.append((ch.data or " ", color_of(ch.fg, FG), ch.bold))
rows.append(cells)
return rows, (self.screen.cursor.y, self.screen.cursor.x)
def wait_prompt(self, timeout=25.0):
"""Wait until the shell prompt shows up again."""
end = time.time() + timeout
while time.time() < end:
self.pump(0.05)
text = "\n".join(self.screen.display)
if text.rstrip().endswith(self.PROMPT):
return True
return False
def type_line(self, text, cps=42.0):
"""Type a line character by character, like a human."""
for ch in text:
self.write(ch)
# slight jitter so it does not look machine-perfect
delay = 1.0 / cps + (hash(ch) % 7) * 0.0016
time.sleep(delay)
self.write("\r")
self.pump(0.12)
def write(self, s):
os.write(self.master, s.encode())
def run(self):
self.pump(1.2)
return self.proc.wait()
# --------------------------------------------------------------- rendering ---
def render(rows, cursor, title, subtitle, phase_no, phase_total, font, bold,
small, footer, panel_label, art=None, art_alpha=0.0):
W, H = 1920, 1080
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W, 70], fill=HEADER_BG)
d.text((44, 24), "PF on Linux", font=bold, fill=HEADER_FG)
tw = d.textlength("PF on Linux", font=bold)
d.text((44 + tw + 24, 28), "-", font=font, fill=MUTED)
d.text((44 + tw + 44, 28), "OpenBSD PF 8.0 engine inside the Linux kernel",
font=font, fill=MUTED)
if phase_total:
label = f"FASE {phase_no}/{phase_total}"
lw = d.textlength(label, font=small)
d.text((W - 44 - lw, 30), label, font=small, fill=ACCENT)
d.text((44, 100), title, font=bold, fill=HEADER_FG)
if subtitle:
d.text((44, 148), subtitle, font=font, fill=MUTED)
top, left, right = 196, 44, W - 44
lh = 28
d.rectangle([left, top, right, H - 78], fill=PANEL, outline=BORDER, width=2)
d.rectangle([left, top, right, top + 40], fill=HEADER_BG)
for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
d.ellipse([left + 22 + i * 24, top + 14, left + 34 + i * 24, top + 26], fill=c)
d.text((left + 100, top + 12), panel_label, font=small, fill=MUTED)
x0, y0 = left + 26, top + 58
cw = d.textlength("M", font=font)
for i, line in enumerate(rows):
y = y0 + i * lh
if y > H - 96:
break
for x, (ch, fg, b) in enumerate(line):
if ch == " ":
continue
d.text((x0 + x * cw, y), ch, font=bold if b else font, fill=fg)
if cursor:
cy, cx = cursor
if 0 <= cy < len(rows) and 0 <= cx < len(rows[0]):
d.rectangle([x0 + cx * cw, y0 + cy * lh, x0 + (cx + 1) * cw - 1,
y0 + (cy + 1) * lh - 1], fill=ACCENT)
d.text((44, H - 52), footer, font=small, fill=MUTED)
url = "git.labexposed.com/lgcosta/pf-linux-port"
uw = d.textlength(url, font=small)
d.text((W - 44 - uw, H - 52), url, font=small, fill=MUTED)
return img
def render_title(title, subtitle, phase_no, phase_total, bold, font, small, art):
W, H = 1920, 1080
img = Image.new("RGB", (W, H), TITLE_BG)
if art:
a = art.resize((W, H)).convert("RGB")
img = Image.blend(img, a, 0.55)
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W, 8], fill=ACCENT)
d.text((120, 420), title, font=bold, fill=(250, 252, 255))
if subtitle:
d.text((120, 490), subtitle, font=font, fill=(190, 200, 214))
if phase_total:
d.text((120, 570), f"FASE {phase_no} DE {phase_total}", font=small, fill=ACCENT)
d.text((120, 630), "PF on Linux - OpenBSD PF 8.0 no kernel Linux",
font=small, fill=(150, 160, 175))
return img
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--plan", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--title", required=True)
ap.add_argument("--subtitle", default="")
ap.add_argument("--footer", default="PF on Linux - OpenBSD PF 8.0 no kernel Linux - lab libvirt")
ap.add_argument("--panel", default="alpine (pf0) - lab libvirt - kernel 7.3.4 + pf.ko")
ap.add_argument("--phase-no", type=int, default=0)
ap.add_argument("--phase-total", type=int, default=0)
ap.add_argument("--cols", type=int, default=118)
ap.add_argument("--rows", type=int, default=34)
ap.add_argument("--fps", type=int, default=30)
ap.add_argument("--title-card", type=float, default=2.5)
ap.add_argument("--art")
args = ap.parse_args()
font, bold, small = load_fonts()
art = None
if args.art and os.path.exists(args.art):
art = Image.open(args.art)
# The plan runs inside the recording shell; the shell itself is a real
# bash with our prompt, so what the user sees is a live session.
sh = Shell(["bash", "--norc", "--noprofile", "-i"], cols=args.cols, rows=args.rows)
sh.pump(1.0)
# make the prompt look like the OpenBSD box
sh.write("export PS1='\\u@\\h:\\w\\$ ' PS2='> ' TERM=xterm-256color\n")
sh.pump(0.6)
sh.write("export LC_ALL=C.UTF-8 LANG=C.UTF-8; clear\n")
sh.pump(0.8)
sh.events = [(0.0, sh.snapshot())]
# run the plan: each line is typed into the shell
plan = open(args.plan).read()
for raw_line in plan.split("\n"):
line = raw_line.rstrip()
if not line.strip() or line.strip().startswith("#"):
continue
if line.startswith("@sleep "):
sh.pump(float(line.split()[1]))
continue
if line.startswith("@run "):
# run a command without showing it typed (used for setup)
sh.write(line[5:] + "\n")
sh.wait_prompt(timeout=30)
continue
sh.type_line(line)
sh.wait_prompt(timeout=60)
sh.pump(0.45)
sh.write("exit\n")
sh.pump(0.5)
sys.stderr.write(f"[session] {len(sh.events)} events, {sh.events[-1][0]:.1f}s\n")
# ------------------------------------------------------ frame timeline ---
frames_dir = os.path.join(os.path.dirname(os.path.abspath(args.out)), "_sframes")
subprocess.run(["rm", "-rf", frames_dir], check=False)
os.makedirs(frames_dir, exist_ok=True)
n = 0
title_img = render_title(args.title, args.subtitle, args.phase_no, args.phase_total,
bold, font, small, art)
for _ in range(int(args.title_card * args.fps)):
title_img.save(os.path.join(frames_dir, f"{n:05d}.png"))
n += 1
# the events carry real timestamps: map them onto a uniform frame clock
ev = sh.events
t0 = ev[0][0]
for i, (ts, snap) in enumerate(ev):
hold = (ev[i + 1][0] - ts) if i + 1 < len(ev) else 0.04
hold = max(0.033, min(0.5, hold))
rows, cur = snap
img = render(rows, cur, args.title, args.subtitle, args.phase_no,
args.phase_total, font, bold, small, args.footer, args.panel, art)
cnt = max(1, int(round(hold * args.fps)))
for _ in range(cnt):
img.save(os.path.join(frames_dir, f"{n:05d}.png"))
n += 1
subprocess.run(["rm", "-rf", frames_dir], check=False)
if __name__ == "__main__":
main()