feat(mikrotikBackup): add MikroTik backup functionality with S3 integration and implement backup scheduler
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m2s

This commit is contained in:
2026-02-09 15:13:56 +07:00
parent 3d9394e27d
commit 275f2f6929
6 changed files with 883 additions and 5 deletions
+204
View File
@@ -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,
};
+15
View File
@@ -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');
}
+185
View File
@@ -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,
};
+38 -1
View File
@@ -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,
};