Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m55s
331 lines
11 KiB
JavaScript
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,
|
|
};
|
|
|