feat(MikrotikConfigRoutes, App): add endpoint for retrieving address lists and integrate firewall page in frontend
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m58s

This commit is contained in:
2026-02-21 18:29:38 +07:00
parent 357503ee2f
commit f9ac5ac3a3
5 changed files with 377 additions and 5 deletions
+46 -1
View File
@@ -15,7 +15,7 @@ const {
buildMikrotikRecursiveRoutes,
getParentGateway,
} = require('../utils/mikrotikInterfaceGenerator');
const { createRosClient, applyBlock } = require('../services/mikrotikApplyService');
const { createRosClient, applyBlock, rosPrint } = require('../services/mikrotikApplyService');
const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
const NETWORK_CONFIG_KEY = 'network-config.json';
@@ -1146,6 +1146,50 @@ async function runScript(req, res) {
}
}
/**
* GET /api/mikrotik/address-lists?serverId=xxx
* Возвращает address-list списки ban и ban_counter с выбранного роутера (MikroTik).
*/
async function getAddressLists(req, res) {
try {
const serverId = req.query?.serverId;
if (!serverId) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
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);
const path = 'ip/firewall/address-list';
const [ban, banCounter] = await Promise.all([
rosPrint(client, path, { list: 'ban' }),
rosPrint(client, path, { list: 'ban_counter' }),
]);
return sendOk(res, {
serverId,
serverLabel: server.dns || server.ip || serverId,
ban: Array.isArray(ban) ? ban : [],
ban_counter: Array.isArray(banCounter) ? banCounter : [],
});
} catch (error) {
const msg = error.response?.data?.detail || error.response?.data?.message || error.message || 'Ошибка получения address-list';
const status = error.response?.status;
console.error('getAddressLists:', error);
return sendError(res, status && status >= 400 ? status : 502, msg, 'E_ADDRESS_LISTS');
}
}
module.exports = {
generateMikrotikConfig,
generateInterfaces,
@@ -1158,4 +1202,5 @@ module.exports = {
runPingViaRouter,
speedTestViaTunnel,
loadNetworkConfig,
getAddressLists,
};
+1
View File
@@ -467,6 +467,7 @@ app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerou
app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTestViaTunnel);
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
+8 -3
View File
@@ -24,7 +24,8 @@ import {
IconLayoutSidebarLeftExpand,
IconLayoutNavbar,
IconChartPie,
IconClockPlay
IconClockPlay,
IconShield
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
@@ -44,6 +45,7 @@ import NetworkMapDashboard from './NetworkMapDashboard.jsx';
import NetworkMapSchedulerPage from './NetworkMapSchedulerPage.jsx';
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
import PingServicesManager from './PingServicesManager.jsx';
import FirewallPage from './FirewallPage.jsx';
import SettingsPage from './SettingsPage.jsx';
import './App.css';
import { NotifyProvider } from './components/NotifyProvider.jsx';
@@ -64,7 +66,7 @@ function LanguageProvider({ children }) {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', networkMap: 'Карта сети', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
light: 'Светлая', dark: 'Тёмная',
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
},
@@ -72,7 +74,7 @@ function LanguageProvider({ children }) {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', networkMap: 'Network Map', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services',
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall',
light: 'Light', dark: 'Dark',
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
}
@@ -231,6 +233,7 @@ function MainLayout() {
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork },
{ id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay }
@@ -400,6 +403,7 @@ function MainLayout() {
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
@@ -560,6 +564,7 @@ function MainLayout() {
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
+319
View File
@@ -0,0 +1,319 @@
import { useState, useEffect, useMemo } from 'react';
import api from './lib/api.js';
import PageHeader from './components/PageHeader.jsx';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx';
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 {
IconShield,
IconRefresh,
IconSearch,
IconCopy,
IconAlertCircle,
IconList,
} from '@tabler/icons-react';
const PAGE_SIZE = 15;
/** Нормализация записи address-list из RouterOS (поля могут приходить в разном регистре) */
function normalizeEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const addr = entry.address ?? entry.Address ?? '';
const list = entry.list ?? entry.List ?? '';
const timeout = entry.timeout ?? entry.Timeout ?? '';
const comment = entry.comment ?? entry.Comment ?? '';
const id = entry['.id'] ?? entry.id ?? '';
const disabled = entry.disabled ?? entry.Disabled ?? '';
const dynamic = entry.dynamic ?? entry.Dynamic ?? '';
return {
id: id || addr || Math.random().toString(36).slice(2),
address: String(addr).trim(),
list: String(list).trim(),
timeout: String(timeout).trim(),
comment: String(comment).trim(),
disabled: String(disabled).trim(),
dynamic: String(dynamic).trim(),
};
}
function AddressListTable({ title, listKey, items, loading, emptyMessage }) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const filtered = useMemo(() => {
const term = String(search || '').toLowerCase().trim();
if (!term) return items;
return items.filter((e) => {
const a = (e.address || '').toLowerCase();
const c = (e.comment || '').toLowerCase();
return a.includes(term) || c.includes(term);
});
}, [items, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = useMemo(
() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filtered, page]
);
useEffect(() => {
setPage(1);
}, [search]);
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
window.notify?.success?.('Скопировано в буфер обмена');
}).catch(() => {
window.notify?.error?.('Не удалось скопировать');
});
};
return (
<div className="card card-flat mb-4">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<div className="d-flex align-items-center gap-2">
<IconList className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">{title}</span>
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
</div>
</div>
<div className="col-12 mt-2" />
<div className="col">
<div className="input-icon flex-grow-1" style={{ maxWidth: '320px' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по IP или комментарию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Поиск"
/>
</div>
</div>
</div>
</div>
<div className="card-body p-0">
{loading ? (
<TableSkeleton rows={8} cols={4} flat />
) : paginated.length === 0 ? (
<TableEmpty cols={4} flat>
<EmptyState
title={items.length === 0 ? emptyMessage : 'Нет совпадений'}
description={items.length === 0 ? `Список ${listKey} пуст на роутере.` : 'Измените поисковый запрос.'}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>#</th>
<th>IP-адрес</th>
<th>Таймаут</th>
<th>Комментарий</th>
<th className="text-end" style={{ width: '50px' }}></th>
</tr>
</thead>
<tbody>
{paginated.map((entry, idx) => (
<tr key={entry.id || entry.address || idx}>
<td className="text-muted small">{(page - 1) * PAGE_SIZE + idx + 1}</td>
<td className="font-monospace">
<div className="d-flex align-items-center">
<span>{entry.address || '—'}</span>
<Tooltip content="Копировать IP" position="top">
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(entry.address)}
aria-label="Копировать"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td className="text-muted">{entry.timeout || '—'}</td>
<td className="text-muted small">{entry.comment || '—'}</td>
<td className="text-end"></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{!loading && filtered.length > PAGE_SIZE && (
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={filtered.length}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
)}
</div>
);
}
export default function FirewallPage() {
const [servers, setServers] = useState([]);
const [serverId, setServerId] = useState('');
const [serverMeta, setServerMeta] = useState(null);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [loadingServers, setLoadingServers] = useState(true);
const [error, setError] = useState('');
const routerServers = useMemo(
() =>
(servers || []).filter(
(s) => String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home'
),
[servers]
);
useEffect(() => {
let cancelled = false;
(async () => {
setLoadingServers(true);
try {
const res = await api.get('/servers');
const list = Array.isArray(res.data) ? res.data : [];
if (!cancelled) setServers(list);
} catch (e) {
if (!cancelled) setError('Не удалось загрузить список серверов.');
} finally {
if (!cancelled) setLoadingServers(false);
}
})();
return () => { cancelled = true; };
}, []);
const fetchAddressLists = async () => {
if (!serverId) {
setData(null);
return;
}
setLoading(true);
setError('');
try {
const res = await api.get('/mikrotik/address-lists', { params: { serverId } });
const payload = res?.data ?? {};
setData({
serverLabel: payload.serverLabel || serverId,
ban: (payload.ban || []).map(normalizeEntry).filter(Boolean),
ban_counter: (payload.ban_counter || []).map(normalizeEntry).filter(Boolean),
});
} catch (e) {
console.error('Firewall fetch:', e);
setData(null);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить address-list с роутера.');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (serverId) fetchAddressLists();
else setData(null);
}, [serverId]);
const currentServerLabel = serverMeta?.dns || serverMeta?.ip || serverId || 'Не выбран';
return (
<div>
{error && (
<ErrorAlert message={error} onClose={() => setError('')} />
)}
<PageHeader
title="Firewall"
icon={<IconShield size={24} />}
meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`}
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-outline-primary"
onClick={fetchAddressLists}
disabled={!serverId || loading}
>
<IconRefresh className={loading ? 'icon spin me-1' : 'me-1'} size={18} />
Обновить
</button>
</div>
}
/>
<div className="card card-flat mb-4">
<div className="card-header py-3">
<h3 className="card-title mb-0">Сервер (MikroTik)</h3>
<p className="text-muted small mb-0 mt-1">
Выберите jumphost или home-роутер для просмотра списков <strong>ban</strong> и <strong>ban_counter</strong>.
</p>
</div>
<div className="card-body">
{loadingServers ? (
<div className="text-muted">Загрузка списка серверов...</div>
) : (
<div style={{ maxWidth: '420px' }}>
<ServerAutocompleteInput
value={serverId}
onChange={setServerId}
onSelectMeta={setServerMeta}
servers={routerServers}
placeholder="Выберите сервер..."
/>
</div>
)}
</div>
</div>
{!serverId && (
<div className="card card-flat">
<div className="card-body text-center py-5">
<IconAlertCircle className="icon text-muted mb-2" size={48} />
<p className="text-muted mb-0">Выберите сервер выше, чтобы загрузить address-list (ban и ban_counter).</p>
</div>
</div>
)}
{serverId && data && (
<>
<AddressListTable
title="Список ban"
listKey="ban"
items={data.ban}
loading={loading}
emptyMessage="Список ban пуст"
/>
<AddressListTable
title="Список ban_counter"
listKey="ban_counter"
items={data.ban_counter}
loading={loading}
emptyMessage="Список ban_counter пуст"
/>
</>
)}
{serverId && loading && !data && (
<div className="card card-flat">
<div className="card-body py-5 text-center text-muted">
<IconRefresh className="icon spin me-2" size={20} />
Загрузка списков с роутера...
</div>
</div>
)}
</div>
);
}
+3 -1
View File
@@ -13,7 +13,8 @@ import {
IconKeyboard,
IconChartPie,
IconSettings,
IconClockPlay
IconClockPlay,
IconShield
} from '@tabler/icons-react'
/**
@@ -50,6 +51,7 @@ function CommandPalette() {
{ icon: IconFilter, label: 'Community', description: 'Справочник BGP Community', action: () => navigate('/communities'), keywords: ['community', 'справочник'] },
{ icon: IconServer, label: 'Серверы', description: 'Управление серверами', action: () => navigate('/servers'), keywords: ['серверы', 'servers'] },
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
{ icon: IconShield, label: 'Firewall', description: 'Списки блокировки ban и ban_counter с роутера', action: () => navigate('/firewall'), keywords: ['firewall', 'ban', 'address-list', 'блокировка', 'роутер'] },
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
{ icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] },
{ icon: IconSettings, label: 'Настройки интерфейса', description: 'WebSocket, DoH, пинг, PTR зона', action: () => navigate('/settings'), keywords: ['настройки', 'settings', 'интерфейс', 'doh', 'websocket'] },