ai_api / src /mitm /tproxy /captureMode.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
8.66 kB
/**
* 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).
*
* Two forwarding modes, chosen per `options.decrypt`:
* - raw tunnel (default): forward to the original destination over the
* bypass-marked socket with a raw bidirectional pipe — bodies stay opaque;
* - decrypt (opt-in): hand the raw socket to the TLS-terminating engine
* (`tlsCapture.ts`, #4179), which decrypts with a per-SNI leaf from the
* dynamic CA (#4173), captures the exchange (source "tproxy"), and forwards
* re-encrypted over the SAME bypass-marked seam. The CA is installed in the
* OS trust store on start (so clients trust the issued leaves) and removed on
* stop, both via injected seams.
*
* 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";
import { createForward, createTlsCaptureServer, type TlsCaptureServer } from "./tlsCapture";
import type { DynamicCertStore } from "./dynamicCert";
/** 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 }),
};
/**
* Enables TLS decryption + content capture for intercepted connections (instead
* of the raw tunnel). The CA private key never leaves the host; `installCa` is
* the only piece that touches the OS trust store and is injected by the caller.
*/
export interface TproxyDecryptOptions {
/** Dynamic CA that issues per-SNI leaves and exposes its CA cert (#4173). */
certStore: Pick<DynamicCertStore, "createSNICallback" | "getCaCertPem">;
/** Install the CA cert (PEM) into the OS trust store so clients trust the
* issued leaves. Called once on start; omit to manage the trust store yourself. */
installCa?: (caPem: string) => Promise<void>;
/** Remove the CA from the trust store on stop (symmetric teardown). */
uninstallCa?: () => Promise<void>;
}
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;
/** Opt into TLS decryption + content capture (source "tproxy"). */
decrypt?: TproxyDecryptOptions;
/** 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 it. When `terminate` is provided (decrypt mode), the raw socket is
* handed to the TLS-terminating engine; otherwise it is raw-piped to the
* destination over a bypass-marked socket.
*/
export function handleTproxyConnection(
client: net.Socket,
cfg: TproxyConfig,
deps: Pick<TproxyDeps, "connectMarked" | "createUpstreamSocket">,
onIntercept?: (info: TproxyInterceptInfo) => void,
terminate?: (client: net.Socket, dest: { ip: string; port: number }) => void
): void {
const destIp = normalizeDest(client.localAddress);
const destPort = client.localPort ?? 0;
onIntercept?.({ destIp, destPort });
if (!destIp || destPort <= 0) {
client.destroy();
return;
}
// Decrypt mode: the engine TLS-terminates, captures (source "tproxy"), and
// forwards re-encrypted over its own bypass-marked socket (anti-loop).
if (terminate) {
terminate(client, { ip: destIp, port: destPort });
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);
// Decrypt mode: stand up the TLS-terminating engine (forwarding over the SAME
// bypass-marked seam, anti-loop) and install its CA so clients trust the leaves.
// `cleanup` is the symmetric teardown for both the error paths and stop().
let engine: TlsCaptureServer | undefined;
let uninstallCa: (() => Promise<void>) | undefined;
const cleanup = async (): Promise<void> => {
await engine?.close().catch(() => {});
await uninstallCa?.().catch(() => {});
await deps.revertTproxy(cfg).catch(() => {});
};
try {
if (options.decrypt) {
const mark = cfg.bypassMark ?? DEFAULT_BYPASS_MARK;
const forward = createForward((ip, port) =>
deps.createUpstreamSocket(deps.connectMarked(ip, port, mark))
);
engine = createTlsCaptureServer(options.decrypt.certStore, { forward });
if (options.decrypt.installCa) {
await options.decrypt.installCa(await options.decrypt.certStore.getCaCertPem());
}
uninstallCa = options.decrypt.uninstallCa;
}
const terminate = engine
? (client: net.Socket, dest: { ip: string; port: number }) => engine!.terminate(client, dest)
: undefined;
const fd = deps.createListenerFd(options.listenIp ?? "0.0.0.0", cfg.onPort);
const server = deps.createServer((client) =>
handleTproxyConnection(client, cfg, deps, options.onIntercept, terminate)
);
await new Promise<void>((resolve, reject) => {
const onErr = (e: Error) => reject(e);
server.once("error", onErr);
server.listen({ fd }, () => {
server.removeListener("error", onErr);
resolve();
});
});
return {
cfg,
server,
stop: async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
await cleanup();
},
};
} catch (err) {
await cleanup();
throw err;
}
}