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,192 @@
import YAML from 'yaml';
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
import type { ProfileIR, RuleIR } from '../ir.js';
export function clashProxy(n: NodeDto): Record<string, unknown> {
const base: Record<string, unknown> = { name: n.name, server: n.server, port: n.port };
switch (n.protocol) {
case 'vmess': {
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher: n.security || 'auto' });
if (n.tls) {
base.tls = true;
if (n.sni) base.servername = n.sni;
}
if (n.network === 'ws') {
base.network = 'ws';
base['ws-opts'] = {
path: n.wsPath,
...(n.wsHost ? { headers: { Host: n.wsHost } } : {}),
};
}
break;
}
case 'trojan': {
Object.assign(base, { type: 'trojan', password: n.password, sni: n.sni || n.server });
if (n.network === 'ws') {
base.network = 'ws';
base['ws-opts'] = {
path: n.wsPath,
...(n.wsHost ? { headers: { Host: n.wsHost } } : {}),
};
}
if (n.skipCertVerify) base['skip-cert-verify'] = true;
break;
}
case 'shadowsocks': {
Object.assign(base, { type: 'ss', cipher: normalizeSsCipher(n.method || ''), password: n.password });
if (n.network === 'ws') {
// SS over WS+TLS 在 Clash 侧唯一可行方案是 v2ray-plugin
base.plugin = 'v2ray-plugin';
base['plugin-opts'] = {
mode: 'websocket',
tls: n.tls,
host: n.wsHost || n.sni || n.server,
path: n.wsPath,
};
}
break;
}
case 'hysteria2': {
Object.assign(base, { type: 'hysteria2', password: n.password });
if (n.sni) base.sni = n.sni;
if (n.obfsType === 'salamander' && n.obfsPassword) {
base.obfs = 'salamander';
base['obfs-password'] = n.obfsPassword;
}
if (n.upMbps) base.up = `${n.upMbps} Mbps`;
if (n.downMbps) base.down = `${n.downMbps} Mbps`;
if (n.skipCertVerify) base['skip-cert-verify'] = true;
break;
}
}
return base;
}
/** Surge 独有策略 → mihomo 等价策略 */
function mapPolicy(policy: string): string {
if (policy === 'REJECT-NO-DROP' || policy === 'REJECT-TINYGIF') return 'REJECT';
return policy;
}
/** Surge 逻辑规则里的子类型 → mihomo 命名 */
function mapLogicValue(value: string): string {
return value.replace(/\bPROTOCOL\b/g, 'NETWORK');
}
const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']);
export interface ClashOutput {
content: string;
excluded: ExcludedNode[];
skippedRules: string[];
}
export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): ClashOutput {
const excluded: ExcludedNode[] = [];
const usable = ir.nodes.filter((n) => {
const verdict = clientSupports('clash', 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 proxies = usable.map(clashProxy);
const proxyGroups = ir.groups.map((g) => {
const group: Record<string, unknown> = { name: g.name, type: g.type };
const members = g.members
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
.map((m) => m.name);
if (g.filterRegex) {
group['include-all-proxies'] = true;
group.filter = g.filterRegex;
} else if (g.includeAllNodes) {
group['include-all-proxies'] = true;
const extra = members.filter((m) => !usableNames.has(m));
if (extra.length) group.proxies = extra;
} else {
group.proxies = members.length ? members : ['DIRECT'];
}
if (g.type !== 'select') {
group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204';
group.interval = g.interval ?? 300;
if (g.tolerance) group.tolerance = g.tolerance;
}
return group;
});
// rule-providers:从启用规则引用的规则集生成,名称唯一化
const providerNameByRulesetId = new Map<string, string>();
const providers: Record<string, unknown> = {};
const takenNames = new Set<string>();
const usedRulesets = ir.rules.flatMap((r) => (r.ruleset ? [r.ruleset] : []));
for (const rs of usedRulesets) {
if (providerNameByRulesetId.has(rs.id)) continue;
let name = rs.name;
for (let i = 2; takenNames.has(name); i++) name = `${rs.name}-${i}`;
takenNames.add(name);
providerNameByRulesetId.set(rs.id, name);
const url = rs.needsConvert || !rs.clashUrl ? `${opts.convertBaseUrl}/ruleset/${rs.id}.yaml` : rs.clashUrl;
providers[name] = {
type: 'http',
url,
behavior: rs.clashBehavior,
format: rs.needsConvert || !rs.clashUrl ? 'yaml' : rs.clashFormat === 'yaml' ? 'yaml' : 'text',
interval: 86400,
};
}
const skippedRules: string[] = [];
const ruleLines: string[] = [];
for (const r of ir.rules) {
if (r.type === 'FINAL') {
ruleLines.push(`MATCH,${mapPolicy(r.policy)}`);
continue;
}
if (CLASH_UNSUPPORTED_RULE_TYPES.has(r.type)) {
skippedRules.push(`${r.type},${r.value},${r.policy}`);
continue;
}
const policy = mapPolicy(r.policy);
if (r.ruleset) {
ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`);
continue;
}
// Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过
if (r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') {
if (r.value === 'LAN') {
ruleLines.push(`GEOIP,private,${policy},no-resolve`);
} else {
skippedRules.push(`${r.type},${r.value},${r.policy}`);
}
continue;
}
if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') {
ruleLines.push(`${r.type},${mapLogicValue(r.value || '')},${policy}`);
continue;
}
const params = r.params.filter((p) => p === 'no-resolve');
ruleLines.push([r.type, r.value, policy, ...params].join(','));
}
const doc = {
'mixed-port': 7890,
'allow-lan': false,
mode: 'rule',
'log-level': 'info',
'unified-delay': true,
ipv6: false,
dns: {
enable: true,
listen: '0.0.0.0:1053',
'enhanced-mode': 'fake-ip',
nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'],
},
proxies,
'proxy-groups': proxyGroups,
'rule-providers': providers,
rules: ruleLines,
};
return { content: YAML.stringify(doc, { lineWidth: 0 }), excluded, skippedRules };
}