fix: proxy opencode through hwlab auth
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user