From 4f5eb9293de264837fb8a34e0173b01d4a2fc823 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Sun, 27 Sep 2026 12:52:55 +0800 Subject: [PATCH] chore(worker): rename proxy to lb and ignore build cache - cloudflare-worker: unify terminology proxy -> lb/forward (PROXIED_ROUTES -> LB_ROUTES, proxyWithFailover -> forwardWithFailover, proxy.js -> lb.js, related tests and README); renames/wording only, no behavior change - Dockerfile: crates.io index mirror rsproxy.cn -> mirrors.ustc.edu.cn - .gitignore: ignore build verification cache .gate-cache/ (cargo registry + rustup, ~1.5G) --- .gitignore | 3 +++ Dockerfile | 2 +- cloudflare-worker/README.md | 6 +++++- cloudflare-worker/src/backends.js | 4 ++-- cloudflare-worker/src/index.js | 10 +++++----- cloudflare-worker/src/{proxy.js => lb.js} | 6 +++--- cloudflare-worker/test/backends.test.js | 6 +++--- cloudflare-worker/test/index.test.js | 10 +++++----- .../test/{proxy.test.js => lb.test.js} | 20 +++++++++---------- 9 files changed, 37 insertions(+), 30 deletions(-) rename cloudflare-worker/src/{proxy.js => lb.js} (96%) rename cloudflare-worker/test/{proxy.test.js => lb.test.js} (86%) diff --git a/.gitignore b/.gitignore index 5b899e7..9586d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ /target/ /Cargo.lock +# 注意:Cargo.lock 实际已被跟踪(对二进制应用应提交以保证 --locked 可复现);此行为历史遗留,勿据它移除跟踪 .env .env.* !.env.example *.pem *.key *.p12 +# 构建验证缓存(cargo registry + rustup 组件,1.5G,绝不能提交) +.gate-cache/ diff --git a/Dockerfile b/Dockerfile index ea718a7..7c6241a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # C-DEBIAN-SLIM: Debian builder and runtime; no Alpine/musl dependencies. FROM rust:1-slim-bookworm AS builder WORKDIR /build -ENV CARGO_REGISTRIES_CRATES_IO_INDEX=sparse+https://rsproxy.cn/index/ +ENV CARGO_REGISTRIES_CRATES_IO_INDEX=sparse+https://mirrors.ustc.edu.cn/crates.io-index/ COPY Cargo.toml Cargo.lock ./ COPY src ./src COPY web ./web diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 623f853..3d0fe77 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -3,6 +3,10 @@ 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 +> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 12 条白名单、 +> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 +> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 + > 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。 > 安全基线:`AGENTS.md §2/§4`。 @@ -11,7 +15,7 @@ - `wrangler.toml` — Wrangler 示例配置(name/main/compat/vars;后端 secret 走 `wrangler secret`) - `src/index.js` — Worker 入口(路由分发、/healthz 聚合、safelist 校验) - `src/backends.js` — origin 解析 + https/凭据/query 校验(C-HTTPS-INBOUND) -- `src/proxy.js` — 透传转发 + 有界故障转移(SAF-LB-PASSTHRU) +- `src/lb.js` — 透传转发 + 有界故障转移(SAF-LB-PASSTHRU) - `src/health.js` — 健康聚合探针(MOD-HEALTH-AGG / C-NO-DB) - `test/*.test.js` — 单元/集成测试(零依赖,`node --test`) - `package.json` — npm 元数据(`dev`/`deploy` 调 wrangler;`test` 纯 node) diff --git a/cloudflare-worker/src/backends.js b/cloudflare-worker/src/backends.js index 82b5678..acb6db7 100644 --- a/cloudflare-worker/src/backends.js +++ b/cloudflare-worker/src/backends.js @@ -1,6 +1,6 @@ /** * origin 配置解析 + 校验(稳定 ID:C-HTTPS-INBOUND / SAF-LB-PASSTHRU / C-NO-DB)。 - * 后端 origin 仅允许 https(C-HTTPS-INBOUND);不代理 Redis/JMAP(C-NO-DB)。 + * 后端 origin 仅允许 https(C-HTTPS-INBOUND);不转发 Redis/JMAP 流量(C-NO-DB)。 * 仅 ES2022,零依赖,可 node 直接执行。 */ @@ -22,7 +22,7 @@ export const SAFE_ROUTES = Object.freeze([ ]); /** 需要透传到后端 origin 的路由(不含 Worker 聚合端点)。 */ -export const PROXIED_ROUTES = Object.freeze([...SAFE_ROUTES]); +export const LB_ROUTES = Object.freeze([...SAFE_ROUTES]); export class BackendConfigError extends Error { constructor(message) { diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index db44850..9308c61 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -22,8 +22,8 @@ * 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。 */ -import { parseBackendOrigins, PROXIED_ROUTES } from "./backends.js"; -import { proxyWithFailover } from "./proxy.js"; +import { parseBackendOrigins, LB_ROUTES } from "./backends.js"; +import { forwardWithFailover } from "./lb.js"; import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health.js"; const DEFAULT_TIMEOUT_MS = 10_000; @@ -67,8 +67,8 @@ export async function handleFetch(request, env) { } // safelist 路由校验:未知路径 404,不透传至后端(C-LB-SINGLE-REG-URL)。 - if (!PROXIED_ROUTES.includes(path)) { - return text(`route not proxied: ${path}`, 404); + if (!LB_ROUTES.includes(path)) { + return text(`route not forwarded: ${path}`, 404); } const allowed = ROUTE_METHODS[path]; if (!allowed || !allowed.includes(request.method)) { @@ -89,7 +89,7 @@ export async function handleFetch(request, env) { const maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS); // 透传转发 + 有界故障转移(仅超时/5xx)。 - return proxyWithFailover(origins, request, { + return forwardWithFailover(origins, request, { maxAttempts, timeoutMs, fetch: lazyFetch(), diff --git a/cloudflare-worker/src/proxy.js b/cloudflare-worker/src/lb.js similarity index 96% rename from cloudflare-worker/src/proxy.js rename to cloudflare-worker/src/lb.js index 63b87c8..a41ea3d 100644 --- a/cloudflare-worker/src/proxy.js +++ b/cloudflare-worker/src/lb.js @@ -75,7 +75,7 @@ function isRetryableNetworkError(err) { * @param {{headers: Headers, body: Uint8Array | undefined}} snapshot 由 snapshotBody 产出 * @returns {Promise} */ -export async function proxyRequest(origin, request, io, snapshot) { +export async function forwardRequest(origin, request, io, snapshot) { const { fetch, setTimeout: st, clearTimeout: ct, timeoutMs } = io; const url = joinUrl(origin, request); const controller = new AbortController(); @@ -102,7 +102,7 @@ export async function proxyRequest(origin, request, io, snapshot) { * @param {object} opts { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng?, log? } * @returns {Promise} */ -export async function proxyWithFailover(origins, request, opts) { +export async function forwardWithFailover(origins, request, opts) { const { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng, log } = opts; const start = origins.length > 1 && rng ? Math.floor(rng() * origins.length) : 0; const queue = origins.slice(start).concat(origins.slice(0, start)); @@ -115,7 +115,7 @@ export async function proxyWithFailover(origins, request, opts) { const origin = queue[i]; const io = { fetch, setTimeout, clearTimeout, timeoutMs }; try { - const resp = await proxyRequest(origin, request, io, snapshot); + const resp = await forwardRequest(origin, request, io, snapshot); // 非 5xx / 非网络错误:直接返回(4xx、2xx、3xx 均原样透传,不故障转移)。 if (!isRetryableError(null, resp.status)) { return mark(resp, origin); diff --git a/cloudflare-worker/test/backends.test.js b/cloudflare-worker/test/backends.test.js index 0eb4044..00fa866 100644 --- a/cloudflare-worker/test/backends.test.js +++ b/cloudflare-worker/test/backends.test.js @@ -4,7 +4,7 @@ */ import test from "node:test"; import assert from "node:assert/strict"; -import { parseBackendOrigin, parseBackendOrigins, SAFE_ROUTES, PROXIED_ROUTES } from "../src/backends.js"; +import { parseBackendOrigin, parseBackendOrigins, SAFE_ROUTES, LB_ROUTES } from "../src/backends.js"; test("http origin is rejected (https-only, fail-closed)", () => { assert.throws(() => parseBackendOrigin("http://bot.example"), /https/); @@ -47,10 +47,10 @@ test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", "/", "/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}`); + assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`); } for (const r of ["/healthz", "/admin", "/debug", "/jmap/session", "/ready/extra"]) { - assert.ok(!PROXIED_ROUTES.includes(r), `must be blocked: ${r}`); + assert.ok(!LB_ROUTES.includes(r), `must be blocked: ${r}`); assert.ok(!SAFE_ROUTES.includes(r), `must not be a safe route: ${r}`); } }); diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index 27e450f..1870bf4 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -25,7 +25,7 @@ function stubFetch(impl) { }; } -test("Worker GET /healthz aggregates backend probes without touching proxied routes", async () => { +test("Worker GET /healthz aggregates backend probes without touching forwarded routes", async () => { const restore = stubFetch(async (url) => new Response("ok", { status: 200 })); try { const res = await handleFetch(new Request("https://lb.example/healthz", { method: "GET" }), makeEnv()); @@ -46,7 +46,7 @@ test("Worker: unknown route => 404 (C-LB-SINGLE-REG-URL)", async () => { makeEnv(), ); assert.equal(res.status, 404); - assert.match(await res.text(), /route not proxied/); + assert.match(await res.text(), /route not forwarded/); }); test("Worker: wrong method on safelist route => 405", async () => { @@ -57,7 +57,7 @@ test("Worker: wrong method on safelist route => 405", async () => { assert.equal(res.status, 405); }); -test("Worker: business config PUT is proxied with the session bearer and empty 204 response", async () => { +test("Worker: business config PUT is forwarded with the session bearer and empty 204 response", async () => { const restore = stubFetch(async (url, init) => { assert.match(url, /^https:\/\/(a|b)\.example\/api\/business-config$/); assert.equal(init.method, "PUT"); @@ -80,7 +80,7 @@ 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 () => { +test("Worker: public setup status is forwarded 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"); @@ -113,7 +113,7 @@ test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () assert.equal(await res.text(), "misconfigured backends"); }); -test("Worker: valid env, GET /ready proxied to backend with auth header intact", async () => { +test("Worker: valid env, GET /ready forwarded to backend with auth header intact", async () => { const restore = stubFetch(async (url, init) => { assert.match(url, /^https:\/\/(a|b)\.example\/ready$/); return new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); diff --git a/cloudflare-worker/test/proxy.test.js b/cloudflare-worker/test/lb.test.js similarity index 86% rename from cloudflare-worker/test/proxy.test.js rename to cloudflare-worker/test/lb.test.js index 988e508..237d4e3 100644 --- a/cloudflare-worker/test/proxy.test.js +++ b/cloudflare-worker/test/lb.test.js @@ -4,7 +4,7 @@ */ import test from "node:test"; import assert from "node:assert/strict"; -import { proxyWithFailover, joinUrl, isRetryableError } from "../src/proxy.js"; +import { forwardWithFailover, joinUrl, isRetryableError } from "../src/lb.js"; /** * 可控 fetch mock:steps[i] 每次调用消费一个; @@ -50,9 +50,9 @@ test("isRetryableError: only 5xx (status) and timeout/network (err) are retryabl assert.equal(isRetryableError(null, 200), false, "2xx not retryable"); }); -test("transparent proxy forwards headers + body + method (SAF-LB-PASSTHRU)", async () => { +test("transparent passthrough forwards headers + body + method (SAF-LB-PASSTHRU)", async () => { const { fetch, calls } = mockFetch([{ status: 202, body: "accepted" }]); - const res = await proxyWithFailover(["https://a.example"], req("/webhook/tg"), { + const res = await forwardWithFailover(["https://a.example"], req("/webhook/tg"), { ...noOpTimers, fetch, maxAttempts: 2, @@ -73,7 +73,7 @@ test("transparent proxy forwards headers + body + method (SAF-LB-PASSTHRU)", asy test("GET/HEAD do not forward a body", async () => { const { fetch, calls } = mockFetch([{ status: 200 }]); const get = new Request("https://lb.example/ready", { method: "GET", headers: { authorization: "Bearer x" } }); - await proxyWithFailover(["https://a.example"], get, { + await forwardWithFailover(["https://a.example"], get, { ...noOpTimers, fetch, maxAttempts: 1, @@ -85,7 +85,7 @@ test("GET/HEAD do not forward a body", async () => { test("4xx is returned as-is WITHOUT retry and without failing over", async () => { const { fetch, calls } = mockFetch([{ status: 401, body: "unauthorized" }, { status: 200 }]); - const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { + const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { ...noOpTimers, fetch, maxAttempts: 2, @@ -99,7 +99,7 @@ test("4xx is returned as-is WITHOUT retry and without failing over", async () => test("5xx triggers failover to next origin, then returns success", async () => { const { fetch, calls } = mockFetch([{ status: 503, body: "busy" }, { status: 200, body: "ok" }]); - const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { + const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { ...noOpTimers, fetch, maxAttempts: 2, @@ -115,7 +115,7 @@ test("5xx triggers failover to next origin, then returns success", async () => { test("timeout(AbortError) triggers failover and recovery", async () => { const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 200, body: "recovered" }]); - const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/reconcile"), { + const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/reconcile"), { ...noOpTimers, fetch, maxAttempts: 2, @@ -128,7 +128,7 @@ test("timeout(AbortError) triggers failover and recovery", async () => { test("all backends 5xx/timeout => aggregated 503 (Telegram redelivery fallback)", async () => { const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 503, body: "x" }]); - const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { + const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { ...noOpTimers, fetch, maxAttempts: 2, @@ -142,7 +142,7 @@ test("all backends 5xx/timeout => aggregated 503 (Telegram redelivery fallback)" test("bounded attempts: maxAttempts caps the number of origin tries", async () => { const { fetch, calls } = mockFetch([{ error: abortError() }]); // 每次都超时(可故障转移) - const res = await proxyWithFailover(["https://a", "https://b", "https://c"], req("/reconcile"), { + const res = await forwardWithFailover(["https://a", "https://b", "https://c"], req("/reconcile"), { ...noOpTimers, fetch, maxAttempts: 2, @@ -155,7 +155,7 @@ test("bounded attempts: maxAttempts caps the number of origin tries", async () = test("non-retryable (non-network) error fails fast without burning other origins", async () => { const { fetch, calls } = mockFetch([{ error: new Error("invalid inner state") }, { status: 200 }]); - const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { + const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { ...noOpTimers, fetch, maxAttempts: 2,