import fs from 'node:fs'; import path from 'node:path'; import { settings } from './config.js'; const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const CUSTOM_OUTBOUNDS = new Set(['direct', 'vpn', 'block']); function clone(value) { return JSON.parse(JSON.stringify(value)); } function findOutbound(subscriptionConfig, selectedTag) { const outbounds = Array.isArray(subscriptionConfig?.outbounds) ? subscriptionConfig.outbounds : []; return outbounds.find((outbound) => outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type)); } function ruleSets() { if (!settings.routingRuDirect) return []; return [ { type: 'remote', tag: 'geoip-ru', format: 'binary', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs', download_detour: 'direct', }, { type: 'remote', tag: 'geosite-category-ru', format: 'binary', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs', download_detour: 'direct', }, ]; } function uniqueClean(values) { return Array.from( new Set( (Array.isArray(values) ? values : []) .map((value) => String(value || '').trim()) .filter(Boolean), ), ); } function parsePorts(values) { return uniqueClean(values) .map((value) => Number.parseInt(value, 10)) .filter((value) => Number.isInteger(value) && value > 0 && value <= 65535); } function toSingboxRule(customRule, vpnTag) { if (!customRule?.enabled) return null; if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null; const rule = {}; const domains = uniqueClean(customRule.domains); const domainSuffixes = uniqueClean(customRule.domainSuffixes); const domainKeywords = uniqueClean(customRule.domainKeywords); const ipCidrs = uniqueClean(customRule.ipCidrs); const ports = parsePorts(customRule.ports); const networks = uniqueClean(customRule.networks).filter((network) => ['tcp', 'udp'].includes(network)); if (domains.length) rule.domain = domains; if (domainSuffixes.length) rule.domain_suffix = domainSuffixes; if (domainKeywords.length) rule.domain_keyword = domainKeywords; if (ipCidrs.length) rule.ip_cidr = ipCidrs; if (ports.length) rule.port = ports; if (networks.length) rule.network = networks; if ( !rule.domain && !rule.domain_suffix && !rule.domain_keyword && !rule.ip_cidr && !rule.port && !rule.network ) { return null; } rule.outbound = customRule.outbound === 'vpn' ? vpnTag : customRule.outbound; return rule; } function customRouteRules(customRules, vpnTag) { return (Array.isArray(customRules) ? customRules : []) .map((rule) => toSingboxRule(rule, vpnTag)) .filter(Boolean); } function routeRules(customRules, vpnTag) { const rules = [ { ip_is_private: true, outbound: 'direct', }, ]; rules.push(...customRouteRules(customRules, vpnTag)); if (settings.routingRuDirect) { rules.push({ rule_set: ['geoip-ru', 'geosite-category-ru'], outbound: 'direct', }); } return rules; } export function buildGatewayConfig(subscriptionConfig, selectedTag) { const selectedOutbound = findOutbound(subscriptionConfig, selectedTag); if (!selectedOutbound) { throw new Error(`Selected outbound not found: ${selectedTag}`); } const vpnOutbound = clone(selectedOutbound); if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) { vpnOutbound.packet_encoding = 'xudp'; } return { log: { level: settings.logLevel, timestamp: true, }, experimental: { cache_file: { enabled: true, path: settings.cachePath, }, }, dns: { independent_cache: true, }, inbounds: [ { type: 'tproxy', tag: 'tproxy-in', listen: '::', listen_port: settings.tproxyPort, sniff: true, sniff_override_destination: true, }, { type: 'mixed', tag: 'mixed-in', listen: settings.bindIp, listen_port: settings.proxyPort, sniff: true, set_system_proxy: false, }, ], outbounds: [ vpnOutbound, { type: 'direct', tag: 'direct' }, { type: 'block', tag: 'block' }, ], route: { rule_set: ruleSets(), rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag), final: vpnOutbound.tag, auto_detect_interface: true, }, }; } export function writeSingboxConfig(config) { fs.mkdirSync(path.dirname(settings.configPath), { recursive: true }); fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8'); }