From ccd28c1f52a7cbf1439f024a25c5e313571b022f Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 17 Jul 2026 15:42:18 -0600 Subject: [PATCH] test(m8a): harden cutover artifacts for M9 (guard table_down, apply/rollback trap, idempotent handover drop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the 3 MAJOR + 1 MINOR artifact-safety findings from the independent M8a review (Part B) before M9 runs these on tron's host: (a) cutover_table_down is GUARDED — it now deletes only a table THIS tool created: a protected-name denylist (voicefw + system tables) AND an ownership sentinel chain that cutover_table_up installs. A stray NFT_TABLE=voicefw can no longer wipe the live firewall (trusted-source allowlist, SIP 45070 drop, RTP 20000-20100 drop). README M9 example now uses the default SEPARATE cutover table (coexists with voicefw at priority -10) instead of NFT_TABLE=voicefw. (b) apply-fs-to-rustisk.sh + rollback-rustisk-to-fs.sh add an EXIT trap that idempotently runs handover_drop_off on ANY failure path, so a mid-sequence abort under set -euo pipefail never leaves the blanket 'udp dport drop' stuck ON (phone outage). (c) handover_drop_on is idempotent (a second call is a no-op, no duplicate rule); handover_drop_off removes ALL handover rules (was head -n1 = one). (d) RESULTS.md rollback mechanism-floor window widened to ~38-52 ms to match the reproduced band. Adds red-capable regression tests (safety-tests.sh + run-safety-tests.sh) run in an ISOLATED container netns (--privileged --network none, synthetic ports 55071/55072, never 45070/RTP/host): each proves the pre-fix bug (RED) and the fix (GREEN) — guard refuses foreign voicefw + owner-less table, mid-sequence failure leaves 0 lingering drops, double-on+one-off leaves 0. --- tests/m8a-cutover/Dockerfile | 2 +- tests/m8a-cutover/README.md | 33 ++- tests/m8a-cutover/RESULTS.md | 2 +- tests/m8a-cutover/apply-fs-to-rustisk.sh | 14 ++ tests/m8a-cutover/cutover_lib.sh | 80 +++++++- tests/m8a-cutover/rollback-rustisk-to-fs.sh | 14 ++ tests/m8a-cutover/run-safety-tests.sh | 48 +++++ tests/m8a-cutover/safety-tests.sh | 212 ++++++++++++++++++++ 8 files changed, 386 insertions(+), 19 deletions(-) create mode 100755 tests/m8a-cutover/run-safety-tests.sh create mode 100755 tests/m8a-cutover/safety-tests.sh diff --git a/tests/m8a-cutover/Dockerfile b/tests/m8a-cutover/Dockerfile index fde7dc0..b4027a0 100644 --- a/tests/m8a-cutover/Dockerfile +++ b/tests/m8a-cutover/Dockerfile @@ -21,7 +21,7 @@ RUN apt-get update \ WORKDIR /m8a COPY listener.py sender.py burst.py assert_boundary.py cutover_lib.sh \ apply-fs-to-rustisk.sh rollback-rustisk-to-fs.sh red-stateful-variant.sh \ - detector-selftest.sh in-container-proof.sh /m8a/ + detector-selftest.sh in-container-proof.sh safety-tests.sh /m8a/ RUN chmod +x /m8a/*.sh /m8a/*.py # The container just sleeps; the driver drives it via `docker exec`. diff --git a/tests/m8a-cutover/README.md b/tests/m8a-cutover/README.md index d945032..fc74eb3 100644 --- a/tests/m8a-cutover/README.md +++ b/tests/m8a-cutover/README.md @@ -51,9 +51,9 @@ independently deletable (`nft delete table inet cutover`). | File | Role | |---|---| -| `cutover_lib.sh` | the nft primitives: `cutover_table_up/down`, `handover_drop_on/off`. **Filter-only table.** The only host-networking surface. | -| `apply-fs-to-rustisk.sh` | **APPLY** (FS→rustisk): drop-on → stop FS → wait released → bind rustisk → wait bound → drop-off. Measures + prints the window. | -| `rollback-rustisk-to-fs.sh` | **ROLLBACK** (rustisk→FS): the exact mirror; `START_NEW_CMD` is the watchdog's `sofia profile start`. | +| `cutover_lib.sh` | the nft primitives: `cutover_table_up/down`, `handover_drop_on/off`. **Filter-only table.** The only host-networking surface. `cutover_table_down` is **guarded** — it deletes only a table this tool created (owner-marker + protected-name denylist); `handover_drop_on/off` are **idempotent** (double-on is a no-op, off removes ALL handover rules). | +| `apply-fs-to-rustisk.sh` | **APPLY** (FS→rustisk): drop-on → stop FS → wait released → bind rustisk → wait bound → drop-off. Measures + prints the window. An **EXIT trap** clears the handover drop on any mid-sequence failure (never leaves the port blocked). | +| `rollback-rustisk-to-fs.sh` | **ROLLBACK** (rustisk→FS): the exact mirror; `START_NEW_CMD` is the watchdog's `sofia profile start`. Same **EXIT trap** as apply. | Both scripts inject the FS/rustisk specifics as commands, so the orchestration is **identical** in this synthetic proof and in the live M9 cutover — only the @@ -64,20 +64,35 @@ hooks differ: STOP_OLD_CMD="kill -USR2 $FS_PID" # release the port from the stand-in START_NEW_CMD="kill -USR1 $RUSTISK_PID" # bind the port on the stand-in -# M9 live (substitute): -PORT=45070 NFT_TABLE=voicefw \ +# M9 live (substitute) — uses the DEFAULT, SEPARATE `cutover` table. +# NFT_TABLE defaults to `cutover`; it coexists with the live `voicefw` firewall +# (both input-hook priority -10, both policy accept). The transient handover drop +# lands in `cutover`; voicefw's persistent source allowlist is never touched. +# +# NEVER set NFT_TABLE=voicefw: cutover_table_down deletes its table by name, and +# tearing down would take the live allowlist + SIP-45070 + RTP-20000-20100 drops +# with it. (The guard now REFUSES a foreign/protected table, but the operating +# rule is to keep the tool pointed at its own `cutover` table.) + +PORT=45070 ./cutover_lib.sh up # create the SEPARATE `cutover` table (+ owner marker) + +PORT=45070 \ STOP_OLD_CMD="fs_cli -x 'sofia profile stop'" \ WAIT_RELEASED_CMD="" \ START_NEW_CMD="" \ WAIT_BOUND_CMD="" \ ./apply-fs-to-rustisk.sh # rollback: STOP_OLD_CMD stops rustisk; START_NEW_CMD = fs_cli -x 'sofia profile start' + +PORT=45070 ./cutover_lib.sh down # remove the cutover table (GUARDED: only a table THIS tool created) ``` -In M9 the source-drops are the **existing** `voice-trunk.nft` allowlist (already -protecting dport 45070) — the cutover adds no new firewall rules. The -`UNTRUSTED_V4/V6` knobs here exist only to *prove* the drop holds across the -handover. +In M9 the persistent **source-drops stay in the existing `voice-trunk.nft` +allowlist** (already protecting dport 45070, in the `voicefw` table) — the +cutover adds no new *source* firewall rules. The separate `cutover` table only +ever holds the transient handover drop, and is created/removed as a unit around +the switch. The `UNTRUSTED_V4/V6` knobs here exist only to *prove* the drop +holds across the handover in the synthetic proof. ## The proof harness diff --git a/tests/m8a-cutover/RESULTS.md b/tests/m8a-cutover/RESULTS.md index 52214cb..e808954 100644 --- a/tests/m8a-cutover/RESULTS.md +++ b/tests/m8a-cutover/RESULTS.md @@ -11,7 +11,7 @@ windows vary a few ms run to run. Reproduced cleanly across repeated runs. | Direction | Mechanism-floor window | Receiver-observed delivery gap | |---|---|---| | **FS → rustisk** (apply) | **~47–53 ms** | ~26–28 ms (12–13 datagrams @ 2 ms) | -| **rustisk → FS** (rollback) | **~49–52 ms** | ~28–30 ms (13–14 datagrams @ 2 ms) | +| **rustisk → FS** (rollback) | **~38–52 ms** | ~28–30 ms (13–14 datagrams @ 2 ms) | The **mechanism-floor** window is nft-drop-toggle + socket close + socket bind + IPC/status-poll, measured in an isolated netns. It is the *floor*: the **live M9 diff --git a/tests/m8a-cutover/apply-fs-to-rustisk.sh b/tests/m8a-cutover/apply-fs-to-rustisk.sh index 94d9b55..42f91ef 100755 --- a/tests/m8a-cutover/apply-fs-to-rustisk.sh +++ b/tests/m8a-cutover/apply-fs-to-rustisk.sh @@ -26,6 +26,20 @@ HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=cutover_lib.sh source "${HERE}/cutover_lib.sh" +# Fail-safe: `set -euo pipefail` aborts mid-sequence if STOP_OLD/WAIT/START_NEW +# fails, which would otherwise leave the blanket handover drop STUCK ON (phone +# outage until an operator clears it). This EXIT trap idempotently clears any +# lingering handover drop on EVERY exit path — failure or success — so a failed +# apply fails CLOSED (SIP retransmits) without leaving the port blocked. It +# preserves the original exit code. handover_drop_off is a no-op if the drop is +# already off (the success path clears it before this trap runs). +_apply_on_exit() { + local rc=$? + handover_drop_off 2>/dev/null || true + exit "${rc}" +} +trap _apply_on_exit EXIT + : "${STOP_OLD_CMD:?}" ; : "${START_NEW_CMD:?}" : "${WAIT_RELEASED_CMD:=true}" ; : "${WAIT_BOUND_CMD:=true}" diff --git a/tests/m8a-cutover/cutover_lib.sh b/tests/m8a-cutover/cutover_lib.sh index a347d7d..b6e77a1 100755 --- a/tests/m8a-cutover/cutover_lib.sh +++ b/tests/m8a-cutover/cutover_lib.sh @@ -22,10 +22,29 @@ # PORT the guarded UDP dport (required) # UNTRUSTED_V4 untrusted v4 source to DROP (optional, for the proof) # UNTRUSTED_V6 untrusted v6 source to DROP (optional, for the proof) +# +# M9 MUST use the default, SEPARATE `cutover` table — it coexists with the live +# `voicefw` firewall (both input-hook priority -10, both policy accept). NEVER +# point NFT_TABLE at `voicefw` (or any other pre-existing host table): the live +# allowlist + RTP/SIP drops are not ours to delete. `cutover_table_down` now +# REFUSES any table it did not create (see the guard below), but the correct +# operating discipline is to never aim the tool at the live firewall in the +# first place. set -euo pipefail NFT_TABLE="${NFT_TABLE:-cutover}" +# Tables this tool must NEVER delete, even if asked — the live host firewall and +# common system tables. `cutover_table_down` refuses these outright (defence in +# depth on top of the ownership marker below). Space-separated; overridable. +CUTOVER_PROTECTED_TABLES="${CUTOVER_PROTECTED_TABLES:-voicefw filter nat mangle raw security}" + +# Ownership sentinel: a regular (never-hooked) chain that `cutover_table_up` +# installs so `cutover_table_down` can prove THIS tool created the table. A table +# lacking it is foreign and will not be deleted. Never carries packets — regular +# chains only run when jumped to, and nothing jumps to this one. +CUTOVER_OWNER_CHAIN="m8a_cutover_owner" + _nft() { nft "$@"; } # Create the cutover table + input chain, and install the PERSISTENT @@ -34,6 +53,9 @@ _nft() { nft "$@"; } cutover_table_up() { : "${PORT:?PORT required}" _nft add table inet "${NFT_TABLE}" + # Ownership sentinel first: a plain regular chain (never hooked, never jumped + # to). Its presence is how cutover_table_down proves this table is ours. + _nft "add chain inet ${NFT_TABLE} ${CUTOVER_OWNER_CHAIN}" _nft "add chain inet ${NFT_TABLE} input { type filter hook input priority -10 ; policy accept ; }" if [ -n "${UNTRUSTED_V4:-}" ]; then _nft add rule inet "${NFT_TABLE}" input meta l4proto udp udp dport "${PORT}" ip saddr "${UNTRUSTED_V4}" drop comment '"srcdrop-v4"' @@ -44,28 +66,70 @@ cutover_table_up() { } # Drop the whole table (independently deletable; never `flush ruleset`). +# +# GUARDED: only ever deletes a table THIS tool created. A stray NFT_TABLE=voicefw +# (or any pre-existing host table) would otherwise wipe the live firewall — the +# trusted-source allowlist, the SIP 45070 drop, the RTP 20000-20100 drop. Two +# independent gates must both pass before the delete: +# (1) name is not on the protected/system denylist (voicefw, filter, nat, ...); +# (2) the table carries our ownership sentinel chain (cutover_table_up put it +# there) — a foreign table won't have it. +# Refuses (returns non-zero) rather than deleting when either gate fails. A +# missing table is a no-op success. cutover_table_down() { - _nft delete table inet "${NFT_TABLE}" 2>/dev/null || true + local t="${NFT_TABLE}" p + # (1) hard refusal: never delete a known host/system table by name. + for p in ${CUTOVER_PROTECTED_TABLES}; do + if [ "${t}" = "${p}" ]; then + echo "REFUSING: 'inet ${t}' is a protected host table; cutover_table_down will not delete it." >&2 + return 3 + fi + done + # (2) nothing to delete if the table doesn't exist. + if ! nft list table inet "${t}" >/dev/null 2>&1; then + return 0 + fi + # (3) ownership check: refuse a table that lacks our sentinel chain — it was + # not created by this tool and is not ours to delete. + if ! nft list table inet "${t}" 2>/dev/null \ + | grep -Eq "chain[[:space:]]+${CUTOVER_OWNER_CHAIN}([[:space:]{]|$)"; then + echo "REFUSING: table 'inet ${t}' exists but lacks the m8a-cutover ownership marker (chain ${CUTOVER_OWNER_CHAIN}); not ours to delete." >&2 + return 3 + fi + # (4) safe: our table. Drop it by name (never `flush ruleset`). + _nft delete table inet "${t}" } # Enable the transient fail-closed handover drop on the guarded dport. # Inserted at the head so it is evaluated first. While it is present the sender # sees plain LOSS (SIP retransmission covers it), NOT ICMP port-unreachable — # which is the whole reason the port must never be left merely unbound. +# +# IDEMPOTENT: a second call is a no-op. Two calls must never stack two rules, or +# a single handover_drop_off could leave a lingering blanket drop behind. handover_drop_on() { : "${PORT:?PORT required}" + if _handover_handles | grep -q .; then + return 0 + fi _nft insert rule inet "${NFT_TABLE}" input meta l4proto udp udp dport "${PORT}" drop comment '"handover"' } -# Disable the handover drop (delete by comment handle). +# Emit the handle of EVERY handover rule currently in the chain (one per line). +_handover_handles() { + nft -a list chain inet "${NFT_TABLE}" input 2>/dev/null \ + | awk '/comment "handover"/ {for(i=1;i<=NF;i++) if($i=="handle") print $(i+1)}' +} + +# Disable the handover drop. Removes ALL handover rules (not just the first) so a +# doubled drop_on, or a failed apply that left one stuck, cannot leave a blanket +# drop behind. nft handles are stable within a generation, so one collected pass +# deletes them all. handover_drop_off() { local h - h="$(nft -a list chain inet "${NFT_TABLE}" input 2>/dev/null \ - | awk '/comment "handover"/ {for(i=1;i<=NF;i++) if($i=="handle") print $(i+1)}' \ - | head -n1)" - if [ -n "${h}" ]; then - _nft delete rule inet "${NFT_TABLE}" input handle "${h}" - fi + for h in $(_handover_handles); do + _nft delete rule inet "${NFT_TABLE}" input handle "${h}" 2>/dev/null || true + done } # Allow being sourced OR invoked as `cutover_lib.sh `. diff --git a/tests/m8a-cutover/rollback-rustisk-to-fs.sh b/tests/m8a-cutover/rollback-rustisk-to-fs.sh index 22f1bac..42d7630 100755 --- a/tests/m8a-cutover/rollback-rustisk-to-fs.sh +++ b/tests/m8a-cutover/rollback-rustisk-to-fs.sh @@ -25,6 +25,20 @@ HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=cutover_lib.sh source "${HERE}/cutover_lib.sh" +# Fail-safe: `set -euo pipefail` aborts mid-sequence if STOP_OLD/WAIT/START_NEW +# fails, which would otherwise leave the blanket handover drop STUCK ON (phone +# outage until an operator clears it). This EXIT trap idempotently clears any +# lingering handover drop on EVERY exit path — failure or success — so a failed +# rollback fails CLOSED (SIP retransmits) without leaving the port blocked. It +# preserves the original exit code. handover_drop_off is a no-op if the drop is +# already off (the success path clears it before this trap runs). +_rollback_on_exit() { + local rc=$? + handover_drop_off 2>/dev/null || true + exit "${rc}" +} +trap _rollback_on_exit EXIT + : "${STOP_OLD_CMD:?}" ; : "${START_NEW_CMD:?}" : "${WAIT_RELEASED_CMD:=true}" ; : "${WAIT_BOUND_CMD:=true}" diff --git a/tests/m8a-cutover/run-safety-tests.sh b/tests/m8a-cutover/run-safety-tests.sh new file mode 100755 index 0000000..7ec24ad --- /dev/null +++ b/tests/m8a-cutover/run-safety-tests.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# run-safety-tests.sh — driver for the M8a artifact-safety regression tests. +# +# Builds the proof image and runs safety-tests.sh inside ONE isolated container +# network namespace (--privileged --network none): the container has only `lo`, +# NO route to the host, and its OWN empty nftables ruleset. Nothing it does can +# touch the host nft, the live voicefw firewall, port 45070, RTP 20000-20100, the +# router, or the cluster. Synthetic ports only. Container + image reaped on exit. +# +# ./run-safety-tests.sh # build + run, transcript to stdout +# TRANSCRIPT=out.txt ./run-safety-tests.sh # also tee the transcript to a file +set -euo pipefail +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +IMG=m8a-cutover-safety:local +CTR="m8a-cutover-safety-$$" +TRANSCRIPT="${TRANSCRIPT:-}" + +cleanup() { + docker rm -f "${CTR}" >/dev/null 2>&1 || true + docker image rm "${IMG}" >/dev/null 2>&1 || true + if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "${CTR}"; then + echo "CLEANUP WARNING: container ${CTR} leaked — docker rm -f ${CTR}" >&2 + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +command -v docker >/dev/null || { echo "docker required" >&2; exit 2; } + +echo "== build proof image ==" +docker build -t "${IMG}" "${HERE}" >/dev/null + +echo "== launch ISOLATED container (--privileged --network none) ==" +docker run -d --rm --name "${CTR}" --privileged --network none "${IMG}" >/dev/null + +# Host-side isolation assertion: docker itself reports 'none' network — no bridge, +# no veth to the host, no published ports. +NETMODE="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "${CTR}")" +PORTS="$(docker inspect -f '{{json .NetworkSettings.Ports}}' "${CTR}")" +echo "host-side check: NetworkMode=${NETMODE} PublishedPorts=${PORTS}" +[ "${NETMODE}" = "none" ] || { echo "REFUSING: container is not on network 'none'" >&2; exit 3; } + +run() { docker exec "${CTR}" bash /m8a/safety-tests.sh; } +if [ -n "${TRANSCRIPT}" ]; then run | tee "${TRANSCRIPT}"; rc="${PIPESTATUS[0]}"; else run; rc=$?; fi + +echo "== safety tests exit: ${rc} ==" +exit "${rc}" diff --git a/tests/m8a-cutover/safety-tests.sh b/tests/m8a-cutover/safety-tests.sh new file mode 100755 index 0000000..8ae1b08 --- /dev/null +++ b/tests/m8a-cutover/safety-tests.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# safety-tests.sh — M8a artifact-safety regression tests (task #40 fixes). +# +# Runs ENTIRELY INSIDE the isolated container netns (--privileged --network none): +# only `lo`, no route to the host, its OWN empty nftables ruleset. nft-only, +# synthetic high ports (55071/55072) — NEVER 45070, NEVER RTP 20000-20100, NEVER +# the host firewall / live voicefw / live stack. Driven by run-safety-tests.sh. +# +# Three red-capable tests. Each proves BOTH the bug (faithful pre-fix behavior => +# RED reproduced) AND the fix (current cutover_lib.sh / apply artifact => GREEN): +# +# T1 cutover_table_down REFUSES to delete a table it did NOT create. +# RED : the original unconditional `nft delete table inet voicefw` WIPES a +# stand-in 'voicefw' firewall it never created. +# GREEN: the guarded cutover_table_down REFUSES (protected-name denylist + +# ownership-marker), and the stand-in table SURVIVES. Also proves the +# ownership gate on a non-denylisted foreign table, and that a table +# this tool DID create still deletes. +# +# T2 a mid-sequence apply failure leaves NO lingering handover drop. +# RED : the pre-fix sequence (drop_on -> STOP fails -> abort, no trap) +# leaves the blanket handover drop STUCK ON (phone outage). +# GREEN: the real apply-fs-to-rustisk.sh with a failing STOP_OLD_CMD aborts +# non-zero, and its EXIT trap clears ALL handover drops (0 left). +# +# T3 double drop_on then one drop_off leaves ZERO handover rules. +# RED : pre-fix non-idempotent drop_on (2 calls => 2 rules) + head -n1 +# drop_off (removes ONE) => 1 blanket drop STUCK. +# GREEN: idempotent drop_on (2 calls => 1 rule) + drop_off removes ALL => 0. +set -uo pipefail +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# cutover_lib.sh sets `-e`; we deliberately run commands expected to FAIL (the +# guard returning non-zero, a failing apply), so drop `-e` and drive rc by hand. +source "${HERE}/cutover_lib.sh" +set +e + +WORK=/m8a/safety-run ; rm -rf "${WORK}" ; mkdir -p "${WORK}" +PASS=0 ; FAIL=0 +ok() { echo " PASS $*"; PASS=$((PASS+1)); } +bad() { echo " FAIL $*"; FAIL=$((FAIL+1)); } +count_handover() { nft -a list chain inet "$1" input 2>/dev/null | grep -c 'comment "handover"'; } + +echo "=================== M8a ARTIFACT-SAFETY TESTS (isolated netns) ===================" +# ---------- ISOLATION assertions (before any nft op) ---------- +echo "--- ISOLATION (must all hold before any nft op) ---" +ip link set lo up 2>/dev/null || true +IFACES=$(ip -o link show | awk -F': ' '{print $2}' | cut -d'@' -f1 | sort | tr '\n' ',') +echo " main-netns links: ${IFACES}" +if ip route get 192.168.0.109 >/dev/null 2>&1; then + echo " REFUSING: host LAN 192.168.0.109 is routable — NOT isolated" >&2 ; exit 3 +fi +echo " host LAN 192.168.0.109: NO ROUTE (isolated) OK" +if [ -n "$(nft list tables 2>/dev/null)" ]; then + echo " REFUSING: pre-existing nft tables present — not a clean private ruleset" >&2 + nft list tables >&2 ; exit 3 +fi +echo " nft ruleset before tests: EMPTY OK" +echo " synthetic ports only: 55071 / 55072 (NEVER 45070 / RTP 20000-20100)" + +# ============================================================================ +echo +echo "=== T1: cutover_table_down refuses to delete a table it did NOT create ===" +# A stand-in 'voicefw' firewall built INSIDE this private netns — its own nft +# ruleset, unrelated to the host's. Same shape as the live firewall: input chain +# (priority -10, policy accept) + a source-allowlist drop. This tool did NOT +# create it (no ownership marker), and the name is on the protected denylist. +make_standin_voicefw() { + nft add table inet voicefw + nft "add chain inet voicefw input { type filter hook input priority -10 ; policy accept ; }" + nft add rule inet voicefw input meta l4proto udp udp dport 55071 ip saddr 10.9.0.3 drop comment '"allowlist-standin"' +} + +# --- RED (pre-fix): the ORIGINAL unconditional cutover_table_down --- +old_cutover_table_down() { nft delete table inet "${NFT_TABLE}" 2>/dev/null || true; } +make_standin_voicefw +NFT_TABLE=voicefw old_cutover_table_down +if nft list table inet voicefw >/dev/null 2>&1; then + bad "RED not reproduced: pre-fix cutover_table_down should have DELETED foreign voicefw" +else + echo " RED (pre-fix): unconditional 'nft delete table inet voicefw' WIPED the foreign firewall (the footgun)" +fi + +# --- GREEN (fixed): guarded cutover_table_down must REFUSE, table survives --- +make_standin_voicefw +NFT_TABLE=voicefw cutover_table_down 2>"${WORK}/t1a.err" +rc=$? +if [ "${rc}" -ne 0 ] && nft list table inet voicefw >/dev/null 2>&1; then + ok "T1a guarded cutover_table_down REFUSED foreign 'voicefw' (rc=${rc}); table SURVIVED" + echo " $(head -1 "${WORK}/t1a.err")" +else + bad "T1a expected refuse+survive for voicefw; rc=${rc}, survived=$(nft list table inet voicefw >/dev/null 2>&1 && echo yes || echo no)" +fi + +# --- GREEN: ownership gate on a NON-denylisted foreign table (proves the marker, +# not just the name list) --- +nft add table inet foreign_tbl +nft "add chain inet foreign_tbl input { type filter hook input priority -10 ; policy accept ; }" +NFT_TABLE=foreign_tbl cutover_table_down 2>"${WORK}/t1b.err" +rc=$? +if [ "${rc}" -ne 0 ] && nft list table inet foreign_tbl >/dev/null 2>&1; then + ok "T1b REFUSED a non-denylisted foreign table lacking the owner marker (rc=${rc}); survived" + echo " $(head -1 "${WORK}/t1b.err")" +else + bad "T1b expected refuse+survive for foreign_tbl; rc=${rc}" +fi + +# --- GREEN: positive control — a table THIS tool created IS deletable --- +PORT=55071 NFT_TABLE=owned_cutover cutover_table_up +NFT_TABLE=owned_cutover cutover_table_down +rc=$? +if [ "${rc}" -eq 0 ] && ! nft list table inet owned_cutover >/dev/null 2>&1; then + ok "T1c cutover_table_down DELETED a table it created (owner marker present, rc=0)" +else + bad "T1c expected clean delete of owned table; rc=${rc}" +fi +nft delete table inet voicefw 2>/dev/null +nft delete table inet foreign_tbl 2>/dev/null + +# ============================================================================ +echo +echo "=== T2: mid-sequence apply failure leaves NO lingering handover drop (EXIT trap) ===" +TT=cutover_trap_test +PORT=55072 NFT_TABLE="${TT}" cutover_table_up + +# --- RED (pre-fix): drop_on -> STOP fails -> set -e aborts BEFORE drop_off, no +# trap => the blanket handover drop is left STUCK ON. Reproduced in a set -e +# subshell so the parent test survives the abort. --- +( + set -e + export NFT_TABLE="${TT}" PORT=55072 + handover_drop_on # drop goes ON + false # STOP_OLD_CMD fails mid-sequence + handover_drop_off # NEVER reached (no trap) => drop stuck ON +) 2>/dev/null +red_left=$(count_handover "${TT}") +if [ "${red_left}" -ge 1 ]; then + echo " RED (pre-fix): mid-sequence failure with NO trap left ${red_left} handover drop(s) STUCK ON (outage)" +else + bad "RED not reproduced: pre-fix should have left the handover drop stuck ON (left=${red_left})" +fi +NFT_TABLE="${TT}" handover_drop_off # clear the stuck drop before GREEN + +# --- GREEN (fixed): the REAL apply artifact with a failing STOP_OLD_CMD. Its +# EXIT trap must clear ALL handover drops even though the sequence aborts. --- +NFT_TABLE="${TT}" PORT=55072 \ +STOP_OLD_CMD="false" START_NEW_CMD="true" \ +WAIT_RELEASED_CMD="true" WAIT_BOUND_CMD="true" \ + bash "${HERE}/apply-fs-to-rustisk.sh" >"${WORK}/t2.out" 2>&1 +rc=$? +green_left=$(count_handover "${TT}") +if [ "${rc}" -ne 0 ] && [ "${green_left}" -eq 0 ]; then + ok "T2 apply FAILED mid-sequence (rc=${rc}) yet EXIT trap cleared ALL handover drops (0 left)" +else + bad "T2 expected failing apply (rc!=0) + 0 lingering drops; rc=${rc} left=${green_left}" +fi +NFT_TABLE="${TT}" cutover_table_down + +# ============================================================================ +echo +echo "=== T3: double drop_on + one drop_off leaves ZERO handover rules ===" +T3=cutover_idem_test +PORT=55071 NFT_TABLE="${T3}" cutover_table_up + +# --- RED (pre-fix): non-idempotent drop_on (plain insert) + head -n1 drop_off --- +old_drop_on() { nft insert rule inet "${NFT_TABLE}" input meta l4proto udp udp dport "${PORT}" drop comment '"handover"'; } +old_drop_off() { + local h + h="$(nft -a list chain inet "${NFT_TABLE}" input 2>/dev/null \ + | awk '/comment "handover"/ {for(i=1;i<=NF;i++) if($i=="handle") print $(i+1)}' \ + | head -n1)" + [ -n "${h}" ] && nft delete rule inet "${NFT_TABLE}" input handle "${h}" +} +NFT_TABLE="${T3}" PORT=55071 old_drop_on +NFT_TABLE="${T3}" PORT=55071 old_drop_on # second call STACKS a duplicate +red_on=$(count_handover "${T3}") +NFT_TABLE="${T3}" old_drop_off # head -n1 removes only ONE +red_off=$(count_handover "${T3}") +if [ "${red_on}" -eq 2 ] && [ "${red_off}" -eq 1 ]; then + echo " RED (pre-fix): 2x old_drop_on => ${red_on} rules; one old_drop_off => ${red_off} left (blanket drop STUCK)" +else + bad "RED not reproduced: expected on=2/off=1, got on=${red_on}/off=${red_off}" +fi +NFT_TABLE="${T3}" old_drop_off ; NFT_TABLE="${T3}" old_drop_off # clean leftover + +# --- GREEN (fixed): idempotent drop_on + drop_off removes ALL --- +NFT_TABLE="${T3}" PORT=55071 handover_drop_on +NFT_TABLE="${T3}" PORT=55071 handover_drop_on # no-op (idempotent) +green_on=$(count_handover "${T3}") +NFT_TABLE="${T3}" handover_drop_off +green_off=$(count_handover "${T3}") +if [ "${green_on}" -eq 1 ] && [ "${green_off}" -eq 0 ]; then + ok "T3 idempotent: 2x drop_on => ${green_on} rule; one drop_off => ${green_off} left" +else + bad "T3 expected on=1/off=0, got on=${green_on}/off=${green_off}" +fi +NFT_TABLE="${T3}" cutover_table_down + +# ============================================================================ +echo +echo "=================== SUMMARY ===================" +echo "PASS=${PASS} FAIL=${FAIL} (expect 5 GREEN asserts, 0 fail; each RED reproduced above)" +# Leave the container's ruleset clean. +for t in voicefw foreign_tbl owned_cutover "${TT}" "${T3}"; do + nft delete table inet "$t" 2>/dev/null +done +if [ "${FAIL}" -eq 0 ] && [ "${PASS}" -ge 5 ]; then + echo "M8a SAFETY TESTS: PASS" + exit 0 +else + echo "M8a SAFETY TESTS: FAIL" + exit 1 +fi