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

135 lines
5.0 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.
/**
* 主机信息,以及经由某一路入站发出的探测。
*
* Patchbay 服务端和 mihomo 跑在同一台机器上,所以这几件事由它代答:
* - 主机指标:mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些
* 从 os 模块和 /proc 里读(解析在 shared/host-info.ts,那边有测试)
* - 公网 IP 检测:浏览器没法「经由某个入站端口」发请求,服务端可以——把本机
* 对应端口设成代理去访问 ip.sb,返回的就是那一路在外面的地址
* - 可访问性测试:同一条路子,换成访问几个常用 AI 服务,判定在 shared/reachability.ts
*/
import os from 'node:os'
import {
cpuUsageBetween,
parseGateway,
parseMemAvailable,
parseMemTotal,
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
let total = 0
for (const cpu of os.cpus()) {
const t = cpu.times
busy += t.user + t.nice + t.sys + t.irq
total += t.user + t.nice + t.sys + t.irq + t.idle
}
return { busy, total }
}
/** 上一次请求时的 CPU 快照。使用率是两次请求之间的平均,第一次没有可比对象。 */
let lastCpu: CpuTimes | null = null
async function readProc(path: string): Promise<string> {
try {
return await Bun.file(path).text()
} catch {
return ''
}
}
export async function hostInfo(): Promise<HostInfo> {
const now = cpuTimesNow()
const cpuUsage = lastCpu ? cpuUsageBetween(lastCpu, now) : null
lastCpu = now
const lanAddresses: Array<{ iface: string; address: string }> = []
for (const [iface, addrs] of Object.entries(os.networkInterfaces())) {
for (const addr of addrs ?? []) {
if (addr.family === 'IPv4' && !addr.internal) lanAddresses.push({ iface, address: addr.address })
}
}
const meminfo = await readProc('/proc/meminfo')
const load = os.loadavg()[0] ?? 0
return {
cpuUsage,
cpuCount: os.cpus().length,
memTotal: parseMemTotal(meminfo) ?? os.totalmem(),
memAvailable: parseMemAvailable(meminfo) ?? os.freemem(),
// loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」
load1: process.platform === 'win32' ? null : load,
uptimeSeconds: os.uptime(),
platform: process.platform === 'linux' ? `Linux ${os.release()}` : `${os.type()} ${os.release()}`,
release: os.release(),
arch: os.arch(),
lanAddresses,
gateway: parseGateway(await readProc('/proc/net/route')),
nameservers: parseNameservers(await readProc('/etc/resolv.conf')),
}
}
/** 公网 IP 检测问谁。ip.sb 的这个接口返回 JSON,带 IP 和归属地。 */
const EXIT_URL = 'https://api.ip.sb/geoip'
/**
* 经由本机某个入站端口探测公网 IP。只对说 HTTP 的入站有效(http/mixed),
* 纯 socks 端口这条路走不通。
*/
export async function probeExit(port: number): Promise<ExitProbe> {
const started = Date.now()
try {
const res = await fetch(EXIT_URL, {
proxy: `http://127.0.0.1:${String(port)}`,
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) return { ok: false, message: `探测目标返回 HTTP ${String(res.status)}` }
const body = (await res.json()) as Record<string, unknown>
return {
ok: true,
ip: typeof body.ip === 'string' ? body.ip : '',
country: typeof body.country === 'string' ? body.country : '',
countryCode: typeof body.country_code === 'string' ? body.country_code : '',
ms: Date.now() - started,
}
} catch (error) {
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<ReachResult> {
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),
}
}
}