/** * Сервис применения конфигурации 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} - текст конфигурации (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, };