/** * Сервис применения конфигурации MikroTik через RouterOS API * Идемпотентная логика: create / update / skip */ const { RouterOSAPI } = require('node-routeros'); /** * Преобразование params в массив для node-routeros: ['=key=value', ...] */ function paramsToRosArray(params) { if (!params || typeof params !== 'object') return []; return Object.entries(params) .filter(([, v]) => v != null && v !== '') .map(([k, v]) => `=${k}=${String(v)}`); } /** * Выполнить print с фильтром * @param {object} conn - RouterOSAPI instance * @param {string} path - e.g. '/interface/gre' * @param {object} filter - e.g. { name: 'gre1' } */ async function rosPrint(conn, path, filter = {}) { const pathClean = path.replace(/^\//, '').replace(/\//g, '/'); const fullPath = `/${pathClean}/print`; const args = Object.entries(filter) .filter(([, v]) => v != null && v !== '') .map(([k, v]) => { const key = k.startsWith('~') ? k.slice(1) : k; const prefix = k.startsWith('~') ? '?~' : '?'; return `${prefix}${key}=${String(v)}`; }); const result = args.length > 0 ? await conn.write(fullPath, args) : await conn.write(fullPath); return Array.isArray(result) ? result : []; } /** * Выполнить add */ async function rosAdd(conn, path, params) { const pathClean = path.replace(/^\//, '').replace(/\//g, '/'); const fullPath = `/${pathClean}/add`; const args = paramsToRosArray(params); return conn.write(fullPath, args); } /** * Выполнить set */ async function rosSet(conn, path, id, params) { const pathClean = path.replace(/^\//, '').replace(/\//g, '/'); const fullPath = `/${pathClean}/set`; const args = [`.id=${id}`, ...paramsToRosArray(params)]; return conn.write(fullPath, args); } /** * Сравнить объект из 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(conn, 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(conn, '/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(conn, '/interface/list', params); result.status = 'created'; return result; } if (path === '/interface/gre' && action === 'add') { const name = params.name; const existing = await rosPrint(conn, '/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(conn, '/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(conn, '/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(conn, '/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(conn, '/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(conn, '/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(conn, '/ip/address', params); result.status = 'created'; return result; } if (path === '/ip/route') { if (action === 'remove' && meta?.findComment) { const existing = await rosPrint(conn, '/ip/route', { '~comment': meta.findComment }); const toRemove = existing; 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) { await conn.write('/ip/route/remove', [`.id=${r['.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(conn, '/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(conn, '/ip/route', params); result.status = 'created'; return result; } } result.status = 'skipped'; result.details = `Unsupported operation: ${path} ${action}`; return result; } /** * Применить блок операций к MikroTik */ async function applyBlock(conn, block, dryRun) { const results = []; const ops = block.operations || []; for (const op of ops) { try { const r = await applyOperation(conn, 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; } module.exports = { paramsToRosArray, rosPrint, rosAdd, rosSet, applyOperation, applyBlock, };