refactor(mikrotikApplyService): transition from node-routeros to REST API client for MikroTik configuration; enhance request handling and error management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
This commit is contained in:
@@ -1,58 +1,164 @@
|
||||
/**
|
||||
* Сервис применения конфигурации MikroTik через RouterOS API
|
||||
* Сервис применения конфигурации MikroTik через RouterOS REST API
|
||||
* Идемпотентная логика: create / update / skip
|
||||
* Требует RouterOS 7.1+ с www-ssl (HTTPS, порт 443) или www (HTTP, порт 80)
|
||||
*/
|
||||
|
||||
const { RouterOSAPI } = require('node-routeros');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
/**
|
||||
* Преобразование params в массив для node-routeros: ['=key=value', ...]
|
||||
* Создать REST API клиент для MikroTik
|
||||
* Поддерживает HTTP (порт 80) и HTTPS (порт 443)
|
||||
* @param {object} opts - { host, port?, user, password, secure? }
|
||||
* @returns {object} client с методами print, add, set, remove, command
|
||||
*/
|
||||
function paramsToRosArray(params) {
|
||||
if (!params || typeof params !== 'object') return [];
|
||||
return Object.entries(params)
|
||||
.filter(([, v]) => v != null && v !== '')
|
||||
.map(([k, v]) => `=${k}=${String(v)}`);
|
||||
function createRosClient(opts) {
|
||||
const { host, port = 443, 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} conn - RouterOSAPI instance
|
||||
* @param {string} path - e.g. '/interface/gre'
|
||||
* @param {object} filter - e.g. { name: 'gre1' }
|
||||
* @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(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)}`;
|
||||
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))}`;
|
||||
});
|
||||
const result = args.length > 0 ? await conn.write(fullPath, args) : await conn.write(fullPath);
|
||||
return Array.isArray(result) ? result : [];
|
||||
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(conn, path, params) {
|
||||
const pathClean = path.replace(/^\//, '').replace(/\//g, '/');
|
||||
const fullPath = `/${pathClean}/add`;
|
||||
const args = paramsToRosArray(params);
|
||||
return conn.write(fullPath, args);
|
||||
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(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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,12 +178,12 @@ function paramsMatch(rosItem, params, keysToCompare) {
|
||||
/**
|
||||
* Применить одну операцию с idempotent логикой
|
||||
*/
|
||||
async function applyOperation(conn, op, dryRun) {
|
||||
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(conn, '/interface/list', { name: params.name });
|
||||
const existing = await rosPrint(client, '/interface/list', { name: params.name });
|
||||
if (existing.length > 0) {
|
||||
result.status = 'skip';
|
||||
result.details = 'Interface list already exists';
|
||||
@@ -88,14 +194,14 @@ async function applyOperation(conn, op, dryRun) {
|
||||
result.details = `Would create interface list ${params.name}`;
|
||||
return result;
|
||||
}
|
||||
await rosAdd(conn, '/interface/list', params);
|
||||
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(conn, '/interface/gre', { 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);
|
||||
@@ -109,7 +215,7 @@ async function applyOperation(conn, op, dryRun) {
|
||||
result.details = `Would update interface ${name}`;
|
||||
return result;
|
||||
}
|
||||
await rosSet(conn, '/interface/gre', existing[0]['.id'], params);
|
||||
await rosSet(client, '/interface/gre', existing[0]['.id'], params);
|
||||
result.status = 'updated';
|
||||
return result;
|
||||
}
|
||||
@@ -118,7 +224,7 @@ async function applyOperation(conn, op, dryRun) {
|
||||
result.details = `Would create GRE interface ${name}`;
|
||||
return result;
|
||||
}
|
||||
await rosAdd(conn, '/interface/gre', params);
|
||||
await rosAdd(client, '/interface/gre', params);
|
||||
result.status = 'created';
|
||||
return result;
|
||||
}
|
||||
@@ -126,7 +232,7 @@ async function applyOperation(conn, op, dryRun) {
|
||||
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 });
|
||||
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}`;
|
||||
@@ -137,7 +243,7 @@ async function applyOperation(conn, op, dryRun) {
|
||||
result.details = `Would add ${iface} to list ${list}`;
|
||||
return result;
|
||||
}
|
||||
await rosAdd(conn, '/interface/list/member', params);
|
||||
await rosAdd(client, '/interface/list/member', params);
|
||||
result.status = 'created';
|
||||
return result;
|
||||
}
|
||||
@@ -145,7 +251,7 @@ async function applyOperation(conn, op, dryRun) {
|
||||
if (path === '/ip/address' && action === 'add') {
|
||||
const addr = params.address;
|
||||
const iface = params.interface;
|
||||
const existing = await rosPrint(conn, '/ip/address', { interface: iface });
|
||||
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';
|
||||
@@ -157,14 +263,14 @@ async function applyOperation(conn, op, dryRun) {
|
||||
result.details = `Would add ${addr} to ${iface}`;
|
||||
return result;
|
||||
}
|
||||
await rosAdd(conn, '/ip/address', params);
|
||||
await rosAdd(client, '/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 existing = await rosPrintWithQuery(client, '/ip/route', [`~comment=${meta.findComment}`]);
|
||||
const toRemove = existing;
|
||||
if (toRemove.length === 0) {
|
||||
result.status = 'skip';
|
||||
@@ -177,7 +283,8 @@ async function applyOperation(conn, op, dryRun) {
|
||||
return result;
|
||||
}
|
||||
for (const r of toRemove) {
|
||||
await conn.write('/ip/route/remove', [`.id=${r['.id']}`]);
|
||||
const id = r['.id'];
|
||||
if (id) await rosRemove(client, '/ip/route', id);
|
||||
}
|
||||
result.status = 'removed';
|
||||
result.details = `${toRemove.length} route(s) removed`;
|
||||
@@ -186,7 +293,7 @@ async function applyOperation(conn, op, dryRun) {
|
||||
if (action === 'add') {
|
||||
const dst = params['dst-address'];
|
||||
const gw = params.gateway;
|
||||
const existing = await rosPrint(conn, '/ip/route', { 'dst-address': dst });
|
||||
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';
|
||||
@@ -198,7 +305,7 @@ async function applyOperation(conn, op, dryRun) {
|
||||
result.details = `Would add route ${dst} via ${gw}`;
|
||||
return result;
|
||||
}
|
||||
await rosAdd(conn, '/ip/route', params);
|
||||
await rosAdd(client, '/ip/route', params);
|
||||
result.status = 'created';
|
||||
return result;
|
||||
}
|
||||
@@ -212,12 +319,12 @@ async function applyOperation(conn, op, dryRun) {
|
||||
/**
|
||||
* Применить блок операций к MikroTik
|
||||
*/
|
||||
async function applyBlock(conn, block, dryRun) {
|
||||
async function applyBlock(client, block, dryRun) {
|
||||
const results = [];
|
||||
const ops = block.operations || [];
|
||||
for (const op of ops) {
|
||||
try {
|
||||
const r = await applyOperation(conn, op, dryRun);
|
||||
const r = await applyOperation(client, op, dryRun);
|
||||
results.push(r);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
@@ -233,10 +340,12 @@ async function applyBlock(conn, block, dryRun) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
paramsToRosArray,
|
||||
createRosClient,
|
||||
rosPrint,
|
||||
rosPrintWithQuery,
|
||||
rosAdd,
|
||||
rosSet,
|
||||
rosRemove,
|
||||
applyOperation,
|
||||
applyBlock,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user