diff --git a/docs/en/feature-matrix.md b/docs/en/feature-matrix.md index e59c09f..50af286 100644 --- a/docs/en/feature-matrix.md +++ b/docs/en/feature-matrix.md @@ -59,7 +59,7 @@ As of 2026-11-11; source of per-row evidence: `out/test-logs/`, | Feature | Upstream syntax | Milestone | Status | |---|---|---|---| | `route-to` pools (`rr`, `round-robin`, `source-hash`, `hash`), `reply-to`, `dup-to`, `fail-to` | `pass out route-to (GW 10.0.3.2)` | M4 | PENDING-M4 (Linux route/neigh glue `pf_route` not implemented yet) | -| `divert-to` / `divert` tag | `pass in … divert-to 10.0.99.9 10000`, `divert tag X` | M4 e2e; socket family per code comments M6 | PENDING — today's observable behavior: PF's `PF_DIVERT` verdict calls `divert_packet()`, which **drops the packet and logs a rate-limited `pr_warn`**. The userspace relay `pdivert` exists and expects an in-module `AF_DIVERT` family (`socket(PF_DIVERT=32, SOCK_RAW, IPPROTO_DIVERT=254)`), which is not instantiated yet. **Do not ship rules using divert on a live host.** | +| `divert-to` / `divert` tag | `pass in … divert-to ` | M4 e2e pending | **COMPILES (untested)** — AF_DIVERT family (PF_DIVERT=32) implemented in-module (`module/pfsrc/divert_sock.c`: bind/recvmsg/sendmsg/poll + BSD `divert_output` reinjection semantics); `pdivert` relay in `tools/pdivert/`. Packet-level proof is gate `11-divert.sh` (M4) — lab-only until then. | | Queues, default scheduler (priq) | `queue root on wan`, `pfctl -s queue -v` | M5 | COMPILES-untested — `compat/pfk_ifq.c` implements the ifq API with **priq fallback semantics**; `queue` rules parse and load, but packets are not actually shunted through a classful qdisc until M5 | | HFSC | `queue on wan { hfsc … }` | M5 | COMPILES-untested — **priq fallback until M5**; `pfsrc/hfsc.c` staged (real OpenBSD 8.0 code) but **not in the module object list** | | FQ-CoDel | `queue on wan { fq_codel … }` | M5 | COMPILES-untested — same as HFSC: staged, not built | diff --git a/docs/en/pf-guide.md b/docs/en/pf-guide.md index 0a0d952..1a762b7 100644 --- a/docs/en/pf-guide.md +++ b/docs/en/pf-guide.md @@ -81,7 +81,7 @@ results, `tcpdump`) as the primary evidence, as the gates do. ``` pfk: PF-on-Linux engine loaded (OpenBSD 8.0 base) ← insmod ok pfk: netfilter hooks registered (10, netisr wq data path) -pfk: divert v4 port N: no divert socket layer (M6 pending), dropping +pfk: divert port N: no socket bound # pdivert not listening on N pfk: … packet not logged ← pflog stub ``` diff --git a/docs/pt-BR/feature-matrix.md b/docs/pt-BR/feature-matrix.md index 35d1353..06737f0 100644 --- a/docs/pt-BR/feature-matrix.md +++ b/docs/pt-BR/feature-matrix.md @@ -59,7 +59,7 @@ Estado em 2026-11-11; evidência por linha: `out/test-logs/`, | Feature | Sintaxe de upstream | Milestone | Status | |---|---|---|---| | Pools de `route-to` (`rr`, `round-robin`, `source-hash`, `hash`), `reply-to`, `dup-to`, `fail-to` | `pass out route-to (GW 10.0.3.2)` | M4 | PENDING-M4 (o glue Linux de rota/neigh `pf_route` ainda não existe) | -| `divert-to` / tag `divert` | `pass in … divert-to 10.0.99.9 10000`, `divert tag X` | M4 e2e; família de sockets conforme comentário no código, M6 | PENDING — comportamento observável hoje: o veredito `PF_DIVERT` chama `divert_packet()`, que **descarta o pacote e registra um `pr_warn` rate-limited**. O relay userspace `pdivert` existe e espera a família `AF_DIVERT` dentro do módulo (`socket(PF_DIVERT=32, SOCK_RAW, IPPROTO_DIVERT=254)`), que ainda não foi instanciada. **Não coloque regras com divert num host em produção.** | +| `divert-to` / tag `divert` | `pass in … divert-to ` | e2e pendente (M4) | **COMPILA (não testado)** — família AF_DIVERT (PF_DIVERT=32) implementada no módulo (`module/pfsrc/divert_sock.c`: bind/recvmsg/sendmsg/poll + reinjeção com semântica `divert_output` do BSD); relay `pdivert` em `tools/pdivert/`. A prova fim-a-fim é o gate `11-divert.sh` (M4) — até lá, apenas em lab. | | Filas, agendador default (priq) | `queue root on wan`, `pfctl -s queue -v` | M5 | COMPILES-untested — `compat/pfk_ifq.c` implementa a API ifq com **semântica de fallback priq**; regras `queue` fazem parse e carregam, mas pacotes ainda não são desviados por um qdisc classful até o M5 | | HFSC | `queue on wan { hfsc … }` | M5 | COMPILES-untested — **fallback priq até o M5**; `pfsrc/hfsc.c` staged (código real do OpenBSD 8.0) mas **fora da lista de objetos** do módulo | | FQ-CoDel | `queue on wan { fq_codel … }` | M5 | COMPILES-untested — igual ao HFSC: staged, não compilado | diff --git a/docs/pt-BR/pf-guide.md b/docs/pt-BR/pf-guide.md index d11dfdb..62e439c 100644 --- a/docs/pt-BR/pf-guide.md +++ b/docs/pt-BR/pf-guide.md @@ -85,7 +85,7 @@ primária, exatamente como os gates fazem. ``` pfk: PF-on-Linux engine loaded (OpenBSD 8.0 base) ← insmod ok pfk: netfilter hooks registered (10, netisr wq data path) -pfk: divert v4 port N: no divert socket layer (M6 pending), dropping +pfk: divert port N: no socket bound # pdivert não ouvido a porta N pfk: … packet not logged ← stub do pflog ``` diff --git a/module/compat/radix.c b/module/compat/radix.c index 8fcbb11..61326ff 100644 --- a/module/compat/radix.c +++ b/module/compat/radix.c @@ -46,7 +46,34 @@ #endif -#define SALEN(sa) (*(u_char *)(sa)) +/* + * PFLINUX: OpenBSD's SALEN() reads the BSD sockaddr's sa_len byte. Linux + * sockaddrs carry no sa_len member (struct sockaddr starts with the + * 16-bit sa_family_t), so the length of a sockaddr key is derived from its + * address family here. The radix mask tree keeps keys of its own in the + * BSD "length byte first" layout (see rn_addmask()), so those continue to + * use the raw byte accessor MSALEN(). + */ +#define MSALEN(sa) (*(u_char *)(sa)) + +static inline int +pfk_salen(const void *sa) +{ + const struct sockaddr *s = sa; + + switch (s->sa_family) { + case AF_INET: + return (sizeof(struct sockaddr_in)); +#ifdef INET6 + case AF_INET6: + return (sizeof(struct sockaddr_in6)); +#endif + default: + return (sizeof(struct sockaddr)); + } +} + +#define SALEN(sa) pfk_salen(sa) /* * Read-only variables, allocated & filled during rn_init(). @@ -215,7 +242,7 @@ rn_satisfies_leaf(char *trial, struct radix_node *leaf, int skip) if (cp3 == NULL) cp3 = rn_ones; else - length = min(length, SALEN(cp3)); + length = min(length, MSALEN(cp3)); cplim = cp + length; cp += skip; cp2 += skip; @@ -252,7 +279,7 @@ rn_match(void *v_arg, struct radix_node_head *head) * are probably the most common case... */ if (t->rn_mask) - vlen = SALEN(t->rn_mask); + vlen = MSALEN(t->rn_mask); else vlen = SALEN(v); cp = v + off; @@ -367,7 +394,8 @@ rn_insert(void *v_arg, struct radix_node_head *head, caddr_t cp, cp2, cplim; int vlen, cmp_res; - vlen = SALEN(v); + /* the mask tree keys carry their length in byte 0 (off == 0) */ + vlen = off ? SALEN(v) : MSALEN(v); cp = v + off; cp2 = t->rn_key + off; cplim = v + vlen; @@ -440,7 +468,7 @@ rn_addmask(void *n_arg, int search, int skip) if (mlen <= skip) return (mask_rnhead->rnh_nodes); memset(addmask_key + m0, 0, max_keylen - m0); - SALEN(addmask_key) = mlen; + addmask_key[0] = mlen; tm = rn_search(addmask_key, mask_rnhead->rnh_treetop); if (memcmp(addmask_key, tm->rn_key, mlen) != 0) tm = NULL; @@ -493,9 +521,9 @@ rn_lexobetter(void *m_arg, void *n_arg) * first. The netmasks were normalized before calling this function and * don't have unneeded trailing zeros. */ - if (SALEN(mp) > SALEN(np)) + if (MSALEN(mp) > MSALEN(np)) return 1; - if (SALEN(mp) < SALEN(np)) + if (MSALEN(mp) < MSALEN(np)) return 0; /* * Must return the first difference between the masks diff --git a/module/pfsrc/pf.c b/module/pfsrc/pf.c index 987acd3..a89e019 100644 --- a/module/pfsrc/pf.c +++ b/module/pfsrc/pf.c @@ -5020,9 +5020,6 @@ pf_test_rule(struct pf_pdesc *pd, struct pf_rule **rm, struct pf_state **sm, if (pd->virtual_proto != PF_VPROTO_FRAGMENT && !ctx.state_icmp && r->keep_state) { - pr_info_ratelimited("pfk: state-eligible proto=%d kstate=%d\n", - pd->proto, r->keep_state); - if (r->rule_flag & PFRULE_SRCTRACK && pf_insert_src_node(&ctx.sns[PF_SN_NONE], r, PF_SN_NONE, pd->af, pd->src, NULL, NULL) != 0) { diff --git a/module/pfsrc/pf_ioctl.c b/module/pfsrc/pf_ioctl.c index 15c3b8a..1b90e00 100644 --- a/module/pfsrc/pf_ioctl.c +++ b/module/pfsrc/pf_ioctl.c @@ -2266,40 +2266,35 @@ pfioctl(dev_t dev, u_long cmd, caddr_t addr, int flags, struct proc *p) sizeof(pr->anchor) || strnlen(pr->anchor_call, sizeof(pr->anchor_call)) >= sizeof(pr->anchor_call)) { - error = ENAMETOOLONG; pr_info("pfkE: addrule1\n"); + error = ENAMETOOLONG; goto fail; } rule = pool_get(&pf_rule_pl, PR_WAITOK|PR_LIMITFAIL|PR_ZERO); if (rule == NULL) { - error = ENOMEM; pr_info("pfkE: addrule-pool\n"); + error = ENOMEM; goto fail; } if ((error = pf_rule_copyin(&pr->rule, rule))) { - pr_info("pfkE: addrule copyin=%d\n", error); pf_rule_free(rule); rule = NULL; goto fail; } if (pr->rule.return_icmp >> 8 > ICMP_MAXTYPE) { - pr_info("pfkE: addrule icmp %x\n", pr->rule.return_icmp); error = EINVAL; pf_rule_free(rule); rule = NULL; goto fail; } if ((error = pf_rule_checkaf(rule))) { - pr_info("pfkE: addrule checkaf=%d\n", error); pf_rule_free(rule); rule = NULL; goto fail; } if (rule->src.addr.type == PF_ADDR_NONE || rule->dst.addr.type == PF_ADDR_NONE) { - pr_info("pfkE: addrule none src=%d dst=%d\n", - rule->src.addr.type, rule->dst.addr.type); error = EINVAL; pf_rule_free(rule); rule = NULL; diff --git a/module/pfsrc/pf_table.c b/module/pfsrc/pf_table.c index 403f149..afa3d3d 100644 --- a/module/pfsrc/pf_table.c +++ b/module/pfsrc/pf_table.c @@ -1983,9 +1983,9 @@ _skip: senderr(EFAULT); if (pfr_validate_addr(&ad)) senderr(EINVAL); - pr_info("pfkD: ina_define addr[%d] af=%d net=%d flags=%llx ifindex=%d\n", - i, ad.pfra_af, ad.pfra_net, (unsigned long long)ad.pfra_flags, - ad.pfra_ifindex); + pr_info("pfkD: ina_define addr[%d] af=%d net=%d not=%d type=%d ifname=%s\n", + i, ad.pfra_af, ad.pfra_net, ad.pfra_not, ad.pfra_type, + ad.pfra_ifname); if (pfr_lookup_addr(shadow, &ad, 1) != NULL) continue; p = pfr_create_kentry(&ad); diff --git a/module/pfsrc/pfk_mod.c b/module/pfsrc/pfk_mod.c index 257fac1..b14a3d1 100644 --- a/module/pfsrc/pfk_mod.c +++ b/module/pfsrc/pfk_mod.c @@ -54,9 +54,6 @@ pfk_ioctl_io(struct file *filp, unsigned int cmd, unsigned long arg) p.p_ucred = &cred; p.p_p = &pp; - if (cmd == DIOCADDRULE) - pr_info("pfkE: ioctl sz=%d kern_size=%d rule=%d\n", (int)sz, - (int)sizeof(struct pfioc_rule), (int)sizeof(struct pf_rule)); kdata = kzalloc(sz ? sz : sizeof(long), GFP_KERNEL); if (!kdata) return -ENOMEM; diff --git a/scripts/demo-record.py b/scripts/demo-record.py new file mode 100755 index 0000000..09537e8 --- /dev/null +++ b/scripts/demo-record.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +PF-on-Linux demo recorder — produces an asciicast v2 (.cast) from a REAL +session driven against the lab VMs (no simulated output: every byte shown +comes from the live systems; only pacing is scripted). + +Usage: ./scripts/demo-record.py [--out out/demo-pf-linux.cast] +Requires: lab running (pftest-pf0/cli/srv), repo built, ssh alias `pf0` +(ProxyJump) and lib.sh-style nested access working. + +Edit DEMO below to change the narration script. Timing: each line is a +(real command, real output) pair; delays are cosmetic only. +""" +import argparse, json, os, pty, re, shlex, subprocess, sys, time + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT_DEFAULT = os.path.join(REPO, "out", "demo-pf-linux.cast") + +DEB = os.environ.get("DEB", "root@192.168.1.242") +PF0_IP = os.environ.get("PF0_IP", "192.168.122.8") + + +def deb_run(cmd: str, timeout=30) -> str: + """run a command on the libvirt host (which has direct keys to VMs)""" + p = subprocess.run(["ssh", "-o", "ConnectTimeout=10", DEB, cmd], + capture_output=True, text=True, timeout=timeout) + return (p.stdout or "") + (p.stderr or "") + + +def vm0(cmd: str, timeout=30) -> str: + return deb_run( + f"ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 " + f"-i /root/.ssh/pflab_ed root@{PF0_IP} {shlex.quote(cmd)}", + timeout) + + +def vm_ip(ip: str, cmd: str, timeout=30) -> str: + return deb_run( + f"ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 " + f"-i /root/.ssh/pflab_ed root@{ip} {shlex.quote(cmd)}", + timeout) + + +CLI, SRV = "10.0.1.10", "10.0.2.20" + + +def scene(title): + return ("#banner", title) + +# (shell-line, runner, command) — runners: pf0 / cli / srv / local +DEMO = [ + ("local", "echo 'PF-on-Linux — OpenBSD PF engine on Linux 7.3.4 (lab: Alpine VMs)'"), + ("local", "uname -r | sed 's/^/build host kernel: /'"), + ("pf0", "uname -r"), + ("pf0", "lsmod | grep -E '^pf ' || echo 'pf: not loaded yet'"), + ("pf0", "insmod /opt/pf/pf.ko && dmesg | tail -2"), + ("pf0", "/opt/pf/pfctl -s info | head -3"), + ("pf0", "/opt/pf/pfctl -e && /opt/pf/pfctl -s info | head -1"), + ("pf0", "cat /tmp/pfdemo.conf"), + ("pf0", "/opt/pf/pfctl -f /tmp/pfdemo.conf && /opt/pf/pfctl -sr"), + ("cli", f"ping -c 2 -W 2 {SRV} 2>&1 | tail -2"), + ("pf0", "/opt/pf/pfctl -s states"), + ("pf0", "/opt/pf/pfctl -s Tables"), + ("pf0", "/opt/pf/pfctl -t demo -T show"), + ("cli", "ping -c 2 -W 2 10.0.9.99 2>&1 | tail -2"), + ("pf0", "/opt/pf/pfctl -s info | grep -E 'match|current'"), + ("pf0", "/opt/pf/pfctl -ss | head -2"), +] + +CONF = """set block-policy return +set loginterface egress +table { 10.0.1.0/24 } +match in quick on ingress proto icmp from tag DEMO +pass in quick on ingress proto icmp keep state +pass out quick proto icmp keep state +block drop in quick proto icmp from any to 10.0.9.99 +""" + + +def prep(): + # stage the demo ruleset into pf0 + import base64 + b64 = base64.b64encode(CONF.encode()).decode() + vm0(f"echo {b64} | base64 -d > /tmp/pfdemo.conf; echo staged") + + +def record(out_path: str, fps_pad=0.4): + os.makedirs(os.path.dirname(out_path), exist_ok=True) + casts = [] + t = 0.0 + + def emit(text, delay): + nonlocal t + t += delay + casts.append([round(t, 3), "o", text]) + + header = {"version": 2, "width": 100, "height": 24, + "timestamp": int(time.time()), "title": + "PF on Linux — port demo (OpenBSD PF 8.0 engine on Linux 7.3.4, Alpine libvirt lab)", + "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} + with open(out_path, "w") as f: + f.write(json.dumps(header) + "\n") + + def show_prompt(host): + emit(f"\x1b[1;36m{host}$\x1b[0m ", 0.15) + + runners = {"pf0": vm0, "cli": lambda c: vm_ip(CLI, c), + "srv": lambda c: vm_ip(SRV, c), "local": + lambda c: subprocess.run(["sh", "-c", c], + capture_output=True, text=True, + timeout=30).stdout + + subprocess.run(["sh", "-c", c], capture_output=True, + text=True).stderr} + prep() + for kind, cmd in DEMO: + label = {"pf0": "pftest-pf0", "cli": "pftest-cli", + "srv": "pftest-srv", "local": "build-host"}[kind] + show_prompt(label) + emit(cmd + "\r\n", 0.25) + try: + out = runners[kind](cmd) + except Exception as e: + out = f"[runner error: {e}]\n" + # strip ANSI-safe passthrough; small human-ish delay + delay = min(1.2, 0.25 + len(out) / 4000.0) + emit(out.replace("\n", "\r\n") or "(no output)\r\n", delay) + time.sleep(0.1) + emit("\x1b[1;36mpftest-pf0$\x1b[0m /opt/pf/pfctl -d\r\n", 0.4) + runners["pf0"]("/opt/pf/pfctl -d") + for i, ev in enumerate(casts): + f.write(json.dumps(ev) + "\n") + print(f"WROTE {out_path} ({len(casts)} events, {t:.1f}s)") + print("play: asciinema play " + out_path + + " (or: script-replay/pf-in-play fallback: cat with 'less -R')") + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=OUT_DEFAULT) + a = ap.parse_args() + record(a.out)