feat(mikrotikBackupScheduler, MikrotikBackupsManager): enhance backup server management by integrating UI settings and adding server selection functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m58s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m58s
This commit is contained in:
@@ -15,6 +15,8 @@
|
|||||||
const { readServersFromS3 } = require('../routes/serversRoutes');
|
const { readServersFromS3 } = require('../routes/serversRoutes');
|
||||||
const { createRosClient } = require('./mikrotikApplyService');
|
const { createRosClient } = require('./mikrotikApplyService');
|
||||||
const { saveBackupForServer } = require('../routes/mikrotikBackupRoutes');
|
const { saveBackupForServer } = require('../routes/mikrotikBackupRoutes');
|
||||||
|
const { decrypt } = require('../utils/encryption');
|
||||||
|
const { readS3TextObject } = require('../services/s3Service');
|
||||||
|
|
||||||
const DEFAULT_INTERVAL_MIN = 60;
|
const DEFAULT_INTERVAL_MIN = 60;
|
||||||
|
|
||||||
@@ -27,6 +29,29 @@ function parseServerIds(envValue) {
|
|||||||
return parts.length > 0 ? parts : null;
|
return parts.length > 0 ? parts : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать список серверов для автобэкапов из UI-настроек (rt_ui_settings.json).
|
||||||
|
* Если поле mikrotikBackupServers отсутствует или пустое — вернуть null.
|
||||||
|
*/
|
||||||
|
async function getServerIdsFromUiSettings(logger) {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject('bgp_data/rt_ui_settings.json').catch(() => null);
|
||||||
|
if (!data || !data.body) return null;
|
||||||
|
const parsed = JSON.parse(data.body || '{}');
|
||||||
|
const list = parsed && Array.isArray(parsed.mikrotikBackupServers)
|
||||||
|
? parsed.mikrotikBackupServers
|
||||||
|
: null;
|
||||||
|
if (!list || list.length === 0) return null;
|
||||||
|
return list.map((v) => String(v)).filter(Boolean);
|
||||||
|
} catch (e) {
|
||||||
|
(logger || console).warn(
|
||||||
|
{ component: 'mikrotik-backup', err: e && e.message },
|
||||||
|
'Failed to read mikrotikBackupServers from ui-settings',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchRouterExport(client) {
|
async function fetchRouterExport(client) {
|
||||||
// Согласно официальной документации, /rest/export позволяет экспортировать конфиг.
|
// Согласно официальной документации, /rest/export позволяет экспортировать конфиг.
|
||||||
// Параметр "compact" без значения эквивалентен /export compact.
|
// Параметр "compact" без значения эквивалентен /export compact.
|
||||||
@@ -61,7 +86,9 @@ async function runBackupOnce(logger) {
|
|||||||
const log = logger || console;
|
const log = logger || console;
|
||||||
try {
|
try {
|
||||||
const allServers = await readServersFromS3();
|
const allServers = await readServersFromS3();
|
||||||
const wantedIds = parseServerIds(process.env.MIKROTIK_BACKUP_SERVERS);
|
const uiIds = await getServerIdsFromUiSettings(log);
|
||||||
|
const envIds = parseServerIds(process.env.MIKROTIK_BACKUP_SERVERS);
|
||||||
|
const wantedIds = (uiIds && uiIds.length > 0) ? uiIds : envIds;
|
||||||
|
|
||||||
const jumphosts = (allServers || []).filter((s) => {
|
const jumphosts = (allServers || []).filter((s) => {
|
||||||
if (!s || String(s.type || '').toLowerCase() !== 'jumphost') return false;
|
if (!s || String(s.type || '').toLowerCase() !== 'jumphost') return false;
|
||||||
@@ -87,32 +114,32 @@ async function runBackupOnce(logger) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!server.encryptedMikrotikPassword) {
|
let password = '';
|
||||||
log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no encryptedMikrotikPassword configured');
|
if (server.encryptedMikrotikPassword) {
|
||||||
|
try {
|
||||||
|
password = decrypt(server.encryptedMikrotikPassword);
|
||||||
|
} catch (e) {
|
||||||
|
log.error(
|
||||||
|
{ component: 'mikrotik-backup', serverId: id, err: e && e.message },
|
||||||
|
'Failed to decrypt MikroTik password, skipping backup',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn(
|
||||||
|
{ component: 'mikrotik-backup', serverId: id },
|
||||||
|
'Skip backup: no encryptedMikrotikPassword configured',
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пароль будет расшифрован на стороне RouterOS — мы используем тот же механизм,
|
|
||||||
// что и в apply/test-connection: encryptedMikrotikPassword должен быть уже расшифрован ранее.
|
|
||||||
// Здесь для простоты предполагаем, что пароль можно получить так же, как в apply.
|
|
||||||
// Чтобы не дублировать логику расшифровки, используем те же поля, что и в mikrotikConfigRoutes.getMikrotikCredentials.
|
|
||||||
const creds = {
|
const creds = {
|
||||||
host,
|
host,
|
||||||
port: Number(port) || 80,
|
port: Number(port) || 80,
|
||||||
user,
|
user,
|
||||||
// Пароль фактически должен быть расшифрован при сохранении/загрузке,
|
password,
|
||||||
// однако в текущей архитектуре encryptedMikrotikPassword расшифровывается в роуте.
|
|
||||||
// Чтобы не нарушать безопасность и не тянуть сюда ключ, требуем наличия plain-поля mikrotikPassword,
|
|
||||||
// если оно временно присутствует (например, для локального использования или через ENV/секреты).
|
|
||||||
password: server.mikrotikPassword || '',
|
|
||||||
secure: false,
|
secure: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!creds.password) {
|
|
||||||
log.warn({ component: 'mikrotik-backup', serverId: id }, 'Skip backup: no plain mikrotikPassword available');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = createRosClient(creds);
|
const client = createRosClient(creds);
|
||||||
log.info({ component: 'mikrotik-backup', serverId: id, host: creds.host }, 'Starting automatic backup via REST /export');
|
log.info({ component: 'mikrotik-backup', serverId: id, host: creds.host }, 'Starting automatic backup via REST /export');
|
||||||
const configText = await fetchRouterExport(client);
|
const configText = await fetchRouterExport(client);
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ function MikrotikBackupsManager() {
|
|||||||
|
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [selectedServer, setSelectedServer] = useState(null);
|
const [selectedServer, setSelectedServer] = useState(null);
|
||||||
|
const [uiSettings, setUiSettings] = useState({});
|
||||||
|
const [uiSettingsEtag, setUiSettingsEtag] = useState('');
|
||||||
|
const [backupServerIds, setBackupServerIds] = useState(new Set());
|
||||||
const [backups, setBackups] = useState([]);
|
const [backups, setBackups] = useState([]);
|
||||||
const [loadingServers, setLoadingServers] = useState(false);
|
const [loadingServers, setLoadingServers] = useState(false);
|
||||||
const [loadingBackups, setLoadingBackups] = useState(false);
|
const [loadingBackups, setLoadingBackups] = useState(false);
|
||||||
@@ -57,6 +60,7 @@ function MikrotikBackupsManager() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
|
fetchUiSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,6 +77,8 @@ function MikrotikBackupsManager() {
|
|||||||
[servers],
|
[servers],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const backupServerIdsList = useMemo(() => Array.from(backupServerIds), [backupServerIds]);
|
||||||
|
|
||||||
const fetchServers = async () => {
|
const fetchServers = async () => {
|
||||||
try {
|
try {
|
||||||
setLoadingServers(true);
|
setLoadingServers(true);
|
||||||
@@ -91,6 +97,21 @@ function MikrotikBackupsManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchUiSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/ui-settings');
|
||||||
|
const data = res?.data || {};
|
||||||
|
setUiSettings(data);
|
||||||
|
const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : [];
|
||||||
|
setBackupServerIds(new Set(list.map((v) => String(v))));
|
||||||
|
const e = res?.headers?.etag || res?.headers?.ETag || '';
|
||||||
|
setUiSettingsEtag(e ? String(e) : '');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching UI settings for backups:', err);
|
||||||
|
// мягко игнорируем, просто будут дефолты
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchBackups = async (serverId) => {
|
const fetchBackups = async (serverId) => {
|
||||||
if (!serverId) return;
|
if (!serverId) return;
|
||||||
try {
|
try {
|
||||||
@@ -196,6 +217,36 @@ function MikrotikBackupsManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleBackupServer = (serverId) => {
|
||||||
|
setBackupServerIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(serverId)) next.delete(serverId);
|
||||||
|
else next.add(serverId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBackupServers = async () => {
|
||||||
|
try {
|
||||||
|
const settingsPayload = {
|
||||||
|
...uiSettings,
|
||||||
|
mikrotikBackupServers: backupServerIdsList,
|
||||||
|
};
|
||||||
|
const payload = {
|
||||||
|
settings: settingsPayload,
|
||||||
|
etag: uiSettingsEtag,
|
||||||
|
};
|
||||||
|
const res = await api.post('/ui-settings', payload);
|
||||||
|
const meta = res?.data || {};
|
||||||
|
setUiSettings(settingsPayload);
|
||||||
|
setUiSettingsEtag(String(meta?.etag || ''));
|
||||||
|
notify.success('Список серверов для автобэкапа сохранён');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving backup servers to ui-settings:', err);
|
||||||
|
notify.error(err?.response?.data?.message || 'Не удалось сохранить список серверов для автобэкапа');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const actions = (
|
const actions = (
|
||||||
<div className="d-flex flex-wrap gap-2 align-items-center">
|
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||||
<div style={{ minWidth: 260 }}>
|
<div style={{ minWidth: 260 }}>
|
||||||
@@ -229,10 +280,10 @@ function MikrotikBackupsManager() {
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className="text-muted small ms-2">
|
<div className="text-muted small ms-2">
|
||||||
Автобэкапы настраиваются через переменные окружения:
|
Автобэкапы включаются для выбранных ниже Jumphost-серверов. Интервал и включение/выключение
|
||||||
<code className="ms-1">MIKROTIK_BACKUP_ENABLED</code>,
|
планировщика настраиваются через переменные окружения
|
||||||
<code className="ms-1">MIKROTIK_BACKUP_INTERVAL_MINUTES</code>,
|
<code className="ms-1">MIKROTIK_BACKUP_ENABLED</code> и
|
||||||
<code className="ms-1">MIKROTIK_BACKUP_SERVERS</code>.
|
<code className="ms-1">MIKROTIK_BACKUP_INTERVAL_MINUTES</code>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -249,6 +300,75 @@ function MikrotikBackupsManager() {
|
|||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Блок выбора серверов для автобэкапа */}
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div className="card-title mb-0">Сервера для автоматических бэкапов</div>
|
||||||
|
<div className="text-muted small">
|
||||||
|
Отмеченные здесь Jumphost-серверы будут периодически бэкапиться планировщиком на backend.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm d-inline-flex align-items-center"
|
||||||
|
onClick={saveBackupServers}
|
||||||
|
disabled={jumphostServers.length === 0}
|
||||||
|
>
|
||||||
|
Сохранить список
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{jumphostServers.length === 0 ? (
|
||||||
|
<div className="text-muted small">
|
||||||
|
Нет серверов типа <code>jumphost</code>. Добавьте их в разделе «Серверы».
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 40 }}></th>
|
||||||
|
<th>Сервер</th>
|
||||||
|
<th>IP / DNS</th>
|
||||||
|
<th>Страна</th>
|
||||||
|
<th>Провайдер</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jumphostServers.map((s) => {
|
||||||
|
const id = s.id || s.dns || s.ip;
|
||||||
|
const checked = id && backupServerIds.has(String(id));
|
||||||
|
return (
|
||||||
|
<tr key={id || `srv-${s.ip}-${s.dns}`}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={!!checked}
|
||||||
|
onChange={() => id && toggleBackupServer(String(id))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{s.dns || s.ip || id}</td>
|
||||||
|
<td>
|
||||||
|
<div className="small text-muted">
|
||||||
|
{s.ip}
|
||||||
|
{s.dns && s.ip ? ' · ' : ''}
|
||||||
|
{s.dns}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="small">{s.country || '-'}</td>
|
||||||
|
<td className="small">{s.provider || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadingServers ? (
|
{loadingServers ? (
|
||||||
<TableSkeleton rows={5} columns={4} />
|
<TableSkeleton rows={5} columns={4} />
|
||||||
) : !selectedServer ? (
|
) : !selectedServer ? (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [etag, setEtag] = useState('');
|
const [etag, setEtag] = useState('');
|
||||||
|
const [rawSettings, setRawSettings] = useState({});
|
||||||
const [dohServer, setDohServer] = useState('');
|
const [dohServer, setDohServer] = useState('');
|
||||||
const [wsUrl, setWsUrl] = useState('');
|
const [wsUrl, setWsUrl] = useState('');
|
||||||
const [baseAS, setBaseAS] = useState('65001');
|
const [baseAS, setBaseAS] = useState('65001');
|
||||||
@@ -30,6 +31,7 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get('/ui-settings');
|
const res = await api.get('/ui-settings');
|
||||||
const data = res?.data || {};
|
const data = res?.data || {};
|
||||||
|
setRawSettings(data);
|
||||||
setDohServer(String(data?.dohServer || ''));
|
setDohServer(String(data?.dohServer || ''));
|
||||||
setWsUrl(String(data?.wsUpdateUrl || ''));
|
setWsUrl(String(data?.wsUpdateUrl || ''));
|
||||||
setBaseAS(String(data?.baseAS || '65001'));
|
setBaseAS(String(data?.baseAS || '65001'));
|
||||||
@@ -90,20 +92,23 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const mergedSettings = {
|
||||||
|
...rawSettings,
|
||||||
|
dohServer: String(dohServer || '').trim(),
|
||||||
|
wsUpdateUrl: String(wsUrl || '').trim(),
|
||||||
|
baseAS: String(baseAS || '65001').trim(),
|
||||||
|
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
|
||||||
|
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(),
|
||||||
|
};
|
||||||
const payload = {
|
const payload = {
|
||||||
settings: {
|
settings: mergedSettings,
|
||||||
dohServer: String(dohServer || '').trim(),
|
|
||||||
wsUpdateUrl: String(wsUrl || '').trim(),
|
|
||||||
baseAS: String(baseAS || '65001').trim(),
|
|
||||||
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
|
|
||||||
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim()
|
|
||||||
},
|
|
||||||
etag
|
etag
|
||||||
};
|
};
|
||||||
const res = await api.post('/ui-settings', payload);
|
const res = await api.post('/ui-settings', payload);
|
||||||
const meta = res?.data || {};
|
const meta = res?.data || {};
|
||||||
setSuccess('Настройки успешно сохранены');
|
setSuccess('Настройки успешно сохранены');
|
||||||
setEtag(String(meta?.etag || ''));
|
setEtag(String(meta?.etag || ''));
|
||||||
|
setRawSettings(mergedSettings);
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e?.response?.data?.message || 'Ошибка при сохранении настроек');
|
setError(e?.response?.data?.message || 'Ошибка при сохранении настроек');
|
||||||
|
|||||||
Reference in New Issue
Block a user