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 : []; const exact = outbounds.find( (outbound) => outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type), ); if (exact) return exact; const trimmedTag = String(selectedTag || "").trim(); return outbounds.find( (outbound) => String(outbound.tag || "").trim() === trimmedTag && PROXY_TYPES.has(outbound.type), ); } function readCustomRuleSets() { try { if (!fs.existsSync(settings.customRuleSetsPath)) return []; const data = JSON.parse( fs.readFileSync(settings.customRuleSetsPath, "utf8"), ); return Array.isArray(data) ? data : []; } catch { return []; } } function ruleSets(customRuleSets = []) { const builtIn = settings.routingRuDirect ? [ { 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", }, ] : []; const custom = (Array.isArray(customRuleSets) ? customRuleSets : []) .filter((rs) => rs.tag && rs.url) .map((rs) => ({ type: "remote", tag: String(rs.tag).trim(), format: rs.format || "binary", url: String(rs.url).trim(), download_detour: "direct", })); // Пользовательские rule-sets не должны дублировать встроенные const builtInTags = new Set(builtIn.map((rs) => rs.tag)); const merged = [ ...builtIn, ...custom.filter((rs) => !builtInTags.has(rs.tag)), ]; return merged; } 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; const ruleSetsRef = uniqueClean(customRule.ruleSets); if (ruleSetsRef.length) rule.rule_set = ruleSetsRef; if ( !rule.domain && !rule.domain_suffix && !rule.domain_keyword && !rule.ip_cidr && !rule.port && !rule.network && !rule.rule_set ) { 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(`Outbound не найден: ${selectedTag}`); } const vpnOutbound = clone(selectedOutbound); if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out"; if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) { vpnOutbound.packet_encoding = "xudp"; } const customRuleSets = readCustomRuleSets(); 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(customRuleSets), 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", ); } export function readSingboxConfig() { if (!fs.existsSync(settings.configPath)) return null; try { return JSON.parse(fs.readFileSync(settings.configPath, "utf8")); } catch { return null; } } export function removeSingboxConfig() { if (fs.existsSync(settings.configPath)) { fs.rmSync(settings.configPath); } }