feat(MikrotikConnection): add MikroTik connection testing functionality in ServerModal; update API routes and README for new encryption key and connection endpoint
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m13s

This commit is contained in:
2026-02-03 19:12:32 +07:00
parent 0ea0f376a6
commit 1a27217676
7 changed files with 350 additions and 52 deletions
+11 -1
View File
@@ -17,6 +17,7 @@
"express": "^4.19.2",
"express-rate-limit": "^6.11.2",
"helmet": "^7.1.0",
"node-routeros": "^1.6.8",
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"prom-client": "^15.1.3"
@@ -1989,7 +1990,6 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2631,6 +2631,16 @@
"node": ">= 0.6"
}
},
"node_modules/node-routeros": {
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/node-routeros/-/node-routeros-1.6.8.tgz",
"integrity": "sha512-6N1N60mAsT8ALpbURMuLVGZ1tJAYislu8n1KBZ1TFBFXKO92krz+U92+xdMhu09RfPh8QQ8CyI5Fp9UznruhkA==",
"license": "MIT",
"dependencies": {
"debug": "*",
"iconv-lite": "*"
}
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
+6 -5
View File
@@ -16,17 +16,18 @@
"license": "ISC",
"description": "",
"dependencies": {
"@aws-sdk/client-s3": "^3.687.0",
"ajv": "^8.17.1",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^17.0.1",
"express": "^4.19.2",
"compression": "^1.7.4",
"ajv": "^8.17.1",
"helmet": "^7.1.0",
"express-rate-limit": "^6.11.2",
"helmet": "^7.1.0",
"node-routeros": "^1.6.8",
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"prom-client": "^15.1.3",
"@aws-sdk/client-s3": "^3.687.0"
"prom-client": "^15.1.3"
},
"devDependencies": {
"nodemon": "^3.1.10"
+66
View File
@@ -13,6 +13,7 @@ const {
buildMikrotikConfig,
} = require('../utils/mikrotikInterfaceGenerator');
const { readS3TextObject } = require('../services/s3Service');
const { RouterOSAPI } = require('node-routeros');
async function fetchJsonFromS3(key, defaultValue = null) {
try {
@@ -162,8 +163,73 @@ async function generateRecursiveRoutes(req, res) {
}
}
/**
* POST /api/mikrotik/test-connection
* Body: { serverId?: string, host?, port?, user?, password? }
* Если host/port/user/password переданы — тестирует с ними (без сохранения).
* Иначе берёт credentials из сервера по serverId.
*/
async function testMikrotikConnection(req, res) {
try {
const { serverId, host: bodyHost, port: bodyPort, user: bodyUser, password: bodyPassword } = req.body || {};
let host, port, user, password;
if (bodyHost && bodyPassword) {
host = bodyHost;
port = parseInt(bodyPort || '8728', 10) || 8728;
user = bodyUser || 'admin';
password = bodyPassword;
} else if (serverId) {
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 have MikroTik credentials', 'E_BAD_REQUEST');
}
if (!server.encryptedMikrotikPassword) {
return sendError(res, 400, 'MikroTik password not configured for this server', 'E_BAD_REQUEST');
}
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (decErr) {
return sendError(res, 500, 'Failed to decrypt MikroTik password', 'E_DECRYPT');
}
host = server.mikrotikHost || server.ip || server.dns;
port = parseInt(server.mikrotikPort || '8728', 10) || 8728;
user = server.mikrotikUser || 'admin';
} else {
return sendError(res, 400, 'Provide serverId or (host + password)', 'E_BAD_REQUEST');
}
const conn = new RouterOSAPI({
host: String(host),
user: String(user),
password: String(password),
port: Number(port) || 8728,
});
await conn.connect();
await conn.write('/system/resource/print', []);
conn.close();
res.json({ ok: true, message: 'Соединение успешно' });
} catch (error) {
const msg = error.message || String(error);
res.status(400).json({
ok: false,
message: msg.includes('ECONNREFUSED') ? 'Соединение отклонено. Проверьте хост и порт.' :
msg.includes('Authentication') || msg.includes('login') ? 'Неверный логин или пароль' :
msg,
});
}
}
module.exports = {
generateMikrotikConfig,
generateInterfaces,
generateRecursiveRoutes,
testMikrotikConnection,
};
+158
View File
@@ -0,0 +1,158 @@
/**
* Кастомные роуты для серверов с поддержкой зашифрованных MikroTik учётных данных
* Для jumphost: mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
*/
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { encrypt, decrypt } = require('../utils/encryption');
const S3_KEY = 'servers.json';
const SERVER_TYPES = ['jumphost', 'exit', 'bgp', 'dns', 'home'];
const TYPES_NEED_TUNNEL = ['jumphost', 'exit'];
const TYPES_NEED_GATEWAYS = ['jumphost', 'exit'];
function validateServer(server, i) {
if (!server.ip || !server.dns || !server.country || !server.provider) {
return `Server at index ${i} is missing required fields (ip, dns, country, provider)`;
}
const normalizedType = String(server.type || '').toLowerCase();
if (!SERVER_TYPES.includes(normalizedType)) {
return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`;
}
if (TYPES_NEED_TUNNEL.includes(normalizedType) && !server.tunnel) {
return `Server at index ${i} (${normalizedType}) requires tunnel type`;
}
if (TYPES_NEED_GATEWAYS.includes(normalizedType)) {
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
return `Server at index ${i} must have gateways for type ${normalizedType}`;
}
const primaries = server.gateways.filter(g => g && g.primary);
if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`;
}
for (let j = 0; j < server.gateways.length; j++) {
const gw = server.gateways[j] || {};
if (!gw.name || String(gw.name).trim().length === 0) {
return `Server at index ${i} gateway at index ${j} is missing name`;
}
}
}
server.type = normalizedType;
return null;
}
async function readServersFromS3() {
try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
const body = await streamToString(data.Body);
const parsed = JSON.parse(body || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return [];
throw e;
}
}
async function writeServersToS3(servers) {
const body = JSON.stringify(servers, null, 2);
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: S3_KEY,
Body: body,
ContentType: 'application/json',
}));
}
/**
* GET /api/servers — возвращает серверы, для jumphost заменяет encryptedMikrotikPassword на hasMikrotikPassword
*/
async function getServers(req, res) {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
if (checkIfNoneMatch(req, res, etag)) return;
const servers = await readServersFromS3();
const sanitized = servers.map(s => {
const out = { ...s };
if (s.type === 'jumphost' && out.encryptedMikrotikPassword) {
out.hasMikrotikPassword = true;
delete out.encryptedMikrotikPassword;
} else if (s.type === 'jumphost') {
out.hasMikrotikPassword = false;
}
return out;
});
res.json(sanitized);
} catch (error) {
if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
return res.json([]);
}
console.error(error);
return sendError(res, 500, 'Error reading servers', 'E_S3');
}
}
/**
* POST /api/servers — сохраняет серверы, шифрует mikrotikPassword при наличии
*/
async function postServers(req, res) {
const items = req.body?.domains ?? req.body?.servers;
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array (domains or servers)', 'E_BAD_REQUEST');
}
for (let i = 0; i < items.length; i++) {
const err = validateServer(items[i], i);
if (err) return sendError(res, 400, err, 'E_SCHEMA');
}
try {
const currentServers = await readServersFromS3();
const findCurrent = (srv) => currentServers.find(c =>
(srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip)
);
const toSave = items.map((srv) => {
const current = findCurrent(srv);
if (srv.type === 'jumphost') {
const out = { ...srv };
if (srv.mikrotikPassword !== undefined && srv.mikrotikPassword !== null && String(srv.mikrotikPassword).trim() !== '') {
try {
out.encryptedMikrotikPassword = encrypt(String(srv.mikrotikPassword).trim());
} catch (encErr) {
console.error('MikroTik password encryption failed:', encErr);
}
delete out.mikrotikPassword;
} else if (current?.encryptedMikrotikPassword) {
out.encryptedMikrotikPassword = current.encryptedMikrotikPassword;
}
return out;
}
return srv;
});
await writeServersToS3(toSave);
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
return sendOk(res, {
etag: head?.ETag || null,
lastModified: head?.LastModified ? head.LastModified.toISOString() : null,
contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null,
});
} catch (error) {
console.error(error);
return sendError(res, 500, error.message || 'Error saving servers', 'E_S3');
}
}
module.exports = {
getServers,
postServers,
readServersFromS3,
};
+5 -45
View File
@@ -302,51 +302,10 @@ app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
// === JSON DATA ROUTES (используют фабрики) ===
// Servers
const SERVER_TYPES = ['jumphost', 'exit', 'bgp', 'dns', 'home'];
const TYPES_NEED_TUNNEL = ['jumphost', 'exit']; // Типы, которым нужен туннель
const TYPES_NEED_GATEWAYS = ['jumphost', 'exit']; // Типы, которым нужны gateways
const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => {
// Базовые обязательные поля
if (!server.ip || !server.dns || !server.country || !server.provider) {
return `Server at index ${i} is missing required fields (ip, dns, country, provider)`;
}
const normalizedType = String(server.type || '').toLowerCase();
if (!SERVER_TYPES.includes(normalizedType)) {
return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`;
}
// Туннель обязателен только для jumphost и exit
if (TYPES_NEED_TUNNEL.includes(normalizedType) && !server.tunnel) {
return `Server at index ${i} (${normalizedType}) requires tunnel type`;
}
// Gateways обязательны только для jumphost и exit
if (TYPES_NEED_GATEWAYS.includes(normalizedType)) {
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
return `Server at index ${i} must have gateways for type ${normalizedType}`;
}
const primaries = server.gateways.filter(g => g && g.primary);
if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`;
}
for (let j = 0; j < server.gateways.length; j++) {
const gw = server.gateways[j] || {};
if (!gw.name || String(gw.name).trim().length === 0) {
return `Server at index ${i} gateway at index ${j} is missing name`;
}
}
}
// Нормализуем тип, чтобы в S3 всегда лежали одинаковые значения
server.type = normalizedType;
return null;
});
app.get('/api/servers', serversRoutes.get);
app.post('/api/servers', serversRoutes.post);
// Servers (кастомные роуты с поддержкой зашифрованных MikroTik учётных данных для jumphost)
const serversRoutes = require('./routes/serversRoutes');
app.get('/api/servers', serversRoutes.getServers);
app.post('/api/servers', serversRoutes.postServers);
// Server connections for topology graph
const serverConnectionsRoutes = createJsonDataRoutes('server-connections.json', (conn, i) => {
@@ -490,6 +449,7 @@ app.delete('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.delete
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);
// === MIKROTIK VALIDATION ===
app.post('/api/mikrotik/validate', async (req, res) => {