feat: opt ai group
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
40
server/src/services/generators/protocol-rules.test.ts
Normal file
40
server/src/services/generators/protocol-rules.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
51
server/src/services/generators/surge-ai-dns.test.ts
Normal file
51
server/src/services/generators/surge-ai-dns.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
46
server/src/services/generators/surge-ai-dns.ts
Normal file
46
server/src/services/generators/surge-ai-dns.ts
Normal 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',
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user