Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions src/mitm/tproxy/captureMode.ts
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);
Comment on lines +107 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Node.js, stream.pipe() does not propagate close or destroy events. If the upstream socket 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 the client socket 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.

  const destroyBoth = () => {
    client.destroy();
    upstream.destroy();
  };
  client.on("error", destroyBoth);
  upstream.on("error", destroyBoth);
  client.on("close", destroyBoth);
  upstream.on("close", destroyBoth);
  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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

By default, server.close() waits for all active connections to close before invoking its callback. If there are active, long-lived TCP connections (such as IDE agent tunnels), await handle.stop() will hang indefinitely, preventing clean or timely shutdowns.

We should use server.closeAllConnections() (available in Node.js v18.2.0+) to immediately terminate all active connections when stopping the capture mode.

    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(() => {});
    },

};
}
49 changes: 49 additions & 0 deletions src/mitm/tproxy/native/transparent.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 napi_get_cb_info succeeded, that argc >= 3, and that each argument is of the expected type before using them.

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,
Expand All @@ -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;
}

Expand Down
19 changes: 18 additions & 1 deletion src/mitm/tproxy/transparentSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface TransparentAddon {
createTransparentListener(ip: string, port: number): number;
/** setsockopt(SO_MARK) on an fd — anti-loop: mark the proxy's own upstream conns. */
setSocketMark(fd: number, mark: number): void;
/** socket()+SO_MARK+non-blocking connect(); returns fd. Anti-loop forward path. */
connectMarked(ip: string, port: number, mark: number): number;
}

/** Candidate locations for the built/prebuilt addon, relative to this module. */
Expand All @@ -47,7 +49,8 @@ export function loadTransparentAddon(
if (
mod &&
typeof mod.createTransparentListener === "function" &&
typeof mod.setSocketMark === "function"
typeof mod.setSocketMark === "function" &&
typeof mod.connectMarked === "function"
) {
return mod as TransparentAddon;
}
Expand Down Expand Up @@ -93,3 +96,17 @@ export function setSocketMark(fd: number, mark: number): void {
}
cached.setSocketMark(fd, mark);
}

/**
* Create a socket with SO_MARK set BEFORE a non-blocking connect (so the SYN
* carries the mark) and return its fd for Node to adopt via `new net.Socket({
* fd })`. This is the forward-path anti-loop: the proxy's upstream SYN is
* excluded by the OUTPUT rule, so the forward does not re-enter TPROXY. Throws
* when the addon is unavailable.
*/
export function connectMarked(ip: string, port: number, mark: number): number {
if (!cached) {
throw new Error("TPROXY transparent-socket addon is not available (connectMarked).");
}
return cached.connectMarked(ip, port, mark);
}
Loading
Loading