feat: Добавить улучшенный rate limiting для операций записи и BGP обновлений, а также валидацию входных данных для ASNs, доменов и IP диапазонов. Реализовать отображение статистики использования сообществ и новый API для валидации конфигурации MikroTik. Обновить интерфейс менеджера сообществ с вкладками для списка и статистики.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m30s

This commit is contained in:
2025-10-02 16:36:52 +07:00
parent c06945eefc
commit cfbcedf9d7
8 changed files with 1501 additions and 17 deletions
+233 -14
View File
@@ -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) {