feat(mitm): local-only route + trust-store installer for TPROXY decrypt (4b/N) (#4211)

Integrated into release/v3.8.29 (round 7). TPROXY decrypt 4b/N: local-only route (/api/tools/agent-bridge/tproxy, LOCAL_ONLY per routeGuard) + dedicated trust-store CA installer (execFileWithPassword, no shell). Validated locally: 12/12 tests + typecheck:core clean (test-unit does not run on release PRs).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-18 20:18:01 -03:00
committed by GitHub
parent 8a0863c5a8
commit 12124aaad8
4 changed files with 359 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
/**
* GET / POST / DELETE /api/tools/agent-bridge/tproxy
*
* Drive the decrypt-capable TPROXY capture mode (Epic A, decrypt 4b/N):
* - GET → current status (running / available / interceptCount / onPort)
* - POST → start: apply the TPROXY rules, open the transparent listener, and
* install the dynamic CA in the OS trust store (dedicated slot)
* - DELETE → stop: close the listener, uninstall the CA, revert the rules
*
* LOCAL_ONLY: covered by the "/api/tools/agent-bridge/" prefix in routeGuard.ts.
* Starting this route applies iptables rules + installs a trust-store CA via
* child processes, so loopback-only enforcement (Hard Rules #15 + #17) is
* mandatory — a leaked JWT over a tunnel must not be able to reach it.
*/
import { z } from "zod";
import {
startCaptureMode,
stopCaptureMode,
getCaptureStatus,
} from "@/mitm/tproxy/captureManager";
import { installTproxyCa, uninstallTproxyCa } from "@/mitm/tproxy/caTrust";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
// Exported for unit testing. Next.js only treats GET/POST/DELETE as route
// handlers; additional named exports are ignored by the App Router.
export const StartTproxyBodySchema = z.object({
dport: z.number().int().min(1).max(65535).default(443),
mark: z.number().int().min(1).default(0x2333),
onPort: z.number().int().min(1).max(65535).default(8443),
routeTable: z.number().int().min(1).default(233),
bypassMark: z.number().int().min(1).default(0x539),
// Required on non-root desktops to authorize the trust-store install; ignored
// when the process is root (the VPS), where sudo is skipped entirely.
sudoPassword: z.string().optional(),
});
export function GET(): Response {
return Response.json(getCaptureStatus());
}
export async function POST(request: Request): Promise<Response> {
const raw = await request.json().catch(() => ({}));
const parsed = StartTproxyBodySchema.safeParse(raw);
if (!parsed.success) {
return createErrorResponse({
status: 400,
type: "invalid_request",
message: "Invalid TPROXY capture config",
});
}
const { sudoPassword, ...cfg } = parsed.data;
const pwd = sudoPassword ?? "";
try {
const status = await startCaptureMode({
cfg,
installCa: (caPem) => installTproxyCa(caPem, pwd),
uninstallCa: () => uninstallTproxyCa(pwd),
});
return Response.json({ ok: true, status });
} catch (err) {
return createErrorResponse({
status: 500,
message: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)),
});
}
}
export async function DELETE(): Promise<Response> {
try {
const status = await stopCaptureMode();
return Response.json({ ok: true, status });
} catch (err) {
return createErrorResponse({
status: 500,
message: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)),
});
}
}

112
src/mitm/tproxy/caTrust.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Fase 3 / Epic A — OS trust-store install for the TPROXY dynamic CA (decrypt 4b/N).
*
* The decrypt capture mode issues per-SNI leaves from a dynamic CA (#4173); the
* intercepted clients must trust that CA. This installs the CA cert into the OS
* trust store under a DEDICATED slot (`omniroute-tproxy-ca.crt`), separate from
* the static MITM cert (`omniroute-mitm.crt` in `cert/install.ts`), so the two
* never clobber each other.
*
* Linux-only (TPROXY is Linux-only). Privileged commands run via the controlled
* `execFileWithPassword` helper — `spawn` with arg arrays, no shell, no string
* interpolation (Hard Rule #13). It already runs the target directly (no `sudo`)
* when the process is root, so on the VPS no password is needed. Every effectful
* seam is injectable so the command sequence is unit-testable without root.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileWithPassword } from "../systemCommands.ts";
/** Dedicated trust-store filename — distinct from the static MITM cert's slot. */
export const TPROXY_CA_CERT_NAME = "omniroute-tproxy-ca.crt";
/** Trust-store anchor dirs + refresh command, in detection order (Debian first). */
const LINUX_CERT_PATHS: ReadonlyArray<{ dir: string; cmd: string }> = [
{ dir: "/usr/local/share/ca-certificates", cmd: "update-ca-certificates" },
{ dir: "/etc/ca-certificates/trust-source/anchors", cmd: "update-ca-trust" },
{ dir: "/etc/pki/ca-trust/source/anchors", cmd: "update-ca-trust" },
{ dir: "/etc/pki/trust/anchors", cmd: "update-ca-certificates" },
];
export type SudoRunner = (command: string, args: string[], password: string) => Promise<unknown>;
export interface CaTrustDeps {
/** Run a privileged command (sudo/arg-array; runs direct when root). */
run: SudoRunner;
/** Stage the CA PEM to a local file before the privileged copy. */
writeFile: (filePath: string, data: string) => void;
/** Remove the staged file (best-effort). */
rmFile: (filePath: string) => void;
/** Directory to stage the PEM in. */
tmpDir: () => string;
/** Resolve the trust-store anchor dir + refresh command for this distro. */
certConfig: () => { dir: string; cmd: string };
/** Host platform (Linux-only gate). */
platform: () => string;
}
function detectCertConfig(): { dir: string; cmd: string } {
for (const c of LINUX_CERT_PATHS) {
if (fs.existsSync(c.dir)) return c;
}
return LINUX_CERT_PATHS[0];
}
const realDeps: CaTrustDeps = {
run: execFileWithPassword,
writeFile: (filePath, data) => fs.writeFileSync(filePath, data, { mode: 0o644 }),
rmFile: (filePath) => {
try {
fs.unlinkSync(filePath);
} catch {
// best-effort cleanup
}
},
tmpDir: () => os.tmpdir(),
certConfig: detectCertConfig,
platform: () => process.platform,
};
/**
* Install the dynamic CA cert (PEM) into the OS trust store under the dedicated
* TPROXY slot. Stages the PEM to a temp file, then (privileged) copies it into the
* anchor dir and refreshes the trust store. Throws on non-Linux hosts.
*/
export async function installTproxyCa(
caPem: string,
sudoPassword = "",
deps: Partial<CaTrustDeps> = {}
): Promise<void> {
const d = { ...realDeps, ...deps };
if (d.platform() !== "linux") {
throw new Error("TPROXY CA trust install is Linux-only.");
}
const cfg = d.certConfig();
const staged = path.join(d.tmpDir(), TPROXY_CA_CERT_NAME);
const dest = `${cfg.dir}/${TPROXY_CA_CERT_NAME}`;
d.writeFile(staged, caPem);
try {
await d.run("sudo", ["-S", "mkdir", "-p", cfg.dir], sudoPassword);
await d.run("sudo", ["-S", "cp", staged, dest], sudoPassword);
await d.run("sudo", ["-S", cfg.cmd], sudoPassword);
} finally {
d.rmFile(staged);
}
}
/**
* Remove the TPROXY CA from the OS trust store (its dedicated slot only — leaves
* the static MITM cert untouched) and refresh. No-op on non-Linux hosts.
*/
export async function uninstallTproxyCa(
sudoPassword = "",
deps: Partial<CaTrustDeps> = {}
): Promise<void> {
const d = { ...realDeps, ...deps };
if (d.platform() !== "linux") return;
const cfg = d.certConfig();
const dest = `${cfg.dir}/${TPROXY_CA_CERT_NAME}`;
await d.run("sudo", ["-S", "rm", "-f", dest], sudoPassword);
await d.run("sudo", ["-S", cfg.cmd], sudoPassword);
}

View File

@@ -0,0 +1,98 @@
/**
* Fase 3 / Epic A — OS trust-store install for the TPROXY dynamic CA (decrypt 4b/N).
*
* Installs the dynamic CA into the trust store under a DEDICATED slot
* (`omniroute-tproxy-ca.crt`) so it never clobbers the static MITM cert slot.
* Every effectful seam is injected, so these tests pin the exact privileged
* command sequence (no shell, arg arrays — Hard Rule #13) without root.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
installTproxyCa,
uninstallTproxyCa,
TPROXY_CA_CERT_NAME,
} from "../../src/mitm/tproxy/caTrust.ts";
function fakeDeps(over: Record<string, unknown> = {}) {
const calls: Array<{ command: string; args: string[] }> = [];
const writes: Array<{ path: string; data: string }> = [];
const removed: string[] = [];
return {
calls,
writes,
removed,
deps: {
run: async (command: string, args: string[]) => {
calls.push({ command, args });
},
writeFile: (p: string, data: string) => {
writes.push({ path: p, data });
},
rmFile: (p: string) => {
removed.push(p);
},
tmpDir: () => "/tmp",
certConfig: () => ({ dir: "/usr/local/share/ca-certificates", cmd: "update-ca-certificates" }),
platform: () => "linux",
...over,
} as never,
};
}
test("installTproxyCa stages the PEM, copies it into the dedicated slot, refreshes, cleans up", async () => {
const f = fakeDeps();
await installTproxyCa("CA-PEM", "", f.deps);
assert.equal(f.writes.length, 1);
assert.match(f.writes[0].path, /omniroute-tproxy-ca\.crt$/);
assert.equal(f.writes[0].data, "CA-PEM");
const cmds = f.calls.map((c) => `${c.command} ${c.args.join(" ")}`);
assert.deepEqual(cmds, [
"sudo -S mkdir -p /usr/local/share/ca-certificates",
`sudo -S cp /tmp/${TPROXY_CA_CERT_NAME} /usr/local/share/ca-certificates/${TPROXY_CA_CERT_NAME}`,
"sudo -S update-ca-certificates",
]);
assert.deepEqual(f.removed, [`/tmp/${TPROXY_CA_CERT_NAME}`], "staged file cleaned up");
});
test("installTproxyCa never touches the static MITM cert slot", async () => {
const f = fakeDeps();
await installTproxyCa("CA-PEM", "", f.deps);
const joined = f.calls.map((c) => c.args.join(" ")).join(" ");
assert.ok(!joined.includes("omniroute-mitm.crt"), "must not collide with the MITM cert");
assert.ok(joined.includes("omniroute-tproxy-ca.crt"));
});
test("installTproxyCa cleans up the staged file even when a privileged command fails", async () => {
const f = fakeDeps({
run: async (_c: string, a: string[]) => {
if (a.includes("cp")) throw new Error("EPERM: not permitted");
},
});
await assert.rejects(() => installTproxyCa("CA-PEM", "", f.deps), /EPERM/);
assert.deepEqual(f.removed, [`/tmp/${TPROXY_CA_CERT_NAME}`], "staged file still cleaned up on failure");
});
test("installTproxyCa throws on non-Linux hosts and runs nothing", async () => {
const f = fakeDeps({ platform: () => "darwin" });
await assert.rejects(() => installTproxyCa("CA-PEM", "", f.deps), /Linux-only/);
assert.equal(f.calls.length, 0);
});
test("uninstallTproxyCa removes only the dedicated slot and refreshes", async () => {
const f = fakeDeps();
await uninstallTproxyCa("", f.deps);
const cmds = f.calls.map((c) => `${c.command} ${c.args.join(" ")}`);
assert.deepEqual(cmds, [
`sudo -S rm -f /usr/local/share/ca-certificates/${TPROXY_CA_CERT_NAME}`,
"sudo -S update-ca-certificates",
]);
});
test("uninstallTproxyCa is a no-op on non-Linux hosts", async () => {
const f = fakeDeps({ platform: () => "win32" });
await uninstallTproxyCa("", f.deps);
assert.equal(f.calls.length, 0);
});

View File

@@ -0,0 +1,68 @@
/**
* Fase 3 / Epic A — /api/tools/agent-bridge/tproxy route (decrypt 4b/N).
*
* Drives the decrypt capture mode (start/stop/status). The route applies iptables
* rules + installs a trust-store CA via child processes, so it MUST be local-only
* (Hard Rules #15 + #17). In CI the native addon is absent, so start fails
* gracefully with a sanitized 500 — which is exactly what these tests pin, along
* with config validation, status, and the local-only classification.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
GET,
POST,
DELETE,
StartTproxyBodySchema,
} from "../../src/app/api/tools/agent-bridge/tproxy/route.ts";
import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts";
function postReq(body: unknown): Request {
return new Request("http://local/api/tools/agent-bridge/tproxy", {
method: "POST",
body: JSON.stringify(body),
});
}
test("the tproxy route is classified LOCAL_ONLY (spawns iptables + installs a CA)", () => {
assert.equal(isLocalOnlyPath("/api/tools/agent-bridge/tproxy"), true);
});
test("GET reports running:false and an available boolean when idle", async () => {
const res = GET();
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.running, false);
assert.equal(typeof body.available, "boolean");
});
test("POST rejects an out-of-range config with a 400 invalid_request", async () => {
const res = await POST(postReq({ dport: 70000 }));
assert.equal(res.status, 400);
const body = await res.json();
assert.equal(body.error.type, "invalid_request");
});
test("POST returns a sanitized 500 when the native addon is unavailable (CI)", async () => {
const res = await POST(postReq({}));
assert.equal(res.status, 500);
const body = await res.json();
assert.match(body.error.message, /native addon|CAP_NET_ADMIN/);
assert.ok(!body.error.message.includes("at /"), "no stack trace leaked");
});
test("DELETE stops (no-op when idle) and returns ok with status", async () => {
const res = await DELETE();
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.equal(body.status.running, false);
});
test("StartTproxyBodySchema applies sensible TPROXY defaults", () => {
const parsed = StartTproxyBodySchema.parse({});
assert.equal(parsed.dport, 443);
assert.equal(parsed.onPort, 8443);
assert.equal(parsed.routeTable, 233);
assert.equal(parsed.bypassMark, 0x539);
});