Files
mihomo-patchbay/shared/rules.ts
T
im0o 9e87517a4e 规则页:全局分流规则的编辑与规则组管理
新增 RulesPage.vue 和规则、规则组、ini 导入三个对话框,纯逻辑放在
shared/rules.ts。拆分规则字符串复用 vendor 的 splitRuleFields——引号和
嵌套括号里的逗号不会被当成字段分隔;目标之后的选项段原样拼回,改一条
规则不会丢掉它的 no-resolve 尾巴。规则组的缓存路径显式生成:不写 path
时内核按 url 哈希自己算一个,那个路径这里算不出来。
2026-08-11 18:53:21 +08:00

341 lines
13 KiB
TypeScript
Raw 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.
/**
* 分流规则与规则组(rule-providers)的纯逻辑。
*
* 拆分规则字符串只为显示和编辑常见字段,不为每种规则类型建模——内核认识的类型
* 比界面需要的多得多,拆不明白的(逻辑规则、子规则)由界面按原文整行编辑。
* 拆分用 vendor 的 splitRuleFields:它认得引号和嵌套括号,`AND`/`OR` 这类载荷里
* 的逗号不会被当成字段分隔。
*/
import { splitRuleFields } from '../vendor/config-editor/index.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
/** 一条分流规则拆开的样子。extras 是目标之后的选项段(如 no-resolve),原样保留。 */
export interface ParsedRule {
type: string
/** MATCH 没有载荷,为 null */
payload: string | null
/** 目标出站。拆不出来(字段不够)时为 null */
target: string | null
extras: string[]
}
export function parseRule(rule: string): ParsedRule {
const fields = splitRuleFields(rule)
const type = fields[0] ?? ''
if (type.toUpperCase() === 'MATCH') {
return { type, payload: null, target: fields[1] ?? null, extras: fields.slice(2) }
}
return { type, payload: fields[1] ?? null, target: fields[2] ?? null, extras: fields.slice(3) }
}
/** 拼回一行规则。空字段不写,所以 MATCH 不会多出一个空载荷段。 */
export function buildRule(parsed: ParsedRule): string {
return [parsed.type, parsed.payload, parsed.target, ...parsed.extras]
.filter((part): part is string => part !== null && part !== '')
.join(',')
}
/**
* 界面能按「类型,载荷,目标」结构化编辑的规则。
*
* 逻辑规则和子规则的结构不是这个形状,让它们进表单只会把原文拆坏,按整行编辑。
*/
const RAW_ONLY_TYPES = new Set(['AND', 'OR', 'NOT', 'SUB-RULE'])
export function isStructuredEditable(rule: string): boolean {
const parsed = parseRule(rule)
if (RAW_ONLY_TYPES.has(parsed.type.toUpperCase())) return false
return parsed.target !== null
}
/** 第一条 MATCH 的下标。它之后的规则永远到不了。没有 MATCH 返回 -1。 */
export function firstMatchIndex(rules: string[]): number {
return rules.findIndex((rule) => parseRule(rule).type.toUpperCase() === 'MATCH')
}
/** 把新规则插到第一条 MATCH 之前;没有 MATCH 就加在末尾。 */
export function insertBeforeMatch(rules: string[], additions: string[]): string[] {
const at = firstMatchIndex(rules)
const index = at < 0 ? rules.length : at
return [...rules.slice(0, index), ...additions, ...rules.slice(index)]
}
/** 上移或下移一条规则。越界时原样返回。 */
export function moveRule(rules: string[], index: number, delta: -1 | 1): string[] {
const to = index + delta
if (index < 0 || index >= rules.length || to < 0 || to >= rules.length) return rules
const next = [...rules]
next[index] = rules[to]!
next[to] = rules[index]!
return next
}
/** 一条规则的断引用:目标出站不存在,或者 RULE-SET 引用的规则组不存在。 */
export interface RuleIssue {
index: number
kind: 'target' | 'rule-set'
name: string
}
/**
* 找出引用了不存在的出站或规则组的规则。
*
* 内核自带的校验命令不检查这类引用,只在有流量经过时才报错,所以保存前的自检
* (vendor 的 validateDocument)和界面上的标注都要自己查。SUB-RULE 的目标段是
* 子规则集合名而不是出站,跳过——那是路由页的段落,这里不认。
*/
export function findRuleIssues(
rules: string[],
outboundNames: ReadonlySet<string>,
providerNames: ReadonlySet<string>,
): RuleIssue[] {
const issues: RuleIssue[] = []
rules.forEach((rule, index) => {
const parsed = parseRule(rule)
const type = parsed.type.toUpperCase()
if (type === 'RULE-SET' && parsed.payload !== null && !providerNames.has(parsed.payload)) {
issues.push({ index, kind: 'rule-set', name: parsed.payload })
}
if (type !== 'SUB-RULE' && parsed.target !== null && !outboundNames.has(parsed.target)) {
issues.push({ index, kind: 'target', name: parsed.target })
}
})
return issues
}
/**
* 「内网直连」预设:回环、私网和链路本地地址一律 DIRECT。
*
* no-resolve 是这类规则的固定尾巴:它们按目标 IP 匹配,让内核先做一次 DNS 解析
* 才能匹配是白忙一场,还会拖慢纯 IP 的连接。
*/
export const LAN_DIRECT_RULES = [
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
'IP-CIDR,10.0.0.0/8,DIRECT,no-resolve',
'IP-CIDR,172.16.0.0/12,DIRECT,no-resolve',
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
'IP-CIDR,169.254.0.0/16,DIRECT,no-resolve',
'IP-CIDR6,::1/128,DIRECT,no-resolve',
'IP-CIDR6,fc00::/7,DIRECT,no-resolve',
'IP-CIDR6,fe80::/10,DIRECT,no-resolve',
] as const
/**
* 预设里还没出现在规则表里的那几条。
*
* 按「类型,载荷」判重而不是整行比对:用户手写的 `IP-CIDR,192.168.0.0/16,DIRECT`
* 没有 no-resolve 尾巴,语义上已经管住了这个网段,再塞一条带尾巴的是重复。
*/
export function missingLanRules(rules: string[]): string[] {
const covered = new Set(
rules.map((rule) => {
const parsed = parseRule(rule)
return `${parsed.type.toUpperCase()},${parsed.payload ?? ''}`
}),
)
return LAN_DIRECT_RULES.filter((preset) => {
const parsed = parseRule(preset)
return !covered.has(`${parsed.type},${parsed.payload ?? ''}`)
})
}
/** rules: 里引用了这个规则组的规则下标。删除规则组前拿它拦人。 */
export function ruleSetReferences(rules: string[], providerName: string): number[] {
const result: number[] = []
rules.forEach((rule, index) => {
const parsed = parseRule(rule)
if (parsed.type.toUpperCase() === 'RULE-SET' && parsed.payload === providerName) {
result.push(index)
}
})
return result
}
/** 配置里的 rule-providers 段。不是映射就当没有。 */
export function ruleProvidersOf(data: ConfigObject): Record<string, ConfigObject> {
const value = data['rule-providers']
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
return value as Record<string, ConfigObject>
}
/** 直接改传入的对象——调用方先 clone。providers 空了就把整个键去掉,不留空映射。 */
export function setRuleProviders(data: ConfigObject, providers: Record<string, ConfigObject>): void {
if (Object.keys(providers).length === 0) delete data['rule-providers']
else data['rule-providers'] = providers
}
/** 配置里的分流规则表。不是数组就当没有;非字符串的条目自检会报,这里不呈现。 */
export function rulesOf(data: ConfigObject): string[] {
if (!Array.isArray(data.rules)) return []
return data.rules.filter((rule): rule is string => typeof rule === 'string')
}
/** 同上。规则表空了就删掉 rules 键,不留空数组。 */
export function setRules(data: ConfigObject, rules: string[]): void {
if (rules.length === 0) delete data.rules
else data.rules = rules
}
/** 给文件名和规则组名去重用。 */
function safeName(text: string): string {
return text.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '')
}
/** FNV-1a。只用来给名字去重,不是安全用途。 */
function shortHash(text: string): string {
let h = 0x811c9dc5
for (let i = 0; i < text.length; i += 1) {
h ^= text.charCodeAt(i)
h = Math.imul(h, 0x01000193) >>> 0
}
return h.toString(16).padStart(8, '0')
}
/**
* 一个 http 规则组的缓存文件落在哪。
*
* 不写 path 时内核按 url 哈希自己生成一个(rules/provider/parse.go:51),那个路径
* 这里算不出来,所以补一个确定的。扩展名跟着格式走,翻目录时看得出里面是什么。
*/
export function ruleProviderPath(name: string, format: string): string {
const ext = format === 'mrs' ? 'mrs' : format === 'text' ? 'txt' : 'yaml'
return `./providers/rules/${safeName(name) || shortHash(name)}.${ext}`
}
/** 清单格式按来源的扩展名推断。拿不准时回到 yaml——那是内核没写 format 时的默认。 */
export function inferRuleFormat(source: string): 'yaml' | 'text' | 'mrs' {
const clean = (source.split(/[?#]/)[0] ?? '').toLowerCase()
if (clean.endsWith('.mrs')) return 'mrs'
if (clean.endsWith('.list') || clean.endsWith('.txt')) return 'text'
return 'yaml'
}
/** ini 里一行 ruleset= 解析出来的东西。 */
export interface IniRuleset {
/** ini 里的目标组名(「🎯 全球直连」这类),导入时由用户映射到实际出站 */
target: string
kind: 'url' | 'file' | 'geoip' | 'geosite' | 'final'
/** kind 为 url/file 时是来源,为 geoip/geosite 时是类别名,final 时为空 */
source: string
}
/**
* 解析 subconverter ini 里的 ruleset= 行。
*
* 以 ; 和 # 开头的是 ini 的注释,跳过。custom_proxy_group= 是 subconverter 自己的
* 建组指令,和本项目的专属组体系冲突,不在解析范围内。
* 来源以 [] 开头的是内联规则而不是清单:[]GEOIP,CN 这类直接生成规则,不建规则组;
* []FINAL(以及 []MATCH)是兜底规则。
*/
export function parseIniRulesets(text: string): IniRuleset[] {
const result: IniRuleset[] = []
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim()
if (trimmed === '' || trimmed.startsWith(';') || trimmed.startsWith('#')) continue
const match = /^ruleset\s*=\s*(.+)$/i.exec(trimmed)
if (!match) continue
const body = match[1]!
const comma = body.indexOf(',')
if (comma < 0) continue
const target = body.slice(0, comma).trim()
const source = body.slice(comma + 1).trim()
if (target === '' || source === '') continue
if (source.startsWith('[]')) {
const inline = source.slice(2).trim()
const geo = /^(GEOIP|GEOSITE)\s*,\s*(.+)$/i.exec(inline)
if (geo) {
result.push({
target,
kind: geo[1]!.toUpperCase() === 'GEOIP' ? 'geoip' : 'geosite',
source: geo[2]!.trim(),
})
} else if (inline.toUpperCase() === 'FINAL' || inline.toUpperCase() === 'MATCH') {
result.push({ target, kind: 'final', source: '' })
}
// 其它 [] 写法([]REJECT 这类)认不出来,跳过比生成一条错规则好
continue
}
result.push({ target, kind: /^https?:\/\//i.test(source) ? 'url' : 'file', source })
}
return result
}
/** ini 导入的产物:一批规则组声明,加一批新规则。 */
export interface IniImportPlan {
providers: Record<string, ConfigObject>
/** 普通规则,插在第一条 MATCH 之前 */
rules: string[]
/** ini 里 []FINAL 生成的兜底规则,加在整张表末尾 */
finals: string[]
}
/**
* 把 ini 的 ruleset 行变成规则组声明和规则。
*
* 每个来源生成一个规则组,名字取来源的文件名(去扩展名),和已有的、以及本次
* 已生成的撞名时加序号。ACL4SSR 的清单是 classical 行为的 text 格式,行为按
* classical 声明,格式按扩展名推断。
* 目标组名由 mapping 逐个映射到实际出站,没映射的行跳过。
*/
export function planIniImport(
entries: IniRuleset[],
mapping: Readonly<Record<string, string>>,
existingProviders: ReadonlySet<string>,
): IniImportPlan {
const providers: Record<string, ConfigObject> = {}
const rules: string[] = []
const finals: string[] = []
const taken = new Set(existingProviders)
/** 同一行 ruleset 重复出现时共用一个名字,不重复声明。 */
const nameBySource = new Map<string, string>()
const nameFor = (source: string): string => {
const known = nameBySource.get(source)
if (known) return known
const base = (source.split(/[?#]/)[0] ?? '').split(/[\\/]/).pop() ?? ''
const stem = safeName(base.replace(/\.(list|txt|yaml|yml|mrs)$/i, '')) || 'rules'
let name = stem
for (let i = 2; taken.has(name); i += 1) name = `${stem}-${String(i)}`
taken.add(name)
nameBySource.set(source, name)
return name
}
for (const entry of entries) {
const target = mapping[entry.target]
if (!target) continue
if (entry.kind === 'geoip') {
rules.push(`GEOIP,${entry.source},${target}`)
continue
}
if (entry.kind === 'geosite') {
rules.push(`GEOSITE,${entry.source},${target}`)
continue
}
if (entry.kind === 'final') {
finals.push(`MATCH,${target}`)
continue
}
const format = inferRuleFormat(entry.source)
const name = nameFor(`${entry.kind}:${entry.source}`)
if (!providers[name]) {
providers[name] =
entry.kind === 'url'
? {
type: 'http',
behavior: 'classical',
url: entry.source,
path: ruleProviderPath(name, format),
format,
interval: 86400,
}
: { type: 'file', behavior: 'classical', path: entry.source, format }
}
rules.push(`RULE-SET,${name},${target}`)
}
return { providers, rules, finals }
}