feat: gate admin ui with optional access key
This commit is contained in:
+226
-2
@@ -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, "<")
|
||||
.replace(/>/g, ">");
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Codex Retry Gateway</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b1020; color: #eef2ff; }
|
||||
main { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
form { width: min(420px, 100%); background: #121933; border: 1px solid #26304f; padding: 24px; border-radius: 8px; }
|
||||
h1 { margin: 0 0 8px; font-size: 20px; }
|
||||
p { margin: 0 0 16px; color: #aab4d6; line-height: 1.5; }
|
||||
label { display: block; margin-bottom: 8px; font-size: 14px; color: #cbd5f5; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 12px; border-radius: 6px; border: 1px solid #33406a; background: #0f1630; color: #eef2ff; }
|
||||
button { margin-top: 16px; width: 100%; padding: 12px; border: 0; border-radius: 6px; background: #7c9cff; color: #09101f; font-weight: 600; cursor: pointer; }
|
||||
.error { min-height: 20px; margin-top: 12px; color: #fca5a5; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<form method="get" action="${escapedAction}">
|
||||
<h1>Codex Retry Gateway</h1>
|
||||
<p>此页面已启用访问 key。输入后会在当前浏览器保存后台访问状态。</p>
|
||||
<label for="key">Access key</label>
|
||||
<input id="key" name="key" type="password" autofocus autocomplete="current-password">
|
||||
<button type="submit">进入后台</button>
|
||||
<div class="error">${escapedError}</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user