Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m2s
461 lines
15 KiB
JavaScript
461 lines
15 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;
|
||
// Порт может прийти строкой из 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<string>} - текст конфигурации (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,
|
||
};
|