feat(MikrotikConfig): add API endpoint for applying MikroTik configuration; update frontend to support configuration application with dry-run option and modal interface
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s

This commit is contained in:
2026-02-03 19:26:49 +07:00
parent 1a27217676
commit 94314c383b
5 changed files with 523 additions and 12 deletions
+98
View File
@@ -14,6 +14,7 @@ const {
} = require('../utils/mikrotikInterfaceGenerator');
const { readS3TextObject } = require('../services/s3Service');
const { RouterOSAPI } = require('node-routeros');
const { applyBlock } = require('../services/mikrotikApplyService');
async function fetchJsonFromS3(key, defaultValue = null) {
try {
@@ -227,9 +228,106 @@ async function testMikrotikConnection(req, res) {
}
}
/**
* POST /api/mikrotik/apply
* Body: { serverId: string, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }
* Применяет конфигурацию (интерфейсы, маршруты) на MikroTik через RouterOS API.
* dryRun=true — только показать план, не выполнять.
*/
async function applyMikrotikConfig(req, res) {
try {
const { serverId, type = 'all', dryRun = true } = req.body || {};
if (!serverId) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
}
const servers = await fetchJsonFromS3('servers.json', []);
const server = servers.find(s => s.id === serverId || s.dns === serverId || s.ip === serverId);
if (!server) {
return sendError(res, 404, 'Server not found', 'E_NOT_FOUND');
}
if (server.type !== 'jumphost') {
return sendError(res, 400, 'Only jumphost servers support apply via API', 'E_BAD_REQUEST');
}
if (!server.encryptedMikrotikPassword) {
return sendError(res, 400, 'MikroTik password not configured. Add credentials in Server settings.', 'E_BAD_REQUEST');
}
let password;
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (decErr) {
return sendError(res, 500, 'Failed to decrypt MikroTik password', 'E_DECRYPT');
}
const host = server.mikrotikHost || server.ip || server.dns;
const port = parseInt(server.mikrotikPort || '8728', 10) || 8728;
const user = server.mikrotikUser || 'admin';
const config = await fetchJsonFromS3('network-config.json', { gateways: [], tunnelInterfaces: [] });
const passwordIds = (config.tunnelInterfaces || [])
.filter(i => i.ipsecPasswordId && String(i.ipsecPasswordId).trim() !== '')
.map(i => i.ipsecPasswordId.trim());
const passwordMap = await fetchIpsecPasswordMap(passwordIds);
const includeInterfaces = type === 'interfaces' || type === 'all';
const includeRecursive = type === 'recursive' || type === 'all';
const blocks = await buildMikrotikConfig(config, servers, passwordMap, {
format: 'json',
serverId,
includeInterfaces,
includeRecursive,
});
const conn = new RouterOSAPI({
host: String(host),
user: String(user),
password: String(password),
port: Number(port) || 8728,
});
await conn.connect();
const allResults = [];
for (const block of blocks) {
const blockResults = await applyBlock(conn, block, !!dryRun);
allResults.push({
blockType: block.type,
serverName: block.serverName,
results: blockResults,
});
}
conn.close();
const summary = {
created: allResults.flatMap(b => b.results).filter(r => r.status === 'created' || r.status === 'would_create').length,
updated: allResults.flatMap(b => b.results).filter(r => r.status === 'updated' || r.status === 'would_update').length,
skipped: allResults.flatMap(b => b.results).filter(r => r.status === 'skip').length,
errors: allResults.flatMap(b => b.results).filter(r => r.status === 'error'),
};
res.json({
ok: true,
dryRun: !!dryRun,
summary,
results: allResults,
});
} catch (error) {
console.error('Error applying MikroTik config:', error);
const msg = error.message || String(error);
return res.status(500).json({
ok: false,
error: msg.includes('ECONNREFUSED') ? 'Соединение отклонено' :
msg.includes('Authentication') || msg.includes('login') ? 'Неверный логин или пароль' : msg,
});
}
}
module.exports = {
generateMikrotikConfig,
generateInterfaces,
generateRecursiveRoutes,
testMikrotikConnection,
applyMikrotikConfig,
};
+1
View File
@@ -450,6 +450,7 @@ app.post('/api/mikrotik/generate', mikrotikConfigRoutes.generateMikrotikConfig);
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection);
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
// === MIKROTIK VALIDATION ===
app.post('/api/mikrotik/validate', async (req, res) => {
+242
View File
@@ -0,0 +1,242 @@
/**
* Сервис применения конфигурации 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,
};