From 8e8ce7cec035e2469b7ac79d4fa9383787b8b46f Mon Sep 17 00:00:00 2001 From: shats Date: Sat, 21 Feb 2026 19:01:28 +0700 Subject: [PATCH] feat(MikrotikConfigRoutes, FirewallPage): add apply-summary endpoint and frontend analysis feature for address lists --- backend/routes/mikrotikConfigRoutes.js | 72 ++++++++- backend/server.js | 1 + frontend/src/FirewallPage.jsx | 193 +++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 7fe9651..eb195b7 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -15,7 +15,7 @@ const { buildMikrotikRecursiveRoutes, getParentGateway, } = require('../utils/mikrotikInterfaceGenerator'); -const { createRosClient, applyBlock, rosPrint } = require('../services/mikrotikApplyService'); +const { createRosClient, applyBlock, rosPrint, rosAdd, rosRemove } = require('../services/mikrotikApplyService'); const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json'; const NETWORK_CONFIG_KEY = 'network-config.json'; @@ -1261,6 +1261,75 @@ async function getAddressLists(req, res) { } } +const ADDRESS_LIST_PATH = 'ip/firewall/address-list'; + +/** + * POST /api/mikrotik/address-lists/apply-summary + * Body: { serverId, removeIds: string[], addEntries: { address, list, comment? }[] } + * Удаляет записи по .id и добавляет новые (суммаризованные) в address-list. + */ +async function applyAddressListSummary(req, res) { + try { + const { serverId, removeIds = [], addEntries = [] } = req.body || {}; + if (!serverId) { + return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); + } + const ids = Array.isArray(removeIds) ? removeIds.filter((id) => id != null && String(id).trim()) : []; + const entries = Array.isArray(addEntries) + ? addEntries.filter((e) => e && (e.address || e.list)) + : []; + + const servers = await readServersFromS3(); + const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId); + if (!server || (server.type !== 'jumphost' && server.type !== 'home')) { + return sendError(res, 400, 'Jumphost or home server not found', 'E_NOT_FOUND'); + } + + const creds = getMikrotikCredentials(server); + if (!creds) { + return sendError(res, 400, 'MikroTik credentials not configured for this server', 'E_CREDENTIALS'); + } + + const client = createRosClient(creds); + let removed = 0; + let added = 0; + + for (const id of ids) { + try { + const pathId = String(id).trim(); + await rosRemove(client, ADDRESS_LIST_PATH, pathId); + removed++; + } catch (err) { + console.warn(`[address-lists] remove ${id}:`, err?.message); + } + } + + for (const entry of entries) { + try { + const params = { + address: String(entry.address || '').trim(), + list: String(entry.list || 'ban').trim(), + }; + if (entry.comment != null && String(entry.comment).trim()) { + params.comment = String(entry.comment).trim(); + } + if (!params.address || !params.list) continue; + await rosAdd(client, ADDRESS_LIST_PATH, params); + added++; + } catch (err) { + console.warn('[address-lists] add:', err?.message); + } + } + + return res.json({ ok: true, removed, added }); + } catch (error) { + const msg = error.response?.data?.detail || error.response?.data?.message || error.message || 'Ошибка применения суммаризации'; + const status = error.response?.status; + console.error(JSON.stringify({ component: 'address-lists-apply', error: msg })); + return sendError(res, status && status >= 400 ? status : 502, msg, 'E_APPLY_SUMMARY'); + } +} + module.exports = { generateMikrotikConfig, generateInterfaces, @@ -1274,4 +1343,5 @@ module.exports = { speedTestViaTunnel, loadNetworkConfig, getAddressLists, + applyAddressListSummary, }; diff --git a/backend/server.js b/backend/server.js index 9942158..272faed 100644 --- a/backend/server.js +++ b/backend/server.js @@ -468,6 +468,7 @@ app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTes app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig); app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript); app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists); +app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary); // === TRAFFIC STATS (MikroTik interfaces by jumphost) === app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats); diff --git a/frontend/src/FirewallPage.jsx b/frontend/src/FirewallPage.jsx index 2943f78..e927eb6 100644 --- a/frontend/src/FirewallPage.jsx +++ b/frontend/src/FirewallPage.jsx @@ -7,6 +7,8 @@ import ErrorAlert from './components/ErrorAlert.jsx'; import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; import Pagination from './components/Pagination.jsx'; import Tooltip from './components/Tooltip.jsx'; +import Modal from './components/Modal.jsx'; +import ConfirmDialog from './components/ConfirmDialog.jsx'; import { IconShield, IconRefresh, @@ -14,9 +16,59 @@ import { IconCopy, IconAlertCircle, IconList, + IconChartBar, } from '@tabler/icons-react'; const PAGE_SIZE = 15; +const DEFAULT_MIN_COUNT_N = 5; + +/** Одиночный IP: без маски или /32 */ +function isSingleIp(addr) { + const s = String(addr || '').trim(); + if (!s) return false; + if (!s.includes('/')) return true; + const mask = s.split('/')[1]; + return mask === '32' || mask === '128'; +} + +/** Базовая подсеть /24 для группировки */ +function getBase24(addr) { + const s = String(addr || '').trim(); + const ipPart = s.includes('/') ? s.split('/')[0] : s; + const parts = ipPart.split('.'); + if (parts.length !== 4) return null; + const [a, b, c] = parts.map((x) => parseInt(x, 10)); + if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null; + return `${a}.${b}.${c}.0/24`; +} + +/** + * Анализ списка ban: группировка одиночных IP по /24, предложения суммаризации если в подсети > N. + */ +function analyzeBanList(banEntries, minCountN) { + const groupBy = new Map(); + for (const entry of banEntries || []) { + if (!entry?.address || !isSingleIp(entry.address)) continue; + const base = getBase24(entry.address); + if (!base) continue; + if (!groupBy.has(base)) groupBy.set(base, { subnet: base, ids: [], entries: [] }); + const g = groupBy.get(base); + g.ids.push(entry.id); + g.entries.push(entry); + } + const suggestions = []; + for (const [, g] of groupBy) { + if (g.entries.length > minCountN) { + suggestions.push({ + subnet: g.subnet, + count: g.entries.length, + ids: g.ids, + addEntry: { address: g.subnet, list: 'ban', comment: `суммаризация ${g.entries.length} IP` }, + }); + } + } + return { suggestions }; +} /** Нормализация записи address-list из RouterOS (поля могут приходить в разном регистре) */ function normalizeEntry(entry) { @@ -172,6 +224,11 @@ export default function FirewallPage() { const [loading, setLoading] = useState(false); const [loadingServers, setLoadingServers] = useState(true); const [error, setError] = useState(''); + const [analysisOpen, setAnalysisOpen] = useState(false); + const [minCountN, setMinCountN] = useState(DEFAULT_MIN_COUNT_N); + const [analysisResult, setAnalysisResult] = useState(null); + const [applyConfirmOpen, setApplyConfirmOpen] = useState(false); + const [applying, setApplying] = useState(false); const routerServers = useMemo( () => @@ -227,6 +284,37 @@ export default function FirewallPage() { else setData(null); }, [serverId]); + const runAnalysis = () => { + if (!data?.ban?.length) return; + const n = Math.max(2, parseInt(minCountN, 10) || DEFAULT_MIN_COUNT_N); + setMinCountN(n); + const result = analyzeBanList(data.ban, n); + setAnalysisResult(result); + }; + + const handleApplySummary = async () => { + if (!serverId || !analysisResult?.suggestions?.length) return; + setApplying(true); + try { + const removeIds = analysisResult.suggestions.flatMap((s) => s.ids); + const addEntries = analysisResult.suggestions.map((s) => s.addEntry); + await api.post('/mikrotik/address-lists/apply-summary', { + serverId, + removeIds, + addEntries, + }); + window.notify?.success?.(`Удалено ${removeIds.length} записей, добавлено ${addEntries.length} подсетей.`); + setApplyConfirmOpen(false); + setAnalysisOpen(false); + setAnalysisResult(null); + await fetchAddressLists(); + } catch (e) { + window.notify?.error?.(e?.response?.data?.message || e?.message || 'Ошибка применения'); + } finally { + setApplying(false); + } + }; + const currentServerLabel = serverMeta?.dns || serverMeta?.ip || serverId || 'Не выбран'; return ( @@ -241,6 +329,19 @@ export default function FirewallPage() { meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`} actions={
+
)} + + { + setAnalysisOpen(false); + setAnalysisResult(null); + setApplyConfirmOpen(false); + }} + title="Анализ списка ban" + size="lg" + > +
+ +
+ setMinCountN(e.target.value)} + /> + (по умолчанию {DEFAULT_MIN_COUNT_N}) +
+
+
+ +
+ {analysisResult && ( + <> + {analysisResult.suggestions.length === 0 ? ( +

Нет подсетей для суммаризации (в каждой /24 не больше {minCountN} одиночных IP).

+ ) : ( + <> +

+ Найдено {analysisResult.suggestions.length} подсетей /24 для замены: удалить одиночные IP и добавить одну запись на подсеть. +

+
+ + + + + + + + + + {analysisResult.suggestions.map((s) => ( + + + + + + ))} + +
ПодсетьКол-во IPДействие
{s.subnet}{s.count}Удалить {s.count} записей → добавить 1
+
+
+ +
+ + )} + + )} +
+ + acc + s.ids.length, 0)} записей и добавлено ${analysisResult.suggestions.length} записей (подсети /24). Продолжить?` + : '' + } + confirmText="Применить" + cancelText="Отмена" + destructive + size="md" + loading={applying} + onConfirm={handleApplySummary} + onCancel={() => setApplyConfirmOpen(false)} + /> ); }