Files
im0o 48e97bd91f 规则编辑:建议列表全量展开,目标补齐候选,删除免确认
组合框展开先给全量、本次展开输入过才过滤——按当前值过滤的话,预填的
类型会把列表滤到只剩它自己,MATCH 这类排在后面的选项等于不存在。类型
因此可以默认留空,placeholder 当示例。

目标建议由页面传入:全局规则给内置两个加用户自建的组和自有线路;附加
规则多一项本路专属组——「这个载荷走本路代理」写的就是它,放在拦截规则
前面还能做豁免。生成的专属组不列,每路三个会把列表灌成一面墙。

原文/字段切换只拦真正整行编辑的类型(isRawOnlyRule):之前用
isStructuredEditable 拦,新增时的空原文永远过不了,卡死在原文模式。

删除规则和附加规则不再二次确认——删错了有撤回,确认框只是多一步;
toast 带上被删规则的原文。
2026-08-12 13:16:58 +08:00

411 lines
16 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.
/**
* 分流规则与规则组(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] ?? ''
const upper = type.toUpperCase()
if (upper === 'MATCH') {
return { type, payload: null, target: fields[1] ?? null, extras: fields.slice(2) }
}
// 载荷可含顶层逗号的类型,内核把最后一段读成目标、其余全归载荷、不许带选项
// (rules/common/base.go:47-79)。按普通形状拆会把正则截一半当目标。
if (RAW_ONLY_TYPES.has(upper) && fields.length >= 3) {
return { type, payload: fields.slice(1, -1).join(','), target: fields.at(-1) ?? null, extras: [] }
}
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(',')
}
/**
* 界面能按「类型,载荷,目标」结构化编辑的规则。
*
* 这份清单必须和内核 `rules/common/base.go:56` 的 `payloadCanContainComma` 一致:
* 那些类型内核按「载荷可含逗号、最后一段是目标、不许带选项」读,按普通形状拆的话,
* 正则里的顶层逗号会被当成字段分隔,显示错的目标、编辑后悄悄改变语义。
*/
const RAW_ONLY_TYPES = new Set([
'AND',
'OR',
'NOT',
'SUB-RULE',
'DOMAIN-REGEX',
'PROCESS-NAME-REGEX',
'PROCESS-PATH-REGEX',
])
/** 这条规则是不是只能按原文整行编辑的类型(逻辑规则、载荷可含逗号的正则类)。 */
export function isRawOnlyRule(rule: string): boolean {
return RAW_ONLY_TYPES.has(parseRule(rule).type.toUpperCase())
}
export function isStructuredEditable(rule: string): boolean {
if (isRawOnlyRule(rule)) return false
return parseRule(rule).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
}
/**
* 找出引用了不存在的出站或规则组的规则。
*
* 内核在配置解析时就会拒绝这类引用(config/config.go:1102-1118),坏配置最终会被
* 校验重载链拦下并还原;这里的标注是为了让人在保存前就看见、改得快,而 vendor 自检
* 对规则目标只给 warning 不拦保存。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
}
/**
* sub-rules 各表(含每路入站的附加规则)里引用这个规则组的规则原文。
* 删除规则组前和顶层的一起查——漏了的话删除会被内核重载拒掉,用户看到的
* 是一段原始解析错误而不是「谁在引用它」。
*/
export function subRuleSetReferences(data: ConfigObject, providerName: string): string[] {
const tables = data['sub-rules']
if (typeof tables !== 'object' || tables === null || Array.isArray(tables)) return []
const hits: string[] = []
for (const rules of Object.values(tables)) {
if (!Array.isArray(rules)) continue
for (const rule of rules) {
if (typeof rule !== 'string') continue
const parsed = parseRule(rule)
if (parsed.type.toUpperCase() === 'RULE-SET' && parsed.payload === providerName) {
hits.push(rule)
}
}
}
return hits
}
/** 配置里的 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}`
}
/**
* 新规则组的清单路径是否和现有规则组的撞车。
*
* 名字查重挡不住这种:路径由名字净化而来,「广告 A」和「广告/A」是两个名字、
* 同一个清单文件,互相覆盖。返回撞上的那个规则组名,没撞返回 null。
*/
export function ruleProviderPathConflict(
data: ConfigObject,
selfName: string,
path: string,
): string | null {
for (const [name, decl] of Object.entries(ruleProvidersOf(data))) {
if (name === selfName) continue
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
if (decl.path === path) return name
}
return null
}
/** 清单格式按来源的扩展名推断。拿不准时回到 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
/** geoip/geosite 类别后面的附加参数(no-resolve 这类)。生成规则时放在目标之后 */
flags?: 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) {
// []GEOIP,CN,no-resolve:第二段才是类别,再往后是 no-resolve 这类附加参数。
// 附加参数在 mihomo 规则里的位置是目标之后,混进类别名会生成字段错位的规则
const fields = geo[2]!
.split(',')
.map((field) => field.trim())
.filter(Boolean)
const [category, ...flags] = fields
if (!category) continue
result.push({
target,
kind: geo[1]!.toUpperCase() === 'GEOIP' ? 'geoip' : 'geosite',
source: category,
...(flags.length > 0 ? { flags } : {}),
})
} 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' || entry.kind === 'geosite') {
const type = entry.kind === 'geoip' ? 'GEOIP' : 'GEOSITE'
rules.push([type, entry.source, target, ...(entry.flags ?? [])].join(','))
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 }
}