Files
router-lists-ui/backend/services/evobgpClient.js
T
2026-04-20 01:17:00 +07:00

427 lines
14 KiB
JavaScript

/**
* Серверный клиент 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,
};