feat(sites): 出口统一与 Cloudflare 过挑战级联(CF-2)
- 出口:站点请求不再隐式继承 HTTP(S)_PROXY,没开代理明确直连(创建客户端时临时拿掉代理环境变量,Deno 2.9 语义见 egress.ts), 开了走站点代理;TMDB / Brave / 图片改为显式按环境变量(行为不变) - CloudflareGate:浏览器代理期直接代抓 → HTTP(可复用时带 cf_clearance)→ 挑战 → 浏览器过挑战(同站 30 分钟一次、单飞) → cf_clearance 仅在 UA 跟随浏览器且两端同出口时给 HTTP,重试 1 次记 cfReuse → 仍被挑战则浏览器代抓并进入 6 小时代理期 - 浏览器:solveSiteChallenge(自动放行 ≤15s / 系统发起视觉子流程最多 2 次 / 真验证码转人工待处理)、fetchSiteHtml(profile 锁 + 站点令牌)、 交还后读回 cf_clearance 继续;site.config.cloudflare 状态契约 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6b14b0da45
commit
16083942c0
14 files changed
+1711
-43
No files matched your search
@@ -0,0 +1,315 @@
|
||||
import { SiteError } from "@umt/adapters";
|
||||
import { siteCloudflareState } from "@umt/contracts";
|
||||
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BrowserFetchOutcome,
|
||||
browserFetchable,
|
||||
CF_ATTEMPT_INTERVAL_MS,
|
||||
type ChallengeSolveOutcome,
|
||||
clearanceUsable,
|
||||
registerChallengeBrowser,
|
||||
withClearance,
|
||||
} from "../../src/modules/sources-identity/service/cloudflare.ts";
|
||||
import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts";
|
||||
import { registerBrowserIdentityReader } from "../../src/modules/sources-identity/service/user-agent.ts";
|
||||
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
|
||||
// CF-2 级联状态机:HTTP 遇 Cloudflare 挑战 → 浏览器过挑战(自动 / 视觉子流程 / 转人工)→ cf_clearance 同步给 HTTP 重试 1 次 →
|
||||
// 仍被挑战则页面请求改由浏览器代抓并进入 6 小时浏览器代理期;同站 30 分钟节流、同一时刻只处理一次;挑战不算凭据错误。
|
||||
// 站点 = testkit mock NexusPHP(真 HTTP),前面套一层"Cloudflare":没带合格 cf_clearance 的请求回 403 + cf-mitigated 挑战页。
|
||||
// 浏览器一侧用假端口(ChallengeBrowserPort),真实浏览器流程见 browser-cloudflare.test.ts。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0";
|
||||
const CF_HTML = `<!DOCTYPE html><html><head><title>Just a moment...</title></head><body>
|
||||
<div id="challenge-spinner"></div><script>window._cf_chl_opt={cType: 'managed'}</script></body></html>`;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 响应体断言
|
||||
const json = async (r: Response): Promise<any> => {
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(`${r.status} ${JSON.stringify(body)}`);
|
||||
return body.data;
|
||||
};
|
||||
|
||||
describe("CF-2 纯函数", () => {
|
||||
it("页面请求才能由浏览器代抓", () => {
|
||||
expect(browserFetchable({ method: "GET", accept: "text/html,application/xhtml+xml" })).toBe(true);
|
||||
expect(browserFetchable({ method: "GET", accept: null })).toBe(false);
|
||||
expect(browserFetchable({ method: "POST", accept: "text/html" })).toBe(false);
|
||||
expect(browserFetchable({ method: "GET", accept: "application/rss+xml" })).toBe(false);
|
||||
});
|
||||
it("cf_clearance 同步条件:UA 跟随浏览器 + UA 相同 + 两端出口相同 + 复用没失败过", () => {
|
||||
const base = {
|
||||
uaSource: "browser",
|
||||
requestUa: "A",
|
||||
clearanceUa: "A",
|
||||
requestEgress: "direct",
|
||||
clearanceEgress: "direct",
|
||||
cfReuse: null,
|
||||
} as const;
|
||||
expect(clearanceUsable(base)).toBe(true);
|
||||
expect(clearanceUsable({ ...base, uaSource: "manual" })).toBe(false);
|
||||
expect(clearanceUsable({ ...base, uaSource: "global" })).toBe(false);
|
||||
expect(clearanceUsable({ ...base, clearanceUa: "B" })).toBe(false);
|
||||
expect(clearanceUsable({ ...base, clearanceEgress: null })).toBe(false);
|
||||
expect(clearanceUsable({ ...base, clearanceEgress: "proxy:http://relay:7891" })).toBe(false);
|
||||
expect(clearanceUsable({ ...base, cfReuse: "fails" })).toBe(false);
|
||||
});
|
||||
it("并进 cf_clearance,去掉 __cf_bm", () => {
|
||||
expect(withClearance("a=1; __cf_bm=x", "cf_clearance=Z")).toBe("a=1; cf_clearance=Z");
|
||||
expect(withClearance(null, "cf_clearance=Z")).toBe("cf_clearance=Z");
|
||||
expect(withClearance("a=1", null)).toBe("a=1");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("CF-2 Cloudflare 级联", () => {
|
||||
let h: Harness;
|
||||
let mock: MockNexusPhpSite;
|
||||
let siteId: string;
|
||||
let accountId: string;
|
||||
/** "Cloudflare" 开关与放行规则 */
|
||||
const cf = {
|
||||
on: false,
|
||||
/** 放行判定;缺省:带 cf_clearance=GOOD 且 UA 是浏览器 UA */
|
||||
pass: (h: Headers) => (h.get("cookie") ?? "").includes("cf_clearance=GOOD") && h.get("user-agent") === BROWSER_UA,
|
||||
challenged: 0,
|
||||
passed: 0,
|
||||
};
|
||||
const seen: Headers[] = [];
|
||||
const solves: { url: string; accountId: string }[] = [];
|
||||
const fetches: string[] = [];
|
||||
let solveResult: ChallengeSolveOutcome = { status: "failed", reason: "unset" };
|
||||
let solveDelay = 0;
|
||||
let fetchResult: BrowserFetchOutcome | null = null;
|
||||
|
||||
const cfFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const hd = new Headers(init?.headers);
|
||||
seen.push(hd);
|
||||
if (cf.on && !cf.pass(hd)) {
|
||||
cf.challenged++;
|
||||
return new Response(CF_HTML, {
|
||||
status: 403,
|
||||
headers: { "cf-mitigated": "challenge", "content-type": "text/html" },
|
||||
});
|
||||
}
|
||||
cf.passed++;
|
||||
return globalThis.fetch(input, init);
|
||||
}) as typeof fetch;
|
||||
|
||||
const site = () => h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow();
|
||||
const account = () =>
|
||||
h.db.selectFrom("site_account").selectAll().where("id", "=", accountId).executeTakeFirstOrThrow();
|
||||
const state = async () => siteCloudflareState((await site()).config);
|
||||
const list = async () => h.rt.siteAdapter(await site()).list(1);
|
||||
|
||||
beforeAll(async () => {
|
||||
mock = await createMockNexusPhpSite({});
|
||||
h = await createHarness("cf2", { fetch: cfFetch });
|
||||
registerBrowserIdentityReader(h.ctx, async () => BROWSER_UA);
|
||||
registerChallengeBrowser(h.ctx, {
|
||||
async solve(req) {
|
||||
solves.push(req);
|
||||
if (solveDelay) await new Promise((r) => setTimeout(r, solveDelay));
|
||||
return solveResult;
|
||||
},
|
||||
async fetchHtml(req) {
|
||||
fetches.push(req.url);
|
||||
if (fetchResult) return fetchResult;
|
||||
// 浏览器已登录且过了挑战:直接取 mock 页面
|
||||
const r = await globalThis.fetch(req.url, { headers: { cookie: mock.cookie } });
|
||||
return { status: "ok", html: await r.text(), url: req.url };
|
||||
},
|
||||
});
|
||||
const s = await json(
|
||||
await h.req("/sites", {
|
||||
body: {
|
||||
key: "audiences",
|
||||
name: "观众",
|
||||
adapter: "nexusphp",
|
||||
baseUrl: `${mock.baseUrl}/`,
|
||||
rateLimit: { minIntervalMs: 0, concurrency: 10 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
siteId = s.id;
|
||||
const r = await json(
|
||||
await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "cookie", cookie: mock.cookie } }),
|
||||
);
|
||||
accountId = r.siteAccountId;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await mock?.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
cf.on = true;
|
||||
cf.pass = (hd) => (hd.get("cookie") ?? "").includes("cf_clearance=GOOD") && hd.get("user-agent") === BROWSER_UA;
|
||||
cf.challenged = 0;
|
||||
cf.passed = 0;
|
||||
seen.length = 0;
|
||||
solves.length = 0;
|
||||
fetches.length = 0;
|
||||
solveDelay = 0;
|
||||
fetchResult = null;
|
||||
solveResult = { status: "passed", by: "auto", clearance: "GOOD", userAgent: BROWSER_UA, browserEgress: "direct" };
|
||||
await h.db
|
||||
.updateTable("site")
|
||||
.set({ config: JSON.stringify({}), health_status: "healthy" })
|
||||
.where("id", "=", siteId)
|
||||
.execute();
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ user_info: JSON.stringify({}), user_agent: null, ua_pending: null, status: "active" })
|
||||
.where("id", "=", accountId)
|
||||
.execute();
|
||||
await h.rt.cloudflare.patch(siteId, {});
|
||||
});
|
||||
|
||||
it("自动通过:浏览器拿到 cf_clearance + UA,HTTP 带上重试 1 次成功,记 cfReuse = ok;之后的请求直接带", async () => {
|
||||
const page = await list();
|
||||
expect(page.items.length).toBeGreaterThan(0);
|
||||
expect(solves).toEqual([{ siteId, accountId, url: expect.stringContaining("/torrents.php") }]);
|
||||
expect(cf.challenged).toBe(1);
|
||||
expect(fetches).toEqual([]);
|
||||
const st = await state();
|
||||
expect(st).toMatchObject({ lastSolvedBy: "auto", cfReuse: "ok", lastResult: "reuse_ok", browserUntil: null });
|
||||
expect(st?.lastChallengeAt).toBeTruthy();
|
||||
expect(st?.lastRule).toBe("header cf-mitigated=challenge");
|
||||
// cf_clearance 存在 secret 里,账号 user_info 只有引用与取得时的 UA / 出口
|
||||
const info = (await account()).user_info as {
|
||||
cloudflare?: { secretRef: string; userAgent: string; egress: string };
|
||||
};
|
||||
expect(info.cloudflare).toMatchObject({ userAgent: BROWSER_UA, egress: "direct" });
|
||||
expect(h.secrets.plain.get(info.cloudflare!.secretRef)).toBe("GOOD");
|
||||
expect(JSON.stringify(info)).not.toContain("GOOD");
|
||||
// __cf_bm 不带;下一条请求直接带 cf_clearance,不再过挑战
|
||||
seen.length = 0;
|
||||
await list();
|
||||
expect(solves).toHaveLength(1);
|
||||
expect(seen[0]?.get("cookie")).toContain("cf_clearance=GOOD");
|
||||
expect(seen[0]?.get("cookie")).not.toContain("__cf_bm");
|
||||
// 账号状态不变
|
||||
expect((await account()).status).toBe("active");
|
||||
});
|
||||
|
||||
it("视觉子流程通过:记 lastSolvedBy = vision", async () => {
|
||||
solveResult = { status: "passed", by: "vision", clearance: "GOOD", userAgent: BROWSER_UA, browserEgress: "direct" };
|
||||
await list();
|
||||
expect(await state()).toMatchObject({ lastSolvedBy: "vision", cfReuse: "ok" });
|
||||
});
|
||||
|
||||
it("复用失败:cf_clearance 给 HTTP 仍被挑战 → cfReuse = fails,本次由浏览器代抓,6 小时浏览器代理期内页面请求直接走浏览器", async () => {
|
||||
cf.pass = () => false; // 站点绑定 TLS 指纹:HTTP 怎么都过不去
|
||||
const page = await list();
|
||||
expect(page.items.length).toBeGreaterThan(0);
|
||||
expect(cf.challenged).toBe(2); // 首次 + 带 cf_clearance 重试 1 次
|
||||
expect(fetches).toHaveLength(1);
|
||||
const st = await state();
|
||||
expect(st).toMatchObject({ cfReuse: "fails", lastResult: "reuse_fails" });
|
||||
const until = Date.parse(st!.browserUntil!);
|
||||
expect(until - Date.now()).toBeGreaterThan(5.9 * 3_600_000);
|
||||
// 代理期内:不发 HTTP,不再过挑战
|
||||
await list();
|
||||
expect(cf.challenged).toBe(2);
|
||||
expect(fetches).toHaveLength(2);
|
||||
expect(solves).toHaveLength(1);
|
||||
// 下载(不是页面请求)仍走 HTTP,报 challenge,不带失败过的 cf_clearance
|
||||
const err = await h.rt
|
||||
.siteAdapter(await site())
|
||||
.downloadTarget(page.items[0]!.siteReleaseId)
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(SiteError);
|
||||
expect((err as SiteError).kind).toBe("challenge");
|
||||
expect(seen.at(-1)?.get("cookie") ?? "").not.toContain("cf_clearance");
|
||||
// 到期再试 HTTP
|
||||
await h.rt.cloudflare.patch(siteId, { browserUntil: new Date(Date.now() - 1000).toISOString() });
|
||||
cf.pass = () => true;
|
||||
const n = fetches.length;
|
||||
await list();
|
||||
expect(fetches).toHaveLength(n);
|
||||
});
|
||||
|
||||
it("真验证码转人工:本次报 challenge(不算凭据错误),站点记 pending = human;快照不置账号失效、不开登录失效事项", async () => {
|
||||
solveResult = { status: "needs_human", sessionId: null, decisionId: null, reason: "选图验证码" };
|
||||
const r = await snapshotSite(h.rt, { siteId, kind: "manual" });
|
||||
expect(r.status).toBe("failed");
|
||||
expect(r.error).toContain("challenge");
|
||||
expect(fetches).toEqual([]);
|
||||
expect(await state()).toMatchObject({ pending: "human", lastResult: "needs_human", lastError: "选图验证码" });
|
||||
expect((await account()).status).toBe("active");
|
||||
expect((await site()).health_status).toBe("degraded");
|
||||
const d = await h.db.selectFrom("decision").select("id").where("dedupe_key", "=", `site-auth:${siteId}`).execute();
|
||||
expect(d).toEqual([]);
|
||||
expect(h.engine.enqueued.filter((e) => e.task === "refreshSiteAccount")).toEqual([]);
|
||||
|
||||
// 人在原站视图里过完、交还:浏览器模块回调 afterHuman → 清掉等人处理,存下 cf_clearance,马上可以再试
|
||||
await h.rt.cloudflare.afterHuman(siteId, accountId, {
|
||||
clearance: "GOOD",
|
||||
userAgent: BROWSER_UA,
|
||||
browserEgress: "direct",
|
||||
});
|
||||
expect(await state()).toMatchObject({ pending: null, lastSolvedBy: "human", lastAttemptAt: null });
|
||||
const page = await list();
|
||||
expect(page.items.length).toBeGreaterThan(0);
|
||||
expect(solves).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("节流与单飞:并发的挑战只过一次;30 分钟内不再过;超过 30 分钟再过", async () => {
|
||||
solveDelay = 150;
|
||||
cf.pass = () => false;
|
||||
fetchResult = { status: "challenge", reason: "still" };
|
||||
const results = await Promise.allSettled([list(), list(), list()]);
|
||||
expect(results.every((r) => r.status === "rejected")).toBe(true);
|
||||
expect(solves).toHaveLength(1);
|
||||
// 30 分钟内:只记挑战,不过
|
||||
await list().catch(() => null);
|
||||
expect(solves).toHaveLength(1);
|
||||
expect((await state())?.lastAttemptAt).toBeTruthy();
|
||||
// 超过 30 分钟
|
||||
await h.rt.cloudflare.patch(siteId, {
|
||||
lastAttemptAt: new Date(Date.now() - CF_ATTEMPT_INTERVAL_MS - 1000).toISOString(),
|
||||
});
|
||||
await list().catch(() => null);
|
||||
expect(solves).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("同步条件不满足:账号手动 UA → 不把 cf_clearance 交给 HTTP(不重试),页面改由浏览器代抓", async () => {
|
||||
await h.db.updateTable("site_account").set({ user_agent: BROWSER_UA }).where("id", "=", accountId).execute();
|
||||
const page = await list();
|
||||
expect(page.items.length).toBeGreaterThan(0);
|
||||
expect(cf.challenged).toBe(1);
|
||||
expect(fetches).toHaveLength(1);
|
||||
expect(seen.every((x) => !(x.get("cookie") ?? "").includes("cf_clearance"))).toBe(true);
|
||||
expect((await state())?.browserUntil).toBeTruthy();
|
||||
});
|
||||
|
||||
it("同步条件不满足:浏览器出口与 HTTP 不同(旧插件按环境变量出网)→ 不复用", async () => {
|
||||
solveResult = { status: "passed", by: "auto", clearance: "GOOD", userAgent: BROWSER_UA, browserEgress: null };
|
||||
await list();
|
||||
expect(cf.challenged).toBe(1);
|
||||
expect(fetches).toHaveLength(1);
|
||||
expect((await state())?.cfReuse).toBeNull();
|
||||
});
|
||||
|
||||
it("没有登录态账号的站点不处理挑战", async () => {
|
||||
await h.db.updateTable("site_account").set({ status: "expired" }).where("id", "=", accountId).execute();
|
||||
// 适配器没有 Cookie 就不发请求;这里直接用站点 fetch(RSS 这类不要登录态的请求同理)
|
||||
const res = await h.rt.siteFetch(await site())(`${mock.baseUrl}/torrents.php`, {
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(solves).toEqual([]);
|
||||
expect(fetches).toEqual([]);
|
||||
expect(await state()).toMatchObject({ lastResult: "skipped" });
|
||||
});
|
||||
|
||||
it("健康检测遇挑战且过不去:记 challenge(degraded),账号状态不变", async () => {
|
||||
solveResult = { status: "failed", reason: "浏览器服务不可用" };
|
||||
fetchResult = { status: "unavailable", reason: "浏览器服务不可用" };
|
||||
const r = await json(await h.req(`/sites/${siteId}/health-check`, { method: "POST", body: {} }));
|
||||
expect(r).toMatchObject({ ok: false, status: "challenge", healthStatus: "degraded", loggedIn: null });
|
||||
expect((await account()).status).toBe("active");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { DEFAULT_SETTINGS } from "@umt/contracts";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
directHttpClient,
|
||||
egressKey,
|
||||
envProxyFor,
|
||||
fetchVia,
|
||||
noProxyMatches,
|
||||
proxyHttpClient,
|
||||
siteEgress,
|
||||
} from "../../src/egress.ts";
|
||||
import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
|
||||
// CF-2 出口统一:站点请求不再隐式继承容器的 HTTP(S)_PROXY。开了站点代理 → 该代理;否则明确直连。
|
||||
// 本地起一个"代理"(收到请求就回 via-proxy)和一个目标(回 direct),把 HTTP_PROXY 指向前者,看请求实际走哪条。
|
||||
|
||||
const PROXY_KEYS = [
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
];
|
||||
|
||||
describe("出口选择(直连 / 代理)", () => {
|
||||
let proxy: Deno.HttpServer<Deno.NetAddr>;
|
||||
let target: Deno.HttpServer<Deno.NetAddr>;
|
||||
let proxyUrl = "";
|
||||
let targetUrl = "";
|
||||
const proxied: string[] = [];
|
||||
const saved = new Map<string, string | undefined>();
|
||||
|
||||
beforeAll(() => {
|
||||
proxy = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, (req) => {
|
||||
proxied.push(req.url);
|
||||
return new Response("via-proxy");
|
||||
});
|
||||
target = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, () => new Response("direct"));
|
||||
proxyUrl = `http://127.0.0.1:${proxy.addr.port}`;
|
||||
targetUrl = `http://127.0.0.1:${target.addr.port}/x`;
|
||||
for (const k of PROXY_KEYS) {
|
||||
saved.set(k, Deno.env.get(k));
|
||||
Deno.env.delete(k);
|
||||
}
|
||||
// 模拟容器环境:HTTP_PROXY 指向"中继",没有 NO_PROXY
|
||||
Deno.env.set("HTTP_PROXY", proxyUrl);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const [k, v] of saved) {
|
||||
if (v === undefined) Deno.env.delete(k);
|
||||
else Deno.env.set(k, v);
|
||||
}
|
||||
await proxy.shutdown();
|
||||
await target.shutdown();
|
||||
});
|
||||
|
||||
const body = async (f: typeof fetch) => (await f(targetUrl)).text();
|
||||
|
||||
it("Deno 2.9 语义:createHttpClient({}) 仍会读 HTTP_PROXY(所以直连要显式做)", async () => {
|
||||
const implicit = Deno.createHttpClient({});
|
||||
try {
|
||||
expect(await body(fetchVia(implicit))).toBe("via-proxy");
|
||||
} finally {
|
||||
implicit.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("directHttpClient:环境变量里有代理也直连;创建后环境变量原样还原", async () => {
|
||||
proxied.length = 0;
|
||||
expect(await body(fetchVia(directHttpClient()))).toBe("direct");
|
||||
expect(proxied).toEqual([]);
|
||||
expect(Deno.env.get("HTTP_PROXY")).toBe(proxyUrl);
|
||||
});
|
||||
|
||||
it("proxyHttpClient:只走给定代理(NO_PROXY 命中也不绕过)", async () => {
|
||||
Deno.env.set("NO_PROXY", "127.0.0.1");
|
||||
try {
|
||||
proxied.length = 0;
|
||||
const client = proxyHttpClient(proxyUrl);
|
||||
expect(await body(fetchVia(client))).toBe("via-proxy");
|
||||
expect(proxied).toHaveLength(1);
|
||||
expect(Deno.env.get("NO_PROXY")).toBe("127.0.0.1");
|
||||
} finally {
|
||||
Deno.env.delete("NO_PROXY");
|
||||
}
|
||||
});
|
||||
|
||||
it("SourcesRuntime.siteFetch:站点没开代理 → 直连;开了且设了代理地址 → 站点代理", async () => {
|
||||
const ctx = { logger: { debug() {}, info() {}, warn() {} } } as unknown as ServerContext;
|
||||
const rt = new SourcesRuntime(ctx);
|
||||
const defaults = { ...DEFAULT_SETTINGS.sites, proxyUrl };
|
||||
proxied.length = 0;
|
||||
expect(await body(rt.siteFetch({ proxy: false }, defaults, "UA"))).toBe("direct");
|
||||
expect(proxied).toEqual([]);
|
||||
expect(await body(rt.siteFetch({ proxy: true }, defaults, "UA"))).toBe("via-proxy");
|
||||
expect(proxied).toHaveLength(1);
|
||||
// 开了代理但没设代理地址 → 直连
|
||||
expect(await body(rt.siteFetch({ proxy: true }, { ...defaults, proxyUrl: null }, "UA"))).toBe("direct");
|
||||
expect(rt.egressKeyOf({ proxy: false }, defaults)).toBe("direct");
|
||||
expect(rt.egressKeyOf({ proxy: true }, defaults)).toBe(`proxy:http://127.0.0.1:${proxy.addr.port}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("外网请求按环境变量(行为不变,改为显式)", () => {
|
||||
it("HTTPS / HTTP / ALL_PROXY 与 NO_PROXY", () => {
|
||||
const env = { HTTPS_PROXY: "http://relay:7891", HTTP_PROXY: "http://relay:7890", NO_PROXY: "localhost,.lan" };
|
||||
expect(envProxyFor(new URL("https://api.themoviedb.org/3"), env)).toBe("http://relay:7891");
|
||||
expect(envProxyFor(new URL("http://image.tmdb.org/t"), env)).toBe("http://relay:7890");
|
||||
expect(envProxyFor(new URL("http://nas.lan/x"), env)).toBeNull();
|
||||
expect(envProxyFor(new URL("https://api.search.brave.com/"), { ALL_PROXY: "socks5h://relay:1080" })).toBe(
|
||||
"socks5h://relay:1080",
|
||||
);
|
||||
expect(envProxyFor(new URL("https://api.search.brave.com/"), {})).toBeNull();
|
||||
expect(envProxyFor(new URL("https://x.example/"), { https_proxy: "http://lower:1" })).toBe("http://lower:1");
|
||||
});
|
||||
it("NO_PROXY 匹配", () => {
|
||||
expect(noProxyMatches("a.example.com", "example.com")).toBe(true);
|
||||
expect(noProxyMatches("example.com", ".example.com")).toBe(true);
|
||||
expect(noProxyMatches("badexample.com", "example.com")).toBe(false);
|
||||
expect(noProxyMatches("x", "*")).toBe(true);
|
||||
expect(noProxyMatches("127.0.0.1", "localhost, 127.0.0.1:8080")).toBe(true);
|
||||
});
|
||||
it("出口键不含代理口令", () => {
|
||||
expect(egressKey(siteEgress(false, "http://u:p@relay:7891"))).toBe("direct");
|
||||
expect(egressKey(siteEgress(true, "http://u:p@relay:7891"))).toBe("proxy:http://relay:7891");
|
||||
expect(egressKey(siteEgress(true, null))).toBe("direct");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user