// Оптимизированная версия server.js с модульной структурой require('dotenv').config(); const express = require('express'); const cors = require('cors'); const path = require('path'); const compression = require('compression'); const Ajv = require('ajv'); 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 crypto = require('crypto'); // Импорт модулей const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler'); const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager'); const { createTextDataRoutes } = require('./routes/textDataRoutes'); const { createJsonDataRoutes } = require('./routes/jsonDataRoutes'); const { splitWhitespace } = require('./utils/helpers'); const validators = require('./lib/validators'); const mikrotikValidator = require('./lib/mikrotik-validator'); // Роуты const communitiesRoutes = require('./routes/communitiesRoutes'); const filtersRoutes = require('./routes/filtersRoutes'); const serverConfigsRoutes = require('./routes/serverConfigsRoutes'); const miscRoutes = require('./routes/miscRoutes'); const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes'); const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes'); const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes'); const trafficRoutes = require('./routes/trafficRoutes'); const resourceStatsRoutes = require('./routes/resourceStatsRoutes'); const alertsRoutes = require('./routes/alertsRoutes'); const routeOptimizerRoutes = require('./routes/routeOptimizerRoutes'); const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler'); const { initNetworkMapScheduler } = require('./services/networkMapScheduler'); const { initPingServicesScheduler } = require('./services/pingServicesScheduler'); const { initUptimeMonitorScheduler } = require('./services/uptimeMonitorScheduler'); const schedulerRoutes = require('./routes/schedulerRoutes'); const { createEvobgpProxy, normalizeEvobgpBaseUrl } = require('./routes/evobgpProxyRoutes'); const app = express(); const port = Number(process.env.PORT) || 3001; // === Logger === 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 === 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 })); } app.options('*', cors()); // === Security === app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, crossOriginResourcePolicy: { policy: 'cross-origin' }, })); app.set('trust proxy', 1); app.disable('x-powered-by'); // === Rate Limiting === const createRateLimiter = (windowMs, max, message) => rateLimit({ windowMs, max, standardHeaders: true, legacyHeaders: false, message: { code: 'E_RATE_LIMIT', message: message || 'Слишком много запросов' }, }); const generalLimiter = createRateLimiter(15 * 60 * 1000, 1000); const writeLimiter = createRateLimiter(5 * 60 * 1000, 100, 'Слишком много операций записи'); const bgpUpdateLimiter = createRateLimiter(1 * 60 * 1000, 5, 'Слишком частые BGP обновления'); app.use(generalLimiter); app.use(express.json({ limit: process.env.JSON_LIMIT || '1mb' })); app.use(compression()); app.set('etag', false); app.use((req, res, next) => { res.setHeader('Access-Control-Expose-Headers', 'ETag, Last-Modified, Content-Length-Source'); // Запрещаем браузеру кешировать API ответы if (req.path.startsWith('/api/')) { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } 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(); }); // === Health & Metrics === 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)); } }); app.get('/api/version', (req, res) => { res.json({ version: process.env.APP_VERSION || null, gitSha: process.env.GIT_SHA || null, buildAt: process.env.BUILD_AT || null }); }); // === EvoBGP reverse proxy (токен и URL только на сервере; без CORS в браузере) === function evobgpProxyHealth(req, res) { const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || ''); const hasToken = Boolean(String(process.env.EVOBGP_API_TOKEN || '').trim()); res.json({ ok: true, proxy: true, evobgpConfigured: Boolean(base && hasToken), }); } // Точное совпадение до app.use: GET /api/evobgp и GET /evobgp — проверка, что запрос доходит до Node app.get('/api/evobgp', evobgpProxyHealth); app.get('/evobgp', evobgpProxyHealth); app.use('/api/evobgp', createEvobgpProxy({ logger })); // Если nginx делает proxy_pass http://upstream/ и срезает префикс /api, до приложения приходит /evobgp/… app.use('/evobgp', createEvobgpProxy({ logger })); // === Cache TTL === const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30)); app.use((req, res, next) => { if (req.method === 'GET') { res.set('Cache-Control', `private, max-age=${DEFAULT_CACHE_TTL}`); } next(); }); app.use(express.static(path.join(__dirname, 'public'))); // === AJV 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 validateDomainsNew = ajv.compile(schemaDomainsNew); const validateAsns = ajv.compile(schemaAsns); const validateIpRanges = ajv.compile(schemaIpRanges); // === Domains (старая версия) === const domainsOldRoutes = createTextDataRoutes({ s3Key: 'bgp_data/domains.txt', mapLine: (line) => { const parts = splitWhitespace(line); return { domain: parts[0] || '', type: parts[1] || '' }; }, formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(), validate: validateAsns, cachePrefix: 'domains' }); app.get('/api/domains', domainsOldRoutes.get); app.post('/api/domains', domainsOldRoutes.post); // === ASNs === const asnsRoutes = createTextDataRoutes({ s3Key: 'bgp_data/asns.txt', mapLine: (line) => { const parts = splitWhitespace(line); return { domain: parts[0] || '', type: parts[1] || '' }; }, formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(), validate: validateAsns, validateItem: (asn, i) => { const errors = []; if (!validators.isValidASN(asn.domain)) { errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`); } if (!validators.isValidCommunity(asn.type)) { errors.push(`Элемент ${i}: неверный community "${asn.type}"`); } return errors; }, cachePrefix: 'asns' }); app.get('/api/asns', asnsRoutes.get); app.post('/api/asns', writeLimiter, asnsRoutes.post); // === Domains New === const domainsNewRoutes = createTextDataRoutes({ s3Key: 'bgp_data/domains_community.txt', mapLine: (line) => { const parts = splitWhitespace(line); return { domain: parts[0] || '', community: parts[1] || '' }; }, formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(), validate: validateDomainsNew, validateItem: (d, i) => { const errors = []; if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) { errors.push(`Элемент ${i}: неверный домен "${d.domain}"`); } if (!validators.isValidCommunity(d.community)) { errors.push(`Элемент ${i}: неверный community "${d.community}"`); } if (!validators.isSafeXSSString(d.domain)) { errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`); } return errors; }, cachePrefix: 'domains-new' }); app.get('/api/domains-new', domainsNewRoutes.get); app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post); // === IP Ranges === const ipRangesRoutes = createTextDataRoutes({ s3Key: 'bgp_data/ips.txt', mapLine: (line) => { const parts = splitWhitespace(line); return { ipRange: parts[0] || '', community: parts[1] || '' }; }, formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(), validate: validateIpRanges, validateItem: (ip, i) => { const errors = []; const ipStr = String(ip.ipRange || '').trim(); const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr); const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr); if (!isValidCIDR && !isValidIP) { errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`); } if (!validators.isValidCommunity(ip.community)) { errors.push(`Элемент ${i}: неверный community "${ip.community}"`); } return errors; }, cachePrefix: 'ip-ranges' }); app.get('/api/ip-ranges', ipRangesRoutes.get); app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post); // === JSON DATA ROUTES (используют фабрики) === // Servers (кастомные роуты с поддержкой зашифрованных MikroTik учётных данных для jumphost) const serversRoutes = require('./routes/serversRoutes'); app.get('/api/servers', serversRoutes.getServers); app.post('/api/servers', serversRoutes.postServers); // Server connections for topology graph const serverConnectionsRoutes = createJsonDataRoutes('server-connections.json', (conn, i) => { if (!conn.from || !conn.to || !conn.tunnelType) { return `Connection at index ${i} is missing required fields: from, to, tunnelType`; } if (conn.from === conn.to) { return `Connection at index ${i} has identical from/to values`; } return null; }); app.get('/api/server-connections', serverConnectionsRoutes.get); app.post('/api/server-connections', serverConnectionsRoutes.post); // Billing const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) => { if (!item.hostName || !item.country || !item.provider) { return `Billing item at index ${i} is missing required fields: hostName, country, provider`; } return null; }); app.get('/api/billing', billingRoutes.get); app.post('/api/billing', billingRoutes.post); // Filters const filtersRoutes2 = createJsonDataRoutes('filters.json', (filter, i) => { if (!filter.community || !filter.gateway) { return `Filter at index ${i} is missing required fields`; } return null; }); app.get('/api/filters', filtersRoutes2.get); app.post('/api/filters', filtersRoutes2.post); // Simple Filters const simpleFiltersRoutes = createJsonDataRoutes('filter-manager/simple-filters.json', (filter, i) => { if (!filter.community || !filter.gateway) { return `Filter at index ${i} is missing required fields`; } return null; }); app.get('/api/simple-filters', simpleFiltersRoutes.get); app.post('/api/simple-filters', simpleFiltersRoutes.post); // Network Config (справочник IP, интерфейсов и gateway) const networkConfigRoutes = createJsonDataRoutes('network-config.json', (config) => { // Валидация структуры конфига if (typeof config !== 'object' || config === null) { return 'Network config must be an object'; } // Валидация gateways if (config.gateways && !Array.isArray(config.gateways)) { return 'gateways must be an array'; } // Валидация tunnelInterfaces if (config.tunnelInterfaces && !Array.isArray(config.tunnelInterfaces)) { return 'tunnelInterfaces must be an array'; } // Валидация ipPools if (config.ipPools && !Array.isArray(config.ipPools)) { return 'ipPools must be an array'; } return null; }, { singleObject: true }); app.get('/api/network-config', networkConfigRoutes.get); app.post('/api/network-config', networkConfigRoutes.post); // === COMMUNITIES ROUTES === app.get('/api/communities', communitiesRoutes.getCommunities); app.post('/api/communities', writeLimiter, communitiesRoutes.postCommunities); app.get('/api/communities/stats', communitiesRoutes.getCommunityStats); // === FILTERS & MIKROTIK CONFIG ROUTES === app.get('/api/filters/generate-config', filtersRoutes.generateConfig); app.post('/api/filters/export-config', filtersRoutes.exportConfig); app.post('/api/server-filters/generate-config', filtersRoutes.generateServerFilterConfig); // === SERVER CONFIGS ROUTES === app.get('/api/server-configs', serverConfigsRoutes.serverConfigsListRoutes.get); app.post('/api/server-configs', serverConfigsRoutes.serverConfigsListRoutes.post); app.get('/api/server-configs/:serverId', serverConfigsRoutes.getServerConfig); app.post('/api/server-configs/:serverId', serverConfigsRoutes.postServerConfig); app.delete('/api/server-configs/:serverId', serverConfigsRoutes.deleteServerConfig); app.delete('/api/server-configs/:serverId/complete', serverConfigsRoutes.deleteServerComplete); // Server Filters app.get('/api/server-filters/:serverId', serverConfigsRoutes.getServerFilters); app.post('/api/server-filters/:serverId', serverConfigsRoutes.postServerFilters); // === LOCKS === app.get('/api/locks/:resource', (req, res) => { const status = getLockStatus(req.params.resource); res.json(status); }); app.post('/api/locks/:resource', (req, res) => { const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {}; const result = acquireLock(req.params.resource, owner, ttlSeconds); if (!result.success) { return sendError(res, 423, 'Resource is locked by another user', 'E_RESOURCE_LOCKED', { owner: result.owner, expiresAt: result.expiresAt }); } res.json(result); }); app.delete('/api/locks/:resource', (req, res) => { const result = releaseLock(req.params.resource); res.json(result); }); // === HISTORY === app.get('/api/history/:resource', miscRoutes.getHistory); app.post('/api/history/:resource/rollback', miscRoutes.postRollback); // === MISC ROUTES === app.get('/api/s3/last-modified', miscRoutes.getS3LastModified); app.get('/api/auto-urls', miscRoutes.getAutoUrls); app.post('/api/auto-urls', miscRoutes.postAutoUrls); app.post('/api/auto-urls/process', miscRoutes.processAutoUrls); app.get('/api/servers/availability', miscRoutes.getServersAvailability); app.post('/api/update-bgp/background', bgpUpdateLimiter, miscRoutes.updateBgpBackground); app.get('/api/ws/url', miscRoutes.getWsUrl); app.get('/api/ui-settings', miscRoutes.getUiSettings); app.post('/api/ui-settings', miscRoutes.postUiSettings); app.get('/api/ping-services-list', miscRoutes.getPingServicesList); app.get('/api/ping-services', miscRoutes.getPingServices); // === IPSEC PASSWORDS === app.get('/api/ipsec-passwords', ipsecPasswordsRoutes.getIpsecPasswords); app.get('/api/ipsec-passwords/:id', ipsecPasswordsRoutes.getIpsecPassword); app.post('/api/ipsec-passwords', writeLimiter, ipsecPasswordsRoutes.createIpsecPassword); app.put('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.updateIpsecPassword); app.delete('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.deleteIpsecPassword); // === MIKROTIK CONFIG GENERATION (text + json) === app.post('/api/mikrotik/generate', mikrotikConfigRoutes.generateMikrotikConfig); app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces); app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes); app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection); // Простой health-check (используется NetworkConfigManager при открытии модалки применения) app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() })); // Реальный ping через RouterOS по интерфейсу/шлюзу app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface); // Traceroute через RouterOS с выбором сервера и шлюза app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway); // Speed-test (TCP upload/download) между MikroTik по туннельному интерфейсу app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTestViaTunnel); app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig); app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript); app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists); app.get('/api/mikrotik/ospf-interface-templates', mikrotikConfigRoutes.getOspfInterfaceTemplates); app.post('/api/mikrotik/ospf-interface-templates/apply', writeLimiter, mikrotikConfigRoutes.applyOspfInterfaceTemplates); app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary); // === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) === app.get('/api/uptime/cache', mikrotikConfigRoutes.getUptimeCache); app.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck); // === TRAFFIC STATS (MikroTik interfaces by jumphost) === app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats); // === RESOURCE STATS (RAM/CPU/HDD по роутерам) === app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats); // === ALERTS (агрегация проблем: серверы, ресурсы роутеров) === app.get('/api/alerts', alertsRoutes.getAlerts); // === MIKROTIK BACKUPS (S3) === app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup); app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups); app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup); app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups); app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBackupNow); // === NETWORK MAP CACHE (для быстрой загрузки карты сети) === app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache); // === LOCAL AI ROUTE OPTIMIZER (rule-based) === app.get('/api/route-optimizer', routeOptimizerRoutes.getRouteOptimizer); // === SCHEDULER (карта сети: пинг и скорость по расписанию) === app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings); app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings); app.get('/api/scheduler/network-map/logs', schedulerRoutes.getNetworkMapSchedulerLogs); app.post('/api/scheduler/network-map/run-now', writeLimiter, schedulerRoutes.runNetworkMapSchedulerNow); // === SCHEDULER (пинг сервисов: обновление кеша по расписанию, по умолчанию каждые 2 мин) === app.get('/api/scheduler/ping-services/settings', schedulerRoutes.getPingServicesSchedulerSettings); app.patch('/api/scheduler/ping-services/settings', writeLimiter, schedulerRoutes.patchPingServicesSchedulerSettings); app.post('/api/scheduler/ping-services/run-now', writeLimiter, schedulerRoutes.runPingServicesSchedulerNow); // === SCHEDULER (Uptime Monitor: проверки по расписанию, по умолчанию каждые 2 мин) === app.get('/api/scheduler/uptime-monitor/settings', schedulerRoutes.getUptimeMonitorSchedulerSettings); app.patch('/api/scheduler/uptime-monitor/settings', writeLimiter, schedulerRoutes.patchUptimeMonitorSchedulerSettings); app.post('/api/scheduler/uptime-monitor/run-now', writeLimiter, schedulerRoutes.runUptimeMonitorSchedulerNow); // === MIKROTIK VALIDATION === app.post('/api/mikrotik/validate', async (req, res) => { const { config } = req.body; if (!config || typeof config !== 'string') { return sendError(res, 400, 'Config is required', 'E_BAD_REQUEST'); } try { const validation = mikrotikValidator.validateMikrotikConfig(config); return res.json(validation); } catch (error) { console.error('Error validating config:', error); return sendError(res, 500, 'Error validating configuration', 'E_VALIDATION'); } }); // === CATCHALL === app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Error handler app.use(errorHandler); app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`); try { initMikrotikBackupScheduler(logger); } catch (e) { logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler'); } try { initNetworkMapScheduler(logger, port); } catch (e) { logger.error({ component: 'network-map-scheduler', err: e && e.message }, 'Failed to start network map scheduler'); } try { initPingServicesScheduler(logger); } catch (e) { logger.error({ component: 'ping-services-scheduler', err: e && e.message }, 'Failed to start ping services scheduler'); } try { initUptimeMonitorScheduler(logger, port); } catch (e) { logger.error({ component: 'uptime-monitor-scheduler', err: e && e.message }, 'Failed to start uptime monitor scheduler'); } });