Remove legacy vpn proxy code and simplify the client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 11:18:03 +03:00
parent e19d33adb9
commit 9d4f312595
53 changed files with 471 additions and 11511 deletions

View File

@@ -1,352 +1,71 @@
import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
import {
MIXED_INBOUND,
TPROXY_INBOUND,
normalizeCidr,
readDeviceProfiles,
} from "./devices.js";
import { readClientSettings } from "./clientSettings.js";
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));
}
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const MIXED_INBOUND = 'mixed-in';
const TPROXY_INBOUND = 'tproxy-in';
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),
);
const tag = String(selectedTag || '').trim();
return outbounds.find((outbound) => (
String(outbound.tag || '').trim() === tag && 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 ruleSetDownloadDetour(vpnTag) {
const detour = String(settings.ruleSetDownloadDetour || "vpn").trim();
if (!detour || detour === "vpn") return vpnTag;
return detour;
}
function ruleSets(customRuleSets = [], vpnTag = "direct") {
const downloadDetour = ruleSetDownloadDetour(vpnTag);
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: downloadDetour,
},
{
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: downloadDetour,
},
]
: [];
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: downloadDetour,
}));
// Пользовательские 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, baseRule = {}) {
if (!customRule?.enabled) return null;
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
const rule = { ...baseRule };
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;
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
const clientMode = settings.appMode === 'client';
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
if (!vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = 'xudp';
}
rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound;
return rule;
}
function customRouteRules(customRules, vpnTag, baseRule = {}) {
return (Array.isArray(customRules) ? customRules : [])
.map((rule) => toSingboxRule(rule, vpnTag, baseRule))
.filter(Boolean);
}
// ─── Device rules (маршрутизация по source IP) ──────────────────────────────
function modeOutbound(mode, vpnTag) {
if (mode === "vpn") return vpnTag;
if (mode === "direct" || mode === "block") return mode;
return null;
}
function deviceDefaultRouteRule(device, vpnTag) {
if (!device?.enabled) return null;
const outbound = modeOutbound(device.mode, vpnTag);
if (!outbound) return null;
const cidr = normalizeCidr(device.ip);
if (!cidr) return null;
return {
inbound: [TPROXY_INBOUND],
source_ip_cidr: [cidr],
outbound,
};
}
function deviceDefaultRouteRules(devices, vpnTag) {
return (Array.isArray(devices) ? devices : [])
.map((device) => deviceDefaultRouteRule(device, vpnTag))
.filter(Boolean);
}
function inboundDefaultRule(inbound, mode, vpnTag) {
const outbound = modeOutbound(mode, vpnTag);
if (!outbound) return null;
return { inbound: [inbound], outbound };
}
function ruDirectRule() {
if (!settings.routingRuDirect) return null;
return {
rule_set: ["geoip-ru", "geosite-category-ru"],
outbound: "direct",
};
}
function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
const deviceProfiles = readDeviceProfiles();
const rules = [
{
ip_is_private: true,
outbound: "direct",
},
];
// Global rules apply to every inbound before contextual fallbacks.
rules.push(...customRouteRules(customRules, vpnTag));
const ruRule = ruDirectRule();
if (ruRule) rules.push(ruRule);
if (includeTransparent) {
// Device defaults are only transparent-gateway fallbacks after global rules.
rules.push(...deviceDefaultRouteRules(deviceProfiles.devices, vpnTag));
}
const proxyFallback = inboundDefaultRule(
MIXED_INBOUND,
deviceProfiles.proxyDefaultMode,
vpnTag,
);
if (proxyFallback) rules.push(proxyFallback);
if (includeTransparent) {
const transparentFallback = inboundDefaultRule(
TPROXY_INBOUND,
deviceProfiles.defaultTransparentMode,
vpnTag,
);
if (transparentFallback) rules.push(transparentFallback);
}
return rules;
}
function sharedProxyOutbound(sharedProxy) {
if (!sharedProxy?.host || !sharedProxy?.port) return null;
if (sharedProxy.protocol === "http") {
return {
type: "http",
tag: "shared-proxy",
server: sharedProxy.host,
server_port: sharedProxy.port,
};
}
return {
type: "socks",
tag: "shared-proxy",
server: sharedProxy.host,
server_port: sharedProxy.port,
version: "5",
};
}
export function buildGatewayConfig(
subscriptionConfig,
selectedTag,
{ bypassAll = false } = {},
) {
const customRuleSets = readCustomRuleSets();
const clientMode = settings.appMode === "client";
const clientSettings = clientMode ? readClientSettings() : null;
const sharedOutbound =
clientMode && clientSettings?.sharedProxyEnabled
? sharedProxyOutbound(clientSettings.sharedProxy)
: null;
const directOnlyClient = clientMode && clientSettings?.homeBypassEnabled;
const selectedOutbound = sharedOutbound
? null
: findOutbound(subscriptionConfig, selectedTag);
if (!sharedOutbound && !directOnlyClient && !selectedOutbound) {
throw new Error(`Outbound не найден: ${selectedTag}`);
}
const vpnOutbound = selectedOutbound ? clone(selectedOutbound) : null;
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound?.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = "xudp";
}
const clientOutbound = sharedOutbound
? sharedOutbound.tag
: clientSettings?.homeBypassEnabled
? "direct"
: vpnOutbound.tag;
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
const inbounds = [
...(clientMode
? []
: [
{
type: "tproxy",
tag: "tproxy-in",
listen: "::",
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
},
]),
...(!clientMode ? [{
type: 'tproxy',
tag: TPROXY_INBOUND,
listen: '::',
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
}] : []),
{
type: "mixed",
tag: "mixed-in",
type: 'mixed',
tag: MIXED_INBOUND,
listen: settings.bindIp,
listen_port: mixedProxyPort,
listen_port: settings.proxyPort,
sniff: true,
set_system_proxy: false,
},
];
const rules = clientMode
? [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }]
: [
{ inbound: [TPROXY_INBOUND], outbound: vpnOutbound.tag },
{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag },
];
return {
log: {
level: settings.logLevel,
timestamp: true,
},
log: { level: settings.logLevel, timestamp: true },
experimental: {
cache_file: {
enabled: true,
path: settings.cachePath,
},
},
dns: {
independent_cache: true,
cache_file: { enabled: true, path: settings.cachePath },
},
dns: { independent_cache: true },
inbounds,
outbounds: [
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
{ type: "direct", tag: "direct" },
{ type: "block", tag: "block" },
vpnOutbound,
{ type: 'direct', tag: 'direct' },
{ type: 'block', tag: 'block' },
],
route: {
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
rules: bypassAll
? [{ ip_is_private: true, outbound: "direct" }]
: clientMode
? proxyOnlyRules
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
includeTransparent: !clientMode,
}),
final: "direct",
rule_set: [],
rules,
final: vpnOutbound.tag,
...(clientMode ? {} : { auto_detect_interface: true }),
},
};
@@ -354,24 +73,9 @@ export function buildGatewayConfig(
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;
}
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
}
export function removeSingboxConfig() {
if (fs.existsSync(settings.configPath)) {
fs.rmSync(settings.configPath);
}
fs.rmSync(settings.configPath, { force: true });
}