Files
2026-07-13 13:39:12 +08:00

266 lines
8.3 KiB
TypeScript

/**
* Fase 3 / Epic A — TPROXY capture-mode listener.
*
* Ties the validated primitives together: apply OUTPUT-based rules → open the
* IP_TRANSPARENT listener → per connection, read the ORIGINAL destination
* (socket.localAddress — TPROXY preserves it), record it, and forward to that
* destination over a SO_MARK-bypass socket (anti-loop) with a raw pipe.
*
* All seams are injected so the orchestration + per-connection logic are
* unit-testable without root. The real intercept/anti-loop/adoption were each
* validated e2e on the VPS (kernel 6.8.0).
*/
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
const { normalizeDest, handleTproxyConnection, startTproxyCapture } = await import(
"../../src/mitm/tproxy/captureMode.ts"
);
const CFG = { dport: 443, mark: 0x2333, onPort: 8443, routeTable: 233, bypassMark: 0x539 };
test("normalizeDest strips the IPv4-mapped-IPv6 prefix Node reports", () => {
assert.equal(normalizeDest("::ffff:1.2.3.4"), "1.2.3.4");
assert.equal(normalizeDest("1.2.3.4"), "1.2.3.4");
assert.equal(normalizeDest("2001:db8::1"), "2001:db8::1");
assert.equal(normalizeDest(undefined), "");
});
function fakeSocket(localAddress: string, localPort: number) {
const s = new EventEmitter() as EventEmitter & {
localAddress: string;
localPort: number;
destroyed: boolean;
destroy: () => void;
pipe: (d: unknown) => unknown;
};
s.localAddress = localAddress;
s.localPort = localPort;
s.destroyed = false;
s.destroy = () => {
s.destroyed = true;
};
s.pipe = (d: unknown) => d;
return s;
}
test("handleTproxyConnection reads orig dest, reports it, and forwards via connectMarked(bypass)", () => {
const client = fakeSocket("::ffff:140.82.112.3", 443);
const calls: Array<{ ip: string; port: number; mark: number }> = [];
const intercepts: Array<{ destIp: string; destPort: number }> = [];
const deps = {
connectMarked: (ip: string, port: number, mark: number) => {
calls.push({ ip, port, mark });
return 99; // fake fd; upstream-socket factory is injected below
},
createUpstreamSocket: () => fakeSocket("", 0),
};
handleTproxyConnection(client as never, CFG, deps as never, (i) => intercepts.push(i));
assert.deepEqual(intercepts, [{ destIp: "140.82.112.3", destPort: 443 }]);
assert.deepEqual(calls, [{ ip: "140.82.112.3", port: 443, mark: 0x539 }]);
});
test("handleTproxyConnection destroys the client when the destination is unreadable", () => {
const client = fakeSocket("", 0);
let connectCalled = false;
const deps = {
connectMarked: () => {
connectCalled = true;
return 1;
},
createUpstreamSocket: () => fakeSocket("", 0),
};
handleTproxyConnection(client as never, CFG, deps as never);
assert.equal(client.destroyed, true);
assert.equal(connectCalled, false, "must not dial when there is no original destination");
});
test("startTproxyCapture applies rules, opens the listener, and stop() reverts", async () => {
const order: string[] = [];
const server = new EventEmitter() as EventEmitter & {
listen: (opts: unknown, cb: () => void) => void;
close: (cb: () => void) => void;
};
server.listen = (_opts, cb) => {
order.push("listen");
cb();
};
server.close = (cb) => {
order.push("close");
cb();
};
const deps = {
applyTproxy: async () => {
order.push("apply");
},
revertTproxy: async () => {
order.push("revert");
},
createListenerFd: () => {
order.push("createFd");
return 20;
},
connectMarked: () => 1,
createServer: () => server,
createUpstreamSocket: () => fakeSocket("", 0),
};
const handle = await startTproxyCapture(CFG, { deps: deps as never });
assert.deepEqual(order, ["apply", "createFd", "listen"]);
await handle.stop();
assert.deepEqual(order, ["apply", "createFd", "listen", "close", "revert"]);
});
test("startTproxyCapture rejects an invalid config and reverts nothing", async () => {
const order: string[] = [];
const deps = {
applyTproxy: async () => order.push("apply"),
revertTproxy: async () => order.push("revert"),
createListenerFd: () => 20,
connectMarked: () => 1,
createServer: () => new EventEmitter(),
createUpstreamSocket: () => fakeSocket("", 0),
};
await assert.rejects(
() => startTproxyCapture({ ...CFG, dport: 0 }, { deps: deps as never }),
/dport/i
);
assert.deepEqual(order, [], "invalid config must not apply any rules");
});
test("handleTproxyConnection in decrypt mode hands the socket to terminate (no raw dial)", () => {
const client = fakeSocket("::ffff:140.82.112.3", 443);
let dialed = false;
const terminated: Array<{ ip: string; port: number }> = [];
const deps = {
connectMarked: () => {
dialed = true;
return 1;
},
createUpstreamSocket: () => fakeSocket("", 0),
};
handleTproxyConnection(client as never, CFG, deps as never, undefined, (_c, dest) =>
terminated.push(dest)
);
assert.deepEqual(terminated, [{ ip: "140.82.112.3", port: 443 }]);
assert.equal(dialed, false, "decrypt path must not raw-dial via connectMarked");
});
test("handleTproxyConnection decrypt mode still drops a connection with no destination", () => {
const client = fakeSocket("", 0);
let terminateCalled = false;
handleTproxyConnection(
client as never,
CFG,
{ connectMarked: () => 1, createUpstreamSocket: () => fakeSocket("", 0) } as never,
undefined,
() => {
terminateCalled = true;
}
);
assert.equal(client.destroyed, true);
assert.equal(terminateCalled, false, "no destination → never reach the engine");
});
test("startTproxyCapture decrypt mode installs the CA, wires the engine, stop() uninstalls + reverts", async () => {
const order: string[] = [];
const server = new EventEmitter() as EventEmitter & {
listen: (opts: unknown, cb: () => void) => void;
close: (cb: () => void) => void;
};
server.listen = (_opts, cb) => {
order.push("listen");
cb();
};
server.close = (cb) => {
order.push("close");
cb();
};
const deps = {
applyTproxy: async () => {
order.push("apply");
},
revertTproxy: async () => {
order.push("revert");
},
createListenerFd: () => {
order.push("createFd");
return 20;
},
connectMarked: () => 1,
createServer: () => server,
createUpstreamSocket: () => fakeSocket("", 0),
};
let installedPem: string | undefined;
const certStore = {
createSNICallback:
() => (_name: string, cb: (e: Error | null, ctx?: unknown) => void) => cb(null, {}),
getCaCertPem: async () => "CA-CERT-PEM",
};
const handle = await startTproxyCapture(CFG, {
deps: deps as never,
decrypt: {
certStore: certStore as never,
installCa: async (pem: string) => {
installedPem = pem;
order.push("installCa");
},
uninstallCa: async () => {
order.push("uninstallCa");
},
},
});
assert.equal(installedPem, "CA-CERT-PEM", "the dynamic CA cert PEM is installed in the trust store");
assert.deepEqual(order, ["apply", "installCa", "createFd", "listen"]);
await handle.stop();
assert.deepEqual(order, [
"apply",
"installCa",
"createFd",
"listen",
"close",
"uninstallCa",
"revert",
]);
});
test("startTproxyCapture decrypt mode reverts + uninstalls the CA when the listener fails", async () => {
const order: string[] = [];
const deps = {
applyTproxy: async () => {
order.push("apply");
},
revertTproxy: async () => {
order.push("revert");
},
createListenerFd: () => {
throw new Error("EPERM: CAP_NET_ADMIN required");
},
connectMarked: () => 1,
createServer: () => new EventEmitter(),
createUpstreamSocket: () => fakeSocket("", 0),
};
const certStore = {
createSNICallback: () => () => {},
getCaCertPem: async () => "CA-CERT-PEM",
};
await assert.rejects(
() =>
startTproxyCapture(CFG, {
deps: deps as never,
decrypt: {
certStore: certStore as never,
installCa: async () => {
order.push("installCa");
},
uninstallCa: async () => {
order.push("uninstallCa");
},
},
}),
/CAP_NET_ADMIN/
);
assert.deepEqual(order, ["apply", "installCa", "uninstallCa", "revert"]);
});