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)
This commit is contained in:
anonsaber 2026-09-27 12:52:55 +08:00
parent 956ef6a1d3
commit 4f5eb9293d
9 changed files with 37 additions and 30 deletions

3
.gitignore vendored
View File

@ -1,8 +1,11 @@
/target/ /target/
/Cargo.lock /Cargo.lock
# 注意:Cargo.lock 实际已被跟踪(对二进制应用应提交以保证 --locked 可复现);此行为历史遗留,勿据它移除跟踪
.env .env
.env.* .env.*
!.env.example !.env.example
*.pem *.pem
*.key *.key
*.p12 *.p12
# 构建验证缓存(cargo registry + rustup 组件,1.5G,绝不能提交)
.gate-cache/

View File

@ -1,7 +1,7 @@
# C-DEBIAN-SLIM: Debian builder and runtime; no Alpine/musl dependencies. # C-DEBIAN-SLIM: Debian builder and runtime; no Alpine/musl dependencies.
FROM rust:1-slim-bookworm AS builder FROM rust:1-slim-bookworm AS builder
WORKDIR /build 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 Cargo.toml Cargo.lock ./
COPY src ./src COPY src ./src
COPY web ./web COPY web ./web

View File

@ -3,6 +3,10 @@
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 12 条白名单、
> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与
> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。
> 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。 > 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。
> 安全基线:`AGENTS.md §2/§4`。 > 安全基线:`AGENTS.md §2/§4`。
@ -11,7 +15,7 @@
- `wrangler.toml` — Wrangler 示例配置(name/main/compat/vars;后端 secret 走 `wrangler secret`) - `wrangler.toml` — Wrangler 示例配置(name/main/compat/vars;后端 secret 走 `wrangler secret`)
- `src/index.js` — Worker 入口(路由分发、/healthz 聚合、safelist 校验) - `src/index.js` — Worker 入口(路由分发、/healthz 聚合、safelist 校验)
- `src/backends.js` — origin 解析 + https/凭据/query 校验(C-HTTPS-INBOUND) - `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) - `src/health.js` — 健康聚合探针(MOD-HEALTH-AGG / C-NO-DB)
- `test/*.test.js` — 单元/集成测试(零依赖,`node --test`) - `test/*.test.js` — 单元/集成测试(零依赖,`node --test`)
- `package.json` — npm 元数据(`dev`/`deploy` 调 wrangler;`test` 纯 node) - `package.json` — npm 元数据(`dev`/`deploy` 调 wrangler;`test` 纯 node)

View File

@ -1,6 +1,6 @@
/** /**
* origin 配置解析 + 校验(稳定 ID:C-HTTPS-INBOUND / SAF-LB-PASSTHRU / C-NO-DB)。 * 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 直接执行。 * 仅 ES2022,零依赖,可 node 直接执行。
*/ */
@ -22,7 +22,7 @@ export const SAFE_ROUTES = Object.freeze([
]); ]);
/** 需要透传到后端 origin 的路由(不含 Worker 聚合端点)。 */ /** 需要透传到后端 origin 的路由(不含 Worker 聚合端点)。 */
export const PROXIED_ROUTES = Object.freeze([...SAFE_ROUTES]); export const LB_ROUTES = Object.freeze([...SAFE_ROUTES]);
export class BackendConfigError extends Error { export class BackendConfigError extends Error {
constructor(message) { constructor(message) {

View File

@ -22,8 +22,8 @@
* 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。 * 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。
*/ */
import { parseBackendOrigins, PROXIED_ROUTES } from "./backends.js"; import { parseBackendOrigins, LB_ROUTES } from "./backends.js";
import { proxyWithFailover } from "./proxy.js"; import { forwardWithFailover } from "./lb.js";
import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health.js"; import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health.js";
const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_TIMEOUT_MS = 10_000;
@ -67,8 +67,8 @@ export async function handleFetch(request, env) {
} }
// safelist 路由校验:未知路径 404,不透传至后端(C-LB-SINGLE-REG-URL)。 // safelist 路由校验:未知路径 404,不透传至后端(C-LB-SINGLE-REG-URL)。
if (!PROXIED_ROUTES.includes(path)) { if (!LB_ROUTES.includes(path)) {
return text(`route not proxied: ${path}`, 404); return text(`route not forwarded: ${path}`, 404);
} }
const allowed = ROUTE_METHODS[path]; const allowed = ROUTE_METHODS[path];
if (!allowed || !allowed.includes(request.method)) { 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); const maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS);
// 透传转发 + 有界故障转移(仅超时/5xx)。 // 透传转发 + 有界故障转移(仅超时/5xx)。
return proxyWithFailover(origins, request, { return forwardWithFailover(origins, request, {
maxAttempts, maxAttempts,
timeoutMs, timeoutMs,
fetch: lazyFetch(), fetch: lazyFetch(),

View File

@ -75,7 +75,7 @@ function isRetryableNetworkError(err) {
* @param {{headers: Headers, body: Uint8Array | undefined}} snapshot 由 snapshotBody 产出 * @param {{headers: Headers, body: Uint8Array | undefined}} snapshot 由 snapshotBody 产出
* @returns {Promise<Res>} * @returns {Promise<Res>}
*/ */
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 { fetch, setTimeout: st, clearTimeout: ct, timeoutMs } = io;
const url = joinUrl(origin, request); const url = joinUrl(origin, request);
const controller = new AbortController(); 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? } * @param {object} opts { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng?, log? }
* @returns {Promise<Res>} * @returns {Promise<Res>}
*/ */
export async function proxyWithFailover(origins, request, opts) { export async function forwardWithFailover(origins, request, opts) {
const { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng, log } = opts; const { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng, log } = opts;
const start = origins.length > 1 && rng ? Math.floor(rng() * origins.length) : 0; const start = origins.length > 1 && rng ? Math.floor(rng() * origins.length) : 0;
const queue = origins.slice(start).concat(origins.slice(0, start)); 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 origin = queue[i];
const io = { fetch, setTimeout, clearTimeout, timeoutMs }; const io = { fetch, setTimeout, clearTimeout, timeoutMs };
try { try {
const resp = await proxyRequest(origin, request, io, snapshot); const resp = await forwardRequest(origin, request, io, snapshot);
// 非 5xx / 非网络错误:直接返回(4xx、2xx、3xx 均原样透传,不故障转移)。 // 非 5xx / 非网络错误:直接返回(4xx、2xx、3xx 均原样透传,不故障转移)。
if (!isRetryableError(null, resp.status)) { if (!isRetryableError(null, resp.status)) {
return mark(resp, origin); return mark(resp, origin);

View File

@ -4,7 +4,7 @@
*/ */
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; 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)", () => { test("http origin is rejected (https-only, fail-closed)", () => {
assert.throws(() => parseBackendOrigin("http://bot.example"), /https/); 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", "/", "/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", "/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"]) { 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}`); assert.ok(!SAFE_ROUTES.includes(r), `must not be a safe route: ${r}`);
} }
}); });

View File

@ -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 })); const restore = stubFetch(async (url) => new Response("ok", { status: 200 }));
try { try {
const res = await handleFetch(new Request("https://lb.example/healthz", { method: "GET" }), makeEnv()); 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(), makeEnv(),
); );
assert.equal(res.status, 404); 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 () => { 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); 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) => { const restore = stubFetch(async (url, init) => {
assert.match(url, /^https:\/\/(a|b)\.example\/api\/business-config$/); assert.match(url, /^https:\/\/(a|b)\.example\/api\/business-config$/);
assert.equal(init.method, "PUT"); 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) => { const restore = stubFetch(async (url, init) => {
assert.match(url, /^https:\/\/(a|b)\.example\/api\/status$/); assert.match(url, /^https:\/\/(a|b)\.example\/api\/status$/);
assert.equal(init.method, "GET"); 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"); 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) => { const restore = stubFetch(async (url, init) => {
assert.match(url, /^https:\/\/(a|b)\.example\/ready$/); assert.match(url, /^https:\/\/(a|b)\.example\/ready$/);
return new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); return new Response("ready", { status: 200, headers: { "content-type": "text/plain" } });

View File

@ -4,7 +4,7 @@
*/ */
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; 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] 每次调用消费一个; * 可控 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"); 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 { 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, ...noOpTimers,
fetch, fetch,
maxAttempts: 2, 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 () => { test("GET/HEAD do not forward a body", async () => {
const { fetch, calls } = mockFetch([{ status: 200 }]); const { fetch, calls } = mockFetch([{ status: 200 }]);
const get = new Request("https://lb.example/ready", { method: "GET", headers: { authorization: "Bearer x" } }); 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, ...noOpTimers,
fetch, fetch,
maxAttempts: 1, 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 () => { test("4xx is returned as-is WITHOUT retry and without failing over", async () => {
const { fetch, calls } = mockFetch([{ status: 401, body: "unauthorized" }, { status: 200 }]); 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, ...noOpTimers,
fetch, fetch,
maxAttempts: 2, 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 () => { test("5xx triggers failover to next origin, then returns success", async () => {
const { fetch, calls } = mockFetch([{ status: 503, body: "busy" }, { status: 200, body: "ok" }]); 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, ...noOpTimers,
fetch, fetch,
maxAttempts: 2, 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 () => { test("timeout(AbortError) triggers failover and recovery", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 200, body: "recovered" }]); 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, ...noOpTimers,
fetch, fetch,
maxAttempts: 2, 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 () => { test("all backends 5xx/timeout => aggregated 503 (Telegram redelivery fallback)", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 503, body: "x" }]); 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, ...noOpTimers,
fetch, fetch,
maxAttempts: 2, 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 () => { test("bounded attempts: maxAttempts caps the number of origin tries", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }]); // 每次都超时(可故障转移) 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, ...noOpTimers,
fetch, fetch,
maxAttempts: 2, 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 () => { 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 { 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, ...noOpTimers,
fetch, fetch,
maxAttempts: 2, maxAttempts: 2,