Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
352 lines
11 KiB
JavaScript
352 lines
11 KiB
JavaScript
/**
|
||
* Сервис применения конфигурации 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) {
|
||
const existing = await rosPrintWithQuery(client, '/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) {
|
||
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,
|
||
};
|