diff --git a/server/host.ts b/server/host.ts index e3b0f85..ef31cf1 100644 --- a/server/host.ts +++ b/server/host.ts @@ -1,11 +1,12 @@ /** - * 主机信息与公网 IP 检测。 + * 主机信息,以及经由某一路入站发出的探测。 * - * Patchbay 服务端和 mihomo 跑在同一台机器上,所以这两件事由它代答: + * Patchbay 服务端和 mihomo 跑在同一台机器上,所以这几件事由它代答: * - 主机指标:mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些 * 从 os 模块和 /proc 里读(解析在 shared/host-info.ts,那边有测试) * - 公网 IP 检测:浏览器没法「经由某个入站端口」发请求,服务端可以——把本机 * 对应端口设成代理去访问 ip.sb,返回的就是那一路在外面的地址 + * - 可访问性测试:同一条路子,换成访问几个常用 AI 服务,判定在 shared/reachability.ts */ import os from 'node:os' import { @@ -16,6 +17,8 @@ import { parseNameservers, } from '../shared/host-info.ts' import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts' +import { describeProbeFailure, judge } from '../shared/reachability.ts' +import type { ReachResult, ReachTarget } from '../shared/reachability.ts' function cpuTimesNow(): CpuTimes { let busy = 0 @@ -98,3 +101,34 @@ export async function probeExit(port: number): Promise { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } + +/** 单个目标的探测超时,毫秒。超时的说法由服务端生成,所以只有这里需要这个数。 */ +const REACH_TIMEOUT_MS = 10_000 + +/** + * 经由本机某个入站端口探测一个目标能不能访问。同 probeExit,只对说 HTTP 的入站有效。 + * + * 不带任何鉴权头,判定全交给 shared/reachability.ts 的 judge()。正文只读前 8 KB: + * 判定只需要认拦截页开头的标记,而目标本身可能返回很大的 JSON。 + */ +export async function probeReach(port: number, target: ReachTarget): Promise { + const started = Date.now() + try { + const res = await fetch(target.url, { + proxy: `http://127.0.0.1:${String(port)}`, + headers: { Accept: 'application/json,text/html,*/*' }, + signal: AbortSignal.timeout(REACH_TIMEOUT_MS), + }) + const body = (await res.text()).slice(0, 8192) + const { verdict, message } = judge(target, res.status, res.headers, body) + return { target: target.id, verdict, status: res.status, ms: Date.now() - started, message } + } catch (error) { + return { + target: target.id, + verdict: 'unreachable', + status: null, + ms: Date.now() - started, + message: describeProbeFailure(port, REACH_TIMEOUT_MS, error), + } + } +} diff --git a/server/routes.ts b/server/routes.ts index 76b79b4..2eb9ca3 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -24,7 +24,8 @@ import { undoConfig, } from './config-store.ts' import { geoStatus, syncGeoFile } from './geo.ts' -import { hostInfo, probeExit } from './host.ts' +import { hostInfo, probeExit, probeReach } from './host.ts' +import { findTarget } from '../shared/reachability.ts' import { kernelVersion, proxyGet, @@ -283,6 +284,20 @@ function makeRoute(env: Env, token: string) { return json(await probeExit(port)) } + // 经由某个入站端口探测一个目标能不能访问。一次一个目标,前端并发发起, + // 这样每一行各自落定,不必等最慢的那个才整表出现 + if (path === '/api/host/reach' && req.method === 'GET') { + const port = Number(url.searchParams.get('port')) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return json({ message: 'port 参数必须是 1-65535 的端口号' }, 400) + } + const target = findTarget(url.searchParams.get('target') ?? '') + if (!target) { + return json({ message: `没有名为 ${url.searchParams.get('target') ?? ''} 的探测目标` }, 400) + } + return json(await probeReach(port, target)) + } + // ── 内核 ──────────────────────────────────────────── if (path === '/api/kernel' && req.method === 'GET') { return json(await kernelVersion(env)) diff --git a/shared/inbounds.test.ts b/shared/inbounds.test.ts index 4d575d6..b0166d1 100644 --- a/shared/inbounds.test.ts +++ b/shared/inbounds.test.ts @@ -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() + }) +}) diff --git a/shared/inbounds.ts b/shared/inbounds.ts index 45b98e7..6eeef34 100644 --- a/shared/inbounds.ts +++ b/shared/inbounds.ts @@ -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) { diff --git a/shared/reachability.test.ts b/shared/reachability.test.ts new file mode 100644 index 0000000..8f0f2e1 --- /dev/null +++ b/shared/reachability.test.ts @@ -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 = {}) => 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 = 'window._cf_chl_opt' + 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(), 'Just a Moment...')).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: cf-ray: abc123')).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', + ) + }) +}) diff --git a/shared/reachability.ts b/shared/reachability.ts new file mode 100644 index 0000000..2a4a7c7 --- /dev/null +++ b/shared/reachability.ts @@ -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)}` +} diff --git a/web/api.ts b/web/api.ts index affc31c..d7891ec 100644 --- a/web/api.ts +++ b/web/api.ts @@ -8,6 +8,7 @@ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/ind import type { ConnectionSnapshot } from '@shared/connections.ts' import type { ExitProbe, HostInfo } from '@shared/host-info.ts' import type { GeoFileStatus } from '@shared/geo.ts' +import type { ReachResult } from '@shared/reachability.ts' import { DEFAULT_TEST_URL, testUrl } from './prefs' const TOKEN_KEY = 'patchbay.token' @@ -194,6 +195,13 @@ export const api = { */ probeExit: (port: number) => request('GET', `/api/host/exit?port=${String(port)}`), + /** + * 经由某个入站端口探测一个目标能不能访问。一次一个目标,调用方并发发起。 + * 同样会发起真实的外网请求,只在用户点了测试时调。 + */ + probeReach: (port: number, target: string) => + request('GET', `/api/host/reach?port=${String(port)}&target=${target}`), + /** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */ kernelConfigs: () => request>('GET', '/api/kernel/configs'), diff --git a/web/components/ReachDialog.vue b/web/components/ReachDialog.vue new file mode 100644 index 0000000..36d5f48 --- /dev/null +++ b/web/components/ReachDialog.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/web/dashboard.test.ts b/web/dashboard.test.ts index f0d6ba4..0315a92 100644 --- a/web/dashboard.test.ts +++ b/web/dashboard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { firstPort, kernelPorts, modeLabel } from './dashboard.ts' +import { kernelPorts, modeLabel } from './dashboard.ts' describe('运行模式', () => { it('三个已知模式有中文名', () => { @@ -31,20 +31,3 @@ describe('代理端口', () => { expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }]) }) }) - -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() - }) -}) diff --git a/web/dashboard.ts b/web/dashboard.ts index 84a1267..a8b27fc 100644 --- a/web/dashboard.ts +++ b/web/dashboard.ts @@ -38,14 +38,3 @@ export function kernelPorts(configs: Record): KernelPort[] { } return result } - -/** - * 入站声明的端口在探测公网 IP 时用哪个数。 - * 新式入站的端口可以写 "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 -} diff --git a/web/views/DashboardPage.vue b/web/views/DashboardPage.vue index 3fd6c59..d934e28 100644 --- a/web/views/DashboardPage.vue +++ b/web/views/DashboardPage.vue @@ -9,7 +9,7 @@ * 除了「检测公网 IP」,全部只读。 */ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts' +import { collectInbounds, firstPort, inboundStatKey } from '@shared/inbounds.ts' import { flagEmoji, formatBytes, formatUptime } from '@shared/format.ts' import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts' import type { RouteSample } from '@shared/telemetry.ts' @@ -29,7 +29,7 @@ import { totals, traffic, } from '../telemetry' -import { firstPort, kernelPorts, modeLabel } from '../dashboard' +import { kernelPorts, modeLabel } from '../dashboard' import { cssColor, themeTick } from '../theme' import EChart from '../components/EChart.vue' import ConfirmDialog from '../components/ConfirmDialog.vue' diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index 8825755..cb39dce 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -11,6 +11,7 @@ * 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。 */ import { computed, nextTick, ref, watch } from 'vue' +import { firstPort } from '@shared/inbounds.ts' import type { Inbound } from '@shared/inbounds.ts' import { autoGroupName, @@ -29,8 +30,10 @@ import { isChainedLine, kernel, linesFrom, membersOf, providers, runtime } from import { mergeMemberPreview } from '../member-preview' import type { MemberPreview } from '../member-preview' import OutboundCard from '../components/OutboundCard.vue' +import ReachDialog from '../components/ReachDialog.vue' import SourcePicker from '../components/SourcePicker.vue' import IconEdit from '~icons/ri/edit-line' +import IconReach from '~icons/ri/test-tube-line' import IconDelete from '~icons/ri/delete-bin-line' import IconUpgrade from '~icons/ri/arrow-up-circle-line' import IconRight from '~icons/ri/arrow-right-s-line' @@ -96,6 +99,24 @@ const subMatchAt = computed(() => firstMatchIndex(props.subRules)) /** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */ const locked = computed(() => props.inbound.isDefault) +/** + * 可访问性测试走本机的哪个端口,测不了就是 null。 + * + * 服务端拿这个端口当 HTTP 代理去访问目标,所以只有说 HTTP 的入站可以测; + * 纯 socks 端口这条路走不通(同公网 IP 检测,server/host.ts)。 + */ +const reachPort = computed(() => + props.inbound.type === 'http' || props.inbound.type === 'mixed' + ? firstPort(props.inbound.port) + : null, +) + +/** + * 对话框留在这里而不是像别的对话框那样提到 RouterPage:它不写配置文件也不改内核状态, + * 不需要经过父组件那条 commit 链路,提上去只会多出一组只为传端口而存在的 props。 + */ +const reachOpen = ref(false) + const activeTab = ref('') watch( () => props.panel.groups, @@ -460,6 +481,9 @@ const complete = computed( {{ inbound.port }} {{ inbound.name }}
+
+ + +