diff --git a/src/mitm/tproxy/commands.ts b/src/mitm/tproxy/commands.ts index cd161813fe..69fa109619 100644 --- a/src/mitm/tproxy/commands.ts +++ b/src/mitm/tproxy/commands.ts @@ -1,36 +1,48 @@ /** * Fase 3 / Epic A — TPROXY transparent capture mode (Linux): command builder. * - * This is the spike artifact for Gap 2: a 5th capture mode that intercepts TCP - * transparently via Linux TPROXY + policy routing, WITHOUT spoofing `/etc/hosts` - * or mutating OS-wide system-proxy settings (so it is headless-friendly and - * auto-flushed on reboot). The kernel listener (IP_TRANSPARENT socket) and the - * live execution wiring are gated on a real-Linux/VPS validation (Hard Rule - * #18) and are intentionally NOT in this module. + * A 5th capture mode that intercepts TCP transparently via Linux TPROXY + policy + * routing, WITHOUT spoofing `/etc/hosts` or mutating OS-wide system-proxy + * settings (headless-friendly, auto-flushed on reboot). * - * What lives here is pure + unit-testable: the exact `iptables` / `ip` commands - * for apply and revert, with the invariant that **revert is the precise inverse - * of apply, in reverse order** — a crash must never leave a mangle rule behind - * (the very invariant Fase 1 / `repairMitm()` establishes). When the Epic is - * built, `setup.ts` will run these via `execFile` (arrays, never a shell string - * — Hard Rule #13) and `repairMitm()` will additionally flush them. + * ⚠️ OUTPUT-based recipe (validated e2e on the VPS, kernel 6.8.0): the MITM use + * case is LOCAL outbound traffic (IDE agents on the same host), which TPROXY in + * PREROUTING does NOT see — PREROUTING only sees *forwarded* traffic. So we mark + * new local outbound connections in the `mangle OUTPUT` chain, an `ip rule` + * reroutes the marked packets to local delivery (`lo`), and on re-entry the + * `mangle PREROUTING` TPROXY target assigns them to the IP_TRANSPARENT listener. + * (An earlier PREROUTING-only recipe was proven not to intercept local traffic.) * - * Reference design (deep-research, confidence high): - * iptables -t mangle -A PREROUTING -p tcp --dport 443 \ - * -j TPROXY --tproxy-mark 1 --on-port 8443 - * ip rule add fwmark 1 lookup 100 - * ip route add local default dev lo table 100 + * Validated recipe (test port 9999, fwmark 0x2333 — isolated from prod 443/80): + * iptables -t mangle -A OUTPUT -p tcp --dport N [-m mark ! --mark BYPASS] -j MARK --set-mark M + * ip rule add fwmark M lookup T + * ip route add local 0.0.0.0/0 dev lo table T + * iptables -t mangle -A PREROUTING -p tcp --dport N -m mark --mark M -j TPROXY --on-port L --tproxy-mark M + * Result: client CONNECTED, listener saw orig-dest preserved (198.51.100.7:9999). + * + * Pure + unit-testable: the exact `iptables`/`ip` commands for apply and revert, + * with the invariant that **revert is the precise inverse of apply, in reverse + * order** — a crash must never leave a mangle rule behind (the Fase 1 / + * `repairMitm()` invariant). `setup.ts` runs these via `execFile` (arrays, never + * a shell string — Hard Rule #13). + * + * `bypassMark` (anti-loop): the SO_MARK the proxy sets on its OWN upstream + * connections; the OUTPUT rule excludes it so the proxy's forwarded traffic is + * not re-intercepted (infinite loop). When omitted, no exclusion is emitted + * (fine for a metadata-only listener that never forwards). */ export interface TproxyConfig { /** Destination TCP port to transparently intercept (e.g. 443). */ dport: number; - /** Firewall mark applied by TPROXY and matched by the ip rule (e.g. 1). */ + /** Firewall mark set on OUTPUT and matched by the ip rule + PREROUTING (e.g. 0x2333). */ mark: number; /** Local port the IP_TRANSPARENT listener binds (e.g. 8443). */ onPort: number; - /** Policy-routing table id holding the `local default` route (e.g. 100). */ + /** Policy-routing table id holding the `local 0.0.0.0/0` route (e.g. 233). */ routeTable: number; + /** SO_MARK the proxy sets on its own upstream conns; excluded in OUTPUT (anti-loop). */ + bypassMark?: number; } /** A single command to run via `execFile(bin, args)` — never a shell string. */ @@ -45,8 +57,7 @@ function isPort(n: number): boolean { /** * Validate a config before any command is built/run. Returns an error message - * string, or null when the config is sane. (Cheap guard so the future execFile - * path never shells out malformed numbers.) + * string, or null when the config is sane. */ export function validateTproxyConfig(cfg: TproxyConfig): string | null { if (!isPort(cfg.dport)) return `dport must be a valid TCP port (1-65535), got ${cfg.dport}`; @@ -55,42 +66,57 @@ export function validateTproxyConfig(cfg: TproxyConfig): string | null { if (!Number.isInteger(cfg.routeTable) || cfg.routeTable < 1) { return `routeTable must be a positive integer, got ${cfg.routeTable}`; } + if (cfg.bypassMark !== undefined) { + if (!Number.isInteger(cfg.bypassMark) || cfg.bypassMark < 1) { + return `bypassMark must be a positive integer when set, got ${cfg.bypassMark}`; + } + if (cfg.bypassMark === cfg.mark) return "bypassMark must differ from mark (anti-loop)"; + } return null; } -/** The mangle PREROUTING rule spec, shared so -A and -D match exactly. */ -function tproxyRuleSpec(cfg: TproxyConfig): string[] { +/** OUTPUT mangle rule spec (mark new local outbound conns), shared so -A/-D match. */ +function outputRuleSpec(cfg: TproxyConfig): string[] { + const spec = ["-t", "mangle", "OUTPUT", "-p", "tcp", "--dport", String(cfg.dport)]; + if (cfg.bypassMark !== undefined) { + spec.push("-m", "mark", "!", "--mark", String(cfg.bypassMark)); + } + spec.push("-j", "MARK", "--set-mark", String(cfg.mark)); + return spec; +} + +/** PREROUTING mangle TPROXY rule spec (assign marked, rerouted packets to the listener). */ +function preroutingRuleSpec(cfg: TproxyConfig): string[] { return [ - "-t", "mangle", - "PREROUTING", - "-p", "tcp", - "--dport", String(cfg.dport), - "-j", "TPROXY", - "--tproxy-mark", String(cfg.mark), - "--on-port", String(cfg.onPort), + "-t", "mangle", "PREROUTING", + "-p", "tcp", "--dport", String(cfg.dport), + "-m", "mark", "--mark", String(cfg.mark), + "-j", "TPROXY", "--on-port", String(cfg.onPort), "--tproxy-mark", String(cfg.mark), ]; } -function iptables(op: "-A" | "-D", cfg: TproxyConfig): TproxyCommand { - const [table, tableName, chain, ...rest] = tproxyRuleSpec(cfg); - // Reassemble as: -t mangle PREROUTING ...rest - return { bin: "iptables", args: [table, tableName, op, chain, ...rest] }; +/** Build an iptables command from a `[-t table, CHAIN, ...rest]` spec + the op flag. */ +function iptables(op: "-A" | "-D", spec: string[]): TproxyCommand { + const [t, table, chain, ...rest] = spec; + return { bin: "iptables", args: [t, table, op, chain, ...rest] }; } -/** Commands to enable TPROXY interception, in apply order. */ +/** Commands to enable TPROXY interception of local outbound traffic, in apply order. */ export function buildTproxyApplyCommands(cfg: TproxyConfig): TproxyCommand[] { return [ - iptables("-A", cfg), { bin: "ip", args: ["rule", "add", "fwmark", String(cfg.mark), "lookup", String(cfg.routeTable)] }, - { bin: "ip", args: ["route", "add", "local", "default", "dev", "lo", "table", String(cfg.routeTable)] }, + { bin: "ip", args: ["route", "add", "local", "0.0.0.0/0", "dev", "lo", "table", String(cfg.routeTable)] }, + iptables("-A", outputRuleSpec(cfg)), + iptables("-A", preroutingRuleSpec(cfg)), ]; } /** Commands to undo TPROXY interception — exact inverse of apply, reverse order. */ export function buildTproxyRevertCommands(cfg: TproxyConfig): TproxyCommand[] { return [ - { bin: "ip", args: ["route", "del", "local", "default", "dev", "lo", "table", String(cfg.routeTable)] }, + iptables("-D", preroutingRuleSpec(cfg)), + iptables("-D", outputRuleSpec(cfg)), + { bin: "ip", args: ["route", "del", "local", "0.0.0.0/0", "dev", "lo", "table", String(cfg.routeTable)] }, { bin: "ip", args: ["rule", "del", "fwmark", String(cfg.mark), "lookup", String(cfg.routeTable)] }, - iptables("-D", cfg), ]; } diff --git a/tests/unit/tproxy-commands.test.ts b/tests/unit/tproxy-commands.test.ts index 317cb9cb8e..106361ef11 100644 --- a/tests/unit/tproxy-commands.test.ts +++ b/tests/unit/tproxy-commands.test.ts @@ -1,15 +1,14 @@ /** - * Fase 3 / Epic A spike — TPROXY transparent capture mode (Linux). + * Fase 3 / Epic A — TPROXY command builder (OUTPUT-based recipe). * - * The kernel wiring (IP_TRANSPARENT listener, live intercept) cannot be unit- - * tested here — it needs CAP_NET_ADMIN + a real kernel and is gated on a VPS - * live test (Hard Rule #18). What CAN be locked down now, with no root, is the - * exact set of iptables / ip-rule / ip-route commands and the invariant that - * revert is the precise inverse of apply (in reverse order). A leftover mangle - * rule after a crash is the very failure Fase 1 set out to prevent, so this - * builder is the spec the VPS spike will execute and the future execFile wiring - * will consume. Commands are produced as {bin, args[]} for execFile — never a - * shell string (Hard Rule #13). + * The recipe was validated end-to-end on the VPS (kernel 6.8.0): a local + * outbound connection to a test port was marked in OUTPUT, rerouted to local + * delivery, and the PREROUTING TPROXY target assigned it to the IP_TRANSPARENT + * listener (client CONNECTED, original destination preserved). These tests pin + * the exact commands + the invariant that revert is the precise inverse of + * apply, in reverse order (a leftover mangle rule after a crash is the failure + * Fase 1 prevents). Commands are {bin, args[]} for execFile — never a shell + * string (Hard Rule #13). */ import test from "node:test"; import assert from "node:assert/strict"; @@ -18,80 +17,92 @@ const { buildTproxyApplyCommands, buildTproxyRevertCommands, validateTproxyConfi "../../src/mitm/tproxy/commands.ts" ); -const CFG = { dport: 443, mark: 1, onPort: 8443, routeTable: 100 }; +const CFG = { dport: 443, mark: 9011, onPort: 8443, routeTable: 233 }; -test("apply builds the three TPROXY/policy-routing commands in order", () => { +test("apply builds the 4 OUTPUT-based commands in order (ip rule, ip route, OUTPUT mark, PREROUTING TPROXY)", () => { const cmds = buildTproxyApplyCommands(CFG); - assert.equal(cmds.length, 3); + assert.equal(cmds.length, 4); - // 1) mangle PREROUTING TPROXY rule - assert.deepEqual(cmds[0], { + assert.deepEqual(cmds[0], { bin: "ip", args: ["rule", "add", "fwmark", "9011", "lookup", "233"] }); + assert.deepEqual(cmds[1], { + bin: "ip", + args: ["route", "add", "local", "0.0.0.0/0", "dev", "lo", "table", "233"], + }); + // OUTPUT marks new local outbound connections to the target port + assert.deepEqual(cmds[2], { + bin: "iptables", + args: ["-t", "mangle", "-A", "OUTPUT", "-p", "tcp", "--dport", "443", "-j", "MARK", "--set-mark", "9011"], + }); + // PREROUTING TPROXY assigns the rerouted, marked packets to the listener + assert.deepEqual(cmds[3], { bin: "iptables", args: [ - "-t", "mangle", "-A", "PREROUTING", - "-p", "tcp", "--dport", "443", - "-j", "TPROXY", "--tproxy-mark", "1", "--on-port", "8443", + "-t", "mangle", "-A", "PREROUTING", "-p", "tcp", "--dport", "443", + "-m", "mark", "--mark", "9011", "-j", "TPROXY", "--on-port", "8443", "--tproxy-mark", "9011", ], }); - // 2) ip rule fwmark -> table - assert.deepEqual(cmds[1], { bin: "ip", args: ["rule", "add", "fwmark", "1", "lookup", "100"] }); - // 3) local default route in that table - assert.deepEqual(cmds[2], { - bin: "ip", - args: ["route", "add", "local", "default", "dev", "lo", "table", "100"], - }); +}); + +test("bypassMark adds the anti-loop exclusion to the OUTPUT rule", () => { + const cmds = buildTproxyApplyCommands({ ...CFG, bypassMark: 1337 }); + const output = cmds[2].args; + assert.deepEqual(output, [ + "-t", "mangle", "-A", "OUTPUT", "-p", "tcp", "--dport", "443", + "-m", "mark", "!", "--mark", "1337", "-j", "MARK", "--set-mark", "9011", + ]); }); test("every arg is a string (execFile-safe, Hard Rule #13)", () => { - for (const cmd of [...buildTproxyApplyCommands(CFG), ...buildTproxyRevertCommands(CFG)]) { + const all = [ + ...buildTproxyApplyCommands(CFG), + ...buildTproxyRevertCommands(CFG), + ...buildTproxyApplyCommands({ ...CFG, bypassMark: 1337 }), + ]; + for (const cmd of all) { assert.ok(typeof cmd.bin === "string" && cmd.bin.length > 0); for (const a of cmd.args) assert.equal(typeof a, "string", `arg ${a} must be a string`); } }); test("revert is the exact inverse of apply, in reverse order", () => { + const apply = buildTproxyApplyCommands(CFG); const revert = buildTproxyRevertCommands(CFG); - assert.equal(revert.length, 3); + assert.equal(revert.length, 4); - // route del (reverse order: route added last is torn down first) - assert.deepEqual(revert[0], { - bin: "ip", - args: ["route", "del", "local", "default", "dev", "lo", "table", "100"], - }); - // rule del - assert.deepEqual(revert[1], { bin: "ip", args: ["rule", "del", "fwmark", "1", "lookup", "100"] }); - // iptables -D mirrors -A exactly except the operation flag + // reverse order: PREROUTING -D, OUTPUT -D, route del, rule del + assert.deepEqual(revert[0].args.slice(0, 4), ["-t", "mangle", "-D", "PREROUTING"]); + assert.deepEqual(revert[1].args.slice(0, 4), ["-t", "mangle", "-D", "OUTPUT"]); assert.deepEqual(revert[2], { - bin: "iptables", - args: [ - "-t", "mangle", "-D", "PREROUTING", - "-p", "tcp", "--dport", "443", - "-j", "TPROXY", "--tproxy-mark", "1", "--on-port", "8443", - ], + bin: "ip", + args: ["route", "del", "local", "0.0.0.0/0", "dev", "lo", "table", "233"], }); -}); + assert.deepEqual(revert[3], { bin: "ip", args: ["rule", "del", "fwmark", "9011", "lookup", "233"] }); -test("apply -A and revert -D differ only in the iptables operation flag", () => { - const apply = buildTproxyApplyCommands(CFG)[0].args; - const revert = buildTproxyRevertCommands(CFG)[2].args; + // -A and -D rule specs match exactly except the op flag (so -D removes the exact -A rule) assert.deepEqual( - apply.map((a) => (a === "-A" ? "OP" : a)), - revert.map((a) => (a === "-D" ? "OP" : a)), - "the rule spec must be identical so -D matches the exact -A rule" + apply[3].args.map((a) => (a === "-A" ? "OP" : a)), + revert[0].args.map((a) => (a === "-D" ? "OP" : a)) + ); + assert.deepEqual( + apply[2].args.map((a) => (a === "-A" ? "OP" : a)), + revert[1].args.map((a) => (a === "-D" ? "OP" : a)) ); }); test("config values flow into the commands (no hardcoding)", () => { const custom = { dport: 8443, mark: 7, onPort: 9999, routeTable: 200 }; const cmds = buildTproxyApplyCommands(custom); - assert.ok(cmds[0].args.includes("8443") && cmds[0].args.includes("9999") && cmds[0].args.includes("7")); - assert.ok(cmds[1].args.includes("200")); + assert.ok(cmds[2].args.includes("8443") && cmds[2].args.includes("7")); + assert.ok(cmds[3].args.includes("9999") && cmds[3].args.includes("200") === false); // table not in TPROXY rule + assert.ok(cmds[0].args.includes("200")); }); -test("validateTproxyConfig accepts a sane config and rejects bad ports/marks", () => { +test("validateTproxyConfig accepts a sane config and rejects bad values", () => { assert.equal(validateTproxyConfig(CFG), null); + assert.equal(validateTproxyConfig({ ...CFG, bypassMark: 1337 }), null); assert.match(validateTproxyConfig({ ...CFG, dport: 0 }) ?? "", /dport/i); assert.match(validateTproxyConfig({ ...CFG, onPort: 70000 }) ?? "", /onPort/i); assert.match(validateTproxyConfig({ ...CFG, mark: 0 }) ?? "", /mark/i); assert.match(validateTproxyConfig({ ...CFG, routeTable: -1 }) ?? "", /table/i); + assert.match(validateTproxyConfig({ ...CFG, bypassMark: CFG.mark }) ?? "", /bypassMark/i); }); diff --git a/tests/unit/tproxy-setup.test.ts b/tests/unit/tproxy-setup.test.ts index b0625adf2a..f277112fd6 100644 --- a/tests/unit/tproxy-setup.test.ts +++ b/tests/unit/tproxy-setup.test.ts @@ -26,14 +26,14 @@ function recorder(failOnIndex = -1) { return { calls, run }; } -test("applyTproxy runs the 3 apply commands in order via the injected runner", async () => { +test("applyTproxy runs the 4 OUTPUT-based 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"); + assert.equal(r.calls.length, 4); + assert.deepEqual(r.calls[0], { bin: "ip", args: ["rule", "add", "fwmark", "1", "lookup", "100"] }); + assert.equal(r.calls[1].args[0], "route"); + assert.deepEqual(r.calls[2].args.slice(0, 4), ["-t", "mangle", "-A", "OUTPUT"]); + assert.deepEqual(r.calls[3].args.slice(0, 4), ["-t", "mangle", "-A", "PREROUTING"]); }); test("applyTproxy rejects an invalid config before running anything", async () => { @@ -43,21 +43,20 @@ test("applyTproxy rejects an invalid config before running anything", async () = }); 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) + const r = recorder(1); // fail on the 2nd apply command (ip route 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"]); + // apply[0], apply[1]=fail, then the 4 revert commands (best-effort cleanup) = 6 + assert.equal(r.calls.length, 6); + // first revert command tears down the PREROUTING rule (reverse order) + assert.deepEqual(r.calls[2].args.slice(0, 4), ["-t", "mangle", "-D", "PREROUTING"]); + // last revert command removes the ip rule + assert.deepEqual(r.calls[5], { bin: "ip", args: ["rule", "del", "fwmark", "1", "lookup", "100"] }); }); -test("revertTproxy runs all 3 reverts best-effort even if one fails (idempotent)", async () => { +test("revertTproxy runs all 4 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"); + assert.equal(r.calls.length, 4, "all four reverts attempted despite the failure"); }); test("every command the runner receives has string args (execFile-safe)", async () => {