实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。 完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
259 lines
9.9 KiB
TypeScript
259 lines
9.9 KiB
TypeScript
import YAML from 'yaml';
|
||
import { clientSupports, normalizeSsCipher, splitRuleExpression, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||
import type { ProfileIR } from '../ir.js';
|
||
import { clashAiDns } from './clash-ai-dns.js';
|
||
import { WG_POLICY } from './wireguard.js';
|
||
|
||
export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||
const base: Record<string, unknown> = {
|
||
name: n.name, server: n.server, port: n.port,
|
||
// SIP003 的 WS 插件只转发 TCP,不能把 UDP 发往 nginx 的同端口。
|
||
udp: !(n.protocol === 'shadowsocks' && n.network === 'ws'),
|
||
};
|
||
switch (n.protocol) {
|
||
case 'vmess': {
|
||
const cipher = n.security === 'chacha20-ietf-poly1305' ? 'chacha20-poly1305' : n.security || 'auto';
|
||
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher });
|
||
if (n.tls) {
|
||
base.tls = true;
|
||
if (n.sni) base.servername = n.sni;
|
||
if (n.skipCertVerify) base['skip-cert-verify'] = true;
|
||
}
|
||
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,
|
||
...(n.skipCertVerify ? { 'skip-cert-verify': true } : {}),
|
||
};
|
||
}
|
||
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;
|
||
}
|
||
|
||
/** 逐个转换逻辑子规则;任何不可表达的子条件使整条规则跳过,避免扩大匹配范围。 */
|
||
function mapLogicValue(value: string): string | null {
|
||
value = value.trim();
|
||
if (!value.startsWith('(') || !value.endsWith(')')) return null;
|
||
const children = splitRuleExpression(value.slice(1, -1));
|
||
if (!children?.length) return null;
|
||
const mapped: string[] = [];
|
||
for (const child of children) {
|
||
if (!child.startsWith('(') || !child.endsWith(')')) return null;
|
||
const parts = splitRuleExpression(child.slice(1, -1));
|
||
if (!parts || parts.length < 2) return null;
|
||
let [type, payload] = parts;
|
||
if (['AND', 'OR', 'NOT'].includes(type)) {
|
||
const nested = mapLogicValue(payload);
|
||
if (!nested) return null;
|
||
payload = nested;
|
||
} else if (type === 'PROTOCOL') {
|
||
if (payload !== 'TCP' && payload !== 'UDP') return null;
|
||
type = 'NETWORK';
|
||
} else {
|
||
const types: Record<string, string> = {
|
||
DOMAIN: 'DOMAIN', 'DOMAIN-SUFFIX': 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD': 'DOMAIN-KEYWORD',
|
||
'DOMAIN-WILDCARD': 'DOMAIN-WILDCARD', 'IP-CIDR': 'IP-CIDR', 'IP-CIDR6': 'IP-CIDR6',
|
||
'IP-ASN': 'IP-ASN', GEOIP: 'GEOIP', 'PROCESS-NAME': 'PROCESS-NAME',
|
||
'SRC-IP': 'SRC-IP-CIDR', 'DEST-PORT': 'DST-PORT', 'SRC-PORT': 'SRC-PORT',
|
||
};
|
||
if (!types[type] || /["'\\]/.test(payload)) return null;
|
||
type = types[type];
|
||
}
|
||
if (parts.slice(2).some((p) => p !== 'no-resolve')) return null;
|
||
mapped.push(`(${[type, payload, ...parts.slice(2)].join(',')})`);
|
||
}
|
||
return `(${mapped.join(',')})`;
|
||
}
|
||
|
||
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 {
|
||
if (ir.wireguard && [...ir.nodes, ...ir.groups].some(n => n.name === WG_POLICY)) {
|
||
throw new Error('WireGuard 组网策略名称与现有节点或组冲突');
|
||
}
|
||
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);
|
||
if (ir.wireguard) {
|
||
const wg = ir.wireguard;
|
||
proxies.push({ name: WG_POLICY, type: 'wireguard', ip: wg.address, 'private-key': wg.privateKey,
|
||
mtu: wg.mtu, udp: true, 'persistent-keepalive': wg.keepalive,
|
||
peers: [{ server: wg.server, port: wg.port, 'public-key': wg.serverPublicKey,
|
||
...(wg.presharedKey ? { 'pre-shared-key': wg.presharedKey } : {}), 'allowed-ips': [wg.subnet] }] });
|
||
}
|
||
|
||
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;
|
||
if (ir.wireguard) group['exclude-filter'] = `^${WG_POLICY}$`;
|
||
group.filter = g.filterRegex;
|
||
if (members.length) group.proxies = [...new Set(members)];
|
||
} else if (g.includeAllNodes) {
|
||
group['include-all-proxies'] = true;
|
||
if (ir.wireguard) group['exclude-filter'] = `^${WG_POLICY}$`;
|
||
if (members.length) group.proxies = [...new Set(members)];
|
||
} 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.type === 'url-test' && g.tolerance !== null) group.tolerance = g.tolerance;
|
||
}
|
||
return group;
|
||
});
|
||
|
||
// rule-providers:从启用规则引用的规则集生成,名称唯一化
|
||
const providerNameByRulesetId = new Map<string, string>();
|
||
const providers: Record<string, 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[] = [];
|
||
if (ir.wireguard) ruleLines.push(`IP-CIDR,${ir.wireguard.subnet},${WG_POLICY},no-resolve`);
|
||
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;
|
||
}
|
||
// Surge 的应用协议识别不能直接作为 mihomo 规则类型输出。
|
||
if (r.type === 'PROTOCOL') {
|
||
if (r.value === 'TCP' || r.value === 'UDP') {
|
||
ruleLines.push(`NETWORK,${r.value},${mapPolicy(r.policy)}`);
|
||
} else {
|
||
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, ...r.params.filter((p) => p === 'no-resolve')].join(','));
|
||
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') {
|
||
const value = mapLogicValue(r.value || '');
|
||
if (value) ruleLines.push([r.type, value, policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
|
||
else skippedRules.push(`${r.type},${r.value},${r.policy}`);
|
||
continue;
|
||
}
|
||
const params = r.params.filter((p) => p === 'no-resolve');
|
||
ruleLines.push([r.type, r.value, policy, ...params].join(','));
|
||
}
|
||
|
||
const defaultNameservers = ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'];
|
||
const aiDns = clashAiDns(ir, providers, providerNameByRulesetId, defaultNameservers);
|
||
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: defaultNameservers,
|
||
...aiDns,
|
||
},
|
||
proxies,
|
||
'proxy-groups': proxyGroups,
|
||
'rule-providers': providers,
|
||
rules: ruleLines,
|
||
};
|
||
|
||
return { content: YAML.stringify(doc, { lineWidth: 0 }), excluded, skippedRules };
|
||
}
|