212 lines
6.5 KiB
JavaScript
212 lines
6.5 KiB
JavaScript
import { createServer } from "node:http";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { isCloudWebSseRoute, proxyCloudApiRequest } from "./cloud-web-proxy.mjs";
|
|
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
|
|
|
|
const readOnlyRpcMethods = new Set([
|
|
"system.health",
|
|
"cloud.adapter.describe",
|
|
"audit.event.query",
|
|
"evidence.record.query"
|
|
]);
|
|
|
|
export async function serveCloudWeb(options) {
|
|
const server = createCloudWebServer(options);
|
|
server.listen(options.port, "0.0.0.0", () => {
|
|
process.stdout.write(JSON.stringify({
|
|
serviceId: options.serviceId,
|
|
environment: process.env.HWLAB_ENVIRONMENT || "dev",
|
|
status: "listening",
|
|
port: options.port
|
|
}) + "\n");
|
|
});
|
|
}
|
|
|
|
export function createCloudWebServer({
|
|
serviceId,
|
|
healthPayload,
|
|
sendJson,
|
|
roots = ["/app/web/hwlab-cloud-web/dist", "/app/web/hwlab-cloud-web"],
|
|
cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || "",
|
|
cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 1260000, {
|
|
min: 1000,
|
|
max: 2400000
|
|
})
|
|
}) {
|
|
return createServer(async (request, response) => {
|
|
const url = new URL(request.url || "/", "http://hwlab-cloud-web.local");
|
|
if (url.pathname === "/health/live" || url.pathname === "/health") {
|
|
sendJson(response, 200, healthPayload());
|
|
return;
|
|
}
|
|
if (await handleCloudWebAuth({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson })) {
|
|
return;
|
|
}
|
|
|
|
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
|
|
if (proxyPolicy.proxy) {
|
|
await proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson });
|
|
return;
|
|
}
|
|
|
|
const routePath = url.pathname.replace(/\/+$/u, "") || "/";
|
|
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help"
|
|
? "index.html"
|
|
: url.pathname.slice(1);
|
|
for (const root of roots) {
|
|
const candidate = path.resolve(root, relativePath);
|
|
if (!candidate.startsWith(root)) continue;
|
|
try {
|
|
const info = await stat(candidate);
|
|
if (!info.isFile()) continue;
|
|
const body = await readFile(candidate);
|
|
response.writeHead(200, {
|
|
"content-type": contentType(candidate),
|
|
"content-length": body.length
|
|
});
|
|
response.end(body);
|
|
return;
|
|
} catch {
|
|
// Try the next root.
|
|
}
|
|
}
|
|
|
|
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
|
|
});
|
|
}
|
|
|
|
export async function handleCloudWebAuth(options) {
|
|
const { request, response, url } = options;
|
|
if (url.pathname === "/auth/session" && request.method === "GET") {
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
if (url.pathname === "/auth/login" && request.method === "POST") {
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
if (url.pathname === "/auth/logout" && request.method === "POST") {
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
if (!url.pathname.startsWith("/auth/")) return false;
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
|
|
async function readRequestBody(request) {
|
|
let body = "";
|
|
for await (const chunk of request) {
|
|
body += chunk;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function parseTimeout(value, fallback, { min, max }) {
|
|
const parsed = Number.parseInt(value || "", 10);
|
|
if (!Number.isInteger(parsed)) return fallback;
|
|
return Math.min(Math.max(parsed, min), max);
|
|
}
|
|
|
|
function requestUpstream({ target, request, response, body, url, cloudApiProxyTimeoutMs }) {
|
|
return proxyCloudApiRequest({
|
|
target,
|
|
request,
|
|
response,
|
|
body,
|
|
timeoutMs: cloudApiProxyTimeoutMs,
|
|
forceStream: isCloudWebSseRoute(url.pathname)
|
|
});
|
|
}
|
|
|
|
export async function proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }) {
|
|
if (!cloudApiBaseUrl) {
|
|
sendJson(response, 503, {
|
|
error: "cloud_api_base_url_missing",
|
|
serviceId,
|
|
path: url.pathname
|
|
});
|
|
return;
|
|
}
|
|
|
|
let body = "";
|
|
if (request.method !== "GET") {
|
|
body = await readRequestBody(request);
|
|
}
|
|
|
|
if (url.pathname === "/json-rpc") {
|
|
try {
|
|
const envelope = body ? JSON.parse(body) : {};
|
|
if (!readOnlyRpcMethods.has(envelope.method)) {
|
|
sendJson(response, 403, {
|
|
error: "readonly_rpc_required",
|
|
serviceId,
|
|
method: envelope.method || null
|
|
});
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
sendJson(response, 400, {
|
|
error: "invalid_json",
|
|
serviceId,
|
|
reason: error.message
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const target = new URL(url.pathname + url.search, cloudApiBaseUrl);
|
|
try {
|
|
await requestUpstream({ target, request, response, body: request.method === "GET" ? "" : body, url, cloudApiProxyTimeoutMs });
|
|
} catch (error) {
|
|
if (response.headersSent || response.writableEnded) {
|
|
if (!response.writableEnded) response.destroy(error);
|
|
return;
|
|
}
|
|
const timedOut = error.timedOut === true || /timed out after \d+ms/iu.test(error.message);
|
|
const code = timedOut ? "cloud_api_proxy_timeout" : "cloud_api_proxy_failed";
|
|
const category = timedOut ? "timeout" : "proxy";
|
|
const traceId = request.headers["x-trace-id"] || null;
|
|
const userMessage = timedOut
|
|
? "Code Agent 代理等待 cloud-api 超过 " + cloudApiProxyTimeoutMs + "ms;输入已保留,可稍后重试。"
|
|
: "Code Agent 代理暂时无法连接 cloud-api;输入已保留,可稍后重试。";
|
|
sendJson(response, 502, {
|
|
status: "failed",
|
|
error: {
|
|
code,
|
|
layer: "proxy",
|
|
category,
|
|
retryable: true,
|
|
userMessage,
|
|
message: userMessage,
|
|
timeoutMs: timedOut ? cloudApiProxyTimeoutMs : null,
|
|
traceId,
|
|
route: url.pathname,
|
|
blocker: {
|
|
code,
|
|
layer: "proxy",
|
|
category,
|
|
retryable: true,
|
|
summary: userMessage,
|
|
userMessage,
|
|
traceId,
|
|
route: url.pathname
|
|
}
|
|
},
|
|
serviceId,
|
|
target: target.href.replace(/\/\/[^/@]*@/u, "//***@"),
|
|
traceId,
|
|
reason: userMessage
|
|
});
|
|
}
|
|
}
|
|
|
|
function contentType(filePath) {
|
|
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
|
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
|
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
return "application/octet-stream";
|
|
}
|