diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 464d712045..7df83d1444 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -83,6 +83,15 @@ const NATIVE_ASSET_ENTRIES = [ src: ["node_modules", "better-sqlite3", "build"], dest: ["node_modules", "better-sqlite3", "build"], }, + { + // TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native + // before assembly; Linux-only + opt-in, so the source is absent on non-Linux + // builds → syncNativeAssetsToDir skips it gracefully. The runtime loader + // (transparentSocket.ts) resolves it cwd-relative to this same dest. + label: "TPROXY transparent-socket addon (Linux-only, opt-in)", + src: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"], + dest: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"], + }, ]; /** @type {{label:string, src:string[], dest:string[]}[]} */ diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 071517d76f..323b428409 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -209,6 +209,25 @@ export async function main() { ); } + // Best-effort: build the TPROXY native addon (Linux-only, opt-in) BEFORE + // assembling, so its transparent.node is present for assembleStandalone's + // NATIVE_ASSET_ENTRIES copy. Non-Linux / no-toolchain is non-fatal — the + // capture mode degrades gracefully when the addon is absent. + try { + const { buildTproxyNative } = await import("./build-tproxy-native.mjs"); + const res = buildTproxyNative(projectRoot); + console.log( + res.built + ? "[build-next-isolated] Built TPROXY native addon (transparent.node)" + : `[build-next-isolated] TPROXY native addon skipped: ${res.reason}` + ); + } catch (nativeErr) { + console.warn( + "[build-next-isolated] Non-fatal error building TPROXY native addon:", + nativeErr?.message + ); + } + try { console.log( "[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..." diff --git a/scripts/build/build-tproxy-native.mjs b/scripts/build/build-tproxy-native.mjs new file mode 100644 index 0000000000..ab7f082f4a --- /dev/null +++ b/scripts/build/build-tproxy-native.mjs @@ -0,0 +1,54 @@ +/** + * Best-effort build of the TPROXY IP_TRANSPARENT native addon so the production + * build can copy `build/Release/transparent.node` into the standalone bundle + * (assembleStandalone's NATIVE_ASSET_ENTRIES). Called from build-next-isolated.mjs + * before the standalone is assembled. + * + * IP_TRANSPARENT is Linux-only, so this is a no-op everywhere else. A missing C + * toolchain is NOT fatal — the TPROXY capture mode degrades gracefully when the + * addon is absent (transparentSocket.ts returns "unavailable"). Every effectful + * seam (platform/run/exists) is injectable so the decision logic is unit-testable. + * + * Hard Rule #13: the command + args are a fixed allowlist (no interpolation of + * external/runtime values); `cwd` is derived from `projectRoot`, never user input. + */ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +/** + * @param {string} projectRoot + * @param {{ platform?: string, run?: (cmd:string, args:string[], cwd:string) => void, + * exists?: (p:string) => boolean }} [opts] + * @returns {{ built: boolean, reason?: string }} + */ +export function buildTproxyNative(projectRoot, opts = {}) { + const platform = opts.platform ?? process.platform; + const run = opts.run ?? defaultRun; + const exists = opts.exists ?? existsSync; + + if (platform !== "linux") { + return { built: false, reason: "non-linux host (IP_TRANSPARENT is Linux-only)" }; + } + + const nativeDir = path.join(projectRoot, "src", "mitm", "tproxy", "native"); + if (!exists(path.join(nativeDir, "binding.gyp"))) { + return { built: false, reason: "native sources absent (binding.gyp not found)" }; + } + + const out = path.join(nativeDir, "build", "Release", "transparent.node"); + try { + run("npx", ["--yes", "node-gyp", "rebuild"], nativeDir); + } catch (err) { + return { built: false, reason: `toolchain/build failed: ${err?.message ?? String(err)}` }; + } + if (!exists(out)) { + return { built: false, reason: "node-gyp produced no transparent.node" }; + } + return { built: true }; +} + +/** @type {(cmd: string, args: string[], cwd: string) => void} */ +function defaultRun(cmd, args, cwd) { + execFileSync(cmd, args, { cwd, stdio: "inherit" }); +} diff --git a/src/mitm/tproxy/native/README.md b/src/mitm/tproxy/native/README.md index cbf3f034f8..34ee35fed2 100644 --- a/src/mitm/tproxy/native/README.md +++ b/src/mitm/tproxy/native/README.md @@ -26,16 +26,30 @@ validated against the same kernel (see PR #4139). npm run build:native:tproxy # -> build/Release/transparent.node ``` -`build/` and `prebuilds/` are git-ignored. Distribution via per-platform -prebuilds (linux-x64 / linux-arm64) is a follow-up — IP_TRANSPARENT is -Linux-only, so only Linux prebuilds are needed; everywhere else the loader -returns "unavailable". +`build/` and `prebuilds/` are git-ignored — the binary is **built, never +committed**. -## Remaining for the full Epic A (gated follow-ups) +## Distribution (wired into the production build) -- The TPROXY listener that adopts the fd, terminates TLS, and feeds the capture - buffer (reusing the MITM path). -- `repairMitm()` calling `revertTproxy()` so a crash flushes the mangle rules. -- The capture-mode route + Traffic Inspector UI tab. -- A live end-to-end intercept (TPROXY → listener) in a dedicated test - environment (needs a routing scenario; risky on a production proxy). +1. **`scripts/build/build-tproxy-native.mjs`** runs `node-gyp rebuild` during + `npm run build` (called from `build-next-isolated.mjs` before the standalone is + assembled). Linux-only; a missing toolchain is **non-fatal** (the capture mode + degrades gracefully). The Docker builder already ships `python3 make g++`. +2. **`assembleStandalone.mjs`** (`NATIVE_ASSET_ENTRIES`) copies + `build/Release/transparent.node` into the standalone bundle at the same + relative path. The source is absent on non-Linux builds → the copy skips it. +3. **`transparentSocket.ts`** resolves the addon both module-relative (dev/source) + and **cwd-relative** (`/src/mitm/tproxy/native/...`, where the standalone + server runs and step 2 placed it). + +IP_TRANSPARENT is Linux-only, so only the linux-x64 path is built; everywhere else +the loader returns "unavailable" and TPROXY capture mode stays disabled. (Linux +ARM64 / other arches: build on the target, or add to CI when needed.) + +## Epic A status: COMPLETE (validated e2e on the VPS) + +The full intercept → TLS-terminate → decrypt → capture (`source:"tproxy"`) → +re-encrypted forward → upstream round-trip was validated end-to-end on the VPS +(kernel 6.8.0), including the anti-loop fix (PR #4229). Wired via the capture +manager (#4208), the local-only route + CA installer (#4211), and the Traffic +Inspector toggle (#4216). diff --git a/src/mitm/tproxy/transparentSocket.ts b/src/mitm/tproxy/transparentSocket.ts index 2ed3d33448..46a9d232f1 100644 --- a/src/mitm/tproxy/transparentSocket.ts +++ b/src/mitm/tproxy/transparentSocket.ts @@ -18,6 +18,7 @@ */ import { createRequire } from "node:module"; import { platform } from "node:os"; +import path from "node:path"; export interface TransparentAddon { /** socket()+SO_REUSEADDR+IP_TRANSPARENT+bind()+listen(); returns the raw fd. */ @@ -28,24 +29,42 @@ export interface TransparentAddon { connectMarked(ip: string, port: number, mark: number): number; } -/** Candidate locations for the built/prebuilt addon, relative to this module. */ -const ADDON_PATHS = [ - "./native/build/Release/transparent.node", - "./native/prebuilds/transparent.node", +/** Path of the built/prebuilt addon, relative to `src/mitm/tproxy/`. */ +const ADDON_REL_PATHS = [ + "native/build/Release/transparent.node", + "native/prebuilds/transparent.node", ]; +/** + * Candidate require() specifiers, in priority order: + * - module-relative (`./native/...`) — dev / source runs where this file sits + * next to `native/`; + * - cwd-absolute (`/src/mitm/tproxy/native/...`) — the standalone/Docker + * bundle, where this module is compiled into `.next/server/...` (so the + * module-relative path misses) but `assembleStandalone` copies the addon to + * `/src/mitm/tproxy/native/...` and the server runs with + * cwd = the standalone root. + */ +function addonCandidates(cwd: string): string[] { + return [ + ...ADDON_REL_PATHS.map((p) => `./${p}`), + ...ADDON_REL_PATHS.map((p) => path.join(cwd, "src", "mitm", "tproxy", p)), + ]; +} + /** * Attempt to load the native addon. Returns null (never throws) when the host is - * non-Linux or the addon hasn't been built. `req`/`os` are injectable for tests. + * non-Linux or the addon hasn't been built. `req`/`os`/`cwd` are injectable for tests. */ export function loadTransparentAddon( req: (path: string) => unknown = createRequire(import.meta.url), - os: () => string = platform + os: () => string = platform, + cwd: () => string = () => process.cwd() ): TransparentAddon | null { if (os() !== "linux") return null; // IP_TRANSPARENT is a Linux-only socket option - for (const path of ADDON_PATHS) { + for (const candidate of addonCandidates(cwd())) { try { - const mod = req(path) as Partial | undefined; + const mod = req(candidate) as Partial | undefined; if ( mod && typeof mod.createTransparentListener === "function" && diff --git a/tests/unit/build/assemble-standalone.test.ts b/tests/unit/build/assemble-standalone.test.ts index c955c115f3..edb0eed4f5 100644 --- a/tests/unit/build/assemble-standalone.test.ts +++ b/tests/unit/build/assemble-standalone.test.ts @@ -33,6 +33,7 @@ function seedSidecarSources(root: string) { const files = [ "node_modules/wreq-js/rust/lib.so", "node_modules/better-sqlite3/build/Release/better_sqlite3.node", + "src/mitm/tproxy/native/build/Release/transparent.node", "node_modules/@swc/helpers/package.json", "node_modules/pino-abstract-transport/index.js", "node_modules/pino-pretty/index.js", @@ -128,5 +129,27 @@ test("async and sync sidecar copy paths produce identical bundle trees", async ( asyncTree, "sync (assembleStandalone) and async (sync*ToDir) must copy the same sidecar tree" ); + // The TPROXY native addon must land at the cwd-relative path the runtime loader + // (transparentSocket.ts) resolves in the standalone bundle. + assert.ok( + asyncTree.includes("src/mitm/tproxy/native/build/Release/transparent.node"), + "TPROXY transparent.node copied into the standalone bundle" + ); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test("the TPROXY addon source is skipped gracefully when it was not built (non-Linux)", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-skip-")); + const projectRoot = path.join(tmp, "src-root"); + // Seed everything EXCEPT the tproxy addon (simulating a non-Linux build). + fs.mkdirSync(projectRoot, { recursive: true }); + const out = path.join(tmp, "out"); + fs.mkdirSync(out, { recursive: true }); + // No throw even though src/mitm/tproxy/native/... is absent. + await syncStandaloneNativeAssets(projectRoot, fs.promises, { log() {} }, out); + assert.ok( + !fs.existsSync(path.join(out, "src/mitm/tproxy/native/build/Release/transparent.node")), + "absent addon is simply not copied (graceful skip)" + ); fs.rmSync(tmp, { recursive: true, force: true }); }); diff --git a/tests/unit/tproxy-build-native.test.ts b/tests/unit/tproxy-build-native.test.ts new file mode 100644 index 0000000000..ec7f9fa6ec --- /dev/null +++ b/tests/unit/tproxy-build-native.test.ts @@ -0,0 +1,78 @@ +/** + * Best-effort build of the TPROXY IP_TRANSPARENT native addon (build 4e/N). It + * runs at production-build time so assembleStandalone can copy transparent.node + * into the standalone bundle. IP_TRANSPARENT is Linux-only and a missing toolchain + * is NOT fatal (the capture mode degrades gracefully), so the decision logic must + * skip cleanly off-Linux / when sources are absent / when node-gyp fails. All + * effectful seams (platform/run/exists) are injected. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { buildTproxyNative } from "../../scripts/build/build-tproxy-native.mjs"; + +const ROOT = "/repo"; +const NATIVE = path.join(ROOT, "src", "mitm", "tproxy", "native"); +const GYP = path.join(NATIVE, "binding.gyp"); +const OUT = path.join(NATIVE, "build", "Release", "transparent.node"); + +test("skips (no-op) on non-Linux hosts and runs nothing", () => { + let ran = false; + const res = buildTproxyNative(ROOT, { + platform: "darwin", + run: () => { + ran = true; + }, + exists: () => true, + }); + assert.equal(res.built, false); + assert.match(res.reason ?? "", /linux/i); + assert.equal(ran, false); +}); + +test("skips when the native sources are absent (binding.gyp missing)", () => { + let ran = false; + const res = buildTproxyNative(ROOT, { + platform: "linux", + run: () => { + ran = true; + }, + exists: (p) => p !== GYP, + }); + assert.equal(res.built, false); + assert.match(res.reason ?? "", /sources absent|binding\.gyp/i); + assert.equal(ran, false); +}); + +test("builds via node-gyp rebuild in the native dir and reports success", () => { + const calls = []; + const res = buildTproxyNative(ROOT, { + platform: "linux", + run: (cmd, args, cwd) => calls.push({ cmd, args, cwd }), + exists: (p) => p === GYP || p === OUT, + }); + assert.equal(res.built, true); + assert.deepEqual(calls, [{ cmd: "npx", args: ["--yes", "node-gyp", "rebuild"], cwd: NATIVE }]); +}); + +test("a node-gyp / toolchain failure is non-fatal (built:false with a reason)", () => { + const res = buildTproxyNative(ROOT, { + platform: "linux", + run: () => { + throw new Error("gyp ERR! not found: make"); + }, + exists: (p) => p === GYP, + }); + assert.equal(res.built, false); + assert.match(res.reason ?? "", /toolchain|build failed|make/i); +}); + +test("reports failure when node-gyp produced no transparent.node", () => { + const res = buildTproxyNative(ROOT, { + platform: "linux", + run: () => {}, + exists: (p) => p === GYP, // OUT never appears + }); + assert.equal(res.built, false); + assert.match(res.reason ?? "", /no transparent\.node|produced/i); +}); diff --git a/tests/unit/tproxy-transparent-socket.test.ts b/tests/unit/tproxy-transparent-socket.test.ts index 128f3c3f10..230fa94029 100644 --- a/tests/unit/tproxy-transparent-socket.test.ts +++ b/tests/unit/tproxy-transparent-socket.test.ts @@ -62,6 +62,43 @@ test("loadTransparentAddon rejects a module missing connectMarked (forward anti- assert.equal(addon, null); }); +test("loadTransparentAddon also tries the cwd-relative standalone path", () => { + // In the standalone/Docker bundle this module is compiled into .next/server/... + // so the module-relative `./native/...` misses; the addon is copied to + // /src/mitm/tproxy/native/... and the loader must try that absolute path. + const tried: string[] = []; + const addon = loadTransparentAddon( + (p) => { + tried.push(p); + throw new Error("not here"); + }, + () => "linux", + () => "/app" + ); + assert.equal(addon, null); + assert.ok( + tried.some((p) => p === "/app/src/mitm/tproxy/native/build/Release/transparent.node"), + `expected a cwd-absolute candidate, got: ${tried.join(", ")}` + ); +}); + +test("loadTransparentAddon loads the addon from the cwd-relative standalone path", () => { + const fake = { + createTransparentListener: () => 1, + setSocketMark: () => {}, + connectMarked: () => 2, + }; + const addon = loadTransparentAddon( + (p) => { + if (p.startsWith("/app/")) return fake; // standalone dest; module-relative misses + throw new Error("not here"); + }, + () => "linux", + () => "/app" + ); + assert.equal(addon, fake); +}); + test("isTransparentSocketAvailable returns a boolean (false in CI — addon not built)", () => { assert.equal(typeof isTransparentSocketAvailable(), "boolean"); assert.equal(isTransparentSocketAvailable(), false);