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

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

146 lines
6.8 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 { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
import type { ProfileIR } from '../ir.js';
import { surgeFfsb } from './surge-ffsb.js';
import { surgeAiDns } from './surge-ai-dns.js';
import { surgeDeclaration, surgeParam, surgeValue as quoteName } from './surge-format.js';
import { WG_POLICY, WG_SECTION, surgeWireguardSection } from './wireguard.js';
export function surgeNodeLine(n: NodeDto): string {
const parts: string[] = [];
switch (n.protocol) {
case 'vmess': {
const verdict = clientSupports('surge', n);
if (!verdict.ok) throw new Error(verdict.reason);
parts.push('vmess', quoteName(n.server), String(n.port), `username=${n.uuid}`);
if (n.network === 'ws') {
parts.push('ws=true', `ws-path=${quoteName(n.wsPath || '/')}`);
if (n.wsHost) parts.push(`ws-headers=${quoteName(`Host:${n.wsHost}`)}`);
}
if (n.tls) {
parts.push('tls=true');
if (n.sni) parts.push(`sni=${quoteName(n.sni)}`);
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
}
// alterId=0 的 AEAD 头必须显式打开,否则 Surge 连不上且症状隐晦
parts.push('vmess-aead=true');
if (n.security !== 'auto') parts.push(`encrypt-method=${normalizeSsCipher(n.security)}`);
break;
}
case 'trojan': {
parts.push('trojan', quoteName(n.server), String(n.port), `password=${quoteName(n.password || '')}`);
if (n.network === 'ws') {
parts.push('ws=true', `ws-path=${quoteName(n.wsPath || '/')}`);
if (n.wsHost) parts.push(`ws-headers=${quoteName(`Host:${n.wsHost}`)}`);
}
if (n.sni) parts.push(`sni=${quoteName(n.sni)}`);
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
break;
}
case 'shadowsocks': {
parts.push('ss', quoteName(n.server), String(n.port), `encrypt-method=${quoteName(normalizeSsCipher(n.method || ''))}`, `password=${quoteName(n.password || '')}`);
break;
}
case 'hysteria2': {
parts.push('hysteria2', quoteName(n.server), String(n.port), `password=${quoteName(n.password || '')}`);
if (n.sni) parts.push(`sni=${quoteName(n.sni)}`);
// Surge 的 salamander 混淆参数名(sub-router 生产验证过)
if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${quoteName(n.obfsPassword)}`);
if (n.downMbps) parts.push(`download-bandwidth=${n.downMbps}`);
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
break;
}
}
return `${surgeDeclaration(n.name)} = ${parts.join(', ')}`;
}
export interface SurgeOutput {
content: string;
excluded: ExcludedNode[];
}
export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput {
if (ir.wireguard && [...ir.nodes, ...ir.groups].some(n => n.name === WG_POLICY)) {
throw new Error('WireGuard 组网策略名称与现有节点或组冲突');
}
const ffsb = surgeFfsb(ir);
ir = ffsb.ir;
const dns = surgeAiDns(ir);
const excluded: ExcludedNode[] = [];
const usable = ir.nodes.filter((n) => {
const verdict = clientSupports('surge', n);
if (!verdict.ok) excluded.push({ name: n.name, reason: verdict.reason! });
return verdict.ok;
});
const usableNames = new Set(usable.map((n) => n.name));
const lines: string[] = [];
lines.push(`#!MANAGED-CONFIG ${opts.selfUrl} interval=86400 strict=true`);
lines.push('# 由 Proxy Station 生成,请勿手动编辑(改动会在下次更新时被覆盖)');
lines.push('');
lines.push('[General]');
lines.push(dns.general);
lines.push('', '[Proxy]');
lines.push(...ffsb.proxyLines);
if (ir.wireguard) lines.push(`${WG_POLICY} = wireguard, section-name=${WG_SECTION}`);
for (const n of usable) lines.push(surgeNodeLine(n));
lines.push('', '[Proxy Group]');
for (const g of ir.groups) {
const parts: string[] = [g.type];
const memberNames = g.members
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
.map((m) => quoteName(m.name));
if (g.filterRegex) {
parts.push(...g.members.filter((m) => m.kind !== 'group' && (m.kind !== 'node' || usableNames.has(m.name))).map((m) => quoteName(m.name)));
// 按名称正则动态筛选:节点来源可以是全部节点,也可以是另一个组
if (g.includeAllNodes) {
parts.push('include-all-proxies=true');
}
const sources = g.members.filter((m) => m.kind === 'group').map((m) => m.name);
if (sources.length) parts.push(`include-other-group=${quoteName(sources.join(','))}`);
// Keep the original regex's unanchored search semantics while excluding only the WG policy.
const filter = ir.wireguard ? `^(?!${WG_POLICY}$).*?(?:${g.filterRegex})` : g.filterRegex;
parts.push(`policy-regex-filter=${quoteName(filter)}`);
} else if (g.includeAllNodes) {
// 「全部节点」组:显式展开当前启用且 Surge 支持的节点
parts.push(...[...new Set([...g.members.filter((m) => m.kind !== 'node' || usableNames.has(m.name)).map((m) => m.name), ...usable.map((n) => n.name)])].map(quoteName));
if (usable.length === 0) parts.push('DIRECT');
} else {
parts.push(...(memberNames.length ? memberNames : ['DIRECT']));
}
parts.push(...(ffsb.extraMembers[g.name] ?? []));
if (ffsb.hiddenGroups.includes(g.name)) parts.push('hidden=true');
if (g.type !== 'select') {
// Surge uses per-node test-url or General.proxy-test-url, never group url=.
if (g.interval) parts.push(`interval=${g.interval}`);
if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`);
}
lines.push(`${surgeDeclaration(g.name)} = ${parts.join(', ')}`);
}
lines.push('', '[Rule]');
if (ir.wireguard) {
if (ir.wireguard.acceptIncoming) lines.push(`IP-CIDR,${ir.wireguard.address}/32,DIRECT,no-resolve`);
lines.push(`IP-CIDR,${ir.wireguard.subnet},${WG_POLICY},no-resolve`);
}
lines.push(...dns.rules);
for (const r of ir.rules.filter((r) => r.type !== 'FINAL')) {
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
const encoded = ['AND', 'OR', 'NOT'].includes(r.type) ? value : quoteName(value || '');
lines.push([r.type, encoded, quoteName(r.policy), ...r.params.map(surgeParam)].join(','));
}
const final = ir.rules.find((r) => r.type === 'FINAL');
lines.push(['FINAL', quoteName(final?.policy ?? 'DIRECT'), ...(final?.params ?? []).map(surgeParam)].join(','));
for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
const content = section === 'Host' ? dns.host : ir.sections[section];
if (content) {
lines.push('', `[${section}]`, content);
}
}
if (ir.wireguard) lines.push('', surgeWireguardSection(ir.wireguard));
return { content: lines.join('\n') + '\n', excluded };
}