Repository navigation
feat(mitm): TPROXY capture-mode listener + connectMarked (Epic A) #4169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| /** | ||
| * Fase 3 / Epic A — TPROXY capture-mode listener (Linux). | ||
| * | ||
| * Ties the validated primitives together into a transparent capture mode for | ||
| * LOCAL outbound traffic (IDE agents on the same host) WITHOUT `/etc/hosts` | ||
| * spoofing or OS-wide proxy mutation: | ||
| * | ||
| * 1. apply the OUTPUT-based TPROXY rules (commands.ts / setup.ts) | ||
| * 2. open the IP_TRANSPARENT listener (native addon → fd → net.Server) | ||
| * 3. per intercepted connection: read the ORIGINAL destination | ||
| * (`socket.localAddress` — TPROXY preserves it), report it, and forward to | ||
| * that destination over a `connectMarked` (SO_MARK bypass) socket so the | ||
| * OUTPUT rule excludes the forward (anti-loop), with a raw bidirectional pipe. | ||
| * | ||
| * Each primitive was validated e2e on the VPS (kernel 6.8.0): intercept, | ||
| * anti-loop (the marked SYN was excluded), and Node adoption of the marked fd + | ||
| * pipe (PONG round-tripped). TLS termination / content decryption (reusing the | ||
| * MITM core) is a deliberate follow-up — this layer is a transparent tunnel that | ||
| * surfaces the intercepted connections via `onIntercept`. | ||
| * | ||
| * All effectful seams are injected (`deps`) so the orchestration and the | ||
| * per-connection logic are unit-testable without root. | ||
| */ | ||
| import net from "node:net"; | ||
| import { applyTproxy, revertTproxy, type CommandRunner } from "./setup"; | ||
| import { | ||
| createTransparentListenerFd, | ||
| connectMarked, | ||
| isTransparentSocketAvailable, | ||
| } from "./transparentSocket"; | ||
| import { validateTproxyConfig, type TproxyConfig } from "./commands"; | ||
|
|
||
| /** Default bypass SO_MARK when `cfg.bypassMark` is unset (anti-loop). */ | ||
| const DEFAULT_BYPASS_MARK = 0x539; | ||
|
|
||
| /** Strip the IPv4-mapped-IPv6 prefix Node reports for dual-stack sockets. */ | ||
| export function normalizeDest(localAddress: string | undefined): string { | ||
| return (localAddress ?? "").replace(/^::ffff:/, ""); | ||
| } | ||
|
|
||
| export interface TproxyInterceptInfo { | ||
| destIp: string; | ||
| destPort: number; | ||
| } | ||
|
|
||
| interface TproxyDeps { | ||
| applyTproxy: (cfg: TproxyConfig, run?: CommandRunner) => Promise<void>; | ||
| revertTproxy: (cfg: TproxyConfig, run?: CommandRunner) => Promise<void>; | ||
| createListenerFd: (ip: string, port: number) => number; | ||
| connectMarked: (ip: string, port: number, mark: number) => number; | ||
| createServer: (onConn: (s: net.Socket) => void) => net.Server; | ||
| createUpstreamSocket: (fd: number) => net.Socket; | ||
| } | ||
|
|
||
| const realDeps: TproxyDeps = { | ||
| applyTproxy, | ||
| revertTproxy, | ||
| createListenerFd: createTransparentListenerFd, | ||
| connectMarked, | ||
| createServer: (onConn) => net.createServer(onConn), | ||
| createUpstreamSocket: (fd) => new net.Socket({ fd }), | ||
| }; | ||
|
|
||
| export interface TproxyCaptureOptions { | ||
| /** Bind address for the transparent listener. Default "0.0.0.0". */ | ||
| listenIp?: string; | ||
| /** Invoked for each intercepted connection (e.g. push metadata to the buffer). */ | ||
| onIntercept?: (info: TproxyInterceptInfo) => void; | ||
| /** Injectable seams for unit testing. */ | ||
| deps?: Partial<TproxyDeps>; | ||
| } | ||
|
|
||
| export interface TproxyCaptureHandle { | ||
| cfg: TproxyConfig; | ||
| server: net.Server; | ||
| stop: () => Promise<void>; | ||
| } | ||
|
|
||
| /** | ||
| * Handle one intercepted connection: read the original destination, report it, | ||
| * and forward to it over a bypass-marked socket with a raw bidirectional pipe. | ||
| */ | ||
| export function handleTproxyConnection( | ||
| client: net.Socket, | ||
| cfg: TproxyConfig, | ||
| deps: Pick<TproxyDeps, "connectMarked" | "createUpstreamSocket">, | ||
| onIntercept?: (info: TproxyInterceptInfo) => void | ||
| ): void { | ||
| const destIp = normalizeDest(client.localAddress); | ||
| const destPort = client.localPort ?? 0; | ||
| onIntercept?.({ destIp, destPort }); | ||
|
|
||
| if (!destIp || destPort <= 0) { | ||
| client.destroy(); | ||
| return; | ||
| } | ||
|
|
||
| let fd: number; | ||
| try { | ||
| fd = deps.connectMarked(destIp, destPort, cfg.bypassMark ?? DEFAULT_BYPASS_MARK); | ||
| } catch { | ||
| client.destroy(); | ||
| return; | ||
| } | ||
|
|
||
| const upstream = deps.createUpstreamSocket(fd); | ||
| upstream.on("error", () => client.destroy()); | ||
| client.on("error", () => upstream.destroy()); | ||
| client.pipe(upstream); | ||
| upstream.pipe(client); | ||
| } | ||
|
|
||
| /** | ||
| * Start TPROXY capture: apply the rules, open the transparent listener, and wire | ||
| * the per-connection forwarder. Returns a handle whose `stop()` closes the | ||
| * listener and reverts the rules (crash-safe teardown). Throws (after reverting) | ||
| * if anything fails to come up. | ||
| */ | ||
| export async function startTproxyCapture( | ||
| cfg: TproxyConfig, | ||
| options: TproxyCaptureOptions = {} | ||
| ): Promise<TproxyCaptureHandle> { | ||
| const deps: TproxyDeps = { ...realDeps, ...options.deps }; | ||
|
|
||
| // In production the native addon is required; tests inject createListenerFd. | ||
| if (!options.deps?.createListenerFd && !isTransparentSocketAvailable()) { | ||
| throw new Error("TPROXY capture mode requires the native addon (Linux + CAP_NET_ADMIN)."); | ||
| } | ||
| const invalid = validateTproxyConfig(cfg); | ||
| if (invalid) throw new Error(invalid); | ||
|
|
||
| await deps.applyTproxy(cfg); | ||
|
|
||
| let fd: number; | ||
| try { | ||
| fd = deps.createListenerFd(options.listenIp ?? "0.0.0.0", cfg.onPort); | ||
| } catch (err) { | ||
| await deps.revertTproxy(cfg).catch(() => {}); | ||
| throw err; | ||
| } | ||
|
|
||
| const server = deps.createServer((client) => | ||
| handleTproxyConnection(client, cfg, deps, options.onIntercept) | ||
| ); | ||
|
|
||
| try { | ||
| await new Promise<void>((resolve, reject) => { | ||
| const onErr = (e: Error) => reject(e); | ||
| server.once("error", onErr); | ||
| server.listen({ fd }, () => { | ||
| server.removeListener("error", onErr); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } catch (err) { | ||
| await deps.revertTproxy(cfg).catch(() => {}); | ||
| throw err; | ||
| } | ||
|
|
||
| return { | ||
| cfg, | ||
| server, | ||
| stop: async () => { | ||
| await new Promise<void>((resolve) => server.close(() => resolve())); | ||
| await deps.revertTproxy(cfg).catch(() => {}); | ||
| }, | ||
|
Comment on lines
+163
to
+166
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By default, We should use stop: async () => {
const closePromise = new Promise<void>((resolve) => server.close(() => resolve()));
if (typeof server.closeAllConnections === "function") {
server.closeAllConnections();
}
await closePromise;
await deps.revertTproxy(cfg).catch(() => {});
}, |
||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ | |
| #include <string.h> | ||
| #include <unistd.h> | ||
| #include <errno.h> | ||
| #include <fcntl.h> | ||
|
|
||
| #define THROW(env, code, msg) do { napi_throw_error((env), (code), (msg)); return NULL; } while (0) | ||
|
|
||
|
|
@@ -83,6 +84,50 @@ static napi_value SetSocketMark(napi_env env, napi_callback_info info) { | |
| return NULL; | ||
| } | ||
|
|
||
| /* | ||
| * connectMarked(ip, port, mark): create a socket, set SO_MARK BEFORE connect so | ||
| * the SYN itself carries the mark, then start a non-blocking connect. Returns | ||
| * the fd (connect in progress). This is the anti-loop for the forward path: the | ||
| * proxy's upstream SYN is excluded by the OUTPUT rule (`-m mark ! --mark`), so | ||
| * the forward does not re-enter TPROXY. The caller adopts the fd into a Node | ||
| * socket and waits for it to become writable. Requires CAP_NET_ADMIN. | ||
| */ | ||
| static napi_value ConnectMarked(napi_env env, napi_callback_info info) { | ||
| size_t argc = 3; | ||
| napi_value argv[3]; | ||
| napi_get_cb_info(env, info, &argc, argv, NULL, NULL); | ||
| char ip[64] = {0}; | ||
| size_t ip_len = 0; | ||
| napi_get_value_string_utf8(env, argv[0], ip, sizeof(ip), &ip_len); | ||
| int32_t port = 0, mark = 0; | ||
| napi_get_value_int32(env, argv[1], &port); | ||
| napi_get_value_int32(env, argv[2], &mark); | ||
|
Comment on lines
+95
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In native C addons, failing to validate the number and types of arguments passed from JavaScript can lead to undefined behavior, memory corruption, or hard process crashes (segfaults) if the JS caller passes incorrect or fewer arguments. We should explicitly check that static napi_value ConnectMarked(napi_env env, napi_callback_info info) {
size_t argc = 3;
napi_value argv[3];
if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 3) {
THROW(env, "EINVAL", "Expected 3 arguments: ip, port, mark");
}
char ip[64] = {0};
size_t ip_len = 0;
if (napi_get_value_string_utf8(env, argv[0], ip, sizeof(ip), &ip_len) != napi_ok) {
THROW(env, "EINVAL", "Argument 'ip' must be a string");
}
int32_t port = 0, mark = 0;
if (napi_get_value_int32(env, argv[1], &port) != napi_ok) {
THROW(env, "EINVAL", "Argument 'port' must be an integer");
}
if (napi_get_value_int32(env, argv[2], &mark) != napi_ok) {
THROW(env, "EINVAL", "Argument 'mark' must be an integer");
} |
||
|
|
||
| int fd = socket(AF_INET, SOCK_STREAM, 0); | ||
| if (fd < 0) THROW(env, "ESOCKET", strerror(errno)); | ||
| if (setsockopt(fd, SOL_SOCKET, SO_MARK, &mark, sizeof(mark)) < 0) { | ||
| int e = errno; close(fd); THROW(env, "ESO_MARK", strerror(e)); | ||
| } | ||
| int flags = fcntl(fd, F_GETFL, 0); | ||
| if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) { | ||
| int e = errno; close(fd); THROW(env, "EFCNTL", strerror(e)); | ||
| } | ||
| struct sockaddr_in addr; | ||
| memset(&addr, 0, sizeof(addr)); | ||
| addr.sin_family = AF_INET; | ||
| addr.sin_port = htons((uint16_t)port); | ||
| if (inet_pton(AF_INET, ip, &addr.sin_addr) != 1) { | ||
| close(fd); THROW(env, "EADDR", "invalid IPv4 address"); | ||
| } | ||
| int r = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); | ||
| if (r < 0 && errno != EINPROGRESS) { | ||
| int e = errno; close(fd); THROW(env, "ECONNECT", strerror(e)); | ||
| } | ||
| napi_value result; | ||
| napi_create_int32(env, fd, &result); | ||
| return result; | ||
| } | ||
|
|
||
| static napi_value Init(napi_env env, napi_value exports) { | ||
| napi_value fn; | ||
| napi_create_function(env, "createTransparentListener", NAPI_AUTO_LENGTH, | ||
|
|
@@ -92,6 +137,10 @@ static napi_value Init(napi_env env, napi_value exports) { | |
| napi_value markFn; | ||
| napi_create_function(env, "setSocketMark", NAPI_AUTO_LENGTH, SetSocketMark, NULL, &markFn); | ||
| napi_set_named_property(env, exports, "setSocketMark", markFn); | ||
|
|
||
| napi_value connFn; | ||
| napi_create_function(env, "connectMarked", NAPI_AUTO_LENGTH, ConnectMarked, NULL, &connFn); | ||
| napi_set_named_property(env, exports, "connectMarked", connFn); | ||
| return exports; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Node.js,
stream.pipe()does not propagatecloseordestroyevents. If theupstreamsocket is abruptly destroyed (e.g., due to an idle timeout or remote reset), it will emit a'close'event but not necessarily an'end'event. This leaves theclientsocket in a half-open state, leaking the socket and its associated file descriptor.To prevent resource leaks, we should register a unified cleanup handler that destroys both sockets as soon as either of them emits an
'error'or'close'event.