From 5eecb8d070f824733cbacccc621ca31cf40925a3 Mon Sep 17 00:00:00 2001 From: shats Date: Mon, 9 Feb 2026 17:06:49 +0700 Subject: [PATCH] feat(mikrotikBackupScheduler, MikrotikBackupsManager): enhance backup server management by integrating UI settings and adding server selection functionality --- backend/services/mikrotikBackupScheduler.js | 63 +++++++--- frontend/src/MikrotikBackupsManager.jsx | 128 +++++++++++++++++++- frontend/src/components/SettingsModal.jsx | 19 +-- 3 files changed, 181 insertions(+), 29 deletions(-) diff --git a/backend/services/mikrotikBackupScheduler.js b/backend/services/mikrotikBackupScheduler.js index 64bdc65..868baf8 100644 --- a/backend/services/mikrotikBackupScheduler.js +++ b/backend/services/mikrotikBackupScheduler.js @@ -15,6 +15,8 @@ const { readServersFromS3 } = require('../routes/serversRoutes'); const { createRosClient } = require('./mikrotikApplyService'); const { saveBackupForServer } = require('../routes/mikrotikBackupRoutes'); +const { decrypt } = require('../utils/encryption'); +const { readS3TextObject } = require('../services/s3Service'); const DEFAULT_INTERVAL_MIN = 60; @@ -27,6 +29,29 @@ function parseServerIds(envValue) { return parts.length > 0 ? parts : null; } +/** + * Прочитать список серверов для автобэкапов из UI-настроек (rt_ui_settings.json). + * Если поле mikrotikBackupServers отсутствует или пустое — вернуть null. + */ +async function getServerIdsFromUiSettings(logger) { + try { + const data = await readS3TextObject('bgp_data/rt_ui_settings.json').catch(() => null); + if (!data || !data.body) return null; + const parsed = JSON.parse(data.body || '{}'); + const list = parsed && Array.isArray(parsed.mikrotikBackupServers) + ? parsed.mikrotikBackupServers + : null; + if (!list || list.length === 0) return null; + return list.map((v) => String(v)).filter(Boolean); + } catch (e) { + (logger || console).warn( + { component: 'mikrotik-backup', err: e && e.message }, + 'Failed to read mikrotikBackupServers from ui-settings', + ); + return null; + } +} + async function fetchRouterExport(client) { // Согласно официальной документации, /rest/export позволяет экспортировать конфиг. // Параметр "compact" без значения эквивалентен /export compact. @@ -61,7 +86,9 @@ async function runBackupOnce(logger) { const log = logger || console; try { const allServers = await readServersFromS3(); - const wantedIds = parseServerIds(process.env.MIKROTIK_BACKUP_SERVERS); + const uiIds = await getServerIdsFromUiSettings(log); + const envIds = parseServerIds(process.env.MIKROTIK_BACKUP_SERVERS); + const wantedIds = (uiIds && uiIds.length > 0) ? uiIds : envIds; const jumphosts = (allServers || []).filter((s) => { if (!s || String(s.type || '').toLowerCase() !== 'jumphost') return false; @@ -87,32 +114,32 @@ async function runBackupOnce(logger) { continue; } - if (!server.encryptedMikrotikPassword) { - log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no encryptedMikrotikPassword configured'); + let password = ''; + if (server.encryptedMikrotikPassword) { + try { + password = decrypt(server.encryptedMikrotikPassword); + } catch (e) { + log.error( + { component: 'mikrotik-backup', serverId: id, err: e && e.message }, + 'Failed to decrypt MikroTik password, skipping backup', + ); + continue; + } + } else { + log.warn( + { component: 'mikrotik-backup', serverId: id }, + 'Skip backup: no encryptedMikrotikPassword configured', + ); continue; } - - // Пароль будет расшифрован на стороне RouterOS — мы используем тот же механизм, - // что и в apply/test-connection: encryptedMikrotikPassword должен быть уже расшифрован ранее. - // Здесь для простоты предполагаем, что пароль можно получить так же, как в apply. - // Чтобы не дублировать логику расшифровки, используем те же поля, что и в mikrotikConfigRoutes.getMikrotikCredentials. const creds = { host, port: Number(port) || 80, user, - // Пароль фактически должен быть расшифрован при сохранении/загрузке, - // однако в текущей архитектуре encryptedMikrotikPassword расшифровывается в роуте. - // Чтобы не нарушать безопасность и не тянуть сюда ключ, требуем наличия plain-поля mikrotikPassword, - // если оно временно присутствует (например, для локального использования или через ENV/секреты). - password: server.mikrotikPassword || '', + password, secure: false, }; - if (!creds.password) { - log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no plain mikrotikPassword available'); - continue; - } - const client = createRosClient(creds); log.info({ component: 'mikrotik-backup', serverId: id, host: creds.host }, 'Starting automatic backup via REST /export'); const configText = await fetchRouterExport(client); diff --git a/frontend/src/MikrotikBackupsManager.jsx b/frontend/src/MikrotikBackupsManager.jsx index 09b6632..698c027 100644 --- a/frontend/src/MikrotikBackupsManager.jsx +++ b/frontend/src/MikrotikBackupsManager.jsx @@ -45,6 +45,9 @@ function MikrotikBackupsManager() { const [servers, setServers] = useState([]); const [selectedServer, setSelectedServer] = useState(null); + const [uiSettings, setUiSettings] = useState({}); + const [uiSettingsEtag, setUiSettingsEtag] = useState(''); + const [backupServerIds, setBackupServerIds] = useState(new Set()); const [backups, setBackups] = useState([]); const [loadingServers, setLoadingServers] = useState(false); const [loadingBackups, setLoadingBackups] = useState(false); @@ -57,6 +60,7 @@ function MikrotikBackupsManager() { useEffect(() => { fetchServers(); + fetchUiSettings(); }, []); useEffect(() => { @@ -73,6 +77,8 @@ function MikrotikBackupsManager() { [servers], ); + const backupServerIdsList = useMemo(() => Array.from(backupServerIds), [backupServerIds]); + const fetchServers = async () => { try { setLoadingServers(true); @@ -91,6 +97,21 @@ function MikrotikBackupsManager() { } }; + const fetchUiSettings = async () => { + try { + const res = await api.get('/ui-settings'); + const data = res?.data || {}; + setUiSettings(data); + const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : []; + setBackupServerIds(new Set(list.map((v) => String(v)))); + const e = res?.headers?.etag || res?.headers?.ETag || ''; + setUiSettingsEtag(e ? String(e) : ''); + } catch (err) { + console.error('Error fetching UI settings for backups:', err); + // мягко игнорируем, просто будут дефолты + } + }; + const fetchBackups = async (serverId) => { if (!serverId) return; try { @@ -196,6 +217,36 @@ function MikrotikBackupsManager() { } }; + const toggleBackupServer = (serverId) => { + setBackupServerIds((prev) => { + const next = new Set(prev); + if (next.has(serverId)) next.delete(serverId); + else next.add(serverId); + return next; + }); + }; + + const saveBackupServers = async () => { + try { + const settingsPayload = { + ...uiSettings, + mikrotikBackupServers: backupServerIdsList, + }; + const payload = { + settings: settingsPayload, + etag: uiSettingsEtag, + }; + const res = await api.post('/ui-settings', payload); + const meta = res?.data || {}; + setUiSettings(settingsPayload); + setUiSettingsEtag(String(meta?.etag || '')); + notify.success('Список серверов для автобэкапа сохранён'); + } catch (err) { + console.error('Error saving backup servers to ui-settings:', err); + notify.error(err?.response?.data?.message || 'Не удалось сохранить список серверов для автобэкапа'); + } + }; + const actions = (
@@ -229,10 +280,10 @@ function MikrotikBackupsManager() {
- Автобэкапы настраиваются через переменные окружения: - MIKROTIK_BACKUP_ENABLED, - MIKROTIK_BACKUP_INTERVAL_MINUTES, - MIKROTIK_BACKUP_SERVERS. + Автобэкапы включаются для выбранных ниже Jumphost-серверов. Интервал и включение/выключение + планировщика настраиваются через переменные окружения + MIKROTIK_BACKUP_ENABLED и + MIKROTIK_BACKUP_INTERVAL_MINUTES.
); @@ -249,6 +300,75 @@ function MikrotikBackupsManager() { actions={actions} /> + {/* Блок выбора серверов для автобэкапа */} +
+
+
+
Сервера для автоматических бэкапов
+
+ Отмеченные здесь Jumphost-серверы будут периодически бэкапиться планировщиком на backend. +
+
+ +
+
+ {jumphostServers.length === 0 ? ( +
+ Нет серверов типа jumphost. Добавьте их в разделе «Серверы». +
+ ) : ( +
+ + + + + + + + + + + + {jumphostServers.map((s) => { + const id = s.id || s.dns || s.ip; + const checked = id && backupServerIds.has(String(id)); + return ( + + + + + + + + ); + })} + +
СерверIP / DNSСтранаПровайдер
+ id && toggleBackupServer(String(id))} + /> + {s.dns || s.ip || id} +
+ {s.ip} + {s.dns && s.ip ? ' · ' : ''} + {s.dns} +
+
{s.country || '-'}{s.provider || '-'}
+
+ )} +
+
+ {loadingServers ? ( ) : !selectedServer ? ( diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index fb4240a..b0b4bf8 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -14,6 +14,7 @@ export default function SettingsModal({ open, onClose }) { const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [etag, setEtag] = useState(''); + const [rawSettings, setRawSettings] = useState({}); const [dohServer, setDohServer] = useState(''); const [wsUrl, setWsUrl] = useState(''); const [baseAS, setBaseAS] = useState('65001'); @@ -30,6 +31,7 @@ export default function SettingsModal({ open, onClose }) { try { const res = await api.get('/ui-settings'); const data = res?.data || {}; + setRawSettings(data); setDohServer(String(data?.dohServer || '')); setWsUrl(String(data?.wsUpdateUrl || '')); setBaseAS(String(data?.baseAS || '65001')); @@ -90,20 +92,23 @@ export default function SettingsModal({ open, onClose }) { setSaving(true); try { + const mergedSettings = { + ...rawSettings, + dohServer: String(dohServer || '').trim(), + wsUpdateUrl: String(wsUrl || '').trim(), + baseAS: String(baseAS || '65001').trim(), + ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(), + ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(), + }; const payload = { - settings: { - dohServer: String(dohServer || '').trim(), - wsUpdateUrl: String(wsUrl || '').trim(), - baseAS: String(baseAS || '65001').trim(), - ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(), - ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim() - }, + settings: mergedSettings, etag }; const res = await api.post('/ui-settings', payload); const meta = res?.data || {}; setSuccess('Настройки успешно сохранены'); setEtag(String(meta?.etag || '')); + setRawSettings(mergedSettings); setTimeout(() => setSuccess(''), 3000); } catch (e) { setError(e?.response?.data?.message || 'Ошибка при сохранении настроек');