All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 18s
252 lines
6.4 KiB
JavaScript
252 lines
6.4 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { settings } from "./config.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) {
|
|
if (!customRule?.enabled) return null;
|
|
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
|
|
|
|
const rule = {};
|
|
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) {
|
|
return (Array.isArray(customRules) ? customRules : [])
|
|
.map((rule) => toSingboxRule(rule, vpnTag))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function routeRules(customRules, vpnTag) {
|
|
const rules = [
|
|
{
|
|
ip_is_private: true,
|
|
outbound: "direct",
|
|
},
|
|
];
|
|
|
|
rules.push(...customRouteRules(customRules, vpnTag));
|
|
|
|
if (settings.routingRuDirect) {
|
|
rules.push({
|
|
rule_set: ["geoip-ru", "geosite-category-ru"],
|
|
outbound: "direct",
|
|
});
|
|
}
|
|
|
|
return rules;
|
|
}
|
|
|
|
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
|
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: ruleSets(customRuleSets),
|
|
rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
|
|
final: vpnOutbound.tag,
|
|
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);
|
|
}
|
|
}
|