Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
243 lines
7.5 KiB
JavaScript
243 lines
7.5 KiB
JavaScript
/**
|
|
* Сервис применения конфигурации 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,
|
|
};
|