diff --git a/.gitignore b/.gitignore index ba25723..38c28e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ _vendor/ -out/ +out/* !out/demo-pf-linux.cast +!out/screencast/ +!out/screencast/*.mp4 *.o *.ko *.mod @@ -11,7 +13,6 @@ out/ modules.order Module.symvers tools/out/ -!out/demo-pf-linux.cast lab/seeds/ *.pcap *.log diff --git a/README.md b/README.md index e1b194d..a71660b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,20 @@ Not in scope: `pfsync`/CARP failover, IPsec/pfkey, `ftp-proxy`, per-netns PF instances, classic-ALTQ-only schedulers (cbq/wfq/red — removed upstream before OpenBSD 8.0). +## Screencasts + +Six silent 1080p30 MP4 screencasts of the lab — implementation in the kernel, +activation, basic rules, routing, balancing and QOS — each one a real session +against the Alpine VMs: + +```sh +./scripts/screencast/record-all.sh # -> out/screencast/*.mp4 +``` + +Details, per-phase breakdown and the YouTube description text: +[`docs/en/screencast.md`](docs/en/screencast.md) · +[`docs/pt-BR/screencast.md`](docs/pt-BR/screencast.md) + ## Quick start Full recipe: [`docs/en/install.md`](docs/en/install.md). Short version diff --git a/docs/en/screencast.md b/docs/en/screencast.md new file mode 100644 index 0000000..230262a --- /dev/null +++ b/docs/en/screencast.md @@ -0,0 +1,70 @@ +# Screencasts / Videos da demonstracao + +> PT-BR: [docs/pt-BR/screencast.md](pt-BR/screencast.md) · EN: this page + +Seis videos silenciosos, 1920x1080 @ 30fps, H.264/MP4 — prontos para subir no +YouTube. Cada video e a gravacao de uma sessao **real** contra o lab: VMs +Alpine sob libvirt, rodando o kernel 7.3.4 compilado do fonte com o `pf.ko` +deste repositorio. Nada e simulado; a unica coisa roteirizada e o ritmo. + +| # | Video | Duracao | O que mostra | +|---|-------|---------|--------------| +| 1 | `01-implementacao-kernel.mp4` | 22 s | A fonte do OpenBSD 8.0 organizada em `module/pfsrc`, a camada de compatibilidade em `module/compat`, e a compilacao do modulo `pf.ko` contra o kernel do lab | +| 2 | `02-ativacao.mp4` | 29 s | `insmod pf.ko`, `/dev/pf` aparecendo, `pfctl -e`, `pfctl -s info` com a tabela de estados | +| 3 | `03-regras-basicas.mp4` | 54 s | Um `pf.conf` real: tabela, anchor, `quick`, `keep state`, `block drop` (o ping morre), contadores por regra e `pfctl -ss` | +| 4 | `04-roteamento.mp4` | 47 s | `route-to` (o pacote sai pelo caminho forcado, provado com `tcpdump` nas duas interfaces) e `reply-to` (resposta por gateway diferente) | +| 5 | `05-balanceamento.mp4` | 69 s | Pool `nat-to { ... } round-robin` alternando a origem no fio, pool `source-hash` fixando por origem, e `dup-to` replicando o pacote | +| 6 | `06-qos.mp4` | 34 s | Filas do OpenBSD 8.0 (`queue ... bandwidth`, classes `hfsc`), `pfctl -s queue -v`, transferencia de 2 MB pela fila e os contadores da classe subindo | + +## Como regravar + +```sh +# renderiza todas as fases +./scripts/screencast/record-all.sh + +# ou uma fase especifica +./scripts/screencast/record-all.sh 05-balanceamento +``` + +Os arquivos sao gravados em `out/screencast/*.mp4`. O renderizador +(`scripts/screencast/render.py`) roda a fase em um pty, emula o terminal com +`pyte`, converte cada evento em um frame PNG e mux com `ffmpeg` (H.264, +`yuv420p`, `+faststart`, **sem trilha de audio**). Se o `ffmpeg` da maquina nao +tiver `libx264`, ele cai para `libopenh264` e depois para VP9 automaticamente. + +Requisitos: `ffmpeg`, `python3` com `PIL` e `pyte`, a fonte DejaVu Sans Mono, e +o lab de VMs no ar (as fases 2 a 6 falam com o lab; a fase 1 roda no host de +build). + +## Roteiro das fases + +Os roteiros estao em `scripts/screencast/phases/` — um shell por fase, com +comentarios explicando cada passo. Eles usam os mesmos comandos que os gates +da suite (`tests/vm/*.sh`), entao o que o video mostra e exatamente o que a +suite verifica. + +## Texto de descricao (YouTube) + +``` +PF on Linux - o motor do OpenBSD PF 8.0 dentro do kernel Linux + +Port completo do Packet Filter do OpenBSD 8.0 para o kernel Linux 7.3.4, +empacotado como modulo (pf.ko) e controlada pelo pfctl original do OpenBSD +sobre a ABI de ioctls do /dev/pf. + +O lab e real: 4 VMs Alpine sob libvirt, kernel compilado do fonte com o +modulo, trafego real entre cliente, firewall e servidor. + +Fases: +1. Implementacao no kernel - das fontes OpenBSD ao pf.ko +2. Ativacao - insmod, /dev/pf, pfctl -e, pfctl -s info +3. Regras basicas - tabelas, anchors, keep state, block drop +4. Roteamento - route-to e reply-to +5. Balanceamento - pools round-robin, source-hash e dup-to +6. QOS - filas hfsc do OpenBSD 8.0 no caminho de dados + +Suite automatizada: 15 gates, todos verdes. +Codigo e documentacao: https://git.labexposed.com/lgcosta/pf-linux-port + +#linux #kernel #openbsd #pf #firewall #netfilter #screencast +``` diff --git a/docs/pt-BR/screencast.md b/docs/pt-BR/screencast.md new file mode 100644 index 0000000..5714559 --- /dev/null +++ b/docs/pt-BR/screencast.md @@ -0,0 +1,70 @@ +# Screencasts / Videos da demonstracao + +> PT-BR: esta pagina · EN: [docs/en/screencast.md](en/screencast.md) + +Seis videos silenciosos, 1920x1080 @ 30fps, H.264/MP4 — prontos para subir no +YouTube. Cada video e a gravacao de uma sessao **real** contra o lab: VMs +Alpine sob libvirt, rodando o kernel 7.3.4 compilado do fonte com o `pf.ko` +deste repositorio. Nada e simulado; a unica coisa roteirizada e o ritmo. + +| # | Video | Duracao | O que mostra | +|---|-------|---------|--------------| +| 1 | `01-implementacao-kernel.mp4` | 22 s | A fonte do OpenBSD 8.0 organizada em `module/pfsrc`, a camada de compatibilidade em `module/compat`, e a compilacao do modulo `pf.ko` contra o kernel do lab | +| 2 | `02-ativacao.mp4` | 29 s | `insmod pf.ko`, `/dev/pf` aparecendo, `pfctl -e`, `pfctl -s info` com a tabela de estados | +| 3 | `03-regras-basicas.mp4` | 54 s | Um `pf.conf` real: tabela, anchor, `quick`, `keep state`, `block drop` (o ping morre), contadores por regra e `pfctl -ss` | +| 4 | `04-roteamento.mp4` | 47 s | `route-to` (o pacote sai pelo caminho forcado, provado com `tcpdump` nas duas interfaces) e `reply-to` (resposta por gateway diferente) | +| 5 | `05-balanceamento.mp4` | 69 s | Pool `nat-to { ... } round-robin` alternando a origem no fio, pool `source-hash` fixando por origem, e `dup-to` replicando o pacote | +| 6 | `06-qos.mp4` | 34 s | Filas do OpenBSD 8.0 (`queue ... bandwidth`, classes `hfsc`), `pfctl -s queue -v`, transferencia de 2 MB pela fila e os contadores da classe subindo | + +## Como regravar + +```sh +# renderiza todas as fases +./scripts/screencast/record-all.sh + +# ou uma fase especifica +./scripts/screencast/record-all.sh 05-balanceamento +``` + +Os arquivos sao gravados em `out/screencast/*.mp4`. O renderizador +(`scripts/screencast/render.py`) roda a fase em um pty, emula o terminal com +`pyte`, converte cada evento em um frame PNG e mux com `ffmpeg` (H.264, +`yuv420p`, `+faststart`, **sem trilha de audio**). Se o `ffmpeg` da maquina nao +tiver `libx264`, ele cai para `libopenh264` e depois para VP9 automaticamente. + +Requisitos: `ffmpeg`, `python3` com `PIL` e `pyte`, a fonte DejaVu Sans Mono, e +o lab de VMs no ar (as fases 2 a 6 falam com o lab; a fase 1 roda no host de +build). + +## Roteiro das fases + +Os roteiros estao em `scripts/screencast/phases/` — um shell por fase, com +comentarios explicando cada passo. Eles usam os mesmos comandos que os gates +da suite (`tests/vm/*.sh`), entao o que o video mostra e exatamente o que a +suite verifica. + +## Texto de descricao (YouTube) + +``` +PF on Linux - o motor do OpenBSD PF 8.0 dentro do kernel Linux + +Port completo do Packet Filter do OpenBSD 8.0 para o kernel Linux 7.3.4, +empacotado como modulo (pf.ko) e controlada pelo pfctl original do OpenBSD +sobre a ABI de ioctls do /dev/pf. + +O lab e real: 4 VMs Alpine sob libvirt, kernel compilado do fonte com o +modulo, trafego real entre cliente, firewall e servidor. + +Fases: +1. Implementacao no kernel - das fontes OpenBSD ao pf.ko +2. Ativacao - insmod, /dev/pf, pfctl -e, pfctl -s info +3. Regras basicas - tabelas, anchors, keep state, block drop +4. Roteamento - route-to e reply-to +5. Balanceamento - pools round-robin, source-hash e dup-to +6. QOS - filas hfsc do OpenBSD 8.0 no caminho de dados + +Suite automatizada: 15 gates, todos verdes. +Codigo e documentacao: https://git.labexposed.com/lgcosta/pf-linux-port + +#linux #kernel #openbsd #pf #firewall #netfilter #screencast +``` diff --git a/out/screencast/01-implementacao-kernel.mp4 b/out/screencast/01-implementacao-kernel.mp4 new file mode 100644 index 0000000..fcd767b Binary files /dev/null and b/out/screencast/01-implementacao-kernel.mp4 differ diff --git a/out/screencast/02-ativacao.mp4 b/out/screencast/02-ativacao.mp4 new file mode 100644 index 0000000..a3e59f1 Binary files /dev/null and b/out/screencast/02-ativacao.mp4 differ diff --git a/out/screencast/03-regras-basicas.mp4 b/out/screencast/03-regras-basicas.mp4 new file mode 100644 index 0000000..4aa8020 Binary files /dev/null and b/out/screencast/03-regras-basicas.mp4 differ diff --git a/out/screencast/04-roteamento.mp4 b/out/screencast/04-roteamento.mp4 new file mode 100644 index 0000000..c56b6a8 Binary files /dev/null and b/out/screencast/04-roteamento.mp4 differ diff --git a/out/screencast/05-balanceamento.mp4 b/out/screencast/05-balanceamento.mp4 new file mode 100644 index 0000000..0bdc492 Binary files /dev/null and b/out/screencast/05-balanceamento.mp4 differ diff --git a/out/screencast/06-qos.mp4 b/out/screencast/06-qos.mp4 new file mode 100644 index 0000000..a1ec90b Binary files /dev/null and b/out/screencast/06-qos.mp4 differ diff --git a/scripts/screencast/phases/01-implementacao-kernel.sh b/scripts/screencast/phases/01-implementacao-kernel.sh new file mode 100755 index 0000000..1c9f9e6 --- /dev/null +++ b/scripts/screencast/phases/01-implementacao-kernel.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Fase 1 - a implementacao no kernel: da fonte OpenBSD ao modulo pf.ko. +# Roda no host de build (o port esta no repositorio; a VM so recebe o .ko). +set -u +cd "$(dirname "$0")" +. ./lib.sh + +banner "FASE 1 - A IMPLEMENTACAO NO KERNEL" + +bold "# o port: fontes BSD trazidos do OpenBSD 8.0 + camada de compatibilidade" +dim " module/pfsrc/ - fontes do OpenBSD (pf.c, pf_lb.c, pf_norm.c, hfsc.c, ...)" +dim " module/compat/ - shims que traduzem as APIs BSD para o Linux" +dim " module/include/ - cabecalhos (pfk.h) e os headers vendorizados do OpenBSD" +echo +cyan "host# ls module/pfsrc | head -20" +ls "$REPO/module/pfsrc" | head -20 +echo +cyan "host# wc -l module/pfsrc/*.c | tail -3" +(cd "$REPO" && wc -l module/pfsrc/*.c | tail -3) +cyan "host# wc -l module/compat/*.c | tail -1" +(cd "$REPO" && wc -l module/compat/*.c | tail -1) +echo +dim " (as fontes OpenBSD entram praticamente intactas; a compat faz a ponte" +dim " mbuf->sk_buff, spl->spinlock, epoch->RCU, ifnet->net_device, ...)" + +bold +bold "# compilando o modulo contra o kernel do lab (build limpo)" +echo +cyan "host# ./scripts/build-module.sh clean" +(cd "$REPO" && ./scripts/build-module.sh clean 2>&1 | tail -3) +cyan "host# ./scripts/build-module.sh" +(cd "$REPO" && ./scripts/build-module.sh 2>&1 | tail -18) +echo +cyan "host# ls -la module/pf.ko" +ls -la "$REPO/module/pf.ko" +cyan "host# modinfo module/pf.ko | head -5" +modinfo "$REPO/module/pf.ko" 2>/dev/null | head -5 || \ + (cd "$REPO" && strings module/pf.ko | grep -m2 vermagic) +echo +green " modulo pronto: o motor PF 8.0 do OpenBSD pronto para carregar no Linux" diff --git a/scripts/screencast/phases/02-ativacao.sh b/scripts/screencast/phases/02-ativacao.sh new file mode 100755 index 0000000..43a33e8 --- /dev/null +++ b/scripts/screencast/phases/02-ativacao.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Fase 2 - ativacao: carregar pf.ko, habilitar o firewall, ver o estado. +set -u +cd "$(dirname "$0")" +. ./lib.sh + +banner "FASE 2 - ATIVACAO" + +bold "# o modulo ainda nao esta carregado" +p0 "rmmod pf 2>/dev/null; lsmod | grep '^pf ' || echo 'pf: not loaded'" +p0 "ls -l /dev/pf 2>/dev/null || echo '/dev/pf: ausente enquanto pf nao carrega'" +echo + +bold "# carregando o motor PF no kernel" +p0 "insmod /opt/pf/pf.ko && echo 'pf.ko carregado no kernel'" +p0 "dmesg | tail -4" +p0 "lsmod | grep '^pf '" +p0 "ls -l /dev/pf" +echo + +bold "# habilitando o firewall" +p0 "/opt/pf/pfctl -e && echo 'pf habilitado'" +p0 "/opt/pf/pfctl -si | head -6" +echo + +bold "# o ruleset comeca vazio (a politica padrao do pf e bloquear, com return)" +p0 "/opt/pf/pfctl -sr" +p0 "/opt/pf/pfctl -sf | head -5" +echo +green " motor ativo: /dev/pf respondendo, pfctl sobre a ABI de ioctl do OpenBSD" diff --git a/scripts/screencast/phases/03-regras-basicas.sh b/scripts/screencast/phases/03-regras-basicas.sh new file mode 100755 index 0000000..96dcb15 --- /dev/null +++ b/scripts/screencast/phases/03-regras-basicas.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Fase 3 - regras basicas: tabelas, anchors, keep state, block/pass. +set -u +cd "$(dirname "$0")" +. ./lib.sh + +banner "FASE 3 - REGRAS BASICAS" + +p0 "/opt/pf/pfctl -e" +p0 "/opt/pf/pfctl -f /dev/null" + +bold "# um pf.conf de verdade, sintaxe do OpenBSD" +cat > /tmp/sc-regras.conf <<'CONF' +# --- regras basicas do PF --- +set block-policy return + +table { 10.0.1.0/24 } + +anchor "demo" + +pass in quick on ingress proto icmp from keep state +pass out quick on egress proto icmp keep state + +pass in quick +pass out quick +CONF +put /tmp/sc-regras.conf >/dev/null +p0 "cat /tmp/sc-regras.conf" +p0 "/opt/pf/pfctl -f /tmp/sc-regras.conf && echo 'ruleset carregado'" +echo + +bold "# o que o pf entendeu" +p0 "/opt/pf/pfctl -sr" +p0 "/opt/pf/pfctl -s Tables" +p0 "/opt/pf/pfctl -t trusted -T show" +echo + +bold "# trafego permitido: passa e cria estado" +vmc "ping -c2 -W2 $SRV_IP | tail -3" +p0 "/opt/pf/pfctl -ss | head -5" +echo + +bold "# a mesma regra com 'block drop' bloqueia de verdade" +cat > /tmp/sc-block.conf < { 10.0.1.0/24 } +block drop in quick on ingress proto icmp from +pass in quick +pass out quick +CONF +put /tmp/sc-block.conf >/dev/null +p0 "/opt/pf/pfctl -f /tmp/sc-block.conf && echo 'ruleset de bloqueio carregado'" +vmc "ping -c2 -W2 $SRV_IP | tail -3" +p0 "/opt/pf/pfctl -sr -vv | head -4" +echo + +bold "# anchors: regras aninhadas, como no OpenBSD" +p0 "cat > /tmp/sc-anchor.conf <<'EOF' +pass in quick on ingress proto icmp from 10.0.1.10 to 10.0.2.20 keep state +pass in quick +pass out quick +EOF" +p0 "/opt/pf/pfctl -a demo -f /tmp/sc-anchor.conf && echo 'ruleset do anchor carregado'" +p0 "/opt/pf/pfctl -a demo -sr" +p0 "cat > /tmp/sc-main.conf <<'EOF' +anchor \"demo\" +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/sc-main.conf && echo 'ruleset principal com anchor carregado'" +p0 "/opt/pf/pfctl -sr" +echo + +bold "# voltando ao ruleset com estado" +p0 "/opt/pf/pfctl -f /tmp/sc-regras.conf && echo 'ruleset stateful restaurado'" +vmc "ping -c2 -W2 $SRV_IP | tail -2" +green " tabelas, anchors, quick, keep state, block drop: todos no lugar" diff --git a/scripts/screencast/phases/04-roteamento.sh b/scripts/screencast/phases/04-roteamento.sh new file mode 100755 index 0000000..7731f4f --- /dev/null +++ b/scripts/screencast/phases/04-roteamento.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Fase 4 - roteamento: route-to (caminho forcado) e reply-to (assimetria). +set -u +cd "$(dirname "$0")" +. ./lib.sh + +banner "FASE 4 - ROTEAMENTO" + +p0 "/opt/pf/pfctl -e" +p0 "/opt/pf/pfctl -f /dev/null" +put ./an.sh >/dev/null + +IF_SRV=$(onvm "$PF0_MGMT" "ip route get $SRV_IP | grep -o 'dev [a-z0-9]*' | cut -d' ' -f2" | tr -d '\r') +IF_GW=$(onvm "$PF0_MGMT" "ip route get $GW2_IP | grep -o 'dev [a-z0-9]*' | cut -d' ' -f2" | tr -d '\r') +[ -n "$IF_SRV" ] || IF_SRV=eth2 +[ -n "$IF_GW" ] || IF_GW=eth3 +dim " (o servidor fica atras de $IF_SRV; o segundo gateway, em $IF_GW)" + +bold "# route-to: o pacote sai pelo caminho forcado, nao pelo normal" +cat > /tmp/sc-route.conf </dev/null +p0 "/opt/pf/pfctl -f /tmp/sc-route.conf && echo 'regra route-to carregada'" +p0 "/opt/pf/pfctl -sr" +p0 "pkill tcpdump 2>/dev/null; (tcpdump -ni $IF_GW -c 4 -e icmp > /tmp/sc_gw.td 2>&1 &); (tcpdump -ni $IF_SRV -c 4 -e icmp > /tmp/sc_srv.td 2>&1 &); sleep 1" +vmc "ping -c2 -W2 $SRV_IP | tail -2" +dim " (o ping falha de proposito: o pacote foi entregue ao gateway $GW2_IP)" +sleep 3 +p0 "echo \"egresso forcado ($IF_GW):\"; sh /tmp/an.sh /tmp/sc_gw.td req" +p0 "echo \"egresso normal ($IF_SRV):\"; sh /tmp/an.sh /tmp/sc_srv.td req" +echo + +bold "# reply-to: a resposta volta por um caminho diferente do pedido" +cat > /tmp/sc-reply.conf </dev/null +p0 "/opt/pf/pfctl -f /tmp/sc-reply.conf && echo 'regra reply-to carregada'" +p0 "/opt/pf/pfctl -sr" +p0 "pkill tcpdump 2>/dev/null; (tcpdump -ni $IF_GW -c 6 -e icmp > /tmp/sc_rgw.td 2>&1 &); (tcpdump -ni eth1 -c 6 -e icmp > /tmp/sc_rcli.td 2>&1 &); sleep 1" +vmc "ping -c2 -W2 $SRV_IP | tail -2" +dim " (de novo sem resposta direta: a resposta foi forcada para o gateway)" +sleep 3 +p0 "echo \"resposta forcada ($IF_GW):\"; sh /tmp/an.sh /tmp/sc_rgw.td rep" +p0 "echo \"resposta pelo caminho normal do cliente:\"; sh /tmp/an.sh /tmp/sc_rcli.td rep" +echo + +bold "# rota normal restaurada" +p0 "/opt/pf/pfctl -f /dev/null; /opt/pf/pfctl -sr" +p0 "pkill tcpdump 2>/dev/null; echo 'capturas encerradas'" +green " route-to e reply-to: o proximo salto e decidido pelo pf, nao pela tabela de rotas" diff --git a/scripts/screencast/phases/05-balanceamento.sh b/scripts/screencast/phases/05-balanceamento.sh new file mode 100755 index 0000000..e16a3a3 --- /dev/null +++ b/scripts/screencast/phases/05-balanceamento.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Fase 5 - balanceamento: pools (round-robin, source-hash) e dup-to. +set -u +cd "$(dirname "$0")" +. ./lib.sh + +banner "FASE 5 - BALANCEAMENTO" + +p0 "/opt/pf/pfctl -e" +p0 "/opt/pf/pfctl -f /dev/null" +put ./an.sh >/dev/null + +IF_SRV=$(onvm "$PF0_MGMT" "ip route get $SRV_IP | grep -o 'dev [a-z0-9]*' | cut -d' ' -f2" | tr -d '\r') +IF_GW=$(onvm "$PF0_MGMT" "ip route get $GW2_IP | grep -o 'dev [a-z0-9]*' | cut -d' ' -f2" | tr -d '\r') +[ -n "$IF_SRV" ] || IF_SRV=eth2 +[ -n "$IF_GW" ] || IF_GW=eth3 + +bold "# pool round-robin: a traducao de origem alterna entre os membros do pool" +cat > /tmp/sc-pool.conf </dev/null +p0 "cat /tmp/sc-pool.conf" +p0 "/opt/pf/pfctl -f /tmp/sc-pool.conf && echo 'pool round-robin carregado'" +p0 "/opt/pf/pfctl -sr" +p0 "pkill tcpdump 2>/dev/null; (tcpdump -ni $IF_SRV -c 12 -e icmp > /tmp/sc_pool.td 2>&1 &); sleep 1" +for i in 1 2 3 4 5 6; do + vmc "ping -c1 -W2 $SRV_IP >/dev/null 2>&1; echo ' pacote $i enviado'" +done +sleep 4 +p0 "echo 'origem vista pelo servidor (o pool alterna entre os membros):'; sh /tmp/an.sh /tmp/sc_pool.td src" +echo + +bold "# pool source-hash: a mesma origem cai sempre no mesmo membro" +cat > /tmp/sc-hash.conf </dev/null +p0 "/opt/pf/pfctl -f /tmp/sc-hash.conf && echo 'pool source-hash carregado'" +p0 "pkill tcpdump 2>/dev/null; (tcpdump -ni $IF_SRV -c 8 -e icmp > /tmp/sc_hash.td 2>&1 &); sleep 1" +for i in 1 2 3 4; do + vmc "ping -c1 -W2 $SRV_IP >/dev/null 2>&1; echo ' pacote $i enviado'" +done +sleep 4 +p0 "echo 'origem com source-hash (estavel por origem):'; sh /tmp/an.sh /tmp/sc_hash.td src" +echo + +bold "# dup-to: o pacote segue o caminho normal e uma copia vai para o outro gateway" +cat > /tmp/sc-dup.conf </dev/null +p0 "/opt/pf/pfctl -f /tmp/sc-dup.conf && echo 'regra dup-to carregada'" +p0 "/opt/pf/pfctl -sr" +p0 "pkill tcpdump 2>/dev/null; (tcpdump -ni $IF_SRV -c 4 -e icmp > /tmp/sc_dup1.td 2>&1 &); (tcpdump -ni $IF_GW -c 4 -e icmp > /tmp/sc_dup2.td 2>&1 &); sleep 1" +vmc "ping -c2 -W2 $SRV_IP | tail -2" +sleep 4 +p0 "echo \"caminho normal ($IF_SRV):\"; sh /tmp/an.sh /tmp/sc_dup1.td req" +p0 "echo \"copia espelhada ($IF_GW):\"; sh /tmp/an.sh /tmp/sc_dup2.td req" +echo + +p0 "/opt/pf/pfctl -f /dev/null; pkill tcpdump 2>/dev/null" +green " round-robin reparte a carga; source-hash fixa por origem; dup-to replica" diff --git a/scripts/screencast/phases/06-qos.sh b/scripts/screencast/phases/06-qos.sh new file mode 100755 index 0000000..5a5817c --- /dev/null +++ b/scripts/screencast/phases/06-qos.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Fase 6 - QOS: filas do OpenBSD 8.0 (hfsc) no caminho de dados do Linux. +set -u +cd "$(dirname "$0")" +. ./lib.sh + +banner "FASE 6 - QOS (FILAS / ALTQ)" + +p0 "/opt/pf/pfctl -e" +p0 "/opt/pf/pfctl -f /dev/null" + +IF_SRV=$(onvm "$PF0_MGMT" "ip route get $SRV_IP | grep -o 'dev [a-z0-9]*' | cut -d' ' -f2" | tr -d '\r') +[ -n "$IF_SRV" ] || IF_SRV=eth2 + +bold "# a sintaxe de filas do OpenBSD 8.0: queue com classes hfsc" +cat > /tmp/sc-q.conf </dev/null +p0 "cat /tmp/sc-q.conf" +p0 "/opt/pf/pfctl -f /tmp/sc-q.conf && echo 'filas carregadas'" +p0 "/opt/pf/pfctl -sr" +echo + +bold "# o pfctl mostra as classes e as estatisticas do escalonador" +p0 "/opt/pf/pfctl -s queue -v" +echo + +bold "# transferencia de 2 MB pela fila: o trafego entra na classe" +vms "head -c 2000000 /dev/zero | tr '\\0' A > /tmp/sc_data.bin; ls -l /tmp/sc_data.bin; pkill nc 2>/dev/null; (setsid nc -l -p 9095 < /tmp/sc_data.bin >/dev/null 2>&1 &); sleep 1; echo 'servidor escutando na 9095'" +vmc "nc -w60 $SRV_IP 9095 > /tmp/sc_out.bin; wc -c < /tmp/sc_out.bin" +sleep 1 +p0 "/opt/pf/pfctl -s queue -v" +echo + +bold "# a disciplina hfsc contou o trafego da classe" +p0 "/opt/pf/pfctl -sr -vv | head -6" +p0 "ip -s link show $IF_SRV | head -4" +echo + +p0 "/opt/pf/pfctl -f /dev/null; pkill nc 2>/dev/null; /opt/pf/pfctl -s queue -v" +green " filas do OpenBSD 8.0 (hfsc) anexadas a interface e contando pacotes" diff --git a/scripts/screencast/phases/an.sh b/scripts/screencast/phases/an.sh new file mode 100755 index 0000000..dd5cd28 --- /dev/null +++ b/scripts/screencast/phases/an.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Analysis helper executed ON the firewall VM (avoids quoting hell through the +# ssh layers). usage: an.sh +f=$1 +mode=$2 +case "$mode" in +req) echo "requerentes no fio: $(grep -c 'echo request' "$f")" ;; +rep) echo "respostas no fio: $(grep -c 'echo reply' "$f")" ;; +src) echo "origem no fio (contagem por endereco de origem):" + grep 'echo request' "$f" | sed 's/.*: \([0-9.]*\) > .*/\1/' | sort | uniq -c ;; +uniq) echo "origens distintas: $(grep 'echo request' "$f" | sed 's/.*: \([0-9.]*\) > .*/\1/' | sort -u | tr '\n' ' ')" ;; +lines) grep 'echo request' "$f" | head -2 ;; +*) echo "modo desconhecido: $mode" ;; +esac diff --git a/scripts/screencast/phases/lib.sh b/scripts/screencast/phases/lib.sh new file mode 100755 index 0000000..cb8b86f --- /dev/null +++ b/scripts/screencast/phases/lib.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Shared helpers for the screencast phase scripts. +# +# Everything printed by a phase is the REAL output of the lab: Alpine VMs +# under libvirt running the kernel built from source with pf.ko. Only the +# framing (prompts, banners) is scripted. +set -u + +DEB=${DEB:-root@192.168.1.242} +KEY=${KEY:-$HOME/.ssh/pflab_ed} +CLI_IP=${CLI_IP:-10.0.1.10} +SRV_IP=${SRV_IP:-10.0.2.20} +GW2_IP=${GW2_IP:-10.0.3.2} +REPO=${REPO:-$(cd "$(dirname "$0")/../../.." && pwd)} + +cyan() { printf '\033[1;36m%s\033[0m\n' "$*"; } +green() { printf '\033[1;32m%s\033[0m\n' "$*"; } +yellow(){ printf '\033[1;33m%s\033[0m\n' "$*"; } +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +dim() { printf '\033[2m%s\033[0m\n' "$*"; } + +banner() { + echo + bold "===============================================================" + bold " $*" + bold "===============================================================" + echo +} + +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=${PF0_MGMT:-$(mgmt pftest-pf0)} +CLI_MGMT=${CLI_MGMT:-$(mgmt pftest-cli)} +SRV_MGMT=${SRV_MGMT:-$(mgmt pftest-srv)} +[ -n "$PF0_MGMT" ] || PF0_MGMT=192.168.122.8 +[ -n "$CLI_MGMT" ] || CLI_MGMT=192.168.122.230 +[ -n "$SRV_MGMT" ] || SRV_MGMT=192.168.122.202 + +onvm() { + local ip=$1; shift + ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR \ + -o ProxyJump=root@192.168.1.242 -i "$KEY" root@"$ip" "$@" 2>&1 +} + +# run on the firewall VM, showing the command as a prompt +p0() { + cyan "pf0# $*" + onvm "$PF0_MGMT" "$@" + sleep 0.7 +} + +# run on the client VM +vmc() { + cyan "cli# $*" + onvm "$CLI_MGMT" "$@" + sleep 0.7 +} + +# run on the server VM +vms() { + cyan "srv# $*" + onvm "$SRV_MGMT" "$@" + sleep 0.7 +} + +# upload a local file to the firewall VM +put() { + scp -q -o ProxyJump=root@192.168.1.242 -i "$KEY" "$1" "root@$PF0_MGMT:/tmp/" 2>/dev/null +} diff --git a/scripts/screencast/record-all.sh b/scripts/screencast/record-all.sh new file mode 100755 index 0000000..0601e7d --- /dev/null +++ b/scripts/screencast/record-all.sh @@ -0,0 +1,65 @@ +#!/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() { # <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 diff --git a/scripts/screencast/render.py b/scripts/screencast/render.py new file mode 100755 index 0000000..46c9ca8 --- /dev/null +++ b/scripts/screencast/render.py @@ -0,0 +1,336 @@ +#!/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()