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
+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,
};