feat: secure frontend and provider ingress

This commit is contained in:
Codex
2026-05-04 11:40:56 +00:00
parent caa80ee5e7
commit 8726611b6f
25 changed files with 1491 additions and 450 deletions
+108 -42
View File
@@ -14,27 +14,35 @@ interface E2ECheck {
interface PublicUrls {
frontendUrl: string;
coreUrl: string;
databaseHost: string;
databasePort: number;
providerIngressHealthUrl: string;
providerIngressWsUrl: string;
blockedCoreUrl: string;
blockedDatabaseHost: string;
blockedDatabasePort: number;
}
function publicUrls(config: UniDeskConfig): PublicUrls {
return {
frontendUrl: `http://${config.network.publicHost}:${config.network.frontend.port}`,
coreUrl: `http://${config.network.publicHost}:${config.network.core.port}`,
databaseHost: config.network.publicHost,
databasePort: config.network.database.port,
providerIngressHealthUrl: `http://${config.network.publicHost}:${config.network.providerIngress.port}/health`,
providerIngressWsUrl: `ws://${config.network.publicHost}:${config.network.providerIngress.port}/ws/provider`,
blockedCoreUrl: `http://${config.network.publicHost}:${config.network.core.port}`,
blockedDatabaseHost: config.network.publicHost,
blockedDatabasePort: config.network.database.port,
};
}
async function fetchJson(url: string): Promise<unknown> {
async function fetchProbe(url: string, timeoutMs = 8000): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
const text = await response.text();
return { ok: response.ok, status: response.status, body: text.length > 0 ? JSON.parse(text) : null };
let body: unknown = text;
try { body = text.length > 0 ? JSON.parse(text) : null; } catch { /* keep text body */ }
return { reachable: true, ok: response.ok, status: response.status, body };
} catch (error) {
return { reachable: false, ok: false, error: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
@@ -63,15 +71,81 @@ function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: str
return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode };
}
async function apiChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const overview = await fetchJson(`${urls.coreUrl}/api/overview`);
const nodes = await fetchJson(`${urls.coreUrl}/api/nodes`);
addCheck(checks, "core:public-overview", (overview as { ok?: boolean; body?: { ok?: boolean; onlineNodeCount?: number } }).ok === true && (overview as { body?: { ok?: boolean; onlineNodeCount?: number } }).body?.ok === true && ((overview as { body?: { onlineNodeCount?: number } }).body?.onlineNodeCount ?? 0) >= 1, overview);
const nodeList = (nodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? [];
addCheck(checks, "core:public-nodes", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), nodes);
function dockerCoreJson(path: string): unknown {
const code = `
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)});
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
console.log(JSON.stringify({ ok: res.ok, status: res.status, body }));
`;
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
if (result.exitCode !== 0) return { ok: false, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
try {
return JSON.parse(result.stdout.trim()) as unknown;
} catch {
return { ok: true, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
}
}
function databaseChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): string {
function dockerPortSummary(): unknown {
const result = runCommand([
"docker",
"ps",
"--filter",
"label=com.docker.compose.project=unidesk",
"--format",
"{{.Names}}\t{{.Ports}}",
], repoRoot);
return {
ok: result.exitCode === 0,
exitCode: result.exitCode,
rows: result.stdout.trim().split("\n").filter(Boolean).map((line) => {
const [name, ports = ""] = line.split("\t");
return { name, ports };
}),
stderr: result.stderr.trim(),
};
}
async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> };
const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n");
const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500);
const databasePublic = runCommand([
"docker",
"run",
"--rm",
"postgres:16-alpine",
"pg_isready",
"-h",
urls.blockedDatabaseHost,
"-p",
String(urls.blockedDatabasePort),
"-U",
config.database.user,
], repoRoot);
addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary);
addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic);
addCheck(checks, "network:database-public-blocked", databasePublic.exitCode !== 0, {
exitCode: databasePublic.exitCode,
stdout: databasePublic.stdout.trim(),
stderr: databasePublic.stderr.trim(),
});
}
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const coreOverview = dockerCoreJson("/api/overview");
const coreNodes = dockerCoreJson("/api/nodes");
const providerIngress = await fetchProbe(urls.providerIngressHealthUrl);
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body;
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? [];
addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview);
addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes);
addCheck(checks, "provider-ingress:public-health", (providerIngress as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (providerIngress as { body?: { ok?: boolean } }).body?.ok === true, providerIngress);
}
function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string {
const markerId = `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
const markerSql = `
CREATE TABLE IF NOT EXISTS unidesk_e2e_markers (
@@ -87,25 +161,6 @@ function databaseChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2EChec
const marker = runPsql(config, markerSql);
addCheck(checks, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker);
addCheck(checks, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker);
const publicProbe = runCommand([
"docker",
"run",
"--rm",
"postgres:16-alpine",
"pg_isready",
"-h",
urls.databaseHost,
"-p",
String(urls.databasePort),
"-U",
config.database.user,
], repoRoot);
addCheck(checks, "database:public-port", publicProbe.exitCode === 0, {
exitCode: publicProbe.exitCode,
stdout: publicProbe.stdout.trim(),
stderr: publicProbe.stderr.trim(),
});
return markerId;
}
@@ -122,14 +177,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
});
page.on("pageerror", (error) => consoleErrors.push(error.message));
await page.goto(urls.frontendUrl, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForFunction(() => document.querySelector("#conn-text")?.textContent?.includes("核心在线"), undefined, { timeout: 15000 });
await page.waitForSelector('[data-testid="login-screen"]', { timeout: 10000 });
await page.fill('input[name="username"]', config.auth.username);
await page.fill('input[name="password"]', config.auth.password);
await page.click('button[type="submit"]');
await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 });
await page.waitForFunction(() => document.querySelector('[data-testid="conn-text"]')?.textContent?.includes("核心在线"), undefined, { timeout: 15000 });
await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 });
await page.waitForSelector("text=Online Nodes", { timeout: 5000 });
await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 });
const bodyText = await page.locator("body").innerText({ timeout: 5000 });
const nodeCount = await page.locator("#node-count").innerText({ timeout: 5000 });
const metricText = await page.locator("#metric-grid").innerText({ timeout: 5000 });
const rawBlocksBefore = await page.locator("pre.raw-json").count();
const nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"');
await page.screenshot({ path: screenshotPath, fullPage: true });
addCheck(checks, "frontend:public-page-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name), { nodeCount, metricText, screenshotPath });
await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click();
await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 });
const rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 });
addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath });
addCheck(checks, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText });
addCheck(checks, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) });
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
return { screenshotPath, bodyText, consoleErrors };
} finally {
@@ -140,8 +205,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
export async function runE2E(config: UniDeskConfig): Promise<unknown> {
const checks: E2ECheck[] = [];
const urls = publicUrls(config);
await apiChecks(config, urls, checks);
const markerId = databaseChecks(config, urls, checks);
await exposureChecks(config, urls, checks);
await serviceChecks(config, urls, checks);
const markerId = databaseChecks(config, checks);
const frontend = await frontendCheck(config, urls, checks);
const ok = checks.every((check) => check.status === "passed");
return {