From ce181e9ae58a2386f047a786e497cf9148943115 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 22:26:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=BB=A3=E7=AD=94?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E6=8C=87=E6=A0=87=E4=B8=8E=E5=85=AC=E7=BD=91?= =?UTF-8?q?=20IP=20=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mihomo 的管理接口只报自己的内存,CPU、主机内存、网关、内网地址只有 跑在同一台机器上的 Patchbay 服务端答得出,所以加 /api/host 由它代答。 /api/host/exit 把本机某个入站端口设成代理去访问 ip.sb——浏览器发不出 「经由某个端口」的请求,这条只能走服务端。 --- server/host.ts | 99 ++++++++++++++++++++++++++++++++++++++++ server/routes.ts | 16 +++++++ shared/format.ts | 10 ++++ shared/host-info.test.ts | 53 +++++++++++++++++++++ shared/host-info.ts | 90 ++++++++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 server/host.ts create mode 100644 shared/host-info.test.ts create mode 100644 shared/host-info.ts diff --git a/server/host.ts b/server/host.ts new file mode 100644 index 0000000..e5032e7 --- /dev/null +++ b/server/host.ts @@ -0,0 +1,99 @@ +/** + * 主机信息与公网 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 { + 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: 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) } + } +} diff --git a/server/routes.ts b/server/routes.ts index c626298..7880431 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -11,6 +11,7 @@ */ import listenerFields from '../shared/listener-fields.json' with { type: 'json' } import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' +import { hostInfo, probeExit } from './host.ts' import { kernelVersion, proxyGet, proxyWrite, selectProxy } from './kernel.ts' import { tokenFromRequest, tokenMatches } from './auth.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' @@ -180,6 +181,21 @@ function makeRoute(env: Env, token: string) { return result.ok ? json(result) : json(result, applyFailureStatus(result.stage)) } + // ── 主机 ──────────────────────────────────────────── + // 服务端和内核在同一台机器上,所以主机指标由 Patchbay 自己代答 + if (path === '/api/host' && req.method === 'GET') { + return json(await hostInfo()) + } + + // 经由某个入站端口探测公网 IP。GET 但会发起真实的外网请求,和延迟测试同一类 + if (path === '/api/host/exit' && 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) + } + return json(await probeExit(port)) + } + // ── 内核 ──────────────────────────────────────────── if (path === '/api/kernel' && req.method === 'GET') { return json(await kernelVersion(env)) diff --git a/shared/format.ts b/shared/format.ts index 6be1949..a279187 100644 --- a/shared/format.ts +++ b/shared/format.ts @@ -18,3 +18,13 @@ export function formatBytes(bytes: number): string { // Number() 去掉无意义的尾零:1.0 KB → 1 KB return `${String(Number(value.toFixed(digits)))} ${UNITS[unit]}` } + +/** 运行时长。一小时内说分钟,一天内说时分,再长说天和小时;秒级抖动没有信息量。 */ +export function formatUptime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '—' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${String(minutes)} 分钟` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${String(hours)} 小时 ${String(minutes % 60)} 分` + return `${String(Math.floor(hours / 24))} 天 ${String(hours % 24)} 小时` +} diff --git a/shared/host-info.test.ts b/shared/host-info.test.ts new file mode 100644 index 0000000..5d52a28 --- /dev/null +++ b/shared/host-info.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { + cpuUsageBetween, + parseGateway, + parseMemAvailable, + parseNameservers, +} from './host-info.ts' + +describe('parseMemAvailable', () => { + test('取 MemAvailable 并换算成字节', () => { + const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n' + expect(parseMemAvailable(meminfo)).toBe(512000 * 1024) + }) + + test('没有这一行(老内核)返回 null', () => { + expect(parseMemAvailable('MemTotal: 1 kB\nMemFree: 1 kB\n')).toBeNull() + }) +}) + +describe('parseGateway', () => { + test('默认路由的网关按小端序倒回来', () => { + // 0102A8C0 → c0.a8.02.01 → 192.168.2.1 + const table = [ + 'Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT', + 'eth0\t00000000\t0102A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0', + 'eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0', + ].join('\n') + expect(parseGateway(table)).toBe('192.168.2.1') + }) + + test('没有默认路由返回 null', () => { + const table = 'Iface\tDestination\tGateway\neth0\t0002A8C0\t00000000' + expect(parseGateway(table)).toBeNull() + }) +}) + +describe('parseNameservers', () => { + test('按出现顺序取 nameserver', () => { + const conf = '# comment\nnameserver 223.5.5.5\nsearch lan\nnameserver 114.114.114.114\n' + expect(parseNameservers(conf)).toEqual(['223.5.5.5', '114.114.114.114']) + }) +}) + +describe('cpuUsageBetween', () => { + test('按两次快照的差值算百分比', () => { + const usage = cpuUsageBetween({ busy: 100, total: 1000 }, { busy: 350, total: 2000 }) + expect(usage).toBe(25) + }) + + test('时间没有前进时返回 null', () => { + expect(cpuUsageBetween({ busy: 1, total: 10 }, { busy: 1, total: 10 })).toBeNull() + }) +}) diff --git a/shared/host-info.ts b/shared/host-info.ts new file mode 100644 index 0000000..b10bdd5 --- /dev/null +++ b/shared/host-info.ts @@ -0,0 +1,90 @@ +/** + * 主机信息的纯解析。 + * + * mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些要靠 Patchbay 服务端 + * 自己读 /proc 和 os 模块——服务端和内核跑在同一台机器上,这正是它能代答的原因。 + * 读文件在 server/host.ts;这里只做字符串到数字的解析,让 bun test 够得着。 + */ + +/** GET /api/host 的响应。数据由 server/host.ts 采集,类型放这里让前后端共用。 */ +export interface HostInfo { + /** 0-100。第一次请求没有参照快照,是 null */ + cpuUsage: number | null + cpuCount: number + memTotal: number + /** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */ + memAvailable: number + /** 1 分钟负载。Windows 上拿不到,是 null */ + load1: number | null + uptimeSeconds: number + platform: string + release: string + arch: string + /** 非回环网卡上的 IPv4,含网卡名 */ + lanAddresses: Array<{ iface: string; address: string }> + gateway: string | null + nameservers: string[] +} + +/** GET /api/host/exit 的响应:经由某个入站端口探测到的公网 IP。 */ +export interface ExitProbe { + ok: boolean + ip?: string + country?: string + countryCode?: string + /** 整个探测来回的毫秒数 */ + ms?: number + message?: string +} + +/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */ +export function parseMemAvailable(meminfo: string): number | null { + const match = /^MemAvailable:\s+(\d+)\s*kB/m.exec(meminfo) + if (!match) return null + return Number(match[1]) * 1024 +} + +/** + * /proc/net/route 里的默认网关。 + * + * 格式是制表分隔的表:目的网络为 00000000 的那行是默认路由,Gateway 列是 + * 小端序的十六进制 IPv4——字节序要倒回来才是人读的顺序。 + */ +export function parseGateway(procNetRoute: string): string | null { + for (const line of procNetRoute.split('\n').slice(1)) { + const cols = line.trim().split(/\s+/) + if (cols.length < 3 || cols[1] !== '00000000') continue + const hex = cols[2]! + if (!/^[0-9a-fA-F]{8}$/.test(hex)) continue + const bytes = [6, 4, 2, 0].map((i) => parseInt(hex.slice(i, i + 2), 16)) + return bytes.join('.') + } + return null +} + +/** /etc/resolv.conf 里的 DNS 服务器,按出现顺序。 */ +export function parseNameservers(resolvConf: string): string[] { + const result: string[] = [] + for (const line of resolvConf.split('\n')) { + const match = /^\s*nameserver\s+(\S+)/.exec(line) + if (match) result.push(match[1]!) + } + return result +} + +/** 一次 CPU 时间快照:忙碌和总量的累计毫秒(os.cpus() 各核求和)。 */ +export interface CpuTimes { + busy: number + total: number +} + +/** + * 两次快照之间的 CPU 使用率,0 到 100。 + * 时间没有前进(同一毫秒内连问两次,或计数回绕)时返回 null,不硬造一个数。 + */ +export function cpuUsageBetween(prev: CpuTimes, next: CpuTimes): number | null { + const total = next.total - prev.total + const busy = next.busy - prev.busy + if (total <= 0 || busy < 0) return null + return Math.min(100, (busy / total) * 100) +}