mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* fix(mitm): gate Agent Bridge DNS and Trust Cert on sudo password (#7938) Extend the #7865 sudo gate to setup wizard DNS, Start DNS, and Trust Cert. Start server still runs without a password but skips privileged cert/DNS steps instead of spawning sudo -S with an empty string. Add a shared sudo password modal on the Agent Bridge page for DNS and trust-cert actions. Fixes #7938 * fix(mitm): use .tsx extension for MitmSudoPasswordModal hook JSX in a .ts file broke dashboard typecheck and ESLint on CI. * fix(mitm): skip DNS teardown on stop when sudo password missing (#7938) stopMitm() no longer invokes removeDNSEntry with an empty password when the server was started in skip mode. The MITM process is still killed. * refactor(mitm): extract privileged step helpers to satisfy file-size cap manager.ts exceeded the 800-line cap after #7938 gates. Move DNS teardown and the shared sudo skip runner into dedicated modules; behavior unchanged.
This commit is contained in:
committed by
GitHub
parent
567736fac5
commit
246b87f739
@@ -9,6 +9,7 @@ import { AgentBridgeMaintenanceCard } from "./components/AgentBridgeMaintenanceC
|
||||
import { AgentList } from "./components/AgentList";
|
||||
import { EmptyStateNoProviders } from "./components/EmptyStateNoProviders";
|
||||
import { useAgentBridgeState } from "./hooks/useAgentBridgeState";
|
||||
import { useMitmSudoPrompt, MitmSudoPasswordModal } from "./hooks/useMitmSudoPrompt";
|
||||
import type { MitmTargetView } from "@/mitm/types";
|
||||
import type { MappingRow } from "./components/ModelMappingTable";
|
||||
|
||||
@@ -78,38 +79,60 @@ export default function AgentBridgePageClient({
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [certGuide, setCertGuide] = useState<CertManualGuide | null>(null);
|
||||
|
||||
const { runPrivileged, sudoModalProps } = useMitmSudoPrompt({
|
||||
hasCachedPassword: data.serverState.hasCachedPassword === true,
|
||||
needsSudoPassword: data.serverState.needsSudoPassword === true,
|
||||
isWin: data.serverState.isWin === true,
|
||||
});
|
||||
|
||||
const postServerAction = useCallback(
|
||||
async (
|
||||
action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert",
|
||||
sudoPassword?: string
|
||||
) => {
|
||||
const res = await fetch("/api/tools/agent-bridge/server", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
sudoPassword ? { action, sudoPassword } : { action }
|
||||
),
|
||||
});
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
error?: { message?: string };
|
||||
skippable?: boolean;
|
||||
manualGuide?: CertManualGuide;
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(payload.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
if (payload.skippable && payload.manualGuide) {
|
||||
setCertGuide(payload.manualGuide);
|
||||
} else if (action === "trust-cert") {
|
||||
setCertGuide(null);
|
||||
}
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
// ── Server actions ────────────────────────────────────────────────────────
|
||||
|
||||
const handleServerAction = useCallback(
|
||||
async (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch("/api/tools/agent-bridge/server", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
if (action === "trust-cert") {
|
||||
await runPrivileged(async (password) => {
|
||||
await postServerAction("trust-cert", password || undefined);
|
||||
});
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
error?: { message?: string };
|
||||
skippable?: boolean;
|
||||
manualGuide?: CertManualGuide;
|
||||
};
|
||||
if (!res.ok) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await postServerAction(action);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
[postServerAction, runPrivileged]
|
||||
);
|
||||
|
||||
// ── Upstream CA ───────────────────────────────────────────────────────────
|
||||
@@ -152,18 +175,27 @@ export default function AgentBridgePageClient({
|
||||
async (agentId: string, enabled: boolean) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
await runPrivileged(async (password) => {
|
||||
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
password ? { enabled, sudoPassword: password } : { enabled }
|
||||
),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
throw new Error(payload.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
[refresh, runPrivileged]
|
||||
);
|
||||
|
||||
// ── Mappings save ─────────────────────────────────────────────────────────
|
||||
@@ -308,6 +340,8 @@ export default function AgentBridgePageClient({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MitmSudoPasswordModal {...sudoModalProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Input, Modal } from "@/shared/components";
|
||||
|
||||
export interface MitmSudoPasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (password: string) => void;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared sudo password modal for Agent Bridge privileged actions (#7938).
|
||||
* Same UX as AgentBridgeMaintenanceCard Repair / Remove CA.
|
||||
*/
|
||||
export function MitmSudoPasswordModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
busy = false,
|
||||
error = null,
|
||||
}: MitmSudoPasswordModalProps) {
|
||||
const tCli = useTranslations("cliTools");
|
||||
const [sudoPassword, setSudoPassword] = useState("");
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setSudoPassword("");
|
||||
setLocalError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!sudoPassword.trim()) {
|
||||
setLocalError(tCli("sudoPasswordRequiredError"));
|
||||
return;
|
||||
}
|
||||
const password = sudoPassword;
|
||||
setSudoPassword("");
|
||||
setLocalError(null);
|
||||
onConfirm(password);
|
||||
};
|
||||
|
||||
const displayError = error ?? localError;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title={tCli("sudoPasswordRequiredTitle")} size="sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
|
||||
<span className="material-symbols-outlined text-[20px] text-yellow-500">warning</span>
|
||||
<p className="text-xs text-text-muted">{tCli("sudoPasswordHint")}</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={tCli("enterSudoPassword")}
|
||||
value={sudoPassword}
|
||||
onChange={(event) => setSudoPassword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !busy) handleConfirm();
|
||||
}}
|
||||
/>
|
||||
|
||||
{displayError && (
|
||||
<div className="flex items-center gap-2 rounded bg-red-500/10 px-2 py-1.5 text-xs text-red-600">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} disabled={busy}>
|
||||
{tCli("cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm} disabled={busy}>
|
||||
{tCli("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface UseMitmSudoPromptOptions {
|
||||
hasCachedPassword: boolean;
|
||||
needsSudoPassword: boolean;
|
||||
isWin: boolean;
|
||||
}
|
||||
|
||||
type PrivilegedRunner = (password: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Prompt for sudo when the server reports `needsSudoPassword` and none is cached.
|
||||
*/
|
||||
export function useMitmSudoPrompt({
|
||||
hasCachedPassword,
|
||||
needsSudoPassword,
|
||||
isWin,
|
||||
}: UseMitmSudoPromptOptions) {
|
||||
const pendingRef = useRef<PrivilegedRunner | null>(null);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const canRunWithoutPassword = isWin || hasCachedPassword || !needsSudoPassword;
|
||||
|
||||
const closePasswordModal = useCallback(() => {
|
||||
setShowPasswordModal(false);
|
||||
pendingRef.current = null;
|
||||
setModalError(null);
|
||||
}, []);
|
||||
|
||||
const runPrivileged = useCallback(
|
||||
async (fn: PrivilegedRunner) => {
|
||||
if (canRunWithoutPassword) {
|
||||
await fn("");
|
||||
return;
|
||||
}
|
||||
setModalError(null);
|
||||
pendingRef.current = fn;
|
||||
setShowPasswordModal(true);
|
||||
},
|
||||
[canRunWithoutPassword]
|
||||
);
|
||||
|
||||
const confirmPassword = useCallback(async (password: string) => {
|
||||
const action = pendingRef.current;
|
||||
if (!action) return;
|
||||
setShowPasswordModal(false);
|
||||
pendingRef.current = null;
|
||||
setBusy(true);
|
||||
setModalError(null);
|
||||
try {
|
||||
await action(password);
|
||||
} catch (err) {
|
||||
pendingRef.current = action;
|
||||
setModalError(err instanceof Error ? err.message : "Action failed");
|
||||
setShowPasswordModal(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runPrivileged,
|
||||
sudoModalProps: {
|
||||
isOpen: showPasswordModal,
|
||||
onClose: closePasswordModal,
|
||||
onConfirm: confirmPassword,
|
||||
busy,
|
||||
error: modalError,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,12 @@
|
||||
import { AgentBridgeDnsActionSchema } from "@/shared/schemas/agentBridge";
|
||||
import { addDNSEntry, removeDNSEntry } from "@/mitm/dns/dnsConfig";
|
||||
import { upsertAgentBridgeState } from "@/lib/db/agentBridgeState";
|
||||
import { getCachedPassword } from "@/mitm/manager";
|
||||
import { getCachedPassword, setCachedPassword } from "@/mitm/manager";
|
||||
import {
|
||||
isMitmSudoPasswordRequired,
|
||||
normalizeMitmSudoPasswordInput,
|
||||
resolveMitmSudoPassword,
|
||||
} from "@/mitm/sudoGate";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { ALL_TARGETS } from "@/mitm/targets/index";
|
||||
@@ -42,8 +47,14 @@ export async function POST(request: Request, { params }: Params): Promise<Respon
|
||||
|
||||
const { enabled } = parsed.data;
|
||||
const raw = body as Record<string, unknown>;
|
||||
const sudoPassword =
|
||||
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
|
||||
const sudoPassword = resolveMitmSudoPassword(
|
||||
typeof raw.sudoPassword === "string" ? raw.sudoPassword : undefined,
|
||||
getCachedPassword()
|
||||
);
|
||||
|
||||
if (isMitmSudoPasswordRequired(sudoPassword)) {
|
||||
return createErrorResponse({ status: 400, message: "Missing sudoPassword" });
|
||||
}
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
@@ -52,6 +63,12 @@ export async function POST(request: Request, { params }: Params): Promise<Respon
|
||||
await removeDNSEntry(sudoPassword, id);
|
||||
}
|
||||
|
||||
const suppliedPassword =
|
||||
typeof raw.sudoPassword === "string" ? normalizeMitmSudoPasswordInput(raw.sudoPassword) : "";
|
||||
if (process.platform !== "win32" && suppliedPassword) {
|
||||
setCachedPassword(suppliedPassword);
|
||||
}
|
||||
|
||||
upsertAgentBridgeState({ agent_id: id, dns_enabled: enabled });
|
||||
|
||||
return Response.json({ ok: true, dns_enabled: enabled });
|
||||
|
||||
@@ -42,8 +42,14 @@ export async function GET(): Promise<Response> {
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const raw = await request.json().catch(() => ({}));
|
||||
const parsed = CertTrustBodySchema.safeParse(raw);
|
||||
const sudoPassword =
|
||||
(parsed.success ? parsed.data.sudoPassword : undefined) ?? getCachedPassword() ?? "";
|
||||
const sudoPassword = resolveMitmSudoPassword(
|
||||
parsed.success ? parsed.data.sudoPassword : undefined,
|
||||
getCachedPassword()
|
||||
);
|
||||
|
||||
if (isMitmSudoPasswordRequired(sudoPassword)) {
|
||||
return createErrorResponse({ status: 400, message: "Missing sudoPassword" });
|
||||
}
|
||||
|
||||
try {
|
||||
const crtPath = certPath();
|
||||
@@ -55,6 +61,12 @@ export async function POST(request: Request): Promise<Response> {
|
||||
}
|
||||
const result = await installCertResult(sudoPassword, crtPath);
|
||||
if (result.installed) {
|
||||
const suppliedPassword = parsed.success
|
||||
? normalizeMitmSudoPasswordInput(parsed.data.sudoPassword)
|
||||
: "";
|
||||
if (process.platform !== "win32" && suppliedPassword) {
|
||||
setCachedPassword(suppliedPassword);
|
||||
}
|
||||
const trusted = await checkCertInstalled(crtPath);
|
||||
return Response.json({ ok: true, trusted });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import { getCachedPassword, setCachedPassword } from "@/mitm/manager";
|
||||
import { installCertResult, checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { generateCert } from "@/mitm/cert/generate";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import {
|
||||
isMitmSudoPasswordRequired,
|
||||
normalizeMitmSudoPasswordInput,
|
||||
resolveMitmSudoPassword,
|
||||
} from "@/mitm/sudoGate";
|
||||
import path from "path";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
@@ -55,13 +60,17 @@ export async function POST(request: Request): Promise<Response> {
|
||||
|
||||
const { action } = parsed.data;
|
||||
const raw = body as Record<string, unknown>;
|
||||
const sudoPassword =
|
||||
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
|
||||
const sudoPassword = resolveMitmSudoPassword(
|
||||
typeof raw.sudoPassword === "string" ? raw.sudoPassword : undefined,
|
||||
getCachedPassword()
|
||||
);
|
||||
const rawApiKey = typeof raw.apiKey === "string" ? raw.apiKey : "";
|
||||
|
||||
try {
|
||||
if (action === "start") {
|
||||
if (sudoPassword) setCachedPassword(sudoPassword);
|
||||
const suppliedPassword =
|
||||
typeof raw.sudoPassword === "string" ? normalizeMitmSudoPasswordInput(raw.sudoPassword) : "";
|
||||
if (suppliedPassword) setCachedPassword(suppliedPassword);
|
||||
const apiKey = await resolveRouterApiKey(rawApiKey);
|
||||
const { startMitm } = await import("@/mitm/manager.runtime");
|
||||
const result = await startMitm(apiKey, sudoPassword);
|
||||
@@ -90,10 +99,17 @@ export async function POST(request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
if (action === "trust-cert") {
|
||||
if (isMitmSudoPasswordRequired(sudoPassword)) {
|
||||
return createErrorResponse({ status: 400, message: "Missing sudoPassword" });
|
||||
}
|
||||
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
|
||||
const pwd = sudoPassword || getCachedPassword() || "";
|
||||
const result = await installCertResult(pwd, certPath);
|
||||
const result = await installCertResult(sudoPassword, certPath);
|
||||
if (result.installed) {
|
||||
const suppliedPassword =
|
||||
typeof raw.sudoPassword === "string" ? normalizeMitmSudoPasswordInput(raw.sudoPassword) : "";
|
||||
if (process.platform !== "win32" && suppliedPassword) {
|
||||
setCachedPassword(suppliedPassword);
|
||||
}
|
||||
const trusted = await checkCertInstalled(certPath);
|
||||
return Response.json({ ok: true, trusted });
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
performRepairSteps,
|
||||
type RepairPlan,
|
||||
} from "./repair.ts";
|
||||
import { runPrivilegedMitmStep } from "./privilegedMitmStep.ts";
|
||||
import { removeStopDnsEntries } from "./stopDnsTeardown.ts";
|
||||
|
||||
export { buildRepairPlan, collectManagedHosts, type RepairPlan };
|
||||
|
||||
@@ -520,30 +522,42 @@ async function startMitmInternal(
|
||||
// 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 =
|
||||
migrationDecision === "use-root-ca"
|
||||
? await installCaCert(sudoPassword, certPath)
|
||||
: 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)"
|
||||
);
|
||||
await runPrivilegedMitmStep(
|
||||
sudoPassword,
|
||||
"Skipping MITM cert trust — no sudo password available (#7938)",
|
||||
async () => {
|
||||
try {
|
||||
const certResult =
|
||||
migrationDecision === "use-root-ca"
|
||||
? await installCaCert(sudoPassword, certPath)
|
||||
: 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)");
|
||||
}
|
||||
}
|
||||
} 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. Best-effort — see provisionDnsEntries.
|
||||
log.info("Adding DNS entries...");
|
||||
try {
|
||||
await provisionDnsEntries(sudoPassword);
|
||||
} catch (err) {
|
||||
log.error({ err }, "DNS provisioning threw unexpectedly (continuing)");
|
||||
}
|
||||
await runPrivilegedMitmStep(
|
||||
sudoPassword,
|
||||
"Skipping DNS provisioning — no sudo password available (#7938)",
|
||||
async () => {
|
||||
log.info("Adding DNS entries...");
|
||||
try {
|
||||
await provisionDnsEntries(sudoPassword);
|
||||
} catch (err) {
|
||||
log.error({ err }, "DNS provisioning threw unexpectedly (continuing)");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 4. Start MITM server
|
||||
log.info("Starting MITM server...");
|
||||
@@ -670,31 +684,6 @@ async function startMitmInternal(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS teardown step of stopMitm() (#1809) — split out purely to keep
|
||||
* stopMitm()'s own cyclomatic complexity under the repo's ratchet; behavior
|
||||
* is unchanged from the original inline implementation.
|
||||
*/
|
||||
async function removeStopDnsEntries(
|
||||
deps: {
|
||||
removeDNSEntry: (sudoPassword: string) => Promise<void>;
|
||||
removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise<void>;
|
||||
collectManagedHosts: () => string[];
|
||||
},
|
||||
sudoPassword: string
|
||||
): Promise<void> {
|
||||
log.info("Removing DNS entries...");
|
||||
await deps.removeDNSEntry(sudoPassword);
|
||||
try {
|
||||
const managed = deps.collectManagedHosts();
|
||||
if (managed.length > 0) {
|
||||
await deps.removeDNSEntries(managed, sudoPassword);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the MITM server process during stop — either the in-memory
|
||||
* `serverProcess` handle or, if that's gone, the PID recorded in `PID_FILE`.
|
||||
@@ -765,9 +754,12 @@ export async function stopMitm(
|
||||
collectManagedHosts: _depsOverride?.collectManagedHosts ?? collectManagedHosts,
|
||||
};
|
||||
|
||||
// 1. Remove DNS entries FIRST — see function doc + module doc above for why
|
||||
// this must happen before the process kill (#1809, Gap 8).
|
||||
await removeStopDnsEntries(deps, sudoPassword);
|
||||
// 1. Remove DNS entries FIRST — see function doc + module doc above (#1809).
|
||||
await runPrivilegedMitmStep(
|
||||
sudoPassword,
|
||||
"Skipping DNS teardown — no sudo password available (#7938)",
|
||||
() => removeStopDnsEntries(deps, sudoPassword)
|
||||
);
|
||||
|
||||
// 2. Kill server process (in-memory or from PID file)
|
||||
await killMitmServerProcessOnStop();
|
||||
|
||||
17
src/mitm/privilegedMitmStep.ts
Normal file
17
src/mitm/privilegedMitmStep.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createLogger } from "@/shared/utils/logger.ts";
|
||||
import { canRunPrivilegedMitmSteps } from "./sudoGate.ts";
|
||||
|
||||
const log = createLogger("mitm-manager");
|
||||
|
||||
/** Run a privileged MITM step when sudo is available; log and skip otherwise (#7938). */
|
||||
export async function runPrivilegedMitmStep(
|
||||
sudoPassword: string,
|
||||
skipLog: string,
|
||||
step: () => Promise<void>
|
||||
): Promise<void> {
|
||||
if (!canRunPrivilegedMitmSteps(sudoPassword)) {
|
||||
log.info(skipLog);
|
||||
return;
|
||||
}
|
||||
await step();
|
||||
}
|
||||
26
src/mitm/stopDnsTeardown.ts
Normal file
26
src/mitm/stopDnsTeardown.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createLogger } from "@/shared/utils/logger.ts";
|
||||
|
||||
const log = createLogger("mitm-manager");
|
||||
|
||||
export type StopDnsDeps = {
|
||||
removeDNSEntry: (sudoPassword: string) => Promise<void>;
|
||||
removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise<void>;
|
||||
collectManagedHosts: () => string[];
|
||||
};
|
||||
|
||||
/** DNS teardown step of stopMitm() (#1809) — extracted for file-size ratchet. */
|
||||
export async function removeStopDnsEntries(
|
||||
deps: StopDnsDeps,
|
||||
sudoPassword: string
|
||||
): Promise<void> {
|
||||
log.info("Removing DNS entries...");
|
||||
await deps.removeDNSEntry(sudoPassword);
|
||||
try {
|
||||
const managed = deps.collectManagedHosts();
|
||||
if (managed.length > 0) {
|
||||
await deps.removeDNSEntries(managed, sudoPassword);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)");
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,8 @@ export function isMitmSudoPasswordRequired(sudoPassword: string): boolean {
|
||||
if (normalizeMitmSudoPasswordInput(sudoPassword)) return false;
|
||||
return isSudoPasswordRequired();
|
||||
}
|
||||
|
||||
/** Whether cert trust / DNS provisioning may run (inverse of the hard gate). */
|
||||
export function canRunPrivilegedMitmSteps(sudoPassword: string): boolean {
|
||||
return !isMitmSudoPasswordRequired(sudoPassword);
|
||||
}
|
||||
|
||||
46
tests/unit/agent-bridge-cert-trust-sudo-gate.test.ts
Normal file
46
tests/unit/agent-bridge-cert-trust-sudo-gate.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* #7938 — Trust Cert must gate empty sudo the same way as Remove CA / Repair.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isSudoPasswordRequired } from "../../src/mitm/dns/dnsConfig.ts";
|
||||
|
||||
const certRoute = await import("../../src/app/api/tools/agent-bridge/cert/route.ts");
|
||||
const serverRoute = await import("../../src/app/api/tools/agent-bridge/server/route.ts");
|
||||
|
||||
function skipWhenSudoNotRequired() {
|
||||
if (process.platform === "win32") return true;
|
||||
if (!isSudoPasswordRequired()) return true;
|
||||
const isRootUser = !!(process.getuid && process.getuid() === 0);
|
||||
return isRootUser;
|
||||
}
|
||||
|
||||
test("POST /cert returns 400 Missing sudoPassword when sudo is required and none supplied", async () => {
|
||||
if (skipWhenSudoNotRequired()) return;
|
||||
|
||||
const res = await certRoute.POST(
|
||||
new Request("http://127.0.0.1/api/tools/agent-bridge/cert", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error?: { message?: string } };
|
||||
assert.match(body.error?.message ?? "", /Missing sudoPassword/);
|
||||
});
|
||||
|
||||
test("POST /server trust-cert returns 400 Missing sudoPassword when sudo is required", async () => {
|
||||
if (skipWhenSudoNotRequired()) return;
|
||||
|
||||
const res = await serverRoute.POST(
|
||||
new Request("http://127.0.0.1/api/tools/agent-bridge/server", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action: "trust-cert" }),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error?: { message?: string } };
|
||||
assert.match(body.error?.message ?? "", /Missing sudoPassword/);
|
||||
});
|
||||
46
tests/unit/agent-bridge-dns-sudo-gate.test.ts
Normal file
46
tests/unit/agent-bridge-dns-sudo-gate.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* #7938 — Agent Bridge DNS toggle must not spawn `sudo -S` with an empty password.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isSudoPasswordRequired } from "../../src/mitm/dns/dnsConfig.ts";
|
||||
|
||||
const dnsRoute = await import(
|
||||
"../../src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts"
|
||||
);
|
||||
|
||||
function makeDnsRequest(body: Record<string, unknown> = { enabled: true }) {
|
||||
return new Request("http://127.0.0.1/api/tools/agent-bridge/agents/cursor/dns", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test("POST .../[id]/dns returns 400 Missing sudoPassword when sudo is required and none supplied", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
if (!isSudoPasswordRequired()) return;
|
||||
const isRootUser = !!(process.getuid && process.getuid() === 0);
|
||||
if (isRootUser) return;
|
||||
|
||||
const res = await dnsRoute.POST(makeDnsRequest({ enabled: true }), {
|
||||
params: { id: "cursor" },
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error?: { message?: string } };
|
||||
assert.match(body.error?.message ?? "", /Missing sudoPassword/);
|
||||
});
|
||||
|
||||
test("POST .../[id]/dns returns 400 for whitespace-only sudoPassword", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
if (!isSudoPasswordRequired()) return;
|
||||
const isRootUser = !!(process.getuid && process.getuid() === 0);
|
||||
if (isRootUser) return;
|
||||
|
||||
const res = await dnsRoute.POST(makeDnsRequest({ enabled: true, sudoPassword: " " }), {
|
||||
params: { id: "cursor" },
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error?: { message?: string } };
|
||||
assert.match(body.error?.message ?? "", /Missing sudoPassword/);
|
||||
});
|
||||
74
tests/unit/mitm-privileged-steps-sudo-gate.test.ts
Normal file
74
tests/unit/mitm-privileged-steps-sudo-gate.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* #7938 — privileged MITM steps (cert trust, DNS) must be skippable when no sudo password.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
canRunPrivilegedMitmSteps,
|
||||
isMitmSudoPasswordRequired,
|
||||
} from "../../src/mitm/sudoGate.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mitm-sudo-gate-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const manager = await import("../../src/mitm/manager.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("canRunPrivilegedMitmSteps is false when isMitmSudoPasswordRequired is true", () => {
|
||||
assert.equal(canRunPrivilegedMitmSteps("secret"), !isMitmSudoPasswordRequired("secret"));
|
||||
});
|
||||
|
||||
test("canRunPrivilegedMitmSteps is false for empty password on POSIX sudo-required hosts", () => {
|
||||
if (process.platform === "win32") return;
|
||||
const isRootUser = !!(process.getuid && process.getuid() === 0);
|
||||
if (isRootUser) return;
|
||||
if (!isMitmSudoPasswordRequired("")) return;
|
||||
assert.equal(canRunPrivilegedMitmSteps(""), false);
|
||||
});
|
||||
|
||||
test("stopMitm skips DNS teardown without sudo password but still kills server (#7938)", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const isRootUser = !!(process.getuid && process.getuid() === 0);
|
||||
if (isRootUser) return;
|
||||
if (!isMitmSudoPasswordRequired("")) return;
|
||||
|
||||
const events: string[] = [];
|
||||
const fakeProc = new EventEmitter() as EventEmitter & {
|
||||
killed: boolean;
|
||||
kill: (signal?: string) => boolean;
|
||||
};
|
||||
fakeProc.killed = false;
|
||||
fakeProc.kill = (signal?: string) => {
|
||||
events.push(`kill:${signal}`);
|
||||
fakeProc.killed = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
manager.__setServerProcessForTest(fakeProc as unknown as import("child_process").ChildProcess, 4242);
|
||||
|
||||
await manager.stopMitm("", {
|
||||
removeDNSEntry: async () => {
|
||||
events.push("removeDNSEntry");
|
||||
},
|
||||
removeDNSEntries: async () => {
|
||||
events.push("removeDNSEntries");
|
||||
},
|
||||
collectManagedHosts: () => ["fake.example.test"],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
events.filter((event) => event.startsWith("remove")).length,
|
||||
0,
|
||||
"must not invoke DNS teardown with empty sudo password"
|
||||
);
|
||||
assert.ok(events.some((event) => event.startsWith("kill:")), "server process must still be stopped");
|
||||
});
|
||||
Reference in New Issue
Block a user