diff --git a/src/mitm/tproxy/commands.ts b/src/mitm/tproxy/commands.ts new file mode 100644 index 0000000000..cd161813fe --- /dev/null +++ b/src/mitm/tproxy/commands.ts @@ -0,0 +1,96 @@ +/** + * 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. + * + * 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. + * + * 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 + */ + +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). */ + 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). */ + routeTable: number; +} + +/** A single command to run via `execFile(bin, args)` — never a shell string. */ +export interface TproxyCommand { + bin: string; + args: string[]; +} + +function isPort(n: number): boolean { + return Number.isInteger(n) && n >= 1 && n <= 65535; +} + +/** + * 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.) + */ +export function validateTproxyConfig(cfg: TproxyConfig): string | null { + if (!isPort(cfg.dport)) return `dport must be a valid TCP port (1-65535), got ${cfg.dport}`; + if (!isPort(cfg.onPort)) return `onPort must be a valid TCP port (1-65535), got ${cfg.onPort}`; + if (!Number.isInteger(cfg.mark) || cfg.mark < 1) return `mark must be a positive integer, got ${cfg.mark}`; + if (!Number.isInteger(cfg.routeTable) || cfg.routeTable < 1) { + return `routeTable must be a positive integer, got ${cfg.routeTable}`; + } + return null; +} + +/** The mangle PREROUTING rule spec, shared so -A and -D match exactly. */ +function tproxyRuleSpec(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), + ]; +} + +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] }; +} + +/** Commands to enable TPROXY interception, 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)] }, + ]; +} + +/** 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)] }, + { 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 new file mode 100644 index 0000000000..317cb9cb8e --- /dev/null +++ b/tests/unit/tproxy-commands.test.ts @@ -0,0 +1,97 @@ +/** + * Fase 3 / Epic A spike — TPROXY transparent capture mode (Linux). + * + * 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). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildTproxyApplyCommands, buildTproxyRevertCommands, validateTproxyConfig } = await import( + "../../src/mitm/tproxy/commands.ts" +); + +const CFG = { dport: 443, mark: 1, onPort: 8443, routeTable: 100 }; + +test("apply builds the three TPROXY/policy-routing commands in order", () => { + const cmds = buildTproxyApplyCommands(CFG); + assert.equal(cmds.length, 3); + + // 1) mangle PREROUTING TPROXY rule + assert.deepEqual(cmds[0], { + bin: "iptables", + args: [ + "-t", "mangle", "-A", "PREROUTING", + "-p", "tcp", "--dport", "443", + "-j", "TPROXY", "--tproxy-mark", "1", "--on-port", "8443", + ], + }); + // 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("every arg is a string (execFile-safe, Hard Rule #13)", () => { + for (const cmd of [...buildTproxyApplyCommands(CFG), ...buildTproxyRevertCommands(CFG)]) { + 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 revert = buildTproxyRevertCommands(CFG); + assert.equal(revert.length, 3); + + // 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 + assert.deepEqual(revert[2], { + bin: "iptables", + args: [ + "-t", "mangle", "-D", "PREROUTING", + "-p", "tcp", "--dport", "443", + "-j", "TPROXY", "--tproxy-mark", "1", "--on-port", "8443", + ], + }); +}); + +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; + 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" + ); +}); + +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")); +}); + +test("validateTproxyConfig accepts a sane config and rejects bad ports/marks", () => { + assert.equal(validateTproxyConfig(CFG), 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); +});