/** * 把配置文件里两套写法的入站合并成界面上的一张表。 * * 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 } /** * 不用指名道姓的那几种出站,以及它们各自对应配置里的什么值。 * * 这张表是「读配置 → 界面 → 写回配置」三者的唯一约定来源。之前它被拆成四份散在 * 各处(读一份、显示一份、写回一份、表单里又抄了一份),加一种出站模式就要同时改 * 四个地方,漏一个就是「界面显示的」和「实际写进文件的」对不上。 * * `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 } } /** 把出站去向写回成配置里 proxy 字段该有的值。返回 undefined 表示不写这个字段。 */ export function outboundToProxyField(mode: OutboundMode): string | undefined { if (mode.kind === 'named') return mode.name return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.field } /** 出站在界面上的名字。指名道姓的就是它自己的名字,其余用配置里的原词。 */ 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 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 } /** * 走规则表的流量最后交给谁。 * * `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).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).name : null)) .filter((n): n is string => typeof n === 'string') return { builtins: [...BUILTIN_OUTBOUNDS], groups, ownProxies } }