Files
im0o 3d7304dd22
release / release (push) Successful in 29s
入站详情可以测常用 AI 服务的可访问性
判定按状态码白名单而不是只认 200:探测不带鉴权,OpenAI 回 401、Anthropic 回 405
才是正常应答,说明 TLS 握手、路由、目标服务全都正常。清单和白名单照 sub2api 的
代理质量检查抄来。

失败文案不照抄 Bun:本机端口没人监听时它给的是「Unable to connect. Is the
computer able to access the url?」,把矛头指向目标网址,而真正的原因在本机。

对话框留在 InboundDetail 而不是提到 RouterPage——它不写配置文件也不改内核状态,
不需要走那条 commit 链路。firstPort 挪进 shared/inbounds.ts,两个页面都要用它了。
2026-08-31 03:59:08 +08:00

159 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 可访问性测试:一路入站到几个常用 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)}`
}