Files
proxy-station/server/src/services/generators/shadowrocket-conf.ts
YANG JIANKUAN a5d980a5e9 feat: 添加 WireGuard 设备管理并完善客户端分流
实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。

完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
2026-11-02 22:44:23 +08:00

122 lines
5.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';
import { SHADOWROCKET_LAN_RULES } from './shadowrocket-lan.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', 'PROTOCOL']);
export interface ShadowrocketConfOutput {
content: string;
skippedRules: string[];
}
/**
* 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。
* 节点由「节点订阅」单独导入。ShadowRocket 没有 Surge 的 `use=` 订阅引用语法,
* 策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛选,
* 因此订阅在 App 里叫什么名字都可以。
*/
export function generateShadowrocketConf(ir: ProfileIR, opts: { convertBaseUrl?: string } = {}): 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];
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.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`);
}
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
const filter = g.filterRegex || (g.includeAllNodes ? '.*' : null);
if (filter) parts.push(`policy-regex-filter=${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 (r.type === 'DOMAIN-SET' && r.ruleset?.clashBehavior === 'domain' && opts.convertBaseUrl) {
const url = `${opts.convertBaseUrl}/ruleset/${encodeURIComponent(r.ruleset.id)}.list`;
lines.push(['RULE-SET', url, policyName(r.policy), ...r.params].join(','));
continue;
}
if (r.type === 'RULE-SET' && !r.ruleset && r.value === 'LAN') {
lines.push(...SHADOWROCKET_LAN_RULES.map((rule) => [rule, policyName(r.policy), ...r.params].join(',')));
continue;
}
// An absolute macOS app-bundle path cannot identify an iOS application.
if (r.type === 'PROCESS-NAME' && /^\/Applications\/.*\.app(?:\/|$)/.test(r.value ?? '')) {
skippedRules.push(`${r.type},${r.value},${r.policy}`);
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 };
}