Files
harbor-net/src/server/singbox.js
Dmitriy Petrov aab7533438
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
Refine routing defaults for global and device fallbacks
2026-05-09 09:53:12 +03:00

322 lines
8.4 KiB
JavaScript

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",
"vmess",
"trojan",
"shadowsocks",
"hysteria2",
]);
const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]);
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
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),
);
}
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 ruleSets(customRuleSets = []) {
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: "direct",
},
{
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: "direct",
},
]
: [];
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: "direct",
}));
// Пользовательские 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;
}
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) {
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);
// 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;
}
export function buildGatewayConfig(
subscriptionConfig,
selectedTag,
{ bypassAll = false } = {},
) {
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
if (!selectedOutbound) {
throw new Error(`Outbound не найден: ${selectedTag}`);
}
const vpnOutbound = clone(selectedOutbound);
if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = "xudp";
}
const customRuleSets = readCustomRuleSets();
return {
log: {
level: settings.logLevel,
timestamp: true,
},
experimental: {
cache_file: {
enabled: true,
path: settings.cachePath,
},
},
dns: {
independent_cache: true,
},
inbounds: [
{
type: "tproxy",
tag: "tproxy-in",
listen: "::",
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
},
{
type: "mixed",
tag: "mixed-in",
listen: settings.bindIp,
listen_port: settings.proxyPort,
sniff: true,
set_system_proxy: false,
},
],
outbounds: [
vpnOutbound,
{ type: "direct", tag: "direct" },
{ type: "block", tag: "block" },
],
route: {
rule_set: bypassAll ? [] : ruleSets(customRuleSets),
rules: bypassAll
? [{ ip_is_private: true, outbound: "direct" }]
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
final: "direct",
auto_detect_interface: true,
},
};
}
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;
}
}
export function removeSingboxConfig() {
if (fs.existsSync(settings.configPath)) {
fs.rmSync(settings.configPath);
}
}