diff --git a/shared/rules.test.ts b/shared/rules.test.ts new file mode 100644 index 0000000..5a14d02 --- /dev/null +++ b/shared/rules.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'bun:test' +import { + buildRule, + findRuleIssues, + firstMatchIndex, + inferRuleFormat, + insertBeforeMatch, + isStructuredEditable, + missingLanRules, + moveRule, + parseIniRulesets, + parseRule, + planIniImport, + ruleProviderPath, + ruleSetReferences, + setRuleProviders, + setRules, +} from './rules.ts' +import type { ConfigObject } from '../vendor/config-editor/index.ts' + +describe('规则字符串的拆分与拼装', () => { + it('常见的三段规则', () => { + expect(parseRule('DOMAIN-SUFFIX,youtube.com,PROXY')).toEqual({ + type: 'DOMAIN-SUFFIX', + payload: 'youtube.com', + target: 'PROXY', + extras: [], + }) + }) + + it('MATCH 没有载荷', () => { + expect(parseRule('MATCH,PROXY')).toEqual({ + type: 'MATCH', + payload: null, + target: 'PROXY', + extras: [], + }) + }) + + it('选项尾部原样保留', () => { + const parsed = parseRule('IP-CIDR,127.0.0.0/8,DIRECT,no-resolve') + expect(parsed.extras).toEqual(['no-resolve']) + // 改目标不能丢掉选项 + expect(buildRule({ ...parsed, target: 'REJECT' })).toBe('IP-CIDR,127.0.0.0/8,REJECT,no-resolve') + }) + + it('拆开再拼回原样', () => { + for (const rule of [ + 'DOMAIN-SUFFIX,youtube.com,PROXY', + 'MATCH,PROXY', + 'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve', + 'RULE-SET,ads,REJECT', + 'AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY', + ]) { + expect(buildRule(parseRule(rule))).toBe(rule) + } + }) + + it('逻辑规则的载荷里有逗号也不被拆开', () => { + const parsed = parseRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY') + expect(parsed.payload).toBe('((DOMAIN-SUFFIX,a.com),(DST-PORT,443))') + expect(parsed.target).toBe('PROXY') + }) + + it('结构化编辑只给常见形状,逻辑规则和子规则按原文编辑', () => { + expect(isStructuredEditable('DOMAIN-SUFFIX,youtube.com,PROXY')).toBe(true) + expect(isStructuredEditable('MATCH,PROXY')).toBe(true) + expect(isStructuredEditable('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(false) + expect(isStructuredEditable('SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1')).toBe(false) + expect(isStructuredEditable('孤零零一段')).toBe(false) + }) +}) + +describe('规则表的顺序', () => { + const rules = ['DOMAIN-SUFFIX,a.com,DIRECT', 'MATCH,PROXY', 'DOMAIN-SUFFIX,b.com,REJECT'] + + it('第一条 MATCH 之后的规则不可达', () => { + expect(firstMatchIndex(rules)).toBe(1) + expect(firstMatchIndex(['DOMAIN-SUFFIX,a.com,DIRECT'])).toBe(-1) + }) + + it('新规则插在第一条 MATCH 之前', () => { + expect(insertBeforeMatch(rules, ['GEOIP,CN,DIRECT'])).toEqual([ + 'DOMAIN-SUFFIX,a.com,DIRECT', + 'GEOIP,CN,DIRECT', + 'MATCH,PROXY', + 'DOMAIN-SUFFIX,b.com,REJECT', + ]) + }) + + it('没有 MATCH 时加在末尾', () => { + expect(insertBeforeMatch(['GEOIP,CN,DIRECT'], ['MATCH,PROXY'])).toEqual([ + 'GEOIP,CN,DIRECT', + 'MATCH,PROXY', + ]) + }) + + it('上移下移只交换相邻两条,其余不动', () => { + expect(moveRule(rules, 0, 1)).toEqual([ + 'MATCH,PROXY', + 'DOMAIN-SUFFIX,a.com,DIRECT', + 'DOMAIN-SUFFIX,b.com,REJECT', + ]) + expect(moveRule(rules, 0, -1)).toBe(rules) + expect(moveRule(rules, 2, 1)).toBe(rules) + }) +}) + +describe('断引用', () => { + it('目标出站不存在和规则组不存在都标出来', () => { + const rules = [ + 'DOMAIN-SUFFIX,a.com,NOPE', + 'RULE-SET,ads,REJECT', + 'RULE-SET,gone,DIRECT', + 'SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1', + 'MATCH,PROXY', + ] + const issues = findRuleIssues(rules, new Set(['PROXY', 'REJECT', 'DIRECT']), new Set(['ads'])) + expect(issues).toEqual([ + { index: 0, kind: 'target', name: 'NOPE' }, + { index: 2, kind: 'rule-set', name: 'gone' }, + ]) + }) +}) + +describe('内网直连预设', () => { + it('空表全缺', () => { + expect(missingLanRules([])).toHaveLength(8) + }) + + it('已管住的网段不重复加,哪怕写法不完全一样', () => { + const existing = ['IP-CIDR,192.168.0.0/16,DIRECT', 'MATCH,PROXY'] + const missing = missingLanRules(existing) + expect(missing).toHaveLength(7) + expect(missing.some((r) => r.includes('192.168.0.0/16'))).toBe(false) + }) +}) + +describe('删除规则组前找引用', () => { + it('列出 rules 里引用它的规则下标', () => { + const rules = ['RULE-SET,ads,REJECT', 'DOMAIN-SUFFIX,a.com,DIRECT', 'RULE-SET,ads,PROXY'] + expect(ruleSetReferences(rules, 'ads')).toEqual([0, 2]) + expect(ruleSetReferences(rules, 'other')).toEqual([]) + }) +}) + +describe('配置形状', () => { + it('rules 空了删掉整个键', () => { + const data: ConfigObject = { rules: ['MATCH,PROXY'] } + setRules(data, ['A,B,C']) + expect(data.rules).toEqual(['A,B,C']) + setRules(data, []) + expect('rules' in data).toBe(false) + }) + + it('rule-providers 空了删掉整个键', () => { + const data: ConfigObject = {} + setRuleProviders(data, { ads: { type: 'http' } }) + expect(Object.keys(data['rule-providers'] as ConfigObject)).toEqual(['ads']) + setRuleProviders(data, {}) + expect('rule-providers' in data).toBe(false) + }) +}) + +describe('ini 的 ruleset= 行解析', () => { + const ini = ` +[custom] +; 注释行不算 +;ruleset=🎯 全球直连,[]GEOIP,LAN +ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list +ruleset=🛑 全球拦截,https://example.com/BanAD.list?token=1 +ruleset=🎯 全球直连,https://example.com/GoogleCN.list +ruleset=🍎 苹果服务,Apple.list +ruleset=🎯 全球直连,[]GEOIP,CN +ruleset=🐟 漏网之鱼,[]FINAL +custom_proxy_group=🚀 节点选择\`select\`[]AUTO +` + + it('URL、本地路径、内联 GEOIP 和 FINAL 都认出来', () => { + expect(parseIniRulesets(ini)).toEqual([ + { target: '🎯 全球直连', kind: 'url', source: 'https://example.com/LocalAreaNetwork.list' }, + { target: '🛑 全球拦截', kind: 'url', source: 'https://example.com/BanAD.list?token=1' }, + { target: '🎯 全球直连', kind: 'url', source: 'https://example.com/GoogleCN.list' }, + { target: '🍎 苹果服务', kind: 'file', source: 'Apple.list' }, + { target: '🎯 全球直连', kind: 'geoip', source: 'CN' }, + { target: '🐟 漏网之鱼', kind: 'final', source: '' }, + ]) + }) + + it('custom_proxy_group 和残缺行不产生条目', () => { + expect(parseIniRulesets('ruleset=\nruleset=只有目标,\ncustom_proxy_group=x`select`y')).toEqual([]) + }) +}) + +describe('ini 导入计划', () => { + const entries = parseIniRulesets(` +ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list +ruleset=🎯 全球直连,https://example.com/GoogleCN.list +ruleset=🎯 全球直连,[]GEOIP,CN +ruleset=🐟 漏网之鱼,[]FINAL +`) + + it('每行一个规则组加一条规则骨架,FINAL 落在表末尾', () => { + const plan = planIniImport( + entries, + { '🎯 全球直连': 'DIRECT', '🐟 漏网之鱼': 'PROXY' }, + new Set(), + ) + expect(Object.keys(plan.providers)).toEqual(['LocalAreaNetwork', 'GoogleCN']) + expect(plan.providers.LocalAreaNetwork).toEqual({ + type: 'http', + behavior: 'classical', + url: 'https://example.com/LocalAreaNetwork.list', + path: './providers/rules/LocalAreaNetwork.txt', + format: 'text', + interval: 86400, + }) + expect(plan.rules).toEqual([ + 'RULE-SET,LocalAreaNetwork,DIRECT', + 'RULE-SET,GoogleCN,DIRECT', + 'GEOIP,CN,DIRECT', + ]) + expect(plan.finals).toEqual(['MATCH,PROXY']) + }) + + it('同一个来源出现多次只声明一个规则组', () => { + const plan = planIniImport( + [ + { target: '甲', kind: 'url', source: 'https://example.com/a.list' }, + { target: '乙', kind: 'url', source: 'https://example.com/a.list' }, + ], + { 甲: 'DIRECT', 乙: 'REJECT' }, + new Set(), + ) + expect(Object.keys(plan.providers)).toEqual(['a']) + expect(plan.rules).toEqual(['RULE-SET,a,DIRECT', 'RULE-SET,a,REJECT']) + }) + + it('和已有规则组撞名时加序号', () => { + const plan = planIniImport( + [{ target: '甲', kind: 'url', source: 'https://example.com/ads.list' }], + { 甲: 'REJECT' }, + new Set(['ads']), + ) + expect(Object.keys(plan.providers)).toEqual(['ads-2']) + expect(plan.rules).toEqual(['RULE-SET,ads-2,REJECT']) + }) + + it('本地路径生成 file 类型,path 就是来源本身', () => { + const plan = planIniImport( + [{ target: '甲', kind: 'file', source: 'D:\\lists\\ads.list' }], + { 甲: 'REJECT' }, + new Set(), + ) + expect(plan.providers['ads']).toEqual({ + type: 'file', + behavior: 'classical', + path: 'D:\\lists\\ads.list', + format: 'text', + }) + }) + + it('没映射目标组的行跳过', () => { + const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set()) + expect(plan.finals).toEqual([]) + expect(plan.rules).toHaveLength(3) + }) +}) + +describe('清单格式与缓存路径', () => { + it('格式按扩展名推断,拿不准回 yaml', () => { + expect(inferRuleFormat('https://example.com/a.list?token=1')).toBe('text') + expect(inferRuleFormat('https://example.com/a.txt')).toBe('text') + expect(inferRuleFormat('https://example.com/a.mrs')).toBe('mrs') + expect(inferRuleFormat('https://example.com/a.yaml')).toBe('yaml') + expect(inferRuleFormat('https://example.com/no-extension')).toBe('yaml') + }) + + it('缓存路径的扩展名跟着格式走', () => { + expect(ruleProviderPath('ads', 'text')).toBe('./providers/rules/ads.txt') + expect(ruleProviderPath('ads', 'mrs')).toBe('./providers/rules/ads.mrs') + expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml') + expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml') + }) +}) diff --git a/shared/rules.ts b/shared/rules.ts new file mode 100644 index 0000000..a0b23c3 --- /dev/null +++ b/shared/rules.ts @@ -0,0 +1,340 @@ +/** + * 分流规则与规则组(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 } +} diff --git a/web/api.ts b/web/api.ts index f222d19..8ecc5e2 100644 --- a/web/api.ts +++ b/web/api.ts @@ -121,6 +121,20 @@ export interface ListenerFields { types: Record } +export interface RuleProviderInfo { + /** domain / ipcidr / classical */ + behavior: string + /** yaml / text / mrs。inline 类型的没有这个字段 */ + format?: string + name: string + /** 当前装了多少条规则 */ + ruleCount: number + /** HTTP / File / Inline */ + vehicleType: string + /** RFC3339。还没拉取过时是零值(0001-01-01) */ + updatedAt: string +} + export const api = { readConfig: () => request('GET', '/api/config'), @@ -216,6 +230,23 @@ export const api = { refreshProvider: (name: string) => request('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`), + /** + * 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。 + * 配置文件里只有声明,这两个数字只有运行中的内核知道。 + */ + ruleProviders: () => + request<{ providers: Record }>('GET', '/api/kernel/providers/rules'), + + /** + * 让内核立刻重新拉取某个规则组。 + * 这是运行时动作,不写配置文件,所以不走落盘那条保护链路。 + */ + updateRuleProvider: (name: string) => + request>( + 'PUT', + `/api/kernel/providers/rules/${encodeURIComponent(name)}`, + ), + /** * 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。 * diff --git a/web/views/IniImportForm.vue b/web/views/IniImportForm.vue new file mode 100644 index 0000000..b0d099d --- /dev/null +++ b/web/views/IniImportForm.vue @@ -0,0 +1,326 @@ + + + + + diff --git a/web/views/RuleForm.vue b/web/views/RuleForm.vue new file mode 100644 index 0000000..9adcdb1 --- /dev/null +++ b/web/views/RuleForm.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/web/views/RuleGroupForm.vue b/web/views/RuleGroupForm.vue new file mode 100644 index 0000000..84b1b61 --- /dev/null +++ b/web/views/RuleGroupForm.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/web/views/RulesPage.vue b/web/views/RulesPage.vue new file mode 100644 index 0000000..eeea541 --- /dev/null +++ b/web/views/RulesPage.vue @@ -0,0 +1,799 @@ + + + + +