判定按状态码白名单而不是只认 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:
@@ -6,6 +6,7 @@ import {
|
||||
collectInbounds,
|
||||
collectOutboundNames,
|
||||
fallbackTarget,
|
||||
firstPort,
|
||||
isUnsetOutbound,
|
||||
outboundLabel,
|
||||
OUTBOUND_PRESETS,
|
||||
@@ -164,3 +165,20 @@ describe('出站预设表是读取和显示的唯一约定', () => {
|
||||
expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('经由入站发请求时连哪个端口', () => {
|
||||
it('单个端口直接用', () => {
|
||||
expect(firstPort('7890')).toBe(7890)
|
||||
})
|
||||
|
||||
it('多端口和区间取第一个数', () => {
|
||||
expect(firstPort('200,302')).toBe(200)
|
||||
expect(firstPort('401-429')).toBe(401)
|
||||
})
|
||||
|
||||
it('解析不出或越界返回 null', () => {
|
||||
expect(firstPort('')).toBeNull()
|
||||
expect(firstPort('abc')).toBeNull()
|
||||
expect(firstPort('70000')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,6 +185,17 @@ export function inboundStatKey(inbound: Inbound): string {
|
||||
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
|
||||
* 没有 MATCH 时内核会直连,返回 null。
|
||||
*/
|
||||
/**
|
||||
* 服务端要经由这一路入站发请求时,连本机的哪个端口。
|
||||
* 命名入站的端口可以写成 "200,302" 或 "401-429",取第一个数字;解析不出返回 null。
|
||||
*/
|
||||
export function firstPort(port: string): number | null {
|
||||
const match = /\d+/.exec(port)
|
||||
if (!match) return null
|
||||
const value = Number(match[0])
|
||||
return value >= 1 && value <= 65535 ? value : null
|
||||
}
|
||||
|
||||
export function fallbackTarget(data: ConfigObject): string | null {
|
||||
const rules = Array.isArray(data.rules) ? data.rules : []
|
||||
for (const rule of rules) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
describeProbeFailure,
|
||||
findTarget,
|
||||
isChallenge,
|
||||
judge,
|
||||
rayId,
|
||||
REACH_TARGETS,
|
||||
} from './reachability.ts'
|
||||
|
||||
const openai = REACH_TARGETS[0]!
|
||||
const gemini = REACH_TARGETS[2]!
|
||||
|
||||
const headers = (init: Record<string, string> = {}) => new Headers(init)
|
||||
|
||||
describe('REACH_TARGETS', () => {
|
||||
test('标识不重名,否则接口按 id 取目标会取错', () => {
|
||||
const ids = REACH_TARGETS.map((t) => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
test('findTarget 认不出来的标识返回 null', () => {
|
||||
expect(findTarget('openai')?.label).toBe('OpenAI')
|
||||
expect(findTarget('nonesuch')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('judge', () => {
|
||||
test('无鉴权探测拿到 401 算可达,并说明这是预期结果', () => {
|
||||
const { verdict, message } = judge(openai, 401, headers(), '')
|
||||
expect(verdict).toBe('reachable')
|
||||
expect(message).toBe('HTTP 401,目标可达')
|
||||
})
|
||||
|
||||
test('2xx 不需要解释,只报状态码', () => {
|
||||
expect(judge(gemini, 200, headers(), '{}')).toEqual({
|
||||
verdict: 'reachable',
|
||||
message: 'HTTP 200',
|
||||
})
|
||||
})
|
||||
|
||||
test('白名单之外的状态码算不通', () => {
|
||||
const { verdict, message } = judge(gemini, 403, headers(), '')
|
||||
expect(verdict).toBe('unreachable')
|
||||
expect(message).toBe('HTTP 403,不是预期的状态码')
|
||||
})
|
||||
|
||||
test('429 单独说是限流,不和连不上混在一起', () => {
|
||||
expect(judge(openai, 429, headers(), '').verdict).toBe('throttled')
|
||||
})
|
||||
|
||||
test('Cloudflare 拦截优先于状态码白名单', () => {
|
||||
// 429 在 openai 的白名单外,但结论要是 challenge 而不是 throttled:
|
||||
// 前者要换出口 IP,后者只要等
|
||||
const { verdict, message } = judge(
|
||||
openai,
|
||||
429,
|
||||
headers({ 'cf-mitigated': 'challenge', 'cf-ray': '8f0a1b2c3d4e5f60-LAX' }),
|
||||
'',
|
||||
)
|
||||
expect(verdict).toBe('challenge')
|
||||
expect(message).toContain('8f0a1b2c3d4e5f60-LAX')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isChallenge', () => {
|
||||
test('只有 403 和 429 才可能是拦截页', () => {
|
||||
const body = '<html>window._cf_chl_opt</html>'
|
||||
expect(isChallenge(403, headers(), body)).toBe(true)
|
||||
expect(isChallenge(429, headers(), body)).toBe(true)
|
||||
expect(isChallenge(200, headers(), body)).toBe(false)
|
||||
})
|
||||
|
||||
test('没有 cf-mitigated 头时认正文里的标记', () => {
|
||||
expect(isChallenge(403, headers(), '<title>Just a Moment...</title>')).toBe(true)
|
||||
})
|
||||
|
||||
test('普通的 403 不是拦截页', () => {
|
||||
expect(isChallenge(403, headers(), '{"error":"forbidden"}')).toBe(false)
|
||||
})
|
||||
|
||||
test('正文只看前 4 KB,标记在那之后不算', () => {
|
||||
expect(isChallenge(403, headers(), 'x'.repeat(5000) + 'just a moment')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rayId', () => {
|
||||
test('优先取响应头', () => {
|
||||
expect(rayId(headers({ 'cf-ray': ' abc123-LAX ' }), 'cf-ray: zzz')).toBe('abc123-LAX')
|
||||
})
|
||||
|
||||
test('没有响应头时从正文里找', () => {
|
||||
expect(rayId(headers(), 'Cloudflare Ray ID: <code>cf-ray: abc123</code>')).toBe('abc123')
|
||||
})
|
||||
|
||||
test('都没有就是空串', () => {
|
||||
expect(rayId(headers(), 'nothing here')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeProbeFailure', () => {
|
||||
test('超时说等了多久', () => {
|
||||
const error = new Error('timed out')
|
||||
error.name = 'TimeoutError'
|
||||
expect(describeProbeFailure(7890, 10_000, error)).toBe('10 秒内没有响应')
|
||||
})
|
||||
|
||||
test('本机端口没人监听时指向内核,不照抄 Bun 那句指向目标网址的话', () => {
|
||||
const error = Object.assign(new Error('Unable to connect. Is the computer able to access the url?'), {
|
||||
code: 'ConnectionRefused',
|
||||
})
|
||||
expect(describeProbeFailure(7890, 10_000, error)).toBe('本机 7890 端口没有响应,内核可能没在运行')
|
||||
})
|
||||
|
||||
test('其余原因照原文给出,不猜', () => {
|
||||
expect(describeProbeFailure(7890, 10_000, new Error('TLS handshake failed'))).toBe(
|
||||
'连接失败:TLS handshake failed',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user