init: init proj

This commit is contained in:
2026-08-25 11:35:05 +08:00
commit bf7f2df67e
80 changed files with 9936 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
import type { ProfileIR } from '../ir.js';
/** 成员名含空格/逗号时按 Surge 习惯加引号 */
function quoteName(name: string): string {
return /[\s,]/.test(name) ? `"${name}"` : name;
}
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}`);
if (n.network === 'ws') {
parts.push('ws=true', `ws-path=${n.wsPath}`);
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
}
if (n.tls) {
parts.push('tls=true');
if (n.sni) parts.push(`sni=${n.sni}`);
}
// alterId=0 的 AEAD 头必须显式打开,否则 Surge 连不上且症状隐晦
parts.push('vmess-aead=true');
break;
}
case 'trojan': {
parts.push('trojan', n.server, String(n.port), `password=${n.password}`);
if (n.network === 'ws') {
parts.push('ws=true', `ws-path=${n.wsPath}`);
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
}
if (n.sni) parts.push(`sni=${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}`);
break;
}
case 'hysteria2': {
parts.push('hysteria2', n.server, String(n.port), `password=${n.password}`);
if (n.sni) parts.push(`sni=${n.sni}`);
// Surge 的 salamander 混淆参数名(sub-router 生产验证过)
if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${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(', ')}`;
}
export interface SurgeOutput {
content: string;
excluded: ExcludedNode[];
}
export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput {
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(ir.sections['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) {
// 按名称正则动态筛选:节点来源可以是全部节点,也可以是另一个组
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}`);
} else if (g.includeAllNodes) {
// 「全部节点」组:显式展开当前启用且 Surge 支持的节点
parts.push(...usable.map((n) => quoteName(n.name)), ...memberNames.filter((m) => !usableNames.has(m)));
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'}`);
if (g.interval) parts.push(`interval=${g.interval}`);
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
}
lines.push(`${g.name} = ${parts.join(', ')}`);
}
lines.push('', '[Rule]');
for (const r of ir.rules) {
if (r.type === 'FINAL') {
lines.push(['FINAL', quoteName(r.policy), ...r.params].join(','));
continue;
}
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(','));
}
for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
if (ir.sections[section]) {
lines.push('', `[${section}]`, ir.sections[section]);
}
}
return { content: lines.join('\n') + '\n', excluded };
}