358 lines
12 KiB
JavaScript
358 lines
12 KiB
JavaScript
import { createServer, request as httpRequest } from "node:http";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { gzip } from "node:zlib";
|
|
import { promisify } from "node:util";
|
|
|
|
import { isCloudWebSseRoute, proxyCloudApiRequest, upstreamRequestHeaders } 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"
|
|
]);
|
|
const gzipAsync = promisify(gzip);
|
|
const GZIP_MIN_BYTES = 1024;
|
|
const DEFAULT_AUTH_USERNAME = "admin";
|
|
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
|
|
|
|
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" || routePath === "/skills" || routePath === "/access"
|
|
? "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);
|
|
const type = contentType(candidate);
|
|
const encoded = await encodeStaticBody(request, body, type);
|
|
response.writeHead(200, {
|
|
"content-type": type,
|
|
...encoded.headers,
|
|
"content-length": encoded.body.length
|
|
});
|
|
response.end(encoded.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/bootstrap" && request.method === "GET") {
|
|
await handleAuthBootstrap(options);
|
|
return true;
|
|
}
|
|
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 handleAuthBootstrap(options) {
|
|
const { request, response, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options;
|
|
if (!cloudApiBaseUrl) {
|
|
sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/bootstrap" });
|
|
return;
|
|
}
|
|
|
|
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
|
if (session.ok && session.body?.authenticated === true) {
|
|
sendJsonWithForwardedCookies(response, session.statusCode, session.body, session.headers);
|
|
return;
|
|
}
|
|
|
|
const config = webAuthConfig();
|
|
if (config.mode !== "auto") {
|
|
sendJsonWithForwardedCookies(response, session.statusCode || 200, session.body ?? { authenticated: false }, session.headers);
|
|
return;
|
|
}
|
|
|
|
const login = await requestCloudApiJson({
|
|
request,
|
|
method: "POST",
|
|
pathname: "/auth/login",
|
|
body: JSON.stringify({ username: config.username, password: config.password }),
|
|
cloudApiBaseUrl,
|
|
cloudApiProxyTimeoutMs
|
|
});
|
|
sendJsonWithForwardedCookies(response, login.statusCode || 502, login.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, login.headers);
|
|
}
|
|
|
|
function webAuthConfig() {
|
|
return {
|
|
mode: authConfigValue("HWLAB_CLOUD_WEB_AUTH_MODE", "auto"),
|
|
username: authConfigValue("HWLAB_CLOUD_WEB_AUTH_USERNAME", DEFAULT_AUTH_USERNAME),
|
|
password: authConfigValue("HWLAB_CLOUD_WEB_AUTH_PASSWORD", DEFAULT_AUTH_PASSWORD)
|
|
};
|
|
}
|
|
|
|
function authConfigValue(name, fallback) {
|
|
const value = process.env[name];
|
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
}
|
|
|
|
function requestCloudApiJson({ request, method, pathname, body = "", cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const target = new URL(pathname, cloudApiBaseUrl);
|
|
const proxyRequest = { method, headers: upstreamRequestHeaders({ headers: request.headers }, body) };
|
|
const upstream = httpRequest(target, { method, headers: proxyRequest.headers }, (upstreamResponse) => {
|
|
const chunks = [];
|
|
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
|
|
upstreamResponse.on("end", () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
let parsed = null;
|
|
try { parsed = rawBody ? JSON.parse(rawBody) : null; } catch { parsed = { error: "invalid_json", rawBody: rawBody.slice(0, 200) }; }
|
|
resolve({ ok: (upstreamResponse.statusCode || 0) >= 200 && (upstreamResponse.statusCode || 0) < 300, statusCode: upstreamResponse.statusCode || 502, body: parsed, headers: upstreamResponse.headers });
|
|
});
|
|
upstreamResponse.on("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
|
|
});
|
|
});
|
|
const timeout = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
upstream.destroy();
|
|
resolve({ ok: false, statusCode: 504, body: { authenticated: false, error: "auth_bootstrap_timeout" }, headers: {} });
|
|
}, cloudApiProxyTimeoutMs);
|
|
upstream.on("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
|
|
});
|
|
upstream.on("close", () => clearTimeout(timeout));
|
|
if (body) upstream.write(body);
|
|
upstream.end();
|
|
});
|
|
}
|
|
|
|
function sendJsonWithForwardedCookies(response, statusCode, body, headers = {}) {
|
|
const payload = JSON.stringify(body);
|
|
const setCookie = headers["set-cookie"];
|
|
const responseHeaders = {
|
|
"content-type": "application/json",
|
|
"content-length": Buffer.byteLength(payload)
|
|
};
|
|
if (setCookie) responseHeaders["set-cookie"] = setCookie;
|
|
response.writeHead(statusCode, responseHeaders);
|
|
response.end(payload);
|
|
}
|
|
|
|
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";
|
|
if (filePath.endsWith(".svg")) return "image/svg+xml; charset=utf-8";
|
|
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
|
|
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
async function encodeStaticBody(request, body, type) {
|
|
if (!isGzipCandidate(type, body.length) || !acceptsEncoding(request.headers["accept-encoding"], "gzip")) {
|
|
return { body, headers: isCompressibleType(type) ? { "vary": "Accept-Encoding" } : {} };
|
|
}
|
|
const compressed = await gzipAsync(body, { level: 6 });
|
|
return {
|
|
body: compressed,
|
|
headers: {
|
|
"content-encoding": "gzip",
|
|
"vary": "Accept-Encoding"
|
|
}
|
|
};
|
|
}
|
|
|
|
function isGzipCandidate(type, sizeBytes) {
|
|
return sizeBytes >= GZIP_MIN_BYTES && isCompressibleType(type);
|
|
}
|
|
|
|
function isCompressibleType(type) {
|
|
return /^(?:text\/|application\/json\b)/u.test(String(type || ""));
|
|
}
|
|
|
|
function acceptsEncoding(header, encoding) {
|
|
const value = Array.isArray(header) ? header.join(",") : String(header || "");
|
|
for (const segment of value.split(",")) {
|
|
const [name, ...params] = segment.trim().split(";");
|
|
const token = name.trim().toLowerCase();
|
|
if (token !== encoding && token !== "*") continue;
|
|
const q = params.map((param) => param.trim().toLowerCase()).find((param) => param.startsWith("q="));
|
|
if (q && Number.parseFloat(q.slice(2)) <= 0) continue;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|