Files
proxy-station/server/src/services/generators/surge.ts
YANG JIANKUAN 5326c572a2 feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
2026-10-03 23:19:59 +08:00

128 lines
5.7 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 { 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 };
}