feat(MikrotikConfig, NetworkConfigManager): implement streaming logs for MikroTik configuration application and enhance error handling in response processing
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m2s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m2s
This commit is contained in:
@@ -13,6 +13,9 @@ const {
|
|||||||
buildMikrotikConfig,
|
buildMikrotikConfig,
|
||||||
} = require('../utils/mikrotikInterfaceGenerator');
|
} = require('../utils/mikrotikInterfaceGenerator');
|
||||||
const { readS3TextObject } = require('../services/s3Service');
|
const { readS3TextObject } = require('../services/s3Service');
|
||||||
|
if (!process.env.DEBUG?.includes('routeros-api')) {
|
||||||
|
process.env.DEBUG = (process.env.DEBUG || '') + (process.env.DEBUG ? ',' : '') + 'routeros-api:*';
|
||||||
|
}
|
||||||
const { RouterOSAPI } = require('node-routeros');
|
const { RouterOSAPI } = require('node-routeros');
|
||||||
const { applyBlock } = require('../services/mikrotikApplyService');
|
const { applyBlock } = require('../services/mikrotikApplyService');
|
||||||
|
|
||||||
@@ -240,58 +243,104 @@ async function applyMikrotikConfig(req, res) {
|
|||||||
let conn = null;
|
let conn = null;
|
||||||
const mikrotikRequests = [];
|
const mikrotikRequests = [];
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
console.log('[MikroTik apply] Request body:', { serverId: body.serverId, type: body.type, dryRun: body.dryRun });
|
const streamLogs = req.headers['x-stream-logs'] === '1' || req.headers['x-stream-logs'] === 'true';
|
||||||
|
|
||||||
|
const log = (msg, data = {}) => {
|
||||||
|
console.log('[MikroTik apply]', msg, data);
|
||||||
|
if (streamLogs && res.write) {
|
||||||
|
try {
|
||||||
|
res.write(JSON.stringify({ t: 'log', msg, ...data }) + '\n');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sendSafe = (status, data) => {
|
const sendSafe = (status, data) => {
|
||||||
if (res.headersSent) return;
|
|
||||||
try {
|
try {
|
||||||
res.status(status).json(data);
|
if (streamLogs && res.write) {
|
||||||
|
res.write(JSON.stringify({ t: 'result', ok: status < 400, ...data }) + '\n');
|
||||||
|
res.end();
|
||||||
|
} else if (!res.headersSent) {
|
||||||
|
res.status(status).json(data);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[MikroTik apply] sendSafe failed:', e);
|
console.error('[MikroTik apply] sendSafe failed:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendErrorSafe = (status, data) => {
|
||||||
|
try {
|
||||||
|
if (streamLogs && res.write) {
|
||||||
|
res.write(JSON.stringify({ t: 'error', ok: false, status, ...data }) + '\n');
|
||||||
|
res.end();
|
||||||
|
} else if (!res.headersSent) {
|
||||||
|
res.status(status).json(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[MikroTik apply] sendErrorSafe failed:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (streamLogs) {
|
||||||
|
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||||
|
res.setHeader('Transfer-Encoding', 'chunked');
|
||||||
|
res.status(200);
|
||||||
|
}
|
||||||
|
log('Request body', { serverId: body.serverId, type: body.type, dryRun: body.dryRun });
|
||||||
const { serverId, type = 'all', dryRun = true } = body;
|
const { serverId, type = 'all', dryRun = true } = body;
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
sendErrorSafe(400, { error: 'serverId is required' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log('Fetching servers from S3');
|
||||||
const servers = await fetchJsonFromS3('servers.json', []);
|
const servers = await fetchJsonFromS3('servers.json', []);
|
||||||
const server = servers.find(s => s.id === serverId || s.dns === serverId || s.ip === serverId);
|
const server = servers.find(s => s.id === serverId || s.dns === serverId || s.ip === serverId);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return sendError(res, 404, 'Server not found', 'E_NOT_FOUND');
|
sendErrorSafe(404, { error: 'Server not found' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (server.type !== 'jumphost') {
|
if (server.type !== 'jumphost') {
|
||||||
return sendError(res, 400, 'Only jumphost servers support apply via API', 'E_BAD_REQUEST');
|
sendErrorSafe(400, { error: 'Only jumphost servers support apply via API' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!server.encryptedMikrotikPassword) {
|
if (!server.encryptedMikrotikPassword) {
|
||||||
return sendError(res, 400, 'MikroTik password not configured. Add credentials in Server settings.', 'E_BAD_REQUEST');
|
sendErrorSafe(400, { error: 'MikroTik password not configured. Add credentials in Server settings.' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log('Decrypting password');
|
||||||
let password;
|
let password;
|
||||||
try {
|
try {
|
||||||
password = decrypt(server.encryptedMikrotikPassword);
|
password = decrypt(server.encryptedMikrotikPassword);
|
||||||
} catch (decErr) {
|
} catch (decErr) {
|
||||||
return sendError(res, 500, 'Failed to decrypt MikroTik password', 'E_DECRYPT');
|
sendErrorSafe(500, { error: 'Failed to decrypt MikroTik password' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = server.mikrotikHost || server.ip || server.dns;
|
const host = server.mikrotikHost || server.ip || server.dns;
|
||||||
const port = parseInt(server.mikrotikPort || '8728', 10) || 8728;
|
const port = parseInt(server.mikrotikPort || '8728', 10) || 8728;
|
||||||
const user = server.mikrotikUser || 'admin';
|
const user = server.mikrotikUser || 'admin';
|
||||||
|
log('Target MikroTik', { host, port, user });
|
||||||
|
|
||||||
|
log('Fetching network config from S3');
|
||||||
const config = await fetchJsonFromS3('network-config.json', { gateways: [], tunnelInterfaces: [] });
|
const config = await fetchJsonFromS3('network-config.json', { gateways: [], tunnelInterfaces: [] });
|
||||||
const passwordIds = (config.tunnelInterfaces || [])
|
const passwordIds = (config.tunnelInterfaces || [])
|
||||||
.filter(i => i.ipsecPasswordId && String(i.ipsecPasswordId).trim() !== '')
|
.filter(i => i.ipsecPasswordId && String(i.ipsecPasswordId).trim() !== '')
|
||||||
.map(i => i.ipsecPasswordId.trim());
|
.map(i => i.ipsecPasswordId.trim());
|
||||||
|
log('Fetching IPSec passwords', { count: passwordIds.length });
|
||||||
const passwordMap = await fetchIpsecPasswordMap(passwordIds);
|
const passwordMap = await fetchIpsecPasswordMap(passwordIds);
|
||||||
|
|
||||||
const includeInterfaces = type === 'interfaces' || type === 'all';
|
const includeInterfaces = type === 'interfaces' || type === 'all';
|
||||||
const includeRecursive = type === 'recursive' || type === 'all';
|
const includeRecursive = type === 'recursive' || type === 'all';
|
||||||
|
log('Building MikroTik config blocks', { includeInterfaces, includeRecursive });
|
||||||
const blocks = await buildMikrotikConfig(config, servers, passwordMap, {
|
const blocks = await buildMikrotikConfig(config, servers, passwordMap, {
|
||||||
format: 'json',
|
format: 'json',
|
||||||
serverId,
|
serverId,
|
||||||
includeInterfaces,
|
includeInterfaces,
|
||||||
includeRecursive,
|
includeRecursive,
|
||||||
});
|
});
|
||||||
|
log('Blocks built', { count: blocks.length });
|
||||||
|
|
||||||
const runApply = async () => {
|
const runApply = async () => {
|
||||||
conn = new RouterOSAPI({
|
conn = new RouterOSAPI({
|
||||||
@@ -300,18 +349,44 @@ async function applyMikrotikConfig(req, res) {
|
|||||||
password: String(password),
|
password: String(password),
|
||||||
port: Number(port) || 8728,
|
port: Number(port) || 8728,
|
||||||
});
|
});
|
||||||
|
conn.on('error', (e) => log('RouterOS API error', { error: e?.message || String(e), errno: e?.errno }));
|
||||||
|
conn.on('close', () => log('RouterOS API connection closed'));
|
||||||
|
if (streamLogs) {
|
||||||
|
process.env.DEBUG = (process.env.DEBUG || '') + (process.env.DEBUG ? ',' : '') + 'routeros-api:*';
|
||||||
|
global.__mikrotikStreamLog = log;
|
||||||
|
const debugMod = require('debug');
|
||||||
|
const origLog = debugMod.log;
|
||||||
|
if (origLog && !debugMod._mikrotikPatched) {
|
||||||
|
debugMod._mikrotikPatched = true;
|
||||||
|
debugMod.log = function (...args) {
|
||||||
|
try {
|
||||||
|
const msg = args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
|
||||||
|
if (msg.includes('routeros-api') && global.__mikrotikStreamLog) {
|
||||||
|
global.__mikrotikStreamLog('node-routeros', { msg });
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return origLog.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
console.log('[MikroTik apply] Connecting to', host + ':' + port, 'user:', user);
|
log('Connecting to MikroTik', { host: host + ':' + port });
|
||||||
await conn.connect();
|
await conn.connect();
|
||||||
|
if (conn.connector) {
|
||||||
|
conn.connector.on('error', (e) => log('RouterOS connector error', { error: e?.message || String(e) }));
|
||||||
|
conn.connector.on('timeout', () => log('RouterOS connector timeout'));
|
||||||
|
}
|
||||||
|
log('Connected');
|
||||||
const originalWrite = conn.write.bind(conn);
|
const originalWrite = conn.write.bind(conn);
|
||||||
conn.write = async function (path, args) {
|
conn.write = async function (path, args) {
|
||||||
const req = { path, args: args || [] };
|
const req = { path, args: args || [] };
|
||||||
mikrotikRequests.push(req);
|
mikrotikRequests.push(req);
|
||||||
console.log('[MikroTik API]', path, req.args);
|
log('MikroTik API', { path, args: req.args });
|
||||||
return originalWrite(path, args);
|
return originalWrite(path, args);
|
||||||
};
|
};
|
||||||
const allResults = [];
|
const allResults = [];
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
|
log('Applying block', { blockType: block.type, serverName: block.serverName });
|
||||||
const blockResults = await applyBlock(conn, block, !!dryRun);
|
const blockResults = await applyBlock(conn, block, !!dryRun);
|
||||||
allResults.push({
|
allResults.push({
|
||||||
blockType: block.type,
|
blockType: block.type,
|
||||||
@@ -327,11 +402,14 @@ async function applyMikrotikConfig(req, res) {
|
|||||||
skipped: allResults.flatMap(b => b.results).filter(r => r.status === 'skip').length,
|
skipped: allResults.flatMap(b => b.results).filter(r => r.status === 'skip').length,
|
||||||
errors: allResults.flatMap(b => b.results).filter(r => r.status === 'error'),
|
errors: allResults.flatMap(b => b.results).filter(r => r.status === 'error'),
|
||||||
};
|
};
|
||||||
|
log('Done', { summary });
|
||||||
return { ok: true, dryRun: !!dryRun, summary, results: allResults, mikrotikRequests };
|
return { ok: true, dryRun: !!dryRun, summary, results: allResults, mikrotikRequests };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (conn) try { conn.close(); } catch (_) {}
|
if (conn) try { conn.close(); } catch (_) {}
|
||||||
conn = null;
|
conn = null;
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
delete global.__mikrotikStreamLog;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,11 +418,11 @@ async function applyMikrotikConfig(req, res) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await Promise.race([runApply(), timeoutPromise]);
|
const result = await Promise.race([runApply(), timeoutPromise]);
|
||||||
return res.json(result);
|
return sendSafe(200, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (conn) try { conn.close(); } catch (_) {}
|
if (conn) try { conn.close(); } catch (_) {}
|
||||||
console.error('Error applying MikroTik config:', error);
|
|
||||||
const msg = error.message || String(error);
|
const msg = error.message || String(error);
|
||||||
|
log('Error', { error: msg });
|
||||||
const errPayload = {
|
const errPayload = {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: msg.includes('ECONNREFUSED') ? 'Соединение отклонено. Проверьте хост и порт.' :
|
error: msg.includes('ECONNREFUSED') ? 'Соединение отклонено. Проверьте хост и порт.' :
|
||||||
@@ -352,7 +430,7 @@ async function applyMikrotikConfig(req, res) {
|
|||||||
msg.includes('Authentication') || msg.includes('login') ? 'Неверный логин или пароль' : msg,
|
msg.includes('Authentication') || msg.includes('login') ? 'Неверный логин или пароль' : msg,
|
||||||
};
|
};
|
||||||
if (mikrotikRequests.length > 0) errPayload.mikrotikRequests = mikrotikRequests;
|
if (mikrotikRequests.length > 0) errPayload.mikrotikRequests = mikrotikRequests;
|
||||||
sendSafe(500, errPayload);
|
sendErrorSafe(500, errPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2237,16 +2237,53 @@ function NetworkConfigManager() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-Stream-Logs': '1',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[MikroTik apply] Response:', { status: response.status, ok: response.ok, url: response.url });
|
console.log('[MikroTik apply] Response:', { status: response.status, ok: response.ok, url: response.url });
|
||||||
|
|
||||||
const data = await response.json().catch((parseErr) => {
|
const contentType = response.headers.get('Content-Type') || '';
|
||||||
console.warn('[MikroTik apply] JSON parse error:', parseErr);
|
const isStream = contentType.includes('ndjson') || contentType.includes('x-ndjson');
|
||||||
return {};
|
|
||||||
});
|
let data = {};
|
||||||
|
if (isStream && response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let finalData = null;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line);
|
||||||
|
if (obj.t === 'log') {
|
||||||
|
console.log('[MikroTik apply]', obj.msg, obj);
|
||||||
|
} else if (obj.t === 'result') {
|
||||||
|
finalData = obj;
|
||||||
|
} else if (obj.t === 'error') {
|
||||||
|
finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(buffer);
|
||||||
|
if (obj.t === 'result') finalData = obj;
|
||||||
|
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
data = finalData || {};
|
||||||
|
} else {
|
||||||
|
data = await response.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok || data.ok === false) {
|
if (!response.ok || data.ok === false) {
|
||||||
const errorMessage = data.error || data.message || `HTTP ${response.status}`;
|
const errorMessage = data.error || data.message || `HTTP ${response.status}`;
|
||||||
@@ -2257,21 +2294,18 @@ function NetworkConfigManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[MikroTik apply] Success:', data);
|
console.log('[MikroTik apply] Success:', data);
|
||||||
if (data.mikrotikRequests?.length) {
|
|
||||||
console.log('[MikroTik apply] Запросы к MikroTik:', data.mikrotikRequests);
|
|
||||||
}
|
|
||||||
setApplyResult(data);
|
setApplyResult(data);
|
||||||
|
|
||||||
if (data.ok && !useDryRun) {
|
if (data.ok && !useDryRun) {
|
||||||
notify.success(`Применено: создано ${data.summary?.created || 0}, обновлено ${data.summary?.updated || 0}, пропущено ${data.summary?.skipped || 0}`);
|
notify.success(`Применено: создано ${data.summary?.created || 0}, обновлено ${data.summary?.updated || 0}, пропущено ${data.summary?.skipped || 0}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MikroTik apply] Fetch failed:', error);
|
const url = `${window.location.origin}/api/mikrotik/apply`;
|
||||||
|
console.error('[MikroTik apply] Fetch failed:', error, '\nURL:', url);
|
||||||
const errMsg = error.message || 'Ошибка применения';
|
const errMsg = error.message || 'Ошибка применения';
|
||||||
const hint = errMsg.includes('fetch') || errMsg.includes('Failed') ? ' Проверьте логи backend — при ERR_EMPTY_RESPONSE сервер мог не ответить.' : '';
|
|
||||||
setApplyResult({
|
setApplyResult({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: errMsg + hint,
|
error: errMsg + ` Сеть: ${url}`,
|
||||||
});
|
});
|
||||||
notify.error('Не удалось применить конфигурацию по API');
|
notify.error('Не удалось применить конфигурацию по API');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user