// History endpoints are registered below after app is initialized require('dotenv').config(); const express = require('express'); const AWS = require('aws-sdk'); const cors = require('cors'); const path = require('path'); const compression = require('compression'); const Ajv = require('ajv'); const net = require('net'); const crypto = require('crypto'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const pino = require('pino'); const pinoHttp = require('pino-http'); const promClient = require('prom-client'); const app = express(); const port = Number(process.env.PORT) || 3001; // Logger with requestId const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); app.use(pinoHttp({ logger, genReqId: (req) => req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex'), serializers: { req(req) { return { id: req.id, method: req.method, url: req.url }; }, res(res) { return { statusCode: res.statusCode }; }, }, })); // CORS: по умолчанию максимально разрешаем, можно сузить через CORS_ORIGINS const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean); if (allowed.length > 0) { app.use(cors({ origin: (origin, cb) => { if (!origin || allowed.includes(origin)) return cb(null, true); return cb(new Error('CORS blocked')); }, credentials: true, })); } else { app.use(cors({ origin: true, credentials: true })); } // Разрешаем preflight для всех путей app.options('*', cors()); app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, crossOriginResourcePolicy: { policy: 'cross-origin' }, })); // Если приложение работает за прокси/ингрессом (Docker/NGINX), доверяем первому прокси для корректной работы rate-limit app.set('trust proxy', 1); app.disable('x-powered-by'); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 1000, standardHeaders: true, legacyHeaders: false, }); app.use(limiter); app.use(express.json()); app.use(compression()); // Disable Express auto-ETag to avoid weak ETags on JSON bodies app.set('etag', false); // Expose important headers to browser JS (for CORS) app.use((req, res, next) => { res.setHeader('Access-Control-Expose-Headers', 'ETag, Last-Modified, Content-Length-Source'); next(); }); // --- Metrics --- promClient.collectDefaultMetrics(); const httpDuration = new promClient.Histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration', labelNames: ['method', 'route', 'code'], buckets: [0.05,0.1,0.2,0.5,1,2,5] }); const httpErrors = new promClient.Counter({ name: 'http_errors_total', help: 'HTTP error count', labelNames: ['route','code'] }); app.use((req, res, next) => { const start = process.hrtime.bigint(); res.on('finish', () => { try { const dur = Number(process.hrtime.bigint() - start) / 1e9; httpDuration.labels(req.method, req.route?.path || req.path, String(res.statusCode)).observe(dur); if (res.statusCode >= 400) httpErrors.labels(req.route?.path || req.path, String(res.statusCode)).inc(); } catch {} }); next(); }); app.get('/health', (req, res) => res.json({ ok: true })); app.get('/ready', (req, res) => res.json({ ok: true })); app.get('/metrics', async (req, res) => { try { res.set('Content-Type', promClient.register.contentType); res.end(await promClient.register.metrics()); } catch (e) { res.status(500).end(String(e?.message || e)); } }); // Централизованный обработчик ошибок (должен быть подключён ПОСЛЕ роутов — см. ниже второе use) // Helpers: meta and responses function toIso(x) { try { return new Date(x).toISOString(); } catch { return null; } } async function headMeta(key) { try { const h = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise(); return { etag: h.ETag || null, lastModified: h.LastModified ? toIso(h.LastModified) : null, contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null, }; } catch (e) { return { etag: null, lastModified: null, contentLength: null }; } } function sendOk(res, meta) { if (meta?.etag) res.set('ETag', String(meta.etag)); if (meta?.lastModified) res.set('Last-Modified', new Date(meta.lastModified).toUTCString()); if (typeof meta?.contentLength === 'number') res.set('Content-Length-Source', String(meta.contentLength)); return res.json({ ok: true, etag: meta?.etag || null, lastModified: meta?.lastModified || null, contentLength: meta?.contentLength ?? null }); } function sendError(res, status, message, code, details) { const requestId = res.req?.id; return res.status(status).json({ code, message, details, requestId }); } // Map UI resource -> S3 key (for history endpoints) function resourceToKey(resource) { switch (resource) { case 'domains-new': return 'bgp_data/domains_community.txt'; case 'ip-ranges': return 'bgp_data/ips.txt'; case 'asns': return 'bgp_data/asns.txt'; default: return null; } } // Compute sha256 of string function sha256OfString(s) { return crypto.createHash('sha256').update(Buffer.from(String(s), 'utf-8')).digest('hex'); } function mapAjvErrors(errors) { if (!Array.isArray(errors)) return []; return errors.map((e) => ({ message: e.message, instancePath: e.instancePath, keyword: e.keyword, params: e.params, })); } // Serve static files from the React app app.use(express.static(path.join(__dirname, 'public'))); // Configure AWS S3 const s3 = new AWS.S3({ endpoint: 'https://storage.yandexcloud.net', region: process.env.AWS_REGION, s3ForcePathStyle: true, signatureVersion: 'v4', httpOptions: { timeout: 15000 }, maxRetries: 3, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY, } }); const BUCKET_NAME = process.env.S3_BUCKET_NAME; const FILE_KEY = 'bgp_data/domains.txt'; // AJV setup and schemas const ajv = new Ajv({ allErrors: true, removeAdditional: 'failing' }); const schemaDomainsNew = { type: 'array', items: { type: 'object', required: ['domain', 'community'], additionalProperties: false, properties: { domain: { type: 'string' }, community: { type: 'string' } } } }; const schemaAsns = { type: 'array', items: { type: 'object', required: ['domain', 'type'], additionalProperties: false, properties: { domain: { type: 'string' }, type: { type: 'string' } } } }; const schemaIpRanges = { type: 'array', items: { type: 'object', required: ['ipRange', 'community'], additionalProperties: false, properties: { ipRange: { type: 'string' }, community: { type: 'string' } } } }; const schemaFilters = { type: 'array', items: { type: 'object', required: ['community', 'gateway'], additionalProperties: true, properties: { community: { type: 'string' }, gateway: { type: 'string' }, description: { type: 'string' } } } }; const schemaServers = { type: 'array', items: { type: 'object', required: ['ip', 'dns', 'country', 'provider', 'tunnel'], additionalProperties: true, properties: { ip: { type: 'string' }, dns: { type: 'string' }, country: { type: 'string' }, provider: { type: 'string' }, tunnel: { type: 'string' }, gateway: { type: 'string' } } } }; const schemaBilling = { type: 'array', items: { type: 'object', required: ['hostName', 'country', 'provider'], additionalProperties: true, properties: { hostName: { type: 'string' }, country: { type: 'string' }, provider: { type: 'string' } } } }; const validateDomainsNew = ajv.compile(schemaDomainsNew); const validateAsns = ajv.compile(schemaAsns); const validateIpRanges = ajv.compile(schemaIpRanges); const validateFilters = ajv.compile(schemaFilters); const validateServers = ajv.compile(schemaServers); const validateBilling = ajv.compile(schemaBilling); // Helper: build MikroTik nested if/else blocks (RouterOS v7 filter language does not support 'else if') function buildNestedGatewayBlocks(gatewayGroups, baseIndentSpaces = 4) { const indent = (n) => ' '.repeat(n); const entries = Object.entries(gatewayGroups); if (entries.length === 0) return ''; function buildAt(index, pad) { const [gateway, communities] = entries[index]; let s = ''; s += `${indent(pad)}if (\n`; communities.forEach((community, i) => { s += `${indent(pad + 4)}(bgp-communities includes ${community})`; if (i < communities.length - 1) s += ' or \n'; }); s += `\n${indent(pad)})\n`; s += `${indent(pad)}{\n${indent(pad + 8 - 4)}set gw ${gateway}; accept;\n${indent(pad)}}\n`; if (index < entries.length - 1) { s += `${indent(pad)}else\n${indent(pad)}{\n`; s += buildAt(index + 1, pad + 4); s += `\n${indent(pad)}}`; } else { s += `${indent(pad)}else\n${indent(pad)}{\n${indent(pad + 4)}reject;\n${indent(pad)}}`; } return s; } return buildAt(0, baseIndentSpaces); } // In-memory LRU-ish cache for small texts and head meta const s3Cache = { text: new Map(), head: new Map(), max: 100, ttlMs: 30_000 }; function getCache(map, key) { const v = map.get(key); if (!v) return null; if (Date.now() > v.at + s3Cache.ttlMs) { map.delete(key); return null; } return v.value; } function setCache(map, key, value) { if (map.size >= s3Cache.max) { const firstKey = map.keys().next().value; if (firstKey) map.delete(firstKey); } map.set(key, { value, at: Date.now() }); } // Helper: read text file from S3 and return { body, etag, lastModified, contentLength } async function readS3TextObject(key) { const cached = getCache(s3Cache.text, key); if (cached) return cached; const params = { Bucket: BUCKET_NAME, Key: key }; const data = await s3.getObject(params).promise(); const out = { body: data.Body.toString('utf-8'), etag: data.ETag ? String(data.ETag).replace(/\"/g, '"') : undefined, lastModified: data.LastModified ? data.LastModified.toISOString() : undefined, contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined }; setCache(s3Cache.text, key, out); return out; } // Helper: head object and return current ETag async function headS3ObjectEtag(key) { const cached = getCache(s3Cache.head, key); if (cached && cached.etag) return cached.etag; const head = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise(); setCache(s3Cache.head, key, { etag: head.ETag ? String(head.ETag).replace(/\"/g, '"') : undefined }); return head.ETag ? String(head.ETag).replace(/\"/g, '"') : undefined; } // Helper: stream and paginate big text files (line-based) async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) { return new Promise(async (resolve, reject) => { let total = 0; const items = []; let sent = 0; let buffered = ''; const matchesQuery = (line) => { if (!q) return true; return line.toLowerCase().includes(String(q).toLowerCase()); }; const stream = s3.getObject({ Bucket: BUCKET_NAME, Key: key }).createReadStream(); stream.on('data', (chunk) => { buffered += chunk.toString('utf-8'); let lines = buffered.split('\n'); buffered = lines.pop(); for (const lnRaw of lines) { const line = lnRaw.trim(); if (!line) continue; if (!matchesQuery(line)) continue; total++; const pos = total - 1; // position among matches if (limit > 0) { if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; } } else { items.push(mapLine(line)); } } }); stream.on('end', () => { const last = (buffered || '').trim(); if (last) { if (!q || last.toLowerCase().includes(String(q).toLowerCase())) { total++; if (limit > 0) { const pos = total - 1; if (pos >= offset && items.length < limit) items.push(mapLine(last)); } else { items.push(mapLine(last)); } } } resolve({ items, total }); }); stream.on('error', reject); }); } // Simple in-memory soft locks with TTL const locks = new Map(); // key -> { owner, expiresAt } function cleanupExpiredLocks() { const now = Date.now(); for (const [k, v] of locks.entries()) { if (!v || typeof v.expiresAt !== 'number' || v.expiresAt <= now) { locks.delete(k); } } } setInterval(cleanupExpiredLocks, 30_000); // Get domains from S3 app.get('/api/domains', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: FILE_KEY, }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); const domains = fileContent.split('\n').filter(line => line).map(line => { // Trim the line to remove any leading/trailing whitespace, including \r // And split by any whitespace to be more robust const parts = line.trim().split(/\s+/); const domain = parts[0] || ''; const type = parts[1] || ''; return { domain, type }; }); if (data.ETag) { res.set('ETag', String(data.ETag)); } if (data.LastModified) { res.set('Last-Modified', new Date(data.LastModified).toUTCString()); } if (typeof data.ContentLength === 'number') { res.set('Content-Length-Source', String(data.ContentLength)); } res.json(domains); } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) }); } } }); // Update domains in S3 with optimistic concurrency via ETag check app.post('/api/domains', async (req, res) => { const { domains, etag } = req.body; const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim()).filter(Boolean).join('\n'); // Concurrency guard: if client sent etag, ensure current ETag matches try { if (etag) { const current = await headS3ObjectEtag(FILE_KEY).catch(() => undefined); if (current && current.replace(/\"/g, '"') !== String(etag)) { return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' }); } } } catch (e) { // ignore if head fails due to NoSuchKey; proceed to create } const params = { Bucket: BUCKET_NAME, Key: FILE_KEY, Body: fileContent, ContentType: 'text/plain', }; try { const put = await s3.putObject(params).promise(); res.set('ETag', put.ETag || ''); res.send('File updated successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing to S3', 'E_S3'); } }); // --- ASNs Routes --- // Get ASNs from S3 app.get('/api/asns', async (req, res) => { const { q = '', offset, limit, countOnly, format } = req.query || {}; const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt', }; try { if (countOnly === 'true') { // только количество const { total } = await streamPaginatedText({ key: 'bgp_data/asns.txt', q, offset: 0, limit: 0, mapLine: (line) => ({}) }); return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ key: 'bgp_data/asns.txt', q, offset: Number(offset) || 0, limit: Number(limit) || 0, mapLine: (line) => { const parts = line.trim().split(/\s+/); return { domain: parts[0] || '', type: parts[1] || '' }; } }); if (!validateAsns(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); } else { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); const asns = fileContent.split('\n').filter(line => line).map(line => { const parts = line.trim().split(/\s+/); const domain = parts[0] || ''; const type = parts[1] || ''; return { domain, type }; }); if (!validateAsns(asns)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); if (data.ETag) res.set('ETag', String(data.ETag)); if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); if (format === 'std') return res.json({ items: asns, total: asns.length, meta: {} }); res.json(asns); } } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); } else { console.error(error); return sendError(res, 500, 'Error reading from S3', 'E_S3'); } } }); // Update ASNs in S3 app.post('/api/asns', async (req, res) => { const { domains: asns, etag } = req.body; // Keep name 'domains' for consistency const fileContent = (asns || []).map(a => `${String(a.domain || '').trim()} ${String(a.type || '').trim()}`.trim()).filter(Boolean).join('\n'); try { if (!validateAsns(asns || [])) { return sendError(res, 400, 'Invalid payload format for asns', 'E_SCHEMA'); } let current = null; const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; try { current = await headS3ObjectEtag('bgp_data/asns.txt'); } catch {} if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { const meta = await headMeta('bgp_data/asns.txt'); return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); } } catch {} const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt', Body: fileContent, ContentType: 'text/plain', }; try { const put = await s3.putObject(params).promise(); const meta = await headMeta('bgp_data/asns.txt'); return sendOk(res, meta); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); } }); // --- Domains New Routes --- // Get domains-new from S3 app.get('/api/domains-new', async (req, res) => { const { q = '', offset, limit, countOnly, format } = req.query || {}; const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt', }; try { if (countOnly === 'true') { const { total } = await streamPaginatedText({ key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0, mapLine: (line) => ({}) }); return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ key: 'bgp_data/domains_community.txt', q, offset: Number(offset) || 0, limit: Number(limit) || 0, mapLine: (line) => { const parts = line.trim().split(/\s+/); return { domain: parts[0] || '', community: parts[1] || '' }; } }); if (!validateDomainsNew(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); } else { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); const domains = fileContent.split('\n').filter(line => line).map(line => { const parts = line.trim().split(/\s+/); const domain = parts[0] || ''; const community = parts[1] || ''; return { domain, community }; }); if (!validateDomainsNew(domains)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); if (data.ETag) res.set('ETag', String(data.ETag)); if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); if (format === 'std') return res.json({ items: domains, total: domains.length, meta: {} }); res.json(domains); } } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading from S3', 'E_S3'); } } }); // Update domains-new in S3 app.post('/api/domains-new', async (req, res) => { const { domains, etag } = req.body; const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.community || '').trim()}`.trim()).filter(Boolean).join('\n'); try { if (!validateDomainsNew(domains || [])) { return sendError(res, 400, 'Invalid payload format for domains-new', 'E_SCHEMA'); } let current = null; const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; try { current = await headS3ObjectEtag('bgp_data/domains_community.txt'); } catch {} if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { const meta = await headMeta('bgp_data/domains_community.txt'); return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); } } catch {} const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt', Body: fileContent, ContentType: 'text/plain', }; try { const put = await s3.putObject(params).promise(); const meta = await headMeta('bgp_data/domains_community.txt'); return sendOk(res, meta); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); } }); // --- IP Ranges Routes --- // Get IP ranges from S3 app.get('/api/ip-ranges', async (req, res) => { const { q = '', offset, limit, countOnly, format } = req.query || {}; const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt', }; try { if (countOnly === 'true') { const { total } = await streamPaginatedText({ key: 'bgp_data/ips.txt', q, offset: 0, limit: 0, mapLine: (line) => ({}) }); return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ key: 'bgp_data/ips.txt', q, offset: Number(offset) || 0, limit: Number(limit) || 0, mapLine: (line) => { const parts = line.trim().split(/\s+/); return { ipRange: parts[0] || '', community: parts[1] || '' }; } }); if (!validateIpRanges(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); } else { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); const ipRanges = fileContent.split('\n').filter(line => line).map(line => { const parts = line.trim().split(/\s+/); const ipRange = parts[0] || ''; const community = parts[1] || ''; return { ipRange, community }; }); if (!validateIpRanges(ipRanges)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); if (data.ETag) res.set('ETag', String(data.ETag)); if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); if (format === 'std') return res.json({ items: ipRanges, total: ipRanges.length, meta: {} }); res.json(ipRanges); } } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading from S3', 'E_S3'); } } }); // Update IP ranges in S3 app.post('/api/ip-ranges', async (req, res) => { const { ipRanges, etag } = req.body; const fileContent = (ipRanges || []).map(ip => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim()).filter(Boolean).join('\n'); try { if (!validateIpRanges(ipRanges || [])) { return sendError(res, 400, 'Invalid payload format for ip-ranges', 'E_SCHEMA'); } let current = null; const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; try { current = await headS3ObjectEtag('bgp_data/ips.txt'); } catch {} if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { const meta = await headMeta('bgp_data/ips.txt'); return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); } } catch {} const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt', Body: fileContent, ContentType: 'text/plain', }; try { const put = await s3.putObject(params).promise(); const meta = await headMeta('bgp_data/ips.txt'); return sendOk(res, meta); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); } }); // --- Communities Directory Routes --- // Get communities from S3 app.get('/api/communities', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/communities.json', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); let communities = []; try { const parsed = JSON.parse(fileContent); communities = Array.isArray(parsed) ? parsed : []; } catch (parseError) { console.error('Error parsing communities.json:', parseError); communities = []; } // Basic normalization 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') { // If file missing, start with empty list return res.json([]); } console.error('Error reading communities from S3:', error); return sendError(res, 500, 'Error reading communities from S3', 'E_S3'); } }); // Update communities in S3 app.post('/api/communities', async (req, res) => { const { communities } = req.body; if (!Array.isArray(communities)) { return sendError(res, 400, 'communities must be an array', 'E_BAD_REQUEST'); } // Validate entries and ensure unique values const seen = new Set(); const normalized = []; for (let i = 0; i < communities.length; i++) { const entry = communities[i] || {}; const value = typeof entry.value === 'string' ? entry.value.trim() : ''; if (!value) { return sendError(res, 400, `Community at index ${i} is missing required field: value`, 'E_SCHEMA'); } if (seen.has(value)) { return sendError(res, 400, `Duplicate community value at index ${i}: ${value}`, 'E_SCHEMA'); } 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) : '' }); } const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/communities.json', Body: JSON.stringify(normalized, null, 2), ContentType: 'application/json', }; try { await s3.putObject(params).promise(); res.send('Communities updated successfully'); } catch (error) { console.error('Error writing communities to S3:', error); return sendError(res, 500, 'Error writing communities to S3', 'E_S3'); } }); // --- Servers Routes (JSON format) --- // Get servers from S3 app.get('/api/servers', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'servers.json', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); let servers = []; try { servers = JSON.parse(fileContent); // Ensure it's an array if (!Array.isArray(servers)) { servers = []; } } catch (parseError) { console.error('Error parsing servers.json:', parseError); servers = []; } res.json(servers); } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading from S3', 'E_S3'); } } }); // Update servers in S3 app.post('/api/servers', async (req, res) => { const { domains: servers } = req.body; // Keep name 'domains' for consistency // Validate servers structure if (!Array.isArray(servers)) { return sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST'); } // Validate each server has required fields for (let i = 0; i < servers.length; i++) { const server = servers[i]; if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) { return sendError(res, 400, `Server at index ${i} is missing required fields`, 'E_SCHEMA'); } } const params = { Bucket: BUCKET_NAME, Key: 'servers.json', Body: JSON.stringify(servers, null, 2), // Pretty print JSON ContentType: 'application/json', }; try { await s3.putObject(params).promise(); res.send('File updated successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing to S3', 'E_S3'); } }); // --- Billing Routes --- // Get billing data from S3 app.get('/api/billing', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'servers-billing.json', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); let billingData = []; try { billingData = JSON.parse(fileContent); // Ensure it's an array if (!Array.isArray(billingData)) { billingData = []; } } catch (parseError) { console.error('Error parsing servers-billing.json:', parseError); billingData = []; } res.json(billingData); } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading from S3', 'E_S3'); } } }); // Update billing data in S3 app.post('/api/billing', async (req, res) => { const { domains: billingData } = req.body; // Keep name 'domains' for consistency // Validate billing data structure if (!Array.isArray(billingData)) { return sendError(res, 400, 'Billing data must be an array', 'E_BAD_REQUEST'); } // Validate each billing item has required fields for (let i = 0; i < billingData.length; i++) { const item = billingData[i]; if (!item.hostName || !item.country || !item.provider) { return sendError(res, 400, `Billing item at index ${i} is missing required fields: hostName, country, provider`, 'E_SCHEMA'); } } const params = { Bucket: BUCKET_NAME, Key: 'servers-billing.json', Body: JSON.stringify(billingData, null, 2), // Pretty print JSON ContentType: 'application/json', }; try { await s3.putObject(params).promise(); res.send('File updated successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing to S3', 'E_S3'); } }); // --- Filters Routes (JSON format) --- // Get filters from S3 app.get('/api/filters', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'filters.json', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); let filters = []; try { filters = JSON.parse(fileContent); // Ensure it's an array if (!Array.isArray(filters)) { filters = []; } } catch (parseError) { console.error('Error parsing filters.json:', parseError); filters = []; } res.json(filters); } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading from S3', 'E_S3'); } } }); // Update filters in S3 app.post('/api/filters', async (req, res) => { const { domains: filters } = req.body; // Keep name 'domains' for consistency // Validate filters structure if (!Array.isArray(filters)) { return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST'); } // Validate each filter has required fields 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'); } } const params = { Bucket: BUCKET_NAME, Key: 'filters.json', Body: JSON.stringify(filters, null, 2), // Pretty print JSON ContentType: 'application/json', }; try { await s3.putObject(params).promise(); res.send('File updated successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing to S3', 'E_S3'); } }); // Эндпоинт метаданных S3 по ключевым файлам (Last-Modified, ETag, Content-Length) app.get('/api/s3/last-modified', async (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' } ]; const results = await Promise.allSettled( keys.map(k => s3.headObject({ Bucket: BUCKET_NAME, Key: k.key }).promise()) ); const out = {}; results.forEach((r, idx) => { const name = keys[idx].name; if (r.status === 'fulfilled') { out[name] = { lastModified: r.value.LastModified ? r.value.LastModified.toISOString() : null, etag: r.value.ETag || null, contentLength: typeof r.value.ContentLength === 'number' ? r.value.ContentLength : null }; } 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'); } }); // Soft-lock endpoints // GET lock status app.get('/api/locks/:resource', (req, res) => { cleanupExpiredLocks(); const { resource } = req.params; const info = locks.get(resource); if (!info) return res.json({ locked: false }); res.json({ locked: true, owner: info.owner, expiresAt: info.expiresAt }); }); // POST acquire/refresh lock app.post('/api/locks/:resource', (req, res) => { cleanupExpiredLocks(); const { resource } = req.params; const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {}; const now = Date.now(); const existing = locks.get(resource); if (existing && existing.expiresAt > now && existing.owner !== owner) { return res.status(423).json({ message: 'Resource is locked by another user', owner: existing.owner, expiresAt: existing.expiresAt }); } const expiresAt = now + Math.max(30, Math.min(600, Number(ttlSeconds) || 120)) * 1000; locks.set(resource, { owner, expiresAt }); res.json({ locked: true, owner, expiresAt }); }); // DELETE release lock app.delete('/api/locks/:resource', (req, res) => { const { resource } = req.params; locks.delete(resource); res.json({ released: true }); }); // History endpoints (require bucket versioning to be enabled). If versioning disabled, best effort. app.get('/api/history/:resource', async (req, res) => { const { resource } = req.params; const key = resourceToKey(resource); if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE'); try { if (!s3.listObjectVersions) { return sendError(res, 501, 'S3 listObjectVersions not available', 'E_NOT_SUPPORTED'); } const out = await s3.listObjectVersions({ Bucket: BUCKET_NAME, Prefix: key, MaxKeys: 20 }).promise(); const versions = (out.Versions || []) .filter(v => v.Key === key) .slice(0, 10) .map(v => ({ versionId: v.VersionId, isLatest: v.IsLatest, lastModified: toIso(v.LastModified), size: v.Size, etag: v.ETag })); return res.json({ items: versions }); } catch (e) { console.error('history error', e); return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) }); } }); app.post('/api/history/:resource/rollback', async (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 { // Copy specific version over same key to rollback await s3.copyObject({ Bucket: BUCKET_NAME, CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`, Key: key }).promise(); 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) }); } }); // Generate MikroTik configuration from filters app.get('/api/filters/generate-config', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'filters.json', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); 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'; // Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте) 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'); } } }); // Export MikroTik configuration to S3 app.post('/api/filters/export-config', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'filters.json', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); 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: 'Нет фильтров для экспорта' }); } // 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'; // Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте) config += buildNestedGatewayBlocks(gatewayGroups, 4); config += '}\n'; // Save configuration to S3 const exportParams = { Bucket: BUCKET_NAME, Key: 'mikrotik-frouting-config.txt', Body: config, ContentType: 'text/plain', }; await s3.putObject(exportParams).promise(); res.json({ success: true, message: 'Конфигурация экспортирована в S3' }); } catch (error) { console.error(error); return sendError(res, 500, 'Error exporting configuration', 'E_S3'); } }); // --- Server Configs Routes --- // Get server configs list app.get('/api/server-configs', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'server-configs.json', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); let servers = []; try { servers = JSON.parse(fileContent); if (!Array.isArray(servers)) { servers = []; } } catch (parseError) { console.error('Error parsing server-configs.json:', parseError); servers = []; } res.json(servers); } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading server configs from S3', 'E_S3'); } } }); // Update server configs list app.post('/api/server-configs', async (req, res) => { const { servers } = req.body; // Validate servers structure if (!Array.isArray(servers)) { return sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST'); } // Validate each server has required fields for (let i = 0; i < servers.length; i++) { const server = servers[i]; if (!server.id || !server.name) { return sendError(res, 400, `Server at index ${i} is missing required fields`, 'E_SCHEMA'); } } const params = { Bucket: BUCKET_NAME, Key: 'server-configs.json', Body: JSON.stringify(servers, null, 2), // Pretty print JSON ContentType: 'application/json', }; try { await s3.putObject(params).promise(); res.send('Server configs updated successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing server configs to S3', 'E_S3'); } }); // Get specific server config app.get('/api/server-configs/:serverId', async (req, res) => { const { serverId } = req.params; const params = { Bucket: BUCKET_NAME, Key: `filter-manager/config-${serverId}.txt`, }; try { const data = await s3.getObject(params).promise(); const config = data.Body.toString('utf-8'); res.json({ config }); } catch (error) { if (error.code === 'NoSuchKey') { res.json({ config: '// Конфигурация не найдена' }); } else { console.error(error); return sendError(res, 500, 'Error reading server config from S3', 'E_S3'); } } }); // Save specific server config app.post('/api/server-configs/:serverId', async (req, res) => { const { serverId } = req.params; const { config } = req.body; if (!config) { return sendError(res, 400, 'Config is required', 'E_BAD_REQUEST'); } const params = { Bucket: BUCKET_NAME, Key: `filter-manager/config-${serverId}.txt`, Body: config, ContentType: 'text/plain', }; try { await s3.putObject(params).promise(); res.send('Server config saved successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing server config to S3', 'E_S3'); } }); // Delete specific server config app.delete('/api/server-configs/:serverId', async (req, res) => { const { serverId } = req.params; const params = { Bucket: BUCKET_NAME, Key: `filter-manager/config-${serverId}.txt`, }; try { await s3.deleteObject(params).promise(); res.send('Server config deleted successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error deleting server config from S3', 'E_S3'); } }); // Delete server completely (config + filters) app.delete('/api/server-configs/:serverId/complete', async (req, res) => { const { serverId } = req.params; try { // Удаляем конфигурацию сервера const configParams = { Bucket: BUCKET_NAME, Key: `filter-manager/config-${serverId}.txt`, }; // Удаляем фильтры сервера const filtersParams = { Bucket: BUCKET_NAME, Key: `filter-manager/server-filters-${serverId}.json`, }; // Удаляем оба файла параллельно await Promise.allSettled([ s3.deleteObject(configParams).promise(), s3.deleteObject(filtersParams).promise() ]); res.send('Server and all associated files deleted successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error deleting server files from S3', 'E_S3'); } }); // --- Server Filters Routes --- // Get server filters app.get('/api/server-filters/:serverId', async (req, res) => { const { serverId } = req.params; const params = { Bucket: BUCKET_NAME, Key: `filter-manager/server-filters-${serverId}.json`, }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); 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([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading server filters from S3', 'E_S3'); } } }); // Generate MikroTik configuration from server filters app.post('/api/server-filters/generate-config', async (req, res) => { console.log('Received generate-config request with filters:', req.body); const { filters } = req.body; if (!Array.isArray(filters) || filters.length === 0) { console.log('No filters provided, returning empty config'); 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); }); console.log('Grouped filters by gateway:', gatewayGroups); 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'; // Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте) config += buildNestedGatewayBlocks(gatewayGroups, 4); config += '}\n'; console.log('Generated config:', config); console.log('Sending response:', { config }); res.json({ config }); }); // Update server filters app.post('/api/server-filters/:serverId', async (req, res) => { const { serverId } = req.params; const { filters } = req.body; // Validate filters structure if (!Array.isArray(filters)) { return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST'); } // Validate each filter has required fields 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'); } } const params = { Bucket: BUCKET_NAME, Key: `filter-manager/server-filters-${serverId}.json`, Body: JSON.stringify(filters, null, 2), // Pretty print JSON ContentType: 'application/json', }; try { await s3.putObject(params).promise(); res.send('Server filters updated successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing server filters to S3', 'E_S3'); } }); // --- Simple Filters Routes --- // Get simple filters app.get('/api/simple-filters', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); let filters = []; try { filters = JSON.parse(fileContent); if (!Array.isArray(filters)) { filters = []; } } catch (parseError) { console.error('Error parsing simple filters:', parseError); filters = []; } res.json(filters); } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading simple filters from S3', 'E_S3'); } } }); // Update simple filters app.post('/api/simple-filters', async (req, res) => { const { filters } = req.body; // Validate filters structure if (!Array.isArray(filters)) { return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST'); } // Validate each filter has required fields 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'); } } const params = { Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json', Body: JSON.stringify(filters, null, 2), // Pretty print JSON ContentType: 'application/json', }; try { await s3.putObject(params).promise(); res.send('Simple filters updated successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing simple filters to S3', 'E_S3'); } }); // --- Auto URL Routes --- // Get auto URLs from S3 app.get('/api/auto-urls', async (req, res) => { const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/auto_url/urls.txt', }; try { const data = await s3.getObject(params).promise(); const fileContent = data.Body.toString('utf-8'); const urls = fileContent.split('\n').filter(line => line).map(line => { const parts = line.trim().split(/\s+/); const url = parts[0] || ''; const community = parts[1] || ''; return { url, community }; }); res.json(urls); } catch (error) { if (error.code === 'NoSuchKey') { res.json([]); // Return empty array if file does not exist } else { console.error(error); return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3'); } } }); // --- Servers availability check --- // Optimized TCP check: parallelize ports and hosts, cap per-host time, add in-memory TTL cache 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([ // try common ports simultaneously tcpCheck(host, 443, perSocketTimeoutMs), tcpCheck(host, 80, perSocketTimeoutMs), ]); const run = anyTrue(hosts.map((h) => tryOneHost(h))); // Per-server overall budget const timeout = new Promise((resolve) => setTimeout(() => resolve(false), perServerBudgetMs)); return Promise.race([run, timeout]); } const availabilityCache = { at: 0, data: null }; app.get('/api/servers/availability', async (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.getObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise(); let servers = []; try { servers = JSON.parse(data.Body.toString('utf-8')); 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: [] }); } }); // Update auto URLs in S3 app.post('/api/auto-urls', async (req, res) => { const { urls } = req.body; const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n'); const params = { Bucket: BUCKET_NAME, Key: 'bgp_data/auto_url/urls.txt', Body: fileContent, ContentType: 'text/plain', }; try { await s3.putObject(params).promise(); res.send('Auto URLs updated successfully'); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3'); } }); // Process auto URLs and update IPs app.post('/api/auto-urls/process', async (req, res) => { const https = require('https'); const http = require('http'); try { // Get current auto URLs const urlsParams = { Bucket: BUCKET_NAME, Key: 'bgp_data/auto_url/urls.txt', }; let urls = []; try { const urlsData = await s3.getObject(urlsParams).promise(); const urlsContent = urlsData.Body.toString('utf-8'); 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'); } // Get current IPs const ipsParams = { Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt', }; let currentIps = []; try { const ipsData = await s3.getObject(ipsParams).promise(); const ipsContent = ipsData.Body.toString('utf-8'); 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; } } // Process each URL const newIps = []; for (const urlData of urls) { try { const url = urlData.url.trim(); const community = urlData.community.trim(); if (!url || !community) continue; // Download content from URL const content = await new Promise((resolve, reject) => { const protocol = url.startsWith('https:') ? https : http; const req = protocol.get(url, (res) => { let data = ''; res.on('data', (chunk) => data += chunk); res.on('end', () => resolve(data)); }); req.on('error', reject); req.setTimeout(10000, () => req.destroy()); }); // Parse IPs from content const lines = content.split('\n'); for (const line of lines) { const ip = line.trim(); if (ip && (ip.includes('.') || ip.includes(':'))) { newIps.push({ ipRange: ip, community }); } } } catch (error) { console.error(`Error processing URL ${urlData.url}:`, error); } } // Merge with existing IPs (avoid duplicates) const existingIpRanges = new Set(currentIps.map(ip => ip.ipRange)); const uniqueNewIps = newIps.filter(ip => !existingIpRanges.has(ip.ipRange)); const allIps = [...currentIps, ...uniqueNewIps]; // Save updated IPs const updatedIpsContent = allIps.map(ip => `${ip.ipRange} ${ip.community}`).join('\n'); const updateParams = { Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt', Body: updatedIpsContent, ContentType: 'text/plain', }; await s3.putObject(updateParams).promise(); res.json({ success: true, message: `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} новых IP-адресов`, processedUrls: urls.length, newIpsCount: uniqueNewIps.length, totalIpsCount: allIps.length }); } catch (error) { console.error('Error processing auto URLs:', error); return sendError(res, 500, 'Error processing auto URLs', 'E_S3'); } }); // The "catchall" handler: for any request that doesn't // match one above, send back React's index.html file. app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Централизованный error handler (последний middleware) // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = typeof err?.status === 'number' ? err.status : 500; const code = err?.code || 'E_INTERNAL'; const message = status === 500 && process.env.NODE_ENV === 'production' ? 'Internal Server Error' : (err?.message || 'Error'); const details = err?.details; const requestId = req?.id; try { req.log?.error({ err, code, requestId }, 'request error'); } catch {} res.status(status).json({ code, message, details, requestId }); }); app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); });