// Оптимизированная версия 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 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 }); }); // === 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 const SERVER_TYPES = ['jumphost', 'exit']; const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => { if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) { return `Server at index ${i} is missing required fields`; } const normalizedType = String(server.type || '').toLowerCase(); if (!SERVER_TYPES.includes(normalizedType)) { return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`; } if (normalizedType === 'jumphost' || normalizedType === 'exit') { if (!Array.isArray(server.gateways) || server.gateways.length === 0) { return `Server at index ${i} must have gateways for type ${normalizedType}`; } const primaries = server.gateways.filter(g => g && g.primary); if (primaries.length !== 1) { return `Server at index ${i} must have exactly one primary gateway`; } for (let j = 0; j < server.gateways.length; j++) { const gw = server.gateways[j] || {}; if (!gw.name || String(gw.name).trim().length === 0) { return `Server at index ${i} gateway at index ${j} is missing name`; } } } // Нормализуем тип, чтобы в S3 всегда лежали одинаковые значения server.type = normalizedType; return null; }); app.get('/api/servers', serversRoutes.get); app.post('/api/servers', serversRoutes.post); // 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); // === 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); // === 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}`); });