feat(MikrotikConfigRoutes, FirewallPage): add apply-summary endpoint and frontend analysis feature for address lists
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
This commit is contained in:
@@ -15,7 +15,7 @@ const {
|
|||||||
buildMikrotikRecursiveRoutes,
|
buildMikrotikRecursiveRoutes,
|
||||||
getParentGateway,
|
getParentGateway,
|
||||||
} = require('../utils/mikrotikInterfaceGenerator');
|
} = 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 IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
|
||||||
const NETWORK_CONFIG_KEY = 'network-config.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 = {
|
module.exports = {
|
||||||
generateMikrotikConfig,
|
generateMikrotikConfig,
|
||||||
generateInterfaces,
|
generateInterfaces,
|
||||||
@@ -1274,4 +1343,5 @@ module.exports = {
|
|||||||
speedTestViaTunnel,
|
speedTestViaTunnel,
|
||||||
loadNetworkConfig,
|
loadNetworkConfig,
|
||||||
getAddressLists,
|
getAddressLists,
|
||||||
|
applyAddressListSummary,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTes
|
|||||||
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
||||||
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
||||||
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
|
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) ===
|
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
|
||||||
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import ErrorAlert from './components/ErrorAlert.jsx';
|
|||||||
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
||||||
import Pagination from './components/Pagination.jsx';
|
import Pagination from './components/Pagination.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
import Modal from './components/Modal.jsx';
|
||||||
|
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||||
import {
|
import {
|
||||||
IconShield,
|
IconShield,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
@@ -14,9 +16,59 @@ import {
|
|||||||
IconCopy,
|
IconCopy,
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconList,
|
IconList,
|
||||||
|
IconChartBar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
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 (поля могут приходить в разном регистре) */
|
/** Нормализация записи address-list из RouterOS (поля могут приходить в разном регистре) */
|
||||||
function normalizeEntry(entry) {
|
function normalizeEntry(entry) {
|
||||||
@@ -172,6 +224,11 @@ export default function FirewallPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingServers, setLoadingServers] = useState(true);
|
const [loadingServers, setLoadingServers] = useState(true);
|
||||||
const [error, setError] = useState('');
|
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(
|
const routerServers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -227,6 +284,37 @@ export default function FirewallPage() {
|
|||||||
else setData(null);
|
else setData(null);
|
||||||
}, [serverId]);
|
}, [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 || 'Не выбран';
|
const currentServerLabel = serverMeta?.dns || serverMeta?.ip || serverId || 'Не выбран';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -241,6 +329,19 @@ export default function FirewallPage() {
|
|||||||
meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`}
|
meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`}
|
||||||
actions={
|
actions={
|
||||||
<div className="btn-list">
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setAnalysisOpen(true);
|
||||||
|
setAnalysisResult(null);
|
||||||
|
}}
|
||||||
|
disabled={!serverId || !data?.ban?.length || loading}
|
||||||
|
title="Анализ списка ban и суммаризация по /24"
|
||||||
|
>
|
||||||
|
<IconChartBar className="me-1" size={18} />
|
||||||
|
Анализ
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
@@ -314,6 +415,98 @@ export default function FirewallPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
show={analysisOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setAnalysisOpen(false);
|
||||||
|
setAnalysisResult(null);
|
||||||
|
setApplyConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
title="Анализ списка ban"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Суммаризировать подсети /24, в которых одиночных IP больше чем</label>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
min={2}
|
||||||
|
max={100}
|
||||||
|
value={minCountN}
|
||||||
|
onChange={(e) => setMinCountN(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted small">(по умолчанию {DEFAULT_MIN_COUNT_N})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={runAnalysis}>
|
||||||
|
<IconChartBar className="me-1" size={18} />
|
||||||
|
Анализировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{analysisResult && (
|
||||||
|
<>
|
||||||
|
{analysisResult.suggestions.length === 0 ? (
|
||||||
|
<p className="text-muted mb-0">Нет подсетей для суммаризации (в каждой /24 не больше {minCountN} одиночных IP).</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">
|
||||||
|
Найдено <strong>{analysisResult.suggestions.length}</strong> подсетей /24 для замены: удалить одиночные IP и добавить одну запись на подсеть.
|
||||||
|
</p>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-vcenter mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Подсеть</th>
|
||||||
|
<th className="text-end">Кол-во IP</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analysisResult.suggestions.map((s) => (
|
||||||
|
<tr key={s.subnet}>
|
||||||
|
<td className="font-monospace">{s.subnet}</td>
|
||||||
|
<td className="text-end">{s.count}</td>
|
||||||
|
<td className="text-muted small">Удалить {s.count} записей → добавить 1</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={() => setApplyConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Применить на роутере
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={applyConfirmOpen}
|
||||||
|
title="Применить суммаризацию на роутере?"
|
||||||
|
message={
|
||||||
|
analysisResult?.suggestions?.length
|
||||||
|
? `Будет удалено ${analysisResult.suggestions.reduce((acc, s) => acc + s.ids.length, 0)} записей и добавлено ${analysisResult.suggestions.length} записей (подсети /24). Продолжить?`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmText="Применить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
destructive
|
||||||
|
size="md"
|
||||||
|
loading={applying}
|
||||||
|
onConfirm={handleApplySummary}
|
||||||
|
onCancel={() => setApplyConfirmOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user