release / release (push) Successful in 29s
判定按状态码白名单而不是只认 200:探测不带鉴权,OpenAI 回 401、Anthropic 回 405 才是正常应答,说明 TLS 握手、路由、目标服务全都正常。清单和白名单照 sub2api 的 代理质量检查抄来。 失败文案不照抄 Bun:本机端口没人监听时它给的是「Unable to connect. Is the computer able to access the url?」,把矛头指向目标网址,而真正的原因在本机。 对话框留在 InboundDetail 而不是提到 RouterPage——它不写配置文件也不改内核状态, 不需要走那条 commit 链路。firstPort 挪进 shared/inbounds.ts,两个页面都要用它了。
135 lines
5.0 KiB
TypeScript
135 lines
5.0 KiB
TypeScript
/**
|
||
* 主机信息,以及经由某一路入站发出的探测。
|
||
*
|
||
* 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<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: 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<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) }
|
||
}
|
||
}
|
||
|
||
/** 单个目标的探测超时,毫秒。超时的说法由服务端生成,所以只有这里需要这个数。 */
|
||
const REACH_TIMEOUT_MS = 10_000
|
||
|
||
/**
|
||
* 经由本机某个入站端口探测一个目标能不能访问。同 probeExit,只对说 HTTP 的入站有效。
|
||
*
|
||
* 不带任何鉴权头,判定全交给 shared/reachability.ts 的 judge()。正文只读前 8 KB:
|
||
* 判定只需要认拦截页开头的标记,而目标本身可能返回很大的 JSON。
|
||
*/
|
||
export async function probeReach(port: number, target: ReachTarget): Promise<ReachResult> {
|
||
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),
|
||
}
|
||
}
|
||
}
|