Files
mihomo-patchbay/shared/inbounds.ts
T
im0o 3d7304dd22
release / release (push) Successful in 29s
入站详情可以测常用 AI 服务的可访问性
判定按状态码白名单而不是只认 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,两个页面都要用它了。
2026-08-31 03:59:08 +08:00

246 lines
9.6 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。
*/
/**
* 服务端要经由这一路入站发请求时,连本机的哪个端口。
* 命名入站的端口可以写成 "200,302" 或 "401-429",取第一个数字;解析不出返回 null。
*/
export function firstPort(port: string): number | null {
const match = /\d+/.exec(port)
if (!match) return null
const value = Number(match[0])
return value >= 1 && value <= 65535 ? value : 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 }
}