feat: Оптимизация структуры server.js с модульным подходом, добавление новых маршрутов и улучшение обработки ошибок. Обновление валидаторов для MikroTik с использованием общих функций. Улучшение кода компонентов приложения для повышения читаемости и производительности.

This commit is contained in:
2025-10-03 01:49:51 +07:00
parent cdf692a2aa
commit 6231b645b6
17 changed files with 5149 additions and 2295 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* Роуты для работы с communities (справочник BGP Community)
*/
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const validators = require('../lib/validators');
const S3_KEY = 'bgp_data/communities.json';
// GET /api/communities
async function getCommunities(req, res) {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
const fileContent = await streamToString(data.Body);
let communities = [];
try {
const parsed = JSON.parse(fileContent);
communities = Array.isArray(parsed) ? parsed : [];
} catch (parseError) {
console.error('Error parsing communities.json:', parseError);
communities = [];
}
// Нормализация
communities = communities
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
.map((c) => ({
value: String(c.value).trim(),
name: c.name ? String(c.name) : '',
description: c.description ? String(c.description) : '',
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
color: c.color ? String(c.color) : ''
}));
res.json(communities);
} catch (error) {
if (error.code === 'NoSuchKey') {
return res.json([]);
}
console.error('Error reading communities from S3:', error);
return sendError(res, 500, 'Error reading communities from S3', 'E_S3');
}
}
// POST /api/communities
async function postCommunities(req, res) {
const { communities } = req.body;
if (!Array.isArray(communities)) {
return sendError(res, 400, 'communities must be an array', 'E_BAD_REQUEST');
}
const seen = new Set();
const normalized = [];
const validationErrors = [];
for (let i = 0; i < communities.length; i++) {
const entry = communities[i] || {};
const value = typeof entry.value === 'string' ? entry.value.trim() : '';
if (!value) {
validationErrors.push(`Community at index ${i} is missing required field: value`);
continue;
}
if (!validators.isValidCommunity(value)) {
validationErrors.push(`Community at index ${i} has invalid value: ${value}`);
continue;
}
if (seen.has(value)) {
validationErrors.push(`Duplicate community value at index ${i}: ${value}`);
continue;
}
seen.add(value);
normalized.push({
value,
name: entry.name ? String(entry.name) : '',
description: entry.description ? String(entry.description) : '',
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '',
color: entry.color ? String(entry.color) : '',
category: entry.category ? String(entry.category) : '',
priority: typeof entry.priority === 'number' ? entry.priority : 0,
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
});
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { errors: validationErrors });
}
try {
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: S3_KEY,
Body: JSON.stringify(normalized, null, 2),
ContentType: 'application/json',
}));
invalidateCacheForKey(S3_KEY);
const { headMeta } = require('../services/s3Service');
const meta = await headMeta(S3_KEY);
return sendOk(res, meta);
} catch (error) {
console.error('Error writing communities to S3:', error);
return sendError(res, 500, 'Error writing communities to S3', 'E_S3');
}
}
// GET /api/communities/stats
async function getCommunityStats(req, res) {
try {
const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })),
]);
const communityUsage = new Map();
// Подсчет использования в доменах
if (domainsRes.status === 'fulfilled') {
const text = await streamToString(domainsRes.value.Body);
text.split('\n').filter(Boolean).forEach(line => {
const parts = line.trim().split(/\s+/);
if (parts[1]) {
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
}
});
}
// Подсчет в IP ranges
if (ipRangesRes.status === 'fulfilled') {
const text = await streamToString(ipRangesRes.value.Body);
text.split('\n').filter(Boolean).forEach(line => {
const parts = line.trim().split(/\s+/);
if (parts[1]) {
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
}
});
}
// Подсчет в ASNs
if (asnsRes.status === 'fulfilled') {
const text = await streamToString(asnsRes.value.Body);
text.split('\n').filter(Boolean).forEach(line => {
const parts = line.trim().split(/\s+/);
if (parts[1]) {
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
}
});
}
// Подсчет в фильтрах
if (filtersRes.status === 'fulfilled') {
try {
const text = await streamToString(filtersRes.value.Body);
const filters = JSON.parse(text);
if (Array.isArray(filters)) {
filters.forEach(f => {
if (f.community) {
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
}
});
}
} catch {}
}
const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({
community,
count,
})).sort((a, b) => b.count - a.count);
res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) });
} catch (error) {
console.error('Error getting community stats:', error);
return sendError(res, 500, 'Error getting community stats', 'E_S3');
}
}
module.exports = {
getCommunities,
postCommunities,
getCommunityStats,
};
+137
View File
@@ -0,0 +1,137 @@
/**
* Роуты для фильтров и генерации конфигураций MikroTik
*/
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { buildNestedGatewayBlocks } = require('../utils/helpers');
// GET /api/filters/generate-config
async function generateConfig(req, res) {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' })).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' }));
const fileContent = await streamToString(data.Body);
let filters = [];
try {
filters = JSON.parse(fileContent);
if (!Array.isArray(filters)) filters = [];
} catch (parseError) {
console.error('Error parsing filters.json:', parseError);
filters = [];
}
if (filters.length === 0) {
return res.json({ config: '// No filters to generate configuration' });
}
// Group filters by gateway
const gatewayGroups = {};
filters.forEach(filter => {
if (!gatewayGroups[filter.gateway]) {
gatewayGroups[filter.gateway] = [];
}
gatewayGroups[filter.gateway].push(filter.community);
});
let config = '// Frouting filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
config += '/routing filter bgp-in-tmp {\n';
config += buildNestedGatewayBlocks(gatewayGroups, 4);
config += '}\n';
res.json({ config });
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json({ config: '// filters.json file not found' });
} else {
console.error(error);
return sendError(res, 500, 'Error generating configuration', 'E_S3');
}
}
}
// POST /api/filters/export-config
async function exportConfig(req, res) {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' }));
const fileContent = await streamToString(data.Body);
let filters = [];
try {
filters = JSON.parse(fileContent);
if (!Array.isArray(filters)) filters = [];
} catch (parseError) {
console.error('Error parsing filters.json:', parseError);
filters = [];
}
if (filters.length === 0) {
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
}
const gatewayGroups = {};
filters.forEach(filter => {
if (!gatewayGroups[filter.gateway]) {
gatewayGroups[filter.gateway] = [];
}
gatewayGroups[filter.gateway].push(filter.community);
});
let config = '// Frouting filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
config += '/routing filter bgp-in-tmp {\n';
config += buildNestedGatewayBlocks(gatewayGroups, 4);
config += '}\n';
await writeS3TextObject('mikrotik-frouting-config.txt', config);
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error exporting configuration', 'E_S3');
}
}
// POST /api/server-filters/generate-config
async function generateServerFilterConfig(req, res) {
const { filters } = req.body;
if (!Array.isArray(filters) || filters.length === 0) {
return res.json({ config: '// No filters to generate configuration' });
}
const gatewayGroups = {};
filters.forEach(filter => {
if (!gatewayGroups[filter.gateway]) {
gatewayGroups[filter.gateway] = [];
}
gatewayGroups[filter.gateway].push(filter.community);
});
let config = '// Frouting filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
config += '/routing filter bgp-in-tmp {\n';
config += buildNestedGatewayBlocks(gatewayGroups, 4);
config += '}\n';
res.json({ config });
}
module.exports = {
generateConfig,
exportConfig,
generateServerFilterConfig,
};
+95
View File
@@ -0,0 +1,95 @@
/**
* Общие роуты для JSON данных (servers, filters, billing и т.д.)
*/
const { s3, BUCKET_NAME, writeS3JsonObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
/**
* Создать GET эндпоинт для JSON данных
*/
function createJsonDataGET(s3Key) {
return async (req, res) => {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
const fileContent = await streamToString(data.Body);
let items = [];
try {
items = JSON.parse(fileContent);
if (!Array.isArray(items)) {
items = [];
}
} catch (parseError) {
console.error(`Error parsing ${s3Key}:`, parseError);
items = [];
}
res.json(items);
} catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
}
};
}
/**
* Создать POST эндпоинт для JSON данных
*/
function createJsonDataPOST(s3Key, validateItem = null) {
return async (req, res) => {
const { domains: items } = req.body; // Используем 'domains' для обратной совместимости
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
// Валидация элементов, если предоставлена
if (validateItem) {
for (let i = 0; i < items.length; i++) {
const error = validateItem(items[i], i);
if (error) {
return sendError(res, 400, error, 'E_SCHEMA');
}
}
}
try {
const meta = await writeS3JsonObject(s3Key, items);
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3');
}
};
}
/**
* Создать роуты для JSON данных (GET + POST)
*/
function createJsonDataRoutes(s3Key, validateItem = null) {
return {
get: createJsonDataGET(s3Key),
post: createJsonDataPOST(s3Key, validateItem)
};
}
module.exports = {
createJsonDataRoutes,
createJsonDataGET,
createJsonDataPOST,
};
+528
View File
@@ -0,0 +1,528 @@
/**
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
*/
const { s3, BUCKET_NAME, writeS3TextObject, headMeta, streamToString } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler');
const { resourceToKey, toIso } = require('../utils/helpers');
const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
const validators = require('../lib/validators');
const net = require('net');
const http = require('http');
const https = require('https');
const { URL } = require('url');
// GET /api/s3/last-modified
async function getS3LastModified(req, res) {
try {
const keys = [
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
{ name: 'asns', key: 'bgp_data/asns.txt' },
{ name: 'servers', key: 'servers.json' },
{ name: 'filters', key: 'filters.json' },
{ name: 'ipRanges', key: 'bgp_data/ips.txt' },
{ name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' }
];
const results = await Promise.allSettled(
keys.map(k => headMeta(k.key))
);
const out = {};
results.forEach((r, idx) => {
const name = keys[idx].name;
if (r.status === 'fulfilled' && r.value) {
out[name] = r.value;
} else {
out[name] = null;
}
});
res.json(out);
} catch (error) {
console.error('Error fetching last modified dates from S3:', error);
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3');
}
}
// GET /api/history/:resource
async function getHistory(req, res) {
const { resource } = req.params;
const { countOnly, format } = req.query || {};
const key = resourceToKey(resource);
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
try {
const out = await s3.send(new ListObjectVersionsCommand({
Bucket: BUCKET_NAME,
Prefix: key,
MaxKeys: 50
}));
const versionsAll = (out.Versions || []).filter(v => v.Key === key);
if (countOnly === 'true') {
const total = versionsAll.length;
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
}
const versions = versionsAll.slice(0, 10).map(v => ({
versionId: v.VersionId,
isLatest: v.IsLatest,
lastModified: toIso(v.LastModified),
size: v.Size,
etag: v.ETag
}));
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
} catch (e) {
console.error('history error', e);
return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) });
}
}
// POST /api/history/:resource/rollback
async function postRollback(req, res) {
const { resource } = req.params;
const { versionId } = req.body || {};
const key = resourceToKey(resource);
if (!key || !versionId) {
return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST');
}
try {
await s3.send(new CopyObjectCommand({
Bucket: BUCKET_NAME,
CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`,
Key: key
}));
const meta = await headMeta(key);
return sendOk(res, meta);
} catch (e) {
console.error('rollback error', e);
return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) });
}
}
// GET /api/auto-urls
async function getAutoUrls(req, res) {
const { checkIfNoneMatch } = require('../middleware/errorHandler');
try {
const { HeadObjectCommand } = require('@aws-sdk/client-s3');
const head = await s3.send(new HeadObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt'
})).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt'
}));
const fileContent = await streamToString(data.Body);
const urls = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { url: parts[0] || '', community: parts[1] || '' };
});
res.json(urls);
} catch (error) {
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3');
}
}
}
// POST /api/auto-urls
async function postAutoUrls(req, res) {
const { urls } = req.body;
const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n');
try {
const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent);
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3');
}
}
// POST /api/auto-urls/process
async function processAutoUrls(req, res) {
try {
// Загрузка URL списков
let urls = [];
try {
const urlsData = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt'
}));
const urlsContent = await streamToString(urlsData.Body);
urls = urlsContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { url: parts[0] || '', community: parts[1] || '' };
});
} catch (error) {
if (error.code !== 'NoSuchKey') throw error;
}
if (urls.length === 0) {
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
}
// Загрузка текущих IP и доменов
let currentIps = [];
try {
const ipsData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' }));
const ipsContent = await streamToString(ipsData.Body);
currentIps = ipsContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { ipRange: parts[0] || '', community: parts[1] || '' };
});
} catch (error) {
if (error.code !== 'NoSuchKey') throw error;
}
let currentDomains = [];
try {
const dData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }));
const dContent = await streamToString(dData.Body);
currentDomains = dContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { domain: (parts[0] || '').toLowerCase(), community: parts[1] || '' };
});
} catch (error) {
if (error.code !== 'NoSuchKey') throw error;
}
// Обработка URL
const newIps = [];
const newDomains = [];
for (const urlData of urls) {
try {
const url = String(urlData.url || '').trim();
const community = String(urlData.community || '').trim();
if (!url || !community) continue;
const content = await new Promise((resolve, reject) => {
const protocol = url.startsWith('https:') ? https : http;
const req = protocol.get(url, (r) => {
let data = '';
r.on('data', (chunk) => { data += chunk; });
r.on('end', () => resolve(data));
});
req.on('error', reject);
req.setTimeout(15000, () => req.destroy());
});
const lines = content.split('\n');
for (const raw of lines) {
const line = String(raw || '').trim();
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
const token = line.split(/\s+/)[0]?.trim();
if (!token) continue;
if (validators.isValidCIDRv4(token) || validators.isValidCIDRv6(token)) {
newIps.push({ ipRange: token, community });
} else if (validators.isValidIPv4(token)) {
newIps.push({ ipRange: `${token}/32`, community });
} else if (validators.isValidDomain(token)) {
newDomains.push({ domain: token.toLowerCase(), community });
}
}
} catch (error) {
console.error(`Error processing URL ${urlData.url}:`, error);
}
}
// Объединение и дедупликация
const existingIpRanges = new Set(currentIps.map(i => i.ipRange));
const uniqueNewIps = newIps.filter(i => !existingIpRanges.has(i.ipRange));
const allIps = [...currentIps, ...uniqueNewIps];
const existingDomains = new Set(currentDomains.map(d => d.domain));
const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain));
const allDomains = [...currentDomains, ...uniqueNewDomains];
// Сохранение
const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n');
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/ips.txt',
Body: updatedIpsContent,
ContentType: 'text/plain'
}));
const updatedDomainsContent = allDomains.map(d => `${d.domain} ${d.community}`).join('\n');
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/domains_community.txt',
Body: updatedDomainsContent,
ContentType: 'text/plain'
}));
const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
return res.json({
success: true,
message: msg,
processedUrls: urls.length,
newIpsCount: uniqueNewIps.length,
newDomainsCount: uniqueNewDomains.length,
totalIpsCount: allIps.length,
totalDomainsCount: allDomains.length
});
} catch (error) {
console.error('Error processing auto URLs:', error);
return sendError(res, 500, 'Error processing auto URLs', 'E_S3');
}
}
// GET /api/servers/availability
const availabilityCache = { at: 0, data: null };
function tcpCheck(host, port, timeoutMs) {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const settle = (ok) => {
if (!settled) {
settled = true;
try { socket.destroy(); } catch {}
resolve(ok);
}
};
socket.setTimeout(timeoutMs, () => settle(false));
socket.once('error', () => settle(false));
socket.connect(port, host, () => settle(true));
});
}
function anyTrue(promises) {
return new Promise((resolve) => {
if (!Array.isArray(promises) || promises.length === 0) return resolve(false);
let remaining = promises.length;
let resolved = false;
for (const p of promises) {
Promise.resolve(p).then((v) => {
if (v && !resolved) { resolved = true; resolve(true); }
}).finally(() => {
remaining -= 1;
if (remaining === 0 && !resolved) resolve(false);
});
}
});
}
async function checkOneServerFast(srv, perSocketTimeoutMs = 800, perServerBudgetMs = 1000) {
const hosts = [];
if (srv.ip) hosts.push(String(srv.ip));
if (srv.dns) hosts.push(String(srv.dns));
const tryOneHost = (host) => anyTrue([
tcpCheck(host, 443, perSocketTimeoutMs),
tcpCheck(host, 80, perSocketTimeoutMs),
]);
const run = anyTrue(hosts.map((h) => tryOneHost(h)));
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), perServerBudgetMs));
return Promise.race([run, timeout]);
}
async function getServersAvailability(req, res) {
try {
const ttlSeconds = Math.max(0, Math.min(300, Number(req.query.ttlSeconds) || 30));
const now = Date.now();
if (availabilityCache.data && (now - availabilityCache.at) < ttlSeconds * 1000) {
return res.json({ ...availabilityCache.data, cached: true });
}
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' }));
let servers = [];
try {
servers = JSON.parse(await streamToString(data.Body));
if (!Array.isArray(servers)) servers = [];
} catch {
servers = [];
}
const checks = await Promise.allSettled(servers.map((s) => checkOneServerFast(s)));
const statuses = servers.map((s, i) => ({
ip: s.ip,
dns: s.dns,
online: checks[i].status === 'fulfilled' ? Boolean(checks[i].value) : false
}));
const online = statuses.filter((x) => x.online).length;
const payload = { online, total: servers.length, statuses };
availabilityCache.at = Date.now();
availabilityCache.data = payload;
res.json(payload);
} catch (e) {
console.error('availability error', e);
res.status(500).json({ online: 0, total: 0, statuses: [] });
}
}
// POST /api/update-bgp/background
async function updateBgpBackground(req, res) {
try {
const targetUrl = process.env.BGP_BACKGROUND_URL;
if (!targetUrl) {
return sendError(res, 500, 'BGP_BACKGROUND_URL is not configured', 'E_CONFIG');
}
const u = new URL(targetUrl);
const client = u.protocol === 'https:' ? https : http;
const options = {
method: 'POST',
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: `${u.pathname}${u.search || ''}`,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
timeout: 15000,
};
const body = req.body && Object.keys(req.body).length ? JSON.stringify(req.body) : '';
const upstream = client.request(options, (r) => {
let data = '';
r.setEncoding('utf8');
r.on('data', (chunk) => { data += chunk; });
r.on('end', () => {
const status = r.statusCode || 502;
try {
const json = data ? JSON.parse(data) : {};
return res.status(status).json(json);
} catch (_) {
return res.status(status).json({ ok: status >= 200 && status < 300, data });
}
});
});
upstream.on('timeout', () => {
try { upstream.destroy(); } catch {}
return sendError(res, 504, 'Upstream timeout', 'E_UPSTREAM_TIMEOUT');
});
upstream.on('error', (e) => {
return sendError(res, 502, 'Upstream error', 'E_UPSTREAM', { error: String(e?.message || e) });
});
if (body) upstream.write(body);
upstream.end();
} catch (e) {
return sendError(res, 500, 'Proxy error', 'E_PROXY', { error: String(e?.message || e) });
}
}
// GET /api/ws/url
async function getWsUrl(req, res) {
try {
const settings = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/rt_ui_settings.json'
})).then(async (d) => {
try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; }
}).catch(() => ({}));
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
return res.json({ url });
} catch (e) {
return res.json({ url: '' });
}
}
// GET/POST /api/ui-settings
async function getUiSettings(req, res) {
const { checkIfNoneMatch } = require('../middleware/errorHandler');
try {
const { HeadObjectCommand } = require('@aws-sdk/client-s3');
const head = await s3.send(new HeadObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/rt_ui_settings.json'
})).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/rt_ui_settings.json'
}));
const jsonText = await streamToString(data.Body);
let settings = {};
try {
const parsed = JSON.parse(jsonText);
if (parsed && typeof parsed === 'object') settings = parsed;
} catch (parseError) {
settings = {};
}
return res.json(settings);
} catch (error) {
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
return res.json({});
}
console.error('Error reading ui settings from S3:', error);
return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3');
}
}
async function postUiSettings(req, res) {
const { settings, etag } = req.body || {};
const payload = (settings && typeof settings === 'object') ? settings : {};
try {
const { headS3ObjectEtag } = require('../services/s3Service');
let current = null;
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
try { current = await headS3ObjectEtag('bgp_data/rt_ui_settings.json'); } catch {}
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
const meta = await headMeta('bgp_data/rt_ui_settings.json');
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta });
}
} catch {}
try {
const { writeS3JsonObject } = require('../services/s3Service');
const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload);
return sendOk(res, meta);
} catch (error) {
console.error('Error writing UI settings to S3:', error);
return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) });
}
}
module.exports = {
getS3LastModified,
getHistory,
postRollback,
getAutoUrls,
postAutoUrls,
processAutoUrls,
getServersAvailability,
updateBgpBackground,
getWsUrl,
getUiSettings,
postUiSettings,
};
+177
View File
@@ -0,0 +1,177 @@
/**
* Роуты для управления конфигурациями серверов
*/
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { createJsonDataRoutes } = require('./jsonDataRoutes');
// GET /api/server-configs (список серверов)
const serverConfigsListRoutes = createJsonDataRoutes('server-configs.json', (server, i) => {
if (!server.id || !server.name) {
return `Server at index ${i} is missing required fields`;
}
return null;
});
// GET /api/server-configs/:serverId (конкретная конфигурация)
async function getServerConfig(req, res) {
const { serverId } = req.params;
try {
const head = await s3.send(new HeadObjectCommand({
Bucket: BUCKET_NAME,
Key: `filter-manager/config-${serverId}.txt`
})).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: `filter-manager/config-${serverId}.txt`
}));
const config = await streamToString(data.Body);
res.json({ config });
} catch (error) {
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json({ config: '// Конфигурация не найдена' });
} else {
console.error(error);
return sendError(res, 500, 'Error reading server config from S3', 'E_S3');
}
}
}
// POST /api/server-configs/:serverId (сохранить конфигурацию)
async function postServerConfig(req, res) {
const { serverId } = req.params;
const { config } = req.body;
if (!config) {
return sendError(res, 400, 'Config is required', 'E_BAD_REQUEST');
}
try {
const meta = await writeS3TextObject(`filter-manager/config-${serverId}.txt`, config);
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing server config to S3', 'E_S3');
}
}
// DELETE /api/server-configs/:serverId (удалить только конфигурацию)
async function deleteServerConfig(req, res) {
const { serverId } = req.params;
try {
await deleteS3Object(`filter-manager/config-${serverId}.txt`);
const meta = await headMeta(`filter-manager/config-${serverId}.txt`);
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error deleting server config from S3', 'E_S3');
}
}
// DELETE /api/server-configs/:serverId/complete (удалить конфигурацию и фильтры)
async function deleteServerComplete(req, res) {
const { serverId } = req.params;
try {
await Promise.allSettled([
deleteS3Object(`filter-manager/config-${serverId}.txt`),
deleteS3Object(`filter-manager/server-filters-${serverId}.json`)
]);
return res.json({ ok: true, etag: null, lastModified: null, contentLength: null });
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error deleting server files from S3', 'E_S3');
}
}
// GET /api/server-filters/:serverId
async function getServerFilters(req, res) {
const { serverId } = req.params;
try {
const head = await s3.send(new HeadObjectCommand({
Bucket: BUCKET_NAME,
Key: `filter-manager/server-filters-${serverId}.json`
})).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: `filter-manager/server-filters-${serverId}.json`
}));
const fileContent = await streamToString(data.Body);
let filters = [];
try {
filters = JSON.parse(fileContent);
if (!Array.isArray(filters)) filters = [];
} catch (parseError) {
console.error('Error parsing server filters:', parseError);
filters = [];
}
res.json(filters);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading server filters from S3', 'E_S3');
}
}
}
// POST /api/server-filters/:serverId
async function postServerFilters(req, res) {
const { serverId } = req.params;
const { filters } = req.body;
if (!Array.isArray(filters)) {
return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST');
}
for (let i = 0; i < filters.length; i++) {
const filter = filters[i];
if (!filter.community || !filter.gateway) {
return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA');
}
}
try {
const { writeS3JsonObject } = require('../services/s3Service');
const meta = await writeS3JsonObject(`filter-manager/server-filters-${serverId}.json`, filters);
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing server filters to S3', 'E_S3');
}
}
module.exports = {
serverConfigsListRoutes,
getServerConfig,
postServerConfig,
deleteServerConfig,
deleteServerComplete,
getServerFilters,
postServerFilters,
};
+187
View File
@@ -0,0 +1,187 @@
/**
* Общие роуты для текстовых данных (domains, asns, ip-ranges)
* Уменьшают дублирование кода для похожих эндпоинтов
*/
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { splitWhitespace } = require('../utils/helpers');
const { s3, BUCKET_NAME } = require('../services/s3Service');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
// Кэш для countOnly запросов
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
function getCountOnlyCache(cacheKey) {
const v = countOnlyCache.map.get(cacheKey);
if (!v) return null;
if (Date.now() > v.at + countOnlyCache.ttlMs) {
countOnlyCache.map.delete(cacheKey);
return null;
}
return v.value;
}
function setCountOnlyCache(cacheKey, value) {
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
}
/**
* Создать GET эндпоинт для текстовых данных
* @param {string} s3Key - Ключ S3 файла
* @param {function} mapLine - Функция маппинга строки в объект
* @param {function} validate - Функция валидации данных
* @param {string} cachePrefix - Префикс для кэша
*/
function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
return async (req, res) => {
const { q = '', offset, limit, countOnly, format } = req.query || {};
try {
if (countOnly === 'true') {
const cacheKey = `${cachePrefix}:count:${q}`;
const cached = getCountOnlyCache(cacheKey);
if (cached != null) {
return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
}
const { total } = await streamPaginatedText({ key: s3Key, q, offset: 0, limit: 0, mapLine: () => ({}) });
setCountOnlyCache(cacheKey, total);
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
} else if (Number(limit) > 0) {
const { items, total } = await streamPaginatedText({
key: s3Key,
q,
offset: Number(offset) || 0,
limit: Number(limit) || 0,
mapLine
});
if (!validate(items)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
}
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
} else {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
const fileContent = await streamToString(data.Body);
const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim()));
if (!validate(items)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
}
if (format === 'std') {
return res.json({ items, total: items.length, meta: {} });
}
res.json(items);
}
} catch (error) {
if (error?.name === 'NoSuchKey' || error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) });
}
}
};
}
/**
* Создать POST эндпоинт для текстовых данных
* @param {string} s3Key - Ключ S3 файла
* @param {function} formatLine - Функция форматирования объекта в строку
* @param {function} validate - Функция валидации данных
* @param {function} validateItem - Функция валидации отдельного элемента (опционально)
*/
function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
return async (req, res) => {
const { domains, etag } = req.body; // Используем 'domains' для обратной совместимости
if (!Array.isArray(domains)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
// Валидация с помощью AJV
if (!validate(domains || [])) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
}
// Дополнительная валидация элементов, если предоставлена
if (validateItem) {
const validationErrors = [];
for (let i = 0; i < domains.length; i++) {
const errors = validateItem(domains[i], i);
if (errors.length > 0) {
validationErrors.push(...errors);
}
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10)
});
}
}
const fileContent = (domains || []).map(formatLine).filter(Boolean).join('\n');
try {
// Проверка ETag для optimistic concurrency
let current = null;
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
try {
current = await headS3ObjectEtag(s3Key);
} catch {}
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
const { headMeta } = require('../services/s3Service');
const meta = await headMeta(s3Key);
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', {
currentEtag: current,
meta
});
}
} catch {}
try {
const meta = await writeS3TextObject(s3Key, fileContent);
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3', {
error: String(error?.message || error)
});
}
};
}
/**
* Создать роуты для текстовых данных (GET + POST)
*/
function createTextDataRoutes(config) {
const {
s3Key,
mapLine,
formatLine,
validate,
validateItem,
cachePrefix
} = config;
return {
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix),
post: createTextDataPOST(s3Key, formatLine, validate, validateItem)
};
}
module.exports = {
createTextDataRoutes,
createTextDataGET,
createTextDataPOST,
};