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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m58s
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'] },
|
||||
|
||||
Reference in New Issue
Block a user