feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
@@ -1,54 +1,54 @@
|
||||
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
import { surgeAiDns } from './surge-ai-dns.js';
|
||||
|
||||
/** 成员名含空格/逗号时按 Surge 习惯加引号 */
|
||||
function quoteName(name: string): string {
|
||||
return /[\s,]/.test(name) ? `"${name}"` : name;
|
||||
}
|
||||
import { surgeDeclaration, surgeParam, surgeValue as quoteName } from './surge-format.js';
|
||||
|
||||
export function surgeNodeLine(n: NodeDto): string {
|
||||
const parts: string[] = [];
|
||||
switch (n.protocol) {
|
||||
case 'vmess': {
|
||||
parts.push('vmess', n.server, String(n.port), `username=${n.uuid}`);
|
||||
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=${n.wsPath}`);
|
||||
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
||||
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=${n.sni}`);
|
||||
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', n.server, String(n.port), `password=${n.password}`);
|
||||
parts.push('trojan', quoteName(n.server), String(n.port), `password=${quoteName(n.password || '')}`);
|
||||
if (n.network === 'ws') {
|
||||
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
||||
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
||||
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=${n.sni}`);
|
||||
if (n.sni) parts.push(`sni=${quoteName(n.sni)}`);
|
||||
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||
break;
|
||||
}
|
||||
case 'shadowsocks': {
|
||||
parts.push('ss', n.server, String(n.port), `encrypt-method=${normalizeSsCipher(n.method || '')}`, `password=${n.password}`);
|
||||
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', n.server, String(n.port), `password=${n.password}`);
|
||||
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||
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=${n.obfsPassword}`);
|
||||
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 `${n.name} = ${parts.join(', ')}`;
|
||||
return `${surgeDeclaration(n.name)} = ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
export interface SurgeOutput {
|
||||
@@ -83,39 +83,38 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
|
||||
.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');
|
||||
} else {
|
||||
const source = g.members.find((m) => m.kind === 'group');
|
||||
if (source) parts.push(`include-other-group=${quoteName(source.name)}`);
|
||||
}
|
||||
parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||||
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(...usable.map((n) => quoteName(n.name)), ...memberNames.filter((m) => !usableNames.has(m)));
|
||||
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') {
|
||||
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||
// Surge uses per-node test-url or General.proxy-test-url, never group url=.
|
||||
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||
if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`);
|
||||
}
|
||||
lines.push(`${g.name} = ${parts.join(', ')}`);
|
||||
lines.push(`${surgeDeclaration(g.name)} = ${parts.join(', ')}`);
|
||||
}
|
||||
|
||||
lines.push('', '[Rule]');
|
||||
lines.push(...dns.rules);
|
||||
for (const r of ir.rules) {
|
||||
if (r.type === 'FINAL') {
|
||||
lines.push(['FINAL', quoteName(r.policy), ...r.params].join(','));
|
||||
continue;
|
||||
}
|
||||
for (const r of ir.rules.filter((r) => r.type !== 'FINAL')) {
|
||||
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
||||
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(','));
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user