/** * 分流规则与规则组(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, providerNames: ReadonlySet, ): 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 { const value = data['rule-providers'] if (typeof value !== 'object' || value === null || Array.isArray(value)) return {} return value as Record } /** 直接改传入的对象——调用方先 clone。providers 空了就把整个键去掉,不留空映射。 */ export function setRuleProviders(data: ConfigObject, providers: Record): 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 /** 普通规则,插在第一条 MATCH 之前 */ rules: string[] /** ini 里 []FINAL 生成的兜底规则,加在整张表末尾 */ finals: string[] } /** * 把 ini 的 ruleset 行变成规则组声明和规则。 * * 每个来源生成一个规则组,名字取来源的文件名(去扩展名),和已有的、以及本次 * 已生成的撞名时加序号。ACL4SSR 的清单是 classical 行为的 text 格式,行为按 * classical 声明,格式按扩展名推断。 * 目标组名由 mapping 逐个映射到实际出站,没映射的行跳过。 */ export function planIniImport( entries: IniRuleset[], mapping: Readonly>, existingProviders: ReadonlySet, ): IniImportPlan { const providers: Record = {} const rules: string[] = [] const finals: string[] = [] const taken = new Set(existingProviders) /** 同一行 ruleset 重复出现时共用一个名字,不重复声明。 */ const nameBySource = new Map() 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 } }