/** * 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, };