Screencasts v2: real shell sessions, MiniMax narration (EN/PT), lab diagram
O problema da primeira serie era a sensacao de 'quadro a quadro': a captura era a saida de um script despejada em blocos. Agora cada video e uma sessao shell de verdade. - record.py: abre um pty, faz SSH no firewall e digita os comandos tecla a tecla (~34 c/s, com jitter). O tty remoto ecoa, o kernel responde ao vivo; a captura e asciicast v2 com timestamps. 'bash --noediting' evita que o editor de linha redesenhe o comando e o tty o repita (a linha aparecia duplicada). - record.py --pace: segura a tela enquanto a voz fala - cada batida dura o trecho de audio que a acompanha, de modo que uma unica captura serve as duas versoes de idioma. - record.py --ensure-pf: garante pf.ko carregado antes de gravar. Sem isso uma fase que roda 'rmmod' (ativacao) pode derrubar o /dev/pf de outra gravacao concorrentes - foi exatamente o que quebrou a fase de roteamento na primeira tentativa. - make_audio.py: geracao da narracao pelo MiniMax TTS, uma voz feminina por idioma (English_ConfidentWoman / Portuguese_ConfidentWoman), um arquivo por segmento. A API devolve o audio em hexadecimal, nao base64. - render_video.py: reproduz a captura por um emulador de terminal e escreve os frames direto no ffmpeg como RGB bruto (um PNG por frame era uma ordem de grandeza mais lento). Desenha o diagrama de rede do lab por cima do terminal nas fases de roteamento e balanceamento, destacando o caminho em discussao, e queima a legenda da narracao. - assemble.py: mixa os segmentos de audio na linha do tempo. - join.py: emenda as fases com crossfade sobre um cartao de capitulo e sincroniza o audio na mesma linha do tempo. - build-all.sh: roda o pipeline inteiro. Arte de fundo e audio vem do MiniMax; a geracao de video do MiniMax nao esta habilitada nesta conta (erro 2068 em todos os modelos), entao as transicoes sao renderizadas localmente com ffmpeg. 16 videos: 7 fases + filme completo, em ingles e portugues, com legenda queimada.
This commit is contained in:
1 parent
fe324259c0
commit
3f64ce3c72
51 files changed
+2818
-71
No files matched your search
+12
-1
@@ -2,7 +2,15 @@ _vendor/
|
||||
out/*
|
||||
!out/demo-pf-linux.cast
|
||||
!out/screencast/
|
||||
!out/screencast/*.mp4
|
||||
out/screencast/*
|
||||
!out/screencast/art/
|
||||
!out/screencast/art/*
|
||||
!out/screencast/en/
|
||||
out/screencast/en/*
|
||||
!out/screencast/en/*.mp4
|
||||
!out/screencast/pt/
|
||||
out/screencast/pt/*
|
||||
!out/screencast/pt/*.mp4
|
||||
*.o
|
||||
*.ko
|
||||
*.mod
|
||||
@@ -16,3 +24,6 @@ tools/out/
|
||||
lab/seeds/
|
||||
*.pcap
|
||||
*.log
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -47,12 +47,14 @@ 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:
|
||||
Seven narrated screencasts per language (English and Portuguese), 1080p30 with
|
||||
burned-in subtitles, the lab's network diagram drawn over the terminal, and a
|
||||
continuous film that crossfades the phases together. Every video is a **real
|
||||
shell session** against the Alpine VMs — commands typed key by key, the kernel
|
||||
answering live — with a MiniMax female voice narrating:
|
||||
|
||||
```sh
|
||||
./scripts/screencast/record-all.sh # -> out/screencast/*.mp4
|
||||
./scripts/screencast/build-all.sh # -> out/screencast/{en,pt}/*.mp4
|
||||
```
|
||||
|
||||
Details, per-phase breakdown and the YouTube description text:
|
||||
|
||||
+84
-31
@@ -2,46 +2,98 @@
|
||||
|
||||
> 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.
|
||||
Sete videos por idioma, com **narracao com voz feminina** (MiniMax),
|
||||
**legendas queimadas**, **diagrama de rede do lab** e um **filme completo**
|
||||
com transicoes entre as fases. 1920x1080 @ 30fps, H.264/MP4 + AAC — prontos
|
||||
para subir no YouTube.
|
||||
|
||||
| # | 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 |
|
||||
Cada video e a gravacao de uma **sessao shell real**: uma conexao SSH de
|
||||
verdade ao firewall Alpine, com os comandos digitados tecla a tecla e a saida
|
||||
do kernel chegando em tempo real. Nada e simulado.
|
||||
|
||||
## As fases
|
||||
|
||||
| # | Video | O que mostra |
|
||||
|---|-------|--------------|
|
||||
| 0 | `00-ambiente.mp4` | O lab: 4 VMs Alpine sob libvirt, as quatro interfaces do firewall, e o diagrama de rede completo |
|
||||
| 1 | `01-implementacao.mp4` | A implementacao no kernel: fontes do OpenBSD 8.0 + camada de compatibilidade = `pf.ko` |
|
||||
| 2 | `02-ativacao.mp4` | `insmod pf.ko`, `/dev/pf`, `pfctl -e`, `pfctl -s info` |
|
||||
| 3 | `03-regras.mp4` | Regras basicas: tabela, anchor, `keep state`, `block drop` (o ping morre) |
|
||||
| 4 | `04-roteamento.mp4` | `route-to` e `reply-to`, com o diagrama mostrando o caminho real do pacote |
|
||||
| 5 | `05-balanceamento.mp4` | Pool `round-robin`, pool `source-hash` e `dup-to` |
|
||||
| 6 | `06-qos.mp4` | Filas `hfsc` do OpenBSD 8.0 e uma transferencia de 2 MB pela classe |
|
||||
|
||||
Mais `pf-on-linux-full.mp4`: o filme completo, com as sete fases emendadas por
|
||||
transicoes (crossfade sobre um cartao de capitulo).
|
||||
|
||||
## Vozes MiniMax
|
||||
|
||||
| Idioma | Voz | Id |
|
||||
|--------|-----|-----|
|
||||
| Ingles (en_US) | Confident Woman | `English_ConfidentWoman` |
|
||||
| Portugues (pt_BR) | Confident Woman | `Portuguese_ConfidentWoman` |
|
||||
|
||||
A audio e gerada pelo MiniMax TTS, **um arquivo por segmento** de fala. Como
|
||||
cada segmento corresponde a uma batida da sessao, a voz comeca exatamente
|
||||
quando a acao correspondente aparece na tela.
|
||||
|
||||
## Como regravar
|
||||
|
||||
```sh
|
||||
# renderiza todas as fases
|
||||
./scripts/screencast/record-all.sh
|
||||
# tudo: audio -> sessoes -> videos -> filme completo
|
||||
./scripts/screencast/build-all.sh
|
||||
|
||||
# ou uma fase especifica
|
||||
./scripts/screencast/record-all.sh 05-balanceamento
|
||||
# uma fase so
|
||||
./scripts/screencast/build-all.sh 04-roteamento
|
||||
```
|
||||
|
||||
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.
|
||||
Etapas individuais:
|
||||
|
||||
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).
|
||||
```sh
|
||||
python3 scripts/screencast/make_audio.py --lang pt # audio MiniMax
|
||||
python3 scripts/screencast/record.py --plan scripts/screencast/plans/04-roteamento.json \\
|
||||
--out out/screencast/caps/04-roteamento.cast --pace
|
||||
python3 scripts/screencast/assemble.py --phase 04-roteamento --lang pt
|
||||
python3 scripts/screencast/join.py --lang pt
|
||||
```
|
||||
|
||||
## Roteiro das fases
|
||||
### Como o pipeline funciona
|
||||
|
||||
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.
|
||||
```
|
||||
planos/*.json --record.py--> caps/*.cast --render_video.py--> video mudo
|
||||
(comandos) (pty real) (asciicast) (pyte + PIL) |
|
||||
|
|
||||
narracao/*.json --make_audio.py--> vo/<fase>.<lang>/*.mp3 ----------+
|
||||
(MiniMax TTS) |
|
||||
assemble.py: mix + legendas
|
||||
|
|
||||
join.py: transicoes
|
||||
```
|
||||
|
||||
Pontos que importam:
|
||||
|
||||
* **Sessao real.** `record.py` abre um pty, faz SSH na VM e digita os comandos
|
||||
um caractere por vez (~34 c/s, com jitter). O `bash --noediting` evita que o
|
||||
editor de linha redesenhe o comando e o tty o repita.
|
||||
* **Pacing.** A gravacao segura a tela enquanto a voz fala: cada batida dura
|
||||
no maximo o trecho de audio que a acompanha (o maior entre EN e PT), para
|
||||
uma unica captura servir as duas versoes.
|
||||
* **Legendas.** O texto da narracao e queimado no video, na lingua da trilha.
|
||||
* **Diagrama.** Nas fases 0, 4 e 5 o mapa do lab e desenhado sobre o
|
||||
terminal, e o caminho em discussao fica destacado (eth1/eth2/eth3).
|
||||
|
||||
Requisitos: `ffmpeg`, `python3` com `PIL` e `pyte`, a fonte DejaVu, a chave
|
||||
`MINIMAX_API_KEY` no ambiente, e o lab de VMs no ar.
|
||||
|
||||
## Onde ficam os arquivos
|
||||
|
||||
| Caminho | Conteudo |
|
||||
|---------|----------|
|
||||
| `out/screencast/en/`, `out/screencast/pt/` | os videos finais |
|
||||
| `out/screencast/caps/` | as capturas asciicast das sessoes |
|
||||
| `out/screencast/vo/` | o audio gerado pelo MiniMax |
|
||||
| `scripts/screencast/plans/` | o roteiro de comandos de cada fase |
|
||||
| `scripts/screencast/narration/` | os textos da narracao (EN e PT) |
|
||||
|
||||
## Texto de descricao (YouTube)
|
||||
|
||||
@@ -49,17 +101,18 @@ suite verifica.
|
||||
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
|
||||
empacotado como modulo (pf.ko) e controlado 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:
|
||||
0. O ambiente de testes - as 4 VMs e o diagrama de rede
|
||||
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
|
||||
4. Roteamento - route-to e reply-to, com o caminho real no diagrama
|
||||
5. Balanceamento - pools round-robin, source-hash e dup-to
|
||||
6. QOS - filas hfsc do OpenBSD 8.0 no caminho de dados
|
||||
|
||||
|
||||
+88
-35
@@ -1,47 +1,99 @@
|
||||
# Screencasts / Videos da demonstracao
|
||||
|
||||
> PT-BR: esta pagina · EN: [docs/en/screencast.md](en/screencast.md)
|
||||
> 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.
|
||||
Sete videos por idioma, com **narracao de voz feminina** (MiniMax), **legendas
|
||||
queimadas**, **diagrama de rede do lab** e um **filme completo** com transicoes
|
||||
entre as fases. 1920x1080 @ 30fps, H.264/MP4 + AAC — prontos para subir no
|
||||
YouTube.
|
||||
|
||||
| # | 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 |
|
||||
Cada video e a gravacao de uma **sessao shell real**: uma conexao SSH de verdade
|
||||
ao firewall Alpine, com os comandos digitados tecla a tecla e a saida do kernel
|
||||
chegando em tempo real. Nada e simulado.
|
||||
|
||||
## As fases
|
||||
|
||||
| # | Video | O que mostra |
|
||||
|---|-------|--------------|
|
||||
| 0 | `00-ambiente.mp4` | O lab: 4 VMs Alpine sob libvirt, as quatro interfaces do firewall e o diagrama de rede completo |
|
||||
| 1 | `01-implementacao.mp4` | A implementacao no kernel: fontes do OpenBSD 8.0 + camada de compatibilidade = `pf.ko` |
|
||||
| 2 | `02-ativacao.mp4` | `insmod pf.ko`, `/dev/pf`, `pfctl -e`, `pfctl -s info` |
|
||||
| 3 | `03-regras.mp4` | Regras basicas: tabela, anchor, `keep state`, `block drop` (o ping morre) |
|
||||
| 4 | `04-roteamento.mp4` | `route-to` e `reply-to`, com o diagrama mostrando o caminho real do pacote |
|
||||
| 5 | `05-balanceamento.mp4` | Pool `round-robin`, pool `source-hash` e `dup-to` |
|
||||
| 6 | `06-qos.mp4` | Filas `hfsc` do OpenBSD 8.0 e uma transferencia de 2 MB pela classe |
|
||||
|
||||
Mais `pf-on-linux-full.mp4`: o filme completo, com as sete fases emendadas por
|
||||
transicoes (crossfade sobre um cartao de capitulo).
|
||||
|
||||
## Vozes MiniMax
|
||||
|
||||
| Idioma | Voz | Id |
|
||||
|--------|-----|-----|
|
||||
| Ingles (en_US) | Confident Woman | `English_ConfidentWoman` |
|
||||
| Portugues (pt_BR) | Confident Woman | `Portuguese_ConfidentWoman` |
|
||||
|
||||
O audio e gerado pelo MiniMax TTS, **um arquivo por segmento** de fala. Como cada
|
||||
segmento corresponde a uma batida da sessao, a voz comeca exatamente quando a
|
||||
acao correspondente aparece na tela.
|
||||
|
||||
## Como regravar
|
||||
|
||||
```sh
|
||||
# renderiza todas as fases
|
||||
./scripts/screencast/record-all.sh
|
||||
# tudo: audio -> sessoes -> videos -> filme completo
|
||||
./scripts/screencast/build-all.sh
|
||||
|
||||
# ou uma fase especifica
|
||||
./scripts/screencast/record-all.sh 05-balanceamento
|
||||
# uma fase so
|
||||
./scripts/screencast/build-all.sh 04-roteamento
|
||||
```
|
||||
|
||||
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.
|
||||
Etapas individuais:
|
||||
|
||||
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).
|
||||
```sh
|
||||
python3 scripts/screencast/make_audio.py --lang pt # audio MiniMax
|
||||
python3 scripts/screencast/record.py --plan scripts/screencast/plans/04-roteamento.json \\
|
||||
--out out/screencast/caps/04-roteamento.cast --pace
|
||||
python3 scripts/screencast/assemble.py --phase 04-roteamento --lang pt
|
||||
python3 scripts/screencast/join.py --lang pt
|
||||
```
|
||||
|
||||
## Roteiro das fases
|
||||
### Como o pipeline funciona
|
||||
|
||||
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.
|
||||
```
|
||||
planos/*.json --record.py--> caps/*.cast --render_video.py--> video mudo
|
||||
(comandos) (pty real) (asciicast) (pyte + PIL) |
|
||||
|
|
||||
narracao/*.json --make_audio.py--> vo/<fase>.<lang>/*.mp3 ----------+
|
||||
(MiniMax TTS) |
|
||||
assemble.py: mix + legendas
|
||||
|
|
||||
join.py: transicoes
|
||||
```
|
||||
|
||||
Pontos que importam:
|
||||
|
||||
* **Sessao real.** `record.py` abre um pty, faz SSH na VM e digita os comandos um
|
||||
caractere por vez (~34 c/s, com jitter). O `bash --noediting` evita que o
|
||||
editor de linha redesenhe o comando e o tty o repita.
|
||||
* **Pacing.** A gravacao segura a tela enquanto a voz fala: cada batida dura no
|
||||
maximo o trecho de audio que a acompanha (o maior entre EN e PT), para uma
|
||||
unica captura servir as duas versoes.
|
||||
* **Legendas.** O texto da narracao e queimado no video, na lingua da trilha.
|
||||
* **Diagrama.** Nas fases 0, 4 e 5 o mapa do lab e desenhado sobre o terminal, e o
|
||||
caminho em discussao fica destacado (eth1/eth2/eth3).
|
||||
|
||||
Requisitos: `ffmpeg`, `python3` com `PIL` e `pyte`, a fonte DejaVu, a chave
|
||||
`MINIMAX_API_KEY` no ambiente, e o lab de VMs no ar.
|
||||
|
||||
## Onde ficam os arquivos
|
||||
|
||||
| Caminho | Conteudo |
|
||||
|---------|----------|
|
||||
| `out/screencast/en/`, `out/screencast/pt/` | os videos finais |
|
||||
| `out/screencast/caps/` | as capturas asciicast das sessoes |
|
||||
| `out/screencast/vo/` | o audio gerado pelo MiniMax |
|
||||
| `scripts/screencast/plans/` | o roteiro de comandos de cada fase |
|
||||
| `scripts/screencast/narration/` | os textos da narracao (EN e PT) |
|
||||
|
||||
## Texto de descricao (YouTube)
|
||||
|
||||
@@ -49,17 +101,18 @@ suite verifica.
|
||||
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.
|
||||
empacotado como modulo (pf.ko) e controlado 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.
|
||||
O lab e real: 4 VMs Alpine sob libvirt, kernel compilado do fonte com o modulo,
|
||||
trafego real entre cliente, firewall e servidor.
|
||||
|
||||
Fases:
|
||||
0. O ambiente de testes - as 4 VMs e o diagrama de rede
|
||||
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
|
||||
4. Roteamento - route-to e reply-to, com o caminho real no diagrama
|
||||
5. Balanceamento - pools round-robin, source-hash e dup-to
|
||||
6. QOS - filas hfsc do OpenBSD 8.0 no caminho de dados
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Assemble the final MP4s: silent video track + MiniMax narration + subtitles.
|
||||
|
||||
Each narration segment was recorded as its own audio file, generated by
|
||||
MiniMax with a female voice for the language of the track. The segments are
|
||||
placed at the moment their beat starts on screen, so the voice, the caption
|
||||
and the terminal action always agree.
|
||||
|
||||
./scripts/screencast/assemble.py --phase 04-roteamento --lang en
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HERE))
|
||||
CAPS = os.path.join(REPO, "out", "screencast", "caps")
|
||||
VO = os.path.join(REPO, "out", "screencast", "vo")
|
||||
OUT = os.path.join(REPO, "out", "screencast")
|
||||
ART = os.path.join(OUT, "art")
|
||||
|
||||
# which phases draw the lab diagram over the terminal
|
||||
DIAGRAM_PHASES = {"00-ambiente", "04-roteamento", "05-balanceamento"}
|
||||
# phases that show the module/host instead of the firewall VM
|
||||
HOST_PHASES = {"01-implementacao"}
|
||||
|
||||
PHASE_ORDER = ["00-ambiente", "01-implementacao", "02-ativacao", "03-regras",
|
||||
"04-roteamento", "05-balanceamento", "06-qos"]
|
||||
|
||||
|
||||
def run(cmd):
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def probe(path):
|
||||
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
|
||||
"format=duration", "-of", "csv=p=0", path],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
return float(out or 0)
|
||||
|
||||
|
||||
def audio_duration(path):
|
||||
return probe(path)
|
||||
|
||||
|
||||
def make_video(phase, lang):
|
||||
"""Render the silent video (terminal + diagram + burned-in subtitles)."""
|
||||
cast = os.path.join(CAPS, f"{phase}.cast")
|
||||
nar = os.path.join(HERE, "narration", f"{phase}.{lang}.json")
|
||||
if not os.path.exists(cast):
|
||||
raise SystemExit(f"[assemble] missing capture: {cast}")
|
||||
art = None
|
||||
if os.path.isdir(ART):
|
||||
imgs = sorted(f for f in os.listdir(ART) if f.endswith((".jpg", ".png")))
|
||||
if imgs:
|
||||
art = os.path.join(ART, imgs[0])
|
||||
outdir = os.path.join(OUT, lang)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
silent = os.path.join(outdir, f"{phase}.silent.mp4")
|
||||
index = PHASE_ORDER.index(phase) + 1
|
||||
cmd = [sys.executable, os.path.join(HERE, "render_video.py"),
|
||||
"--cast", cast, "--narration", nar, "--out", silent,
|
||||
"--lang", lang, "--phase-no", str(index),
|
||||
"--phase-total", str(len(PHASE_ORDER))]
|
||||
if phase in DIAGRAM_PHASES:
|
||||
cmd.append("--diagram")
|
||||
if art:
|
||||
cmd += ["--art", art]
|
||||
run(cmd)
|
||||
return silent
|
||||
|
||||
|
||||
def mux_audio(phase, lang, silent):
|
||||
"""Lay every narration segment on the timeline and add it to the video."""
|
||||
meta = json.load(open(os.path.join(CAPS, f"{phase}.json")))
|
||||
beats = {b["id"]: b for b in meta["beats"]}
|
||||
nar = json.load(open(os.path.join(HERE, "narration", f"{phase}.{lang}.json")))
|
||||
vodir = os.path.join(VO, f"{phase}.{lang}")
|
||||
|
||||
segments = []
|
||||
for i, seg in enumerate(nar["segments"]):
|
||||
beat = beats.get(seg["beat"])
|
||||
audio = os.path.join(vodir, f"{i:02d}-{seg['beat']}.mp3")
|
||||
if beat is None or not os.path.exists(audio):
|
||||
sys.stderr.write(f"[assemble] missing audio: {audio}\n")
|
||||
continue
|
||||
segments.append((beat["start"], audio))
|
||||
|
||||
tmp = os.path.join(OUT, "_tmp")
|
||||
os.makedirs(tmp, exist_ok=True)
|
||||
if not segments:
|
||||
out = os.path.join(tmp, f"{phase}.{lang}.m4v")
|
||||
run(["ffmpeg", "-y", "-loglevel", "error", "-i", silent,
|
||||
"-c:v", "copy", "-an", out])
|
||||
return out
|
||||
|
||||
vdur = probe(silent)
|
||||
inputs = []
|
||||
filters = []
|
||||
# input 0 is the video; the narration files are inputs 1..N
|
||||
for k, (start, audio) in enumerate(segments):
|
||||
inputs += ["-i", audio]
|
||||
delay = int(start * 1000)
|
||||
# pad so every segment reaches the end of the video, then the mix is
|
||||
# trimmed to the video length
|
||||
filters.append(f"[{k + 1}:a]adelay={delay}|{delay},apad[aud{k}]")
|
||||
mix_in = "".join(f"[aud{k}]" for k in range(len(segments)))
|
||||
filters.append(f"{mix_in}amix=inputs={len(segments)}:normalize=0,"
|
||||
f"atrim=0:{vdur:.3f},asetpts=N/SR/TB[mixed]")
|
||||
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-i", silent] + inputs + \
|
||||
["-filter_complex", ";".join(filters),
|
||||
"-map", "0:v", "-map", "[mixed]", "-c:v", "copy", "-c:a", "aac",
|
||||
"-b:a", "160k", "-ar", "44100", "-ac", "2",
|
||||
os.path.join(tmp, f"{phase}.{lang}.m4v")]
|
||||
run(cmd)
|
||||
return os.path.join(tmp, f"{phase}.{lang}.m4v")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--phase", required=True)
|
||||
ap.add_argument("--lang", required=True, choices=["en", "pt"])
|
||||
ap.add_argument("--keep-silent", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
phase, lang = args.phase, args.lang
|
||||
silent = make_video(phase, lang)
|
||||
muxed = mux_audio(phase, lang, silent)
|
||||
|
||||
outdir = os.path.join(OUT, lang)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
final = os.path.join(outdir, f"{phase}.mp4")
|
||||
run(["ffmpeg", "-y", "-loglevel", "error", "-i", muxed,
|
||||
"-c", "copy", "-movflags", "+faststart", final])
|
||||
if not args.keep_silent:
|
||||
os.remove(silent)
|
||||
os.remove(muxed)
|
||||
|
||||
dur = probe(final)
|
||||
size = os.path.getsize(final) / 1e6
|
||||
print(f"[assemble] {final} {dur:.1f}s {size:.1f}MB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build every screencast: capture -> narration -> video -> full movie.
|
||||
#
|
||||
# ./scripts/screencast/build-all.sh # everything
|
||||
# ./scripts/screencast/build-all.sh 04-roteamento
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
REPO=$(pwd)
|
||||
|
||||
PHASES=("$@")
|
||||
[ ${#PHASES[@]} -eq 0 ] && PHASES=(00-ambiente 01-implementacao 02-ativacao 03-regras \
|
||||
04-roteamento 05-balanceamento 06-qos)
|
||||
|
||||
echo "== narration audio =="
|
||||
for lang in en pt; do
|
||||
python3 scripts/screencast/make_audio.py --lang "$lang" | tail -1
|
||||
done
|
||||
|
||||
echo "== sessions =="
|
||||
for p in "${PHASES[@]}"; do
|
||||
# the activation phase shows the module being loaded, so it is the one
|
||||
# that must not have it preloaded
|
||||
extra=""
|
||||
[ "$p" = "02-ativacao" ] || extra="--ensure-pf"
|
||||
python3 scripts/screencast/record.py --plan "scripts/screencast/plans/$p.json" \
|
||||
--out "out/screencast/caps/$p.cast" --pace $extra
|
||||
done
|
||||
|
||||
echo "== videos =="
|
||||
for lang in en pt; do
|
||||
for p in "${PHASES[@]}"; do
|
||||
python3 scripts/screencast/assemble.py --phase "$p" --lang "$lang" || echo "FALHOU: $p $lang"
|
||||
done
|
||||
done
|
||||
|
||||
echo "== full movie =="
|
||||
for lang in en pt; do
|
||||
python3 scripts/screencast/join.py --lang "$lang" || echo "FALHOU: join $lang"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "== resultado =="
|
||||
for f in out/screencast/en/*.mp4 out/screencast/pt/*.mp4; do
|
||||
[ -e "$f" ] || continue
|
||||
d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
|
||||
a=$(ffprobe -v error -select_streams a -show_entries stream=codec_name -of csv=p=0 "$f")
|
||||
printf '%-52s %6.1fs audio=%s\n' "$f" "$d" "${a:-none}"
|
||||
done
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Join the phases into one continuous video per language, with transitions.
|
||||
|
||||
Each phase fades into a short "chapter" card that names it, and the card
|
||||
fades into the next phase - one continuous stream, no hard cuts, so the
|
||||
viewer always knows where they are.
|
||||
|
||||
./scripts/screencast/join.py --lang en
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HERE))
|
||||
OUT = os.path.join(REPO, "out", "screencast")
|
||||
ART = os.path.join(OUT, "art")
|
||||
|
||||
PHASE_ORDER = ["00-ambiente", "01-implementacao", "02-ativacao", "03-regras",
|
||||
"04-roteamento", "05-balanceamento", "06-qos"]
|
||||
|
||||
TITLES = {
|
||||
"00-ambiente": ("The lab environment", "O ambiente de testes",
|
||||
"Four Alpine VMs under libvirt", "Quatro VMs Alpine sob libvirt"),
|
||||
"01-implementacao": ("Implementation in the kernel", "Implementacao no kernel",
|
||||
"OpenBSD 8.0 sources + a Linux compatibility layer",
|
||||
"Fontes do OpenBSD 8.0 + camada de compatibilidade Linux"),
|
||||
"02-ativacao": ("Activation", "Ativacao",
|
||||
"insmod pf.ko, then pfctl over the OpenBSD ioctl ABI",
|
||||
"insmod pf.ko e o pfctl pela ABI de ioctls do OpenBSD"),
|
||||
"03-regras": ("Basic rules", "Regras basicas",
|
||||
"Tables, anchors, keep state, block drop",
|
||||
"Tabelas, anchors, keep state, block drop"),
|
||||
"04-roteamento": ("Routing", "Roteamento",
|
||||
"route-to and reply-to", "route-to e reply-to"),
|
||||
"05-balanceamento": ("Load balancing", "Balanceamento",
|
||||
"Pools round-robin, source-hash, dup-to",
|
||||
"Pools round-robin, source-hash, dup-to"),
|
||||
"06-qos": ("Quality of service", "Qualidade de servico",
|
||||
"hfsc queues in the data path", "Filas hfsc no caminho de dados"),
|
||||
}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def probe(path):
|
||||
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
|
||||
"format=duration", "-of", "csv=p=0", path],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
return float(out or 0)
|
||||
|
||||
|
||||
def chapter_card(phase, lang, out_path, seconds=1.8):
|
||||
"""A short card naming the phase; it is the pivot of the crossfade."""
|
||||
title = TITLES[phase][0 if lang == "en" else 1]
|
||||
sub = TITLES[phase][2 if lang == "en" else 3]
|
||||
num = PHASE_ORDER.index(phase) + 1
|
||||
art = None
|
||||
if os.path.isdir(ART):
|
||||
imgs = sorted(f for f in os.listdir(ART) if f.endswith((".jpg", ".png")))
|
||||
if imgs:
|
||||
art = os.path.join(ART, imgs[0])
|
||||
|
||||
script = os.path.join(OUT, "_chapter.py")
|
||||
with open(script, "w") as f:
|
||||
f.write(f"""
|
||||
import os, sys
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
W, H = 1920, 1080
|
||||
BG = (12, 16, 22); ACCENT = (88, 168, 255); FG = (240, 246, 252); MUTED = (140, 150, 165)
|
||||
mono = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf"
|
||||
sans = "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf"
|
||||
sansb = "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf"
|
||||
art = {art!r}
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
if art and os.path.exists(art):
|
||||
img = Image.blend(img, Image.open(art).resize((W, H)).convert("RGB"), 0.5)
|
||||
d = ImageDraw.Draw(img, "RGBA")
|
||||
d.rectangle([0, 0, W, 8], fill=ACCENT)
|
||||
d.text((140, 430), {title!r}, font=ImageFont.truetype(sansb, 62), fill=FG)
|
||||
d.text((140, 520), {sub!r}, font=ImageFont.truetype(sans, 30), fill=MUTED)
|
||||
d.text((140, 586), "PHASE %d OF %d" % ({num}, {len(PHASE_ORDER)}),
|
||||
font=ImageFont.truetype(mono, 24), fill=ACCENT)
|
||||
d.text((140, 640), "PF on Linux - OpenBSD PF 8.0 no kernel Linux",
|
||||
font=ImageFont.truetype(mono, 24), fill=MUTED)
|
||||
img.save(sys.argv[1])
|
||||
""")
|
||||
png = out_path + ".png"
|
||||
run([sys.executable, script, png])
|
||||
have = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True).stdout
|
||||
venc = (["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-profile:v", "high"]
|
||||
if "libx264" in have else
|
||||
["-c:v", "libopenh264", "-b:v", "6M", "-profile:v", "high"])
|
||||
run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1", "-i", png,
|
||||
"-t", str(seconds), "-r", "30"] + venc + ["-pix_fmt", "yuv420p", out_path])
|
||||
os.remove(png)
|
||||
os.remove(script)
|
||||
return out_path
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--lang", required=True, choices=["en", "pt"])
|
||||
ap.add_argument("--out")
|
||||
ap.add_argument("--fade", type=float, default=0.7)
|
||||
args = ap.parse_args()
|
||||
|
||||
lang = args.lang
|
||||
tmp = os.path.join(OUT, "_tmp")
|
||||
os.makedirs(tmp, exist_ok=True)
|
||||
|
||||
segs = [] # (path, duration)
|
||||
for phase in PHASE_ORDER:
|
||||
src = os.path.join(OUT, lang, f"{phase}.mp4")
|
||||
if not os.path.exists(src):
|
||||
sys.stderr.write(f"[join] missing {src}, skipping\n")
|
||||
continue
|
||||
segs.append((src, probe(src)))
|
||||
card = chapter_card(phase, lang, os.path.join(tmp, f"card-{phase}.mp4"))
|
||||
segs.append((card, probe(card)))
|
||||
|
||||
if not segs:
|
||||
raise SystemExit("[join] nothing to join")
|
||||
|
||||
inputs = []
|
||||
for path, _ in segs:
|
||||
inputs += ["-i", path]
|
||||
|
||||
# sequential xfade: each segment fades into the next
|
||||
filters = []
|
||||
prev = "0:v"
|
||||
offset = segs[0][1] - args.fade
|
||||
for i in range(1, len(segs)):
|
||||
filters.append(f"[{prev}][{i}:v]xfade=transition=fade:"
|
||||
f"duration={args.fade}:offset={offset:.3f}[v{i}]")
|
||||
prev = f"v{i}"
|
||||
offset += segs[i][1] - args.fade
|
||||
|
||||
# The audio has to follow the same timeline: with xfade the segments
|
||||
# overlap by the fade duration, so each one starts `fade` earlier than
|
||||
# the plain sum would suggest.
|
||||
audio_at = []
|
||||
acc = 0.0
|
||||
for i, (_, dur) in enumerate(segs):
|
||||
audio_at.append(max(0.0, acc - args.fade * i))
|
||||
acc += dur
|
||||
|
||||
# Only the phase videos carry narration; the chapter cards are silent.
|
||||
# Their input indices are the even ones in `inputs`.
|
||||
phase_idx = [i for i in range(len(segs)) if i % 2 == 0]
|
||||
afilters = []
|
||||
labels = []
|
||||
for k, i in enumerate(phase_idx):
|
||||
ms = int(audio_at[i] * 1000)
|
||||
afilters.append(f"[{i}:a]adelay={ms}|{ms}[a{k}]")
|
||||
labels.append(f"[a{k}]")
|
||||
afilters.append("".join(labels) +
|
||||
f"amix=inputs={len(phase_idx)}:normalize=0,"
|
||||
f"atrim=0:{audio_at[-1] + segs[-1][1]:.3f},"
|
||||
f"asetpts=N/SR/TB[amix]")
|
||||
filters += afilters
|
||||
amap = "[amix]"
|
||||
|
||||
out = args.out or os.path.join(OUT, lang, "pf-on-linux-full.mp4")
|
||||
have = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True).stdout
|
||||
if "libx264" in have:
|
||||
venc = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
||||
"-profile:v", "high", "-level", "4.0"]
|
||||
elif "libopenh264" in have:
|
||||
venc = ["-c:v", "libopenh264", "-b:v", "6M", "-profile:v", "high"]
|
||||
else:
|
||||
venc = ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30", "-row-mt", "1",
|
||||
"-cpu-used", "4"]
|
||||
# the phases already carry their narration; the cards are silent
|
||||
run(["ffmpeg", "-y", "-loglevel", "error"] + inputs +
|
||||
["-filter_complex", ";".join(filters), "-map", f"[{prev}]",
|
||||
"-map", amap] + venc +
|
||||
["-pix_fmt", "yuv420p", "-movflags", "+faststart", out])
|
||||
print(f"[join] {out} {probe(out):.1f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate the narration audio for every screencast phase with MiniMax TTS.
|
||||
|
||||
Female voices, one per language:
|
||||
en -> English_ConfidentWoman
|
||||
pt -> Portuguese_ConfidentWoman
|
||||
|
||||
Each *segment* of a narration script becomes its own audio file, so the
|
||||
renderer can start the voice exactly when the matching beat appears on
|
||||
screen and burn the same sentence as a subtitle.
|
||||
|
||||
./scripts/screencast/make_audio.py --lang en
|
||||
./scripts/screencast/make_audio.py --lang pt --phase 04-roteamento
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
NARR = os.path.join(HERE, "narration")
|
||||
VO_DIR = os.path.join(HERE, "..", "..", "out", "screencast", "vo")
|
||||
|
||||
VOICES = {
|
||||
"en": {"voice_id": "English_ConfidentWoman", "language_boost": "English"},
|
||||
"pt": {"voice_id": "Portuguese_ConfidentWoman", "language_boost": "Portuguese"},
|
||||
}
|
||||
|
||||
# MiniMax occasionally stumbles on the punctuation of long sentences; keeping
|
||||
# the text plain (no dashes, no slashes) makes the prosody predictable.
|
||||
MODEL = "speech-02-hd"
|
||||
API = "https://api.minimax.io/v1/t2a_v2"
|
||||
|
||||
|
||||
def decode_audio(payload):
|
||||
"""MiniMax returns the audio as a hex string (it starts with the ASCII
|
||||
bytes of the ID3 header, 49443304). Older/other endpoints use base64,
|
||||
so both are handled."""
|
||||
if len(payload) > 8 and all(c in "0123456789abcdefABCDEF" for c in payload[:64]):
|
||||
return bytes.fromhex(payload)
|
||||
data = payload.replace("-", "+").replace("_", "/")
|
||||
data += "=" * (-len(data) % 4)
|
||||
return base64.b64decode(data, validate=False)
|
||||
|
||||
|
||||
def tts(text, voice, lang, out_path, retries=3):
|
||||
body = {
|
||||
"model": MODEL,
|
||||
"text": text,
|
||||
"stream": False,
|
||||
"voice_setting": {
|
||||
"voice_id": voice["voice_id"],
|
||||
"speed": 0.97, "vol": 1.0, "pitch": 0, "emotion": "neutral",
|
||||
},
|
||||
"audio_setting": {"sample_rate": 44100, "bitrate": 128000,
|
||||
"format": "mp3", "channel": 1},
|
||||
"language_boost": voice["language_boost"],
|
||||
}
|
||||
key = os.environ.get("MINIMAX_API_KEY")
|
||||
if not key:
|
||||
raise SystemExit("MINIMAX_API_KEY is not set")
|
||||
for attempt in range(retries):
|
||||
req = urllib.request.Request(
|
||||
API, data=json.dumps(body).encode(),
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
data = json.loads(r.read())
|
||||
if data.get("base_resp", {}).get("status_code") != 0:
|
||||
raise RuntimeError(str(data.get("base_resp")))
|
||||
audio = data.get("data", {}).get("audio")
|
||||
if not audio:
|
||||
raise RuntimeError("no audio in response")
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(decode_audio(audio))
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
sys.stderr.write(f"[tts] attempt {attempt + 1} failed: {exc}\n")
|
||||
time.sleep(2 + attempt * 3)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--lang", required=True, choices=["en", "pt"])
|
||||
ap.add_argument("--phase", help="only this phase")
|
||||
ap.add_argument("--force", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
voice = VOICES[args.lang]
|
||||
files = sorted(f for f in os.listdir(NARR) if f.endswith(f".{args.lang}.json"))
|
||||
if args.phase:
|
||||
files = [f for f in files if f.startswith(args.phase + ".")]
|
||||
|
||||
total = 0
|
||||
for fn in files:
|
||||
phase = fn.split(".")[0]
|
||||
script = json.load(open(os.path.join(NARR, fn)))
|
||||
outdir = os.path.abspath(os.path.join(VO_DIR, f"{phase}.{args.lang}"))
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
for i, seg in enumerate(script["segments"]):
|
||||
out = os.path.join(outdir, f"{i:02d}-{seg['beat']}.mp3")
|
||||
if os.path.exists(out) and not args.force:
|
||||
print(f"skip {phase}.{args.lang} seg{i} ({seg['beat']})")
|
||||
total += 1
|
||||
continue
|
||||
ok = tts(seg["text"], voice, args.lang, out)
|
||||
size = os.path.getsize(out) if os.path.exists(out) else 0
|
||||
print(f"{'ok ' if ok else 'FAIL'} {phase}.{args.lang} seg{i} "
|
||||
f"({seg['beat']}) {size} bytes")
|
||||
total += 1
|
||||
print(f"[audio] {total} segments processed for lang={args.lang}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Write the narration scripts for every screencast phase, in English and in
|
||||
Brazilian Portuguese.
|
||||
|
||||
Each segment belongs to one *beat* of the recorded session (see
|
||||
scripts/screencast/plans/*.json). The renderer places the segment's audio at
|
||||
the moment that beat starts on screen, and burns the same text as a subtitle,
|
||||
so the voice, the caption and the terminal action always agree.
|
||||
|
||||
A segment may also carry:
|
||||
highlight - nodes of the lab diagram to light up while it is spoken
|
||||
diagram_caption - one-line caption shown inside the diagram panel
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "narration")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
EN_VOICE = "English_ConfidentWoman"
|
||||
PT_VOICE = "Portuguese_ConfidentWoman"
|
||||
|
||||
PHASES = {}
|
||||
|
||||
# ------------------------------------------------------------------ 00 ----
|
||||
PHASES["00-ambiente"] = {
|
||||
"en": {
|
||||
"title": "The lab environment",
|
||||
"subtitle": "Four Alpine VMs under libvirt, running a kernel built from source",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Welcome to the PF on Linux demonstration. Before we look at the firewall itself, let me show you the environment where everything runs. These are four virtual machines managed by libvirt, all of them Alpine Linux, and the machine you are looking at is the firewall: a kernel we compiled from source, version seven point three point four, with our port of the OpenBSD packet filter built in as a module."},
|
||||
{"beat": "b2", "text": "The firewall has four interfaces. The management interface on the virtual network the hypervisor uses to reach us. Then three transit networks: ten point zero point one, where the client lives; ten point zero point two, where the server lives; and ten point zero point three, a second gateway we use to demonstrate routing decisions.", "highlight": ["cli", "pf0", "srv", "gw2"], "diagram_caption": "three transit networks, four interfaces"},
|
||||
{"beat": "b3", "text": "The OpenBSD tools are installed on this machine exactly as they ship: the original pfctl, a pflog daemon and our divert tool. The module is not loaded yet, which is where we will start.", "highlight": ["pf0"], "diagram_caption": "pfctl, pflogd and pdivert, straight from OpenBSD"},
|
||||
{"beat": "b4", "text": "And from the firewall we can reach the other machines directly. The client is on ten point zero point one point ten, the server on ten point zero point two point twenty, and the second gateway on ten point zero point three point two. Every command in this demonstration is real traffic crossing real interfaces.", "highlight": ["cli", "pf0", "srv", "gw2"], "diagram_caption": "cli, srv and gw2 reachable from pf0"},
|
||||
],
|
||||
},
|
||||
"pt": {
|
||||
"title": "O ambiente de testes",
|
||||
"subtitle": "Quatro VMs Alpine sob libvirt, rodando um kernel compilado do fonte",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Bem-vindo à demonstração do PF on Linux. Antes de falar do firewall em si, deixa eu mostrar o ambiente onde tudo acontece. São quatro máquinas virtuais administradas pelo libvirt, todas com Alpine Linux, e a máquina que você está vendo é o firewall: um kernel que compilamos do fonte, versão sete ponto três ponto quatro, com o nosso port do packet filter do OpenBSD compilado dentro como módulo."},
|
||||
{"beat": "b2", "text": "O firewall tem quatro interfaces. A interface de gerência, na rede virtual que o hypervisor usa para falar com a gente. E depois três redes de trânsito: dez ponto zero ponto um, onde mora o cliente; dez ponto zero ponto dois, onde mora o servidor; e dez ponto zero ponto três, um segundo gateway que vamos usar para demonstrar decisões de roteamento.", "highlight": ["cli", "pf0", "srv", "gw2"], "diagram_caption": "três redes de transito, quatro interfaces"},
|
||||
{"beat": "b3", "text": "As ferramentas do OpenBSD estão instaladas nesta máquina exatamente como são distribuídas: o pfctl original, um daemon de pflog e a nossa ferramenta de divert. O módulo ainda não está carregado, e é por aí que a gente começa.", "highlight": ["pf0"], "diagram_caption": "pfctl, pflogd e pdivert originais do OpenBSD"},
|
||||
{"beat": "b4", "text": "E do firewall a gente alcança as outras máquinas diretamente. O cliente está em dez ponto zero ponto um ponto dez, o servidor em dez ponto zero ponto dois ponto vinte, e o segundo gateway em dez ponto zero ponto três ponto dois. Todo comando desta demonstração é tráfego real cruzando interfaces reais.", "highlight": ["cli", "pf0", "srv", "gw2"], "diagram_caption": "cli, srv e gw2 alcançáveis a partir do pf0"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 01 ----
|
||||
PHASES["01-implementacao"] = {
|
||||
"en": {
|
||||
"title": "Implementation in the kernel",
|
||||
"subtitle": "OpenBSD 8.0 sources plus a Linux compatibility layer equals pf.ko",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "This is the heart of the work. We did not rewrite the firewall. We took the OpenBSD eight point zero sources, nearly twenty seven thousand lines of C, and we kept them almost intact. On top of that we wrote a compatibility layer, about seven thousand lines, that translates the BSD kernel interfaces into Linux ones."},
|
||||
{"beat": "b2", "text": "The result is a normal Linux kernel module. Here modinfo shows us the license, the description and the vermagic, which tells us exactly which kernel this object was compiled against."},
|
||||
{"beat": "b3", "text": "The vermagic line matters: it is the kernel version, the compiler flags and the module signature. Load the wrong one and the kernel refuses it. Everything you will see in the next minutes is running inside that module."},
|
||||
{"beat": "b4", "text": "This file is what we load into the firewall. A single artifact, built out of tree, carrying the entire OpenBSD packet filter."},
|
||||
],
|
||||
},
|
||||
"pt": {
|
||||
"title": "Implementação no kernel",
|
||||
"subtitle": "Fontes do OpenBSD 8.0 mais uma camada de compatibilidade Linux resultam em pf.ko",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Aqui está o coração do trabalho. A gente não reescreveu o firewall. Pegamos as fontes do OpenBSD oito ponto zero, quase vinte e sete mil linhas de C, e mantivemos praticamente intactas. Em cima disso escrevemos uma camada de compatibilidade, cerca de sete mil linhas, que traduz as interfaces de kernel do BSD para as do Linux."},
|
||||
{"beat": "b2", "text": "O resultado é um módulo de kernel Linux comum. Aqui o modinfo mostra a licença, a descrição e o vermagic, que nos diz exatamente com qual kernel este objeto foi compilado."},
|
||||
{"beat": "b3", "text": "A linha do vermagic importa: é a versão do kernel, as flags do compilador e a assinatura do módulo. Carregar o arquivo errado e o kernel recusa. Tudo que você vai ver nos próximos minutos roda dentro desse módulo."},
|
||||
{"beat": "b4", "text": "Este é o arquivo que carregamos no firewall. Um único artefato, compilado fora da árvore do kernel, carregando o packet filter inteiro do OpenBSD."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 02 ----
|
||||
PHASES["02-ativacao"] = {
|
||||
"en": {
|
||||
"title": "Activation",
|
||||
"subtitle": "insmod pf.ko, then pfctl talks to it over the OpenBSD ioctl ABI",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "First we make sure nothing is loaded, so the activation is honest. No module, no firewall."},
|
||||
{"beat": "b2", "text": "Now we insert it. The kernel accepts the module and the log shows our initialization message. This is the OpenBSD engine running inside Linux."},
|
||||
{"beat": "b3", "text": "The device appears, owned by root with mode six hundred, exactly like the BSD original, and the module shows up in the list of loaded modules."},
|
||||
{"beat": "b4", "text": "pfctl is the original OpenBSD tool. We enable the firewall and ask for its status. The ioctl interface, the statistics table, the rule counters, all of it is the OpenBSD ABI, unchanged."},
|
||||
{"beat": "b5", "text": "The ruleset is still empty, and that is exactly right: with no rules, pf blocks everything and returns. The engine is armed and waiting for instructions."},
|
||||
],
|
||||
},
|
||||
"pt": {
|
||||
"title": "Ativação",
|
||||
"subtitle": "insmod pf.ko e o pfctl conversa com ele pela ABI de ioctls do OpenBSD",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Primeiro a gente garante que não tem nada carregado, para a ativação ser honesta. Sem módulo, sem firewall."},
|
||||
{"beat": "b2", "text": "Agora inserimos. O kernel aceita o módulo e o log mostra a nossa mensagem de inicialização. Este é o motor do OpenBSD rodando dentro do Linux."},
|
||||
{"beat": "b3", "text": "O dispositivo aparece, do root, com modo seiscentos, exatamente como no BSD original, e o módulo aparece na lista de módulos carregados."},
|
||||
{"beat": "b4", "text": "O pfctl é a ferramenta original do OpenBSD. Habilitamos o firewall e pedimos o status. A interface de ioctl, a tabela de estatísticas, os contadores das regras: tudo é a ABI do OpenBSD, sem alteração."},
|
||||
{"beat": "b5", "text": "O ruleset ainda está vazio, e isso está certo: sem regras, o pf bloqueia tudo e devolve. O motor está armado e esperando instruções."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 03 ----
|
||||
PHASES["03-regras"] = {
|
||||
"en": {
|
||||
"title": "Basic rules",
|
||||
"subtitle": "Tables, anchors, keep state and block drop",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Now we write a real pf dot conf. This is the original OpenBSD syntax: a table with the trusted network, an anchor, and the usual quick rules with keep state. pfctl parses it and the kernel accepts it."},
|
||||
{"beat": "b2", "text": "pfctl shows us what the kernel understood: the rules as they were compiled, the list of tables, and the contents of the trusted table."},
|
||||
{"beat": "b3", "text": "Let the traffic through. From the client we ping the server, and the firewall creates a state. You can see it in the state table: the five tuple, the timeout, the counters."},
|
||||
{"beat": "b4", "text": "Now the same thing with a drop. We replace the pass rule with a block drop rule for the client network, and reload."},
|
||||
{"beat": "b5", "text": "The ping is dead. One hundred percent packet loss. And the rule counters show the packets that were evaluated and the ones that were blocked. This is a stateful firewall behaving exactly like the original."},
|
||||
{"beat": "b6", "text": "Finally, anchors. We load a ruleset into the anchor called demo, and pfctl lists the rules that live inside it, separately from the main ruleset."},
|
||||
],
|
||||
},
|
||||
"pt": {
|
||||
"title": "Regras básicas",
|
||||
"subtitle": "Tabelas, anchors, keep state e block drop",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Agora escrevemos um pf ponto conf de verdade. Esta é a sintaxe original do OpenBSD: uma tabela com a rede confiável, um anchor, e as regras quick de sempre com keep state. O pfctl faz o parsing e o kernel aceita."},
|
||||
{"beat": "b2", "text": "O pfctl nos mostra o que o kernel entendeu: as regras como foram compiladas, a lista de tabelas e o conteúdo da tabela trusted."},
|
||||
{"beat": "b3", "text": "Deixa o tráfego passar. Do cliente a gente dá ping no servidor, e o firewall cria um estado. Você vê isso na tabela de estados: a tupla de cinco campos, o timeout, os contadores."},
|
||||
{"beat": "b4", "text": "Agora a mesma coisa com um drop. Trocamos a regra de pass por uma regra block drop para a rede do cliente, e recarregamos."},
|
||||
{"beat": "b5", "text": "O ping morreu. Cem por cento de perda. E os contadores da regra mostram os pacotes avaliados e os bloqueados. Este é um firewall stateful se comportando exatamente como o original."},
|
||||
{"beat": "b6", "text": "Por fim, os anchors. Carregamos um ruleset no anchor chamado demo, e o pfctl lista as regras que moram dentro dele, separadamente do ruleset principal."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 04 ----
|
||||
PHASES["04-roteamento"] = {
|
||||
"en": {
|
||||
"title": "Routing",
|
||||
"subtitle": "route-to forces the next hop, reply-to forces the way back",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Now the routing features. This rule matches traffic from the client to the server and sends it through the second gateway, ten point zero point three point two, instead of the normal path. pfctl shows the rule exactly as the kernel stored it.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "route-to: normal path is eth2, forced path is eth3"},
|
||||
{"beat": "b2", "text": "To prove where the packet actually went, we capture on both interfaces at the same time: eth three, towards the gateway, and eth two, towards the server."},
|
||||
{"beat": "b3", "text": "And now the client pings the server.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "the request leaves through eth3, to the gateway"},
|
||||
{"beat": "b4", "text": "The evidence is in the captures. The request appears on eth three, on the way to the gateway, and it never appears on eth two. The firewall decided the next hop, not the routing table.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "1 request on eth3, 0 on eth2"},
|
||||
{"beat": "b5", "text": "The other half of the problem is the way back. reply to forces the reply of a flow to leave through a different gateway than the request came in, which is how you build asymmetric routing.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "reply-to: the answer leaves through another gateway"},
|
||||
{"beat": "b6", "text": "We capture eth three again and also eth one, the link where the client is."},
|
||||
{"beat": "b7", "text": "The replies show up on eth three, on the gateway link, and not on the client link. The request and the reply now take different paths, exactly as commanded.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "reply on eth3, nothing on the client link"},
|
||||
],
|
||||
},
|
||||
"pt": {
|
||||
"title": "Roteamento",
|
||||
"subtitle": "route-to força o próximo salto, reply-to força o caminho de volta",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Agora as funcionalidades de roteamento. Esta regra casa com o tráfego do cliente para o servidor e o manda pelo segundo gateway, dez ponto zero ponto três ponto dois, em vez do caminho normal. O pfctl mostra a regra exatamente como o kernel guardou.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "route-to: o caminho normal e' eth2; o forcado, eth3"},
|
||||
{"beat": "b2", "text": "Para provar por onde o pacote realmente passou, capturamos nas duas interfaces ao mesmo tempo: eth três, rumo ao gateway, e eth dois, rumo ao servidor."},
|
||||
{"beat": "b3", "text": "E agora o cliente dá ping no servidor.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "o pedido sai por eth3, rumo ao gateway"},
|
||||
{"beat": "b4", "text": "A prova está nas capturas. O pedido aparece na eth três, indo para o gateway, e nunca aparece na eth dois. Quem decidiu o próximo salto foi o firewall, não a tabela de rotas.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "1 pedido em eth3, 0 em eth2"},
|
||||
{"beat": "b5", "text": "A outra metade do problema é o caminho de volta. O reply to força a resposta de um fluxo a sair por um gateway diferente do por onde o pedido entrou, que é assim que se constrói roteamento assimétrico.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "reply-to: a resposta sai por um gateway diferente"},
|
||||
{"beat": "b6", "text": "Capturamos a eth três de novo e também a eth um, o link onde está o cliente."},
|
||||
{"beat": "b7", "text": "As respostas aparecem na eth três, no link do gateway, e não no link do cliente. O pedido e a resposta agora seguem caminhos diferentes, exatamente como foi pedido.", "highlight": ["cli", "pf0", "gw2"], "diagram_caption": "resposta em eth3, nada no link do cliente"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 05 ----
|
||||
PHASES["05-balanceamento"] = {
|
||||
"en": {
|
||||
"title": "Load balancing",
|
||||
"subtitle": "NAT pools in round-robin and source-hash, plus dup-to",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "Load balancing in pf is done with pools. This rule translates the source of the client network into a pool of two addresses, the two gateways of the firewall, and selects between them round robin.", "highlight": ["cli", "pf0", "srv"], "diagram_caption": "pool { 10.0.2.1, 10.0.3.1 }: round-robin"},
|
||||
{"beat": "b2", "text": "We capture on the interface towards the server to see which source address each packet carries."},
|
||||
{"beat": "b3", "text": "Six packets from the client."},
|
||||
{"beat": "b4", "text": "And the capture shows the truth: three packets went out with one address, three with the other. The pool is really rotating between its members, and the server sees a balanced load.", "highlight": ["cli", "pf0", "srv"], "diagram_caption": "3 packets with each source address"},
|
||||
{"beat": "b5", "text": "Sometimes you do not want rotation. source hash keeps a given origin always mapped to the same pool member, which is what you want when a session has to be sticky.", "highlight": ["cli", "pf0", "srv"], "diagram_caption": "pool { 10.0.2.1, 10.0.3.1 }: source-hash"},
|
||||
{"beat": "b6", "text": "Four more packets, and now every single one carries the same source address. The mapping is stable per origin.", "highlight": ["cli", "pf0", "srv"], "diagram_caption": "every packet with the same source"},
|
||||
{"beat": "b7", "text": "And dup to is the mirror case: the packet keeps its normal path, and a copy is delivered to the second gateway. We count the requests on both interfaces: one on the normal path, one on the mirrored path. The packet was duplicated, not moved.", "highlight": ["cli", "pf0", "srv", "gw2"], "diagram_caption": "dup-to: the normal path and the mirrored copy"},
|
||||
],
|
||||
},
|
||||
"pt": {
|
||||
"title": "Balanceamento",
|
||||
"subtitle": "Pools de NAT em round-robin e source-hash, mais o dup-to",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "O balanceamento no pf é feito com pools. Esta regra traduz a origem da rede do cliente para um pool de dois endereços, os dois gateways do firewall, e escolhe entre eles em round robin.", "highlight": ["cli", "pf0", "srv"], "diagram_caption": "pool { 10.0.2.1, 10.0.3.1 }: round-robin"},
|
||||
{"beat": "b2", "text": "Capturamos na interface que vai para o servidor para ver qual endereço de origem cada pacote carrega."},
|
||||
{"beat": "b3", "text": "Seis pacotes vindo do cliente."},
|
||||
{"beat": "b4", "text": "E a captura mostra a verdade: três pacotes saíram com um endereço, três com o outro. O pool está realmente rodando entre os membros, e o servidor enxerga a carga balanceada.", "highlight": ["cli", "pf0", "srv"], "diagram_caption": "3 pacotes com cada endereço de origem"},
|
||||
{"beat": "b5", "text": "Às vezes você não quer rotação. O source hash mantém uma origem sempre mapeada para o mesmo membro do pool, que é o que se quer quando a sessão precisa ser sticky.", "highlight": ["cli", "pf0", "srv"], "diagram_caption": "pool { 10.0.2.1, 10.0.3.1 }: source-hash"},
|
||||
{"beat": "b6", "text": "Mais quatro pacotes, e agora todos carregam o mesmo endereço de origem. O mapeamento é estável por origem.", "highlight": ["cli", "pf0", "srv"], "diagram_caption": "todos os pacotes com a mesma origem"},
|
||||
{"beat": "b7", "text": "E o dup to é o caso espelho: o pacote mantém o caminho normal e uma cópia é entregue no segundo gateway. Contamos os pedidos nas duas interfaces: um no caminho normal, um no caminho espelhado. O pacote foi duplicado, não movido.", "highlight": ["cli", "pf0", "srv", "gw2"], "diagram_caption": "dup-to: o caminho normal e a cópia espelhada"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 06 ----
|
||||
PHASES["06-qos"] = {
|
||||
"en": {
|
||||
"title": "Quality of service",
|
||||
"subtitle": "OpenBSD 8.0 queues: hfsc classes attached to the interface",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "The last piece is quality of service. This is the OpenBSD eight point zero queue syntax: a root class with ten megabits of bandwidth, a leaf class with two megabits as the default, and a rule that steers the traffic from the client to that leaf class."},
|
||||
{"beat": "b2", "text": "pfctl shows the queues with their statistics, and the rule set shows the queue attached to the rule."},
|
||||
{"beat": "b3", "text": "Now we move real traffic through the queue. On the server we prepare two megabytes and listen on port ninety ninety five."},
|
||||
{"beat": "b4", "text": "And the client downloads it, through the firewall, through the class."},
|
||||
{"beat": "b5", "text": "Two million bytes transferred, and look at the queue counters: the hfsc class counted the packets and the bytes. The scheduler is in the data path, not just in the configuration."},
|
||||
],
|
||||
},
|
||||
"pt": {
|
||||
"title": "Qualidade de serviço",
|
||||
"subtitle": "Filas do OpenBSD 8.0: classes hfsc anexadas à interface",
|
||||
"segments": [
|
||||
{"beat": "b1", "text": "A última peça é qualidade de serviço. Esta é a sintaxe de filas do OpenBSD oito ponto zero: uma classe raiz com dez megabits de banda, uma classe folha com dois megabits como default, e uma regra que direciona o tráfego do cliente para essa classe folha."},
|
||||
{"beat": "b2", "text": "O pfctl mostra as filas com as estatísticas, e o ruleset mostra a fila anexada à regra."},
|
||||
{"beat": "b3", "text": "Agora movemos tráfego de verdade pela fila. No servidor preparamos dois megabytes e escutamos na porta noventa e noventa e cinco."},
|
||||
{"beat": "b4", "text": "E o cliente baixa isso, passando pelo firewall, passando pela classe."},
|
||||
{"beat": "b5", "text": "Dois milhões de bytes transferidos, e olha os contadores das filas: a classe hfsc contou os pacotes e os bytes. O escalonador está no caminho de dados, não só na configuração."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# the diagram is drawn over these phases
|
||||
DIAGRAM_PHASES = {"00-ambiente", "04-roteamento", "05-balanceamento"}
|
||||
|
||||
for phase, langs in PHASES.items():
|
||||
for lang, data in langs.items():
|
||||
out = {
|
||||
"phase": phase, "lang": lang,
|
||||
"voice": EN_VOICE if lang == "en" else PT_VOICE,
|
||||
"title": data["title"], "subtitle": data["subtitle"],
|
||||
"segments": data["segments"],
|
||||
}
|
||||
with open(os.path.join(OUT, f"{phase}.{lang}.json"), "w") as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=2)
|
||||
print(f"wrote {sum(len(v) for v in PHASES.values())} narration scripts to {OUT}")
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"phase": "00-ambiente",
|
||||
"lang": "en",
|
||||
"voice": "English_ConfidentWoman",
|
||||
"title": "The lab environment",
|
||||
"subtitle": "Four Alpine VMs under libvirt, running a kernel built from source",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Welcome to the PF on Linux demonstration. Before we look at the firewall itself, let me show you the environment where everything runs. These are four virtual machines managed by libvirt, all of them Alpine Linux, and the machine you are looking at is the firewall: a kernel we compiled from source, version seven point three point four, with our port of the OpenBSD packet filter built in as a module."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "The firewall has four interfaces. The management interface on the virtual network the hypervisor uses to reach us. Then three transit networks: ten point zero point one, where the client lives; ten point zero point two, where the server lives; and ten point zero point three, a second gateway we use to demonstrate routing decisions.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "three transit networks, four interfaces"
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "The OpenBSD tools are installed on this machine exactly as they ship: the original pfctl, a pflog daemon and our divert tool. The module is not loaded yet, which is where we will start.",
|
||||
"highlight": [
|
||||
"pf0"
|
||||
],
|
||||
"diagram_caption": "pfctl, pflogd and pdivert, straight from OpenBSD"
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "And from the firewall we can reach the other machines directly. The client is on ten point zero point one point ten, the server on ten point zero point two point twenty, and the second gateway on ten point zero point three point two. Every command in this demonstration is real traffic crossing real interfaces.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "cli, srv and gw2 reachable from pf0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"phase": "00-ambiente",
|
||||
"lang": "pt",
|
||||
"voice": "Portuguese_ConfidentWoman",
|
||||
"title": "O ambiente de testes",
|
||||
"subtitle": "Quatro VMs Alpine sob libvirt, rodando um kernel compilado do fonte",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Bem-vindo à demonstração do PF on Linux. Antes de falar do firewall em si, deixa eu mostrar o ambiente onde tudo acontece. São quatro máquinas virtuais administradas pelo libvirt, todas com Alpine Linux, e a máquina que você está vendo é o firewall: um kernel que compilamos do fonte, versão sete ponto três ponto quatro, com o nosso port do packet filter do OpenBSD compilado dentro como módulo."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "O firewall tem quatro interfaces. A interface de gerência, na rede virtual que o hypervisor usa para falar com a gente. E depois três redes de trânsito: dez ponto zero ponto um, onde mora o cliente; dez ponto zero ponto dois, onde mora o servidor; e dez ponto zero ponto três, um segundo gateway que vamos usar para demonstrar decisões de roteamento.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "três redes de transito, quatro interfaces"
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "As ferramentas do OpenBSD estão instaladas nesta máquina exatamente como são distribuídas: o pfctl original, um daemon de pflog e a nossa ferramenta de divert. O módulo ainda não está carregado, e é por aí que a gente começa.",
|
||||
"highlight": [
|
||||
"pf0"
|
||||
],
|
||||
"diagram_caption": "pfctl, pflogd e pdivert originais do OpenBSD"
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "E do firewall a gente alcança as outras máquinas diretamente. O cliente está em dez ponto zero ponto um ponto dez, o servidor em dez ponto zero ponto dois ponto vinte, e o segundo gateway em dez ponto zero ponto três ponto dois. Todo comando desta demonstração é tráfego real cruzando interfaces reais.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "cli, srv e gw2 alcançáveis a partir do pf0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"phase": "01-implementacao",
|
||||
"lang": "en",
|
||||
"voice": "English_ConfidentWoman",
|
||||
"title": "Implementation in the kernel",
|
||||
"subtitle": "OpenBSD 8.0 sources plus a Linux compatibility layer equals pf.ko",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "This is the heart of the work. We did not rewrite the firewall. We took the OpenBSD eight point zero sources, nearly twenty seven thousand lines of C, and we kept them almost intact. On top of that we wrote a compatibility layer, about seven thousand lines, that translates the BSD kernel interfaces into Linux ones."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "The result is a normal Linux kernel module. Here modinfo shows us the license, the description and the vermagic, which tells us exactly which kernel this object was compiled against."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "The vermagic line matters: it is the kernel version, the compiler flags and the module signature. Load the wrong one and the kernel refuses it. Everything you will see in the next minutes is running inside that module."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "This file is what we load into the firewall. A single artifact, built out of tree, carrying the entire OpenBSD packet filter."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"phase": "01-implementacao",
|
||||
"lang": "pt",
|
||||
"voice": "Portuguese_ConfidentWoman",
|
||||
"title": "Implementação no kernel",
|
||||
"subtitle": "Fontes do OpenBSD 8.0 mais uma camada de compatibilidade Linux resultam em pf.ko",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Aqui está o coração do trabalho. A gente não reescreveu o firewall. Pegamos as fontes do OpenBSD oito ponto zero, quase vinte e sete mil linhas de C, e mantivemos praticamente intactas. Em cima disso escrevemos uma camada de compatibilidade, cerca de sete mil linhas, que traduz as interfaces de kernel do BSD para as do Linux."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "O resultado é um módulo de kernel Linux comum. Aqui o modinfo mostra a licença, a descrição e o vermagic, que nos diz exatamente com qual kernel este objeto foi compilado."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "A linha do vermagic importa: é a versão do kernel, as flags do compilador e a assinatura do módulo. Carregar o arquivo errado e o kernel recusa. Tudo que você vai ver nos próximos minutos roda dentro desse módulo."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "Este é o arquivo que carregamos no firewall. Um único artefato, compilado fora da árvore do kernel, carregando o packet filter inteiro do OpenBSD."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"phase": "02-ativacao",
|
||||
"lang": "en",
|
||||
"voice": "English_ConfidentWoman",
|
||||
"title": "Activation",
|
||||
"subtitle": "insmod pf.ko, then pfctl talks to it over the OpenBSD ioctl ABI",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "First we make sure nothing is loaded, so the activation is honest. No module, no firewall."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "Now we insert it. The kernel accepts the module and the log shows our initialization message. This is the OpenBSD engine running inside Linux."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "The device appears, owned by root with mode six hundred, exactly like the BSD original, and the module shows up in the list of loaded modules."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "pfctl is the original OpenBSD tool. We enable the firewall and ask for its status. The ioctl interface, the statistics table, the rule counters, all of it is the OpenBSD ABI, unchanged."
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "The ruleset is still empty, and that is exactly right: with no rules, pf blocks everything and returns. The engine is armed and waiting for instructions."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"phase": "02-ativacao",
|
||||
"lang": "pt",
|
||||
"voice": "Portuguese_ConfidentWoman",
|
||||
"title": "Ativação",
|
||||
"subtitle": "insmod pf.ko e o pfctl conversa com ele pela ABI de ioctls do OpenBSD",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Primeiro a gente garante que não tem nada carregado, para a ativação ser honesta. Sem módulo, sem firewall."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "Agora inserimos. O kernel aceita o módulo e o log mostra a nossa mensagem de inicialização. Este é o motor do OpenBSD rodando dentro do Linux."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "O dispositivo aparece, do root, com modo seiscentos, exatamente como no BSD original, e o módulo aparece na lista de módulos carregados."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "O pfctl é a ferramenta original do OpenBSD. Habilitamos o firewall e pedimos o status. A interface de ioctl, a tabela de estatísticas, os contadores das regras: tudo é a ABI do OpenBSD, sem alteração."
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "O ruleset ainda está vazio, e isso está certo: sem regras, o pf bloqueia tudo e devolve. O motor está armado e esperando instruções."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"phase": "03-regras",
|
||||
"lang": "en",
|
||||
"voice": "English_ConfidentWoman",
|
||||
"title": "Basic rules",
|
||||
"subtitle": "Tables, anchors, keep state and block drop",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Now we write a real pf dot conf. This is the original OpenBSD syntax: a table with the trusted network, an anchor, and the usual quick rules with keep state. pfctl parses it and the kernel accepts it."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "pfctl shows us what the kernel understood: the rules as they were compiled, the list of tables, and the contents of the trusted table."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "Let the traffic through. From the client we ping the server, and the firewall creates a state. You can see it in the state table: the five tuple, the timeout, the counters."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "Now the same thing with a drop. We replace the pass rule with a block drop rule for the client network, and reload."
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "The ping is dead. One hundred percent packet loss. And the rule counters show the packets that were evaluated and the ones that were blocked. This is a stateful firewall behaving exactly like the original."
|
||||
},
|
||||
{
|
||||
"beat": "b6",
|
||||
"text": "Finally, anchors. We load a ruleset into the anchor called demo, and pfctl lists the rules that live inside it, separately from the main ruleset."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"phase": "03-regras",
|
||||
"lang": "pt",
|
||||
"voice": "Portuguese_ConfidentWoman",
|
||||
"title": "Regras básicas",
|
||||
"subtitle": "Tabelas, anchors, keep state e block drop",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Agora escrevemos um pf ponto conf de verdade. Esta é a sintaxe original do OpenBSD: uma tabela com a rede confiável, um anchor, e as regras quick de sempre com keep state. O pfctl faz o parsing e o kernel aceita."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "O pfctl nos mostra o que o kernel entendeu: as regras como foram compiladas, a lista de tabelas e o conteúdo da tabela trusted."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "Deixa o tráfego passar. Do cliente a gente dá ping no servidor, e o firewall cria um estado. Você vê isso na tabela de estados: a tupla de cinco campos, o timeout, os contadores."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "Agora a mesma coisa com um drop. Trocamos a regra de pass por uma regra block drop para a rede do cliente, e recarregamos."
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "O ping morreu. Cem por cento de perda. E os contadores da regra mostram os pacotes avaliados e os bloqueados. Este é um firewall stateful se comportando exatamente como o original."
|
||||
},
|
||||
{
|
||||
"beat": "b6",
|
||||
"text": "Por fim, os anchors. Carregamos um ruleset no anchor chamado demo, e o pfctl lista as regras que moram dentro dele, separadamente do ruleset principal."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"phase": "04-roteamento",
|
||||
"lang": "en",
|
||||
"voice": "English_ConfidentWoman",
|
||||
"title": "Routing",
|
||||
"subtitle": "route-to forces the next hop, reply-to forces the way back",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Now the routing features. This rule matches traffic from the client to the server and sends it through the second gateway, ten point zero point three point two, instead of the normal path. pfctl shows the rule exactly as the kernel stored it.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "route-to: normal path is eth2, forced path is eth3"
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "To prove where the packet actually went, we capture on both interfaces at the same time: eth three, towards the gateway, and eth two, towards the server."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "And now the client pings the server.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "the request leaves through eth3, to the gateway"
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "The evidence is in the captures. The request appears on eth three, on the way to the gateway, and it never appears on eth two. The firewall decided the next hop, not the routing table.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "1 request on eth3, 0 on eth2"
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "The other half of the problem is the way back. reply to forces the reply of a flow to leave through a different gateway than the request came in, which is how you build asymmetric routing.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "reply-to: the answer leaves through another gateway"
|
||||
},
|
||||
{
|
||||
"beat": "b6",
|
||||
"text": "We capture eth three again and also eth one, the link where the client is."
|
||||
},
|
||||
{
|
||||
"beat": "b7",
|
||||
"text": "The replies show up on eth three, on the gateway link, and not on the client link. The request and the reply now take different paths, exactly as commanded.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "reply on eth3, nothing on the client link"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"phase": "04-roteamento",
|
||||
"lang": "pt",
|
||||
"voice": "Portuguese_ConfidentWoman",
|
||||
"title": "Roteamento",
|
||||
"subtitle": "route-to força o próximo salto, reply-to força o caminho de volta",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Agora as funcionalidades de roteamento. Esta regra casa com o tráfego do cliente para o servidor e o manda pelo segundo gateway, dez ponto zero ponto três ponto dois, em vez do caminho normal. O pfctl mostra a regra exatamente como o kernel guardou.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "route-to: o caminho normal e' eth2; o forcado, eth3"
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "Para provar por onde o pacote realmente passou, capturamos nas duas interfaces ao mesmo tempo: eth três, rumo ao gateway, e eth dois, rumo ao servidor."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "E agora o cliente dá ping no servidor.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "o pedido sai por eth3, rumo ao gateway"
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "A prova está nas capturas. O pedido aparece na eth três, indo para o gateway, e nunca aparece na eth dois. Quem decidiu o próximo salto foi o firewall, não a tabela de rotas.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "1 pedido em eth3, 0 em eth2"
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "A outra metade do problema é o caminho de volta. O reply to força a resposta de um fluxo a sair por um gateway diferente do por onde o pedido entrou, que é assim que se constrói roteamento assimétrico.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "reply-to: a resposta sai por um gateway diferente"
|
||||
},
|
||||
{
|
||||
"beat": "b6",
|
||||
"text": "Capturamos a eth três de novo e também a eth um, o link onde está o cliente."
|
||||
},
|
||||
{
|
||||
"beat": "b7",
|
||||
"text": "As respostas aparecem na eth três, no link do gateway, e não no link do cliente. O pedido e a resposta agora seguem caminhos diferentes, exatamente como foi pedido.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "resposta em eth3, nada no link do cliente"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"phase": "05-balanceamento",
|
||||
"lang": "en",
|
||||
"voice": "English_ConfidentWoman",
|
||||
"title": "Load balancing",
|
||||
"subtitle": "NAT pools in round-robin and source-hash, plus dup-to",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "Load balancing in pf is done with pools. This rule translates the source of the client network into a pool of two addresses, the two gateways of the firewall, and selects between them round robin.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv"
|
||||
],
|
||||
"diagram_caption": "pool { 10.0.2.1, 10.0.3.1 }: round-robin"
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "We capture on the interface towards the server to see which source address each packet carries."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "Six packets from the client."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "And the capture shows the truth: three packets went out with one address, three with the other. The pool is really rotating between its members, and the server sees a balanced load.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv"
|
||||
],
|
||||
"diagram_caption": "3 packets with each source address"
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "Sometimes you do not want rotation. source hash keeps a given origin always mapped to the same pool member, which is what you want when a session has to be sticky.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv"
|
||||
],
|
||||
"diagram_caption": "pool { 10.0.2.1, 10.0.3.1 }: source-hash"
|
||||
},
|
||||
{
|
||||
"beat": "b6",
|
||||
"text": "Four more packets, and now every single one carries the same source address. The mapping is stable per origin.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv"
|
||||
],
|
||||
"diagram_caption": "every packet with the same source"
|
||||
},
|
||||
{
|
||||
"beat": "b7",
|
||||
"text": "And dup to is the mirror case: the packet keeps its normal path, and a copy is delivered to the second gateway. We count the requests on both interfaces: one on the normal path, one on the mirrored path. The packet was duplicated, not moved.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "dup-to: the normal path and the mirrored copy"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"phase": "05-balanceamento",
|
||||
"lang": "pt",
|
||||
"voice": "Portuguese_ConfidentWoman",
|
||||
"title": "Balanceamento",
|
||||
"subtitle": "Pools de NAT em round-robin e source-hash, mais o dup-to",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "O balanceamento no pf é feito com pools. Esta regra traduz a origem da rede do cliente para um pool de dois endereços, os dois gateways do firewall, e escolhe entre eles em round robin.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv"
|
||||
],
|
||||
"diagram_caption": "pool { 10.0.2.1, 10.0.3.1 }: round-robin"
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "Capturamos na interface que vai para o servidor para ver qual endereço de origem cada pacote carrega."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "Seis pacotes vindo do cliente."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "E a captura mostra a verdade: três pacotes saíram com um endereço, três com o outro. O pool está realmente rodando entre os membros, e o servidor enxerga a carga balanceada.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv"
|
||||
],
|
||||
"diagram_caption": "3 pacotes com cada endereço de origem"
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "Às vezes você não quer rotação. O source hash mantém uma origem sempre mapeada para o mesmo membro do pool, que é o que se quer quando a sessão precisa ser sticky.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv"
|
||||
],
|
||||
"diagram_caption": "pool { 10.0.2.1, 10.0.3.1 }: source-hash"
|
||||
},
|
||||
{
|
||||
"beat": "b6",
|
||||
"text": "Mais quatro pacotes, e agora todos carregam o mesmo endereço de origem. O mapeamento é estável por origem.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv"
|
||||
],
|
||||
"diagram_caption": "todos os pacotes com a mesma origem"
|
||||
},
|
||||
{
|
||||
"beat": "b7",
|
||||
"text": "E o dup to é o caso espelho: o pacote mantém o caminho normal e uma cópia é entregue no segundo gateway. Contamos os pedidos nas duas interfaces: um no caminho normal, um no caminho espelhado. O pacote foi duplicado, não movido.",
|
||||
"highlight": [
|
||||
"cli",
|
||||
"pf0",
|
||||
"srv",
|
||||
"gw2"
|
||||
],
|
||||
"diagram_caption": "dup-to: o caminho normal e a cópia espelhada"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"phase": "06-qos",
|
||||
"lang": "en",
|
||||
"voice": "English_ConfidentWoman",
|
||||
"title": "Quality of service",
|
||||
"subtitle": "OpenBSD 8.0 queues: hfsc classes attached to the interface",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "The last piece is quality of service. This is the OpenBSD eight point zero queue syntax: a root class with ten megabits of bandwidth, a leaf class with two megabits as the default, and a rule that steers the traffic from the client to that leaf class."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "pfctl shows the queues with their statistics, and the rule set shows the queue attached to the rule."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "Now we move real traffic through the queue. On the server we prepare two megabytes and listen on port ninety ninety five."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "And the client downloads it, through the firewall, through the class."
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "Two million bytes transferred, and look at the queue counters: the hfsc class counted the packets and the bytes. The scheduler is in the data path, not just in the configuration."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"phase": "06-qos",
|
||||
"lang": "pt",
|
||||
"voice": "Portuguese_ConfidentWoman",
|
||||
"title": "Qualidade de serviço",
|
||||
"subtitle": "Filas do OpenBSD 8.0: classes hfsc anexadas à interface",
|
||||
"segments": [
|
||||
{
|
||||
"beat": "b1",
|
||||
"text": "A última peça é qualidade de serviço. Esta é a sintaxe de filas do OpenBSD oito ponto zero: uma classe raiz com dez megabits de banda, uma classe folha com dois megabits como default, e uma regra que direciona o tráfego do cliente para essa classe folha."
|
||||
},
|
||||
{
|
||||
"beat": "b2",
|
||||
"text": "O pfctl mostra as filas com as estatísticas, e o ruleset mostra a fila anexada à regra."
|
||||
},
|
||||
{
|
||||
"beat": "b3",
|
||||
"text": "Agora movemos tráfego de verdade pela fila. No servidor preparamos dois megabytes e escutamos na porta noventa e noventa e cinco."
|
||||
},
|
||||
{
|
||||
"beat": "b4",
|
||||
"text": "E o cliente baixa isso, passando pelo firewall, passando pela classe."
|
||||
},
|
||||
{
|
||||
"beat": "b5",
|
||||
"text": "Dois milhões de bytes transferidos, e olha os contadores das filas: a classe hfsc contou os pacotes e os bytes. O escalonador está no caminho de dados, não só na configuração."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "The lab environment",
|
||||
"subtitle": "4 Alpine VMs under libvirt, kernel 7.3.4 built from source",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1",
|
||||
"cmds": [
|
||||
"hostname",
|
||||
"uname -r",
|
||||
"cat /etc/os-release | head -2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"cmds": [
|
||||
"ip -br addr",
|
||||
"ip route"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3",
|
||||
"cmds": [
|
||||
"ls -l /opt/pf/",
|
||||
"lsmod | grep '^pf ' || echo 'pf module: not loaded yet'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4",
|
||||
"cmds": [
|
||||
"ssh cli 'hostname; ip -br addr | head -2'",
|
||||
"ssh srv 'hostname; ip -br addr | head -2'"
|
||||
]
|
||||
}
|
||||
],
|
||||
"phase": "00-ambiente"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"title": "Implementation in the kernel",
|
||||
"subtitle": "OpenBSD 8.0 sources + a Linux compatibility layer = pf.ko",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1",
|
||||
"cmds": [
|
||||
"ls /opt/pf/src 2>/dev/null | head -20 || echo 'source tree is on the build host'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"cmds": [
|
||||
"modinfo /opt/pf/pf.ko | head -6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3",
|
||||
"cmds": [
|
||||
"modinfo /opt/pf/pf.ko | grep -E 'license|description|vermagic'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4",
|
||||
"cmds": [
|
||||
"ls -la /opt/pf/pf.ko"
|
||||
]
|
||||
}
|
||||
],
|
||||
"phase": "01-implementacao"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"title": "Activation",
|
||||
"subtitle": "insmod pf.ko, then pfctl talks to it over the OpenBSD ioctl ABI",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1",
|
||||
"cmds": [
|
||||
"rmmod pf 2>/dev/null; lsmod | grep '^pf ' || echo 'pf: not loaded'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"cmds": [
|
||||
"insmod /opt/pf/pf.ko && echo 'pf.ko loaded'",
|
||||
"dmesg | tail -4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3",
|
||||
"cmds": [
|
||||
"ls -l /dev/pf",
|
||||
"lsmod | grep '^pf '"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4",
|
||||
"cmds": [
|
||||
"pfctl -e && echo 'pf enabled'",
|
||||
"pfctl -si | head -6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b5",
|
||||
"cmds": [
|
||||
"pfctl -sr",
|
||||
"pfctl -s Tables"
|
||||
]
|
||||
}
|
||||
],
|
||||
"phase": "02-ativacao"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"title": "Basic rules",
|
||||
"subtitle": "tables, anchors, keep state and block drop",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1",
|
||||
"cmds": [
|
||||
"cat > /tmp/demo.conf <<'EOF'\nset block-policy return\ntable <trusted> { 10.0.1.0/24 }\nanchor \"demo\"\npass in quick on ingress proto icmp from <trusted> keep state\npass out quick on egress proto icmp keep state\npass in quick\npass out quick\nEOF",
|
||||
"pfctl -f /tmp/demo.conf && echo 'ruleset loaded'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"cmds": [
|
||||
"pfctl -sr",
|
||||
"pfctl -s Tables",
|
||||
"pfctl -t trusted -T show"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3",
|
||||
"cmds": [
|
||||
"ssh cli 'ping -c2 -W2 10.0.2.20 | tail -3'",
|
||||
"pfctl -ss | head -5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4",
|
||||
"cmds": [
|
||||
"cat > /tmp/block.conf <<'EOF'\nset block-policy return\nblock drop in quick on ingress proto icmp from 10.0.1.0/24\npass in quick\npass out quick\nEOF",
|
||||
"pfctl -f /tmp/block.conf && echo 'blocking ruleset loaded'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b5",
|
||||
"cmds": [
|
||||
"ssh cli 'ping -c2 -W2 10.0.2.20 | tail -3'",
|
||||
"pfctl -sr -vv | head -4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b6",
|
||||
"cmds": [
|
||||
"cat > /tmp/anchor.conf <<'EOF'\npass in quick on ingress proto icmp from 10.0.1.10 to 10.0.2.20 keep state\npass in quick\npass out quick\nEOF",
|
||||
"pfctl -a demo -f /tmp/anchor.conf && echo 'anchor ruleset loaded'",
|
||||
"pfctl -a demo -sr"
|
||||
]
|
||||
}
|
||||
],
|
||||
"phase": "03-regras"
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"title": "Routing",
|
||||
"subtitle": "route-to forces the next hop, reply-to forces the way back",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1",
|
||||
"cmds": [
|
||||
"cat > /tmp/route.conf <<'EOF'\npass in quick proto icmp from 10.0.1.10 to 10.0.2.20 route-to { 10.0.3.2 } keep state\npass out quick\nEOF",
|
||||
"pfctl -f /tmp/route.conf && echo 'route-to loaded'",
|
||||
"pfctl -sr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"cmds": [
|
||||
"pkill tcpdump; tcpdump -ni eth3 -c 4 -e icmp > /tmp/gw.td 2>&1 &",
|
||||
"tcpdump -ni eth2 -c 4 -e icmp > /tmp/srv.td 2>&1 &",
|
||||
"sleep 1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3",
|
||||
"cmds": [
|
||||
"ssh cli 'ping -c2 -W2 10.0.2.20 | tail -2'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4",
|
||||
"cmds": [
|
||||
"sleep 2",
|
||||
"echo 'forced egress eth3 -> gw2:'; grep -c 'echo request' /tmp/gw.td; echo 'normal egress eth2 -> srv:'; grep -c 'echo request' /tmp/srv.td"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b5",
|
||||
"cmds": [
|
||||
"cat > /tmp/reply.conf <<'EOF'\npass in quick proto icmp from 10.0.1.10 to 10.0.2.20 reply-to { 10.0.3.2 } keep state\npass out quick\nEOF",
|
||||
"pfctl -f /tmp/reply.conf && echo 'reply-to loaded'",
|
||||
"pfctl -sr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b6",
|
||||
"cmds": [
|
||||
"pkill tcpdump; tcpdump -ni eth3 -c 6 -e icmp > /tmp/rgw.td 2>&1 &",
|
||||
"tcpdump -ni eth1 -c 6 -e icmp > /tmp/rcli.td 2>&1 &",
|
||||
"sleep 1",
|
||||
"ssh cli 'ping -c2 -W2 10.0.2.20 | tail -2'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b7",
|
||||
"cmds": [
|
||||
"sleep 2",
|
||||
"echo 'reply forced onto eth3:'; grep -c 'echo reply' /tmp/rgw.td; echo 'reply on the client link:'; grep -c 'echo reply' /tmp/rcli.td"
|
||||
]
|
||||
}
|
||||
],
|
||||
"phase": "04-roteamento"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"title": "Load balancing",
|
||||
"subtitle": "NAT pools in round-robin and source-hash, plus dup-to",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1",
|
||||
"cmds": [
|
||||
"cat > /tmp/pool.conf <<'EOF'\npass out quick on egress from 10.0.1.0/24 to any nat-to { 10.0.2.1, 10.0.3.1 } round-robin\npass in quick on ingress proto icmp keep state\npass out quick on egress proto icmp keep state\nEOF",
|
||||
"pfctl -f /tmp/pool.conf && echo 'round-robin pool loaded'",
|
||||
"pfctl -sr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"cmds": [
|
||||
"pkill tcpdump; tcpdump -ni eth2 -c 12 -e icmp > /tmp/pool.td 2>&1 &",
|
||||
"sleep 1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3",
|
||||
"cmds": [
|
||||
"ssh cli 'for i in 1 2 3 4 5 6; do ping -c1 -W2 10.0.2.20 >/dev/null 2>&1; done; echo sent'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4",
|
||||
"cmds": [
|
||||
"sleep 2",
|
||||
"echo 'source seen by the server (the pool alternates):'; grep 'echo request' /tmp/pool.td | sed 's/.*: \\([0-9.]*\\) > .*/\\1/' | sort | uniq -c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b5",
|
||||
"cmds": [
|
||||
"cat > /tmp/hash.conf <<'EOF'\npass out quick on egress from 10.0.1.0/24 to any nat-to { 10.0.2.1, 10.0.3.1 } source-hash\npass in quick on ingress proto icmp keep state\npass out quick on egress proto icmp keep state\nEOF",
|
||||
"pfctl -f /tmp/hash.conf && echo 'source-hash pool loaded'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b6",
|
||||
"cmds": [
|
||||
"pkill tcpdump; tcpdump -ni eth2 -c 8 -e icmp > /tmp/hash.td 2>&1 &",
|
||||
"sleep 1",
|
||||
"ssh cli 'for i in 1 2 3 4; do ping -c1 -W2 10.0.2.20 >/dev/null 2>&1; done; echo sent'",
|
||||
"sleep 2",
|
||||
"echo 'source-hash keeps the same origin on the same member:'; grep 'echo request' /tmp/hash.td | sed 's/.*: \\([0-9.]*\\) > .*/\\1/' | sort | uniq -c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b7",
|
||||
"cmds": [
|
||||
"cat > /tmp/dup.conf <<'EOF'\npass in quick proto icmp from 10.0.1.10 to 10.0.2.20 dup-to { 10.0.3.2 } keep state\npass out quick\nEOF",
|
||||
"pfctl -f /tmp/dup.conf && echo 'dup-to loaded'",
|
||||
"pkill tcpdump; tcpdump -ni eth2 -c 4 -e icmp > /tmp/d1.td 2>&1 &",
|
||||
"tcpdump -ni eth3 -c 4 -e icmp > /tmp/d2.td 2>&1 &",
|
||||
"sleep 1",
|
||||
"ssh cli 'ping -c2 -W2 10.0.2.20 | tail -2'",
|
||||
"sleep 2",
|
||||
"echo 'normal path eth2:'; grep -c 'echo request' /tmp/d1.td; echo 'mirrored copy eth3:'; grep -c 'echo request' /tmp/d2.td"
|
||||
]
|
||||
}
|
||||
],
|
||||
"phase": "05-balanceamento"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Quality of service",
|
||||
"subtitle": "OpenBSD 8.0 queues: hfsc classes attached to the interface",
|
||||
"beats": [
|
||||
{
|
||||
"id": "b1",
|
||||
"cmds": [
|
||||
"cat > /tmp/q.conf <<'EOF'\nqueue rootq on eth2 bandwidth 10Mbit\nqueue leafq on eth2 bandwidth 2Mbit parent rootq default\npass in quick proto tcp from 10.0.1.10 to 10.0.2.20 port 9095 queue leafq keep state\npass out quick keep state\nEOF",
|
||||
"pfctl -f /tmp/q.conf && echo 'queues loaded'",
|
||||
"pfctl -sr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"cmds": [
|
||||
"pfctl -s queue -v"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b3",
|
||||
"cmds": [
|
||||
"ssh srv 'head -c 2000000 /dev/zero | tr \"\\0\" A > /tmp/data.bin; ls -l /tmp/data.bin'",
|
||||
"ssh srv 'pkill nc 2>/dev/null; (setsid nc -l -p 9095 < /tmp/data.bin >/dev/null 2>&1 &); sleep 1; echo listening'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b4",
|
||||
"cmds": [
|
||||
"ssh cli 'nc -w60 10.0.2.20 9095 > /tmp/out.bin; wc -c < /tmp/out.bin'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b5",
|
||||
"cmds": [
|
||||
"sleep 1",
|
||||
"pfctl -s queue -v",
|
||||
"pfctl -sr -vv | head -6"
|
||||
]
|
||||
}
|
||||
],
|
||||
"phase": "06-qos"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@run clear
|
||||
uname -r
|
||||
lsmod | grep '^pf ' || echo 'pf: not loaded yet'
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Record a *real* interactive shell session on the lab firewall, as asciicast v2.
|
||||
|
||||
Why this exists: the first screencast batch rendered a script whose output was
|
||||
dumped in blocks, which looked like a slideshow. Here the session is captured
|
||||
as it happens - an SSH session into the Alpine firewall, commands typed key by
|
||||
key, the remote tty echoing them, the kernel answering in real time. The
|
||||
capture is raw bytes with timestamps, so any renderer can replay it at any
|
||||
speed, and the same capture serves both narrated languages.
|
||||
|
||||
./scripts/screencast/record.py --plan plans/04-roteamento.json \\
|
||||
--out out/screencast/caps/04-roteamento.cast
|
||||
|
||||
The plan is JSON:
|
||||
|
||||
{
|
||||
"title": "...", "subtitle": "...",
|
||||
"beats": [
|
||||
{"id": "b1", "cmds": ["uname -r", "lsmod"]},
|
||||
{"id": "b2", "cmds": ["pfctl -f /tmp/x.conf"], "note": "..."}
|
||||
]
|
||||
}
|
||||
"""
|
||||
import argparse
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
import pyte
|
||||
|
||||
# The prompt of the remote shell is "root@pf0:~# ". A prompt is only accepted
|
||||
# when it is the *whole* line: while a command is being typed, the line still
|
||||
# ends with the command text, never with the prompt marker.
|
||||
PROMPT_RE = re.compile(r"^\S+:[^\r\n]{0,80}[#$]\s*$")
|
||||
COLS, ROWS = 118, 34
|
||||
|
||||
|
||||
class RemoteShell:
|
||||
"""An SSH session on a pty, with a terminal emulator on our side."""
|
||||
|
||||
def __init__(self, argv, env=None):
|
||||
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", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8"})
|
||||
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.screen.set_mode(pyte.modes.LNM)
|
||||
self.stream = pyte.ByteStream(self.screen)
|
||||
self.events = [] # (t, bytes)
|
||||
|
||||
def pump(self, duration):
|
||||
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 not in r:
|
||||
continue
|
||||
try:
|
||||
data = os.read(self.master, 65536)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EIO:
|
||||
return
|
||||
raise
|
||||
if not data:
|
||||
return
|
||||
self.stream.feed(data)
|
||||
self.events.append((time.time(), data))
|
||||
|
||||
def last_line(self):
|
||||
lines = [l for l in self.screen.display if l.strip()]
|
||||
return lines[-1].rstrip() if lines else ""
|
||||
|
||||
def at_prompt(self):
|
||||
return bool(PROMPT_RE.match(self.last_line()))
|
||||
|
||||
def wait_prompt(self, timeout=90.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
self.pump(0.05)
|
||||
if self.at_prompt():
|
||||
self.pump(0.12)
|
||||
if self.at_prompt():
|
||||
return True
|
||||
return False
|
||||
|
||||
def type_line(self, text, cps=34.0):
|
||||
"""Type a line the way a person does.
|
||||
|
||||
The remote tty echoes the characters back, exactly like a real
|
||||
terminal - that echo is what ends up in the capture, so the video
|
||||
shows the command being written, one character at a time.
|
||||
"""
|
||||
for ch in text:
|
||||
os.write(self.master, ch.encode())
|
||||
time.sleep(1.0 / cps + (ord(ch) % 7) * 0.0035)
|
||||
os.write(self.master, b"\r")
|
||||
self.pump(0.15)
|
||||
|
||||
def send(self, text):
|
||||
os.write(self.master, text.encode())
|
||||
self.pump(0.2)
|
||||
|
||||
|
||||
def beat_pacing(phase):
|
||||
"""Minimum on-screen time per beat, taken from the narration audio.
|
||||
|
||||
Only this phase's narration counts: beat ids repeat across phases, so
|
||||
mixing them would stretch a beat by another phase's sentence.
|
||||
"""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
vo = os.path.abspath(os.path.join(here, "..", "..", "out", "screencast", "vo"))
|
||||
out = {}
|
||||
for lang in ("en", "pt"):
|
||||
script = json.load(open(os.path.join(here, "narration", f"{phase}.{lang}.json")))
|
||||
for i, seg in enumerate(script["segments"]):
|
||||
audio = os.path.join(vo, f"{phase}.{lang}", f"{i:02d}-{seg['beat']}.mp3")
|
||||
if not os.path.exists(audio):
|
||||
continue
|
||||
dur = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", audio],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
try:
|
||||
d = float(dur)
|
||||
except ValueError:
|
||||
continue
|
||||
out[seg["beat"]] = max(out.get(seg["beat"], 0.0), d + 0.8)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--plan", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--host", default="192.168.122.8")
|
||||
ap.add_argument("--jump", default="root@192.168.1.242")
|
||||
ap.add_argument("--key", default=os.path.expanduser("~/.ssh/pflab_ed"))
|
||||
ap.add_argument("--cps", type=float, default=34.0)
|
||||
ap.add_argument("--pace", action="store_true",
|
||||
help="hold each beat long enough for its narration")
|
||||
ap.add_argument("--ensure-pf", action="store_true",
|
||||
help="make sure pf.ko is loaded before the capture starts")
|
||||
args = ap.parse_args()
|
||||
|
||||
plan = json.load(open(args.plan))
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
|
||||
|
||||
# A login shell would print the Alpine MOTD; running bash directly with a
|
||||
# fixed PS1 gives a clean, deterministic opening - one prompt, no banner.
|
||||
# --noediting turns off readline: with it, the line editor redraws the
|
||||
# command and the terminal echoes it a second time. Without it, the
|
||||
# command appears exactly once, the way a real terminal shows it.
|
||||
argv = ["ssh", "-tt", "-J", args.jump,
|
||||
"-o", "StrictHostKeyChecking=accept-new", "-o", "LogLevel=ERROR",
|
||||
"-i", args.key, f"root@{args.host}",
|
||||
"env PS1='\\u@\\h:\\w\\$ ' LC_ALL=C.UTF-8 LANG=C.UTF-8 "
|
||||
"PATH=/opt/pf:$PATH bash --norc --noprofile --noediting -i"]
|
||||
sh = RemoteShell(argv)
|
||||
sh.pump(2.0)
|
||||
sh.wait_prompt(timeout=40)
|
||||
|
||||
if args.ensure_pf:
|
||||
# The module has to be in place before the capture starts: a phase
|
||||
# that runs `rmmod` (activation) or a lost race with another
|
||||
# recording would otherwise leave /dev/pf missing mid-session.
|
||||
sh.send("lsmod | grep -q '^pf ' || insmod /opt/pf/pf.ko\n")
|
||||
sh.wait_prompt(timeout=60)
|
||||
sh.send("pfctl -e\n")
|
||||
sh.wait_prompt(timeout=60)
|
||||
|
||||
# Drop the opening prompt: the capture must start with the first command.
|
||||
sh.events = []
|
||||
|
||||
# How long each beat has to stay on screen: the narration of that beat
|
||||
# must fit, otherwise the voice would talk over the next one. The
|
||||
# longest of the two languages wins, so one capture serves both.
|
||||
pace = {}
|
||||
if args.pace:
|
||||
pace = beat_pacing(plan.get("phase") or os.path.basename(args.plan)[:-5])
|
||||
|
||||
beats = []
|
||||
for beat in plan["beats"]:
|
||||
b_start = time.time() - sh.events[0][0] if sh.events else 0.0
|
||||
for cmd in beat["cmds"]:
|
||||
sh.type_line(cmd, cps=args.cps)
|
||||
if not sh.wait_prompt(timeout=150):
|
||||
sys.stderr.write(f"[record] WARNING: no prompt after: {cmd}\n")
|
||||
sh.pump(0.45)
|
||||
b_end = time.time() - sh.events[0][0] if sh.events else 0.0
|
||||
# hold the screen while the narrator talks about what just happened
|
||||
want = pace.get(beat["id"], 0.0)
|
||||
left = want - (b_end - b_start)
|
||||
if left > 0.1:
|
||||
sh.pump(left)
|
||||
b_end = time.time() - sh.events[0][0] if sh.events else b_end
|
||||
sys.stderr.write(f"[record] {plan.get('phase', '?')} {beat['id']}: "
|
||||
f"{b_start:.1f}-{b_end:.1f}s (narration {want:.1f}s)\n")
|
||||
beats.append({"id": beat["id"], "start": b_start, "end": b_end,
|
||||
"note": beat.get("note", "")})
|
||||
|
||||
sh.send("exit\n")
|
||||
sh.pump(0.8)
|
||||
|
||||
t0 = sh.events[0][0]
|
||||
header = {"version": 2, "width": COLS, "height": ROWS,
|
||||
"timestamp": int(time.time()),
|
||||
"title": plan.get("title", ""), "subtitle": plan.get("subtitle", "")}
|
||||
with open(args.out, "w") as f:
|
||||
f.write(json.dumps(header) + "\n")
|
||||
for t, data in sh.events:
|
||||
f.write(json.dumps([round(t - t0, 4), "o", data.decode("utf-8", "replace")]) + "\n")
|
||||
meta = {"title": plan.get("title", ""), "subtitle": plan.get("subtitle", ""),
|
||||
"duration": sh.events[-1][0] - t0, "beats": beats}
|
||||
with open(args.out.replace(".cast", ".json"), "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
sys.stderr.write(f"[record] {args.out}: {len(sh.events)} chunks, "
|
||||
f"{meta['duration']:.1f}s, {len(beats)} beats\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+405
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Render a recorded shell session (asciicast v2) into a narrated, subtitled MP4.
|
||||
|
||||
The recording is replayed through a terminal emulator, so the video shows the
|
||||
session as it was typed: characters appear one by one, output scrolls in.
|
||||
Three layers end up on screen:
|
||||
|
||||
* the terminal, framed like a real window;
|
||||
* a *network diagram* of the lab, drawn over the terminal in the phases that
|
||||
talk about routing and balancing - it highlights the path the packet
|
||||
really took;
|
||||
* burned-in subtitles of the narration, in the language of the track.
|
||||
|
||||
The narration audio is generated by MiniMax (female voices) and mixed in at
|
||||
the moment the matching beat appears on screen.
|
||||
|
||||
./scripts/screencast/render_video.py --cast out/screencast/caps/04.cast \\
|
||||
--narration scripts/screencast/narration/04.en.json \\
|
||||
--out out/screencast/en/04-roteamento.mp4 --lang en
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pyte
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ---------------------------------------------------------------- palette ---
|
||||
BG = (10, 13, 18)
|
||||
PANEL = (17, 21, 27)
|
||||
BORDER = (46, 52, 60)
|
||||
FG = (205, 213, 221)
|
||||
HEADER_BG = (20, 25, 32)
|
||||
HEADER_FG = (240, 246, 252)
|
||||
ACCENT = (88, 168, 255)
|
||||
ACCENT2 = (56, 199, 209)
|
||||
MUTED = (110, 118, 130)
|
||||
GREEN = (63, 185, 80)
|
||||
YELLOW = (212, 155, 36)
|
||||
RED = (255, 123, 114)
|
||||
TITLE_BG = (14, 18, 24)
|
||||
SUB_BG = (8, 10, 14)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def load_fonts():
|
||||
mono = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf"
|
||||
monob = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono-Bold.ttf"
|
||||
sans = "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf"
|
||||
sansb = "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf"
|
||||
return {
|
||||
"term": ImageFont.truetype(mono, 18),
|
||||
"termb": ImageFont.truetype(monob, 18),
|
||||
"small": ImageFont.truetype(mono, 16),
|
||||
"sans": ImageFont.truetype(sans, 20),
|
||||
"sansb": ImageFont.truetype(sansb, 32),
|
||||
"sub": ImageFont.truetype(sans, 25),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- lab diagram ---
|
||||
# The topology the lab really has (see docs/en/vm-validation.md):
|
||||
# cli 10.0.1.10 --- (eth1) pf0 (eth2) --- srv 10.0.2.20
|
||||
# pf0 (eth3) --- gw2 10.0.3.2
|
||||
class Diagram:
|
||||
"""Small, precise network map of the lab, drawn with PIL."""
|
||||
|
||||
NODES = {
|
||||
"cli": (110, 300, "cli\n10.0.1.10", (72, 170, 255)),
|
||||
"pf0": (560, 300, "pf0 (firewall)\n10.0.1.1 / 10.0.2.1 / 10.0.3.1", (56, 199, 209)),
|
||||
"srv": (1010, 430, "srv\n10.0.2.20", (63, 185, 80)),
|
||||
"gw2": (1010, 90, "gw2\n10.0.3.2", (212, 155, 36)),
|
||||
}
|
||||
LINKS = [
|
||||
("cli", "pf0", "eth1"),
|
||||
("pf0", "srv", "eth2"),
|
||||
("pf0", "gw2", "eth3"),
|
||||
]
|
||||
|
||||
def __init__(self, width, height, fonts):
|
||||
self.w, self.h = width, height
|
||||
self.f = fonts
|
||||
|
||||
def draw(self, img, highlight=None, caption=None):
|
||||
d = ImageDraw.Draw(img, "RGBA")
|
||||
W, H = self.w, self.h
|
||||
panel = [W - 660, 206, W - 44, 622]
|
||||
d.rounded_rectangle(panel, radius=14, fill=(14, 19, 26, 240),
|
||||
outline=(58, 68, 80, 255), width=2)
|
||||
d.text((panel[0] + 22, panel[1] + 16), "LAB - libvirt, 4 VMs Alpine",
|
||||
font=self.f["small"], fill=(140, 190, 230))
|
||||
ox, oy = panel[0] + 40, panel[1] + 60
|
||||
s = min((panel[2] - panel[0] - 80) / 1010.0, (panel[3] - panel[1] - 130) / 430.0)
|
||||
|
||||
def pos(name):
|
||||
x, y, _, _ = self.NODES[name]
|
||||
return (ox + x * s, oy + y * s)
|
||||
|
||||
hot_links = set()
|
||||
for a, b, label in self.LINKS:
|
||||
hot = highlight and {a, b} <= set(highlight)
|
||||
(x1, y1), (x2, y2) = pos(a), pos(b)
|
||||
if hot:
|
||||
hot_links.add(label)
|
||||
d.line([x1, y1, x2, y2], fill=(56, 199, 209, 255), width=7)
|
||||
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
|
||||
d.ellipse([mx - 9, my - 9, mx + 9, my + 9], fill=(56, 199, 209, 255))
|
||||
d.text((mx + 14, my - 24), label, font=self.f["small"], fill=(56, 199, 209, 255))
|
||||
else:
|
||||
d.line([x1, y1, x2, y2], fill=(104, 114, 126, 170), width=3)
|
||||
d.text(((x1 + x2) / 2 + 14, (y1 + y2) / 2 - 24), label,
|
||||
font=self.f["small"], fill=(112, 120, 132, 190))
|
||||
|
||||
for name, (x, y, label, col) in self.NODES.items():
|
||||
cx, cy = pos(name)
|
||||
hot = highlight and name in highlight
|
||||
r = 26 if name != "pf0" else 34
|
||||
if hot:
|
||||
d.ellipse([cx - r - 9, cy - r - 9, cx + r + 9, cy + r + 9],
|
||||
fill=(col[0], col[1], col[2], 55))
|
||||
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=col)
|
||||
for i, line in enumerate(label.split("\n")):
|
||||
tw = d.textlength(line, font=self.f["small"])
|
||||
d.text((cx - tw / 2, cy + r + 6 + i * 20), line,
|
||||
font=self.f["small"], fill=(224, 231, 240))
|
||||
if caption:
|
||||
d.text((panel[0] + 22, panel[3] - 32), caption,
|
||||
font=self.f["small"], fill=(150, 200, 235))
|
||||
|
||||
|
||||
# ------------------------------------------------------------- rendering ---
|
||||
def wrap(text, font, draw, max_w):
|
||||
words, lines, cur = text.split(), [], ""
|
||||
for w in words:
|
||||
trial = (cur + " " + w).strip()
|
||||
if draw.textlength(trial, font=font) <= max_w:
|
||||
cur = trial
|
||||
else:
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
cur = w
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
return lines
|
||||
|
||||
|
||||
def render_frame(screen, fonts, title, subtitle, phase_no, phase_total, footer,
|
||||
diagram, highlight, diagram_caption, art, subtitle_text):
|
||||
W, H = 1920, 1080
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
if art is not None:
|
||||
img = Image.blend(img, art, 0.16)
|
||||
d = ImageDraw.Draw(img, "RGBA")
|
||||
|
||||
d.rectangle([0, 0, W, 66], fill=HEADER_BG)
|
||||
d.text((44, 20), "PF on Linux", font=fonts["sansb"], fill=HEADER_FG)
|
||||
tw = d.textlength("PF on Linux", font=fonts["sansb"])
|
||||
d.text((44 + tw + 22, 28), "OpenBSD PF 8.0 engine inside the Linux kernel",
|
||||
font=fonts["small"], fill=MUTED)
|
||||
if phase_total:
|
||||
label = f"FASE {phase_no}/{phase_total}"
|
||||
lw = d.textlength(label, font=fonts["small"])
|
||||
d.text((W - 44 - lw, 26), label, font=fonts["small"], fill=ACCENT)
|
||||
|
||||
d.text((44, 88), title, font=fonts["sansb"], fill=HEADER_FG)
|
||||
if subtitle:
|
||||
d.text((44, 134), subtitle, font=fonts["sans"], fill=MUTED)
|
||||
|
||||
top, left, right = 182, 44, W - 44
|
||||
bottom = H - 168 if subtitle_text else H - 84
|
||||
d.rectangle([left, top, right, bottom], fill=PANEL, outline=BORDER, width=2)
|
||||
d.rectangle([left, top, right, top + 36], fill=HEADER_BG)
|
||||
for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
|
||||
d.ellipse([left + 20 + i * 22, top + 12, left + 30 + i * 22, top + 22], fill=c)
|
||||
d.text((left + 92, top + 9),
|
||||
"root@pf0 - alpine, kernel 7.3.4 + pf.ko - lab libvirt",
|
||||
font=fonts["small"], fill=MUTED)
|
||||
|
||||
x0, y0, lh = left + 24, top + 52, 25
|
||||
cw = d.textlength("M", font=fonts["term"])
|
||||
for y in range(screen.lines):
|
||||
line = screen.buffer[y]
|
||||
yy = y0 + y * lh
|
||||
if yy > bottom - 30:
|
||||
break
|
||||
# group consecutive cells with the same style into one draw call
|
||||
x = 0
|
||||
while x < screen.columns:
|
||||
ch = line[x]
|
||||
if ch.data in (" ", ""):
|
||||
x += 1
|
||||
continue
|
||||
fg = color_of(ch.fg, FG)
|
||||
bold = ch.bold
|
||||
run = []
|
||||
while x < screen.columns:
|
||||
c = line[x]
|
||||
if c.data in ("", " ") or color_of(c.fg, FG) != fg or c.bold != bold:
|
||||
break
|
||||
run.append(c.data)
|
||||
x += 1
|
||||
d.text((x0 + (x - len(run)) * cw, yy), "".join(run),
|
||||
font=fonts["termb"] if bold else fonts["term"], fill=fg)
|
||||
cx, cy = screen.cursor.x, screen.cursor.y
|
||||
if 0 <= cy < screen.lines and 0 <= cx < screen.columns:
|
||||
d.rectangle([x0 + cx * cw, y0 + cy * lh, x0 + (cx + 1) * cw - 1,
|
||||
y0 + (cy + 1) * lh - 1], fill=ACCENT)
|
||||
|
||||
if diagram is not None:
|
||||
diagram.draw(img, highlight, diagram_caption)
|
||||
|
||||
if subtitle_text:
|
||||
lines = wrap(subtitle_text, fonts["sub"], d, W - 260)
|
||||
bh = len(lines) * 34 + 22
|
||||
by = H - 76 - bh
|
||||
d.rounded_rectangle([70, by, W - 70, by + bh], radius=10,
|
||||
fill=(6, 8, 12, 215), outline=(48, 56, 66, 220), width=2)
|
||||
for i, line in enumerate(lines):
|
||||
lw2 = d.textlength(line, font=fonts["sub"])
|
||||
d.text(((W - lw2) / 2, by + 11 + i * 34), line, font=fonts["sub"],
|
||||
fill=(236, 241, 248))
|
||||
|
||||
d.text((44, H - 56), footer, font=fonts["small"], fill=MUTED)
|
||||
url = "git.labexposed.com/lgcosta/pf-linux-port"
|
||||
uw = d.textlength(url, font=fonts["small"])
|
||||
d.text((W - 44 - uw, H - 56), url, font=fonts["small"], fill=MUTED)
|
||||
return img
|
||||
|
||||
|
||||
def render_title(title, subtitle, phase_no, phase_total, fonts, art):
|
||||
W, H = 1920, 1080
|
||||
img = Image.new("RGB", (W, H), TITLE_BG)
|
||||
if art is not None:
|
||||
img = Image.blend(img, art, 0.55)
|
||||
d = ImageDraw.Draw(img, "RGBA")
|
||||
d.rectangle([0, 0, W, 8], fill=ACCENT)
|
||||
d.text((120, 430), title, font=fonts["sansb"], fill=(250, 252, 255))
|
||||
if subtitle:
|
||||
d.text((120, 494), subtitle, font=fonts["sans"], fill=(190, 200, 214))
|
||||
if phase_total:
|
||||
d.text((120, 562), f"FASE {phase_no} DE {phase_total}",
|
||||
font=fonts["small"], fill=ACCENT)
|
||||
d.text((120, 614), "PF on Linux - OpenBSD PF 8.0 no kernel Linux",
|
||||
font=fonts["small"], fill=(150, 160, 175))
|
||||
return img
|
||||
|
||||
|
||||
def srt_time(t):
|
||||
h = int(t // 3600)
|
||||
m = int((t % 3600) // 60)
|
||||
s = t % 60
|
||||
return f"{h:02d}:{m:02d}:{s:06.3f}".replace(".", ",")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--cast", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--narration", required=True)
|
||||
ap.add_argument("--lang", default="en")
|
||||
ap.add_argument("--phase-no", type=int, default=0)
|
||||
ap.add_argument("--phase-total", type=int, default=0)
|
||||
ap.add_argument("--title-card", type=float, default=2.6)
|
||||
ap.add_argument("--art")
|
||||
ap.add_argument("--fps", type=int, default=30)
|
||||
ap.add_argument("--diagram", action="store_true",
|
||||
help="draw the lab diagram over the terminal")
|
||||
ap.add_argument("--footer", default="PF on Linux - OpenBSD PF 8.0 no kernel Linux - lab libvirt")
|
||||
ap.add_argument("--keep-frames")
|
||||
args = ap.parse_args()
|
||||
|
||||
fonts = load_fonts()
|
||||
art = None
|
||||
if args.art and os.path.exists(args.art):
|
||||
# resize once: doing it per frame costs more than the whole draw
|
||||
art = Image.open(args.art).resize((1920, 1080)).convert("RGB")
|
||||
|
||||
with open(args.cast) as f:
|
||||
lines = f.read().splitlines()
|
||||
header = json.loads(lines[0])
|
||||
events = [json.loads(l) for l in lines[1:]]
|
||||
meta = json.load(open(args.cast.replace(".cast", ".json")))
|
||||
|
||||
nar = json.load(open(args.narration))
|
||||
title = nar.get("title", header.get("title", ""))
|
||||
subtitle = nar.get("subtitle", header.get("subtitle", ""))
|
||||
|
||||
# map every narration segment onto the beat it belongs to
|
||||
beats = {b["id"]: b for b in meta["beats"]}
|
||||
segs = []
|
||||
for i, seg in enumerate(nar["segments"]):
|
||||
beat = beats.get(seg["beat"])
|
||||
if not beat:
|
||||
continue
|
||||
segs.append({
|
||||
"index": i, "beat": seg["beat"],
|
||||
"start": beat["start"], "end": beat["end"],
|
||||
"text": seg["text"],
|
||||
"highlight": seg.get("highlight"),
|
||||
"diagram_caption": seg.get("diagram_caption"),
|
||||
})
|
||||
|
||||
outdir = os.path.dirname(os.path.abspath(args.out))
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
|
||||
# frames go straight into ffmpeg as raw RGB: writing a PNG per frame is
|
||||
# an order of magnitude slower and buys nothing.
|
||||
have = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True).stdout
|
||||
if "libx264" in have:
|
||||
venc = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
||||
"-profile:v", "high", "-level", "4.0"]
|
||||
elif "libopenh264" in have:
|
||||
venc = ["-c:v", "libopenh264", "-b:v", "6M", "-profile:v", "high"]
|
||||
else:
|
||||
venc = ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30", "-row-mt", "1",
|
||||
"-cpu-used", "4"]
|
||||
ff = subprocess.Popen(
|
||||
["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
||||
"-s", "1920x1080", "-r", str(args.fps), "-i", "-",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart"] + venc + [args.out],
|
||||
stdin=subprocess.PIPE)
|
||||
|
||||
screen = pyte.Screen(header["width"], header["height"])
|
||||
screen.set_mode(pyte.modes.LNM)
|
||||
stream = pyte.ByteStream(screen)
|
||||
diagram = Diagram(1920, 1080, fonts)
|
||||
|
||||
n = 0
|
||||
title_img = render_title(title, subtitle, args.phase_no, args.phase_total, fonts, art)
|
||||
for _ in range(int(args.title_card * args.fps)):
|
||||
ff.stdin.write(title_img.tobytes())
|
||||
n += 1
|
||||
|
||||
# subtitle state: the spoken sentence of the segment currently running
|
||||
sub_text = None
|
||||
sub_until = 0.0
|
||||
|
||||
end_t = events[-1][0] if events else 0.0
|
||||
t = 0.0
|
||||
idx = 0
|
||||
frame_dt = 1.0 / args.fps
|
||||
while t <= end_t:
|
||||
while idx < len(events) and events[idx][0] <= t:
|
||||
stream.feed(events[idx][2].encode())
|
||||
idx += 1
|
||||
active = None
|
||||
for s in segs:
|
||||
if s["start"] <= t:
|
||||
active = s
|
||||
else:
|
||||
break
|
||||
if active is not None:
|
||||
if active["index"] != (sub_until or 0) and t >= active["start"]:
|
||||
sub_text = active["text"]
|
||||
sub_until = active["index"]
|
||||
if t > end_t - 0.05:
|
||||
sub_text = None
|
||||
img = render_frame(screen, fonts, title, subtitle, args.phase_no,
|
||||
args.phase_total, args.footer,
|
||||
diagram if args.diagram else None,
|
||||
active["highlight"] if active else None,
|
||||
active["diagram_caption"] if active else None,
|
||||
art, sub_text)
|
||||
ff.stdin.write(img.tobytes())
|
||||
n += 1
|
||||
t += frame_dt
|
||||
|
||||
# the closing card holds the last frame
|
||||
ff.stdin.write(img.tobytes())
|
||||
n += 1
|
||||
for _ in range(int(1.6 * args.fps)):
|
||||
ff.stdin.write(img.tobytes())
|
||||
n += 1
|
||||
|
||||
ff.stdin.close()
|
||||
if ff.wait() != 0:
|
||||
raise SystemExit("[render] ffmpeg failed")
|
||||
sys.stderr.write(f"[render] {args.out}: {n} frames, {end_t:.1f}s\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user