From d90a1cd94ff9fb1309fe095f1e6de792a2ccedef Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Tue, 30 Jun 2026 19:46:03 +0800 Subject: [PATCH] feat: gate admin ui with optional access key --- config.example.json | 1 + gateway.mjs | 228 ++++++++++++++++++++++++++++++++++- scripts/test-gateway-e2e.mjs | 71 ++++++++--- ui-src/src/App.tsx | 22 +++- 4 files changed, 300 insertions(+), 22 deletions(-) diff --git a/config.example.json b/config.example.json index 84aa739..5971451 100644 --- a/config.example.json +++ b/config.example.json @@ -17,6 +17,7 @@ "Selected model is at capacity. Please try a different model.", "stream disconnected before completion: Concurrency limit exceeded for account, please retry later" ], + "management_access_key": "", "non_stream_status_code": 502, "stream_action": "strict_502", "log_match": true, diff --git a/gateway.mjs b/gateway.mjs index 58ed1db..b092915 100644 --- a/gateway.mjs +++ b/gateway.mjs @@ -26,6 +26,7 @@ const PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/switch`; const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`; const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`; const STATUS_REASONING_COUNT_LIMIT = 24; +const MANAGEMENT_ACCESS_COOKIE = "codex_retry_gateway_access"; const DEFAULT_CONFIG = { profile_name: "default", @@ -47,6 +48,7 @@ const DEFAULT_CONFIG = { "Selected model is at capacity. Please try a different model.", "stream disconnected before completion: Concurrency limit exceeded for account, please retry later", ], + management_access_key: "", upstream_fetch_retry_attempts: 5, upstream_fetch_retry_backoff_ms: 350, non_stream_status_code: 502, @@ -216,6 +218,105 @@ function firstNonEmptyString(...values) { return null; } +function timingSafeEquals(left, right) { + const leftText = Buffer.from(`${left || ""}`); + const rightText = Buffer.from(`${right || ""}`); + if (leftText.length !== rightText.length) { + return false; + } + let mismatch = 0; + for (let i = 0; i < leftText.length; i += 1) { + mismatch |= leftText[i] ^ rightText[i]; + } + return mismatch === 0; +} + +function parseCookieHeader(cookieHeader) { + const cookies = {}; + for (const part of `${cookieHeader || ""}`.split(";")) { + const trimmed = part.trim(); + if (!trimmed) { + continue; + } + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex <= 0) { + continue; + } + const key = trimmed.slice(0, separatorIndex).trim(); + const value = trimmed.slice(separatorIndex + 1).trim(); + cookies[key] = value; + } + return cookies; +} + +function normalizeManagementAccessKey(value) { + return `${value || ""}`.trim(); +} + +function currentManagementAccessKey(config) { + return normalizeManagementAccessKey(config?.management_access_key); +} + +function managementAccessEnabled(config) { + return Boolean(currentManagementAccessKey(config)); +} + +function requestManagementAccessKey(req, requestUrl) { + const headerKey = firstNonEmptyString( + req.headers["x-codex-retry-gateway-key"], + req.headers["x-codex-retry-gateway-access-key"], + req.headers.authorization?.startsWith?.("Bearer ") + ? req.headers.authorization.slice("Bearer ".length) + : null, + ); + if (headerKey) { + return headerKey; + } + const queryKey = firstNonEmptyString( + requestUrl?.searchParams?.get("key"), + requestUrl?.searchParams?.get("access_key"), + ); + if (queryKey) { + return queryKey; + } + const cookies = parseCookieHeader(req.headers.cookie || ""); + return firstNonEmptyString(cookies[MANAGEMENT_ACCESS_COOKIE]); +} + +function hasManagementAccess(req, requestUrl, config) { + const expected = currentManagementAccessKey(config); + if (!expected) { + return true; + } + const provided = requestManagementAccessKey(req, requestUrl); + return Boolean(provided) && timingSafeEquals(provided, expected); +} + +function clearManagementAccessCookieHeaders() { + return [ + `${MANAGEMENT_ACCESS_COOKIE}=; Path=${ADMIN_BASE_PATH}; HttpOnly; SameSite=Lax; Max-Age=0`, + ]; +} + +function buildManagementAccessCookieHeaders(config, accessKey) { + const expected = currentManagementAccessKey(config); + if (!expected || !accessKey || !timingSafeEquals(accessKey, expected)) { + return clearManagementAccessCookieHeaders(); + } + return [ + `${MANAGEMENT_ACCESS_COOKIE}=${accessKey}; Path=${ADMIN_BASE_PATH}; HttpOnly; SameSite=Lax`, + ]; +} + +function managementUnauthorizedPayload() { + return { + error: { + message: "management access key required", + code: "management_access_key_required", + }, + }; +} + function extractStringByPointers(payload, pointers) { for (const pointer of pointers) { const raw = jsonPointerGet(payload, pointer); @@ -1043,6 +1144,9 @@ function buildConfigFromProfileEnv(profileName, env) { env.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || DEFAULT_CONFIG.retryable_error_messages, DEFAULT_CONFIG.retryable_error_messages, ), + management_access_key: normalizeManagementAccessKey( + env.CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY || DEFAULT_CONFIG.management_access_key, + ), upstream_fetch_retry_attempts: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS ? normalizePositiveInteger( env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS, @@ -1194,6 +1298,11 @@ async function buildProfileEnvText(payload) { ["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")], ]; + const managementAccessKey = normalizeManagementAccessKey(payload.management_access_key); + if (managementAccessKey) { + envPairs.push(["CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY", managementAccessKey]); + } + const modelRemap = `${payload.model_remap || ""}`.trim(); if (modelRemap) { envPairs.push(["CODEX_RETRY_GATEWAY_MODEL_REMAP", modelRemap]); @@ -1486,6 +1595,7 @@ async function loadConfig(configPath) { config.retryable_error_messages, DEFAULT_CONFIG.retryable_error_messages, ); + config.management_access_key = normalizeManagementAccessKey(config.management_access_key); config.upstream_fetch_retry_attempts = normalizePositiveInteger( config.upstream_fetch_retry_attempts, DEFAULT_CONFIG.upstream_fetch_retry_attempts, @@ -1543,6 +1653,7 @@ function normalizeAuthMode(value) { function sanitizeConfigForStatus(config) { const { + management_access_key, upstream_auth_file, upstream_auth_json_path, upstream_auth_env, @@ -1553,6 +1664,7 @@ function sanitizeConfigForStatus(config) { return { ...rest, + management_access_key_configured: Boolean(management_access_key), upstream_auth_env: upstream_auth_env || null, upstream_auth_file: upstream_auth_file ? "[configured]" : "", upstream_auth_json_path: upstream_auth_json_path ? "[configured]" : "", @@ -2077,6 +2189,72 @@ async function serveManagementUi(req, res, requestPathname) { return serveStaticFile(req, res, path.join(UI_STATIC_ROOT, "index.html")); } +async function serveManagementUiWithOptionalCookie(req, res, requestPathname, cookieHeaders) { + const uiPrefix = `${UI_PATH}/`; + if (requestPathname === UI_PATH || requestPathname === `${UI_PATH}/`) { + try { + const body = await readFile(path.join(UI_STATIC_ROOT, "index.html")); + respondBuffer( + res, + 200, + body, + { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-cache", + ...(cookieHeaders?.length ? { "set-cookie": cookieHeaders } : {}), + }, + req?.headers?.["accept-encoding"] || "", + ); + return true; + } catch { + return false; + } + } + if (!requestPathname.startsWith(uiPrefix)) { + return false; + } + return serveManagementUi(req, res, requestPathname); +} + +function renderManagementAccessPage(requestPathname, errorMessage = "") { + const escapedAction = `${requestPathname || UI_PATH}`.replace(/&/g, "&").replace(/"/g, """); + const escapedError = `${errorMessage || ""}` + .replace(/&/g, "&") + .replace(//g, ">"); + return ` + + + + + Codex Retry Gateway + + + +
+
+

Codex Retry Gateway

+

此页面已启用访问 key。输入后会在当前浏览器保存后台访问状态。

+ + + +
${escapedError}
+
+
+ +`; +} + function buildEditableConfig(currentConfig, payload) { const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals); const nextRetryableStatusCodes = normalizeIntegerList( @@ -2140,6 +2318,10 @@ function buildEditableConfig(currentConfig, payload) { reasoning_equals: nextReasoning, retryable_status_codes: nextRetryableStatusCodes, retryable_error_messages: nextRetryableErrorMessages, + management_access_key: + payload.management_access_key === undefined + ? currentManagementAccessKey(currentConfig) + : normalizeManagementAccessKey(payload.management_access_key), endpoints: nextEndpoints, upstream_fetch_retry_attempts: nextUpstreamFetchRetryAttempts, upstream_fetch_retry_backoff_ms: nextUpstreamFetchRetryBackoffMs, @@ -2150,9 +2332,43 @@ function buildEditableConfig(currentConfig, payload) { async function handleManagementRequest(runtime, req, res, requestUrl) { const pathname = normalizePath(requestUrl.pathname); + const accessEnabled = managementAccessEnabled(runtime.config); + const accessGranted = hasManagementAccess(req, requestUrl, runtime.config); if (pathname === UI_PATH || pathname.startsWith(`${UI_PATH}/`)) { - if (!(await serveManagementUi(req, res, requestUrl.pathname))) { + if (accessEnabled && !accessGranted) { + if ( + pathname === UI_PATH || + pathname === `${UI_PATH}/` || + pathname === `${UI_PATH}/index.html` + ) { + const body = Buffer.from( + renderManagementAccessPage(pathname, requestUrl.searchParams.get("key") ? "access key 不正确" : ""), + "utf8", + ); + return respondBuffer( + res, + 401, + body, + { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "set-cookie": clearManagementAccessCookieHeaders(), + }, + req?.headers?.["accept-encoding"] || "", + ); + } + jsonResponse(req, res, 401, managementUnauthorizedPayload(), { + "cache-control": "no-store", + "set-cookie": clearManagementAccessCookieHeaders(), + }); + return true; + } + + const cookieHeaders = accessEnabled + ? buildManagementAccessCookieHeaders(runtime.config, requestManagementAccessKey(req, requestUrl)) + : []; + if (!(await serveManagementUiWithOptionalCookie(req, res, requestUrl.pathname, cookieHeaders))) { jsonResponse(req, res, 503, { error: { message: "UI assets were not built. Run: npm run build:ui", @@ -2163,6 +2379,14 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { return true; } + if (accessEnabled && !accessGranted && pathname.startsWith(`${ADMIN_BASE_PATH}/`)) { + jsonResponse(req, res, 401, managementUnauthorizedPayload(), { + "cache-control": "no-store", + "set-cookie": clearManagementAccessCookieHeaders(), + }); + return true; + } + if (pathname === STATUS_API_PATH && req.method === "GET") { const state = await readRuntimeState(runtime); jsonResponse(req, res, 200, { @@ -2179,7 +2403,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { profiles_dir: runtime.paths.profilesDir, }, metrics: buildMetricsSnapshot(runtime.monitor), - }); + }, accessEnabled && accessGranted ? { "set-cookie": buildManagementAccessCookieHeaders(runtime.config, requestManagementAccessKey(req, requestUrl)) } : {}); return true; } diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs index a84136b..ac4b57f 100644 --- a/scripts/test-gateway-e2e.mjs +++ b/scripts/test-gateway-e2e.mjs @@ -11,11 +11,15 @@ import path from "node:path"; const gatewayRoot = path.resolve(import.meta.dirname, ".."); const gatewayEntry = path.join(gatewayRoot, "gateway.mjs"); -function assert(condition, message) { - if (!condition) { - throw new Error(message); - } -} +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const adminHeaders = { + "x-codex-retry-gateway-key": "test-admin-key", +}; async function getFreePort() { const server = net.createServer(); @@ -388,6 +392,7 @@ async function run() { "Selected model is at capacity. Please try a different model.", "stream disconnected before completion: Concurrency limit exceeded for account, please retry later", ], + management_access_key: "test-admin-key", upstream_fetch_retry_attempts: 5, upstream_fetch_retry_backoff_ms: 25, non_stream_status_code: 502, @@ -410,8 +415,35 @@ async function run() { `${error?.message || error}\nstdout:\n${output.stdout || "(empty)"}\nstderr:\n${output.stderr || "(empty)"}`, ); } - - const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`); + + const lockedUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui`); + assert(lockedUiResponse.status === 401, `未带 key 的 UI 不应可访问: ${lockedUiResponse.status}`); + const lockedUiText = await lockedUiResponse.text(); + assert(lockedUiText.includes("Access key"), "未带 key 的 UI 未返回 access key 页面"); + + const lockedStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`); + assert(lockedStatusResponse.status === 401, `未带 key 的 status API 不应可访问: ${lockedStatusResponse.status}`); + + const unlockedUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui?key=test-admin-key`, { + redirect: "manual", + }); + assert(unlockedUiResponse.status === 200, `带 key 的 UI 访问失败: ${unlockedUiResponse.status}`); + assert( + (unlockedUiResponse.headers.get("set-cookie") || "").includes("codex_retry_gateway_access=test-admin-key"), + "带 key 的 UI 未设置 access cookie", + ); + + const unlockedStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { + headers: { "x-codex-retry-gateway-key": "test-admin-key" }, + }); + assert(unlockedStatusResponse.status === 200, `带 key 的 status API 访问失败: ${unlockedStatusResponse.status}`); + const unlockedStatusPayload = await unlockedStatusResponse.json(); + assert( + unlockedStatusPayload?.config?.management_access_key_configured === true, + "status API 未暴露 management_access_key_configured", + ); + + const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`); assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`); assert( modelsResponse.headers.get("x-upstream-test") === "models-ok", @@ -455,7 +487,7 @@ async function run() { assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`); assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求"); - const requestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`); + const requestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders }); const requestsPayload = await requestsResponse.json(); const recoveredEntry = requestsPayload?.entries?.find((entry) => entry.path === "/responses" && entry.status_code === 200); assert(requestsResponse.status === 200, `请求历史 API 状态异常: ${requestsResponse.status}`); @@ -475,7 +507,7 @@ async function run() { assert(threadTrackedBody?.id === "resp_test", "thread non-stream 返回体缺少 response id"); assert(threadTrackedBody?.thread_id === "thread_nonstream", "thread non-stream 返回体缺少 thread_id"); - const threadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_nonstream")}`); + const threadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_nonstream")}`, { headers: adminHeaders }); const threadRequestsPayload = await threadRequestsResponse.json(); const threadEntry = (threadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_nonstream"); assert(threadEntry?.response_id === "resp_test", "non-stream 请求记录未保留 response_id"); @@ -515,7 +547,7 @@ async function run() { assert(differentPathResponse.status === 200, `不同路径发送失败: ${differentPathResponse.status}`); await differentPathResponse.json(); - const requestIdRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=40`); + const requestIdRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=40`, { headers: adminHeaders }); const requestIdRequestsPayload = await requestIdRequestsResponse.json(); const sameRequestEntries = (requestIdRequestsPayload?.entries || []).filter( (entry) => entry.path === "/responses" && entry.request_body_bytes === Buffer.byteLength(sameRequestPayload), @@ -543,6 +575,7 @@ async function run() { const requestIdQueryResponse = await fetch( `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(sameRequestEntryA.request_id)}`, + { headers: adminHeaders }, ); const requestIdQueryPayload = await requestIdQueryResponse.json(); assert(requestIdQueryResponse.status === 200, `request_id 搜索失败: ${requestIdQueryResponse.status}`); @@ -595,7 +628,7 @@ async function run() { "capacity 抖动恢复后的返回体异常", ); - const requestsAfterCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`); + const requestsAfterCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders }); const requestsAfterCapacityRecovery = await requestsAfterCapacityRecoveryResponse.json(); const capacityRecoveredEntry = requestsAfterCapacityRecovery?.entries?.find( (entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3, @@ -615,7 +648,7 @@ async function run() { "200+capacity 抖动恢复后的返回体异常", ); - const requestsAfterCapacityStatus200RecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`); + const requestsAfterCapacityStatus200RecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`, { headers: adminHeaders }); const requestsAfterCapacityStatus200Recovery = await requestsAfterCapacityStatus200RecoveryResponse.json(); const capacityStatus200RecoveredEntry = requestsAfterCapacityStatus200Recovery?.entries?.find( (entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3, @@ -660,7 +693,7 @@ async function run() { assert(streamCapacityRecoveredResponse.status === 200, `stream capacity 抖动后未自动恢复: ${streamCapacityRecoveredResponse.status}`); assert(streamCapacityRecoveredText.includes("hello"), "stream capacity 恢复后未拿到正常 SSE 内容"); - const requestsAfterStreamCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`); + const requestsAfterStreamCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders }); const requestsAfterStreamCapacityRecovery = await requestsAfterStreamCapacityRecoveryResponse.json(); const streamCapacityRecoveredEntry = requestsAfterStreamCapacityRecovery?.entries?.find( (entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3, @@ -682,7 +715,7 @@ async function run() { assert(streamCapacityResponseFailedRecoveredResponse.status === 200, `stream response.failed capacity 抖动后未自动恢复: ${streamCapacityResponseFailedRecoveredResponse.status}`); assert(streamCapacityResponseFailedRecoveredText.includes("hello"), "stream response.failed capacity 恢复后未拿到正常 SSE 内容"); - const requestsAfterStreamCapacityResponseFailedRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`); + const requestsAfterStreamCapacityResponseFailedRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`, { headers: adminHeaders }); const requestsAfterStreamCapacityResponseFailedRecovery = await requestsAfterStreamCapacityResponseFailedRecoveryResponse.json(); const streamCapacityResponseFailedRecoveredEntry = requestsAfterStreamCapacityResponseFailedRecovery?.entries?.find( (entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3, @@ -694,7 +727,7 @@ async function run() { { stream: true, test_reasoning_tokens: 128, thread_id: "thread_stream_ok" }, ); assert(streamThreadResponse.status === 200, `stream thread 请求失败: ${streamThreadResponse.status}`); - const streamThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_stream_ok")}`); + const streamThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_stream_ok")}`, { headers: adminHeaders }); const streamThreadRequestsPayload = await streamThreadRequestsResponse.json(); const streamThreadEntry = (streamThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_stream_ok"); assert(streamThreadEntry?.response_id === "resp_stream", "stream 请求记录未保留 response_id"); @@ -717,7 +750,7 @@ async function run() { }); assert(metadataThreadResponse.status === 200, `metadata thread 请求失败: ${metadataThreadResponse.status}`); await metadataThreadResponse.json(); - const metadataThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_client_metadata")}`); + const metadataThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_client_metadata")}`, { headers: adminHeaders }); const metadataThreadRequestsPayload = await metadataThreadRequestsResponse.json(); const metadataThreadEntry = (metadataThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_client_metadata"); assert(metadataThreadEntry?.thread_id === "thread_client_metadata", "client_metadata.thread_id 未写入请求记录"); @@ -770,7 +803,7 @@ async function run() { body: JSON.stringify({ stream: true, test_reasoning_tokens: 128, test_stream_chunk_delay_ms: 180 }), }); await new Promise((resolve) => setTimeout(resolve, 260)); - const midRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`); + const midRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders }); const midRequestsPayload = await midRequestsResponse.json(); const inFlightStreamEntry = midRequestsPayload?.entries?.find( (entry) => @@ -800,7 +833,7 @@ async function run() { ); assert(terminatedStream.status === 502, `/responses 上游半路断流未返回 502: ${terminatedStream.status}`); - const metricsBeforeRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`); + const metricsBeforeRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders }); const metricsBeforeRestart = await metricsBeforeRestartResponse.json(); assert(metricsBeforeRestartResponse.status === 200, `status API 状态异常: ${metricsBeforeRestartResponse.status}`); assert(metricsBeforeRestart?.metrics?.reasoning_516_count >= 1, "重启前 reasoning_516_count 未累计"); @@ -812,7 +845,7 @@ async function run() { gateway = startGateway(configPath, logPath); await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`); - const metricsAfterRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`); + const metricsAfterRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders }); const metricsAfterRestart = await metricsAfterRestartResponse.json(); assert(metricsAfterRestartResponse.status === 200, `重启后 status API 状态异常: ${metricsAfterRestartResponse.status}`); assert(metricsAfterRestart?.metrics?.reasoning_516_count >= metricsBeforeRestart?.metrics?.reasoning_516_count, "重启后 reasoning_516_count 未保留"); diff --git a/ui-src/src/App.tsx b/ui-src/src/App.tsx index 7fc50bf..6e322be 100644 --- a/ui-src/src/App.tsx +++ b/ui-src/src/App.tsx @@ -7,6 +7,8 @@ type GatewayConfig = { profile_name?: string; listen_host?: string; listen_port?: number; + management_access_key?: string; + management_access_key_configured?: boolean; upstream_base_url?: string; upstream_auth_mode?: string; upstream_auth_env?: string | null; @@ -227,6 +229,8 @@ const api = { restore: "/__codex_retry_gateway/api/restore", }; +const ACCESS_KEY_STORAGE_KEY = "codex_retry_gateway_access_key"; + const REQUEST_PAGE_SIZE = 20; const LOG_PAGE_SIZE = 200; @@ -403,7 +407,12 @@ function splitLines(value: string) { } async function fetchJson(url: string, options?: RequestInit): Promise { - const response = await fetch(url, { cache: "no-store", ...options }); + const headers = new Headers(options?.headers || {}); + const accessKey = window.localStorage.getItem(ACCESS_KEY_STORAGE_KEY)?.trim(); + if (accessKey && !headers.has("x-codex-retry-gateway-key")) { + headers.set("x-codex-retry-gateway-key", accessKey); + } + const response = await fetch(url, { cache: "no-store", ...options, headers }); const payload = await response.json(); if (!response.ok) { throw new Error(payload?.error?.message || "请求失败"); @@ -499,6 +508,17 @@ function ruleFormFromStatus(status: StatusPayload | null): RuleFormState { } export default function App() { + useEffect(() => { + const url = new URL(window.location.href); + const accessKey = url.searchParams.get("key")?.trim(); + if (!accessKey) { + return; + } + window.localStorage.setItem(ACCESS_KEY_STORAGE_KEY, accessKey); + url.searchParams.delete("key"); + window.history.replaceState({}, "", url.toString()); + }, []); + const [page, setPage] = useState(() => { const hash = window.location.hash.replace("#", "") as PageKey; return pageCopy[hash] ? hash : "overview";