fix: proxy opencode through hwlab auth

This commit is contained in:
UniDesk Codex
2026-06-30 01:53:52 +08:00
parent e02a339240
commit e105deb0bb
4 changed files with 269 additions and 9 deletions
+4
View File
@@ -558,6 +558,9 @@ lanes:
HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: Asia/Shanghai
HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: zh-CN
HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: 北京时间
HWLAB_CLOUD_WEB_OPENCODE_UPSTREAM_URL: http://opencode-server.hwlab-v03.svc.cluster.local:4096
HWLAB_CLOUD_WEB_OPENCODE_USERNAME: secretRef:hwlab-opencode-server-auth/username
HWLAB_CLOUD_WEB_OPENCODE_PASSWORD: secretRef:hwlab-opencode-server-auth/password
observable: true
hwlab-gateway:
runtimeKind: bun-command
@@ -734,6 +737,7 @@ lanes:
HWLAB_CLOUD_DB_URL: secretRef:hwlab-cloud-api-v03-db/database-url
HWLAB_CLOUD_DB_CONTRACT: v03-redacted-presence-only
HWLAB_ACCESS_CONTROL_REQUIRED: "1"
HWLAB_SESSION_COOKIE_DOMAIN: .pikapython.com
HWLAB_BOOTSTRAP_ADMIN_ID: usr_v03_admin
HWLAB_BOOTSTRAP_ADMIN_USERNAME: admin
HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME: HWLAB v0.3 Admin
+31 -8
View File
@@ -17,17 +17,26 @@ import { createUserBillingClient } from "./user-billing-client.ts";
const SESSION_COOKIE = "hwlab_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24;
const SESSION_COOKIE_SAMESITE = "Lax";
function buildSessionCookie(token, maxAge, { secure = false } = {}) {
return `${SESSION_COOKIE}=${encodeURIComponent(token)}; ${sessionCookieAttributes(maxAge, { secure })}`;
function buildSessionCookie(token, maxAge, { secure = false, domain = "" } = {}) {
return `${SESSION_COOKIE}=${encodeURIComponent(token)}; ${sessionCookieAttributes(maxAge, { secure, domain })}`;
}
function buildClearSessionCookie({ secure = false } = {}) {
return `${SESSION_COOKIE}=; ${sessionCookieAttributes(0, { secure })}`;
function buildClearSessionCookie({ secure = false, domain = "" } = {}) {
return `${SESSION_COOKIE}=; ${sessionCookieAttributes(0, { secure, domain })}`;
}
function sessionCookieAttributes(maxAge, { secure = false } = {}) {
return ["Path=/", "HttpOnly", secure ? "Secure" : "", `SameSite=${SESSION_COOKIE_SAMESITE}`, `Max-Age=${maxAge}`]
function sessionCookieAttributes(maxAge, { secure = false, domain = "" } = {}) {
return ["Path=/", "HttpOnly", secure ? "Secure" : "", sessionCookieDomainAttribute(domain), `SameSite=${SESSION_COOKIE_SAMESITE}`, `Max-Age=${maxAge}`]
.filter(Boolean)
.join("; ");
}
function sessionCookieDomainAttribute(domain) {
const value = normalizeSessionCookieDomain(domain);
return value ? `Domain=${value}` : "";
}
function normalizeSessionCookieDomain(value) {
const domain = String(value ?? "").trim().toLowerCase();
if (!domain) return "";
return /^\.?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/u.test(domain) ? domain : "";
}
const API_KEY_PREFIX = "hwl_live_";
const API_KEY_RANDOM_BYTES = 32;
const API_KEY_DEFAULT_NAME = "Default API key";
@@ -2462,8 +2471,22 @@ function base64UrlToBase64(value) {
}
function sessionCookieFromRequest(request) { const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; }
function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); }
function setSessionCookie(response, token, maxAge, request, env = process.env) { response.setHeader("set-cookie", buildSessionCookie(token, maxAge, { secure: shouldUseSecureSessionCookie(request, env) })); }
function clearSessionCookie(response, request, env = process.env) { response.setHeader("set-cookie", buildClearSessionCookie({ secure: shouldUseSecureSessionCookie(request, env) })); }
function setSessionCookie(response, token, maxAge, request, env = process.env) {
const secure = shouldUseSecureSessionCookie(request, env);
const domain = normalizeSessionCookieDomain(env.HWLAB_SESSION_COOKIE_DOMAIN);
const cookies = domain
? [buildSessionCookie(token, maxAge, { secure, domain }), buildClearSessionCookie({ secure })]
: [buildSessionCookie(token, maxAge, { secure })];
response.setHeader("set-cookie", cookies);
}
function clearSessionCookie(response, request, env = process.env) {
const secure = shouldUseSecureSessionCookie(request, env);
const domain = normalizeSessionCookieDomain(env.HWLAB_SESSION_COOKIE_DOMAIN);
const cookies = domain
? [buildClearSessionCookie({ secure, domain }), buildClearSessionCookie({ secure })]
: [buildClearSessionCookie({ secure })];
response.setHeader("set-cookie", cookies);
}
function shouldUseSecureSessionCookie(request, env = process.env) {
const forced = textOr(env.HWLAB_SESSION_COOKIE_SECURE, "").toLowerCase();
if (["1", "true", "yes", "on", "secure"].includes(forced)) return true;
+127 -1
View File
@@ -18,6 +18,7 @@ const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected
const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed");
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/opencode", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
const OPENCODE_PROXY_PREFIX = "/_opencode";
const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}";
const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu;
@@ -43,7 +44,15 @@ export function createCloudWebServer({
cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 1260000, {
min: 1000,
max: 2400000
})
}),
opencodeUpstreamUrl = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_UPSTREAM_URL") || "",
opencodeProxyHost = opencodeProxyHostFromEnv(),
opencodeProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_OPENCODE_PROXY_TIMEOUT_MS, cloudApiProxyTimeoutMs, {
min: 1000,
max: 2400000
}),
opencodeUsername = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_USERNAME") || "",
opencodePassword = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_PASSWORD") || ""
}) {
return createServer(async (request, response) => {
attachRequestErrorHandlers({ request, response, serviceId });
@@ -53,6 +62,10 @@ export function createCloudWebServer({
sendJson(response, 200, healthPayload());
return;
}
if (isOpencodeProxyRequest(request, url, opencodeProxyHost)) {
await proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, serviceId, sendJson });
return;
}
if (await handleCloudWebAuth({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson })) {
return;
}
@@ -182,6 +195,19 @@ function validateOpencodeUrl(value) {
}
}
function opencodeProxyHostFromEnv() {
const explicit = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_PROXY_HOST");
if (explicit) return normalizeHostName(explicit);
const frameUrl = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL");
if (!frameUrl) return "";
try {
const url = new URL(frameUrl);
return normalizeHostName(url.hostname);
} catch {
return "";
}
}
function validateDisplayTimeZone(timeZone) {
try {
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0));
@@ -538,6 +564,106 @@ export async function proxyCloudApi({ request, response, url, cloudApiBaseUrl, c
}
}
async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, serviceId, sendJson }) {
if (!cloudApiBaseUrl) {
sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname });
return;
}
if (!opencodeUpstreamUrl) {
sendJson(response, 503, { error: "opencode_upstream_url_missing", serviceId, path: url.pathname });
return;
}
const authorization = opencodeBasicAuthorization(opencodeUsername, opencodePassword);
if (!authorization) {
sendJson(response, 503, { error: "opencode_credentials_missing", serviceId, path: url.pathname });
return;
}
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (!session.ok || session.body?.authenticated !== true) {
sendJson(response, session.statusCode === 403 ? 403 : 401, {
authenticated: false,
error: "opencode_auth_required",
serviceId,
path: url.pathname
});
return;
}
let body = "";
if (request.method !== "GET" && request.method !== "HEAD") {
body = await readRequestBody(request);
}
const target = opencodeTargetUrl(url, opencodeUpstreamUrl);
const upstreamRequest = {
method: request.method,
headers: opencodeUpstreamHeaders(request, body, authorization)
};
try {
await proxyCloudApiRequest({
target,
request: upstreamRequest,
response,
body: request.method === "GET" || request.method === "HEAD" ? "" : body,
timeoutMs: opencodeProxyTimeoutMs
});
} catch (error) {
if (response.headersSent || response.writableEnded) {
if (!response.writableEnded) response.destroy(error);
return;
}
sendJson(response, 502, {
status: "failed",
error: "opencode_proxy_unavailable",
serviceId,
path: url.pathname,
reason: UPSTREAM_UNAVAILABLE_MESSAGE
});
}
}
function isOpencodeProxyRequest(request, url, opencodeProxyHost) {
if (url.pathname === OPENCODE_PROXY_PREFIX || url.pathname.startsWith(`${OPENCODE_PROXY_PREFIX}/`)) return true;
const targetHost = normalizeHostName(opencodeProxyHost);
if (!targetHost) return false;
return requestHostName(request) === targetHost;
}
function opencodeTargetUrl(url, opencodeUpstreamUrl) {
const pathname = url.pathname === OPENCODE_PROXY_PREFIX
? "/"
: url.pathname.startsWith(`${OPENCODE_PROXY_PREFIX}/`)
? url.pathname.slice(OPENCODE_PROXY_PREFIX.length) || "/"
: url.pathname;
return new URL(`${pathname}${url.search}`, opencodeUpstreamUrl);
}
function opencodeUpstreamHeaders(request, body, authorization) {
const headers = {
accept: request.headers.accept || "*/*",
authorization
};
for (const name of ["content-type", "prefer", "traceparent", "tracestate", "x-hwlab-otel-trace-id", "x-trace-id", "x-request-id"]) {
if (request.headers[name] !== undefined) headers[name] = request.headers[name];
}
if (body) headers["content-length"] = Buffer.byteLength(body);
return headers;
}
function opencodeBasicAuthorization(username, password) {
if (!username || !password) return "";
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
}
function requestHostName(request) {
return normalizeHostName(String(request.headers.host || "").split(":")[0]);
}
function normalizeHostName(value) {
return String(value || "").trim().toLowerCase().replace(/\.+$/u, "");
}
function contentType(filePath) {
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
@@ -499,6 +499,113 @@ test("cloud web injects trace explorer runtime config from env", async () => {
}
});
test("cloud web OpenCode proxy requires an authenticated HWLAB session", async () => {
let authRequests = 0;
let opencodeRequests = 0;
const cloudApi = createServer((request, response) => {
authRequests += 1;
request.resume();
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ authenticated: false }));
});
const opencode = createServer((request, response) => {
opencodeRequests += 1;
request.resume();
response.writeHead(200, { "content-type": "text/plain" });
response.end("opencode should not be reached");
});
await listen(cloudApi);
await listen(opencode);
const cloudWeb = createCloudWebServer({
serviceId: "hwlab-cloud-web",
roots: [],
cloudApiBaseUrl: serverUrl(cloudApi),
cloudApiProxyTimeoutMs: 1000,
opencodeUpstreamUrl: serverUrl(opencode),
opencodeProxyHost: "127.0.0.1",
opencodeProxyTimeoutMs: 1000,
opencodeUsername: "oc_user",
opencodePassword: "oc_password",
healthPayload: () => ({ status: "ok" }),
sendJson(response, statusCode, body) {
const payload = JSON.stringify(body);
response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
response.end(payload);
}
});
await listen(cloudWeb);
try {
const response = await fetch(`${serverUrl(cloudWeb)}/`, { headers: { accept: "text/html" } });
assert.equal(response.status, 401);
assert.equal((await response.json()).error, "opencode_auth_required");
assert.equal(authRequests, 1);
assert.equal(opencodeRequests, 0);
} finally {
await close(cloudWeb);
await close(opencode);
await close(cloudApi);
}
});
test("cloud web OpenCode proxy injects upstream Basic Auth without forwarding HWLAB cookie", async () => {
const opencodeRequests = [];
const cloudApi = createServer((request, response) => {
request.resume();
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ authenticated: request.headers.cookie === "hwlab_session=session-a" }));
});
const opencode = createServer((request, response) => {
opencodeRequests.push({
url: request.url,
authorization: request.headers.authorization,
cookie: request.headers.cookie
});
request.resume();
response.writeHead(200, { "content-type": "text/javascript" });
response.end("export const ok = true;\n");
});
await listen(cloudApi);
await listen(opencode);
const cloudWeb = createCloudWebServer({
serviceId: "hwlab-cloud-web",
roots: [],
cloudApiBaseUrl: serverUrl(cloudApi),
cloudApiProxyTimeoutMs: 1000,
opencodeUpstreamUrl: serverUrl(opencode),
opencodeProxyHost: "127.0.0.1",
opencodeProxyTimeoutMs: 1000,
opencodeUsername: "oc_user",
opencodePassword: "oc_password",
healthPayload: () => ({ status: "ok" }),
sendJson(response, statusCode, body) {
const payload = JSON.stringify(body);
response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
response.end(payload);
}
});
await listen(cloudWeb);
try {
const response = await fetch(`${serverUrl(cloudWeb)}/assets/index.js`, {
headers: { accept: "text/javascript", cookie: "hwlab_session=session-a", "x-trace-id": "trc_opencode_proxy" }
});
assert.equal(response.status, 200);
assert.equal(await response.text(), "export const ok = true;\n");
assert.deepEqual(opencodeRequests, [{
url: "/assets/index.js",
authorization: `Basic ${Buffer.from("oc_user:oc_password", "utf8").toString("base64")}`,
cookie: undefined
}]);
} finally {
await close(cloudWeb);
await close(opencode);
await close(cloudApi);
}
});
function withEnv(values) {
const previous = new Map();
for (const [key, value] of Object.entries(values)) {