实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。 完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
122 lines
5.0 KiB
TypeScript
122 lines
5.0 KiB
TypeScript
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 };
|
||
}
|