/** * 主机信息,以及经由某一路入站发出的探测。 * * 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 { try { return await Bun.file(path).text() } catch { return '' } } export async function hostInfo(): Promise { 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 { 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 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 { 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), } } }