feat: opt ai group

This commit is contained in:
2026-10-03 19:33:03 +08:00
parent b95cb4a813
commit 7358008936
13 changed files with 382 additions and 8 deletions

View File

@@ -111,6 +111,9 @@ Speedtest = select, Mainland, Automatic, AllServer, no-alert=0, hidden=0, includ
[Rule]
# 阻断 STUN,避免未匹配请求落到直连兜底
PROTOCOL,STUN,REJECT
# >> 规则修正 (Unbreak Rules)
RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT

View File

@@ -147,6 +147,15 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
skippedRules.push(`${r.type},${r.value},${r.policy}`);
continue;
}
// Surge 的应用协议识别不能直接作为 mihomo 规则类型输出。
if (r.type === 'PROTOCOL') {
if (r.value === 'TCP' || r.value === 'UDP') {
ruleLines.push(`NETWORK,${r.value},${mapPolicy(r.policy)}`);
} else {
skippedRules.push(`${r.type},${r.value},${r.policy}`);
}
continue;
}
const policy = mapPolicy(r.policy);
if (r.ruleset) {
ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`);

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import YAML from 'yaml';
import { generateSurge } from './surge.js';
import { generateClash } from './clash.js';
import { generateShadowrocketConf } from './shadowrocket-conf.js';
import type { ProfileIR } from '../ir.js';
import { ruleInputSchema } from '@proxy-station/shared';
const ir: ProfileIR = {
nodes: [], groups: [], sections: {},
rules: [
{ type: 'PROTOCOL', value: 'DOH', policy: '🤖 AI', params: [], ruleset: null },
{ type: 'PROTOCOL', value: 'STUN', policy: 'REJECT', params: [], ruleset: null },
{ type: 'PROTOCOL', value: 'UDP', policy: 'REJECT', params: [], ruleset: null },
{ type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null },
],
};
describe('client-specific protocol rules', () => {
it('accepts privacy rules through the management API schema', () => {
for (const rule of ir.rules.slice(0, 2)) {
expect(ruleInputSchema.safeParse(rule).success).toBe(true);
}
});
it('preserves Surge DNS/STUN rules before FINAL', () => {
const { content } = generateSurge(ir, { selfUrl: 'https://example.com/sub/test/surge' });
expect(content).toContain('PROTOCOL,DOH,"🤖 AI"\nPROTOCOL,STUN,REJECT\nPROTOCOL,UDP,REJECT\nFINAL,DIRECT');
});
it('maps transport rules and skips Surge application protocols in Clash', () => {
const result = generateClash(ir, { convertBaseUrl: 'https://example.com/sub/test' });
expect(YAML.parse(result.content).rules).toEqual(['NETWORK,UDP,REJECT', 'MATCH,DIRECT']);
expect(result.skippedRules).toEqual(['PROTOCOL,DOH,🤖 AI', 'PROTOCOL,STUN,REJECT']);
});
it('does not emit Surge PROTOCOL rules in Shadowrocket', () => {
const result = generateShadowrocketConf(ir);
expect(result.content).not.toContain('PROTOCOL,');
expect(result.content).toContain('FINAL,DIRECT');
expect(result.skippedRules).toHaveLength(3);
});
});

View File

@@ -45,7 +45,7 @@ function shadowrocketGeneral(surgeGeneral: string): string {
}
/** ShadowRocket 不认识的规则类型 */
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX']);
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX', 'PROTOCOL']);
export interface ShadowrocketConfOutput {
content: string;

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { surgeAiDns } from './surge-ai-dns.js';
import { generateSurge } from './surge.js';
import { generateClash } from './clash.js';
import { generateShadowrocketConf } from './shadowrocket-conf.js';
import type { ProfileIR, RuleIR } from '../ir.js';
const rule = (type: string, value: string, policy = '🤖 AI'): RuleIR => ({ type, value, policy, params: [], ruleset: null });
const ir: ProfileIR = {
nodes: [],
groups: [{ name: '🤖 AI', type: 'select', testUrl: null, interval: null, tolerance: null, filterRegex: null, includeAllNodes: false, members: [{ kind: 'builtin', name: 'REJECT' }] }],
rules: [rule('DOMAIN', 'exact.example'), rule('DOMAIN-SUFFIX', 'ai.example'), rule('DOMAIN-KEYWORD', 'assistant'),
rule('RULE-SET', 'set-id'), rule('IP-CIDR', '192.0.2.0/24'), rule('DOMAIN', 'local.example', 'DIRECT')],
sections: { General: 'encrypted-dns-follow-outbound-mode = false\nencrypted-dns-server = https://doh.pub/dns-query, https://dns.alidns.com/dns-query\nhijack-dns = 8.8.8.8:53, 8.8.4.4:53', Host: 'local.example = server:223.5.5.5' },
};
ir.rules[3].ruleset = { id: 'set-id', name: 'ai', surgeUrl: 'https://example.com/ai.list', clashUrl: null, clashBehavior: 'classical', clashFormat: 'yaml', needsConvert: true };
describe('AI-only Surge DNS', () => {
it('derives exact, suffix, keyword and remote set mappings without changing normal DNS', () => {
const dns = surgeAiDns(ir);
for (const key of ['exact.example', 'ai.example', '*.ai.example', '*assistant*', 'RULE-SET:https://example.com/ai.list']) {
expect(dns.host).toContain(`${key} = server:https://1.1.1.1/dns-query`);
}
expect(dns.host).not.toContain('192.0.2.0/24');
expect(dns.host).not.toContain('local.example = server:https://');
expect(dns.host).toContain(ir.sections.Host);
expect(dns.general).toContain('encrypted-dns-follow-outbound-mode = true');
expect(dns.general).toContain('encrypted-dns-server = https://doh.pub/dns-query, https://dns.alidns.com/dns-query');
expect(dns.general).toContain('hijack-dns = 8.8.8.8:53, 8.8.4.4:53');
});
it('routes only Cloudflare through the same AI group and defaults directly before ordinary rules', () => {
const { content } = generateSurge(ir, { selfUrl: 'https://example.com/sub/test/surge' });
expect(content).toContain('[Rule]\nIP-CIDR,1.1.1.1/32,"🤖 AI",no-resolve\nDOMAIN,doh.pub,DIRECT\nDOMAIN,dns.alidns.com,DIRECT');
expect(content).not.toContain('PROTOCOL,DOH');
});
it('does not leak Surge-specific DNS mappings into other clients', () => {
expect(generateClash(ir, { convertBaseUrl: 'https://example.com' }).content).not.toContain('server:https://1.1.1.1');
expect(generateShadowrocketConf(ir).content).not.toContain('server:https://1.1.1.1');
});
it('removes derived DNS when AI is disabled or all its domain rules are removed', () => {
expect(surgeAiDns({ ...ir, groups: [] })).toEqual({ general: ir.sections.General, host: ir.sections.Host, rules: [] });
expect(surgeAiDns({ ...ir, rules: [] }).rules).toEqual([]);
});
it('supports the upstream AI group name, deduplicates mappings and honors changed policy', () => {
const base = { ...ir, groups: [{ ...ir.groups[0], name: 'AI' }], rules: [rule('DOMAIN', 'new.example', 'AI'), rule('DOMAIN', 'new.example', 'AI'), rule('DOMAIN', 'old.example', 'DIRECT')] };
const dns = surgeAiDns(base);
expect(dns.rules[0]).toBe('IP-CIDR,1.1.1.1/32,AI,no-resolve');
expect(dns.host.match(/new\.example =/g)).toHaveLength(1);
expect(dns.host).not.toContain('old.example');
});
});

View File

@@ -0,0 +1,46 @@
import type { ProfileIR } from '../ir.js';
const AI_DNS = 'https://1.1.1.1/dns-query';
/** Derive DNS coverage from the same enabled AI rules used for connection routing. */
export function surgeAiDns(ir: ProfileIR): { general: string; host: string; rules: string[] } {
const general = ir.sections.General ?? '';
const host = ir.sections.Host ?? '';
const ai = ir.groups.find((g) => /^[^\p{L}\p{N}]*AI$/u.test(g.name));
if (!ai) return { general, host, rules: [] };
const mappings = new Set<string>();
for (const rule of ir.rules) {
if (rule.policy !== ai.name || !rule.value) continue;
const value = rule.ruleset?.surgeUrl ?? rule.value;
switch (rule.type) {
case 'DOMAIN': mappings.add(value); break;
// *.example.com alone does not include the apex hostname in Surge Host syntax.
case 'DOMAIN-SUFFIX': mappings.add(value); mappings.add(`*.${value}`); break;
case 'DOMAIN-KEYWORD': mappings.add(`*${value}*`); break;
case 'RULE-SET':
case 'DOMAIN-SET':
if (/^https?:\/\//.test(value)) mappings.add(`${rule.type}:${value}`);
break;
}
}
if (!mappings.size) return { general, host, rules: [] };
const updatedGeneral = /^encrypted-dns-follow-outbound-mode\s*=/mi.test(general)
? general.replace(/^encrypted-dns-follow-outbound-mode\s*=.*$/gmi, 'encrypted-dns-follow-outbound-mode = true')
: `${general}\nencrypted-dns-follow-outbound-mode = true`;
const generatedHost = [...mappings].map((pattern) => `${pattern} = server:${AI_DNS}`).join('\n');
const name = /[\s,]/.test(ai.name) ? `"${ai.name}"` : ai.name;
return {
general: updatedGeneral,
// AI mappings take precedence over the template's ordinary per-domain DNS mappings.
host: `# AI DNS: generated from enabled AI routing rules\n${generatedHost}\n${host}`.trim(),
rules: [
`IP-CIDR,1.1.1.1/32,${name},no-resolve`,
// Keep the upstream template's default DoH on the local path.
'DOMAIN,doh.pub,DIRECT',
'DOMAIN,dns.alidns.com,DIRECT',
],
};
}

View File

@@ -1,5 +1,6 @@
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
import type { ProfileIR } from '../ir.js';
import { surgeAiDns } from './surge-ai-dns.js';
/** 成员名含空格/逗号时按 Surge 习惯加引号 */
function quoteName(name: string): string {
@@ -56,6 +57,7 @@ export interface SurgeOutput {
}
export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput {
const dns = surgeAiDns(ir);
const excluded: ExcludedNode[] = [];
const usable = ir.nodes.filter((n) => {
const verdict = clientSupports('surge', n);
@@ -69,7 +71,7 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
lines.push('# 由 Proxy Station 生成,请勿手动编辑(改动会在下次更新时被覆盖)');
lines.push('');
lines.push('[General]');
lines.push(ir.sections['General'] ?? '');
lines.push(dns.general);
lines.push('', '[Proxy]');
for (const n of usable) lines.push(surgeNodeLine(n));
@@ -105,6 +107,7 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
}
lines.push('', '[Rule]');
lines.push(...dns.rules);
for (const r of ir.rules) {
if (r.type === 'FINAL') {
lines.push(['FINAL', quoteName(r.policy), ...r.params].join(','));
@@ -115,8 +118,9 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
}
for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
if (ir.sections[section]) {
lines.push('', `[${section}]`, ir.sections[section]);
const content = section === 'Host' ? dns.host : ir.sections[section];
if (content) {
lines.push('', `[${section}]`, content);
}
}