组合框展开先给全量、本次展开输入过才过滤——按当前值过滤的话,预填的 类型会把列表滤到只剩它自己,MATCH 这类排在后面的选项等于不存在。类型 因此可以默认留空,placeholder 当示例。 目标建议由页面传入:全局规则给内置两个加用户自建的组和自有线路;附加 规则多一项本路专属组——「这个载荷走本路代理」写的就是它,放在拦截规则 前面还能做豁免。生成的专属组不列,每路三个会把列表灌成一面墙。 原文/字段切换只拦真正整行编辑的类型(isRawOnlyRule):之前用 isStructuredEditable 拦,新增时的空原文永远过不了,卡死在原文模式。 删除规则和附加规则不再二次确认——删错了有撤回,确认框只是多一步; toast 带上被删规则的原文。
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
/**
|
||
* 分流规则与规则组(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 }
|
||
}
|