Files
router-lists-ui/backend/services/mikrotikApplyService.js
T
2026-02-10 01:48:07 +07:00

461 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Сервис применения конфигурации MikroTik через RouterOS REST API
* Идемпотентная логика: create / update / skip
* Требует RouterOS 7.1+ с www-ssl (HTTPS, порт 443) или www (HTTP, порт 80)
*/
const http = require('http');
const https = require('https');
/**
* Создать REST API клиент для MikroTik
* Поддерживает HTTP (порт 80) и HTTPS (порт 443)
* @param {object} opts - { host, port?, user, password, secure? }
* @returns {object} client с методами print, add, set, remove, command
*/
function createRosClient(opts) {
const { host, port = 80, user, password, secure = false } = opts;
// Порт может прийти строкой из JSON — приводим к числу
const portNumber = Number(port) || 80;
// REST API по умолчанию висит на HTTP:80, HTTPS обычно на 443/8443
const useHttp = portNumber === 80;
const protocol = useHttp ? http : https;
const makeRequest = (method, urlPath, body = null) => {
return new Promise((resolve, reject) => {
const auth = Buffer.from(`${user}:${password || ''}`).toString('base64');
const cleanPath = (urlPath.startsWith('/') ? urlPath.slice(1) : urlPath).replace(/\\/g, '/');
const pathStr = `/rest/${cleanPath}`.replace(/\/+/g, '/');
const bodyStr = (body !== null && body !== undefined) ? JSON.stringify(body) : '';
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Basic ${auth}`,
};
if (bodyStr) headers['Content-Length'] = Buffer.byteLength(bodyStr, 'utf8');
const options = {
hostname: host,
port: portNumber,
path: pathStr,
method,
headers,
rejectUnauthorized: secure,
};
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if ([200, 201, 204].includes(res.statusCode)) {
const parsed = data ? (() => { try { return JSON.parse(data); } catch { return data; } })() : null;
resolve({ data: parsed, code: res.statusCode });
} else {
let errMsg = res.statusMessage || 'Request failed';
try {
const errBody = JSON.parse(data);
errMsg = errBody.detail || errBody.message || errMsg;
} catch (_) {}
const err = new Error(errMsg);
err.response = { status: res.statusCode, data };
reject(err);
}
});
});
req.on('error', reject);
if (bodyStr) req.write(bodyStr);
req.end();
});
};
return {
print: (p) => makeRequest('GET', p),
add: (p, body) => makeRequest('PUT', p, body),
set: (p, body) => makeRequest('PATCH', p, body),
remove: (p) => makeRequest('DELETE', p),
command: (p, body) => makeRequest('POST', p, body),
};
}
/**
* Нормализовать path: убрать ведущий слэш
*/
function normalizePath(path) {
return (path || '').replace(/^\//, '');
}
/**
* Выполнить print с фильтром
* @param {object} client - ros-rest client
* @param {string} path - e.g. '/interface/gre' или 'interface/gre'
* @param {object} filter - e.g. { name: 'gre1' } или { '~comment': 'Recursive' }
*/
async function rosPrint(client, path, filter = {}) {
const pathClean = normalizePath(path);
const entries = Object.entries(filter).filter(([, v]) => v != null && v !== '');
let fullPath = pathClean;
if (entries.length > 0) {
const queryParts = entries.map(([k, v]) => {
const key = k.startsWith('~') ? `~${k.slice(1)}` : k;
return `${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`;
});
fullPath = `${pathClean}?${queryParts.join('&')}`;
}
try {
const res = await client.print(fullPath);
const data = res?.data;
return Array.isArray(data) ? data : (data ? [data] : []);
} catch (err) {
if (err?.response?.status === 404) return [];
throw err;
}
}
/**
* Выполнить print с .query (для сложных фильтров вроде ~comment)
*/
async function rosPrintWithQuery(client, path, queryList) {
const pathClean = normalizePath(path);
const fullPath = `${pathClean}/print`;
try {
const res = await client.command(fullPath, { '.query': queryList });
const data = res?.data;
return Array.isArray(data) ? data : (data ? [data] : []);
} catch (err) {
if (err?.response?.status === 404) return [];
throw err;
}
}
/**
* Выполнить add
*/
async function rosAdd(client, path, params) {
const pathClean = normalizePath(path);
const body = params && typeof params === 'object'
? Object.fromEntries(
Object.entries(params).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])
)
: {};
const res = await client.add(pathClean, body);
return res?.data;
}
/**
* Выполнить set
*/
async function rosSet(client, path, id, params) {
const pathClean = normalizePath(path);
const fullPath = id ? `${pathClean}/${id}` : pathClean;
const body = params && typeof params === 'object'
? Object.fromEntries(
Object.entries(params).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])
)
: {};
const res = await client.set(fullPath, body);
return res?.data;
}
/**
* Выполнить remove
*/
async function rosRemove(client, path, id) {
const pathClean = normalizePath(path);
const fullPath = id ? `${pathClean}/${id}` : pathClean;
await client.remove(fullPath);
}
/**
* Сравнить объект из RouterOS с желаемыми params (только ключевые поля)
*/
function paramsMatch(rosItem, params, keysToCompare) {
if (!rosItem || !params) return false;
for (const k of keysToCompare) {
const rosVal = rosItem[k];
const wantVal = params[k];
if (wantVal == null) continue;
if (String(rosVal || '').trim() !== String(wantVal || '').trim()) return false;
}
return true;
}
/**
* Применить одну операцию с idempotent логикой
*/
async function applyOperation(client, op, dryRun) {
const { path, action, params, meta } = op;
const result = { path, action, params: { ...params }, status: null, details: null, error: null };
if (path === '/interface/list' && action === 'add' && meta?.ensureExists) {
const existing = await rosPrint(client, '/interface/list', { name: params.name });
if (existing.length > 0) {
result.status = 'skip';
result.details = 'Interface list already exists';
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would create interface list ${params.name}`;
return result;
}
await rosAdd(client, '/interface/list', params);
result.status = 'created';
return result;
}
if (path === '/interface/gre' && action === 'add') {
const name = params.name;
const existing = await rosPrint(client, '/interface/gre', { name });
const compareKeys = ['remote-address', 'local-address', 'mtu', 'ipsec-secret', 'keepalive', 'allow-fast-path'];
if (existing.length > 0) {
const match = paramsMatch(existing[0], params, compareKeys);
if (match) {
result.status = 'skip';
result.details = `Interface ${name} already configured`;
return result;
}
if (dryRun) {
result.status = 'would_update';
result.details = `Would update interface ${name}`;
return result;
}
await rosSet(client, '/interface/gre', existing[0]['.id'], params);
result.status = 'updated';
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would create GRE interface ${name}`;
return result;
}
await rosAdd(client, '/interface/gre', params);
result.status = 'created';
return result;
}
if (path === '/interface/list/member' && action === 'add') {
const iface = params.interface;
const list = params.list;
const existing = await rosPrint(client, '/interface/list/member', { list, interface: iface });
if (existing.length > 0) {
result.status = 'skip';
result.details = `Member ${iface} already in list ${list}`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add ${iface} to list ${list}`;
return result;
}
await rosAdd(client, '/interface/list/member', params);
result.status = 'created';
return result;
}
if (path === '/ip/address' && action === 'add') {
const addr = params.address;
const iface = params.interface;
const existing = await rosPrint(client, '/ip/address', { interface: iface });
const match = existing.find(e => (e.address || '').startsWith(addr.split('/')[0]));
if (match) {
result.status = 'skip';
result.details = `Address ${addr} already on ${iface}`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add ${addr} to ${iface}`;
return result;
}
await rosAdd(client, '/ip/address', params);
result.status = 'created';
return result;
}
if (path === '/ip/route') {
if (action === 'remove' && meta?.findComment) {
// REST API может игнорировать regex в .query и возвращать все маршруты — фильтруем на нашей стороне
const raw = await rosPrintWithQuery(client, '/ip/route', [`~comment=${meta.findComment}`]);
const toRemove = raw.filter(r => (r.comment || '').startsWith('Recursive: '));
if (toRemove.length === 0) {
result.status = 'skip';
result.details = 'No matching routes to remove';
return result;
}
if (dryRun) {
result.status = 'would_remove';
result.details = `Would remove ${toRemove.length} route(s)`;
return result;
}
for (const r of toRemove) {
const id = r['.id'];
if (id) await rosRemove(client, '/ip/route', id);
}
result.status = 'removed';
result.details = `${toRemove.length} route(s) removed`;
return result;
}
if (action === 'add') {
const dst = params['dst-address'];
const gw = params.gateway;
const existing = await rosPrint(client, '/ip/route', { 'dst-address': dst });
const match = existing.find(e => (e.gateway || '').includes((gw || '').split('%')[0]));
if (match) {
result.status = 'skip';
result.details = `Route to ${dst} already exists`;
return result;
}
if (dryRun) {
result.status = 'would_create';
result.details = `Would add route ${dst} via ${gw}`;
return result;
}
await rosAdd(client, '/ip/route', params);
result.status = 'created';
return result;
}
}
result.status = 'skipped';
result.details = `Unsupported operation: ${path} ${action}`;
return result;
}
/**
* Применить блок операций к MikroTik
*/
async function applyBlock(client, block, dryRun) {
const results = [];
const ops = block.operations || [];
for (const op of ops) {
try {
const r = await applyOperation(client, op, dryRun);
results.push(r);
} catch (err) {
results.push({
path: op.path,
action: op.action,
params: op.params,
status: 'error',
error: err.message || String(err),
});
}
}
return results;
}
/**
* Получить полный экспорт конфигурации MikroTik через REST API.
* API не возвращает текст /export напрямую — только при export file=... сохраняется в файл.
* Мы экспортируем во временный файл, читаем его, удаляем.
*
* @param {object} client - ros-rest client (createRosClient)
* @returns {Promise<string>} - текст конфигурации (export compact)
*/
async function fetchExportViaFile(client) {
const crypto = require('crypto');
const basename = `backup_${Date.now()}_${crypto.randomBytes(4).toString('hex')}.rsc`;
// Явно указываем flash/ — экспорт сохраняется в flash
const filePath = `flash/${basename}`;
try {
await client.command('export', { compact: '', file: filePath });
} catch (err) {
// Попробуем без flash/ — старые версии RouterOS могут сохранять иначе
try {
await client.command('export', { compact: '', file: basename });
} catch (err2) {
const msg = err?.message || err2?.message || String(err);
const detail = err?.response?.data || err2?.response?.data;
console.error('[fetchExportViaFile] export failed:', msg, detail);
throw new Error(`Export failed: ${msg}${detail ? ` (${JSON.stringify(detail).slice(0, 200)})` : ''}`);
}
}
// POST file/print — получаем все файлы и ищем по имени (надёжнее GET с query)
let list = [];
try {
const printRes = await client.command('file/print', {
'.proplist': ['.id', 'name', 'contents'],
});
const data = printRes?.data;
const raw = Array.isArray(data) ? data : (data ? [data] : []);
const found = raw.find(
(f) =>
(f.name || '') === basename ||
(f.name || '') === filePath ||
(f.name || '').endsWith('/' + basename) ||
(f.name || '').endsWith(basename),
);
if (found) list = [found];
} catch (err) {
console.error('[fetchExportViaFile] file/print failed:', err?.message, err?.response?.data);
throw new Error(`file/print failed: ${err?.message || String(err)}`);
}
const file = list[0];
if (!file) {
throw new Error('Export file not found after export (check write permissions on router)');
}
let config = String(file.contents || '');
// Для файлов >60KB API не возвращает contents в print — читаем через file/read
if (!config && file.name) {
const chunkSize = 32768;
let offset = 0;
const chunks = [];
try {
for (;;) {
const readRes = await client.command('file/read', {
file: file.name,
offset: String(offset),
'chunk-size': String(chunkSize),
});
const rd = readRes?.data;
const chunk = Array.isArray(rd) ? (rd[0]?.data ?? rd[0]) : (rd?.data ?? rd);
const str = typeof chunk === 'string' ? chunk : (chunk ? String(chunk) : '');
if (!str) break;
chunks.push(str);
offset += str.length;
if (str.length < chunkSize) break;
}
config = chunks.join('');
} catch (err) {
console.error('[fetchExportViaFile] file/read failed:', err?.message);
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
throw new Error(`file/read failed: ${err?.message || String(err)}`);
}
}
if (!config) {
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
throw new Error('File/print and file/read returned no contents (router may restrict file access)');
}
try {
const id = file['.id'];
if (id) await client.remove(`file/${id}`);
} catch (_) {}
return config;
}
module.exports = {
createRosClient,
rosPrint,
rosPrintWithQuery,
rosAdd,
rosSet,
rosRemove,
applyOperation,
applyBlock,
fetchExportViaFile,
};