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
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m2s
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user