109 lines
4.7 KiB
TypeScript
109 lines
4.7 KiB
TypeScript
/**
|
|
* Fase 3 / Epic A — TPROXY command builder (OUTPUT-based recipe).
|
|
*
|
|
* 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";
|
|
|
|
const { buildTproxyApplyCommands, buildTproxyRevertCommands, validateTproxyConfig } = await import(
|
|
"../../src/mitm/tproxy/commands.ts"
|
|
);
|
|
|
|
const CFG = { dport: 443, mark: 9011, onPort: 8443, routeTable: 233 };
|
|
|
|
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, 4);
|
|
|
|
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",
|
|
"-m", "mark", "--mark", "9011", "-j", "TPROXY", "--on-port", "8443", "--tproxy-mark", "9011",
|
|
],
|
|
});
|
|
});
|
|
|
|
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)", () => {
|
|
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, 4);
|
|
|
|
// 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: "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"] });
|
|
|
|
// -A and -D rule specs match exactly except the op flag (so -D removes the exact -A rule)
|
|
assert.deepEqual(
|
|
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[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 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);
|
|
});
|