From 9064fc5f1d53651a94e8c19e7795c4058fd443b1 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Fri, 25 Sep 2026 10:05:47 +0800 Subject: [PATCH] fix: hide admin authorization in setup mode --- README.md | 2 +- cloudflare-worker/README.md | 1 + cloudflare-worker/src/backends.js | 1 + cloudflare-worker/src/index.js | 2 + cloudflare-worker/test/backends.test.js | 2 +- cloudflare-worker/test/index.test.js | 24 +++++ docs/deployment.md | 3 +- docs/design.md | 1 + web/config.js | 68 ++++++++++-- web/config.test.mjs | 137 ++++++++++++++++++++++++ web/index.html | 21 +++- web/styles.css | 4 + 12 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 web/config.test.mjs diff --git a/README.md b/README.md index dcbfb95..f8ed9cb 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary `;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。 -> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;状态页会提示缺少的环境变量,不回显任何密钥,也不会伪造 Redis 持久化成功。 +> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;SPA 隐藏管理会话授权区,只显示缺少的环境变量与密钥不保存、不回显说明。配置恢复后 `/api/status` ready=true 才显示授权入口。 --- diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index f09ff94..936de19 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -31,6 +31,7 @@ + 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。 ### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) +- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 - 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。 - 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 - method 不符 → **405**。 diff --git a/cloudflare-worker/src/backends.js b/cloudflare-worker/src/backends.js index 45b5cc7..82b5678 100644 --- a/cloudflare-worker/src/backends.js +++ b/cloudflare-worker/src/backends.js @@ -10,6 +10,7 @@ export const SAFE_ROUTES = Object.freeze([ "/", "/assets/config.js", "/assets/styles.css", + "/api/status", "/api/config", "/api/business-config", "/api/admin/session", diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index 0a52962..c641ad2 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -12,6 +12,7 @@ * * 路由模型: * - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传 + * - GET /api/status → 后端配置引导状态;只返回缺失变量名称 * - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke] * → 透传至后端;后端校验 bootstrap 凭据或短期 admin session * - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG) @@ -40,6 +41,7 @@ const ROUTE_METHODS = Object.freeze({ "/": ["GET"], "/assets/config.js": ["GET"], "/assets/styles.css": ["GET"], + "/api/status": ["GET"], "/api/config": ["GET", "PUT"], "/api/business-config": ["PUT"], "/api/admin/session": ["POST"], diff --git a/cloudflare-worker/test/backends.test.js b/cloudflare-worker/test/backends.test.js index d3a3b85..0eb4044 100644 --- a/cloudflare-worker/test/backends.test.js +++ b/cloudflare-worker/test/backends.test.js @@ -44,7 +44,7 @@ test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => { test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => { for (const r of [ - "/", "/assets/config.js", "/assets/styles.css", "/ready", "/webhook/tg", "/push/jmap", "/reconcile", + "/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile", "/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke", ]) { assert.ok(PROXIED_ROUTES.includes(r), `proxied: ${r}`); diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index 8ed389f..27e450f 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -80,6 +80,30 @@ test("Worker: business config PUT is proxied with the session bearer and empty 2 } }); +test("Worker: public setup status is proxied without an authorization header", async () => { + const restore = stubFetch(async (url, init) => { + assert.match(url, /^https:\/\/(a|b)\.example\/api\/status$/); + assert.equal(init.method, "GET"); + assert.equal(new Headers(init.headers).has("authorization"), false); + return new Response(JSON.stringify({ + ready: false, + mode: "configuration-setup", + missing: ["REDIS_URL"], + }), { status: 200, headers: { "content-type": "application/json" } }); + }); + try { + const res = await handleFetch(new Request("https://lb.example/api/status"), makeEnv()); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + ready: false, + mode: "configuration-setup", + missing: ["REDIS_URL"], + }); + } finally { + restore(); + } +}); + test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () => { const res = await handleFetch( new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }), diff --git a/docs/deployment.md b/docs/deployment.md index 4d6725d..264f49b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -158,6 +158,7 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链 # 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080 GET / 业务配置与运行参数管理 SPA GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式 +GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名 POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session POST /api/admin/session/revoke Bearer admin session;成功返回 204 GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503 @@ -338,7 +339,7 @@ Stalwart PushSub ────┼─▶ https://lb..workers.dev ← 唯 ### 10.4 路由与故障转移 -- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 +- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。 - **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 diff --git a/docs/design.md b/docs/design.md index 1478a15..c6ca22b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -439,6 +439,7 @@ stalwart-bot/ ### 7.3.1 配置管理 API - `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 +- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[] }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。 - `POST /api/admin/session` 接受 `Authorization: Bearer `,返回 `{ "session": "", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。 - 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。 - `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: diff --git a/web/config.js b/web/config.js index caae5ff..314faf0 100644 --- a/web/config.js +++ b/web/config.js @@ -24,7 +24,13 @@ const bootstrapInput = document.querySelector("#bootstrap-input"); const authForm = document.querySelector("#auth-form"); + const intro = document.querySelector("#intro"); const authCard = document.querySelector("#auth-card"); + const setupCard = document.querySelector("#setup-card"); + const setupMessage = document.querySelector("#setup-message"); + const setupMissing = document.querySelector("#setup-missing"); + const statusRetryButton = document.querySelector("#status-retry-button"); + const privacyNote = document.querySelector("#privacy-note"); const configPanel = document.querySelector("#config-panel"); const businessForm = document.querySelector("#business-form"); const businessFields = document.querySelector("#business-fields"); @@ -50,6 +56,7 @@ let expiryTimer = 0; let busyAction = ""; let runtimeLoaded = false; + let setupMode = true; class ApiError extends Error { constructor(status, message) { @@ -59,6 +66,7 @@ } function showNotice(kind, message) { + notice.hidden = false; notice.className = `notice notice-${kind}`; notice.textContent = message; notice.setAttribute("role", kind === "error" ? "alert" : "status"); @@ -122,27 +130,71 @@ } async function showSetupStatus() { + showSetupView("正在检查服务启动配置…"); + statusRetryButton.disabled = true; + statusRetryButton.textContent = "正在检查…"; try { const status = await request("/api/status", "GET", undefined, ""); - if (status && status.ready === false) { - const missing = Array.isArray(status.missing) ? status.missing.join("、") : "启动配置"; - showNotice("error", `服务处于配置引导模式。请在部署环境设置:${missing};密钥不会在页面保存或回显。`); + if (status && status.ready === true) { + setupMode = false; + setupCard.hidden = true; + notice.hidden = false; + intro.hidden = false; + authCard.hidden = false; + configPanel.hidden = true; + privacyNote.hidden = false; + showNotice("info", "使用 Redis 管理凭据创建短期管理会话。"); + return; } + const isSetup = status && status.ready === false && status.mode === "configuration-setup"; + if (isSetup) { + const knownMissing = Array.isArray(status.missing) + ? status.missing.filter((name) => name === "REDIS_URL" || name === "CONFIG_ENCRYPTION_KEY") + : []; + const message = knownMissing.length + ? "服务处于配置引导模式。请在部署环境补齐以下启动变量:" + : "服务处于配置引导模式,但状态接口未返回缺少的启动变量名称。"; + showSetupView(message, knownMissing); + return; + } + showSetupView("无法确认服务是否已完成启动配置。管理授权入口保持隐藏,请重新检查服务状态。"); } catch { - // The regular connection flow reports unavailable services without exposing details. + showSetupView("暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。"); + } finally { + statusRetryButton.disabled = false; + statusRetryButton.textContent = "重新检查"; } } + function showSetupView(message, missing = []) { + setupMode = true; + setupMessage.textContent = message; + setupMissing.textContent = missing.join("、"); + setupMissing.hidden = missing.length === 0; + setupCard.hidden = false; + intro.hidden = true; + notice.hidden = true; + authCard.hidden = true; + configPanel.hidden = true; + privacyNote.hidden = true; + } + function showConfigView() { + setupCard.hidden = true; + intro.hidden = false; + privacyNote.hidden = false; authCard.hidden = true; configPanel.hidden = false; } function showAuthView() { + setupCard.hidden = true; + intro.hidden = false; + privacyNote.hidden = false; configPanel.hidden = true; - authCard.hidden = false; + authCard.hidden = setupMode; bootstrapInput.value = ""; - bootstrapInput.focus(); + if (!setupMode) bootstrapInput.focus(); } function clearSecretFields() { @@ -490,6 +542,8 @@ showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。"); }); + statusRetryButton.addEventListener("click", showSetupStatus); + defaultsButton.addEventListener("click", () => { if (!runtimeLoaded || busyAction) return; fillRuntimeFields(RUNTIME_DEFAULTS); @@ -574,7 +628,7 @@ clearForms(); clearSecretFields(); configPanel.hidden = true; - authCard.hidden = false; + authCard.hidden = setupMode; bootstrapInput.value = ""; setBusy(""); }); diff --git a/web/config.test.mjs b/web/config.test.mjs new file mode 100644 index 0000000..19b5bb8 --- /dev/null +++ b/web/config.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const html = readFileSync(new URL("./index.html", import.meta.url), "utf8"); +const source = readFileSync(new URL("./config.js", import.meta.url), "utf8"); + +class FakeElement { + constructor(hidden = false) { + this.hidden = hidden; + this.disabled = false; + this.required = false; + this.checked = false; + this.value = ""; + this.textContent = ""; + this.listeners = new Map(); + this.attributes = new Map(); + this.classList = { + toggle() {}, + add() {}, + remove() {}, + }; + this.children = new Map(); + } + + addEventListener(type, callback) { + this.listeners.set(type, callback); + } + + setAttribute(name, value) { + this.attributes.set(name, value); + } + + querySelector(selector) { + if (!this.children.has(selector)) this.children.set(selector, new FakeElement()); + return this.children.get(selector); + } + + querySelectorAll() { return []; } + focus() {} + reset() {} + setCustomValidity() {} + reportValidity() { return true; } +} + +async function startPage(statusResponse) { + const elements = new Map(); + for (const match of html.matchAll(/<[^>]*\bid="([^"]+)"[^>]*>/gs)) { + const [, id] = match; + const hidden = /\shidden(?:\s|=|>)/.test(match[0]); + elements.set(id, new FakeElement(hidden)); + } + const document = { + querySelector(selector) { + const match = selector.match(/^#(.+)$/); + if (!match || !elements.has(match[1])) throw new Error(`Missing selector: ${selector}`); + return elements.get(match[1]); + }, + getElementById(id) { + if (!elements.has(id)) throw new Error(`Missing element: ${id}`); + return elements.get(id); + }, + querySelectorAll() { return []; }, + }; + const window = { + addEventListener() {}, + setTimeout() { return 1; }, + clearTimeout() {}, + }; + const fetchCalls = []; + const fetch = async (path, options) => { + fetchCalls.push({ path, options }); + if (path !== "/api/status") throw new Error(`Unexpected request: ${path}`); + if (statusResponse instanceof Error) throw statusResponse; + const httpStatus = statusResponse.httpStatus ?? 200; + return { + ok: httpStatus >= 200 && httpStatus < 300, + status: httpStatus, + text: async () => JSON.stringify(statusResponse.body), + }; + }; + + vm.runInNewContext(source, { document, window, fetch, Headers }); + await new Promise((resolve) => setImmediate(resolve)); + await Promise.resolve(); + return { elements, fetchCalls }; +} + +test("configuration-setup hides all authorization UI and lists only missing variable names", async () => { + const { elements, fetchCalls } = await startPage({ + body: { + ready: false, + mode: "configuration-setup", + missing: ["REDIS_URL", "CONFIG_ENCRYPTION_KEY", "secret-value-must-not-display"], + }, + }); + + assert.equal(elements.get("setup-card").hidden, false); + assert.equal(elements.get("intro").hidden, true); + assert.equal(elements.get("auth-card").hidden, true); + assert.equal(elements.get("config-panel").hidden, true); + assert.equal(elements.get("notice").hidden, true); + assert.equal(elements.get("privacy-note").hidden, true); + assert.equal(elements.get("setup-missing").textContent, "REDIS_URL、CONFIG_ENCRYPTION_KEY"); + assert.equal(elements.get("setup-message").textContent.includes("配置引导模式"), true); + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0].path, "/api/status"); + assert.equal(fetchCalls[0].options.credentials, "omit"); + assert.equal(fetchCalls[0].options.cache, "no-store"); + assert.equal(fetchCalls[0].options.headers.has("authorization"), false); +}); + +test("ready status preserves the existing admin-session entry and configuration page", async () => { + const { elements } = await startPage({ + body: { ready: true, mode: "configured", missing: [] }, + }); + + assert.equal(elements.get("setup-card").hidden, true); + assert.equal(elements.get("intro").hidden, false); + assert.equal(elements.get("auth-card").hidden, false); + assert.equal(elements.get("config-panel").hidden, true); + assert.equal(elements.get("privacy-note").hidden, false); + assert.equal(elements.get("notice").hidden, false); +}); + +test("status errors keep the authorization form hidden until readiness is confirmed", async () => { + const { elements } = await startPage({ + httpStatus: 503, + body: {}, + }); + + assert.equal(elements.get("setup-card").hidden, false); + assert.equal(elements.get("auth-card").hidden, true); + assert.equal(elements.get("config-panel").hidden, true); + assert.match(elements.get("setup-message").textContent, /授权入口保持隐藏/); +}); diff --git a/web/index.html b/web/index.html index 61d0694..830eed0 100644 --- a/web/index.html +++ b/web/index.html @@ -22,17 +22,30 @@
-
+ -
+ -
+
+
+ +

配置引导

服务启动配置

+
+

正在检查服务启动配置…

+ +

启动所需密钥请在部署环境中配置。密钥不会在页面保存或回显。

+ +
+ +
- +
diff --git a/web/styles.css b/web/styles.css index 187a745..59a2262 100644 --- a/web/styles.css +++ b/web/styles.css @@ -60,6 +60,10 @@ h1 { margin-bottom: 12px; font-size: clamp(29px, 5vw, 40px); line-height: 1.16; h2 { margin-bottom: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; } h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; } .panel-copy { max-width: 760px; margin-bottom: 22px; color: var(--muted); font-size: 13px; line-height: 1.7; } +.setup-panel { max-width: 720px; } +.setup-panel .panel-copy { margin-bottom: 14px; } +.setup-missing { margin: 0 0 17px; padding: 12px 14px; border: 1px solid #f2e9c8; border-radius: 8px; background: #fffbef; color: #796132; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } +.setup-privacy { margin: 0 0 17px; color: #858697; font-size: 12px; line-height: 1.65; } code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; } .auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; } .token-row { display: flex; gap: 10px; }