mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
291103513e
commit
910a202e79
@@ -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[]}[]} */
|
||||
|
||||
@@ -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)..."
|
||||
|
||||
54
scripts/build/build-tproxy-native.mjs
Normal file
54
scripts/build/build-tproxy-native.mjs
Normal 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" });
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
78
tests/unit/tproxy-build-native.test.ts
Normal file
78
tests/unit/tproxy-build-native.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user