fix: ShadowRocket 策略名去引号,修复规则整条失效
ShadowRocket 的 conf 语法没有引号机制,含空格/emoji 的策略名一律裸写。 此前 quoteName 给策略名加引号,引号被当作名字的一部分,规则指向不存在的 策略组而失效,流量落到默认行为走代理(小红书/微信/抖音配了直连仍走代理)。 改为 policyName 剔除引号与逗号后裸写,补回归测试,并在 CLAUDE.md 记录此约束。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -74,7 +74,7 @@ IR 层承担三件防护,改动时不要绕过:
|
|||||||
|
|
||||||
### ShadowRocket 分两步导入
|
### ShadowRocket 分两步导入
|
||||||
|
|
||||||
ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`URL-REGEX`,生成时跳过并回报 `skippedRules`。
|
ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`URL-REGEX`,生成时跳过并回报 `skippedRules`。**ShadowRocket 的 conf 语法没有引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。
|
||||||
|
|
||||||
### 鉴权与订阅
|
### 鉴权与订阅
|
||||||
|
|
||||||
|
|||||||
48
server/src/services/generators/shadowrocket-conf.test.ts
Normal file
48
server/src/services/generators/shadowrocket-conf.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateShadowrocketConf } from './shadowrocket-conf.js';
|
||||||
|
import type { ProfileIR } from '../ir.js';
|
||||||
|
|
||||||
|
const ir: ProfileIR = {
|
||||||
|
nodes: [],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: '🇨🇳 Mainland',
|
||||||
|
type: 'select',
|
||||||
|
testUrl: null,
|
||||||
|
interval: null,
|
||||||
|
tolerance: null,
|
||||||
|
filterRegex: null,
|
||||||
|
includeAllNodes: false,
|
||||||
|
members: [{ kind: 'builtin', name: 'DIRECT' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '🎯 NoAuto',
|
||||||
|
type: 'select',
|
||||||
|
testUrl: null,
|
||||||
|
interval: null,
|
||||||
|
tolerance: null,
|
||||||
|
filterRegex: null,
|
||||||
|
includeAllNodes: false,
|
||||||
|
members: [
|
||||||
|
{ kind: 'group', name: '🇨🇳 Mainland' },
|
||||||
|
{ kind: 'group', name: '⚡️ Automatic' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ type: 'DOMAIN-SUFFIX', value: 'xiaohongshu.com', policy: '🇨🇳 Mainland', params: [], ruleset: null },
|
||||||
|
{ type: 'FINAL', value: null, policy: '🎯 NoAuto', params: ['dns-failed'], ruleset: null },
|
||||||
|
],
|
||||||
|
sections: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('generateShadowrocketConf', () => {
|
||||||
|
// ShadowRocket 不剥引号:带引号的策略名会匹配不到策略组,整条规则失效(真机与社区配置证实)
|
||||||
|
it('含空格/emoji 的策略名一律裸写,不加引号', () => {
|
||||||
|
const { content } = generateShadowrocketConf(ir);
|
||||||
|
expect(content).not.toContain('"');
|
||||||
|
expect(content).toContain('DOMAIN-SUFFIX,xiaohongshu.com,🇨🇳 Mainland');
|
||||||
|
expect(content).toContain('FINAL,🎯 NoAuto');
|
||||||
|
expect(content).toContain('🎯 NoAuto = select, 🇨🇳 Mainland, ⚡️ Automatic');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { ProfileIR } from '../ir.js';
|
import type { ProfileIR } from '../ir.js';
|
||||||
|
|
||||||
/** 成员名含空格/逗号时加引号 */
|
/**
|
||||||
function quoteName(name: string): string {
|
* ShadowRocket 的 conf 语法没有引号机制:策略名一律裸写,含空格/emoji 均可;
|
||||||
return /[\s,]/.test(name) ? `"${name}"` : name;
|
* 加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效。
|
||||||
|
* 逗号是唯一分隔符,名字里的逗号无法表达,只能剔除。
|
||||||
|
*/
|
||||||
|
function policyName(name: string): string {
|
||||||
|
return name.replace(/[",]/g, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +71,7 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
|
|||||||
for (const g of ir.groups) {
|
for (const g of ir.groups) {
|
||||||
const parts: string[] = [g.type];
|
const parts: string[] = [g.type];
|
||||||
if (!g.filterRegex && !g.includeAllNodes) {
|
if (!g.filterRegex && !g.includeAllNodes) {
|
||||||
const members = g.members.map((m) => quoteName(m.name));
|
const members = g.members.map((m) => policyName(m.name));
|
||||||
parts.push(...(members.length ? members : ['DIRECT']));
|
parts.push(...(members.length ? members : ['DIRECT']));
|
||||||
}
|
}
|
||||||
if (g.type !== 'select') {
|
if (g.type !== 'select') {
|
||||||
@@ -78,14 +82,14 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
|
|||||||
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
|
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
|
||||||
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
|
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||||||
else if (g.includeAllNodes) parts.push('policy-regex-filter=.*');
|
else if (g.includeAllNodes) parts.push('policy-regex-filter=.*');
|
||||||
lines.push(`${g.name} = ${parts.join(', ')}`);
|
lines.push(`${policyName(g.name)} = ${parts.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const skippedRules: string[] = [];
|
const skippedRules: string[] = [];
|
||||||
lines.push('', '[Rule]');
|
lines.push('', '[Rule]');
|
||||||
for (const r of ir.rules) {
|
for (const r of ir.rules) {
|
||||||
if (r.type === 'FINAL') {
|
if (r.type === 'FINAL') {
|
||||||
lines.push(['FINAL', quoteName(r.policy)].join(','));
|
lines.push(['FINAL', policyName(r.policy)].join(','));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) {
|
if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) {
|
||||||
@@ -93,7 +97,7 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
||||||
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(','));
|
lines.push([r.type, value, policyName(r.policy), ...r.params].join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const section of ['Host', 'URL Rewrite', 'MITM']) {
|
for (const section of ['Host', 'URL Rewrite', 'MITM']) {
|
||||||
|
|||||||
Reference in New Issue
Block a user