diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 0fc5964..69f34f3 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -13,6 +13,9 @@ const { buildMikrotikConfig, } = require('../utils/mikrotikInterfaceGenerator'); 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 { applyBlock } = require('../services/mikrotikApplyService'); @@ -240,58 +243,104 @@ async function applyMikrotikConfig(req, res) { let conn = null; const mikrotikRequests = []; 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) => { - if (res.headersSent) return; 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) { 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 { + 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; 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 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'); + sendErrorSafe(404, { error: 'Server not found' }); + return; } 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) { - 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; try { password = decrypt(server.encryptedMikrotikPassword); } 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 port = parseInt(server.mikrotikPort || '8728', 10) || 8728; 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 passwordIds = (config.tunnelInterfaces || []) .filter(i => i.ipsecPasswordId && String(i.ipsecPasswordId).trim() !== '') .map(i => i.ipsecPasswordId.trim()); + log('Fetching IPSec passwords', { count: passwordIds.length }); const passwordMap = await fetchIpsecPasswordMap(passwordIds); const includeInterfaces = type === 'interfaces' || type === 'all'; const includeRecursive = type === 'recursive' || type === 'all'; + log('Building MikroTik config blocks', { includeInterfaces, includeRecursive }); const blocks = await buildMikrotikConfig(config, servers, passwordMap, { format: 'json', serverId, includeInterfaces, includeRecursive, }); + log('Blocks built', { count: blocks.length }); const runApply = async () => { conn = new RouterOSAPI({ @@ -300,18 +349,44 @@ async function applyMikrotikConfig(req, res) { password: String(password), 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 { - console.log('[MikroTik apply] Connecting to', host + ':' + port, 'user:', user); + log('Connecting to MikroTik', { host: host + ':' + port }); 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); conn.write = async function (path, args) { const req = { path, args: args || [] }; mikrotikRequests.push(req); - console.log('[MikroTik API]', path, req.args); + log('MikroTik API', { path, args: req.args }); return originalWrite(path, args); }; const allResults = []; for (const block of blocks) { + log('Applying block', { blockType: block.type, serverName: block.serverName }); const blockResults = await applyBlock(conn, block, !!dryRun); allResults.push({ blockType: block.type, @@ -327,11 +402,14 @@ async function applyMikrotikConfig(req, res) { skipped: allResults.flatMap(b => b.results).filter(r => r.status === 'skip').length, errors: allResults.flatMap(b => b.results).filter(r => r.status === 'error'), }; + log('Done', { summary }); return { ok: true, dryRun: !!dryRun, summary, results: allResults, mikrotikRequests }; } catch (err) { if (conn) try { conn.close(); } catch (_) {} conn = null; throw err; + } finally { + delete global.__mikrotikStreamLog; } }; @@ -340,11 +418,11 @@ async function applyMikrotikConfig(req, res) { }); const result = await Promise.race([runApply(), timeoutPromise]); - return res.json(result); + return sendSafe(200, result); } catch (error) { if (conn) try { conn.close(); } catch (_) {} - console.error('Error applying MikroTik config:', error); const msg = error.message || String(error); + log('Error', { error: msg }); const errPayload = { ok: false, error: msg.includes('ECONNREFUSED') ? 'Соединение отклонено. Проверьте хост и порт.' : @@ -352,7 +430,7 @@ async function applyMikrotikConfig(req, res) { msg.includes('Authentication') || msg.includes('login') ? 'Неверный логин или пароль' : msg, }; if (mikrotikRequests.length > 0) errPayload.mikrotikRequests = mikrotikRequests; - sendSafe(500, errPayload); + sendErrorSafe(500, errPayload); } } diff --git a/frontend/src/NetworkConfigManager.jsx b/frontend/src/NetworkConfigManager.jsx index 86847c3..5abcbcb 100644 --- a/frontend/src/NetworkConfigManager.jsx +++ b/frontend/src/NetworkConfigManager.jsx @@ -2237,16 +2237,53 @@ function NetworkConfigManager() { method: 'POST', headers: { 'Content-Type': 'application/json', + 'X-Stream-Logs': '1', }, body: JSON.stringify(requestBody), }); console.log('[MikroTik apply] Response:', { status: response.status, ok: response.ok, url: response.url }); - const data = await response.json().catch((parseErr) => { - console.warn('[MikroTik apply] JSON parse error:', parseErr); - return {}; - }); + const contentType = response.headers.get('Content-Type') || ''; + const isStream = contentType.includes('ndjson') || contentType.includes('x-ndjson'); + + 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) { const errorMessage = data.error || data.message || `HTTP ${response.status}`; @@ -2257,21 +2294,18 @@ function NetworkConfigManager() { } console.log('[MikroTik apply] Success:', data); - if (data.mikrotikRequests?.length) { - console.log('[MikroTik apply] Запросы к MikroTik:', data.mikrotikRequests); - } setApplyResult(data); if (data.ok && !useDryRun) { notify.success(`Применено: создано ${data.summary?.created || 0}, обновлено ${data.summary?.updated || 0}, пропущено ${data.summary?.skipped || 0}`); } } 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 hint = errMsg.includes('fetch') || errMsg.includes('Failed') ? ' Проверьте логи backend — при ERR_EMPTY_RESPONSE сервер мог не ответить.' : ''; setApplyResult({ ok: false, - error: errMsg + hint, + error: errMsg + ` Сеть: ${url}`, }); notify.error('Не удалось применить конфигурацию по API'); } finally {