Files
mihomo-patchbay/server/host.ts
T
im0o ce181e9ae5 服务端代答主机指标与公网 IP 检测
mihomo 的管理接口只报自己的内存,CPU、主机内存、网关、内网地址只有
跑在同一台机器上的 Patchbay 服务端答得出,所以加 /api/host 由它代答。
/api/host/exit 把本机某个入站端口设成代理去访问 ip.sb——浏览器发不出
「经由某个端口」的请求,这条只能走服务端。
2026-08-11 22:26:17 +08:00

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) }
}
}