fix: 完善移动端订阅生成并同步分流配置文档
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import YAML from 'yaml';
|
||||
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR, RuleIR } from '../ir.js';
|
||||
import { clientSupports, normalizeSsCipher, splitRuleExpression, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
|
||||
export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = { name: n.name, server: n.server, port: n.port };
|
||||
const base: Record<string, unknown> = {
|
||||
name: n.name, server: n.server, port: n.port,
|
||||
// SIP003 的 WS 插件只转发 TCP,不能把 UDP 发往 nginx 的同端口。
|
||||
udp: !(n.protocol === 'shadowsocks' && n.network === 'ws'),
|
||||
};
|
||||
switch (n.protocol) {
|
||||
case 'vmess': {
|
||||
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher: n.security || 'auto' });
|
||||
const cipher = n.security === 'chacha20-ietf-poly1305' ? 'chacha20-poly1305' : n.security || 'auto';
|
||||
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher });
|
||||
if (n.tls) {
|
||||
base.tls = true;
|
||||
if (n.sni) base.servername = n.sni;
|
||||
if (n.skipCertVerify) base['skip-cert-verify'] = true;
|
||||
}
|
||||
if (n.network === 'ws') {
|
||||
base.network = 'ws';
|
||||
@@ -42,6 +48,7 @@ export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||||
tls: n.tls,
|
||||
host: n.wsHost || n.sni || n.server,
|
||||
path: n.wsPath,
|
||||
...(n.skipCertVerify ? { 'skip-cert-verify': true } : {}),
|
||||
};
|
||||
}
|
||||
break;
|
||||
@@ -68,9 +75,39 @@ function mapPolicy(policy: string): string {
|
||||
return policy;
|
||||
}
|
||||
|
||||
/** Surge 逻辑规则里的子类型 → mihomo 命名 */
|
||||
function mapLogicValue(value: string): string {
|
||||
return value.replace(/\bPROTOCOL\b/g, 'NETWORK');
|
||||
/** 逐个转换逻辑子规则;任何不可表达的子条件使整条规则跳过,避免扩大匹配范围。 */
|
||||
function mapLogicValue(value: string): string | null {
|
||||
value = value.trim();
|
||||
if (!value.startsWith('(') || !value.endsWith(')')) return null;
|
||||
const children = splitRuleExpression(value.slice(1, -1));
|
||||
if (!children?.length) return null;
|
||||
const mapped: string[] = [];
|
||||
for (const child of children) {
|
||||
if (!child.startsWith('(') || !child.endsWith(')')) return null;
|
||||
const parts = splitRuleExpression(child.slice(1, -1));
|
||||
if (!parts || parts.length < 2) return null;
|
||||
let [type, payload] = parts;
|
||||
if (['AND', 'OR', 'NOT'].includes(type)) {
|
||||
const nested = mapLogicValue(payload);
|
||||
if (!nested) return null;
|
||||
payload = nested;
|
||||
} else if (type === 'PROTOCOL') {
|
||||
if (payload !== 'TCP' && payload !== 'UDP') return null;
|
||||
type = 'NETWORK';
|
||||
} else {
|
||||
const types: Record<string, string> = {
|
||||
DOMAIN: 'DOMAIN', 'DOMAIN-SUFFIX': 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD': 'DOMAIN-KEYWORD',
|
||||
'DOMAIN-WILDCARD': 'DOMAIN-WILDCARD', 'IP-CIDR': 'IP-CIDR', 'IP-CIDR6': 'IP-CIDR6',
|
||||
'IP-ASN': 'IP-ASN', GEOIP: 'GEOIP', 'PROCESS-NAME': 'PROCESS-NAME',
|
||||
'SRC-IP': 'SRC-IP-CIDR', 'DEST-PORT': 'DST-PORT', 'SRC-PORT': 'SRC-PORT',
|
||||
};
|
||||
if (!types[type] || /["'\\]/.test(payload)) return null;
|
||||
type = types[type];
|
||||
}
|
||||
if (parts.slice(2).some((p) => p !== 'no-resolve')) return null;
|
||||
mapped.push(`(${[type, payload, ...parts.slice(2)].join(',')})`);
|
||||
}
|
||||
return `(${mapped.join(',')})`;
|
||||
}
|
||||
|
||||
const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']);
|
||||
@@ -100,17 +137,17 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
if (g.filterRegex) {
|
||||
group['include-all-proxies'] = true;
|
||||
group.filter = g.filterRegex;
|
||||
if (members.length) group.proxies = [...new Set(members)];
|
||||
} else if (g.includeAllNodes) {
|
||||
group['include-all-proxies'] = true;
|
||||
const extra = members.filter((m) => !usableNames.has(m));
|
||||
if (extra.length) group.proxies = extra;
|
||||
if (members.length) group.proxies = [...new Set(members)];
|
||||
} else {
|
||||
group.proxies = members.length ? members : ['DIRECT'];
|
||||
}
|
||||
if (g.type !== 'select') {
|
||||
group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204';
|
||||
group.interval = g.interval ?? 300;
|
||||
if (g.tolerance) group.tolerance = g.tolerance;
|
||||
if (g.type === 'url-test' && g.tolerance !== null) group.tolerance = g.tolerance;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
@@ -158,7 +195,7 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
}
|
||||
const policy = mapPolicy(r.policy);
|
||||
if (r.ruleset) {
|
||||
ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`);
|
||||
ruleLines.push(['RULE-SET', providerNameByRulesetId.get(r.ruleset.id), policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
|
||||
continue;
|
||||
}
|
||||
// Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过
|
||||
@@ -171,7 +208,9 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
continue;
|
||||
}
|
||||
if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') {
|
||||
ruleLines.push(`${r.type},${mapLogicValue(r.value || '')},${policy}`);
|
||||
const value = mapLogicValue(r.value || '');
|
||||
if (value) ruleLines.push([r.type, value, policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
|
||||
else skippedRules.push(`${r.type},${r.value},${r.policy}`);
|
||||
continue;
|
||||
}
|
||||
const params = r.params.filter((p) => p === 'no-resolve');
|
||||
|
||||
148
server/src/services/generators/mobile-clients.test.ts
Normal file
148
server/src/services/generators/mobile-clients.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import YAML from 'yaml';
|
||||
import type { NodeDto } from '@proxy-station/shared';
|
||||
import type { GroupIR, ProfileIR, RuleIR } from '../ir.js';
|
||||
import { generateClash, clashProxy } from './clash.js';
|
||||
import { generateShadowrocket } from './shadowrocket.js';
|
||||
import { generateShadowrocketConf } from './shadowrocket-conf.js';
|
||||
import { parseShareLink } from '../sharelink/parse.js';
|
||||
|
||||
const node: NodeDto = {
|
||||
id: 'placeholder', name: '🇯🇵 JP VMess Direct', protocol: 'vmess',
|
||||
server: 'node.example.com', port: 443, uuid: '00000000-1111-2222-3333-444444444444',
|
||||
password: 'placeholder:password', method: 'chacha20-poly1305', network: 'ws',
|
||||
tls: true, sni: 'node.example.com', skipCertVerify: false, wsHost: 'node.example.com', wsPath: '/ws',
|
||||
alterId: 0, security: 'auto', obfsType: null, obfsPassword: null, upMbps: null, downMbps: null,
|
||||
region: null, tags: [], enabled: true, sortOrder: 0, createdAt: 0, updatedAt: 0,
|
||||
};
|
||||
const group: GroupIR = {
|
||||
name: 'Filtered', type: 'url-test', testUrl: 'https://example.com/generate_204',
|
||||
interval: 300, tolerance: 0, filterRegex: '^🇯🇵', includeAllNodes: true,
|
||||
members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'node', name: node.name }, { kind: 'group', name: 'Source' }],
|
||||
};
|
||||
const final: RuleIR = { type: 'FINAL', value: null, policy: 'DIRECT', params: ['dns-failed'], ruleset: null };
|
||||
const base: ProfileIR = { nodes: [node], groups: [], rules: [final], sections: {} };
|
||||
const generate = (ir: ProfileIR) => generateClash(ir, { convertBaseUrl: 'https://example.com/sub/placeholder' });
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-mobile-'));
|
||||
const cli = process.env.MIHOMO_CLI ?? 'mihomo';
|
||||
const nativeAvailable = spawnSync(cli, ['-v'], { timeout: 10000 }).status === 0;
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
describe('mobile client node subscriptions', () => {
|
||||
it('sorts complete Shadowrocket names by string, retains all four protocols and does not mutate IR', () => {
|
||||
const names = ['🇸🇬 SG VMess Warp', '🇯🇵 JP Trojan Warp', '🇺🇸 US LA SS Direct',
|
||||
'🇸🇬 SG VMess Direct', '🇯🇵 JP HY2 Direct', '🇯🇵 JP Trojan Direct'];
|
||||
const protocols: NodeDto['protocol'][] = ['vmess', 'trojan', 'shadowsocks', 'vmess', 'hysteria2', 'trojan'];
|
||||
const nodes = names.map((name, i) => ({ ...node, name, protocol: protocols[i],
|
||||
...(protocols[i] === 'hysteria2' ? { network: 'tcp' as const, wsHost: null, wsPath: null,
|
||||
obfsType: 'salamander', obfsPassword: 'placeholder-obfs' } : {}) }));
|
||||
const output = generateShadowrocket({ ...base, nodes });
|
||||
const uris = Buffer.from(output.content, 'base64').toString('utf8').split('\n');
|
||||
expect(uris).toEqual(output.uris);
|
||||
expect(uris.map((uri) => parseShareLink(uri).name)).toEqual([...names].sort());
|
||||
expect(nodes.map((n) => n.name)).toEqual(names);
|
||||
expect(output.excluded).toEqual([]);
|
||||
for (const uri of uris) {
|
||||
const parsed = parseShareLink(uri);
|
||||
const original = nodes.find((n) => n.name === parsed.name)!;
|
||||
expect(parsed).toMatchObject({ name: original.name, protocol: original.protocol,
|
||||
server: original.server, port: original.port, network: original.network,
|
||||
tls: original.tls, sni: original.sni });
|
||||
if (original.protocol === 'vmess') expect(parsed.uuid).toBe(original.uuid);
|
||||
else expect(parsed.password).toBe(original.password);
|
||||
if (original.protocol === 'hysteria2') expect(parsed.obfsPassword).toBe(original.obfsPassword);
|
||||
}
|
||||
});
|
||||
|
||||
it('enables supported Clash UDP transports, keeps SS WS TCP-only, and preserves TLS flags', () => {
|
||||
expect(clashProxy({ ...node, skipCertVerify: true })).toMatchObject({ type: 'vmess', udp: true,
|
||||
tls: true, servername: node.sni, 'skip-cert-verify': true, 'ws-opts': { headers: { Host: node.wsHost } } });
|
||||
expect(clashProxy({ ...node, protocol: 'trojan' })).toMatchObject({ type: 'trojan', udp: true, sni: node.sni });
|
||||
expect(clashProxy({ ...node, protocol: 'shadowsocks', skipCertVerify: true })).toMatchObject({
|
||||
type: 'ss', udp: false, cipher: 'chacha20-ietf-poly1305', plugin: 'v2ray-plugin',
|
||||
'plugin-opts': { mode: 'websocket', tls: true, path: '/ws', 'skip-cert-verify': true },
|
||||
});
|
||||
expect(clashProxy({ ...node, protocol: 'shadowsocks', network: 'tcp' }).udp).toBe(true);
|
||||
expect(clashProxy({ ...node, protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander',
|
||||
obfsPassword: 'placeholder-obfs', upMbps: 20, downMbps: 100 })).toMatchObject({ type: 'hysteria2',
|
||||
udp: true, obfs: 'salamander', 'obfs-password': 'placeholder-obfs', up: '20 Mbps', down: '100 Mbps' });
|
||||
});
|
||||
|
||||
it('maps the VMess cipher alias and reports unsupported nodes without leaving dangling group references', () => {
|
||||
expect(clashProxy({ ...node, security: 'chacha20-ietf-poly1305' }).cipher).toBe('chacha20-poly1305');
|
||||
const result = generate({ ...base, nodes: [{ ...node, security: 'unsupported-placeholder' }],
|
||||
groups: [{ ...group, filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }] });
|
||||
const doc = YAML.parse(result.content);
|
||||
expect(result.excluded).toEqual([{ name: node.name, reason: 'Clash 不支持该 VMess 加密方式:unsupported-placeholder' }]);
|
||||
expect(doc.proxies).toEqual([]);
|
||||
expect(doc['proxy-groups'][0].proxies).toEqual(['DIRECT']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile client groups and rule semantics', () => {
|
||||
it('preserves explicit members in dynamic groups, zero tolerance, and omits fallback tolerance', () => {
|
||||
const source = { ...group, name: 'Source', filterRegex: null, includeAllNodes: false,
|
||||
members: [{ kind: 'node' as const, name: node.name }] };
|
||||
const all = { ...group, name: 'All', filterRegex: null };
|
||||
const fallback = { ...group, name: 'Fallback', type: 'fallback' as const, tolerance: 50 };
|
||||
const ir = { ...base, groups: [source, group, all, fallback] };
|
||||
const doc = YAML.parse(generate(ir).content);
|
||||
expect(doc['proxy-groups'][1]).toMatchObject({ proxies: ['DIRECT', node.name, 'Source'],
|
||||
'include-all-proxies': true, filter: group.filterRegex, tolerance: 0, url: group.testUrl });
|
||||
expect(doc['proxy-groups'][2].proxies).toEqual(['DIRECT', node.name, 'Source']);
|
||||
expect(doc['proxy-groups'][3]).not.toHaveProperty('tolerance');
|
||||
const sr = generateShadowrocketConf(ir).content;
|
||||
expect(sr).toContain(`Filtered = url-test, DIRECT, ${node.name}, Source, url=${group.testUrl}, interval=300, tolerance=0, policy-regex-filter=^🇯🇵`);
|
||||
expect(sr.split('\n').find((l) => l.startsWith('Fallback ='))).not.toContain('tolerance=');
|
||||
expect(sr).not.toContain('use=');
|
||||
});
|
||||
|
||||
it('preserves provider no-resolve, handles duplicate provider names, keeps rule order and FINAL mapping', () => {
|
||||
const ruleset = { id: 'first', name: 'same', surgeUrl: 'https://example.com/ip.list', clashUrl: null,
|
||||
clashBehavior: 'classical', clashFormat: 'yaml', needsConvert: true };
|
||||
const rules: RuleIR[] = [
|
||||
{ type: 'RULE-SET', value: 'first', policy: 'REJECT-DROP', params: ['no-resolve'], ruleset },
|
||||
{ type: 'DOMAIN', value: 'example.com', policy: 'DIRECT', params: [], ruleset: null },
|
||||
{ type: 'RULE-SET', value: 'second', policy: 'DIRECT', params: [], ruleset: { ...ruleset, id: 'second' } }, final,
|
||||
];
|
||||
const doc = YAML.parse(generate({ ...base, rules }).content);
|
||||
expect(doc.rules).toEqual(['RULE-SET,same,REJECT-DROP,no-resolve', 'DOMAIN,example.com,DIRECT',
|
||||
'RULE-SET,same-2,DIRECT', 'MATCH,DIRECT']);
|
||||
expect(doc['rule-providers']['same-2'].url).toBe('https://example.com/sub/placeholder/ruleset/second.yaml');
|
||||
});
|
||||
|
||||
it('converts only transport subrules, preserves payload literals and skips whole unsupported logical expressions', () => {
|
||||
const values = ['((PROTOCOL,UDP),(DOMAIN-SUFFIX,PROTOCOL.example.com))',
|
||||
'((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DEST-PORT,443))',
|
||||
'((PROTOCOL,STUN),(DOMAIN,example.com))', '((PROTOCOL,DOH),(DOMAIN,example.com))',
|
||||
'((URL-REGEX,^https://example.com),(DOMAIN,example.com))',
|
||||
'((DOMAIN,example.com,extended-matching),(PROTOCOL,UDP))'];
|
||||
const result = generate({ ...base, rules: [...values.map((value): RuleIR => ({
|
||||
type: 'AND', value, policy: 'REJECT-NO-DROP', params: [], ruleset: null,
|
||||
})), final] });
|
||||
expect(YAML.parse(result.content).rules).toEqual([
|
||||
'AND,((NETWORK,UDP),(DOMAIN-SUFFIX,PROTOCOL.example.com)),REJECT',
|
||||
'AND,((OR,((NETWORK,TCP),(NETWORK,UDP))),(DST-PORT,443)),REJECT', 'MATCH,DIRECT',
|
||||
]);
|
||||
expect(result.skippedRules).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.skipIf(!nativeAvailable)('passes the native mihomo parser with all node protocols, dynamic groups and nested logic', () => {
|
||||
const nodes: NodeDto[] = [node, { ...node, name: 'Trojan', protocol: 'trojan' },
|
||||
{ ...node, name: 'SS', protocol: 'shadowsocks' },
|
||||
{ ...node, name: 'HY2', protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander', obfsPassword: 'placeholder-obfs' }];
|
||||
const ir: ProfileIR = { ...base, nodes, groups: [
|
||||
{ ...group, name: 'Source', filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }, group,
|
||||
], rules: [{ type: 'AND', value: '((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DOMAIN,example.com))',
|
||||
policy: 'REJECT', params: [], ruleset: null }, final] };
|
||||
const file = path.join(directory, 'fixture.yaml');
|
||||
writeFileSync(file, generate(ir).content, { mode: 0o600 });
|
||||
const result = spawnSync(cli, ['-t', '-d', directory, '-f', file], { encoding: 'utf8', timeout: 15000 });
|
||||
expect(result.stdout + result.stderr).toContain('test is successful');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ function shadowrocketGeneral(surgeGeneral: string): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** ShadowRocket 不认识的规则类型 */
|
||||
/** 尚未验证 Shadowrocket 配置语法的类型;这是生成器的保守范围,不是客户端能力断言。 */
|
||||
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX', 'PROTOCOL']);
|
||||
|
||||
export interface ShadowrocketConfOutput {
|
||||
@@ -70,14 +70,12 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
|
||||
lines.push('', '[Proxy Group]');
|
||||
for (const g of ir.groups) {
|
||||
const parts: string[] = [g.type];
|
||||
if (!g.filterRegex && !g.includeAllNodes) {
|
||||
const members = g.members.map((m) => policyName(m.name));
|
||||
parts.push(...(members.length ? members : ['DIRECT']));
|
||||
}
|
||||
const members = [...new Set(g.members.map((m) => policyName(m.name)))];
|
||||
parts.push(...(members.length ? members : !g.filterRegex && !g.includeAllNodes ? ['DIRECT'] : []));
|
||||
if (g.type !== 'select') {
|
||||
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||
if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`);
|
||||
}
|
||||
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
|
||||
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||||
|
||||
@@ -11,7 +11,9 @@ export interface ShadowrocketOutput {
|
||||
export function generateShadowrocket(ir: ProfileIR): ShadowrocketOutput {
|
||||
const excluded: ExcludedNode[] = [];
|
||||
const uris: string[] = [];
|
||||
for (const n of ir.nodes) {
|
||||
// 按完整名称的字符串顺序排列,保留国家、协议和出口后缀;不改 IR 的数据库顺序。
|
||||
const sortedNodes = [...ir.nodes].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
||||
for (const n of sortedNodes) {
|
||||
const verdict = clientSupports('shadowrocket', n);
|
||||
if (!verdict.ok) {
|
||||
excluded.push({ name: n.name, reason: verdict.reason! });
|
||||
|
||||
Reference in New Issue
Block a user