mihomo 的管理接口只报自己的内存,CPU、主机内存、网关、内网地址只有 跑在同一台机器上的 Patchbay 服务端答得出,所以加 /api/host 由它代答。 /api/host/exit 把本机某个入站端口设成代理去访问 ip.sb——浏览器发不出 「经由某个端口」的请求,这条只能走服务端。
100 lines
3.5 KiB
TypeScript
100 lines
3.5 KiB
TypeScript
/**
|
|
* 主机信息与公网 IP 检测。
|
|
*
|
|
* Patchbay 服务端和 mihomo 跑在同一台机器上,所以这两件事由它代答:
|
|
* - 主机指标:mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些
|
|
* 从 os 模块和 /proc 里读(解析在 shared/host-info.ts,那边有测试)
|
|
* - 公网 IP 检测:浏览器没法「经由某个入站端口」发请求,服务端可以——把本机
|
|
* 对应端口设成代理去访问 ip.sb,返回的就是那一路在外面的地址
|
|
*/
|
|
import os from 'node:os'
|
|
import {
|
|
cpuUsageBetween,
|
|
parseGateway,
|
|
parseMemAvailable,
|
|
parseNameservers,
|
|
} from '../shared/host-info.ts'
|
|
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.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: 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) }
|
|
}
|
|
}
|