import fs from 'node:fs'; import { settings } from './config.js'; import { HarborError } from '../shared/errors.js'; import { normalizeRouteRules } from '../shared/routingRules.js'; import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js'; const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const MIXED_INBOUND = 'mixed-in'; const TPROXY_INBOUND = 'tproxy-in'; const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in'; const SNIFF_TIMEOUT = '1s'; const SNIFFERS = ['http', 'tls', 'quic']; interface ProxyOutbound extends Record { tag?: string; type?: string; packet_encoding?: string; } function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function findOutbound(subscriptionConfig: unknown, selectedTag: unknown): ProxyOutbound | undefined { const config = record(subscriptionConfig); const outbounds = Array.isArray(config.outbounds) ? config.outbounds.map(record) : []; const tag = String(selectedTag || '').trim(); return outbounds.find((outbound) => ( String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(String(outbound.type || '')) )); } export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unknown, { clientDirect = false, routeRules = [], }: { clientDirect?: boolean; routeRules?: unknown } = {}) { const clientMode = settings.appMode === 'client'; const directClient = clientMode && clientDirect; const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag)); if (!vpnOutbound) throw new HarborError('SERVER_NOT_FOUND'); if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) { vpnOutbound.packet_encoding = 'xudp'; } const outboundTag = directClient ? 'direct' : vpnOutbound.tag; const inbounds = [ ...(!clientMode ? [{ type: 'tproxy', tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort, }] : []), { type: 'mixed', tag: MIXED_INBOUND, listen: settings.bindIp, listen_port: settings.proxyPort, set_system_proxy: false, }, { type: 'mixed', tag: DIAGNOSTICS_INBOUND, listen: '127.0.0.1', listen_port: settings.diagnosticsProxyPort, set_system_proxy: false, }, ]; const directRules = normalizeRouteRules(routeRules) .filter((rule) => rule.enabled) .map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' })); const rules = clientMode ? [ { inbound: [MIXED_INBOUND, DIAGNOSTICS_INBOUND], action: 'sniff', sniffer: SNIFFERS, timeout: SNIFF_TIMEOUT, }, { inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag }, ...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }, ] : [ { inbound: [TPROXY_INBOUND, MIXED_INBOUND, DIAGNOSTICS_INBOUND], action: 'sniff', sniffer: SNIFFERS, timeout: SNIFF_TIMEOUT, }, { inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag }, ...directRules, { inbound: [TPROXY_INBOUND], outbound: outboundTag }, { inbound: [MIXED_INBOUND], outbound: outboundTag }, ]; return { log: { level: settings.logLevel, timestamp: true }, experimental: { cache_file: { enabled: true, path: settings.cachePath }, ...(!clientMode ? { clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` }, } : {}), }, dns: { independent_cache: true }, inbounds, outbounds: [ vpnOutbound, { type: 'direct', tag: 'direct' }, ], route: { rule_set: [], rules, final: outboundTag, ...(clientMode ? {} : { auto_detect_interface: true }), }, }; } export function writeSingboxConfig(config: unknown) { atomicWriteJson(settings.configPath, config); } export function restoreSingboxConfig(contents: string) { atomicWriteFile(settings.configPath, contents); } export function removeSingboxConfig() { fs.rmSync(settings.configPath, { force: true }); }