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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m13s
This commit is contained in:
@@ -36,6 +36,7 @@ Frontend доступен на `http://localhost:5173` (по умолчанию)
|
|||||||
|
|
||||||
### Важные переменные окружения
|
### Важные переменные окружения
|
||||||
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
|
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
|
||||||
|
- `ENCRYPTION_KEY` — ключ шифрования (64 hex символа) для IPSec и MikroTik паролей
|
||||||
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все)
|
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все)
|
||||||
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию)
|
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию)
|
||||||
- `PORT` — порт backend (по умолчанию 3001)
|
- `PORT` — порт backend (по умолчанию 3001)
|
||||||
@@ -97,6 +98,7 @@ BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=d
|
|||||||
- POST `/api/mikrotik/generate` — сгенерировать конфиг интерфейсов и маршрутов. Body: `{ format?: 'text'|'json', type?: 'interfaces'|'recursive'|'all', serverId?: string, config?, servers? }`. Возвращает `{ blocks }` — массив блоков с полем `code` (text) или `operations` (json).
|
- POST `/api/mikrotik/generate` — сгенерировать конфиг интерфейсов и маршрутов. Body: `{ format?: 'text'|'json', type?: 'interfaces'|'recursive'|'all', serverId?: string, config?, servers? }`. Возвращает `{ blocks }` — массив блоков с полем `code` (text) или `operations` (json).
|
||||||
- GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы.
|
- GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы.
|
||||||
- GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты.
|
- GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты.
|
||||||
|
- POST `/api/mikrotik/test-connection` — проверить соединение с MikroTik. Body: `{ serverId }` или `{ host, port?, user?, password }`.
|
||||||
|
|
||||||
### Прочее
|
### Прочее
|
||||||
- GET `/api/servers` / POST `/api/servers` — список серверов.
|
- GET `/api/servers` / POST `/api/servers` — список серверов.
|
||||||
|
|||||||
Generated
+11
-1
@@ -17,6 +17,7 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^6.11.2",
|
"express-rate-limit": "^6.11.2",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"node-routeros": "^1.6.8",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"prom-client": "^15.1.3"
|
"prom-client": "^15.1.3"
|
||||||
@@ -1989,7 +1990,6 @@
|
|||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -2631,6 +2631,16 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||||
|
|||||||
@@ -16,17 +16,18 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.687.0",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.0.1",
|
"dotenv": "^17.0.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"compression": "^1.7.4",
|
|
||||||
"ajv": "^8.17.1",
|
|
||||||
"helmet": "^7.1.0",
|
|
||||||
"express-rate-limit": "^6.11.2",
|
"express-rate-limit": "^6.11.2",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"node-routeros": "^1.6.8",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3"
|
||||||
"@aws-sdk/client-s3": "^3.687.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.10"
|
"nodemon": "^3.1.10"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const {
|
|||||||
buildMikrotikConfig,
|
buildMikrotikConfig,
|
||||||
} = require('../utils/mikrotikInterfaceGenerator');
|
} = require('../utils/mikrotikInterfaceGenerator');
|
||||||
const { readS3TextObject } = require('../services/s3Service');
|
const { readS3TextObject } = require('../services/s3Service');
|
||||||
|
const { RouterOSAPI } = require('node-routeros');
|
||||||
|
|
||||||
async function fetchJsonFromS3(key, defaultValue = null) {
|
async function fetchJsonFromS3(key, defaultValue = null) {
|
||||||
try {
|
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 = {
|
module.exports = {
|
||||||
generateMikrotikConfig,
|
generateMikrotikConfig,
|
||||||
generateInterfaces,
|
generateInterfaces,
|
||||||
generateRecursiveRoutes,
|
generateRecursiveRoutes,
|
||||||
|
testMikrotikConnection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -302,51 +302,10 @@ app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
|
|||||||
|
|
||||||
// === JSON DATA ROUTES (используют фабрики) ===
|
// === JSON DATA ROUTES (используют фабрики) ===
|
||||||
|
|
||||||
// Servers
|
// Servers (кастомные роуты с поддержкой зашифрованных MikroTik учётных данных для jumphost)
|
||||||
const SERVER_TYPES = ['jumphost', 'exit', 'bgp', 'dns', 'home'];
|
const serversRoutes = require('./routes/serversRoutes');
|
||||||
const TYPES_NEED_TUNNEL = ['jumphost', 'exit']; // Типы, которым нужен туннель
|
app.get('/api/servers', serversRoutes.getServers);
|
||||||
const TYPES_NEED_GATEWAYS = ['jumphost', 'exit']; // Типы, которым нужны gateways
|
app.post('/api/servers', serversRoutes.postServers);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Server connections for topology graph
|
// Server connections for topology graph
|
||||||
const serverConnectionsRoutes = createJsonDataRoutes('server-connections.json', (conn, i) => {
|
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.post('/api/mikrotik/generate', mikrotikConfigRoutes.generateMikrotikConfig);
|
||||||
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
|
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
|
||||||
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
|
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
|
||||||
|
app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection);
|
||||||
|
|
||||||
// === MIKROTIK VALIDATION ===
|
// === MIKROTIK VALIDATION ===
|
||||||
app.post('/api/mikrotik/validate', async (req, res) => {
|
app.post('/api/mikrotik/validate', async (req, res) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin, IconRouter, IconPlug } from '@tabler/icons-react';
|
||||||
|
import api from '../../lib/api.js';
|
||||||
import {
|
import {
|
||||||
makeGateway,
|
makeGateway,
|
||||||
normalizeGateways,
|
normalizeGateways,
|
||||||
@@ -76,6 +77,8 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
|
|||||||
const [localServer, setLocalServer] = useState(getEmptyServer());
|
const [localServer, setLocalServer] = useState(getEmptyServer());
|
||||||
const [customProvider, setCustomProvider] = useState('');
|
const [customProvider, setCustomProvider] = useState('');
|
||||||
const [internalError, setInternalError] = useState('');
|
const [internalError, setInternalError] = useState('');
|
||||||
|
const [mikrotikTesting, setMikrotikTesting] = useState(false);
|
||||||
|
const [mikrotikTestResult, setMikrotikTestResult] = useState(null);
|
||||||
|
|
||||||
const isEditMode = mode === 'edit';
|
const isEditMode = mode === 'edit';
|
||||||
const error = externalError || internalError;
|
const error = externalError || internalError;
|
||||||
@@ -119,6 +122,7 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
|
|||||||
}
|
}
|
||||||
if (!show) {
|
if (!show) {
|
||||||
initializedRef.current = false;
|
initializedRef.current = false;
|
||||||
|
setMikrotikTestResult(null);
|
||||||
}
|
}
|
||||||
}, [show, server, isEditMode]);
|
}, [show, server, isEditMode]);
|
||||||
|
|
||||||
@@ -560,6 +564,103 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* MikroTik — только для jumphost */}
|
||||||
|
{localServer.type === 'jumphost' && (
|
||||||
|
<div className="mt-4 pt-3 border-top">
|
||||||
|
<label className="form-label d-flex align-items-center gap-2">
|
||||||
|
<IconRouter size={18} />
|
||||||
|
MikroTik RouterOS API
|
||||||
|
</label>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label small">Хост</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={localServer.mikrotikHost || ''}
|
||||||
|
onChange={(e) => handleFieldChange('mikrotikHost', e.target.value)}
|
||||||
|
placeholder={localServer.ip || 'IP или DNS'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-2">
|
||||||
|
<label className="form-label small">Порт</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={localServer.mikrotikPort || '8728'}
|
||||||
|
onChange={(e) => handleFieldChange('mikrotikPort', e.target.value)}
|
||||||
|
placeholder="8728"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label small">Пользователь</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={localServer.mikrotikUser || 'admin'}
|
||||||
|
onChange={(e) => handleFieldChange('mikrotikUser', e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label small">Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={localServer.mikrotikPassword ?? ''}
|
||||||
|
onChange={(e) => handleFieldChange('mikrotikPassword', e.target.value)}
|
||||||
|
placeholder={localServer.hasMikrotikPassword ? '••••••' : 'Введите пароль'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 d-flex align-items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm d-flex align-items-center gap-1"
|
||||||
|
disabled={mikrotikTesting || !(localServer.mikrotikHost || localServer.ip)}
|
||||||
|
onClick={async () => {
|
||||||
|
setMikrotikTestResult(null);
|
||||||
|
setMikrotikTesting(true);
|
||||||
|
try {
|
||||||
|
const hasPassword = localServer.mikrotikPassword && String(localServer.mikrotikPassword).trim() !== '';
|
||||||
|
const payload = hasPassword
|
||||||
|
? {
|
||||||
|
host: localServer.mikrotikHost || localServer.ip,
|
||||||
|
port: localServer.mikrotikPort || 8728,
|
||||||
|
user: localServer.mikrotikUser || 'admin',
|
||||||
|
password: localServer.mikrotikPassword,
|
||||||
|
}
|
||||||
|
: (localServer.hasMikrotikPassword && (localServer.id || localServer.dns || localServer.ip))
|
||||||
|
? { serverId: localServer.id || localServer.dns || localServer.ip }
|
||||||
|
: null;
|
||||||
|
if (!payload) {
|
||||||
|
setMikrotikTestResult({ ok: false, message: 'Введите пароль или сохраните сервер с паролем' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { data } = await api.post('/mikrotik/test-connection', payload);
|
||||||
|
setMikrotikTestResult(data);
|
||||||
|
} catch (err) {
|
||||||
|
setMikrotikTestResult({
|
||||||
|
ok: false,
|
||||||
|
message: err.response?.data?.message || err.message || 'Ошибка соединения',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setMikrotikTesting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPlug size={14} />
|
||||||
|
{mikrotikTesting ? 'Проверка...' : 'Проверить соединение'}
|
||||||
|
</button>
|
||||||
|
{mikrotikTestResult && (
|
||||||
|
<span className={mikrotikTestResult.ok ? 'text-success' : 'text-danger'}>
|
||||||
|
{mikrotikTestResult.ok ? '✓ ' + (mikrotikTestResult.message || 'OK') : '✗ ' + mikrotikTestResult.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user