/** * Роуты для бэкапов конфигурации MikroTik * Хранят сырый текст конфигурации (например, вывод /export) в S3. * * Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc * * Задачи: * - создать бэкап (ручной или из планировщика) * - получить список бэкапов для сервера * - прочитать конкретный бэкап * - (опционально) отдать две версии для сравнения и отката */ const crypto = require('crypto'); const { sendError, sendOk } = require('../middleware/errorHandler'); const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service'); const { readServersFromS3 } = require('./serversRoutes'); const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService'); const { encrypt, decrypt } = require('../utils/encryption'); const BACKUP_PREFIX = 'backups/mikrotik'; const ENCRYPTED_PREFIX = 'ENC:'; /** Расшифровать содержимое бэкапа (поддерживает старые незашифрованные) */ function decryptBackupContent(raw) { if (!raw || typeof raw !== 'string') return ''; if (raw.startsWith(ENCRYPTED_PREFIX)) { try { return decrypt(raw.slice(ENCRYPTED_PREFIX.length)); } catch (e) { console.error('Backup decryption failed:', e?.message); throw new Error(`Failed to decrypt backup: ${e?.message || 'decryption error'}`); } } return raw; } 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); let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`; // Шифруем содержимое на S3, если задан ENCRYPTION_KEY if (process.env.ENCRYPTION_KEY) { try { content = ENCRYPTED_PREFIX + encrypt(content); } catch (e) { console.error('Backup encryption failed:', e?.message); throw new Error(`Failed to encrypt backup: ${e?.message || 'encryption error'}`); } } 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); const config = decryptBackupContent(data.body || ''); return res.json({ ok: true, key, config, 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 = decryptBackupContent(a.body || ''); const bodyB = decryptBackupContent(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'); } } /** * Вспомогательная функция: получить MikroTik REST-клиент по serverId */ async function getMikrotikClientForServerId(serverId) { const servers = await readServersFromS3(); const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId); if (!server || server.type !== 'jumphost') { const err = new Error('Jumphost server not found'); err.code = 'E_NOT_FOUND'; throw err; } const host = server.mikrotikHost || server.ip || server.dns; if (!host) { const err = new Error('MikroTik host is not configured for this server'); err.code = 'E_HOST'; throw err; } let password = ''; if (server.encryptedMikrotikPassword) { try { password = decrypt(server.encryptedMikrotikPassword); } catch (e) { const err = new Error('Failed to decrypt MikroTik password'); err.code = 'E_CREDENTIALS'; throw err; } } else { const err = new Error('MikroTik credentials not configured for this server'); err.code = 'E_CREDENTIALS'; throw err; } const port = server.mikrotikPort; const restPort = port === 80 || port === 443 || port === 8443 ? port : 80; const user = server.mikrotikUser || 'admin'; const creds = { host, port: restPort, user, password, secure: false }; return createRosClient(creds); } /** * POST /api/mikrotik/backups/run * Body: { serverId: string } * * Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3. */ async function runBackupNow(req, res) { try { const { serverId } = req.body || {}; if (!serverId || typeof serverId !== 'string') { return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); } let client; try { client = await getMikrotikClientForServerId(serverId); } catch (e) { if (e.code === 'E_NOT_FOUND') { return sendError(res, 400, e.message, e.code); } if (e.code === 'E_HOST' || e.code === 'E_CREDENTIALS') { return sendError(res, 400, e.message, e.code); } throw e; } // API не возвращает текст /export напрямую — экспортируем в файл, читаем, удаляем const config = await fetchExportViaFile(client); if (!config || !config.trim()) { return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT'); } const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source: 'manual', comment: 'Manual backup from UI (/api/mikrotik/backups/run)', }); return res.json({ ok: true, key, serverId, createdAt, s3, }); } catch (error) { console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data); let msg = error?.message || 'Error running backup now'; const detail = error?.response?.data; if (detail && typeof detail === 'object') { const d = detail.detail || detail.message; if (d) msg += ` (${d})`; } else if (typeof detail === 'string') { msg += ` (${detail})`; } return sendError(res, 500, msg, 'E_BACKUP_RUN', process.env.NODE_ENV !== 'production' ? { stack: error?.stack } : undefined); } } module.exports = { createBackup, listBackups, getBackup, diffBackups, saveBackupForServer, runBackupNow, };