/** * Серверный клиент EvoBGP API (Bearer, base /v1). Логика выборки совместима с frontend/src/lib/evobgpData.js */ const { normalizeEvobgpBaseUrl } = require('../routes/evobgpProxyRoutes'); const PAGE_LIMIT = 500; const DEFAULT_TIMEOUT_MS = Number(process.env.EVOBGP_CLIENT_TIMEOUT_MS) || 30000; function getBaseAndToken() { const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || ''); const token = String(process.env.EVOBGP_API_TOKEN || '').trim(); return { base, token }; } function isConfigured() { const { base, token } = getBaseAndToken(); return Boolean(base && token); } async function evobgpRequest(method, pathname, { query, body } = {}) { const { base, token } = getBaseAndToken(); if (!base || !token) { const err = new Error('EvoBGP not configured'); err.code = 'E_EVOBGP_NOT_CONFIGURED'; throw err; } const u = new URL(pathname.startsWith('/') ? pathname.slice(1) : pathname, base.endsWith('/') ? base : `${base}/`); if (query && typeof query === 'object') { for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== null) u.searchParams.set(k, String(v)); } } const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS); try { const res = await fetch(u.toString(), { method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: ctrl.signal, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) { data = text; } if (!res.ok) { const err = new Error(typeof data === 'object' && data?.message ? data.message : res.statusText); err.code = 'E_EVOBGP_HTTP'; err.status = res.status; err.body = data; throw err; } return data; } finally { clearTimeout(t); } } async function paginate(path, params = {}) { const items = []; let cursor = null; while (true) { const res = await evobgpRequest('GET', path, { query: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) }, }); const chunk = Array.isArray(res?.items) ? res.items : []; items.push(...chunk); if (!res?.has_more || !res?.next_cursor) break; cursor = res.next_cursor; } return items; } async function fetchRouterListsCatalog() { return evobgpRequest('GET', '/router-lists/catalog'); } function getCatalogItems(section) { if (!section || typeof section !== 'object') return []; return Array.isArray(section.items) ? section.items : []; } async function listCommunities() { let communities = []; try { const catalog = await fetchRouterListsCatalog(); communities = getCatalogItems(catalog.communities); } catch { communities = await paginate('/communities'); } return communities.map((c) => ({ id: c.id, value: String(c.community || ''), name: String(c.title || ''), description: '', tags: [], color: '', icon: '', })); } async function getOrCreateModule(moduleType, defaultName) { const modules = await paginate('/modules', { type: moduleType }); if (modules.length > 0) return modules[0]; const created = await evobgpRequest('POST', '/modules', { body: { type: moduleType, name: defaultName, enabled: true }, }); return created?.data || created; } function normalizeList(items, keyName) { const out = []; const seen = new Set(); for (const item of items || []) { const key = String(item?.[keyName] || '').trim().toLowerCase(); if (!key || seen.has(key)) continue; seen.add(key); out.push({ ...item, [keyName]: key }); } return out; } async function buildCommunityMaps() { const communities = await listCommunities(); const byId = new Map(communities.map((c) => [c.id, c.value])); const byValue = new Map(communities.map((c) => [c.value, c.id])); return { communities, byId, byValue }; } function mapCommunityToValue(communityId, byId) { if (!communityId) return ''; return byId.get(communityId) || String(communityId); } async function syncEntries({ moduleType, moduleName, listPath, keyName, createBody, patchBody, currentItems, originalItems, requireCommunity = false, }) { const module = await getOrCreateModule(moduleType, moduleName); const moduleId = module.id; const { byId, byValue } = await buildCommunityMaps(); const existing = await paginate(`/modules/${moduleId}/${listPath}`); const existingByKey = new Map(); for (const row of existing) { const entryKey = String(row?.[keyName] || '').trim().toLowerCase(); if (!entryKey) continue; existingByKey.set(entryKey, row); } const normalizedCurrent = normalizeList(currentItems, keyName); const normalizedOriginal = normalizeList(originalItems, keyName); const currentMap = new Map(normalizedCurrent.map((i) => [String(i[keyName]).toLowerCase(), i])); const originalKeys = new Set(normalizedOriginal.map((i) => String(i[keyName]).toLowerCase())); for (const key of originalKeys) { if (!currentMap.has(key) && existingByKey.has(key)) { const row = existingByKey.get(key); await evobgpRequest('DELETE', `/modules/${moduleId}/${listPath}/${row.id}`); existingByKey.delete(key); } } for (const [key, item] of currentMap.entries()) { const rawCommunity = String(item.community || '').trim(); const communityId = byValue.get(rawCommunity) || null; if (requireCommunity && !communityId) { throw new Error(`Community "${rawCommunity}" не найдена в справочнике`); } const existingRow = existingByKey.get(key); if (!existingRow) { await evobgpRequest('POST', `/modules/${moduleId}/${listPath}`, { body: createBody(item, communityId) }); continue; } const currentCommunity = mapCommunityToValue(existingRow.community_id, byId); if (currentCommunity !== rawCommunity) { await evobgpRequest('PATCH', `/modules/${moduleId}/${listPath}/${existingRow.id}`, { body: patchBody(item, communityId), }); } } } async function fetchDomainsData() { try { const catalog = await fetchRouterListsCatalog(); const communities = getCatalogItems(catalog.communities); const byId = new Map(communities.map((c) => [c.id, String(c.community || '')])); const rows = getCatalogItems(catalog.domains); return rows .map((row) => ({ id: row?.entry?.id || row?.id || '', domain: String(row?.entry?.fqdn || row?.fqdn || '') .trim() .toLowerCase(), community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId), })) .filter((row) => row.domain); } catch { const module = await getOrCreateModule('DOMAINS', 'Domains'); const { byId } = await buildCommunityMaps(); const rows = await paginate(`/modules/${module.id}/domain-entries`); return rows.map((row) => ({ id: row.id, domain: String(row.fqdn || '') .trim() .toLowerCase(), community: mapCommunityToValue(row.community_id, byId), })); } } async function fetchIpRangesData() { try { const catalog = await fetchRouterListsCatalog(); const communities = getCatalogItems(catalog.communities); const byId = new Map(communities.map((c) => [c.id, String(c.community || '')])); const rows = getCatalogItems(catalog.ip_ranges); return rows .map((row) => ({ id: row?.entry?.id || row?.id || '', ipRange: String(row?.entry?.prefix || row?.prefix || '').trim(), community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId), })) .filter((row) => row.ipRange); } catch { const module = await getOrCreateModule('IP_RANGES', 'IP ranges'); const { byId } = await buildCommunityMaps(); const rows = await paginate(`/modules/${module.id}/ip-range-entries`); return rows.map((row) => ({ id: row.id, ipRange: String(row.prefix || '').trim(), community: mapCommunityToValue(row.community_id, byId), })); } } async function fetchAsnsData() { try { const catalog = await fetchRouterListsCatalog(); const communities = getCatalogItems(catalog.communities); const byId = new Map(communities.map((c) => [c.id, String(c.community || '')])); const rows = getCatalogItems(catalog.asns); return rows .map((row) => ({ id: row?.entry?.id || row?.id || '', asn: String(row?.entry?.asn || row?.asn || '').trim(), community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId), })) .filter((row) => row.asn); } catch { const module = await getOrCreateModule('AS_PREFIXES', 'ASNs'); const { byId } = await buildCommunityMaps(); const rows = await paginate(`/modules/${module.id}/as-entries`); return rows.map((row) => ({ id: row.id, asn: String(row.asn || '').trim(), community: mapCommunityToValue(row.community_id, byId), })); } } /** Auto-URL: секция каталога или модуль AUTO_URLS / URL_LIST (настраивается env). */ async function fetchAutoUrlsData() { try { const catalog = await fetchRouterListsCatalog(); for (const name of ['auto_urls', 'auto_url', 'urls', 'url_list']) { const sec = catalog[name]; const items = getCatalogItems(sec); if (items.length) { const communities = getCatalogItems(catalog.communities); const byId = new Map(communities.map((c) => [c.id, String(c.community || '')])); return items.map((row) => { const url = String(row?.entry?.url || row?.entry?.fqdn || row?.url || row?.fqdn || '').trim(); const community = mapCommunityToValue( row?.entry?.community_id || row?.community_id || null, byId, ); return { url, community }; }).filter((r) => r.url); } } } catch (_) {} const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS'; try { const module = await getOrCreateModule(modType, 'Auto URLs'); const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries'; const rows = await paginate(`/modules/${module.id}/${listPath}`); const { byId } = await buildCommunityMaps(); return rows.map((row) => ({ url: String(row.url || row.fqdn || '').trim(), community: mapCommunityToValue(row.community_id, byId), })).filter((r) => r.url); } catch { return []; } } async function saveDomainsData(currentItems, originalItems) { await syncEntries({ moduleType: 'DOMAINS', moduleName: 'Domains', listPath: 'domain-entries', keyName: 'domain', currentItems, originalItems, createBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }), patchBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }), }); } async function saveIpRangesData(currentItems, originalItems) { await syncEntries({ moduleType: 'IP_RANGES', moduleName: 'IP ranges', listPath: 'ip-range-entries', keyName: 'ipRange', currentItems, originalItems, requireCommunity: true, createBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }), patchBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }), }); } async function saveAsnsData(currentItems, originalItems) { await syncEntries({ moduleType: 'AS_PREFIXES', moduleName: 'ASNs', listPath: 'as-entries', keyName: 'asn', currentItems, originalItems, createBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }), patchBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }), }); } async function saveCommunitiesData(currentItems) { const existing = await paginate('/communities'); const existingByValue = new Map(existing.map((c) => [String(c.community || ''), c])); const nextByValue = new Map(); for (const item of currentItems || []) { const value = String(item?.value || '').trim(); if (!value) continue; nextByValue.set(value, item); } for (const [value, row] of existingByValue.entries()) { if (!nextByValue.has(value)) { await evobgpRequest('DELETE', `/communities/${row.id}`); } } for (const [value, item] of nextByValue.entries()) { const title = String(item?.name || '').trim(); const row = existingByValue.get(value); if (!row) { await evobgpRequest('POST', '/communities', { body: { community: value, title } }); continue; } if (String(row.title || '') !== title) { await evobgpRequest('PATCH', `/communities/${row.id}`, { body: { community: value, title } }); } } } async function saveAutoUrlsData(urls) { const originalItems = await fetchAutoUrlsData(); const list = (urls || []).map((u) => ({ url: String(u?.url || '').trim(), community: String(u?.community || '').trim(), })).filter((u) => u.url && u.community); const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS'; const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries'; const keyName = process.env.EVOBGP_AUTO_URL_KEY_FIELD || 'url'; const currentItems = list.map((u) => ({ [keyName]: u.url, community: u.community })); const origMapped = originalItems.map((u) => ({ [keyName]: u.url, community: u.community })); await syncEntries({ moduleType: modType, moduleName: 'Auto URLs', listPath, keyName, currentItems, originalItems: origMapped, requireCommunity: true, createBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }), patchBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }), }); } module.exports = { isConfigured, evobgpRequest, paginate, fetchRouterListsCatalog, listCommunities, fetchDomainsData, fetchIpRangesData, fetchAsnsData, fetchAutoUrlsData, saveCommunitiesData, saveDomainsData, saveIpRangesData, saveAsnsData, saveAutoUrlsData, buildCommunityMaps, mapCommunityToValue, };