Skip to content

test(m8a): prove the cutover mechanism — LISTENER SWAP (no NAT), isolated netns#163

Merged
ryanmurf merged 1 commit into
masterfrom
feat/m8a-cutover-listener-swap-proof
Jul 17, 2026
Merged

test(m8a): prove the cutover mechanism — LISTENER SWAP (no NAT), isolated netns#163
ryanmurf merged 1 commit into
masterfrom
feat/m8a-cutover-listener-swap-proof

Conversation

@ryanmurf

Copy link
Copy Markdown
Owner

M8a — select + prove the cutover mechanism M9 invokes

Proves the mechanism to switch the public UDP trunk endpoint (tron:45070 + RTP 20000-20100) between FreeSWITCH and rustisk. The entire proof runs inside ONE isolated container netns (--privileged --network none) on synthetic high ports over an internal veth pair + child netns (chime → tron), so packets traverse prerouting+input like the real trunk. The host nftables, host ports, the live trunk, port 45070, the router, and the cluster were NEVER touched. No flush ruleset.

Selected mechanism: LISTENER SWAP (no NAT)

FS trunk profile stopped → rustisk binds hostIP:45070 directly. Delivery is per-packet socket lookup — no NAT, no conntrack, no redirect — so the conntrack objection is structurally absent. A preinstalled fail-closed DROP on the guarded dport is enabled only across the handover so the sender sees loss (SIP retransmits), not ICMP port-unreachable. Chosen over redirect/DNAT/router-repoint per PLAN-v3 §M8a (no new firewall rules, source allowlist unchanged, sofia profile start rollback already proven, direct bind ⇒ New-3 N/A; New-5 honored: no K8s Service).

Artifacts M9 invokes (tests/m8a-cutover/)

  • cutover_lib.sh — filter-only nft primitives (table up/down, handover_drop_on/off). The only host-net surface.
  • apply-fs-to-rustisk.sh — APPLY (FS→rustisk): drop-on → stop FS → bind rustisk → drop-off. STOP_OLD/START_NEW injected as commands so the orchestration is identical here and in live M9 (substitute sofia profile <trunk> stop/start). Measures the window.
  • rollback-rustisk-to-fs.sh — ROLLBACK (rustisk→FS), the exact mirror.
  • run-proof.sh builds + runs --privileged --network none + reaps; README.md/RESULTS.md document it.

Measured handover windows (both directions)

Direction Mechanism-floor window
FS → rustisk (apply) ~47–53 ms
rustisk → FS (rollback) ~49–52 ms

Floor = nft-toggle + socket close/bind + IPC. The live M9 window adds FS sofia profile stop/start + DNS re-resolve (feed M0a's number). bind-on-command (main.rs:2189-2192 / PR #65) is NOT required on this evidence — flagged conditional, not built.

Passing transcript (steps 1–4 + RED + detector) — full in RESULTS.md

PASS source-drop EVILV4: delivered_to_FS=0 delivered_to_RUSTISK=0 (must be 0/0)
PASS source-drop EVILV6: delivered_to_FS=0 delivered_to_RUSTISK=0 (must be 0/0)
PASS disjoint captures (no split-brain): overlap_count=0
PASS ownership run pattern by seq = [FS,RUSTISK,FS] (expected [FS,RUSTISK,FS])
PASS boundary FS->RUSTISK: last FS seq=745, first RUSTISK seq=758 (clean); handover gap = 12 datagrams / 26.0 ms
PASS boundary RUSTISK->FS: last RUSTISK seq=1519, first FS seq=1533 (clean); handover gap = 13 datagrams / 28.0 ms
RESULT: PASS
FS->rustisk apply    window: 47.1 ms
rustisk->FS rollback window: 52.3 ms
--- RED CONTROL (stateful redirect lever) ---
FAIL source-drop EVILV4: delivered_to_FS=0 delivered_to_RUSTISK=695 (must be 0/0)   <- redirect bypasses the drop
RED TEETH CONFIRMED: redirect lever FAILED the proof — listener swap PASSES.
--- DETECTOR SELF-TEST --- ALL OK (accepts clean, rejects overlap/interleave/norollback/untrusted)
M8a RESULT: PASS
  • step 1 one fixed five-tuple primed, numbered datagrams flowing continuously (seq 1..2296) across both transitions, never restarted.
  • step 2 switch FS→rustisk: single clean boundary, overlap_count=0.
  • step 3 rollback rustisk→FS under the same flow: clean reverse boundary.
  • step 4 source-drop holds for v4 AND v6 throughout — untrusted 10.9.0.3 / fd00::3 delivered to neither stand-in (0/0), a genuine DROP.

RED teeth + honest finding

A stateful dnat/redirect lever fails the proof where the swap passes — it rewrites the dport past the source-drop, delivering the untrusted source to the successor (PLAN-v3's port-rewrite hazard). Honest negative result: PLAN-v3's conntrack-persistence rollback objection did not reproduce on tron's kernel (redirect reverts cleanly on rule removal); the listener swap is still chosen for introducing no NAT/conntrack into a NAT-free path. A detector self-test proves assert_boundary rejects every crafted pathology.

Test-harness only (shell/python/docs under a new tests/ dir); no Rust/product/CI-workflow code changed.

…ated netns

Selects and proves the mechanism M9 will use to switch the public UDP trunk
endpoint (tron:45070 + RTP 20000-20100) between FreeSWITCH and rustisk. The
whole proof runs INSIDE ONE isolated container network namespace
(--privileged --network none) on SYNTHETIC high ports over an internal veth
pair + child netns ("chime" -> "tron"), so packets traverse prerouting+input
exactly like the live trunk. It never touches the host nftables, host ports,
the live trunk, port 45070, the router, or the cluster. No `flush ruleset`;
the cutover table is separate and independently deletable.

Selected mechanism: LISTENER SWAP. FS trunk profile stopped -> rustisk binds
hostIP:45070 directly. Delivery is per-packet socket lookup — no NAT, no
conntrack, no redirect — so the conntrack objection is structurally absent. A
preinstalled fail-closed DROP on the guarded dport is enabled only across the
handover so the sender sees loss (SIP retransmits) not ICMP port-unreachable.

M9 invokes exactly these artifacts:
- cutover_lib.sh          — the filter-only nft primitives (table up/down,
                            handover drop on/off). The only host-net surface.
- apply-fs-to-rustisk.sh  — APPLY (FS->rustisk): drop-on, stop FS, bind rustisk,
                            drop-off. STOP_OLD/START_NEW injected as commands, so
                            the orchestration is identical in the synthetic proof
                            and the live cutover (M9 substitutes
                            `sofia profile <trunk> stop/start`). Measures window.
- rollback-rustisk-to-fs.sh — ROLLBACK (rustisk->FS), the exact mirror.

Proof (tests/m8a-cutover, run-proof.sh; RESULTS.md holds the passing transcript):
1. one fixed UDP five-tuple primed, numbered datagrams flowing continuously
   (seq 1..2296) across BOTH transitions, never restarted;
2. switch FS->rustisk: single clean delivery boundary, FS through seq 745,
   rustisk from 758 (12-datagram handover-drop gap), overlap_count=0 — no
   split-brain / no interleaving;
3. rollback rustisk->FS under the SAME flow: reverse boundary just as clean
   (rustisk through 1519, FS from 1533);
4. source-drop holds throughout both transitions for v4 AND v6 — untrusted
   10.9.0.3 (v4) and fd00::3 (v6) delivered to NEITHER stand-in (0/0), a genuine
   DROP in an accept-policy chain;
5. handover windows measured both directions: FS->rustisk ~47-53 ms,
   rustisk->FS ~49-52 ms (mechanism floor; the live M9 window adds FS profile
   stop/start + DNS re-resolve). bind-on-command (main.rs:2189-2192 / PR #65) is
   NOT required on this evidence — flagged conditional, not built.

RED control (teeth): a stateful dnat/redirect lever FAILS the proof where the
listener swap passes — it rewrites the dport past the source-drop, delivering
the untrusted source to the successor (delivered_to_RUSTISK=695). Honest finding:
PLAN-v3's conntrack-persistence rollback objection did NOT reproduce on tron's
kernel (redirect reverts cleanly on rule removal); the listener swap is still
chosen for introducing no NAT/conntrack into a NAT-free path. A detector
self-test proves assert_boundary rejects overlap/interleave/missing-rollback/
untrusted-delivery and accepts a clean capture.

Synthetic ports only; live voice stack, Helm, host nft, router untouched.

Co-authored-by: Repin Agent <repin-agent@herodevs.dev>
@ryanmurf
ryanmurf merged commit 30ad577 into master Jul 17, 2026
3 checks passed
@ryanmurf
ryanmurf deleted the feat/m8a-cutover-listener-swap-proof branch July 17, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant