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

@@ -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, {