/** * Сервис применения конфигурации 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; const useHttp = port === 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 options = { hostname: host, port, path: pathStr, method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Basic ${auth}`, }, 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 (body !== null && body !== undefined) { req.write(JSON.stringify(body)); } 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; } module.exports = { createRosClient, rosPrint, rosPrintWithQuery, rosAdd, rosSet, rosRemove, applyOperation, applyBlock, };