mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
Integrated into release/v3.8.39. Agent-bridge graceful cert-install fallback + manual guide (#4546); 6/6 tests pass on merge result.
This commit is contained in:
committed by
GitHub
parent
898a1cb62d
commit
422b7bd3cf
@@ -8,6 +8,10 @@
|
||||
|
||||
_In development — bullets added per PR; finalized at release._
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(agent-bridge): graceful cert-install fallback for containers / headless** — when the MITM root CA can't be installed into the system trust store automatically (Docker / headless / no sudo / read-only trust store), the Agent Bridge no longer hard-fails on start with a generic "Certificate install failed". It now starts in skip mode and the dashboard surfaces a platform-specific **manual-install guide** (plus a CA download link) so the operator can trust the certificate by hand. The trust-cert endpoints return a structured `{ skippable, manualGuide }` response (HTTP 200) for environment failures instead of a 500; an explicit user cancellation is still reported distinctly. ([#4546](https://github.com/diegosouzapw/OmniRoute/issues/4546) — thanks @phuchptty)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **fix(api): LAN/Tailscale dashboard access — `ws:` CSP scheme, GET-exempt version route, surface combo field errors** — three failures when opening the dashboard from a non-loopback host: (1) CSP `connect-src` allowed the `ws:` scheme only for loopback origins, blocking the dashboard's `ws://<lan-host>:*` Live WebSocket from LAN/Tailscale clients; the bare `ws:` scheme is now permitted (symmetric with the bare `wss:` already allowed), kept declarative in `next.config.mjs` with no global middleware (the project has none by design); (2) `GET /api/system/version` was blocked by `LOCAL_ONLY_API_PREFIXES` for all methods despite only `POST` spawning child processes (git/npm/pm2) — a new `LOCAL_ONLY_API_GET_EXEMPTIONS` set exempts safe read methods for this path while keeping `POST`/`PUT`/`PATCH`/`DELETE` strictly loopback-only; (3) `COMBO_002` validation errors only surfaced the generic message — `firstField`/`firstMessage` are now extracted from the first Zod issue and included in the response body. ([#5083](https://github.com/diegosouzapw/OmniRoute/issues/5083) — thanks @KooshaPari for the diagnosis and original PR #5084)
|
||||
|
||||
@@ -23,6 +23,14 @@ export interface AgentStateEntry {
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
/** Manual cert-install guide returned when auto-trust isn't possible (containers). */
|
||||
export interface CertManualGuide {
|
||||
platform: string;
|
||||
certPath: string;
|
||||
downloadUrl: string;
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
export interface AgentBridgeServerState {
|
||||
running: boolean;
|
||||
port: number;
|
||||
@@ -62,6 +70,7 @@ export default function AgentBridgePageClient({
|
||||
const t = useTranslations("agentBridge");
|
||||
const { data, refresh } = useAgentBridgeState({ initialData });
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [certGuide, setCertGuide] = useState<CertManualGuide | null>(null);
|
||||
|
||||
// ── Server actions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -74,11 +83,20 @@ export default function AgentBridgePageClient({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
error?: { message?: string };
|
||||
skippable?: boolean;
|
||||
manualGuide?: CertManualGuide;
|
||||
};
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({ error: { message: `HTTP ${res.status}` } }))) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
throw new Error(err.error?.message ?? `HTTP ${res.status}`);
|
||||
throw new Error(payload.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
// Cert couldn't be auto-installed (container / headless): not an error —
|
||||
// surface the manual-install guide instead of blocking. (#4546)
|
||||
if (payload.skippable && payload.manualGuide) {
|
||||
setCertGuide(payload.manualGuide);
|
||||
} else if (action === "trust-cert") {
|
||||
setCertGuide(null);
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
@@ -188,6 +206,43 @@ export default function AgentBridgePageClient({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual cert-install guide (container / headless fallback) */}
|
||||
{certGuide && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<span className="material-symbols-outlined text-[16px]">info</span>
|
||||
{t("certManualTitle") ||
|
||||
"Certificate couldn't be installed automatically (e.g. inside a container). The bridge can still run — trust the CA manually:"}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCertGuide(null)}
|
||||
className="ml-auto text-amber-600 hover:text-amber-500"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<ol className="mt-2 list-decimal pl-6 space-y-1">
|
||||
{certGuide.steps.map((step, i) => (
|
||||
<li key={i} className="font-mono text-xs break-all">
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<a
|
||||
href={certGuide.downloadUrl}
|
||||
download
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium underline hover:no-underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">download</span>
|
||||
{t("downloadCert") || "Download Cert"}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state: no providers */}
|
||||
{!hasProviders ? (
|
||||
<EmptyStateNoProviders />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* LOCAL_ONLY: registered in routeGuard.ts
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { installCert, uninstallCert, checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { installCertResult, uninstallCert, checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import { getCachedPassword } from "@/mitm/manager";
|
||||
import path from "path";
|
||||
@@ -48,9 +48,24 @@ export async function POST(request: Request): Promise<Response> {
|
||||
message: "Certificate not found. Generate one first.",
|
||||
});
|
||||
}
|
||||
await installCert(sudoPassword, crtPath);
|
||||
const trusted = await checkCertInstalled(crtPath);
|
||||
return Response.json({ ok: true, trusted });
|
||||
const result = await installCertResult(sudoPassword, crtPath);
|
||||
if (result.installed) {
|
||||
const trusted = await checkCertInstalled(crtPath);
|
||||
return Response.json({ ok: true, trusted });
|
||||
}
|
||||
if (result.reason === "canceled") {
|
||||
return createErrorResponse({ status: 409, message: "User canceled authorization" });
|
||||
}
|
||||
// Environment failure (container / headless): not a 500 — surface the
|
||||
// manual-install guide so the operator can trust the CA by hand. (#4546)
|
||||
return Response.json({
|
||||
ok: false,
|
||||
trusted: false,
|
||||
skippable: true,
|
||||
reason: result.reason,
|
||||
message: sanitizeErrorMessage(result.message ?? "Certificate install failed"),
|
||||
manualGuide: result.manualGuide,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { AgentBridgeServerActionSchema } from "@/shared/schemas/agentBridge";
|
||||
import { startMitm, stopMitm, getMitmStatus, setCachedPassword, getCachedPassword } from "@/mitm/manager";
|
||||
import { installCert, checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { installCertResult, checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { generateCert } from "@/mitm/cert/generate";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import path from "path";
|
||||
@@ -63,9 +63,24 @@ export async function POST(request: Request): Promise<Response> {
|
||||
if (action === "trust-cert") {
|
||||
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
|
||||
const pwd = sudoPassword || getCachedPassword() || "";
|
||||
await installCert(pwd, certPath);
|
||||
const trusted = await checkCertInstalled(certPath);
|
||||
return Response.json({ ok: true, trusted });
|
||||
const result = await installCertResult(pwd, certPath);
|
||||
if (result.installed) {
|
||||
const trusted = await checkCertInstalled(certPath);
|
||||
return Response.json({ ok: true, trusted });
|
||||
}
|
||||
if (result.reason === "canceled") {
|
||||
return createErrorResponse({ status: 409, message: "User canceled authorization" });
|
||||
}
|
||||
// Environment failure (container / headless): not an error — return the
|
||||
// manual-install guide so the UI can let the operator trust the CA by hand.
|
||||
return Response.json({
|
||||
ok: false,
|
||||
trusted: false,
|
||||
skippable: true,
|
||||
reason: result.reason,
|
||||
message: sanitizeErrorMessage(result.message ?? "Certificate install failed"),
|
||||
manualGuide: result.manualGuide,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "regenerate-cert") {
|
||||
|
||||
@@ -8564,6 +8564,7 @@
|
||||
"restartServer": "Restart",
|
||||
"trustCert": "Trust Cert",
|
||||
"downloadCert": "Download Cert",
|
||||
"certManualTitle": "Certificate couldn't be installed automatically (e.g. inside a container). The bridge can still run — trust the CA manually:",
|
||||
"regenerateCert": "Regenerate Cert",
|
||||
"starting": "Starting…",
|
||||
"stopping": "Stopping…",
|
||||
|
||||
@@ -180,6 +180,106 @@ export async function installCert(sudoPassword: string, certPath: string): Promi
|
||||
}
|
||||
}
|
||||
|
||||
// ── Graceful fallback for containers / headless environments (#4546) ──────────
|
||||
//
|
||||
// In a container the system trust store can't be written (no sudo / read-only
|
||||
// store / no interactive auth), so installCert() throws and used to abort the
|
||||
// whole Agent Bridge start. The helpers below let callers treat that as a
|
||||
// recoverable "skip" with a manual-install guide, instead of a hard failure.
|
||||
|
||||
const CERT_DOWNLOAD_URL = "/api/tools/agent-bridge/cert/download";
|
||||
|
||||
/** Why an automatic cert install did not complete. */
|
||||
export type CertInstallReason = "canceled" | "environment";
|
||||
|
||||
/** Platform-specific steps the operator can run to trust the MITM root CA by hand. */
|
||||
export interface CertManualGuide {
|
||||
platform: NodeJS.Platform;
|
||||
certPath: string;
|
||||
downloadUrl: string;
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
/** Structured outcome of an attempted cert install (never throws for env failures). */
|
||||
export interface CertInstallResult {
|
||||
installed: boolean;
|
||||
skipped: boolean;
|
||||
reason?: CertInstallReason;
|
||||
/** Safe, already-sanitized message (no stack trace). */
|
||||
message?: string;
|
||||
manualGuide?: CertManualGuide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cert-install failure message. Only an explicit user cancellation
|
||||
* counts as "canceled"; every other failure (missing trust store, no sudo,
|
||||
* read-only FS, container) is treated as an "environment" failure that the
|
||||
* operator can resolve with a manual install.
|
||||
*/
|
||||
export function classifyCertInstallError(message: string): CertInstallReason {
|
||||
return /cancel+ed/i.test(message) ? "canceled" : "environment";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the manual-install instructions for trusting the MITM root CA on the
|
||||
* given platform. Pure + platform-overridable so it is fully unit-testable.
|
||||
*/
|
||||
export function buildCertManualGuide(
|
||||
certPath: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): CertManualGuide {
|
||||
let steps: string[];
|
||||
if (platform === "win32") {
|
||||
steps = [
|
||||
`certutil -addstore -f Root "${certPath}"`,
|
||||
"Or import it via certmgr.msc → Trusted Root Certification Authorities → Certificates → Import.",
|
||||
];
|
||||
} else if (platform === "darwin") {
|
||||
steps = [
|
||||
`sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`,
|
||||
];
|
||||
} else {
|
||||
// Linux — match the detected distro's anchor dir + refresh command.
|
||||
const config = getLinuxCertConfig();
|
||||
steps = [
|
||||
`sudo cp "${certPath}" ${config.dir}/${LINUX_CERT_NAME}`,
|
||||
`sudo ${config.cmd}`,
|
||||
`Container-friendly per-tool trust (no root needed): set NODE_EXTRA_CA_CERTS="${certPath}" (Node) or REQUESTS_CA_BUNDLE="${certPath}" (Python), or import "${certPath}" into your client's trust store.`,
|
||||
];
|
||||
}
|
||||
return { platform, certPath, downloadUrl: CERT_DOWNLOAD_URL, steps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to install the cert, returning a structured result instead of
|
||||
* throwing on environment failures. A user-canceled authorization is reported
|
||||
* with reason "canceled" (not skipped); any other failure is reported as a
|
||||
* skippable "environment" failure carrying a manual-install guide so the bridge
|
||||
* can still start and the operator can trust the CA by hand.
|
||||
*/
|
||||
export async function installCertResult(
|
||||
sudoPassword: string,
|
||||
certPath: string
|
||||
): Promise<CertInstallResult> {
|
||||
try {
|
||||
await installCert(sudoPassword, certPath);
|
||||
return { installed: true, skipped: false };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
const reason = classifyCertInstallError(message);
|
||||
if (reason === "canceled") {
|
||||
return { installed: false, skipped: false, reason, message };
|
||||
}
|
||||
return {
|
||||
installed: false,
|
||||
skipped: true,
|
||||
reason,
|
||||
message,
|
||||
manualGuide: buildCertManualGuide(certPath),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function installCertMac(sudoPassword: string, certPath: string): Promise<void> {
|
||||
try {
|
||||
await execFileWithPassword(
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "fs";
|
||||
import { resolveMitmDataDir } from "./dataDir.ts";
|
||||
import { addDNSEntry, addDNSEntries, removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts";
|
||||
import { generateCert } from "./cert/generate.ts";
|
||||
import { installCert, uninstallCert } from "./cert/install.ts";
|
||||
import { installCertResult, uninstallCert } from "./cert/install.ts";
|
||||
import { ALL_TARGETS } from "./targets/index.ts";
|
||||
import { detectAgent } from "./detection/index.ts";
|
||||
import type { AgentId, DetectionResult, MitmTarget } from "./types.ts";
|
||||
@@ -397,7 +397,7 @@ export async function startMitm(
|
||||
apiKey: string,
|
||||
sudoPassword: string,
|
||||
options: { port?: number } = {}
|
||||
): Promise<{ running: true; pid: number | null }> {
|
||||
): Promise<{ running: true; pid: number | null; certTrusted: boolean }> {
|
||||
// Check if already running
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
throw new Error("MITM proxy is already running");
|
||||
@@ -446,8 +446,23 @@ export async function startMitm(
|
||||
await generateCert();
|
||||
}
|
||||
|
||||
// 2. Install certificate to system keychain
|
||||
await installCert(sudoPassword, certPath);
|
||||
// 2. Install certificate to system keychain. A failure here must NOT abort the
|
||||
// bridge: in containers/headless the system trust store can't be written,
|
||||
// so we start in "untrusted" mode and let the operator trust the CA by hand
|
||||
// (mirrors the best-effort "continuing" pattern used for DNS below). (#4546)
|
||||
let certTrusted = false;
|
||||
try {
|
||||
const certResult = await installCertResult(sudoPassword, certPath);
|
||||
certTrusted = certResult.installed;
|
||||
if (!certResult.installed) {
|
||||
log.warn(
|
||||
{ reason: certResult.reason },
|
||||
"MITM cert not auto-trusted; bridge starting in skip mode (manual trust required)"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, "installCertResult threw unexpectedly (continuing without trusted cert)");
|
||||
}
|
||||
|
||||
// 3. Add DNS entries: Antigravity defaults + all agents with dns_enabled=true +
|
||||
// all custom hosts with enabled=true.
|
||||
@@ -600,6 +615,7 @@ export async function startMitm(
|
||||
return {
|
||||
running: true,
|
||||
pid: serverPid,
|
||||
certTrusted,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
64
tests/unit/agent-bridge-cert-install-fallback-4546.test.ts
Normal file
64
tests/unit/agent-bridge-cert-install-fallback-4546.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4546 — In containers/headless, the system trust store can't be written
|
||||
// (no sudo / read-only store / no interactive auth), so the cert install
|
||||
// throws and used to abort the whole Agent Bridge start. These tests pin the
|
||||
// graceful-fallback contract: a structured result that distinguishes a
|
||||
// user-canceled auth from an environment failure, plus a platform-specific
|
||||
// manual-install guide so the operator can trust the MITM root CA themselves.
|
||||
|
||||
const { classifyCertInstallError, buildCertManualGuide, installCertResult } = await import(
|
||||
"../../src/mitm/cert/install.ts"
|
||||
);
|
||||
|
||||
const DOWNLOAD_URL = "/api/tools/agent-bridge/cert/download";
|
||||
|
||||
test("classifyCertInstallError → 'canceled' only when the message says canceled", () => {
|
||||
assert.equal(classifyCertInstallError("User canceled authorization"), "canceled");
|
||||
assert.equal(classifyCertInstallError("Operation was canceled by the user"), "canceled");
|
||||
});
|
||||
|
||||
test("classifyCertInstallError → 'environment' for trust-store / sudo failures", () => {
|
||||
assert.equal(classifyCertInstallError("Certificate install failed"), "environment");
|
||||
assert.equal(classifyCertInstallError("sudo: no tty present and no askpass program specified"), "environment");
|
||||
assert.equal(classifyCertInstallError("Certificate file not found: /x/server.crt"), "environment");
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(linux) → update-ca-certificates steps + download url + cert path", () => {
|
||||
const guide = buildCertManualGuide("/data/mitm/server.crt", "linux");
|
||||
assert.equal(guide.platform, "linux");
|
||||
assert.equal(guide.certPath, "/data/mitm/server.crt");
|
||||
assert.equal(guide.downloadUrl, DOWNLOAD_URL);
|
||||
assert.ok(Array.isArray(guide.steps) && guide.steps.length > 0);
|
||||
const joined = guide.steps.join("\n");
|
||||
assert.ok(joined.includes("update-ca-"), "should mention the distro CA refresh command");
|
||||
assert.ok(joined.includes("/data/mitm/server.crt"), "should reference the cert path");
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(darwin) → security add-trusted-cert", () => {
|
||||
const guide = buildCertManualGuide("/d/server.crt", "darwin");
|
||||
assert.equal(guide.platform, "darwin");
|
||||
assert.ok(guide.steps.join("\n").includes("add-trusted-cert"));
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(win32) → certutil -addstore Root", () => {
|
||||
const guide = buildCertManualGuide("C:/d/server.crt", "win32");
|
||||
assert.equal(guide.platform, "win32");
|
||||
assert.ok(guide.steps.join("\n").toLowerCase().includes("certutil"));
|
||||
});
|
||||
|
||||
test("installCertResult → environment skip (not a throw) when install is impossible", async () => {
|
||||
// A non-existent cert path makes installCert() throw before any privileged
|
||||
// command runs — deterministic, no sudo. The wrapper must convert that into a
|
||||
// structured skippable result with a manual guide, never a thrown error.
|
||||
const result = await installCertResult("", "/nonexistent/omniroute-4546-server.crt");
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.skipped, true);
|
||||
assert.equal(result.reason, "environment");
|
||||
assert.ok(result.manualGuide, "environment skip must carry a manual guide");
|
||||
assert.equal(result.manualGuide?.downloadUrl, DOWNLOAD_URL);
|
||||
// The message must be a safe string (no stack trace leaked).
|
||||
assert.equal(typeof result.message, "string");
|
||||
assert.ok(!String(result.message).includes("\n at "), "must not leak a stack trace");
|
||||
});
|
||||
Reference in New Issue
Block a user