Refine routing defaults for global and device fallbacks
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s

This commit is contained in:
2026-05-09 09:53:12 +03:00
parent 62b39cdf58
commit aab7533438
14 changed files with 695 additions and 160 deletions

View File

@@ -15,6 +15,7 @@ export const settings = {
statePath: path.join(dataDir, "state.json"),
customRulesPath: path.join(dataDir, "custom-rules.json"),
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
devicesPath: path.join(dataDir, "devices.json"),
deviceRulesPath: path.join(dataDir, "device-rules.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
hwidPath: path.join(dataDir, "hwid"),

153
src/server/devices.js Normal file
View File

@@ -0,0 +1,153 @@
import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block"]);
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
export const DEFAULT_DEVICE_MODE = "direct";
export const DEFAULT_PROXY_MODE = "vpn";
export const TPROXY_INBOUND = "tproxy-in";
export const MIXED_INBOUND = "mixed-in";
const IPISH_RE = /^[\.\d:/]+$/;
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
}
function normalizeDeviceMode(mode, fallback = "rules") {
const value = String(mode || "").trim().toLowerCase();
if (value === "bypass") return "direct";
return DEVICE_MODES.has(value) ? value : fallback;
}
function normalizeDefaultMode(mode) {
const value = String(mode || "").trim().toLowerCase();
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_DEVICE_MODE;
}
function normalizeProxyMode(mode) {
const value = String(mode || "").trim().toLowerCase();
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_PROXY_MODE;
}
function normalizeIp(ip) {
const value = String(ip || "").trim();
return value && IPISH_RE.test(value) ? value : "";
}
function normalizeMac(mac) {
return String(mac || "").trim();
}
function fromLegacyDeviceRules(input) {
const rules = Array.isArray(input) ? input : [];
const devices = [];
for (const rule of rules) {
const sourceIps = Array.isArray(rule?.sourceIps) ? rule.sourceIps : [];
const mode = normalizeDeviceMode(rule?.outbound, "direct");
sourceIps.forEach((sourceIp, ipIndex) => {
const ip = normalizeIp(sourceIp);
if (!ip) return;
devices.push({
id: String(rule.id || `dev-${devices.length}`) + `-${ipIndex}`,
name: String(rule.name || `Устройство ${devices.length + 1}`).trim(),
enabled: rule.enabled !== false,
ip,
mac: "",
mode,
lastSeen: null,
});
});
}
return {
defaultTransparentMode: DEFAULT_DEVICE_MODE,
proxyDefaultMode: DEFAULT_PROXY_MODE,
devices,
};
}
export function normalizeDeviceProfiles(input) {
const raw =
input && typeof input === "object" && !Array.isArray(input)
? input
: { devices: input };
const rawDevices = Array.isArray(raw.devices) ? raw.devices : [];
return {
defaultTransparentMode: normalizeDefaultMode(
raw.defaultTransparentMode || raw.defaultMode,
),
proxyDefaultMode: normalizeProxyMode(raw.proxyDefaultMode),
devices: rawDevices.map((device, index) => ({
id: String(device.id || `dev-${Date.now()}-${index}`),
name: String(device.name || `Устройство ${index + 1}`).trim(),
enabled: device.enabled !== false,
ip: normalizeIp(device.ip || device.sourceIp),
mac: normalizeMac(device.mac),
mode: normalizeDeviceMode(device.mode || device.outbound, "rules"),
lastSeen: device.lastSeen || null,
})),
};
}
export function readDeviceProfiles() {
if (fs.existsSync(settings.devicesPath)) {
return normalizeDeviceProfiles(readJson(settings.devicesPath, null));
}
if (fs.existsSync(settings.deviceRulesPath)) {
return normalizeDeviceProfiles(
fromLegacyDeviceRules(readJson(settings.deviceRulesPath, [])),
);
}
return {
defaultTransparentMode: DEFAULT_DEVICE_MODE,
proxyDefaultMode: DEFAULT_PROXY_MODE,
devices: [],
};
}
export function writeDeviceProfiles(value) {
const normalized = normalizeDeviceProfiles(value);
writeJson(settings.devicesPath, normalized);
return normalized;
}
export function normalizeCidr(ip) {
const value = normalizeIp(ip);
if (!value) return "";
return value.includes("/") ? value : `${value}/32`;
}
export function deviceCidrs(devices, modes) {
const allowedModes = new Set(Array.isArray(modes) ? modes : [modes]);
return (Array.isArray(devices) ? devices : [])
.filter((device) => device.enabled !== false && allowedModes.has(device.mode))
.map((device) => normalizeCidr(device.ip))
.filter(Boolean);
}
export function legacyDeviceRulesFromProfiles(profiles) {
const { devices } = normalizeDeviceProfiles(profiles);
return devices.map((device) => ({
id: device.id,
name: device.name,
enabled: device.enabled,
sourceIps: device.ip ? [device.ip] : [],
outbound: device.mode === "rules" ? "direct" : device.mode,
}));
}

View File

@@ -12,6 +12,11 @@ import {
readSingboxConfig,
removeSingboxConfig,
} from "./singbox.js";
import {
legacyDeviceRulesFromProfiles,
readDeviceProfiles,
writeDeviceProfiles,
} from "./devices.js";
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
import { tcpPing, resolveHost } from "./ping.js";
@@ -24,10 +29,11 @@ const SINGBOX_PID_FILE = path.join(settings.dataDir, "singbox.pid");
// ─── Direct bypass cache (ipset) ────────────────────────────────────────────
const DIRECT_BYPASS_SET = process.env.DIRECT_BYPASS_SET || "vpn_direct_bypass";
const DIRECT_BYPASS_TTL = process.env.DIRECT_BYPASS_TTL || "3600";
const DIRECT_BYPASS_CACHE = process.env.DIRECT_BYPASS_CACHE === "true";
const IPSET_AVAILABLE = (() => {
try {
spawnSync("ipset", ["version"], { timeout: 1000 });
return true;
const result = spawnSync("ipset", ["version"], { timeout: 1000 });
return !result.error && result.status === 0;
} catch {
return false;
}
@@ -38,7 +44,7 @@ const IP_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/;
let directBypassCount = 0;
function addToDirectBypass(ip) {
if (!IPSET_AVAILABLE || !IP_RE.test(ip)) return;
if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE || !IP_RE.test(ip)) return;
try {
spawnSync(
"ipset",
@@ -60,7 +66,7 @@ function flushDirectBypass() {
}
function listDirectBypass() {
if (!IPSET_AVAILABLE) return [];
if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE) return [];
try {
const result = spawnSync(
"ipset",
@@ -478,7 +484,7 @@ async function startSingbox() {
function publicState() {
const state = readJson(settings.statePath, {});
const customRules = readJson(settings.customRulesPath, []);
const deviceRules = readJson(settings.deviceRulesPath, []);
const deviceProfiles = readDeviceProfiles();
const { subscriptionUrl, ...rest } = state;
return {
mode: "gateway",
@@ -492,12 +498,16 @@ function publicState() {
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
hasSubscription: Boolean(subscriptionUrl),
customRules,
deviceRules,
devicesConfig: deviceProfiles,
devices: deviceProfiles.devices,
deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles),
appliedHistory: state.appliedHistory || [],
rulesUpdatedAt: state.rulesUpdatedAt || null,
devicesUpdatedAt: state.devicesUpdatedAt || null,
rulesAppliedAt: state.rulesAppliedAt || null,
bypassMode: Boolean(state.bypassMode),
directBypassCount,
directBypassEnabled: DIRECT_BYPASS_CACHE,
directBypassAvailable: IPSET_AVAILABLE,
...rest,
};
@@ -739,17 +749,69 @@ async function handleApi(req, res) {
}
if (req.method === "GET" && req.url === "/api/device-rules") {
const deviceProfiles = readDeviceProfiles();
return sendJson(res, 200, {
success: true,
deviceRules: readJson(settings.deviceRulesPath, []),
deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles),
});
}
if (req.method === "PUT" && req.url === "/api/device-rules") {
const body = await readBody(req);
const rules = normalizeDeviceRules(body.deviceRules);
writeJson(settings.deviceRulesPath, rules);
return sendJson(res, 200, { success: true, deviceRules: rules });
const devices = [];
for (const rule of rules) {
rule.sourceIps.forEach((ip, index) => {
devices.push({
id: `${rule.id}-${index}`,
name: rule.name,
enabled: rule.enabled,
ip,
mode: rule.outbound,
});
});
}
const profiles = writeDeviceProfiles({
defaultTransparentMode: "direct",
proxyDefaultMode: "vpn",
devices,
});
const prevState = readJson(settings.statePath, {});
writeJson(settings.statePath, {
...prevState,
devicesUpdatedAt: new Date().toISOString(),
});
return sendJson(res, 200, {
success: true,
...profiles,
deviceRules: legacyDeviceRulesFromProfiles(profiles),
});
}
if (req.method === "GET" && req.url === "/api/devices") {
const profiles = readDeviceProfiles();
return sendJson(res, 200, { success: true, ...profiles });
}
if (req.method === "PUT" && req.url === "/api/devices") {
const body = await readBody(req);
const input = body.devicesConfig || {
defaultTransparentMode: body.defaultTransparentMode || body.defaultMode,
proxyDefaultMode: body.proxyDefaultMode,
devices: body.devices,
};
const profiles = writeDeviceProfiles(input);
const prevState = readJson(settings.statePath, {});
const devicesUpdatedAt = new Date().toISOString();
writeJson(settings.statePath, {
...prevState,
devicesUpdatedAt,
});
return sendJson(res, 200, {
success: true,
...profiles,
devicesUpdatedAt,
});
}
if (req.method === "GET" && req.url === "/api/rule-sets") {
@@ -954,6 +1016,8 @@ async function handleApi(req, res) {
? Number(body.port)
: undefined;
const network = String(body.network || "").trim() || undefined;
const sourceIp = String(body.sourceIp || "").trim() || undefined;
const inbound = String(body.inbound || "").trim() || undefined;
if (!host && !ip) {
return sendJson(res, 400, {
@@ -972,12 +1036,12 @@ async function handleApi(req, res) {
}
const rules = readJson(settings.customRulesPath, []);
const cached = readJson(settings.subscriptionCachePath, null);
const state = readJson(settings.statePath, {});
const vpnTag = state.selectedTag || "vpn-out";
const result = matchRoute({ host, ip, port, network }, rules, {
const result = matchRoute({ host, ip, port, network, sourceIp, inbound }, rules, {
routingRuDirect: settings.routingRuDirect,
vpnTag,
deviceProfiles: readDeviceProfiles(),
});
return sendJson(res, 200, {

View File

@@ -4,6 +4,7 @@
// мы не можем точно сказать, попадает ли IP/домен в RU.
import net from "node:net";
import { TPROXY_INBOUND, MIXED_INBOUND } from "./devices.js";
function ipv4ToInt(ip) {
const parts = ip.split(".").map((x) => Number.parseInt(x, 10));
@@ -49,6 +50,28 @@ function isPrivateIp(ip) {
return PRIVATE_CIDRS.some((cidr) => ipInCidr(ip, cidr));
}
function normalizeCidr(ip) {
const value = String(ip || "").trim();
if (!value) return "";
return value.includes("/") ? value : `${value}/32`;
}
function deviceMatchesSourceIp(device, sourceIp) {
if (!device?.ip || !sourceIp) return false;
return ipInCidr(sourceIp, normalizeCidr(device.ip));
}
function modeOutbound(mode, vpnTag) {
if (mode === "vpn") return `${vpnTag} (VPN)`;
if (mode === "direct" || mode === "block") return mode;
return null;
}
function likelyRuHost(host) {
const value = String(host || "").toLowerCase();
return value === "ru" || value.endsWith(".ru");
}
function hostMatchesDomain(host, domain) {
if (!host || !domain) return false;
return host.toLowerCase() === domain.toLowerCase();
@@ -116,8 +139,25 @@ function ruleMatches(rule, target) {
* @param {object} options { routingRuDirect, vpnTag }
*/
export function matchRoute(target, customRules, options = {}) {
const { routingRuDirect = true, vpnTag = "vpn-out" } = options;
const {
routingRuDirect = true,
vpnTag = "vpn-out",
deviceProfiles = {
defaultTransparentMode: "direct",
proxyDefaultMode: "vpn",
devices: [],
},
} = options;
const rules = Array.isArray(customRules) ? customRules : [];
const inbound = target.inbound || TPROXY_INBOUND;
const sourceIp = target.sourceIp || "";
const devices = Array.isArray(deviceProfiles.devices)
? deviceProfiles.devices
: [];
const matchedDevice = devices.find(
(device) =>
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
);
// 1. private IP → direct
if (target.ip && isPrivateIp(target.ip)) {
@@ -130,7 +170,7 @@ export function matchRoute(target, customRules, options = {}) {
};
}
// 2. custom rules (first match wins)
// 2. global custom rules apply to every inbound before fallbacks.
for (let i = 0; i < rules.length; i += 1) {
const rule = rules[i];
if (ruleMatches(rule, target)) {
@@ -142,30 +182,68 @@ export function matchRoute(target, customRules, options = {}) {
ruleId: rule.id,
ruleName: rule.name,
outbound,
reason: "Совпадение по custom-правилу",
reason: "Совпадение по global custom rule",
};
}
}
// 3. RU direct (geoip/geosite) — мы не знаем точно, скажем "может сработать"
if (routingRuDirect) {
// 3. RU direct is global. Without a local rule-set DB we only detect obvious .ru hosts.
if (routingRuDirect && likelyRuHost(target.host)) {
return {
matched: "fallback-ru-or-vpn",
matched: "geo",
ruleIndex: -2,
ruleName: "geoip-ru / geosite-category-ru → direct, иначе VPN",
outbound: `direct или ${vpnTag}`,
reason:
"Если домен/IP попадает в geoip-ru или geosite-category-ru — direct; иначе — VPN. Без локальной базы точно не определить.",
ruleName: "geosite-category-ru → direct",
outbound: "direct",
reason: "Домен выглядит как RU; точное попадание в rule-set проверит sing-box",
};
}
// 4. final → VPN
// 4. transparent device defaults.
if (inbound === TPROXY_INBOUND && matchedDevice) {
const outbound = modeOutbound(matchedDevice.mode, vpnTag);
if (outbound) {
return {
matched: "device-default",
ruleIndex: -1,
ruleId: matchedDevice.id,
ruleName: `${matchedDevice.name}${matchedDevice.mode}`,
outbound,
reason: "Fallback устройства после global rules",
};
}
}
// 5. explicit proxy default.
if (inbound === MIXED_INBOUND) {
const mode = deviceProfiles.proxyDefaultMode || "vpn";
return {
matched: "proxy-default",
ruleIndex: -1,
ruleName: `mixed-in default → ${mode}`,
outbound: modeOutbound(mode, vpnTag) || `${vpnTag} (VPN)`,
reason: "Fallback explicit HTTP/SOCKS proxy после global rules",
};
}
// 6. unknown transparent device default.
if (inbound === TPROXY_INBOUND) {
const mode = deviceProfiles.defaultTransparentMode || "direct";
return {
matched: "transparent-default",
ruleIndex: -1,
ruleName: `transparent default → ${mode}`,
outbound: modeOutbound(mode, vpnTag) || "direct",
reason: "Fallback unknown transparent device после global rules",
};
}
// 7. final → direct
return {
matched: "final",
ruleIndex: -3,
ruleName: "final",
outbound: vpnTag,
reason: "Не сработало ни одно правило — пойдёт через VPN",
outbound: "direct",
reason: "Не сработало ни одно правило — итоговый final отправляет напрямую",
};
}

View File

@@ -1,6 +1,12 @@
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";
const PROXY_TYPES = new Set([
"vless",
@@ -100,11 +106,11 @@ function parsePorts(values) {
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
}
function toSingboxRule(customRule, vpnTag) {
function toSingboxRule(customRule, vpnTag, baseRule = {}) {
if (!customRule?.enabled) return null;
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
const rule = {};
const rule = { ...baseRule };
const domains = uniqueClean(customRule.domains);
const domainSuffixes = uniqueClean(customRule.domainSuffixes);
const domainKeywords = uniqueClean(customRule.domainKeywords);
@@ -140,47 +146,57 @@ function toSingboxRule(customRule, vpnTag) {
return rule;
}
function customRouteRules(customRules, vpnTag) {
function customRouteRules(customRules, vpnTag, baseRule = {}) {
return (Array.isArray(customRules) ? customRules : [])
.map((rule) => toSingboxRule(rule, vpnTag))
.map((rule) => toSingboxRule(rule, vpnTag, baseRule))
.filter(Boolean);
}
// ─── Device rules (маршрутизация по source IP) ──────────────────────────────
function readDeviceRules() {
try {
if (!fs.existsSync(settings.deviceRulesPath)) return [];
const data = JSON.parse(fs.readFileSync(settings.deviceRulesPath, "utf8"));
return Array.isArray(data) ? data : [];
} catch {
return [];
}
function modeOutbound(mode, vpnTag) {
if (mode === "vpn") return vpnTag;
if (mode === "direct" || mode === "block") return mode;
return null;
}
function normalizeCidr(ip) {
return ip.includes("/") ? ip : `${ip}/32`;
}
function toDeviceRouteRule(device, vpnTag) {
function deviceDefaultRouteRule(device, vpnTag) {
if (!device?.enabled) return null;
const cidrs = (Array.isArray(device.sourceIps) ? device.sourceIps : [])
.map((ip) => normalizeCidr(ip.trim()))
.filter(Boolean);
if (!cidrs.length) return null;
const outbound =
device.outbound === "vpn" ? vpnTag : device.outbound || "direct";
return { source_ip_cidr: cidrs, outbound };
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 deviceRouteRules(deviceRules, vpnTag) {
return (Array.isArray(deviceRules) ? deviceRules : [])
.map((d) => toDeviceRouteRule(d, vpnTag))
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) {
const deviceRules = readDeviceRules();
const deviceProfiles = readDeviceProfiles();
const rules = [
{
ip_is_private: true,
@@ -188,17 +204,28 @@ function routeRules(customRules, vpnTag) {
},
];
// Правила по устройствам (source IP) — выполняются ДО правил по назначению
rules.push(...deviceRouteRules(deviceRules, vpnTag));
// Global rules apply to every inbound before contextual fallbacks.
rules.push(...customRouteRules(customRules, vpnTag));
if (settings.routingRuDirect) {
rules.push({
rule_set: ["geoip-ru", "geosite-category-ru"],
outbound: "direct",
});
}
const ruRule = ruDirectRule();
if (ruRule) rules.push(ruRule);
// 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);
const transparentFallback = inboundDefaultRule(
TPROXY_INBOUND,
deviceProfiles.defaultTransparentMode,
vpnTag,
);
if (transparentFallback) rules.push(transparentFallback);
return rules;
}
@@ -263,7 +290,7 @@ export function buildGatewayConfig(
rules: bypassAll
? [{ ip_is_private: true, outbound: "direct" }]
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
final: bypassAll ? "direct" : vpnOutbound.tag,
final: "direct",
auto_detect_interface: true,
},
};