mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
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:
committed by
GitHub
parent
8a0863c5a8
commit
12124aaad8
81
src/app/api/tools/agent-bridge/tproxy/route.ts
Normal file
81
src/app/api/tools/agent-bridge/tproxy/route.ts
Normal 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
112
src/mitm/tproxy/caTrust.ts
Normal 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);
|
||||
}
|
||||
98
tests/unit/tproxy-ca-trust.test.ts
Normal file
98
tests/unit/tproxy-ca-trust.test.ts
Normal 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);
|
||||
});
|
||||
68
tests/unit/tproxy-route.test.ts
Normal file
68
tests/unit/tproxy-route.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user