chore(build): build + ship the TPROXY native addon in the standalone (prebuilds 4e) (#4236)

Integrated into release/v3.8.29
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 00:01:21 -03:00
committed by GitHub
parent 291103513e
commit 910a202e79
8 changed files with 272 additions and 19 deletions

View File

@@ -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[]}[]} */

View File

@@ -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)..."

View File

@@ -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" });
}

View File

@@ -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** (`<cwd>/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).

View File

@@ -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 (`<cwd>/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
* `<standalone-root>/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<TransparentAddon> | undefined;
const mod = req(candidate) as Partial<TransparentAddon> | undefined;
if (
mod &&
typeof mod.createTransparentListener === "function" &&

View File

@@ -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 });
});

View File

@@ -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);
});

View File

@@ -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
// <cwd>/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);