diff --git a/backend/routes/mikrotikBackupRoutes.js b/backend/routes/mikrotikBackupRoutes.js
new file mode 100644
index 0000000..bc12d97
--- /dev/null
+++ b/backend/routes/mikrotikBackupRoutes.js
@@ -0,0 +1,204 @@
+/**
+ * Роуты для бэкапов конфигурации MikroTik
+ * Хранят сырый текст конфигурации (например, вывод /export) в S3.
+ *
+ * Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
+ *
+ * Задачи:
+ * - создать бэкап (ручной или из планировщика)
+ * - получить список бэкапов для сервера
+ * - прочитать конкретный бэкап
+ * - (опционально) отдать две версии для сравнения и отката
+ */
+
+const crypto = require('crypto');
+const { sendError } = require('../middleware/errorHandler');
+const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service');
+
+const BACKUP_PREFIX = 'backups/mikrotik';
+
+function buildBackupKey(serverId, createdAt, suffix) {
+ const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
+ const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z');
+ const rand = suffix || crypto.randomBytes(3).toString('hex');
+ return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`;
+}
+
+/**
+ * Внутренняя утилита: сохранить бэкап в S3.
+ * Используется как HTTP-роутом, так и планировщиком.
+ */
+async function saveBackupForServer(serverId, config, { source, comment } = {}) {
+ const now = new Date();
+ const key = buildBackupKey(serverId, now);
+
+ const headerLines = [
+ '# MikroTik configuration backup',
+ `# Server: ${serverId}`,
+ `# CreatedAt: ${now.toISOString()}`,
+ source ? `# Source: ${String(source)}` : null,
+ comment ? `# Comment: ${String(comment).replace(/\r?\n/g, ' ')}` : null,
+ '',
+ ].filter(Boolean);
+
+ const content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
+
+ const meta = await writeS3TextObject(key, content, 'text/plain');
+ return { key, createdAt: now.toISOString(), s3: meta };
+}
+
+/**
+ * POST /api/mikrotik/backups
+ * Body: { serverId: string, config: string, source?: string, comment?: string }
+ *
+ * Сохраняет текст конфигурации (обычно вывод /export) в S3.
+ * Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
+ */
+async function createBackup(req, res) {
+ try {
+ const { serverId, config, source, comment } = req.body || {};
+
+ if (!serverId || typeof serverId !== 'string') {
+ return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
+ }
+ if (!config || typeof config !== 'string') {
+ return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST');
+ }
+
+ const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment });
+
+ return res.json({
+ ok: true,
+ key,
+ serverId,
+ createdAt,
+ s3,
+ });
+ } catch (error) {
+ console.error('createBackup error:', error);
+ return sendError(res, 500, error.message || 'Error creating backup', 'E_BACKUP_CREATE');
+ }
+}
+
+/**
+ * GET /api/mikrotik/backups?serverId=...
+ *
+ * Возвращает список бэкапов для сервера (без содержимого).
+ */
+async function listBackups(req, res) {
+ try {
+ const serverId = req.query.serverId;
+ if (!serverId || typeof serverId !== 'string') {
+ return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
+ }
+
+ const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`;
+ const objects = await listS3Objects(prefix, { maxKeys: 200 });
+
+ const items = objects
+ .filter(o => o.key && o.key.startsWith(prefix))
+ .map(o => ({
+ key: o.key,
+ serverId,
+ createdAt: o.lastModified,
+ size: o.size,
+ etag: o.etag,
+ }))
+ .sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
+
+ return res.json({ ok: true, items });
+ } catch (error) {
+ console.error('listBackups error:', error);
+ return sendError(res, 500, error.message || 'Error listing backups', 'E_BACKUP_LIST');
+ }
+}
+
+/**
+ * GET /api/mikrotik/backups/item?key=...
+ *
+ * Возвращает содержимое конкретного бэкапа.
+ */
+async function getBackup(req, res) {
+ try {
+ const key = req.query.key;
+ if (!key || typeof key !== 'string') {
+ return sendError(res, 400, 'key is required', 'E_BAD_REQUEST');
+ }
+ if (!key.startsWith(`${BACKUP_PREFIX}/`)) {
+ return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
+ }
+
+ const data = await readS3TextObject(key);
+
+ return res.json({
+ ok: true,
+ key,
+ config: data.body || '',
+ etag: data.etag,
+ lastModified: data.lastModified,
+ contentLength: data.contentLength,
+ });
+ } catch (error) {
+ console.error('getBackup error:', error);
+ return sendError(res, 500, error.message || 'Error reading backup', 'E_BACKUP_READ');
+ }
+}
+
+/**
+ * POST /api/mikrotik/backups/diff
+ * Body: { keyA: string, keyB: string }
+ *
+ * Простой текстовый diff: возвращает оба варианта и флаг same.
+ * Визуальное сравнение (подсветка отличий) можно сделать на frontend.
+ */
+async function diffBackups(req, res) {
+ try {
+ const { keyA, keyB } = req.body || {};
+ if (!keyA || !keyB) {
+ return sendError(res, 400, 'keyA and keyB are required', 'E_BAD_REQUEST');
+ }
+ if (!String(keyA).startsWith(`${BACKUP_PREFIX}/`) || !String(keyB).startsWith(`${BACKUP_PREFIX}/`)) {
+ return sendError(res, 400, 'Invalid backup keys', 'E_BAD_REQUEST');
+ }
+
+ const [a, b] = await Promise.all([
+ readS3TextObject(keyA),
+ readS3TextObject(keyB),
+ ]);
+
+ const bodyA = a.body || '';
+ const bodyB = b.body || '';
+
+ return res.json({
+ ok: true,
+ same: bodyA === bodyB,
+ a: {
+ key: keyA,
+ etag: a.etag,
+ lastModified: a.lastModified,
+ contentLength: a.contentLength,
+ },
+ b: {
+ key: keyB,
+ etag: b.etag,
+ lastModified: b.lastModified,
+ contentLength: b.contentLength,
+ },
+ // Сырые тексты отдаем целиком — UI сам покажет diff
+ configA: bodyA,
+ configB: bodyB,
+ });
+ } catch (error) {
+ console.error('diffBackups error:', error);
+ return sendError(res, 500, error.message || 'Error diffing backups', 'E_BACKUP_DIFF');
+ }
+}
+
+module.exports = {
+ createBackup,
+ listBackups,
+ getBackup,
+ diffBackups,
+ saveBackupForServer,
+};
+
diff --git a/backend/server.js b/backend/server.js
index f090df9..4340bc7 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -28,6 +28,8 @@ const serverConfigsRoutes = require('./routes/serverConfigsRoutes');
const miscRoutes = require('./routes/miscRoutes');
const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes');
const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
+const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
+const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
const app = express();
const port = Number(process.env.PORT) || 3001;
@@ -454,6 +456,12 @@ app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now()
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
+// === MIKROTIK BACKUPS (S3) ===
+app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
+app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
+app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
+app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups);
+
// === MIKROTIK VALIDATION ===
app.post('/api/mikrotik/validate', async (req, res) => {
const { config } = req.body;
@@ -483,3 +491,10 @@ app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
+// Запускаем планировщик автоматического бэкапа MikroTik (если включен через ENV)
+try {
+ initMikrotikBackupScheduler(logger);
+} catch (e) {
+ logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler');
+}
+
diff --git a/backend/services/mikrotikBackupScheduler.js b/backend/services/mikrotikBackupScheduler.js
new file mode 100644
index 0000000..64bdc65
--- /dev/null
+++ b/backend/services/mikrotikBackupScheduler.js
@@ -0,0 +1,185 @@
+/**
+ * Простейший планировщик автоматических бэкапов MikroTik.
+ *
+ * Работает внутри Node-процесса backend:
+ * - периодически обходит jumphost-сервера из servers.json
+ * - вызывает RouterOS REST API /rest/export (compact)
+ * - сохраняет конфиг в S3 через saveBackupForServer
+ *
+ * Управляется через переменные окружения:
+ * - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
+ * - MIKROTIK_BACKUP_INTERVAL_MINUTES=60 (интервал между запусками)
+ * - MIKROTIK_BACKUP_SERVERS="id1,id2" (если не указано — все jumphost)
+ */
+
+const { readServersFromS3 } = require('../routes/serversRoutes');
+const { createRosClient } = require('./mikrotikApplyService');
+const { saveBackupForServer } = require('../routes/mikrotikBackupRoutes');
+
+const DEFAULT_INTERVAL_MIN = 60;
+
+function parseServerIds(envValue) {
+ if (!envValue) return null;
+ const parts = String(envValue)
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+ return parts.length > 0 ? parts : null;
+}
+
+async function fetchRouterExport(client) {
+ // Согласно официальной документации, /rest/export позволяет экспортировать конфиг.
+ // Параметр "compact" без значения эквивалентен /export compact.
+ const res = await client.command('export', { compact: '' });
+ const data = res && res.data;
+ if (!data) return '';
+
+ if (typeof data === 'string') return data;
+ if (Array.isArray(data)) {
+ // Некоторые версии могут вернуть массив строк/объектов
+ return data
+ .map((line) => {
+ if (typeof line === 'string') return line;
+ if (line && typeof line === 'object') {
+ // Пытаемся собрать строку из полей, если они есть
+ if (line.rsc) return String(line.rsc);
+ if (line.config) return String(line.config);
+ return JSON.stringify(line);
+ }
+ return String(line ?? '');
+ })
+ .join('\n');
+ }
+ if (typeof data === 'object') {
+ // Fallback: сериализуем объект
+ return JSON.stringify(data, null, 2);
+ }
+ return String(data);
+}
+
+async function runBackupOnce(logger) {
+ const log = logger || console;
+ try {
+ const allServers = await readServersFromS3();
+ const wantedIds = parseServerIds(process.env.MIKROTIK_BACKUP_SERVERS);
+
+ const jumphosts = (allServers || []).filter((s) => {
+ if (!s || String(s.type || '').toLowerCase() !== 'jumphost') return false;
+ if (!wantedIds) return true;
+ const id = s.id || s.dns || s.ip;
+ return id && wantedIds.includes(String(id));
+ });
+
+ if (jumphosts.length === 0) {
+ log.info({ component: 'mikrotik-backup' }, 'No jumphost servers found for backup');
+ return;
+ }
+
+ for (const server of jumphosts) {
+ const id = server.id || server.dns || server.ip || 'unknown';
+ try {
+ const host = server.mikrotikHost || server.ip || server.dns;
+ const port = server.mikrotikPort || 80;
+ const user = server.mikrotikUser || 'admin';
+
+ if (!host) {
+ log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no MikroTik host');
+ continue;
+ }
+
+ if (!server.encryptedMikrotikPassword) {
+ 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 || '',
+ 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);
+
+ if (!configText || !configText.trim()) {
+ log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: empty export result');
+ continue;
+ }
+
+ const result = await saveBackupForServer(id, configText, {
+ source: 'scheduler',
+ comment: 'Automatic MikroTik backup (REST /export)',
+ });
+
+ log.info(
+ { component: 'mikrotik-backup', serverId: id, key: result.key },
+ 'Automatic backup saved to S3',
+ );
+ } catch (err) {
+ log.error(
+ { component: 'mikrotik-backup', serverId: id, err: err && err.message },
+ 'Failed to create automatic backup',
+ );
+ }
+ }
+ } catch (err) {
+ const msg = err && err.message ? err.message : String(err);
+ (logger || console).error({ component: 'mikrotik-backup', err: msg }, 'Backup run failed');
+ }
+}
+
+function initMikrotikBackupScheduler(logger) {
+ const enabledEnv = String(process.env.MIKROTIK_BACKUP_ENABLED || 'true').toLowerCase();
+ const enabled = enabledEnv !== 'false' && enabledEnv !== '0' && enabledEnv !== 'off';
+ const log = logger || console;
+
+ if (!enabled) {
+ log.info({ component: 'mikrotik-backup' }, 'Automatic MikroTik backup scheduler is disabled');
+ return;
+ }
+
+ const intervalMin =
+ Number(process.env.MIKROTIK_BACKUP_INTERVAL_MINUTES || DEFAULT_INTERVAL_MIN) || DEFAULT_INTERVAL_MIN;
+ const intervalMs = Math.max(5, intervalMin) * 60 * 1000;
+
+ log.info(
+ {
+ component: 'mikrotik-backup',
+ intervalMinutes: intervalMs / 60000,
+ servers: process.env.MIKROTIK_BACKUP_SERVERS || 'all jumphost',
+ },
+ 'Starting automatic MikroTik backup scheduler',
+ );
+
+ // Первый запуск с небольшой задержкой, чтобы сервер успел подняться
+ setTimeout(() => {
+ runBackupOnce(log);
+ }, 30_000);
+
+ // Периодический запуск
+ setInterval(() => {
+ runBackupOnce(log);
+ }, intervalMs);
+}
+
+module.exports = {
+ initMikrotikBackupScheduler,
+ runBackupOnce,
+};
+
diff --git a/backend/services/s3Service.js b/backend/services/s3Service.js
index 2c6e87a..2e130f2 100644
--- a/backend/services/s3Service.js
+++ b/backend/services/s3Service.js
@@ -3,7 +3,7 @@
* Централизованные операции чтения/записи/кэширования
*/
-const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
+const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
const { NodeHttpHandler } = require('@smithy/node-http-handler');
const http = require('http');
const https = require('https');
@@ -167,6 +167,42 @@ async function deleteS3Object(key) {
invalidateCacheForKey(key);
}
+/**
+ * Список объектов в S3 по префиксу
+ * Используется для истории/бэкапов (количество ограничено для безопасности)
+ */
+async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
+ const out = [];
+ let continuationToken = undefined;
+
+ while (out.length < maxKeys) {
+ const resp = await s3.send(new ListObjectsV2Command({
+ Bucket: BUCKET_NAME,
+ Prefix: prefix,
+ ContinuationToken: continuationToken,
+ MaxKeys: Math.min(1000, maxKeys - out.length),
+ }));
+
+ const contents = resp.Contents || [];
+ for (const obj of contents) {
+ out.push({
+ key: obj.Key,
+ size: typeof obj.Size === 'number' ? obj.Size : null,
+ lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
+ etag: obj.ETag || null,
+ });
+ if (out.length >= maxKeys) break;
+ }
+
+ if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) {
+ break;
+ }
+ continuationToken = resp.NextContinuationToken;
+ }
+
+ return out;
+}
+
/**
* Потоковое чтение с пагинацией больших текстовых файлов
*/
@@ -257,5 +293,6 @@ module.exports = {
deleteS3Object,
streamPaginatedText,
invalidateCacheForKey,
+ listS3Objects,
};
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f6ec100..917e682 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -20,7 +20,8 @@ import {
IconDownload,
IconCreditCard,
IconMenu2,
- IconArrowsExchange
+ IconArrowsExchange,
+ IconDatabaseBackup
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
@@ -33,6 +34,7 @@ import BillingManager from './BillingManager';
import CommunitiesManager from './CommunitiesManager';
import NetworkConfigManager from './NetworkConfigManager';
import Dashboard from './Dashboard';
+import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
import './App.css';
import { NotifyProvider } from './components/NotifyProvider.jsx';
import SettingsModal from './components/SettingsModal.jsx';
@@ -52,14 +54,14 @@ function LanguageProvider({ children }) {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
- easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки',
+ easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы',
light: 'Светлая', dark: 'Тёмная'
},
en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
- easySwitch: 'Easy Switch', networkConfig: 'Network Config',
+ easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups',
light: 'Light', dark: 'Dark'
}
};
@@ -201,7 +203,8 @@ function MainLayout() {
title: t('tools'),
icon: IconSettings,
items: [
- { id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }
+ { id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
+ { id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabaseBackup }
]
},
// Убрали неиспользуемые/неработающие разделы
@@ -401,6 +404,7 @@ function MainLayout() {
MIKROTIK_BACKUP_ENABLED,
+ MIKROTIK_BACKUP_INTERVAL_MINUTES,
+ MIKROTIK_BACKUP_SERVERS.
+ | + | Создан | +Размер | +Ключ S3 | +Действия | +
|---|---|---|---|---|
| + handleSelectKey(b.key)} + /> + | +{formatDateTime(b.createdAt)} | +{bytesToHuman(b.size)} | ++ {shortKey} + | +
+
+
+
+
+ |
+
+ {viewConfigText || '# Нет данных для отображения'}
+
+ )}
+
+ {diffResult.configA || '# пусто'}
+
+
+ {diffResult.configB || '# пусто'}
+
+