Files
router-lists-ui/backend/routes/mikrotikBackupRoutes.js

331 lines
11 KiB
JavaScript

/**
* Роуты для бэкапов конфигурации 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,
};