Files
router-lists-ui/backend/services/mikrotikApplyService.js
T
denozord 61c87e69ef
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m9s
fix(mikrotikConfigRoutes): enhance script execution logic to find script ID before running
2026-02-03 23:47:39 +07:00

354 lines
11 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;
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 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,
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;
}
module.exports = {
createRosClient,
rosPrint,
rosPrintWithQuery,
rosAdd,
rosSet,
rosRemove,
applyOperation,
applyBlock,
};