Files
2026-04-20 01:17:00 +07:00

860 lines
30 KiB
JavaScript

/**
* Rule-based "local AI" optimizer for route selection.
* Returns best paths and probability of optimality for all candidates.
*/
const { sendError } = require('../middleware/errorHandler');
const { readS3TextObject, listS3Objects } = require('../services/s3Service');
const evobgpClient = require('../services/evobgpClient');
const { readServersFromS3 } = require('./serversRoutes');
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
const NETWORK_CONFIG_KEY = 'network-config.json';
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
const DEFAULT_AI_SETTINGS = {
latencyWeight: 0.55,
bandwidthWeight: 0.35,
freshnessWeight: 0.1,
downloadWeight: 0.5,
uploadWeight: 0.5,
combineHomeToJumphostWeight: 0.45,
combineJumphostToExitWeight: 0.55,
probabilityScale: 5,
minProbabilityGainForSwitch: 10,
noPingScore: 0.2,
noSpeedScore: 0.15,
staleScore: 0.35,
freshnessExcellentSeconds: 120,
freshnessGoodSeconds: 600,
freshnessFairSeconds: 1800,
distancePenaltyPerStep: 0.03,
pinnedCommunityGateways: [],
};
function asObject(v) {
return v && typeof v === 'object' ? v : {};
}
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function positiveNumber(v, fallback) {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function nonNegativeNumber(v, fallback) {
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function makeServerKey(server) {
if (!server || typeof server !== 'object') return '';
return String(server.id || server.dns || server.ip || '').trim();
}
function resolveServerRef(ref, byRef) {
const key = String(ref || '').trim();
if (!key) return null;
return byRef.get(key) || null;
}
function pairKey(a, b) {
return [String(a || ''), String(b || '')].sort().join(':');
}
function pairProbability(items, scoreGetter) {
if (!Array.isArray(items) || items.length === 0) return [];
const values = items.map((it) => Number(scoreGetter(it) || 0));
const max = Math.max(...values);
const exps = values.map((v) => Math.exp(v - max));
const sum = exps.reduce((acc, x) => acc + x, 0) || 1;
return exps.map((x) => (x / sum) * 100);
}
function latencyScore(pingMs, aiSettings) {
if (typeof pingMs !== 'number') return aiSettings.noPingScore;
const normalized = 1 / (1 + pingMs / 35);
return clamp(normalized, 0, 1);
}
function normalizeBandwidthValue(speedMbps, aiSettings) {
if (typeof speedMbps !== 'number' || speedMbps <= 0) return aiSettings.noSpeedScore;
// log-scale to avoid dominance by very high channels
const normalized = Math.log10(1 + speedMbps) / Math.log10(1001);
return clamp(normalized, 0, 1);
}
function freshnessScore(cacheUpdatedAt, aiSettings) {
if (typeof cacheUpdatedAt !== 'number') return aiSettings.staleScore;
const ageMs = Math.max(0, Date.now() - cacheUpdatedAt);
const excellentMs = aiSettings.freshnessExcellentSeconds * 1000;
const goodMs = aiSettings.freshnessGoodSeconds * 1000;
const fairMs = aiSettings.freshnessFairSeconds * 1000;
if (ageMs <= excellentMs) return 1;
if (ageMs <= goodMs) return 0.8;
if (ageMs <= fairMs) return 0.6;
return aiSettings.staleScore;
}
function bandwidthScore({ speedDownloadMbps, speedUploadMbps, aiSettings }) {
// DL/UL considered separately with configurable weights.
const dl = normalizeBandwidthValue(speedDownloadMbps, aiSettings);
const ul = normalizeBandwidthValue(speedUploadMbps, aiSettings);
return clamp(dl * aiSettings.downloadWeight + ul * aiSettings.uploadWeight, 0, 1);
}
function buildSegmentScore({
pingMs,
speedMbps,
speedDownloadMbps,
speedUploadMbps,
cacheUpdatedAt,
aiSettings,
}) {
const l = latencyScore(pingMs, aiSettings);
const b = bandwidthScore({ speedDownloadMbps, speedUploadMbps, aiSettings });
const f = freshnessScore(cacheUpdatedAt, aiSettings);
const hasAnySpeed =
typeof speedMbps === 'number' ||
typeof speedDownloadMbps === 'number' ||
typeof speedUploadMbps === 'number';
const metricPresence = (typeof pingMs === 'number' ? 1 : 0) + (hasAnySpeed ? 1 : 0);
const confidence = metricPresence === 2 ? 1 : metricPresence === 1 ? 0.65 : 0.35;
const score =
l * aiSettings.latencyWeight +
b * aiSettings.bandwidthWeight +
f * aiSettings.freshnessWeight;
return { score: clamp(score, 0, 1), confidence };
}
function compactServer(server) {
return {
id: server?.id || null,
ip: server?.ip || null,
dns: server?.dns || null,
type: server?.type || null,
country: server?.country || null,
label: server?.dns || server?.ip || server?.id || 'unknown',
};
}
function normalizeRef(value) {
return String(value || '').trim();
}
function buildServerRefSet(server) {
return new Set(collectServerRefs(server).map(normalizeRef).filter(Boolean));
}
function findPinnedCommunityGateway(pinnedCommunityGateways, jumphost, community) {
const targetCommunity = normalizeRef(community);
if (!targetCommunity || !Array.isArray(pinnedCommunityGateways)) return null;
const jumphostRefs = buildServerRefSet(jumphost);
for (const entry of pinnedCommunityGateways) {
const entryCommunity = normalizeRef(entry?.community);
if (!entryCommunity || entryCommunity !== targetCommunity) continue;
const pinnedGateway = normalizeRef(entry?.gateway);
if (!pinnedGateway) continue;
const pinnedJumphost = normalizeRef(entry?.jumphost);
if (!pinnedJumphost) return { community: entryCommunity, gateway: pinnedGateway, jumphost: null };
if (jumphostRefs.has(pinnedJumphost)) {
return { community: entryCommunity, gateway: pinnedGateway, jumphost: pinnedJumphost };
}
}
return null;
}
async function loadCommunitiesIndex() {
try {
if (!evobgpClient.isConfigured()) return new Map();
const arr = await evobgpClient.listCommunities();
const m = new Map();
if (!Array.isArray(arr)) return m;
for (const c of arr) {
const value = String(c?.value || '').trim();
if (!value) continue;
m.set(value, {
value,
name: c?.name ? String(c.name) : '',
description: c?.description ? String(c.description) : '',
tags: Array.isArray(c?.tags) ? c.tags.map(String) : [],
});
}
return m;
} catch (_) {
return new Map();
}
}
function parseServerIdFromFilterKey(key) {
const m = String(key || '').match(/^filter-manager\/server-filters-(.+)\.json$/);
return m ? m[1] : null;
}
async function loadServerFiltersByServerId() {
const out = new Map();
try {
const objects = await listS3Objects('filter-manager/server-filters-', { maxKeys: 500 });
for (const obj of objects) {
const key = obj?.key;
const serverId = parseServerIdFromFilterKey(key);
if (!serverId) continue;
try {
const raw = await readS3TextObject(key).catch(() => null);
const parsed = raw?.body ? JSON.parse(raw.body || '[]') : [];
const list = Array.isArray(parsed) ? parsed : [];
out.set(serverId, list.filter((f) => f && f.community && f.gateway).map((f) => ({
community: String(f.community),
gateway: String(f.gateway),
description: f.description ? String(f.description) : '',
})));
} catch (_) {
out.set(serverId, []);
}
}
} catch (_) {}
return out;
}
async function loadNetworkMapCache() {
try {
const raw = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
if (!raw?.body) return { pingMap: {}, speedMap: {}, updatedAt: null };
const parsed = JSON.parse(raw.body || '{}');
return {
pingMap: asObject(parsed.pingMap),
speedMap: asObject(parsed.speedMap),
updatedAt: toNumber(parsed.updatedAt),
};
} catch (_) {
return { pingMap: {}, speedMap: {}, updatedAt: null };
}
}
async function loadNetworkConfig() {
try {
const raw = await readS3TextObject(NETWORK_CONFIG_KEY).catch(() => null);
if (!raw?.body) return {};
const parsed = JSON.parse(raw.body || '{}');
return asObject(parsed);
} catch (_) {
return {};
}
}
async function loadAiSettings() {
try {
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => null);
const parsed = raw?.body ? JSON.parse(raw.body || '{}') : {};
const src = asObject(parsed?.aiRouteOptimizer);
const latencyWeight = positiveNumber(src.latencyWeight, DEFAULT_AI_SETTINGS.latencyWeight);
const bandwidthWeight = positiveNumber(src.bandwidthWeight, DEFAULT_AI_SETTINGS.bandwidthWeight);
const freshnessWeight = positiveNumber(src.freshnessWeight, DEFAULT_AI_SETTINGS.freshnessWeight);
const sum = latencyWeight + bandwidthWeight + freshnessWeight || 1;
const downloadWeight = nonNegativeNumber(src.downloadWeight, DEFAULT_AI_SETTINGS.downloadWeight);
const uploadWeight = nonNegativeNumber(src.uploadWeight, DEFAULT_AI_SETTINGS.uploadWeight);
const sumDu = downloadWeight + uploadWeight;
const hjW = positiveNumber(
src.combineHomeToJumphostWeight,
DEFAULT_AI_SETTINGS.combineHomeToJumphostWeight
);
const jeW = positiveNumber(
src.combineJumphostToExitWeight,
DEFAULT_AI_SETTINGS.combineJumphostToExitWeight
);
const sum2 = hjW + jeW || 1;
const pinnedCommunityGateways = Array.isArray(src.pinnedCommunityGateways)
? src.pinnedCommunityGateways
.map((x) => ({
community: normalizeRef(x?.community),
gateway: normalizeRef(x?.gateway),
jumphost: normalizeRef(x?.jumphost),
}))
.filter((x) => x.community && x.gateway)
: [];
return {
latencyWeight: latencyWeight / sum,
bandwidthWeight: bandwidthWeight / sum,
freshnessWeight: freshnessWeight / sum,
downloadWeight: sumDu > 0 ? downloadWeight / sumDu : DEFAULT_AI_SETTINGS.downloadWeight,
uploadWeight: sumDu > 0 ? uploadWeight / sumDu : DEFAULT_AI_SETTINGS.uploadWeight,
combineHomeToJumphostWeight: hjW / sum2,
combineJumphostToExitWeight: jeW / sum2,
probabilityScale: clamp(
positiveNumber(src.probabilityScale, DEFAULT_AI_SETTINGS.probabilityScale),
0.5,
20
),
minProbabilityGainForSwitch: clamp(
positiveNumber(
src.minProbabilityGainForSwitch,
DEFAULT_AI_SETTINGS.minProbabilityGainForSwitch
),
0,
100
),
noPingScore: clamp(positiveNumber(src.noPingScore, DEFAULT_AI_SETTINGS.noPingScore), 0, 1),
noSpeedScore: clamp(positiveNumber(src.noSpeedScore, DEFAULT_AI_SETTINGS.noSpeedScore), 0, 1),
staleScore: clamp(positiveNumber(src.staleScore, DEFAULT_AI_SETTINGS.staleScore), 0, 1),
freshnessExcellentSeconds: clamp(
positiveNumber(src.freshnessExcellentSeconds, DEFAULT_AI_SETTINGS.freshnessExcellentSeconds),
10,
86400
),
freshnessGoodSeconds: clamp(
positiveNumber(src.freshnessGoodSeconds, DEFAULT_AI_SETTINGS.freshnessGoodSeconds),
10,
86400
),
freshnessFairSeconds: clamp(
positiveNumber(src.freshnessFairSeconds, DEFAULT_AI_SETTINGS.freshnessFairSeconds),
10,
86400
),
distancePenaltyPerStep: clamp(
positiveNumber(src.distancePenaltyPerStep, DEFAULT_AI_SETTINGS.distancePenaltyPerStep),
0,
1
),
pinnedCommunityGateways,
};
} catch (_) {
return { ...DEFAULT_AI_SETTINGS };
}
}
function buildInterfaceCandidates({ servers, tunnelInterfaces, pingMap, speedMap, cacheUpdatedAt, aiSettings }) {
const byRef = new Map();
servers.forEach((s) => {
const refs = [s.id, s.ip, s.dns].filter(Boolean).map((v) => String(v));
refs.forEach((ref) => byRef.set(ref, s));
});
const homeToJh = [];
const jhToExit = [];
for (const iface of tunnelInterfaces) {
if (!iface || !iface.serverId || !iface.serverId2) continue;
const s1 = resolveServerRef(iface.serverId, byRef);
const s2 = resolveServerRef(iface.serverId2, byRef);
if (!s1 || !s2) continue;
const t1 = String(s1.type || '').toLowerCase();
const t2 = String(s2.type || '').toLowerCase();
const k1 = makeServerKey(s1);
const k2 = makeServerKey(s2);
const pkey = pairKey(s1.ip, s2.ip);
const skey = pairKey(k1, k2);
const pingMs = toNumber(pingMap[pkey]);
const speedEntry = asObject(speedMap[skey]);
const downBps = toNumber(speedEntry.tcpDownloadBps);
const upBps = toNumber(speedEntry.tcpUploadBps);
const speedDownloadMbps = downBps != null ? downBps / 1e6 : null;
const speedUploadMbps = upBps != null ? upBps / 1e6 : null;
const speedMbps =
speedDownloadMbps != null && speedUploadMbps != null
? (speedDownloadMbps + speedUploadMbps) / 2
: speedDownloadMbps != null
? speedDownloadMbps
: speedUploadMbps != null
? speedUploadMbps
: null;
const scoring = buildSegmentScore({
pingMs,
speedMbps,
speedDownloadMbps,
speedUploadMbps,
cacheUpdatedAt,
aiSettings,
});
const base = {
interfaceName: iface.name || null,
localIp: iface.localIp || null,
remoteIp: iface.remoteIp || null,
pingMs,
speedMbps: speedMbps != null ? Number(speedMbps.toFixed(2)) : null,
speedDownloadMbps: speedDownloadMbps != null ? Number(speedDownloadMbps.toFixed(2)) : null,
speedUploadMbps: speedUploadMbps != null ? Number(speedUploadMbps.toFixed(2)) : null,
score: Number(scoring.score.toFixed(4)),
confidence: Number(scoring.confidence.toFixed(4)),
};
if ((t1 === 'home' && t2 === 'jumphost') || (t1 === 'jumphost' && t2 === 'home')) {
const home = t1 === 'home' ? s1 : s2;
const jumphost = t1 === 'jumphost' ? s1 : s2;
homeToJh.push({
id: `${makeServerKey(home)}->${makeServerKey(jumphost)}::${base.interfaceName || 'iface'}`,
homeKey: makeServerKey(home),
jumphostKey: makeServerKey(jumphost),
home: compactServer(home),
jumphost: compactServer(jumphost),
...base,
});
continue;
}
if ((t1 === 'jumphost' && t2 === 'exit') || (t1 === 'exit' && t2 === 'jumphost')) {
const jumphost = t1 === 'jumphost' ? s1 : s2;
const exit = t1 === 'exit' ? s1 : s2;
const gatewayIpForJumphost = t1 === 'jumphost' ? (iface.remoteIp || null) : (iface.localIp || null);
jhToExit.push({
id: `${makeServerKey(jumphost)}->${makeServerKey(exit)}::${base.interfaceName || 'iface'}`,
jumphostKey: makeServerKey(jumphost),
exitKey: makeServerKey(exit),
gatewayIpForJumphost,
jumphost: compactServer(jumphost),
exit: compactServer(exit),
...base,
});
}
}
return { homeToJh, jhToExit };
}
function enrichProbabilities(candidates, scoreField = 'score', aiSettings = DEFAULT_AI_SETTINGS) {
if (!Array.isArray(candidates) || candidates.length === 0) return [];
const probs = pairProbability(candidates, (c) => c[scoreField] * aiSettings.probabilityScale);
return candidates.map((c, i) => ({
...c,
probabilityOptimal: Number(probs[i].toFixed(2)),
}));
}
function groupBy(items, keyGetter) {
const m = new Map();
for (const item of items) {
const key = keyGetter(item);
if (!m.has(key)) m.set(key, []);
m.get(key).push(item);
}
return m;
}
function collectServerRefs(server) {
return [server?.id, server?.ip, server?.dns]
.filter(Boolean)
.map((x) => String(x).trim());
}
function buildServerIndex(servers) {
const byRef = new Map();
(Array.isArray(servers) ? servers : []).forEach((s) => {
collectServerRefs(s).forEach((ref) => byRef.set(ref, s));
});
return byRef;
}
function getServerByAnyRef(ref, byRef) {
const r = String(ref || '').trim();
if (!r) return null;
return byRef.get(r) || null;
}
function gatewayBelongsToJumphost(gw, jumphost, byRef) {
if (!gw || !jumphost) return false;
const gwServer = getServerByAnyRef(gw.serverId, byRef);
if (!gwServer) return false;
const jhRefs = new Set(collectServerRefs(jumphost));
return collectServerRefs(gwServer).some((r) => jhRefs.has(r));
}
function resolveParentGatewayRef(parentId, networkConfig) {
const gateways = Array.isArray(networkConfig?.gateways) ? networkConfig.gateways : [];
const interfaces = Array.isArray(networkConfig?.tunnelInterfaces)
? networkConfig.tunnelInterfaces
: [];
const gw = gateways.find((x) => x && x.id === parentId);
if (gw) return { type: 'gateway', value: gw };
const iface = interfaces.find((x) => x && x.id === parentId);
if (iface) return { type: 'interface', value: iface };
return null;
}
function getInterfaceGatewayIpForJumphost(iface, jumphost, byRef) {
const s1 = getServerByAnyRef(iface?.serverId, byRef);
const s2 = getServerByAnyRef(iface?.serverId2, byRef);
if (!s1 || !s2 || !jumphost) return null;
const jhRefs = new Set(collectServerRefs(jumphost));
const s1isJh = collectServerRefs(s1).some((r) => jhRefs.has(r));
const s2isJh = collectServerRefs(s2).some((r) => jhRefs.has(r));
if (s1isJh) return iface.remoteIp || null;
if (s2isJh) return iface.localIp || null;
return null;
}
function computeDistanceAdjustedScore(score, distance, aiSettings) {
const d = Number.isFinite(Number(distance)) ? Number(distance) : 1;
const penaltySteps = Math.max(0, d - 1);
return clamp(Number(score || 0) - penaltySteps * aiSettings.distancePenaltyPerStep, 0, 1);
}
function buildRecursiveGatewayOptionsForJumphost({
jumphost,
jumphostCandidates,
networkConfig,
servers,
aiSettings,
}) {
const byRef = buildServerIndex(servers);
const gateways = Array.isArray(networkConfig?.gateways) ? networkConfig.gateways : [];
const recursive = gateways.filter(
(gw) =>
gw &&
String(gw.type || '').toLowerCase() === 'recursive' &&
gatewayBelongsToJumphost(gw, jumphost, byRef)
);
if (recursive.length === 0) return [];
const options = [];
for (const rgw of recursive) {
const parentRefs =
Array.isArray(rgw.parentGateways) && rgw.parentGateways.length > 0
? rgw.parentGateways
: rgw.parentGatewayId
? [{ id: rgw.parentGatewayId, distance: undefined }]
: [];
const mapped = [];
for (const pref of parentRefs) {
const resolved = resolveParentGatewayRef(pref?.id, networkConfig);
if (!resolved) continue;
const distance = Number.isFinite(Number(pref?.distance)) ? Number(pref.distance) : 1;
let physicalGatewayIp = null;
if (resolved.type === 'gateway') {
physicalGatewayIp = resolved.value?.ip || null;
} else if (resolved.type === 'interface') {
physicalGatewayIp = getInterfaceGatewayIpForJumphost(
resolved.value,
jumphost,
byRef
);
}
if (!physicalGatewayIp) continue;
const candidate = (Array.isArray(jumphostCandidates) ? jumphostCandidates : []).find(
(c) => String(c.gatewayIpForJumphost || '') === String(physicalGatewayIp)
);
if (!candidate) continue;
mapped.push({
distance,
physicalGatewayIp,
candidate,
scoreWithDistance: computeDistanceAdjustedScore(candidate.score, distance, aiSettings),
});
}
if (mapped.length === 0) continue;
mapped.sort((a, b) => b.scoreWithDistance - a.scoreWithDistance);
const best = mapped[0];
options.push({
recursiveGateway: rgw.ip || null,
recursiveGatewayId: rgw.id || null,
recursiveDescription: rgw.description || '',
tunnelGatewayIp: best.physicalGatewayIp,
interfaceName: best.candidate.interfaceName || null,
distance: best.distance,
exit: best.candidate.exit,
baseScore: best.candidate.score,
score: Number(best.scoreWithDistance.toFixed(4)),
pingMs: best.candidate.pingMs,
speedMbps: best.candidate.speedMbps,
sourceCandidateId: best.candidate.id,
});
}
return options.sort((a, b) => b.score - a.score);
}
function buildCommunityOptimization({
exitsByJh,
serverFiltersByServerId,
communitiesIndex,
networkConfig,
servers,
aiSettings,
}) {
const jumphostByCommunity = [];
for (const [jumphostKey, rawCandidates] of exitsByJh.entries()) {
const candidates = enrichProbabilities([...rawCandidates].sort((a, b) => b.score - a.score), 'score', aiSettings);
const jumphost = candidates[0]?.jumphost || null;
const recursiveOptionsRaw = buildRecursiveGatewayOptionsForJumphost({
jumphost,
jumphostCandidates: candidates,
networkConfig,
servers,
aiSettings,
});
const recursiveOptions = enrichProbabilities(recursiveOptionsRaw, 'score', aiSettings);
const gatewayBestMap = new Map();
for (const c of candidates) {
const gw = String(c.gatewayIpForJumphost || '').trim();
if (!gw) continue;
if (!gatewayBestMap.has(gw) || gatewayBestMap.get(gw).score < c.score) {
gatewayBestMap.set(gw, c);
}
}
const recursiveByIp = new Map();
const recursiveById = new Map();
recursiveOptions.forEach((o) => {
if (o.recursiveGateway) recursiveByIp.set(String(o.recursiveGateway), o);
if (o.recursiveGatewayId) recursiveById.set(String(o.recursiveGatewayId), o);
});
const filters = serverFiltersByServerId.get(jumphostKey) || [];
if (filters.length === 0) {
jumphostByCommunity.push({
jumphost,
candidates,
recursiveGatewayOptions: recursiveOptions,
recommendations: [],
});
continue;
}
const recommendations = filters.map((f) => {
const currentGateway = String(f.gateway || '').trim();
const pinned = findPinnedCommunityGateway(
aiSettings.pinnedCommunityGateways,
jumphost,
f.community
);
const currentCandidate =
recursiveByIp.get(currentGateway) ||
recursiveById.get(currentGateway) ||
gatewayBestMap.get(currentGateway) ||
null;
const pinnedCandidate = pinned
? recursiveByIp.get(pinned.gateway) ||
recursiveById.get(pinned.gateway) ||
gatewayBestMap.get(pinned.gateway) ||
null
: null;
const recommendedCandidate = pinned
? pinnedCandidate
: (recursiveOptions.length > 0 ? recursiveOptions[0] : null) || candidates[0] || null;
const communityInfo = communitiesIndex.get(String(f.community)) || null;
const recommendedGatewayRef = normalizeRef(
recommendedCandidate?.recursiveGateway || recommendedCandidate?.gatewayIpForJumphost
);
const currentGatewayRef = normalizeRef(
currentCandidate?.recursiveGateway || currentCandidate?.gatewayIpForJumphost || currentGateway
);
const shouldSwitch = pinned
? Boolean(recommendedGatewayRef && recommendedGatewayRef !== currentGatewayRef)
: Boolean(
recommendedCandidate &&
currentCandidate &&
recommendedGatewayRef !== currentGatewayRef &&
(recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >=
aiSettings.minProbabilityGainForSwitch
);
return {
community: String(f.community),
communityInfo: communityInfo || {
value: String(f.community),
name: '',
description: f.description || '',
tags: [],
},
currentGateway: currentGateway || null,
current: currentCandidate ? {
gateway: currentCandidate.recursiveGateway || currentCandidate.gatewayIpForJumphost || null,
tunnelGateway: currentCandidate.tunnelGatewayIp || currentCandidate.gatewayIpForJumphost || null,
interfaceName: currentCandidate.interfaceName || null,
distance: currentCandidate.distance != null ? currentCandidate.distance : null,
exit: currentCandidate.exit,
score: currentCandidate.score,
probabilityOptimal: currentCandidate.probabilityOptimal,
pingMs: currentCandidate.pingMs,
speedMbps: currentCandidate.speedMbps,
speedDownloadMbps: currentCandidate.speedDownloadMbps,
speedUploadMbps: currentCandidate.speedUploadMbps,
} : null,
recommended: recommendedCandidate ? {
gateway: recommendedCandidate.recursiveGateway || recommendedCandidate.gatewayIpForJumphost || null,
tunnelGateway: recommendedCandidate.tunnelGatewayIp || recommendedCandidate.gatewayIpForJumphost || null,
interfaceName: recommendedCandidate.interfaceName || null,
distance: recommendedCandidate.distance != null ? recommendedCandidate.distance : null,
exit: recommendedCandidate.exit,
score: recommendedCandidate.score,
probabilityOptimal: recommendedCandidate.probabilityOptimal,
pingMs: recommendedCandidate.pingMs,
speedMbps: recommendedCandidate.speedMbps,
speedDownloadMbps: recommendedCandidate.speedDownloadMbps,
speedUploadMbps: recommendedCandidate.speedUploadMbps,
} : null,
pinnedGateway: pinned?.gateway || null,
pinnedBySettings: Boolean(pinned),
pinnedGatewayResolved: Boolean(pinned && recommendedCandidate),
shouldSwitch,
};
});
jumphostByCommunity.push({
jumphost,
candidates,
recursiveGatewayOptions: recursiveOptions,
recommendations,
});
}
return jumphostByCommunity;
}
async function getRouteOptimizer(req, res) {
try {
const [servers, networkConfig, networkMapCache, communitiesIndex, serverFiltersByServerId, aiSettings] = await Promise.all([
readServersFromS3(),
loadNetworkConfig(),
loadNetworkMapCache(),
loadCommunitiesIndex(),
loadServerFiltersByServerId(),
loadAiSettings(),
]);
const tunnelInterfaces = Array.isArray(networkConfig.tunnelInterfaces)
? networkConfig.tunnelInterfaces
: [];
const { homeToJh, jhToExit } = buildInterfaceCandidates({
servers: Array.isArray(servers) ? servers : [],
tunnelInterfaces,
pingMap: networkMapCache.pingMap,
speedMap: networkMapCache.speedMap,
cacheUpdatedAt: networkMapCache.updatedAt,
aiSettings,
});
const homeGroups = groupBy(homeToJh, (x) => x.homeKey);
const exitsByJh = groupBy(jhToExit, (x) => x.jumphostKey);
const homes = [];
for (const [homeKey, hjListRaw] of homeGroups.entries()) {
const hjList = enrichProbabilities(hjListRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
const bestHomeToJumphost = hjList[0] || null;
const fullRoutesRaw = [];
for (const hj of hjList) {
const exitCandidates = exitsByJh.get(hj.jumphostKey) || [];
for (const jhExit of exitCandidates) {
const combinedScore = clamp(
hj.score * aiSettings.combineHomeToJumphostWeight +
jhExit.score * aiSettings.combineJumphostToExitWeight,
0,
1
);
fullRoutesRaw.push({
id: `${hj.id}>>>${jhExit.id}`,
home: hj.home,
jumphost: hj.jumphost,
exit: jhExit.exit,
homeToJumphost: {
interfaceName: hj.interfaceName,
pingMs: hj.pingMs,
speedMbps: hj.speedMbps,
speedDownloadMbps: hj.speedDownloadMbps,
speedUploadMbps: hj.speedUploadMbps,
score: hj.score,
confidence: hj.confidence,
},
jumphostToExit: {
interfaceName: jhExit.interfaceName,
pingMs: jhExit.pingMs,
speedMbps: jhExit.speedMbps,
speedDownloadMbps: jhExit.speedDownloadMbps,
speedUploadMbps: jhExit.speedUploadMbps,
score: jhExit.score,
confidence: jhExit.confidence,
},
score: Number(combinedScore.toFixed(4)),
confidence: Number(((hj.confidence + jhExit.confidence) / 2).toFixed(4)),
});
}
}
const fullRoutes = enrichProbabilities(fullRoutesRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
const bestFullRoute = fullRoutes[0] || null;
homes.push({
home: bestHomeToJumphost?.home || null,
bestHomeToJumphost,
bestFullRoute,
homeToJumphostCandidates: hjList,
fullRouteCandidates: fullRoutes,
});
}
// Also expose jumphost->exit view for transparency
const jumphostSummaries = [];
for (const [jumphostKey, exitListRaw] of exitsByJh.entries()) {
const candidates = enrichProbabilities(exitListRaw.sort((a, b) => b.score - a.score), 'score', aiSettings);
jumphostSummaries.push({
jumphost: candidates[0]?.jumphost || null,
bestCandidate: candidates[0] || null,
candidates,
});
}
const communityOptimization = buildCommunityOptimization({
exitsByJh,
serverFiltersByServerId,
communitiesIndex,
networkConfig,
servers,
aiSettings,
});
homes.sort((a, b) => {
const pa = a.bestFullRoute?.probabilityOptimal ?? 0;
const pb = b.bestFullRoute?.probabilityOptimal ?? 0;
return pb - pa;
});
return res.json({
ok: true,
generatedAt: Date.now(),
metricsUpdatedAt: networkMapCache.updatedAt || null,
aiSettingsUsed: aiSettings,
homes,
jumphostToExitByJumphost: jumphostSummaries,
communityOptimizationByJumphost: communityOptimization,
totals: {
homes: homes.length,
homeToJumphostCandidates: homeToJh.length,
jumphostToExitCandidates: jhToExit.length,
jumphostsWithCommunityFilters: communityOptimization.filter((x) => (x.recommendations || []).length > 0).length,
},
});
} catch (error) {
console.error('[route-optimizer] getRouteOptimizer:', error);
return sendError(
res,
500,
error?.message || 'Ошибка расчёта оптимального маршрута',
'E_ROUTE_OPTIMIZER'
);
}
}
module.exports = {
getRouteOptimizer,
};