266 lines
8.3 KiB
TypeScript
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"]);
|
|
});
|