Files
UHD_Media_Tool/apps/server/src/egress.ts
T
rootandClaude Opus 5.5 16083942c0 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]>
2026-09-30 21:26:54 +08:00

137 lines
5.7 KiB
TypeScript

// CF-2 出口统一:每条对外 HTTP 请求走哪条出口由代码显式决定,不再隐式继承容器的 HTTP(S)_PROXY。
//
// Deno 2.9 的语义(2026-09-30 本机实测,见 docs/runbook/sites.md「出口」):
// - `fetch` 缺省客户端与 `Deno.createHttpClient({})` 都会读 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY(大小写都认)与 NO_PROXY;
// 读取发生在**客户端创建时**(缺省客户端在进程第一次 fetch 时创建);
// - `createHttpClient` 没有"不走代理"的选项,`NO_PROXY=*` 也不认;显式 `proxy` 会排在环境变量代理前面,
// 但 NO_PROXY 命中的主机连显式代理也绕过;
// - 所以直连客户端的做法是:创建那一刻把这几个环境变量临时拿掉(同步调用,同一个 isolate 内不会被别的代码插进来),建完立即还原。
// 代理客户端同样在拿掉环境变量后创建,保证只走给定的代理。
//
// 出口表:
// - 站点请求(适配器、RSS、登录、用户数据):站点开"使用代理"且设了代理地址 → 该代理;否则直连(siteClient);
// - TMDB、Brave、图片缓存等外网请求:照旧用环境变量里的代理(行为不变),但改成显式读 env 建客户端(externalFetch)。
const PROXY_ENV = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const;
const NO_PROXY_ENV = ["NO_PROXY", "no_proxy"] as const;
/** 进程启动时的代理环境变量快照(外网请求显式使用;之后的临时改动不影响) */
const ENV_SNAPSHOT: Record<string, string | undefined> = (() => {
const out: Record<string, string | undefined> = {};
try {
for (const k of [...PROXY_ENV, ...NO_PROXY_ENV]) out[k] = Deno.env.get(k);
} catch {
// 没有 env 权限:当作没有代理
}
return out;
})();
/** 在"没有代理环境变量"的状态下同步执行 fn(建 HttpClient 用),结束立即还原 */
function withoutProxyEnv<T>(fn: () => T): T {
const saved = new Map<string, string>();
try {
for (const k of [...PROXY_ENV, ...NO_PROXY_ENV]) {
const v = Deno.env.get(k);
if (v !== undefined) {
saved.set(k, v);
Deno.env.delete(k);
}
}
} catch {
// 没有 env 权限时也没有可读的代理变量,直接建
}
try {
return fn();
} finally {
for (const [k, v] of saved) Deno.env.set(k, v);
}
}
let direct: Deno.HttpClient | null = null;
const proxied = new Map<string, Deno.HttpClient>();
/** 明确直连的客户端(不读任何代理环境变量) */
export function directHttpClient(): Deno.HttpClient {
direct ??= withoutProxyEnv(() => Deno.createHttpClient({}));
return direct;
}
/** 只走给定代理的客户端(同一地址复用) */
export function proxyHttpClient(url: string): Deno.HttpClient {
let c = proxied.get(url);
if (!c) {
c = withoutProxyEnv(() => Deno.createHttpClient({ proxy: { url } }));
proxied.set(url, c);
}
return c;
}
/** 用指定客户端发请求的 fetch */
export function fetchVia(client: Deno.HttpClient): typeof globalThis.fetch {
return ((input: RequestInfo | URL, init?: RequestInit) =>
globalThis.fetch(input, { ...init, client } as RequestInit)) as typeof globalThis.fetch;
}
/** 站点请求的出口:开代理且有代理地址 → proxy,否则 direct */
export interface SiteEgress {
mode: "direct" | "proxy";
proxyUrl: string | null;
}
export function siteEgress(proxy: boolean, proxyUrl: string | null | undefined): SiteEgress {
return proxy && proxyUrl ? { mode: "proxy", proxyUrl } : { mode: "direct", proxyUrl: null };
}
/** 出口的比较键(不含代理口令):direct / proxy:<主机:端口> */
export function egressKey(e: SiteEgress): string {
if (e.mode === "direct" || !e.proxyUrl) return "direct";
try {
const u = new URL(e.proxyUrl);
return `proxy:${u.protocol}//${u.host}`;
} catch {
return "proxy:?";
}
}
export function siteFetchFor(e: SiteEgress): typeof globalThis.fetch {
return fetchVia(e.mode === "proxy" && e.proxyUrl ? proxyHttpClient(e.proxyUrl) : directHttpClient());
}
// ---------- 外网请求(TMDB / Brave / 图片):显式按环境变量 ----------
/** NO_PROXY 匹配(与常见实现一致:逗号分隔;`.a.com` / `a.com` 匹配自身与子域;`*` 匹配全部;IP 精确匹配) */
export function noProxyMatches(host: string, noProxy: string | undefined): boolean {
if (!noProxy) return false;
const h = host.toLowerCase().replace(/^\[|\]$/g, "");
for (const raw of noProxy.split(",")) {
const p = raw.trim().toLowerCase().replace(/:\d+$/, "");
if (!p) continue;
if (p === "*") return true;
const d = p.replace(/^\*?\./, "");
if (h === d || h.endsWith(`.${d}`)) return true;
}
return false;
}
/** 外网请求按环境变量应走的代理(进程启动时的快照);没有 / NO_PROXY 命中为 null */
export function envProxyFor(target: URL, env: Record<string, string | undefined> = ENV_SNAPSHOT): string | null {
const get = (k: string) => env[k] ?? env[k.toLowerCase()];
if (noProxyMatches(target.hostname, get("NO_PROXY"))) return null;
const byScheme = target.protocol === "https:" ? get("HTTPS_PROXY") : get("HTTP_PROXY");
const p = byScheme || get("ALL_PROXY");
return p ? p : null;
}
/** 外网请求用的 fetch:按环境变量显式选代理 / 直连(与之前隐式继承的行为一致) */
export const externalFetch: typeof globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
let target: URL | null = null;
try {
target = new URL(input instanceof Request ? input.url : String(input));
} catch {
// 交给 fetch 报错
}
const proxy = target ? envProxyFor(target) : null;
const client = proxy ? proxyHttpClient(proxy) : directHttpClient();
return globalThis.fetch(input, { ...init, client } as RequestInit);
}) as typeof globalThis.fetch;