mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(mitm): Fase 3 Epic A spike — TPROXY command builder (#4139)
Fase 3 spike — TPROXY command builder (pure, execFile-safe {bin,args[]} per Rule #13) + ProxyBridge capture-source decision. Integrado em release/v3.8.29.
This commit is contained in:
committed by
GitHub
parent
541ff3bfa7
commit
ce8157caef
96
src/mitm/tproxy/commands.ts
Normal file
96
src/mitm/tproxy/commands.ts
Normal file
@@ -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 <op> 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),
|
||||
];
|
||||
}
|
||||
97
tests/unit/tproxy-commands.test.ts
Normal file
97
tests/unit/tproxy-commands.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user