Files
mihomo-patchbay/shared/inbounds.ts
T
im0o eec5b791c8 仪表盘重做:ECharts 曲线、常驻遥测、路由行直达详情
采集从仪表盘组件挪进模块级单例(web/telemetry.ts),流量曲线和每路
吞吐的历史跨页面留存——跟着组件走的话切一次页面就清零一次。每路吞吐
从 /connections 快照按连接逐条求差再按 inboundName 聚合:先求和再求差
会在连接关闭时算出负数。

图表换 ECharts(按需注册):手写 SVG 折线做不出平滑曲线和时间轴,
颜色在运行时从 CSS 变量取,跟随主题切换重取。

路由列表每行带走势、速率和可选的公网 IP 检测,点击跳路由页并选中
那一路(外壳传 focus 给 RouterPage 消费)。
2026-08-11 22:26:36 +08:00

235 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 把配置文件里两套写法的入站合并成界面上的一张表。
*
* mihomo 有新旧两套声明入站的方式:
* 旧:顶层的 mixed-port / port / socks-port / redir-port / tproxy-port,
* 每种只能有一个,而且没法给它单独指定出站
* 新:listeners 数组,数量不限,每个都能有自己的出站和附加规则
*
* 界面上必须两边都画。只画 listeners 的话,一台只用 mixed-port 的机器打开是空板——
* 而实际上那个端口正在跑,这等于对用户撒谎。
*/
import type { ConfigObject } from '../vendor/config-editor/index.ts'
/** 内核自带、不需要配置就存在的出站。 */
export const BUILTIN_OUTBOUNDS = [
'DIRECT',
'REJECT',
'REJECT-DROP',
'PASS',
'PASS-RULE',
'COMPATIBLE',
'GLOBAL',
] as const
/** 旧写法的顶层端口字段,以及它们各自等价于哪种入站类型。 */
const LEGACY_PORTS: Array<{ key: string; type: string }> = [
{ key: 'mixed-port', type: 'mixed' },
{ key: 'port', type: 'http' },
{ key: 'socks-port', type: 'socks' },
{ key: 'redir-port', type: 'redir' },
{ key: 'tproxy-port', type: 'tproxy' },
]
/** 一路入站的出站去向。四选一,对应界面上那个下拉框。 */
export type OutboundMode =
| { kind: 'rules' } // 没写 proxy,流量进分流规则
| { kind: 'direct' } // proxy: DIRECT
| { kind: 'global' } // proxy: GLOBAL
| { kind: 'named'; name: string } // proxy: 某个策略组或自有线路
export interface Inbound {
/** 界面上用来定位这一行 */
id: string
name: string
type: string
/** 端口。旧写法一定是单个数字,新写法可以是 "200,302" 或 "401-429" 这类写法 */
port: string
listen: string | null
outbound: OutboundMode
/** 挂在这路入站上的附加规则组名,没有就是 null */
ruleSet: string | null
/** 旧写法声明的入站。界面上不可删除,也不能单独指定出站 */
isDefault: boolean
/** 旧写法对应的顶层字段名,用于写回。新写法为 null */
legacyKey: string | null
/** 在 listeners 数组里的下标,用于写回。旧写法为 null */
listenerIndex: number | null
}
/**
* 不用指名道姓的那几种出站,以及它们各自对应配置里的什么值。
*
* 这张表是「读配置 → 界面显示」的唯一约定来源。Patchbay 自己不会写出这些值
* (出站模式的写方向已随设计废弃),但手写配置里它们都可能出现,读和显示必须认得。
*
* `field` 为 undefined 表示配置里**没写** proxy 这个字段——「没有这个键」和
* 「这个键是空字符串」在 YAML 里是两回事。
*/
export const OUTBOUND_PRESETS: Array<{
mode: OutboundMode
field: string | undefined
label: string
}> = [
{ mode: { kind: 'rules' }, field: undefined, label: 'RULE' },
{ mode: { kind: 'direct' }, field: 'DIRECT', label: 'DIRECT' },
{ mode: { kind: 'global' }, field: 'GLOBAL', label: 'GLOBAL' },
]
function outboundOf(proxy: unknown): OutboundMode {
if (typeof proxy !== 'string' || proxy === '') return { kind: 'rules' }
const preset = OUTBOUND_PRESETS.find((p) => p.field === proxy)
return preset ? preset.mode : { kind: 'named', name: proxy }
}
/** 出站在界面上的名字。指名道姓的就是它自己的名字,其余用配置里的原词。 */
export function outboundLabel(mode: OutboundMode): string {
if (mode.kind === 'named') return mode.name
return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.label ?? mode.kind
}
/**
* 是不是「没有明确指定」那种状态。
*
* 界面上用来决定要不要弱化显示:RULE 是不写 proxy 字段时的默认结果,不是用户做出的
* 选择,跟他明确选了 DIRECT 是两回事,不该长得一样。
*/
export function isUnsetOutbound(mode: OutboundMode): boolean {
return mode.kind === 'rules'
}
/** 入站类型在界面上怎么念。以配置里的原词为准,不另造描述。 */
export function inboundTypeLabel(type: string): string {
return type === '' ? '—' : type
}
/**
* 老式顶层端口实际绑在哪个地址上。
*
* 它没有自己的 listen 字段,地址是内核从两个全局开关推出来的
* (内核源码 `listener/listener.go:709-718`):
* allow-lan 关 → 127.0.0.1,只有本机连得上
* allow-lan 开 → bind-address 指定的地址,写 * 或没写就是所有网卡
* 界面上不能一律显示 0.0.0.0,那会让人以为局域网连得上而实际连不上。
*/
export function legacyListenAddress(data: ConfigObject): string {
if (data['allow-lan'] !== true) return '127.0.0.1'
const bind = data['bind-address']
if (typeof bind !== 'string' || bind === '' || bind === '*') return '0.0.0.0'
return bind
}
export function collectInbounds(data: ConfigObject): Inbound[] {
const result: Inbound[] = []
const legacyListen = legacyListenAddress(data)
for (const { key, type } of LEGACY_PORTS) {
const raw = data[key]
const port = typeof raw === 'number' ? raw : Number(raw)
// 0 和缺省都表示没启用这个端口
if (!Number.isFinite(port) || port <= 0) continue
result.push({
id: `legacy:${key}`,
// 名字直接用它来自的那个配置键。比「HTTP 和 SOCKS5 共用」这种描述好:
// 类型那一列已经写着协议了,名字再说一遍是重复,而配置键能直接对上文件里的行。
name: key,
type,
port: String(port),
listen: legacyListen,
outbound: { kind: 'rules' },
ruleSet: null,
isDefault: true,
legacyKey: key,
listenerIndex: null,
})
}
const listeners = Array.isArray(data.listeners) ? data.listeners : []
listeners.forEach((entry, index) => {
if (typeof entry !== 'object' || entry === null) return
const item = entry as Record<string, unknown>
result.push({
id: `listener:${String(index)}`,
name: typeof item.name === 'string' ? item.name : `第 ${String(index + 1)} 个入站`,
type: typeof item.type === 'string' ? item.type : '',
port: item.port === undefined || item.port === null ? '' : String(item.port),
listen: typeof item.listen === 'string' ? item.listen : null,
outbound: outboundOf(item.proxy),
ruleSet: typeof item.rule === 'string' && item.rule ? item.rule : null,
isDefault: false,
legacyKey: null,
listenerIndex: index,
})
})
return result
}
/**
* 这一路入站在内核连接快照里的 `inboundName`。
*
* 命名入站带的是配置里的 name 原词(内核源码 `adapter/inbound/addition.go:19`,
* 名字来自 listener 配置的 name 字段)。老式顶层端口没有名字,内核给它们造了
* `DEFAULT-协议` 这样的内部名(如 `listener/mixed/mixed.go:57` 的 DEFAULT-MIXED、
* `listener/http/server.go:61` 的 DEFAULT-HTTP)。按这个名字才能把连接、吞吐这些
* 运行时数据对回界面上的某一路。
*/
export function inboundStatKey(inbound: Inbound): string {
if (!inbound.isDefault) return inbound.name
return `DEFAULT-${inbound.type.toUpperCase()}`
}
/**
* 走规则表的流量最后交给谁。
*
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
* 没有 MATCH 时内核会直连,返回 null。
*/
export function fallbackTarget(data: ConfigObject): string | null {
const rules = Array.isArray(data.rules) ? data.rules : []
for (const rule of rules) {
if (typeof rule !== 'string') continue
const parts = rule.split(',').map((p) => p.trim())
if (parts[0]?.toUpperCase() === 'MATCH' && parts[1]) return parts[1]
}
return null
}
/**
* 这一路入站的流量最终交给哪个出站。
*
* 和 `inbound.outbound` 的区别在于「跟着规则走」会被继续解析下去:那种情况下真正决定
* 去向的是规则表末尾的 MATCH。界面上要显示的是这个结果——用户想知道的是流量去哪,
* 不是配置里那个字段写没写。
*/
export function resolveDestination(data: ConfigObject, inbound: Inbound): string | null {
const mode = inbound.outbound
if (mode.kind === 'direct') return 'DIRECT'
if (mode.kind === 'global') return 'GLOBAL'
if (mode.kind === 'named') return mode.name
return fallbackTarget(data)
}
/**
* 所有可以被入站的出站字段引用的名字。
*
* 订阅带来的线路**不在这里**——内核按名字找出站时查的是一张由内置出站、顶层 proxies
* 和 proxy-groups 构成的表,订阅线路住在订阅对象里,不在表上。想让入站固定走订阅里的
* 某条线路,得先建一个只装它的策略组。
*/
export function collectOutboundNames(data: ConfigObject): {
builtins: string[]
groups: string[]
ownProxies: string[]
} {
const groups = (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : [])
.map((g) => (typeof g === 'object' && g !== null ? (g as Record<string, unknown>).name : null))
.filter((n): n is string => typeof n === 'string')
const ownProxies = (Array.isArray(data.proxies) ? data.proxies : [])
.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : null))
.filter((n): n is string => typeof n === 'string')
return { builtins: [...BUILTIN_OUTBOUNDS], groups, ownProxies }
}