mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
feat(mitm): TPROXY setup layer — transactional apply/revert (Epic A) (#4144)
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.
This commit is contained in:
committed by
GitHub
parent
2c09f5c0c3
commit
7170d712b4
68
src/mitm/tproxy/setup.ts
Normal file
68
src/mitm/tproxy/setup.ts
Normal file
@@ -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<void>;
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
for (const cmd of buildTproxyRevertCommands(cfg)) {
|
||||
try {
|
||||
await run(cmd.bin, cmd.args);
|
||||
} catch {
|
||||
// idempotent: rule/route/rule-entry may not exist — keep going.
|
||||
}
|
||||
}
|
||||
}
|
||||
71
tests/unit/tproxy-setup.test.ts
Normal file
71
tests/unit/tproxy-setup.test.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user