入站详情可以测常用 AI 服务的可访问性
release / release (push) Successful in 29s

判定按状态码白名单而不是只认 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,两个页面都要用它了。
This commit is contained in:
2026-08-31 03:59:08 +08:00
parent 7d2a3afff0
commit 3d7304dd22
12 changed files with 716 additions and 34 deletions
+158
View File
@@ -0,0 +1,158 @@
/**
* 可访问性测试:一路入站到几个常用 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)}`
}