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:
parent
956ef6a1d3
commit
4f5eb9293d
|
|
@ -1,8 +1,11 @@
|
|||
/target/
|
||||
/Cargo.lock
|
||||
# 注意:Cargo.lock 实际已被跟踪(对二进制应用应提交以保证 --locked 可复现);此行为历史遗留,勿据它移除跟踪
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
# 构建验证缓存(cargo registry + rustup 组件,1.5G,绝不能提交)
|
||||
.gate-cache/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function isRetryableNetworkError(err) {
|
|||
* @param {{headers: Headers, body: Uint8Array | undefined}} snapshot 由 snapshotBody 产出
|
||||
* @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 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<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 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);
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
Loading…
Reference in New Issue