From 7170d712b45123970e7dc0c27acbcea21bd8ee25 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:13:56 -0300 Subject: [PATCH] =?UTF-8?q?feat(mitm):=20TPROXY=20setup=20layer=20?= =?UTF-8?q?=E2=80=94=20transactional=20apply/revert=20(Epic=20A)=20(#4144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TPROXY setup layer — transactional apply/revert (Epic A). execFile runner (Rule #13, args array, no shell) over the VPS-validated command builder (#4139); apply rolls back on mid-way failure, revert idempotent. Integrado em release/v3.8.29. --- src/mitm/tproxy/setup.ts | 68 +++++++++++++++++++++++++++++++ tests/unit/tproxy-setup.test.ts | 71 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/mitm/tproxy/setup.ts create mode 100644 tests/unit/tproxy-setup.test.ts diff --git a/src/mitm/tproxy/setup.ts b/src/mitm/tproxy/setup.ts new file mode 100644 index 0000000000..6089d47539 --- /dev/null +++ b/src/mitm/tproxy/setup.ts @@ -0,0 +1,68 @@ +/** + * Fase 3 / Epic A — TPROXY setup layer: transactional apply/revert. + * + * Wraps the pure command builder (commands.ts) with an execFile runner and the + * crash-safe invariant: a partial apply never leaves firewall/routing state + * behind. The builder's output was validated against a real kernel on the VPS + * (apply/revert accepted, exact-inverse, zero impact on non-targeted traffic — + * PR #4139). The runner is injectable so the orchestration is unit-testable + * without root; the default runner uses `execFile` with an args array (Hard + * Rule #13 — never a shell string). + * + * NOT in this layer (gated on a live intercept, needs CAP_NET_ADMIN + traffic): + * the IP_TRANSPARENT listener (`listener.cjs`), the capture-mode route, and the + * UI tab. `repairMitm()` should also call `revertTproxy()` once a config is + * persisted, so a crash flushes the mangle rules too. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + buildTproxyApplyCommands, + buildTproxyRevertCommands, + validateTproxyConfig, + type TproxyConfig, +} from "./commands"; + +const execFileAsync = promisify(execFile); + +/** Runs a single command. Injected in tests; defaults to execFile (no shell). */ +export type CommandRunner = (bin: string, args: string[]) => Promise; + +const defaultRunner: CommandRunner = async (bin, args) => { + await execFileAsync(bin, args); +}; + +/** + * Enable TPROXY interception. Runs the apply commands in order; if any step + * fails, runs a best-effort full revert (so a half-applied rule set never + * lingers) and rethrows the original error. + */ +export async function applyTproxy(cfg: TproxyConfig, run: CommandRunner = defaultRunner): Promise { + const invalid = validateTproxyConfig(cfg); + if (invalid) throw new Error(invalid); + + try { + for (const cmd of buildTproxyApplyCommands(cfg)) { + await run(cmd.bin, cmd.args); + } + } catch (err) { + await revertTproxy(cfg, run); // best-effort cleanup of whatever was applied + throw err instanceof Error ? err : new Error(String(err)); + } +} + +/** + * Disable TPROXY interception. Best-effort and idempotent: each revert command + * may fail if its rule isn't present (e.g. only a partial apply happened, or a + * prior crash) — those failures are swallowed so a clean teardown always runs + * to completion. Safe for `repairMitm()` to call unconditionally. + */ +export async function revertTproxy(cfg: TproxyConfig, run: CommandRunner = defaultRunner): Promise { + for (const cmd of buildTproxyRevertCommands(cfg)) { + try { + await run(cmd.bin, cmd.args); + } catch { + // idempotent: rule/route/rule-entry may not exist — keep going. + } + } +} diff --git a/tests/unit/tproxy-setup.test.ts b/tests/unit/tproxy-setup.test.ts new file mode 100644 index 0000000000..b0625adf2a --- /dev/null +++ b/tests/unit/tproxy-setup.test.ts @@ -0,0 +1,71 @@ +/** + * Fase 3 / Epic A — TPROXY setup layer (execFile wiring over the command builder). + * + * The builder (commands.ts) was validated against a real kernel on the VPS + * (apply/revert accepted, exact-inverse, zero impact on non-targeted traffic — + * see PR #4139). This layer adds the transactional runner: apply runs the + * commands in order and, if any step fails mid-way, runs a best-effort full + * revert so a partial apply never leaves firewall/routing state behind (the + * Fase 1 / repairMitm crash-safe invariant). revert is itself idempotent / + * best-effort. The command runner is injected so this is unit-testable without + * root; the real runner uses execFile (Hard Rule #13 — args array, no shell). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { applyTproxy, revertTproxy } = await import("../../src/mitm/tproxy/setup.ts"); + +const CFG = { dport: 443, mark: 1, onPort: 8443, routeTable: 100 }; + +function recorder(failOnIndex = -1) { + const calls: Array<{ bin: string; args: string[] }> = []; + const run = async (bin: string, args: string[]) => { + calls.push({ bin, args }); + if (calls.length - 1 === failOnIndex) throw new Error(`boom at ${failOnIndex}`); + }; + return { calls, run }; +} + +test("applyTproxy runs the 3 apply commands in order via the injected runner", async () => { + const r = recorder(); + await applyTproxy(CFG, r.run); + assert.equal(r.calls.length, 3); + assert.equal(r.calls[0].bin, "iptables"); + assert.deepEqual(r.calls[0].args.slice(0, 4), ["-t", "mangle", "-A", "PREROUTING"]); + assert.deepEqual(r.calls[1], { bin: "ip", args: ["rule", "add", "fwmark", "1", "lookup", "100"] }); + assert.equal(r.calls[2].args[0], "route"); +}); + +test("applyTproxy rejects an invalid config before running anything", async () => { + const r = recorder(); + await assert.rejects(() => applyTproxy({ ...CFG, dport: 0 }, r.run), /dport/i); + assert.equal(r.calls.length, 0, "no command runs when the config is invalid"); +}); + +test("applyTproxy runs a best-effort full revert when a command fails mid-way", async () => { + const r = recorder(1); // fail on the 2nd apply command (ip rule add) + await assert.rejects(() => applyTproxy(CFG, r.run), /boom/); + // apply[0], apply[1]=fail, then the 3 revert commands (best-effort cleanup) + assert.equal(r.calls.length, 5); + assert.deepEqual(r.calls[2], { + bin: "ip", + args: ["route", "del", "local", "default", "dev", "lo", "table", "100"], + }); + assert.deepEqual(r.calls[4].args.slice(0, 4), ["-t", "mangle", "-D", "PREROUTING"]); +}); + +test("revertTproxy runs all 3 reverts best-effort even if one fails (idempotent)", async () => { + const r = recorder(1); // 2nd revert throws (e.g. rule not present) + await revertTproxy(CFG, r.run); // must NOT throw + assert.equal(r.calls.length, 3, "all three reverts attempted despite the failure"); +}); + +test("every command the runner receives has string args (execFile-safe)", async () => { + const r = recorder(); + await applyTproxy(CFG, r.run); + await revertTproxy(CFG, r.run); + for (const c of r.calls) { + assert.ok(typeof c.bin === "string"); + for (const a of c.args) assert.equal(typeof a, "string"); + } +});