/** * 可访问性测试:一路入站到几个常用 AI 服务通不通。 * * 判定方式来自 sub2api 的代理质量检查(backend/internal/service/admin_proxy.go:337): * 发一个**不带鉴权**的 GET,按状态码白名单判可达。这些接口没有 key 一定不返回 200, * 但只要能返回 401/405 这类业务错误,就说明 TLS 握手、路由、目标服务全都正常—— * 这正是「这一路能不能用」要问的。用 200 当唯一标准反而永远测不通。 * * 判定逻辑放在 shared/ 而不是 server/,是因为「什么状态码算通」是这个功能的全部内容, * 它得能被 bun test 覆盖;server/ 那边只剩下发请求。 */ /** 一个探测目标。 */ export interface ReachTarget { /** 接口参数里用的标识 */ id: string /** 界面上显示的服务名 */ label: string url: string /** 这些状态码都算目标可达 */ reachableStatuses: number[] } /** * 预设目标清单,照抄 sub2api 的四家(admin_service.go:600)。 * * Anthropic 的白名单最宽,是因为 /v1/messages 只接受 POST,用 GET 探它时不同的 * 边缘节点会回 400/404/405 中的任意一个,这几种都只说明请求到达了对面。 */ export const REACH_TARGETS: ReachTarget[] = [ { id: 'openai', label: 'OpenAI', url: 'https://api.openai.com/v1/models', reachableStatuses: [200, 401], }, { id: 'anthropic', label: 'Anthropic', url: 'https://api.anthropic.com/v1/messages', reachableStatuses: [200, 400, 401, 404, 405], }, { id: 'gemini', label: 'Gemini', url: 'https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta', reachableStatuses: [200], }, { id: 'grok', label: 'Grok', url: 'https://api.x.ai/v1/models', reachableStatuses: [200, 401], }, ] export function findTarget(id: string): ReachTarget | null { return REACH_TARGETS.find((t) => t.id === id) ?? null } /** * 一次探测的结论。 * reachable 通了 * challenge 网络通,但出口 IP 被 Cloudflare 拦下要人机验证——这一路发得出请求,用不了服务 * throttled 对面回了 429,通但有频控 * unreachable 没通,或者回了预期之外的状态码 */ export type ReachVerdict = 'reachable' | 'challenge' | 'throttled' | 'unreachable' export interface ReachResult { target: string verdict: ReachVerdict /** 没拿到响应时(连接失败、超时)为 null */ status: number | null /** 整个来回的毫秒数 */ ms: number /** 说明这个结论怎么来的,直接显示给用户 */ message: string } /** Cloudflare 拦截页面里必然出现的标记之一。摘自 sub2api util/httputil/httputil.go:15。 */ const CHALLENGE_MARKERS = [ 'window._cf_chl_opt', 'just a moment', 'enable javascript and cookies to continue', '__cf_chl_', 'challenge-platform', ] /** * 这个响应是不是 Cloudflare 的人机验证。 * * 只有 403 和 429 才可能是。cf-mitigated 头是 Cloudflare 自己给的明示,没有这个头时 * 退回去认拦截页面的正文标记——正文只看前 4 KB,验证页的标记都在最前面。 */ export function isChallenge(status: number, headers: Headers, body: string): boolean { if (status !== 403 && status !== 429) return false if (headers.get('cf-mitigated')?.trim().toLowerCase() === 'challenge') return true const preview = body.slice(0, 4096).toLowerCase() return CHALLENGE_MARKERS.some((marker) => preview.includes(marker)) } /** Cloudflare 的请求编号。报障时对面要这个,所以命中拦截要把它带出来。 */ export function rayId(headers: Headers, body: string): string { const header = headers.get('cf-ray')?.trim() if (header) return header return /cf-ray[:\s=]+([a-z0-9-]+)/i.exec(body.slice(0, 8192))?.[1] ?? '' } /** 把一次拿到了响应的探测判成结论。连接根本没建立时不走这里,见 server/host.ts。 */ export function judge( target: ReachTarget, status: number, headers: Headers, body: string, ): { verdict: ReachVerdict; message: string } { if (isChallenge(status, headers, body)) { const ray = rayId(headers, body) return { verdict: 'challenge', message: `出口 IP 被 Cloudflare 拦下要人机验证${ray ? `(cf-ray ${ray})` : ''}`, } } if (target.reachableStatuses.includes(status)) { return { verdict: 'reachable', // 白名单里的非 2xx 是无鉴权探测的预期结果,不解释一句会被当成失败 message: status >= 200 && status < 300 ? `HTTP ${status}` : `HTTP ${status},目标可达`, } } if (status === 429) return { verdict: 'throttled', message: 'HTTP 429,目标在限流' } return { verdict: 'unreachable', message: `HTTP ${status},不是预期的状态码` } } /** * 把「连响应都没拿到」翻译成一句说明原因的话。 * * 探测走的是本机的入站端口,所以连接失败有两种完全不同的来源:端口上根本没人监听 * (内核没运行),和端口通了但代理那一跳走不出去。Bun 对前者给的是 * 「Unable to connect. Is the computer able to access the url?」——这句话把矛头指向了 * 目标网址,而真正的原因在本机,照抄出来只会让人去查错方向。 */ export function describeProbeFailure(port: number, timeoutMs: number, error: unknown): string { if (error instanceof Error && error.name === 'TimeoutError') { return `${String(timeoutMs / 1000)} 秒内没有响应` } const code = error instanceof Error ? (error as { code?: unknown }).code : undefined if (code === 'ConnectionRefused') { return `本机 ${String(port)} 端口没有响应,内核可能没在运行` } return `连接失败:${error instanceof Error ? error.message : String(error)}` }