服务端代答主机指标与公网 IP 检测
mihomo 的管理接口只报自己的内存,CPU、主机内存、网关、内网地址只有 跑在同一台机器上的 Patchbay 服务端答得出,所以加 /api/host 由它代答。 /api/host/exit 把本机某个入站端口设成代理去访问 ip.sb——浏览器发不出 「经由某个端口」的请求,这条只能走服务端。
This commit is contained in:
@@ -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<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) }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user