import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared'; import type { ProfileIR } from '../ir.js'; import { surgeAiDns } from './surge-ai-dns.js'; import { surgeDeclaration, surgeParam, surgeValue as quoteName } from './surge-format.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 { 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]'); 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(','))}`); parts.push(`policy-regex-filter=${quoteName(g.filterRegex)}`); } 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'])); } 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]'); 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); } } return { content: lines.join('\n') + '\n', excluded }; }