Files
proxy-station/server/src/services/generators/shadowrocket-conf.ts
YANG JIANKUAN a30360ab38 fix: ShadowRocket 策略名去引号,修复规则整条失效
ShadowRocket 的 conf 语法没有引号机制,含空格/emoji 的策略名一律裸写。
此前 quoteName 给策略名加引号,引号被当作名字的一部分,规则指向不存在的
策略组而失效,流量落到默认行为走代理(小红书/微信/抖音配了直连仍走代理)。
改为 policyName 剔除引号与逗号后裸写,补回归测试,并在 CLAUDE.md 记录此约束。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-03 16:44:40 +08:00

109 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ProfileIR } from '../ir.js';
/**
* ShadowRocket 的 conf 语法没有引号机制:策略名一律裸写,含空格/emoji 均可;
* 加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效。
* 逗号是唯一分隔符,名字里的逗号无法表达,只能剔除。
*/
function policyName(name: string): string {
return name.replace(/[",]/g, '').trim();
}
/**
* ShadowRocket 的 [General] 只保留它认识的通用键——模板里的 Surge 专有键
* all-hybrid / udp-priority / http-api / compatibility-mode 等)会被过滤掉。
*/
const SR_GENERAL_KEYS = new Set([
'bypass-system',
'skip-proxy',
'bypass-tun',
'dns-server',
'fallback-dns-server',
'ipv6',
'prefer-ipv6',
'dns-direct-system',
'icmp-auto-reply',
'always-real-ip',
'hijack-dns',
'udp-policy-not-supported-behaviour',
'interface-mode',
]);
function shadowrocketGeneral(surgeGeneral: string): string {
const lines: string[] = [];
for (const raw of surgeGeneral.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
const key = line.split('=')[0].trim();
if (SR_GENERAL_KEYS.has(key)) lines.push(line);
}
// ShadowRocket 的常用默认值,模板里没有就补上
if (!lines.some((l) => l.startsWith('bypass-system'))) lines.push('bypass-system = true');
if (!lines.some((l) => l.startsWith('ipv6'))) lines.push('ipv6 = false');
if (!lines.some((l) => l.startsWith('dns-server'))) lines.push('dns-server = 223.5.5.5, 119.29.29.29');
return lines.join('\n');
}
/** ShadowRocket 不认识的规则类型 */
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX']);
export interface ShadowrocketConfOutput {
content: string;
skippedRules: string[];
}
/**
* 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。
* 节点由「节点订阅」单独导入。ShadowRocket 没有 Surge 的 `use=` 订阅引用语法,
* 策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛选,
* 因此订阅在 App 里叫什么名字都可以。
*/
export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput {
const lines: string[] = [];
lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)');
lines.push('# 节点请另行导入「节点订阅」');
lines.push('');
lines.push('[General]');
lines.push(shadowrocketGeneral(ir.sections['General'] ?? ''));
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']));
}
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}`);
}
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
else if (g.includeAllNodes) parts.push('policy-regex-filter=.*');
lines.push(`${policyName(g.name)} = ${parts.join(', ')}`);
}
const skippedRules: string[] = [];
lines.push('', '[Rule]');
for (const r of ir.rules) {
if (r.type === 'FINAL') {
lines.push(['FINAL', policyName(r.policy)].join(','));
continue;
}
if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) {
skippedRules.push(`${r.type},${r.ruleset ? r.ruleset.name : r.value},${r.policy}`);
continue;
}
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
lines.push([r.type, value, policyName(r.policy), ...r.params].join(','));
}
for (const section of ['Host', 'URL Rewrite', 'MITM']) {
if (ir.sections[section]) lines.push('', `[${section}]`, ir.sections[section]);
}
return { content: lines.join('\n') + '\n', skippedRules };
}