feat: Добавить улучшенный rate limiting для операций записи и BGP обновлений, а также валидацию входных данных для ASNs, доменов и IP диапазонов. Реализовать отображение статистики использования сообществ и новый API для валидации конфигурации MikroTik. Обновить интерфейс менеджера сообществ с вкладками для списка и статистики.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m30s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m30s
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Модуль валидации синтаксиса MikroTik RouterOS конфигурации
|
||||
*/
|
||||
|
||||
/**
|
||||
* Валидация MikroTik конфигурации
|
||||
* @param {string} config - Текст конфигурации
|
||||
* @returns {object} { valid: boolean, errors: array, warnings: array }
|
||||
*/
|
||||
function validateMikrotikConfig(config) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
if (!config || typeof config !== 'string') {
|
||||
errors.push({ line: 0, message: 'Конфигурация пуста или неверного типа' });
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const lines = config.split('\n');
|
||||
let inBlock = false;
|
||||
let blockName = '';
|
||||
let braceBalance = 0;
|
||||
let currentBlockLine = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1;
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Пропускаем комментарии и пустые строки
|
||||
if (line.startsWith('//') || line.startsWith('#') || line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверка начала блока (например, /routing filter bgp-in-tmp {)
|
||||
if (line.match(/^\/\w+(\s+\w+)*\s+\w+(-\w+)*\s*\{/)) {
|
||||
if (inBlock) {
|
||||
errors.push({ line: lineNum, message: `Вложенные блоки не поддерживаются в RouterOS` });
|
||||
}
|
||||
inBlock = true;
|
||||
blockName = line.match(/^\/\w+(\s+\w+)*/)?.[0] || '';
|
||||
currentBlockLine = lineNum;
|
||||
braceBalance++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Подсчет фигурных скобок
|
||||
const openBraces = (line.match(/\{/g) || []).length;
|
||||
const closeBraces = (line.match(/\}/g) || []).length;
|
||||
braceBalance += openBraces - closeBraces;
|
||||
|
||||
// Проверка закрытия блока
|
||||
if (line === '}') {
|
||||
if (braceBalance === 0) {
|
||||
inBlock = false;
|
||||
blockName = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверка синтаксиса внутри блока
|
||||
if (inBlock) {
|
||||
// Проверка if конструкций
|
||||
if (line.startsWith('if')) {
|
||||
// Проверка наличия условия в скобках
|
||||
if (!line.match(/if\s*\(/)) {
|
||||
errors.push({ line: lineNum, message: `Отсутствует открывающая скобка после if` });
|
||||
}
|
||||
|
||||
// Проверка сбалансированности скобок в условии
|
||||
const conditionPart = line.substring(line.indexOf('('));
|
||||
const openParens = (conditionPart.match(/\(/g) || []).length;
|
||||
const closeParens = (conditionPart.match(/\)/g) || []).length;
|
||||
if (openParens !== closeParens) {
|
||||
errors.push({ line: lineNum, message: `Несбалансированные скобки в условии if` });
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка команд set gw
|
||||
if (line.match(/set\s+gw\s+/)) {
|
||||
const match = line.match(/set\s+gw\s+([^;]+)/);
|
||||
if (match) {
|
||||
const gateway = match[1].trim();
|
||||
// Проверка валидности имени gateway
|
||||
if (!gateway || !/^[a-zA-Z0-9_-]+$/.test(gateway)) {
|
||||
warnings.push({ line: lineNum, message: `Возможно неверное имя gateway: "${gateway}"` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка команд accept/reject
|
||||
if (line.match(/;\s*(accept|reject)\s*;/)) {
|
||||
warnings.push({ line: lineNum, message: `Двойная точка с запятой перед ${line.includes('accept') ? 'accept' : 'reject'}` });
|
||||
}
|
||||
|
||||
// Проверка bgp-communities
|
||||
if (line.includes('bgp-communities includes')) {
|
||||
const match = line.match(/bgp-communities\s+includes\s+(\S+)/);
|
||||
if (match) {
|
||||
const community = match[1].replace(/[()]/g, '');
|
||||
// Базовая проверка формата community
|
||||
if (!community.match(/^\d+:\d+$/)) {
|
||||
warnings.push({ line: lineNum, message: `Community "${community}" может иметь неверный формат (ожидается N:N)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка else конструкций
|
||||
if (line.startsWith('else')) {
|
||||
// else должен следовать после закрывающей скобки if
|
||||
const prevNonEmpty = lines.slice(0, i).reverse().find(l => l.trim() !== '' && !l.trim().startsWith('//'));
|
||||
if (prevNonEmpty && !prevNonEmpty.trim().endsWith('}')) {
|
||||
warnings.push({ line: lineNum, message: `else должен следовать после закрывающей скобки блока if` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка команд вне блока
|
||||
if (!inBlock && line.startsWith('/')) {
|
||||
// Команды верхнего уровня должны начинаться с /
|
||||
const validTopLevel = ['/routing', '/ip', '/interface', '/system'];
|
||||
const isValidTopLevel = validTopLevel.some(cmd => line.startsWith(cmd));
|
||||
if (!isValidTopLevel) {
|
||||
warnings.push({ line: lineNum, message: `Неизвестная команда верхнего уровня: ${line.substring(0, 30)}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка сбалансированности фигурных скобок
|
||||
if (braceBalance !== 0) {
|
||||
errors.push({ line: 0, message: `Несбалансированные фигурные скобки (баланс: ${braceBalance})` });
|
||||
}
|
||||
|
||||
// Проверка незакрытых блоков
|
||||
if (inBlock) {
|
||||
errors.push({ line: currentBlockLine, message: `Блок "${blockName}" не закрыт` });
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка корректности community value
|
||||
* @param {string} community - Community value (например, 65000:100)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidCommunity(community) {
|
||||
if (!community || typeof community !== 'string') return false;
|
||||
// Формат: N:N где N - число от 0 до 65535
|
||||
const match = community.match(/^(\d+):(\d+)$/);
|
||||
if (!match) return false;
|
||||
const first = Number(match[1]);
|
||||
const second = Number(match[2]);
|
||||
return first >= 0 && first <= 65535 && second >= 0 && second <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка корректности имени gateway
|
||||
* @param {string} gateway - Gateway имя
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidGateway(gateway) {
|
||||
if (!gateway || typeof gateway !== 'string') return false;
|
||||
// Допустимы: буквы, цифры, дефис, подчеркивание
|
||||
// Длина: 1-64 символа
|
||||
return /^[a-zA-Z0-9_-]{1,64}$/.test(gateway);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateMikrotikConfig,
|
||||
isValidCommunity,
|
||||
isValidGateway,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Модуль валидации входных данных
|
||||
* Содержит валидаторы для IP, CIDR, доменов, ASN, Community
|
||||
*/
|
||||
|
||||
/**
|
||||
* Валидация IPv4 адреса
|
||||
* @param {string} ip - IP адрес для валидации
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidIPv4(ip) {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
const octets = ip.trim().split('.');
|
||||
if (octets.length !== 4) return false;
|
||||
return octets.every(octet => {
|
||||
if (!/^\d{1,3}$/.test(octet)) return false;
|
||||
const num = Number(octet);
|
||||
return num >= 0 && num <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация IPv6 адреса (базовая)
|
||||
* @param {string} ip - IPv6 адрес
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidIPv6(ip) {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
const segments = ip.trim().split(':');
|
||||
if (segments.length < 3 || segments.length > 8) return false;
|
||||
return segments.every(seg => {
|
||||
if (seg === '') return true; // :: notation
|
||||
return /^[0-9a-fA-F]{1,4}$/.test(seg);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация CIDR блока (IPv4)
|
||||
* @param {string} cidr - CIDR блок (например, 192.168.1.0/24)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidCIDRv4(cidr) {
|
||||
if (!cidr || typeof cidr !== 'string') return false;
|
||||
const parts = cidr.trim().split('/');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const [ip, mask] = parts;
|
||||
if (!isValidIPv4(ip)) return false;
|
||||
|
||||
if (!/^\d{1,2}$/.test(mask)) return false;
|
||||
const maskNum = Number(mask);
|
||||
return maskNum >= 0 && maskNum <= 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация CIDR блока (IPv6)
|
||||
* @param {string} cidr - CIDR блок IPv6
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidCIDRv6(cidr) {
|
||||
if (!cidr || typeof cidr !== 'string') return false;
|
||||
const parts = cidr.trim().split('/');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const [ip, mask] = parts;
|
||||
if (!isValidIPv6(ip)) return false;
|
||||
|
||||
if (!/^\d{1,3}$/.test(mask)) return false;
|
||||
const maskNum = Number(mask);
|
||||
return maskNum >= 0 && maskNum <= 128;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация доменного имени (FQDN)
|
||||
* Поддержка IDN (интернационализированных доменов)
|
||||
* @param {string} domain - доменное имя
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidDomain(domain) {
|
||||
if (!domain || typeof domain !== 'string') return false;
|
||||
const d = domain.trim().toLowerCase();
|
||||
|
||||
// Проверка на максимальную длину
|
||||
if (d.length > 253) return false;
|
||||
|
||||
// Проверка на IP адрес (не должен быть IP)
|
||||
if (isValidIPv4(d) || isValidIPv6(d)) return false;
|
||||
|
||||
// Базовая проверка структуры домена
|
||||
// Разрешаем буквы, цифры, дефис, точку, и IDN символы
|
||||
const domainRegex = /^([a-z0-9\u00a1-\uffff]([a-z0-9\u00a1-\uffff-]{0,61}[a-z0-9\u00a1-\uffff])?\.)+[a-z\u00a1-\uffff]{2,}$/i;
|
||||
return domainRegex.test(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация wildcard домена (*.example.com)
|
||||
* @param {string} domain - wildcard домен
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidWildcardDomain(domain) {
|
||||
if (!domain || typeof domain !== 'string') return false;
|
||||
const d = domain.trim().toLowerCase();
|
||||
|
||||
// Проверка на wildcard в начале
|
||||
if (d.startsWith('*.')) {
|
||||
const baseDomain = d.substring(2);
|
||||
return isValidDomain(baseDomain);
|
||||
}
|
||||
|
||||
return isValidDomain(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация номера ASN
|
||||
* @param {string|number} asn - номер ASN
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidASN(asn) {
|
||||
if (!asn) return false;
|
||||
const asnStr = String(asn).trim();
|
||||
|
||||
// Формат: AS12345 или просто 12345
|
||||
const asnRegex = /^(AS)?(\d{1,10})$/i;
|
||||
const match = asnStr.match(asnRegex);
|
||||
if (!match) return false;
|
||||
|
||||
const num = Number(match[2]);
|
||||
// ASN диапазон: 0-4294967295 (32-bit)
|
||||
return num >= 0 && num <= 4294967295;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация BGP Community
|
||||
* Поддержка форматов: 65000:100, 65000
|
||||
* @param {string} community - BGP community
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidCommunity(community) {
|
||||
if (!community || typeof community !== 'string') return false;
|
||||
const c = community.trim();
|
||||
|
||||
// Формат: N:N или просто N
|
||||
const communityRegex = /^(\d{1,10})(:(\d{1,10}))?$/;
|
||||
const match = c.match(communityRegex);
|
||||
if (!match) return false;
|
||||
|
||||
const first = Number(match[1]);
|
||||
if (first < 0 || first > 4294967295) return false;
|
||||
|
||||
if (match[3]) {
|
||||
const second = Number(match[3]);
|
||||
if (second < 0 || second > 65535) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация gateway имени (для MikroTik)
|
||||
* @param {string} gateway - имя gateway
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidGateway(gateway) {
|
||||
if (!gateway || typeof gateway !== 'string') return false;
|
||||
const g = gateway.trim();
|
||||
|
||||
// MikroTik gateway: буквы, цифры, дефис, подчеркивание
|
||||
// Длина: 1-64 символа
|
||||
if (g.length < 1 || g.length > 64) return false;
|
||||
|
||||
const gatewayRegex = /^[a-zA-Z0-9_-]+$/;
|
||||
return gatewayRegex.test(g);
|
||||
}
|
||||
|
||||
/**
|
||||
* Санитизация строки от опасных символов
|
||||
* @param {string} str - входная строка
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeString(str) {
|
||||
if (!str || typeof str !== 'string') return '';
|
||||
|
||||
// Удаляем управляющие символы
|
||||
let cleaned = str.replace(/[\x00-\x1F\x7F]/g, '');
|
||||
|
||||
// Экранируем HTML специальные символы
|
||||
cleaned = cleaned
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/');
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка на SQL injection паттерны
|
||||
* @param {string} str - строка для проверки
|
||||
* @returns {boolean} true если безопасно
|
||||
*/
|
||||
function isSafeSQLString(str) {
|
||||
if (!str || typeof str !== 'string') return true;
|
||||
|
||||
const dangerousPatterns = [
|
||||
/(\bSELECT\b|\bINSERT\b|\bUPDATE\b|\bDELETE\b|\bDROP\b|\bCREATE\b)/i,
|
||||
/(\bUNION\b|\bJOIN\b)/i,
|
||||
/(--|;|\/\*|\*\/)/,
|
||||
/(\bOR\b|\bAND\b)\s+\d+\s*=\s*\d+/i,
|
||||
];
|
||||
|
||||
return !dangerousPatterns.some(pattern => pattern.test(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка на XSS паттерны
|
||||
* @param {string} str - строка для проверки
|
||||
* @returns {boolean} true если безопасно
|
||||
*/
|
||||
function isSafeXSSString(str) {
|
||||
if (!str || typeof str !== 'string') return true;
|
||||
|
||||
const dangerousPatterns = [
|
||||
/<script/i,
|
||||
/javascript:/i,
|
||||
/on\w+\s*=/i, // onclick=, onerror=, etc
|
||||
/<iframe/i,
|
||||
/<embed/i,
|
||||
/<object/i,
|
||||
];
|
||||
|
||||
return !dangerousPatterns.some(pattern => pattern.test(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* Комплексная валидация входных данных
|
||||
* @param {object} data - объект с данными для валидации
|
||||
* @param {object} schema - схема валидации
|
||||
* @returns {object} { valid: boolean, errors: array }
|
||||
*/
|
||||
function validateData(data, schema) {
|
||||
const errors = [];
|
||||
|
||||
for (const [field, rules] of Object.entries(schema)) {
|
||||
const value = data[field];
|
||||
|
||||
// Проверка required
|
||||
if (rules.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push({ field, message: `Поле ${field} обязательно` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Если поле не обязательно и пустое - пропускаем остальные проверки
|
||||
if (!rules.required && (value === undefined || value === null || value === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверка типа
|
||||
if (rules.type) {
|
||||
switch (rules.type) {
|
||||
case 'ipv4':
|
||||
if (!isValidIPv4(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным IPv4 адресом` });
|
||||
}
|
||||
break;
|
||||
case 'cidrv4':
|
||||
if (!isValidCIDRv4(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным CIDR блоком` });
|
||||
}
|
||||
break;
|
||||
case 'domain':
|
||||
if (!isValidDomain(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным доменом` });
|
||||
}
|
||||
break;
|
||||
case 'asn':
|
||||
if (!isValidASN(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным ASN` });
|
||||
}
|
||||
break;
|
||||
case 'community':
|
||||
if (!isValidCommunity(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным BGP Community` });
|
||||
}
|
||||
break;
|
||||
case 'gateway':
|
||||
if (!isValidGateway(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным gateway именем` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка минимальной длины
|
||||
if (rules.minLength && String(value).length < rules.minLength) {
|
||||
errors.push({ field, message: `${field} должен быть не короче ${rules.minLength} символов` });
|
||||
}
|
||||
|
||||
// Проверка максимальной длины
|
||||
if (rules.maxLength && String(value).length > rules.maxLength) {
|
||||
errors.push({ field, message: `${field} должен быть не длиннее ${rules.maxLength} символов` });
|
||||
}
|
||||
|
||||
// Проверка безопасности
|
||||
if (rules.checkXSS && !isSafeXSSString(value)) {
|
||||
errors.push({ field, message: `${field} содержит потенциально опасные символы` });
|
||||
}
|
||||
|
||||
if (rules.checkSQL && !isSafeSQLString(value)) {
|
||||
errors.push({ field, message: `${field} содержит недопустимые SQL конструкции` });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isValidIPv4,
|
||||
isValidIPv6,
|
||||
isValidCIDRv4,
|
||||
isValidCIDRv6,
|
||||
isValidDomain,
|
||||
isValidWildcardDomain,
|
||||
isValidASN,
|
||||
isValidCommunity,
|
||||
isValidGateway,
|
||||
sanitizeString,
|
||||
isSafeSQLString,
|
||||
isSafeXSSString,
|
||||
validateData,
|
||||
};
|
||||
|
||||
+233
-14
@@ -17,6 +17,8 @@ const promClient = require('prom-client');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { URL } = require('url');
|
||||
const validators = require('./lib/validators');
|
||||
const mikrotikValidator = require('./lib/mikrotik-validator');
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
@@ -56,13 +58,25 @@ app.use(helmet({
|
||||
// Если приложение работает за прокси/ингрессом (Docker/NGINX), доверяем первому прокси для корректной работы rate-limit
|
||||
app.set('trust proxy', 1);
|
||||
app.disable('x-powered-by');
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 1000,
|
||||
|
||||
// Улучшенный rate limiting с разными лимитами для разных операций
|
||||
const createRateLimiter = (windowMs, max, message) => rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { code: 'E_RATE_LIMIT', message: message || 'Слишком много запросов, попробуйте позже' },
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Общий лимитер для всех запросов
|
||||
const generalLimiter = createRateLimiter(15 * 60 * 1000, 1000);
|
||||
app.use(generalLimiter);
|
||||
|
||||
// Строгий лимитер для операций записи
|
||||
const writeLimiter = createRateLimiter(5 * 60 * 1000, 100, 'Слишком много операций записи');
|
||||
|
||||
// Очень строгий лимитер для BGP обновлений
|
||||
const bgpUpdateLimiter = createRateLimiter(1 * 60 * 1000, 5, 'Слишком частые BGP обновления');
|
||||
app.use(express.json({ limit: process.env.JSON_LIMIT || '1mb' }));
|
||||
app.use(compression());
|
||||
// Disable Express auto-ETag to avoid weak ETags on JSON bodies
|
||||
@@ -640,9 +654,37 @@ app.get('/api/asns', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update ASNs in S3
|
||||
app.post('/api/asns', async (req, res) => {
|
||||
app.post('/api/asns', writeLimiter, 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');
|
||||
|
||||
// Валидация входных данных
|
||||
if (!Array.isArray(asns)) {
|
||||
return sendError(res, 400, 'ASNs должен быть массивом', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Проверка каждого ASN
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < asns.length; i++) {
|
||||
const asn = asns[i];
|
||||
if (!asn || typeof asn !== 'object') {
|
||||
validationErrors.push(`Элемент ${i}: неверный формат`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validators.isValidASN(asn.domain)) {
|
||||
validationErrors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
||||
}
|
||||
|
||||
if (!validators.isValidCommunity(asn.type)) {
|
||||
validationErrors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Ошибки валидации', 'E_VALIDATION', { errors: validationErrors.slice(0, 10) });
|
||||
}
|
||||
|
||||
const fileContent = (asns || []).map(a => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim()).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
if (!validateAsns(asns || [])) {
|
||||
@@ -737,9 +779,42 @@ app.get('/api/domains-new', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update domains-new in S3
|
||||
app.post('/api/domains-new', async (req, res) => {
|
||||
app.post('/api/domains-new', writeLimiter, 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');
|
||||
|
||||
// Валидация входных данных
|
||||
if (!Array.isArray(domains)) {
|
||||
return sendError(res, 400, 'domains должен быть массивом', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Проверка каждого домена
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < domains.length; i++) {
|
||||
const d = domains[i];
|
||||
if (!d || typeof d !== 'object') {
|
||||
validationErrors.push(`Элемент ${i}: неверный формат`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
||||
validationErrors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
||||
}
|
||||
|
||||
if (!validators.isValidCommunity(d.community)) {
|
||||
validationErrors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
||||
}
|
||||
|
||||
// Проверка на XSS
|
||||
if (!validators.isSafeXSSString(d.domain)) {
|
||||
validationErrors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Ошибки валидации', 'E_VALIDATION', { errors: validationErrors.slice(0, 10) });
|
||||
}
|
||||
|
||||
const fileContent = (domains || []).map(d => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim()).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
if (!validateDomainsNew(domains || [])) {
|
||||
@@ -834,8 +909,41 @@ app.get('/api/ip-ranges', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update IP ranges in S3
|
||||
app.post('/api/ip-ranges', async (req, res) => {
|
||||
app.post('/api/ip-ranges', writeLimiter, async (req, res) => {
|
||||
const { ipRanges, etag } = req.body;
|
||||
|
||||
// Валидация входных данных
|
||||
if (!Array.isArray(ipRanges)) {
|
||||
return sendError(res, 400, 'ipRanges должен быть массивом', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Проверка каждого IP range
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < ipRanges.length; i++) {
|
||||
const ip = ipRanges[i];
|
||||
if (!ip || typeof ip !== 'object') {
|
||||
validationErrors.push(`Элемент ${i}: неверный формат`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем CIDR или IP адрес
|
||||
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) {
|
||||
validationErrors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
||||
}
|
||||
|
||||
if (!validators.isValidCommunity(ip.community)) {
|
||||
validationErrors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Ошибки валидации', 'E_VALIDATION', { errors: validationErrors.slice(0, 10) });
|
||||
}
|
||||
|
||||
const fileContent = (ipRanges || []).map(ip => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim()).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
@@ -922,7 +1030,7 @@ app.get('/api/communities', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update communities in S3
|
||||
app.post('/api/communities', async (req, res) => {
|
||||
app.post('/api/communities', writeLimiter, async (req, res) => {
|
||||
const { communities } = req.body;
|
||||
|
||||
if (!Array.isArray(communities)) {
|
||||
@@ -932,15 +1040,27 @@ app.post('/api/communities', async (req, res) => {
|
||||
// Validate entries and ensure unique values
|
||||
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) {
|
||||
return sendError(res, 400, `Community at index ${i} is missing required field: value`, 'E_SCHEMA');
|
||||
validationErrors.push(`Community at index ${i} is missing required field: value`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Валидация community значения
|
||||
if (!validators.isValidCommunity(value)) {
|
||||
validationErrors.push(`Community at index ${i} has invalid value: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return sendError(res, 400, `Duplicate community value at index ${i}: ${value}`, 'E_SCHEMA');
|
||||
validationErrors.push(`Duplicate community value at index ${i}: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
normalized.push({
|
||||
value,
|
||||
@@ -948,9 +1068,17 @@ app.post('/api/communities', async (req, res) => {
|
||||
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) : ''
|
||||
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 });
|
||||
}
|
||||
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
@@ -970,6 +1098,80 @@ app.post('/api/communities', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get community usage statistics
|
||||
app.get('/api/communities/stats', async (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(); // community -> count
|
||||
|
||||
// Считаем использование в доменах
|
||||
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 {}
|
||||
}
|
||||
|
||||
// Преобразуем Map в массив объектов
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Servers Routes (JSON format) ---
|
||||
|
||||
// Get servers from S3
|
||||
@@ -1664,6 +1866,23 @@ app.get('/api/server-filters/:serverId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Validate MikroTik configuration syntax
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -2180,7 +2399,7 @@ app.post('/api/auto-urls/process', async (req, res) => {
|
||||
});
|
||||
|
||||
// --- Proxy: Background BGP Update (avoids CORS from browser) ---
|
||||
app.post('/api/update-bgp/background', async (req, res) => {
|
||||
app.post('/api/update-bgp/background', bgpUpdateLimiter, async (req, res) => {
|
||||
try {
|
||||
const targetUrl = process.env.BGP_BACKGROUND_URL;
|
||||
if (!targetUrl) {
|
||||
|
||||
Reference in New Issue
Block a user