feat: добавлены правила маршрутизации по устройствам и управление ими через API
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s

Refs: None
This commit is contained in:
2026-05-09 09:12:03 +03:00
parent b3fad00f80
commit 4bb8507e3f
7 changed files with 506 additions and 42 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"),
deviceRulesPath: path.join(dataDir, "device-rules.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
hwidPath: path.join(dataDir, "hwid"),
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",

View File

@@ -436,6 +436,7 @@ async function startSingbox() {
function publicState() {
const state = readJson(settings.statePath, {});
const customRules = readJson(settings.customRulesPath, []);
const deviceRules = readJson(settings.deviceRulesPath, []);
const { subscriptionUrl, ...rest } = state;
return {
mode: "gateway",
@@ -449,6 +450,7 @@ function publicState() {
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
hasSubscription: Boolean(subscriptionUrl),
customRules,
deviceRules,
appliedHistory: state.appliedHistory || [],
rulesUpdatedAt: state.rulesUpdatedAt || null,
rulesAppliedAt: state.rulesAppliedAt || null,
@@ -492,6 +494,21 @@ function normalizeCustomRules(input) {
}));
}
function normalizeDeviceRules(input) {
const rules = Array.isArray(input) ? input : [];
return rules.map((r, index) => ({
id: String(r.id || `dev-${Date.now()}-${index}`),
name: String(r.name || `Устройство ${index + 1}`).trim(),
enabled: r.enabled !== false,
sourceIps: normalizeList(r.sourceIps).filter((ip) =>
/^[\.\d:/]+$/.test(ip),
),
outbound: ["direct", "vpn", "block"].includes(r.outbound)
? r.outbound
: "direct",
}));
}
async function applySelectedServer(selectedTag) {
const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) {
@@ -679,6 +696,20 @@ async function handleApi(req, res) {
});
}
if (req.method === "GET" && req.url === "/api/device-rules") {
return sendJson(res, 200, {
success: true,
deviceRules: readJson(settings.deviceRulesPath, []),
});
}
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 });
}
if (req.method === "GET" && req.url === "/api/rule-sets") {
return sendJson(res, 200, {
success: true,

View File

@@ -146,7 +146,41 @@ function customRouteRules(customRules, vpnTag) {
.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 normalizeCidr(ip) {
return ip.includes("/") ? ip : `${ip}/32`;
}
function toDeviceRouteRule(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 };
}
function deviceRouteRules(deviceRules, vpnTag) {
return (Array.isArray(deviceRules) ? deviceRules : [])
.map((d) => toDeviceRouteRule(d, vpnTag))
.filter(Boolean);
}
function routeRules(customRules, vpnTag) {
const deviceRules = readDeviceRules();
const rules = [
{
ip_is_private: true,
@@ -154,6 +188,9 @@ function routeRules(customRules, vpnTag) {
},
];
// Правила по устройствам (source IP) — выполняются ДО правил по назначению
rules.push(...deviceRouteRules(deviceRules, vpnTag));
rules.push(...customRouteRules(customRules, vpnTag));
if (settings.routingRuDirect) {