diff --git a/src/mitm/tproxy/native/transparent.c b/src/mitm/tproxy/native/transparent.c index b3972d3bf229..e5542aff0055 100644 --- a/src/mitm/tproxy/native/transparent.c +++ b/src/mitm/tproxy/native/transparent.c @@ -63,11 +63,35 @@ static napi_value CreateTransparentListener(napi_env env, napi_callback_info inf return result; } +/* + * setSocketMark(fd, mark): set SO_MARK on an existing socket fd. Anti-loop for + * the OUTPUT-based TPROXY recipe — the proxy marks its OWN upstream connections + * so the mangle OUTPUT rule (`-m mark ! --mark `) excludes them and they + * are not re-intercepted. Requires CAP_NET_ADMIN. Returns undefined; throws on + * failure. + */ +static napi_value SetSocketMark(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + int32_t fd = -1, mark = 0; + napi_get_value_int32(env, argv[0], &fd); + napi_get_value_int32(env, argv[1], &mark); + if (setsockopt(fd, SOL_SOCKET, SO_MARK, &mark, sizeof(mark)) < 0) { + THROW(env, "ESO_MARK", strerror(errno)); + } + return NULL; +} + static napi_value Init(napi_env env, napi_value exports) { napi_value fn; napi_create_function(env, "createTransparentListener", NAPI_AUTO_LENGTH, CreateTransparentListener, NULL, &fn); napi_set_named_property(env, exports, "createTransparentListener", fn); + + napi_value markFn; + napi_create_function(env, "setSocketMark", NAPI_AUTO_LENGTH, SetSocketMark, NULL, &markFn); + napi_set_named_property(env, exports, "setSocketMark", markFn); return exports; } diff --git a/src/mitm/tproxy/transparentSocket.ts b/src/mitm/tproxy/transparentSocket.ts index abe4fd554b7c..965eb13b17e0 100644 --- a/src/mitm/tproxy/transparentSocket.ts +++ b/src/mitm/tproxy/transparentSocket.ts @@ -22,6 +22,8 @@ import { platform } from "node:os"; export interface TransparentAddon { /** socket()+SO_REUSEADDR+IP_TRANSPARENT+bind()+listen(); returns the raw fd. */ 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; } /** Candidate locations for the built/prebuilt addon, relative to this module. */ @@ -42,7 +44,11 @@ export function loadTransparentAddon( for (const path of ADDON_PATHS) { try { const mod = req(path) as Partial | undefined; - if (mod && typeof mod.createTransparentListener === "function") { + if ( + mod && + typeof mod.createTransparentListener === "function" && + typeof mod.setSocketMark === "function" + ) { return mod as TransparentAddon; } } catch { @@ -75,3 +81,15 @@ export function createTransparentListenerFd(ip: string, port: number): number { } return cached.createTransparentListener(ip, port); } + +/** + * Set SO_MARK on a socket fd (anti-loop). The TPROXY listener marks its OWN + * upstream connections with the bypass mark so the mangle OUTPUT rule excludes + * them and they are not re-intercepted. Throws when the addon is unavailable. + */ +export function setSocketMark(fd: number, mark: number): void { + if (!cached) { + throw new Error("TPROXY transparent-socket addon is not available (setSocketMark)."); + } + cached.setSocketMark(fd, mark); +} diff --git a/tests/unit/tproxy-transparent-socket.test.ts b/tests/unit/tproxy-transparent-socket.test.ts index 849c3f588a1a..246c8e735665 100644 --- a/tests/unit/tproxy-transparent-socket.test.ts +++ b/tests/unit/tproxy-transparent-socket.test.ts @@ -13,7 +13,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { loadTransparentAddon, isTransparentSocketAvailable, createTransparentListenerFd } = +const { loadTransparentAddon, isTransparentSocketAvailable, createTransparentListenerFd, setSocketMark } = await import("../../src/mitm/tproxy/transparentSocket.ts"); test("loadTransparentAddon returns null on non-Linux (IP_TRANSPARENT is Linux-only)", () => { @@ -32,14 +32,19 @@ test("loadTransparentAddon returns null when the prebuilt addon is absent (requi }); test("loadTransparentAddon returns the addon when present and well-shaped", () => { - const fake = { createTransparentListener: () => 42 }; + const fake = { createTransparentListener: () => 42, setSocketMark: () => {} }; const addon = loadTransparentAddon(() => fake, () => "linux"); assert.equal(addon, fake); assert.equal(addon?.createTransparentListener("0.0.0.0", 1), 42); }); test("loadTransparentAddon rejects a module missing createTransparentListener", () => { - const addon = loadTransparentAddon(() => ({ somethingElse: true }), () => "linux"); + const addon = loadTransparentAddon(() => ({ setSocketMark: () => {} }), () => "linux"); + assert.equal(addon, null); +}); + +test("loadTransparentAddon rejects a module missing setSocketMark (anti-loop primitive)", () => { + const addon = loadTransparentAddon(() => ({ createTransparentListener: () => 1 }), () => "linux"); assert.equal(addon, null); }); @@ -51,3 +56,7 @@ test("isTransparentSocketAvailable returns a boolean (false in CI — addon not test("createTransparentListenerFd throws a clear, actionable error when unavailable", () => { assert.throws(() => createTransparentListenerFd("0.0.0.0", 8443), /not available|Linux|build/i); }); + +test("setSocketMark throws when the addon is unavailable", () => { + assert.throws(() => setSocketMark(7, 0x539), /not available/i); +});