Refine routing defaults for global and device fallbacks
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
This commit is contained in:
@@ -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
153
src/server/devices.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 отправляет напрямую",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user