From 970b53e5ba5c95a59d50534e362e572749f6b067 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 18 Nov 2025 22:48:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 18 +- backend/routes/auth.js | 42 +- backend/routes/vds.js | 646 +++++++++++++++---- backend/save_vds_settings.js | 50 ++ backend/services/encryptedDatabaseService.js | 258 +++++--- backend/update_vds_no_password.js | 41 ++ docker-compose.yml | 1 + frontend/src/components/WebSshForm.vue | 24 +- frontend/src/views/VdsManagementView.vue | 37 +- sync-to-vds.sh | 149 +++++ webssh-agent/agent.js | 120 +++- webssh-agent/docker-compose.prod.yml | 1 + webssh-agent/utils/cleanupUtils.js | 116 +++- webssh-agent/utils/dockerUtils.js | 95 ++- webssh-agent/utils/sshUtils.js | 36 +- 15 files changed, 1297 insertions(+), 337 deletions(-) create mode 100644 backend/save_vds_settings.js create mode 100644 backend/update_vds_no_password.js create mode 100755 sync-to-vds.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index 5482c03..4f758fe 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,13 +19,17 @@ LABEL website="https://hb3-accelerator.com" WORKDIR /app # Устанавливаем системные зависимости для компиляции нативных модулей Node.js -RUN apt-get update && apt-get install -y \ - python3 \ - make \ - g++ \ - curl \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* +# Устанавливаем базовые пакеты отдельно от компиляторов для большей надежности +RUN apt-get update && \ + apt-get install -y --fix-missing \ + python3 \ + make \ + curl \ + ca-certificates \ + openssh-client && \ + apt-get install -y --fix-missing g++ || \ + (sleep 10 && apt-get update && apt-get install -y --fix-missing g++) && \ + rm -rf /var/lib/apt/lists/* # Docker CLI НЕ устанавливаем - используем Docker Socket + dockerode SDK diff --git a/backend/routes/auth.js b/backend/routes/auth.js index d8cc247..2e1ee95 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -172,29 +172,29 @@ router.post('/verify', async (req, res) => { return res.status(401).json({ success: false, error: 'Invalid nonce' }); } - // Получаем базовый URL из БД (домен VDS) или используем текущий хост из запроса - const consentService = require('../services/consentService'); - const baseUrl = await consentService.getBaseUrl(); - - // Если домена нет в БД, используем текущий хост из запроса (более надежно, чем origin) - let baseUrlForResources; - if (baseUrl !== 'http://localhost:9000') { - // Домен есть в БД - используем его - baseUrlForResources = baseUrl; - } else { - // Домена нет в БД - используем текущий хост из запроса - const protocol = req.protocol || 'http'; - let host = req.get('host') || 'localhost:9000'; - // Убеждаемся, что порт присутствует для localhost - if (host === 'localhost' || host.startsWith('localhost:')) { - if (!host.includes(':')) { - // Если порта нет, добавляем стандартный порт для протокола - const defaultPort = protocol === 'https' ? '443' : '9000'; - host = `${host}:${defaultPort}`; - } + // ВАЖНО: Для SIWE сообщения ВСЕГДА используем хост из запроса, чтобы он совпадал с фронтендом + // Фронтенд использует window.location.host и window.location.origin, поэтому бэкенд должен использовать то же самое + // Это означает, что даже если в БД есть домен (например, 185.221.214.140), для SIWE будет использоваться + // хост из текущего запроса (например, localhost:9000), если запрос приходит с localhost + const protocol = req.protocol || 'http'; + let host = req.get('host') || 'localhost:9000'; + + logger.info(`[verify] Request protocol: ${protocol}, host header: ${req.get('host')}, original host: ${host}`); + + // Убеждаемся, что порт присутствует для localhost + if (host === 'localhost' || host.startsWith('localhost:')) { + if (!host.includes(':')) { + // Если порта нет, добавляем стандартный порт для протокола + const defaultPort = protocol === 'https' ? '443' : '9000'; + host = `${host}:${defaultPort}`; + logger.info(`[verify] Added default port to localhost: ${host}`); } - baseUrlForResources = `${protocol}://${host}`; } + + // Формируем domain и origin для SIWE сообщения из текущего запроса + // domain - это host (например, "localhost:9000" или "example.com:443") + // ВАЖНО: domain и origin для SIWE НИКОГДА не берутся из БД, только из запроса! + const baseUrlForResources = `${protocol}://${host}`; // Извлекаем домен и origin из baseUrlForResources для SIWE сообщения const baseUrlObj = new URL(baseUrlForResources); diff --git a/backend/routes/vds.js b/backend/routes/vds.js index 800a244..4661799 100644 --- a/backend/routes/vds.js +++ b/backend/routes/vds.js @@ -23,6 +23,40 @@ const encryptedDb = require('../services/encryptedDatabaseService'); const execAsync = promisify(exec); +/** + * Сохранить настройки VDS в базу данных + * @param {Object} settings - Объект с настройками для сохранения + * @returns {Promise} - Сохраненная запись + */ +async function saveVdsSettingsToDb(settings) { + // Проверяем существующие настройки + const existing = await encryptedDb.getData('vds_settings', {}, 1); + + if (existing.length > 0) { + // UPDATE существующей записи + return await encryptedDb.saveData('vds_settings', settings, { id: existing[0].id }); + } else { + // INSERT новой записи + return await encryptedDb.saveData('vds_settings', { + ...settings, + created_at: new Date() + }); + } +} + +/** + * Обновить домен в process.env и сбросить кэш + * @param {string} domain - Домен для установки + */ +function updateDomainCache(domain) { + // Обновляем process.env.BASE_URL для текущего процесса + process.env.BASE_URL = `https://${domain}`; + + // Сбрасываем кэш домена в consentService + const consentService = require('../services/consentService'); + consentService.clearDomainCache(); +} + /** * Получить настройки VDS */ @@ -31,42 +65,58 @@ router.get('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTIN const encryptionUtils = require('../utils/encryptionUtils'); const encryptionKey = encryptionUtils.getEncryptionKey(); - const { rows } = await db.getQuery()( - `SELECT - id, - decrypt_text(domain_encrypted, $1) as domain, - decrypt_text(email_encrypted, $1) as email, - decrypt_text(ubuntu_user_encrypted, $1) as ubuntu_user, - decrypt_text(docker_user_encrypted, $1) as docker_user, - decrypt_text(ssh_host_encrypted, $1) as ssh_host, - ssh_port, - decrypt_text(ssh_user_encrypted, $1) as ssh_user, - decrypt_text(ssh_password_encrypted, $1) as ssh_password, - created_at, - updated_at - FROM vds_settings - ORDER BY id DESC - LIMIT 1`, - [encryptionKey] - ); - - if (rows.length === 0) { - return res.json({ success: true, settings: null }); - } - - res.json({ - success: true, - settings: { - domain: rows[0].domain, - email: rows[0].email, - ubuntuUser: rows[0].ubuntu_user, - dockerUser: rows[0].docker_user, - sshHost: rows[0].ssh_host, - sshPort: rows[0].ssh_port, - sshUser: rows[0].ssh_user - // sshPassword не возвращаем по соображениям безопасности + try { + const { rows } = await db.getQuery()( + `SELECT + id, + decrypt_text(domain_encrypted, $1) as domain, + decrypt_text(email_encrypted, $1) as email, + decrypt_text(ubuntu_user_encrypted, $1) as ubuntu_user, + decrypt_text(docker_user_encrypted, $1) as docker_user, + decrypt_text(ssh_host_encrypted, $1) as ssh_host, + ssh_port, + decrypt_text(ssh_user_encrypted, $1) as ssh_user, + decrypt_text(ssh_password_encrypted, $1) as ssh_password, + created_at, + updated_at + FROM vds_settings + ORDER BY id DESC + LIMIT 1`, + [encryptionKey] + ); + + if (rows.length === 0) { + return res.json({ success: true, settings: null }); } - }); + + res.json({ + success: true, + settings: { + domain: rows[0].domain, + email: rows[0].email, + ubuntuUser: rows[0].ubuntu_user, + dockerUser: rows[0].docker_user, + sshHost: rows[0].ssh_host, + sshPort: rows[0].ssh_port, + sshUser: rows[0].ssh_user + // sshPassword не возвращаем по соображениям безопасности + } + }); + } catch (decryptError) { + // Если ошибка расшифровки (некорректные данные в БД), очищаем их и возвращаем null + if (decryptError.message && decryptError.message.includes('decoding base64')) { + logger.warn('[VDS] Ошибка расшифровки настроек (некорректные данные в БД). Очищаем некорректные данные из таблицы vds_settings.'); + try { + // Автоматически очищаем некорректные данные из БД + await db.getQuery()('DELETE FROM vds_settings'); + logger.info('[VDS] Некорректные настройки VDS удалены из таблицы vds_settings. Создайте новые настройки через интерфейс.'); + } catch (deleteError) { + logger.error('[VDS] Ошибка при удалении некорректных настроек:', deleteError); + } + return res.json({ success: true, settings: null }); + } + throw decryptError; // Пробрасываем другие ошибки + } } catch (error) { logger.error('[VDS] Ошибка получения настроек:', error); res.status(500).json({ success: false, error: error.message }); @@ -92,27 +142,14 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI // Если передан только домен (для обратной совместимости) if (domain && !email && !sshHost) { const normalizedDomain = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, ''); - const existing = await encryptedDb.getData('vds_settings', {}, 1); const settings = { domain_encrypted: normalizedDomain, // encryptedDb автоматически зашифрует updated_at: new Date() }; - if (existing.length > 0) { - await encryptedDb.saveData('vds_settings', settings, { id: existing[0].id }); - } else { - await encryptedDb.saveData('vds_settings', { - ...settings, - created_at: new Date() - }); - } - - process.env.BASE_URL = `https://${normalizedDomain}`; - - // Сбрасываем кэш домена в consentService - const consentService = require('../services/consentService'); - consentService.clearDomainCache(); + await saveVdsSettingsToDb(settings); + updateDomainCache(normalizedDomain); logger.info(`[VDS] Домен сохранен: ${normalizedDomain}`); return res.json({ success: true, domain: normalizedDomain }); @@ -129,12 +166,9 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI // Нормализуем домен const normalizedDomain = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, ''); - // Проверяем существующие настройки + // Проверяем существующие настройки (для валидации пароля) const existing = await encryptedDb.getData('vds_settings', {}, 1); - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); - // Подготавливаем данные для сохранения с правильными именами полей для шифрования const settings = { domain_encrypted: normalizedDomain, // encryptedDb автоматически зашифрует поля с _encrypted @@ -159,21 +193,8 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI } // Если пароль не указан (undefined/null/пустая строка) и настройки уже есть - не обновляем пароль - if (existing.length > 0) { - await encryptedDb.saveData('vds_settings', settings, { id: existing[0].id }); - } else { - await encryptedDb.saveData('vds_settings', { - ...settings, - created_at: new Date() - }); - } - - // Обновляем process.env.BASE_URL для текущего процесса - process.env.BASE_URL = `https://${normalizedDomain}`; - - // Сбрасываем кэш домена в consentService - const consentService = require('../services/consentService'); - consentService.clearDomainCache(); + await saveVdsSettingsToDb(settings); + updateDomainCache(normalizedDomain); logger.info(`[VDS] Настройки сохранены: ${normalizedDomain}`); res.json({ success: true, settings }); @@ -195,21 +216,148 @@ async function checkDockerAvailable() { } } +/** + * Получить настройки VDS из базы данных + */ +async function getVdsSettings() { + try { + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + const { rows } = await db.getQuery()( + `SELECT + decrypt_text(domain_encrypted, $1) as domain, + decrypt_text(email_encrypted, $1) as email, + decrypt_text(ubuntu_user_encrypted, $1) as ubuntu_user, + decrypt_text(docker_user_encrypted, $1) as docker_user, + decrypt_text(ssh_host_encrypted, $1) as ssh_host, + ssh_port, + decrypt_text(ssh_user_encrypted, $1) as ssh_user, + decrypt_text(ssh_password_encrypted, $1) as ssh_password + FROM vds_settings + ORDER BY id DESC + LIMIT 1`, + [encryptionKey] + ); + + if (rows.length > 0 && rows[0].ssh_host && rows[0].ssh_user) { + return { + domain: rows[0].domain, + email: rows[0].email, + ubuntuUser: rows[0].ubuntu_user, + dockerUser: rows[0].docker_user, + sshHost: rows[0].ssh_host, + sshPort: rows[0].ssh_port || 22, + sshUser: rows[0].ssh_user, + sshPassword: rows[0].ssh_password + }; + } + } catch (decryptError) { + // Если ошибка расшифровки (некорректные данные в БД), очищаем их и возвращаем null + if (decryptError.message && decryptError.message.includes('decoding base64')) { + logger.warn('[VDS] Ошибка расшифровки настроек (некорректные данные в БД). Очищаем некорректные данные из таблицы vds_settings.'); + try { + // Автоматически очищаем некорректные данные из БД + await db.getQuery()('DELETE FROM vds_settings'); + logger.info('[VDS] Некорректные настройки VDS удалены из таблицы vds_settings. Создайте новые настройки через интерфейс.'); + } catch (deleteError) { + logger.error('[VDS] Ошибка при удалении некорректных настроек:', deleteError); + } + return null; + } + // Для других ошибок просто логируем + logger.warn('[VDS] Не удалось получить настройки VDS из базы данных:', decryptError.message); + } + return null; +} + +/** + * Выполнить команду Docker (локально или через SSH на VDS) + */ +async function execDockerCommand(command) { + const vdsSettings = await getVdsSettings(); + + if (vdsSettings && vdsSettings.sshHost && vdsSettings.sshUser) { + // Выполняем через SSH на VDS + return await execSshCommandOnVds(command, vdsSettings); + } else { + // Выполняем локально + try { + const { stdout, stderr } = await execAsync(command); + return { code: 0, stdout, stderr }; + } catch (error) { + return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message }; + } + } +} + +/** + * Выполнить SSH команду на VDS + */ +async function execSshCommandOnVds(command, settings) { + const { sshHost, sshPort = 22, sshUser, sshPassword } = settings; + + // Экранируем команду для SSH + // Экранируем двойные кавычки и знаки доллара для правильной передачи через SSH + const escapedCommand = command + .replace(/\\/g, '\\\\') // Сначала экранируем обратные слеши + .replace(/\$/g, '\\$') // Экранируем знаки доллара + .replace(/"/g, '\\"'); // Экранируем двойные кавычки + + // Базовые опции SSH + const sshOptions = `-p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR`; + + // Строим SSH команду + let sshCommand; + if (sshPassword && sshPassword.trim()) { + // Используем sshpass для подключения с паролем (если пароль указан) + sshCommand = `sshpass -p "${sshPassword.replace(/"/g, '\\"')}" ssh ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`; + } else { + // Используем SSH ключи (по умолчанию из ~/.ssh/id_rsa или ~/.ssh/id_ed25519) + // SSH автоматически найдет ключ в ~/.ssh/ + sshCommand = `ssh ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`; + } + + try { + const { stdout, stderr } = await execAsync(sshCommand); + return { code: 0, stdout, stderr }; + } catch (error) { + return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message }; + } +} + /** * Получить список контейнеров */ router.get('/containers', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { - const dockerAvailable = await checkDockerAvailable(); - if (!dockerAvailable) { - return res.json({ success: true, containers: [], message: 'Docker недоступен (работает локально, не на VDS)' }); + const vdsSettings = await getVdsSettings(); + + // Проверяем, есть ли настройки VDS или локальный Docker доступен + if (!vdsSettings && !(await checkDockerAvailable())) { + return res.json({ + success: true, + containers: [], + message: 'VDS не настроена и Docker недоступен локально' + }); } - const { stdout } = await execAsync('docker ps -a --format "{{.Names}}|{{.Status}}|{{.Image}}"'); - const containers = stdout.trim().split('\n').filter(line => line.trim()).map(line => { - const [name, status, image] = line.split('|'); - return { name, status, image }; - }); + const result = await execDockerCommand('docker ps -a --format "{{.Names}}|{{.Status}}|{{.Image}}"'); + + if (result.code !== 0) { + logger.error(`[VDS] Ошибка выполнения Docker команды: ${result.stderr}`); + return res.status(500).json({ + success: false, + error: `Не удалось получить список контейнеров: ${result.stderr}` + }); + } + + const containers = result.stdout.trim().split('\n') + .filter(line => line.trim()) + .map(line => { + const [name, status, image] = line.split('|'); + return { name, status, image }; + }); res.json({ success: true, containers }); } catch (error) { @@ -229,7 +377,12 @@ router.post('/containers/:name/restart', requireAuth, requirePermission(PERMISSI return res.status(400).json({ success: false, error: 'Имя контейнера обязательно' }); } - await execAsync(`docker restart ${name}`); + const result = await execDockerCommand(`docker restart ${name}`); + + if (result.code !== 0) { + return res.status(500).json({ success: false, error: result.stderr || 'Не удалось перезапустить контейнер' }); + } + logger.info(`[VDS] Контейнер ${name} перезапущен`); res.json({ success: true, message: `Контейнер ${name} перезапущен` }); } catch (error) { @@ -243,14 +396,24 @@ router.post('/containers/:name/restart', requireAuth, requirePermission(PERMISSI */ router.post('/containers/restart-all', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { - const { stdout } = await execAsync('docker ps -q'); - const containerIds = stdout.trim().split('\n').filter(id => id.trim()); + const result = await execDockerCommand('docker ps -q'); + + if (result.code !== 0) { + return res.status(500).json({ success: false, error: result.stderr || 'Не удалось получить список контейнеров' }); + } + + const containerIds = result.stdout.trim().split('\n').filter(id => id.trim()); if (containerIds.length === 0) { return res.json({ success: true, message: 'Нет запущенных контейнеров', restarted: 0 }); } - await execAsync(`docker restart ${containerIds.join(' ')}`); + const restartResult = await execDockerCommand(`docker restart ${containerIds.join(' ')}`); + + if (restartResult.code !== 0) { + return res.status(500).json({ success: false, error: restartResult.stderr || 'Не удалось перезапустить контейнеры' }); + } + logger.info(`[VDS] Перезапущено контейнеров: ${containerIds.length}`); res.json({ success: true, message: `Перезапущено контейнеров: ${containerIds.length}`, restarted: containerIds.length }); } catch (error) { @@ -271,24 +434,28 @@ router.post('/containers/:name/rebuild', requireAuth, requirePermission(PERMISSI } // Получаем информацию о контейнере - const { stdout: inspectOutput } = await execAsync(`docker inspect ${name} --format '{{.Config.Image}}'`); - const imageName = inspectOutput.trim(); + const inspectResult = await execDockerCommand(`docker inspect ${name} --format '{{.Config.Image}}'`); + if (inspectResult.code !== 0) { + return res.status(404).json({ success: false, error: 'Контейнер не найден' }); + } + const imageName = inspectResult.stdout.trim(); if (!imageName) { return res.status(404).json({ success: false, error: 'Контейнер не найден' }); } // Останавливаем контейнер - await execAsync(`docker stop ${name}`).catch(() => {}); + await execDockerCommand(`docker stop ${name}`); // Удаляем контейнер - await execAsync(`docker rm ${name}`).catch(() => {}); + await execDockerCommand(`docker rm ${name}`); // Пересобираем образ (если есть Dockerfile) // Для простоты просто пересоздаем контейнер из образа - await execAsync(`docker run -d --name ${name} ${imageName}`).catch(() => { - throw new Error('Не удалось пересоздать контейнер. Возможно, нужны дополнительные параметры запуска.'); - }); + const runResult = await execDockerCommand(`docker run -d --name ${name} ${imageName}`); + if (runResult.code !== 0) { + return res.status(500).json({ success: false, error: 'Не удалось пересоздать контейнер. Возможно, нужны дополнительные параметры запуска.' }); + } logger.info(`[VDS] Контейнер ${name} пересобран`); res.json({ success: true, message: `Контейнер ${name} пересобран` }); @@ -305,64 +472,146 @@ router.get('/stats', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS) try { const { period = '1h' } = req.query; // 1h, 6h, 24h, 7d - // Получаем текущую статистику CPU и RAM + // Получаем текущую статистику CPU и RAM с VDS сервера let cpuUsage = 0; let ramUsage = 0; + let ramTotal = 0; + let ramUsed = 0; let totalTraffic = 0; let rxBytes = 0; let txBytes = 0; - - try { - const { stdout: cpuRam } = await execAsync('top -bn1 | grep "Cpu(s)" | sed "s/.*, *\\([0-9.]*\\)%* id.*/\\1/" | awk \'{print 100 - $1}\''); - cpuUsage = parseFloat(cpuRam.trim()) || 0; - } catch (error) { - logger.warn('[VDS] Не удалось получить статистику CPU:', error.message); - } - - try { - const { stdout: memInfo } = await execAsync('free -m | awk \'NR==2{printf "%.2f", $3*100/$2}\''); - ramUsage = parseFloat(memInfo.trim()) || 0; - } catch (error) { - logger.warn('[VDS] Не удалось получить статистику RAM:', error.message); - } - - try { - // Используем /host/proc если доступно, иначе /proc - const procPath = require('fs').existsSync('/host/proc') ? '/host/proc' : '/proc'; - const { stdout: networkStats } = await execAsync(`cat ${procPath}/net/dev | awk 'NR>2 {rx+=$2; tx+=$10} END {print rx, tx}'`); - [rxBytes, txBytes] = networkStats.trim().split(' ').map(Number); - totalTraffic = (rxBytes + txBytes) / 1024 / 1024; // MB - } catch (error) { - logger.warn('[VDS] Не удалось получить статистику трафика:', error.message); - } + let cpuCores = 0; // Получаем статистику по контейнерам (если Docker доступен) let containers = []; - const dockerAvailable = await checkDockerAvailable(); - if (dockerAvailable) { + const vdsSettings = await getVdsSettings(); + + // Если есть настройки VDS, выполняем команды на VDS сервере + if (vdsSettings) { try { - const { stdout: containerStats } = await execAsync('docker stats --no-stream --format "{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}"'); - containers = containerStats.trim().split('\n').filter(line => line.trim()).map(line => { - const [name, cpu, mem, net] = line.split('|'); - return { name, cpu, mem, net }; - }); + // CPU usage - используем упрощенную команду через /proc/stat + // $ будет экранирован в execSshCommandOnVds + const procCpuResult = await execDockerCommand('head -n1 /proc/stat | awk \'{idle=$5+$6; total=$2+$3+$4+$5+$6+$7+$8+$9; if(total>0) print (100*(total-idle)/total); else print 0}\''); + if (procCpuResult.code === 0 && procCpuResult.stdout && procCpuResult.stdout.trim()) { + const parsed = parseFloat(procCpuResult.stdout.trim()); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) { + cpuUsage = parsed; + logger.info(`[VDS] CPU usage получен через /proc/stat: ${cpuUsage}%`); + } else { + throw new Error(`Invalid CPU value: ${parsed}`); + } + } else { + throw new Error(`Command failed: code=${procCpuResult.code}, stderr=${procCpuResult.stderr}`); + } + } catch (error) { + logger.warn('[VDS] Не удалось получить CPU через /proc/stat, пробуем top:', error.message); + try { + // Fallback: через top - упрощенная команда (idle обычно предпоследнее значение) + const cpuResult = await execDockerCommand('top -bn1 | grep "%Cpu(s)" | awk \'{print 100-$(NF-2)}\' | sed \'s/%//\''); + if (cpuResult.code === 0 && cpuResult.stdout && cpuResult.stdout.trim()) { + cpuUsage = parseFloat(cpuResult.stdout.trim()) || 0; + logger.info(`[VDS] CPU usage получен через top: ${cpuUsage}%`); + } + } catch (topError) { + logger.warn('[VDS] Не удалось получить CPU usage:', topError.message); + } + } + + try { + // RAM usage и total - $ будет экранирован в execSshCommandOnVds + const memResult = await execDockerCommand('free -m | awk \'NR==2{usage=$3*100/$2; printf "%.2f %d %d", usage, $2, $3}\''); + if (memResult.code === 0 && memResult.stdout && memResult.stdout.trim()) { + const parts = memResult.stdout.trim().split(' '); + if (parts.length >= 3) { + ramUsage = parseFloat(parts[0]) || 0; + ramTotal = parseInt(parts[1]) || 0; + ramUsed = parseInt(parts[2]) || 0; + logger.info(`[VDS] RAM получена с VDS: usage=${ramUsage}%, total=${ramTotal}MB, used=${ramUsed}MB (raw: ${memResult.stdout.trim()})`); + } else { + logger.warn('[VDS] Неверный формат RAM данных:', memResult.stdout); + } + } else { + logger.warn('[VDS] Не удалось получить RAM, code:', memResult.code, 'stdout:', memResult.stdout, 'stderr:', memResult.stderr); + } + } catch (error) { + logger.warn('[VDS] Не удалось получить статистику RAM:', error.message); + } + + try { + // CPU cores + const coresResult = await execDockerCommand('nproc'); + if (coresResult.code === 0 && coresResult.stdout) { + cpuCores = parseInt(coresResult.stdout.trim()) || 0; + } + } catch (error) { + logger.warn('[VDS] Не удалось получить количество ядер CPU:', error.message); + } + + try { + // Network traffic + const networkResult = await execDockerCommand('cat /proc/net/dev | awk \'NR>2 {rx+=$2; tx+=$10} END {print rx, tx}\''); + if (networkResult.code === 0 && networkResult.stdout) { + [rxBytes, txBytes] = networkResult.stdout.trim().split(' ').map(Number); + totalTraffic = (rxBytes + txBytes) / 1024 / 1024; // MB + } + } catch (error) { + logger.warn('[VDS] Не удалось получить статистику трафика:', error.message); + } + + try { + // Docker containers stats + const result = await execDockerCommand('docker stats --no-stream --format "{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}"'); + if (result.code === 0 && result.stdout) { + containers = result.stdout.trim().split('\n').filter(line => line.trim()).map(line => { + const [name, cpu, mem, net] = line.split('|'); + return { name, cpu, mem, net }; + }); + } } catch (error) { logger.warn('[VDS] Не удалось получить статистику контейнеров:', error.message); - // Продолжаем без статистики контейнеров } + } else { + // Fallback: локальное выполнение (если VDS не настроена) + try { + const { stdout: cpuRam } = await execAsync('top -bn1 | grep "Cpu(s)" | sed "s/.*, *\\([0-9.]*\\)%* id.*/\\1/" | awk \'{print 100 - $1}\''); + cpuUsage = parseFloat(cpuRam.trim()) || 0; + } catch (error) { + logger.warn('[VDS] Не удалось получить статистику CPU:', error.message); + } + + try { + const { stdout: memInfo } = await execAsync('free -m | awk \'NR==2{printf "%.2f %d %d", $3*100/$2, $2, $3}\''); + const [usage, total, used] = memInfo.trim().split(' ').map(Number); + ramUsage = usage || 0; + ramTotal = total || 0; + ramUsed = used || 0; + } catch (error) { + logger.warn('[VDS] Не удалось получить статистику RAM:', error.message); + } + + try { + const procPath = require('fs').existsSync('/host/proc') ? '/host/proc' : '/proc'; + const { stdout: networkStats } = await execAsync(`cat ${procPath}/net/dev | awk 'NR>2 {rx+=$2; tx+=$10} END {print rx, tx}'`); + [rxBytes, txBytes] = networkStats.trim().split(' ').map(Number); + totalTraffic = (rxBytes + txBytes) / 1024 / 1024; // MB + } catch (error) { + logger.warn('[VDS] Не удалось получить статистику трафика:', error.message); + } + + cpuCores = require('os').cpus().length; } - res.json({ + const responseData = { success: true, stats: { cpu: { usage: cpuUsage, - cores: require('os').cpus().length + cores: cpuCores || require('os').cpus().length }, ram: { usage: ramUsage, - total: Math.round(require('os').totalmem() / 1024 / 1024), // MB - used: Math.round(require('os').totalmem() / 1024 / 1024 * ramUsage / 100) // MB + total: ramTotal || Math.round(require('os').totalmem() / 1024 / 1024), // MB + used: ramUsed || Math.round((ramTotal || require('os').totalmem() / 1024 / 1024) * ramUsage / 100) // MB }, traffic: { total: totalTraffic, // MB @@ -372,7 +621,11 @@ router.get('/stats', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS) containers }, timestamp: new Date().toISOString() - }); + }; + + logger.info(`[VDS] Статистика отправлена: CPU=${cpuUsage}%, RAM=${ramUsage}% (${ramUsed}/${ramTotal}MB), Traffic=${totalTraffic}MB`); + + res.json(responseData); } catch (error) { logger.error('[VDS] Ошибка получения статистики:', error); res.status(500).json({ success: false, error: error.message }); @@ -385,7 +638,12 @@ router.get('/stats', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS) router.post('/containers/:name/stop', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; - await execAsync(`docker stop ${name}`); + const result = await execDockerCommand(`docker stop ${name}`); + + if (result.code !== 0) { + return res.status(500).json({ success: false, error: result.stderr || 'Не удалось остановить контейнер' }); + } + logger.info(`[VDS] Контейнер ${name} остановлен`); res.json({ success: true, message: `Контейнер ${name} остановлен` }); } catch (error) { @@ -400,7 +658,12 @@ router.post('/containers/:name/stop', requireAuth, requirePermission(PERMISSIONS router.post('/containers/:name/start', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; - await execAsync(`docker start ${name}`); + const result = await execDockerCommand(`docker start ${name}`); + + if (result.code !== 0) { + return res.status(500).json({ success: false, error: result.stderr || 'Не удалось запустить контейнер' }); + } + logger.info(`[VDS] Контейнер ${name} запущен`); res.json({ success: true, message: `Контейнер ${name} запущен` }); } catch (error) { @@ -415,8 +678,13 @@ router.post('/containers/:name/start', requireAuth, requirePermission(PERMISSION router.delete('/containers/:name', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; - await execAsync(`docker stop ${name}`).catch(() => {}); - await execAsync(`docker rm ${name}`); + await execDockerCommand(`docker stop ${name}`); + const result = await execDockerCommand(`docker rm ${name}`); + + if (result.code !== 0) { + return res.status(500).json({ success: false, error: result.stderr || 'Не удалось удалить контейнер' }); + } + logger.info(`[VDS] Контейнер ${name} удален`); res.json({ success: true, message: `Контейнер ${name} удален` }); } catch (error) { @@ -432,8 +700,13 @@ router.get('/containers/:name/logs', requireAuth, requirePermission(PERMISSIONS. try { const { name } = req.params; const { tail = 100 } = req.query; - const { stdout } = await execAsync(`docker logs --tail ${tail} ${name}`); - res.json({ success: true, logs: stdout }); + const result = await execDockerCommand(`docker logs --tail ${tail} ${name}`); + + if (result.code !== 0) { + return res.status(500).json({ success: false, error: result.stderr || 'Не удалось получить логи контейнера' }); + } + + res.json({ success: true, logs: result.stdout }); } catch (error) { logger.error(`[VDS] Ошибка получения логов контейнера ${req.params.name}:`, error); res.status(500).json({ success: false, error: error.message }); @@ -446,8 +719,13 @@ router.get('/containers/:name/logs', requireAuth, requirePermission(PERMISSIONS. router.get('/containers/:name/stats', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; - const { stdout } = await execAsync(`docker stats --no-stream --format "{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}" ${name}`); - const [cpu, mem, net, block] = stdout.trim().split('|'); + const result = await execDockerCommand(`docker stats --no-stream --format "{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}" ${name}`); + + if (result.code !== 0) { + return res.status(500).json({ success: false, error: result.stderr || 'Не удалось получить статистику контейнера' }); + } + + const [cpu, mem, net, block] = result.stdout.trim().split('|'); res.json({ success: true, stats: { cpu, mem, net, block } }); } catch (error) { logger.error(`[VDS] Ошибка получения статистики контейнера ${req.params.name}:`, error); @@ -794,6 +1072,106 @@ router.post('/backup/send', requireAuth, requirePermission(PERMISSIONS.MANAGE_SE } }); +/** + * Проверить и обновить SSL сертификат + */ +router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { + try { + const vdsSettings = await getVdsSettings(); + + if (!vdsSettings) { + return res.status(400).json({ success: false, error: 'VDS не настроена' }); + } + + // Проверяем, используется ли Docker certbot + const dockerUser = vdsSettings.dockerUser || 'docker'; + const domain = vdsSettings.domain || vdsSettings.sshHost; + + // Проверяем статус сертификата через Docker certbot + const checkResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`); + + if (checkResult.code !== 0) { + logger.warn('[VDS] Ошибка проверки сертификатов:', checkResult.stderr); + } + + // Пытаемся обновить сертификат через Docker certbot + logger.info('[VDS] Обновление SSL сертификата...'); + const renewResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot renew --force-renewal --non-interactive 2>&1 || certbot renew --force-renewal --non-interactive 2>&1`); + + if (renewResult.code === 0) { + // Перезапускаем nginx для применения нового сертификата + const reloadResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || systemctl reload nginx 2>&1`); + + logger.info('[VDS] SSL сертификат обновлен'); + res.json({ + success: true, + message: 'SSL сертификат обновлен', + output: renewResult.stdout, + reloadOutput: reloadResult.stdout + }); + } else { + logger.error('[VDS] Ошибка обновления SSL сертификата:', renewResult.stderr); + res.status(500).json({ + success: false, + error: 'Не удалось обновить SSL сертификат', + details: renewResult.stderr + }); + } + } catch (error) { + logger.error('[VDS] Ошибка обновления SSL сертификата:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * Получить статус SSL сертификата + */ +router.get('/ssl/status', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { + try { + const vdsSettings = await getVdsSettings(); + + if (!vdsSettings) { + return res.status(400).json({ success: false, error: 'VDS не настроена' }); + } + + const dockerUser = vdsSettings.dockerUser || 'docker'; + const domain = vdsSettings.domain || vdsSettings.sshHost; + + // Проверяем статус сертификата через Docker certbot + const checkResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`); + + // Проверяем срок действия сертификата + let certInfo = null; + + if (domain) { + const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`; + const certCheckResult = await execDockerCommand(`openssl x509 -in ${certPath} -noout -dates -subject 2>&1 || echo "Certificate not found"`); + + if (certCheckResult.code === 0 && !certCheckResult.stdout.includes('not found')) { + certInfo = { + exists: true, + details: certCheckResult.stdout + }; + } else { + certInfo = { + exists: false, + error: certCheckResult.stdout + }; + } + } + + res.json({ + success: true, + certificates: checkResult.stdout, + domain: domain, + certInfo: certInfo + }); + } catch (error) { + logger.error('[VDS] Ошибка проверки SSL сертификата:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + /** * Получить историю статистики (для графиков) */ diff --git a/backend/save_vds_settings.js b/backend/save_vds_settings.js new file mode 100644 index 0000000..500d302 --- /dev/null +++ b/backend/save_vds_settings.js @@ -0,0 +1,50 @@ +/** + * Скрипт для сохранения настроек VDS в базу данных + */ + +const encryptedDb = require('./services/encryptedDatabaseService'); + +async function saveVdsSettings() { + try { + console.log('🔧 Сохранение настроек VDS...'); + + // Данные для сохранения + // ВАЖНО: передаем ключи БЕЗ суффикса _encrypted, сервис сам добавит его + const settings = { + domain: '185.221.214.140', // Можно использовать IP или домен + email: 'info@hb3-accelerator.com', + ubuntu_user: 'root', + docker_user: 'root', + ssh_host: '185.221.214.140', + ssh_port: 22, + ssh_user: 'root', + ssh_password: '1414Bcar', + updated_at: new Date() + }; + + // Проверяем существующие настройки + const existing = await encryptedDb.getData('vds_settings', {}, 1); + + if (existing.length > 0) { + console.log('📝 Обновление существующих настроек (id:', existing[0].id, ')'); + const result = await encryptedDb.saveData('vds_settings', settings, { id: existing[0].id }); + console.log('✅ Настройки обновлены:', result); + } else { + console.log('➕ Создание новых настроек'); + const result = await encryptedDb.saveData('vds_settings', { + ...settings, + created_at: new Date() + }); + console.log('✅ Настройки созданы:', result); + } + + console.log('✅ Настройки VDS успешно сохранены!'); + process.exit(0); + } catch (error) { + console.error('❌ Ошибка сохранения настроек:', error); + process.exit(1); + } +} + +saveVdsSettings(); + diff --git a/backend/services/encryptedDatabaseService.js b/backend/services/encryptedDatabaseService.js index 894fe97..50d7e9f 100644 --- a/backend/services/encryptedDatabaseService.js +++ b/backend/services/encryptedDatabaseService.js @@ -113,7 +113,7 @@ class EncryptedDataService { if (encryptedColumn) { // Для зашифрованных колонок используем прямое сравнение с зашифрованным значением - return `${key}_encrypted = encrypt_text($${paramIndex++}, ${hasEncryptedFields ? '$1' : 'NULL'})`; + return `${key}_encrypted = encrypt_text($${paramIndex++}, ${hasEncryptedFields ? '($1)::text' : 'NULL'})`; } else { // Для незашифрованных колонок используем обычное сравнение // Заключаем зарезервированные слова в кавычки @@ -198,7 +198,7 @@ class EncryptedDataService { // Проверяем, есть ли зашифрованные поля в таблице const hasEncryptedFields = columns.some(col => col.column_name.endsWith('_encrypted')); - let paramIndex = hasEncryptedFields ? 2 : 1; // Начинаем с 2, если есть зашифрованные поля, иначе с 1 + let paramIndex = 1; // Начинаем с 1, encryptionKey будет последним (как в работающих примерах) for (const [key, value] of Object.entries(data)) { // Проверяем, есть ли зашифрованная версия колонки @@ -228,10 +228,12 @@ class EncryptedDataService { filteredData[key] = valueToEncrypt; // Добавляем в отфильтрованные данные console.log(`✅ Добавили зашифрованное поле ${key} = "${valueToEncrypt}" в filteredData`); + // В INSERT запросах encryptionKey идет последним параметром (как в работающих примерах) + // Используем плейсхолдер, который будет заменен на реальный номер после подсчета всех параметров if (encryptedColumn.data_type === 'jsonb') { - encryptedData[`${key}_encrypted`] = `encrypt_json($${currentParamIndex}, ${hasEncryptedFields ? '$1::text' : 'NULL'})`; + encryptedData[`${key}_encrypted`] = `encrypt_json($${currentParamIndex}, ${hasEncryptedFields ? '$ENCRYPTION_KEY_PARAM' : 'NULL'})`; } else { - encryptedData[`${key}_encrypted`] = `encrypt_text($${currentParamIndex}, ${hasEncryptedFields ? '$1::text' : 'NULL'})`; + encryptedData[`${key}_encrypted`] = `encrypt_text($${currentParamIndex}, ${hasEncryptedFields ? '$ENCRYPTION_KEY_PARAM' : 'NULL'})`; } console.log(`🔐 Будем шифровать ${key} -> ${key}_encrypted`); } else if (unencryptedColumn) { @@ -274,92 +276,196 @@ class EncryptedDataService { }; if (whereConditions) { - // UPDATE - const setClause = Object.keys(allData) - .map((key, index) => `${quoteReservedWord(key)} = ${allData[key]}`) - .join(', '); - const whereClause = Object.keys(whereConditions) - .map((key, index) => { - // Для WHERE условий используем зашифрованные имена колонок - const encryptedColumn = columns.find(col => col.column_name === `${key}_encrypted`); - if (encryptedColumn) { - // Для зашифрованных колонок используем encrypt_text для сравнения - return `${quoteReservedWord(`${key}_encrypted`)} = encrypt_text($${paramIndex + index}, ${hasEncryptedFields ? '$1' : 'NULL'})`; - } else { - // Для незашифрованных колонок используем обычное сравнение - return `${quoteReservedWord(key)} = $${paramIndex + index}`; + // UPDATE - используем тот же подход, что и в работающих примерах (auth.js, tables.js) + // Как в auth.js: 'UPDATE nonces SET nonce_encrypted = encrypt_text($1, $2)' + // Параметры: [nonce, encryptionKey] - encryptionKey идет последним + const updateParams = []; + let paramIndex = 1; + let encryptionKeyParamIndex = null; + + // Сначала собираем все значения для SET и WHERE, чтобы узнать общее количество параметров + const setParts = []; + // Итерируемся по filteredData, чтобы использовать только реальные значения + for (const key of Object.keys(filteredData)) { + // Пропускаем ключи, которые используются только в WHERE + if (whereConditions && whereConditions.hasOwnProperty(key)) { + continue; + } + + // Проверяем, зашифрованное ли это поле + // encryptedData содержит ключи с _encrypted (например, domain_encrypted) + // filteredData содержит оригинальные ключи (например, domain) + const encryptedKey = `${key}_encrypted`; + if (encryptedData[encryptedKey]) { + // Зашифрованное поле - key уже оригинальный (без _encrypted) + const dataParamIndex = paramIndex++; + updateParams.push(filteredData[key]); + setParts.push({ key: encryptedKey, dataParamIndex, encrypted: true }); + } else if (unencryptedData.hasOwnProperty(key)) { + // Незашифрованное поле - проверяем, что оно есть в unencryptedData + const dataParamIndex = paramIndex++; + setParts.push({ key, dataParamIndex, encrypted: false }); + updateParams.push(filteredData[key]); + } + } + + // Проверяем, есть ли зашифрованные поля в SET или WHERE + const hasEncryptedInSet = setParts.some(part => part.encrypted); + + // Формируем WHERE часть + const whereParts = []; + let hasEncryptedInWhere = false; + for (const [key, value] of Object.entries(whereConditions)) { + const encryptedColumn = columns.find(col => col.column_name === `${key}_encrypted`); + if (encryptedColumn) { + // Для зашифрованных колонок используем encrypt_text для сравнения + const dataParamIndex = paramIndex++; + whereParts.push({ key, dataParamIndex, encrypted: true }); + updateParams.push(value); + hasEncryptedInWhere = true; + } else { + // Для незашифрованных колонок используем обычное сравнение + const dataParamIndex = paramIndex++; + whereParts.push({ key, dataParamIndex, encrypted: false }); + updateParams.push(value); + } + } + + // Определяем номер параметра для encryptionKey (последний, после всех данных) + // encryptionKey нужен, если есть зашифрованные поля в SET или WHERE + // ВАЖНО: encryptionKey используется один раз для всех зашифрованных полей + if (hasEncryptedInSet || hasEncryptedInWhere) { + encryptionKeyParamIndex = paramIndex; // paramIndex уже увеличен после последнего параметра данных + } + + // Формируем SET clause с правильными номерами параметров + const setClause = setParts.map(part => { + if (part.encrypted) { + if (!encryptionKeyParamIndex) { + throw new Error('encryptionKeyParamIndex должен быть определен для зашифрованных полей'); } - }) - .join(' AND '); - + return `${quoteReservedWord(part.key)} = encrypt_text($${part.dataParamIndex}, $${encryptionKeyParamIndex})`; + } else { + return `${quoteReservedWord(part.key)} = $${part.dataParamIndex}`; + } + }).join(', '); + + // Формируем WHERE clause с правильными номерами параметров + const whereClause = whereParts.map(part => { + if (part.encrypted) { + if (!encryptionKeyParamIndex) { + throw new Error('encryptionKeyParamIndex должен быть определен для зашифрованных полей в WHERE'); + } + // part.key уже без _encrypted, нужно добавить _encrypted для имени колонки + return `${quoteReservedWord(`${part.key}_encrypted`)} = encrypt_text($${part.dataParamIndex}, $${encryptionKeyParamIndex})`; + } else { + return `${quoteReservedWord(part.key)} = $${part.dataParamIndex}`; + } + }).join(' AND '); + const query = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING *`; - const allParams = hasEncryptedFields ? [this.encryptionKey, ...Object.values(filteredData), ...Object.values(whereConditions)] : [...Object.values(filteredData), ...Object.values(whereConditions)]; - + + // Собираем параметры: сначала все значения для SET и WHERE, затем encryptionKey (если есть) + const allParams = encryptionKeyParamIndex + ? [...updateParams, this.encryptionKey] + : updateParams; + + // Подсчитываем количество плейсхолдеров в запросе + const placeholderCount = (query.match(/\$\d+/g) || []).length; + const maxPlaceholder = Math.max(...(query.match(/\$\d+/g) || ['$0']).map(m => parseInt(m.replace('$', '')))); + + console.log(`🔍 UPDATE запрос: ${query}`); + console.log(`🔍 setParts (${setParts.length}):`, JSON.stringify(setParts, null, 2)); + console.log(`🔍 whereParts (${whereParts.length}):`, JSON.stringify(whereParts, null, 2)); + console.log(`🔍 encryptionKeyParamIndex:`, encryptionKeyParamIndex); + console.log(`🔍 updateParams.length:`, updateParams.length); + console.log(`🔍 Параметры (${allParams.length}):`, allParams.map((p, i) => { + const val = typeof p === 'string' && p.length > 50 ? p.substring(0, 50) + '...' : p; + return `$${i+1}=${val}`; + })); + console.log(`🔍 Проверка: плейсхолдеров в запросе=${placeholderCount}, максимальный номер=${maxPlaceholder}, параметров=${allParams.length}`); + + if (maxPlaceholder !== allParams.length) { + const errorMsg = `Несоответствие параметров: в запросе используется $${maxPlaceholder}, но передано ${allParams.length} параметров. setParts=${setParts.length}, whereParts=${whereParts.length}, hasEncryptionKey=${!!encryptionKeyParamIndex}`; + console.error(`❌ ${errorMsg}`); + throw new Error(errorMsg); + } const { rows } = await db.getQuery()(query, allParams); return rows[0]; } else { - // INSERT - const columns = Object.keys(allData).map(key => quoteReservedWord(key)); - const placeholders = Object.keys(allData).map(key => allData[key]).join(', '); - - const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING *`; + // INSERT - используем тот же подход, что и в работающих примерах (tables.js, users.js) + // Как в tables.js: 'INSERT INTO user_cell_values VALUES ($1, $2, encrypt_text($3, $4))' + // Параметры: [row_id, column_id, value, encryptionKey] - encryptionKey идет последним + const insertParams = []; + let insertParamIndex = 1; + let encryptionKeyParamIndex = null; - // Собираем параметры в правильном порядке по номерам из плейсхолдеров - const paramMap = new Map(); // номер параметра -> значение + // Формируем VALUES часть с правильными плейсхолдерами + const valuesParts = []; + const columns = []; - if (hasEncryptedFields) { - paramMap.set(1, this.encryptionKey); // $1 - ключ шифрования - } + // Сначала обрабатываем все поля из filteredData + // Проверяем, есть ли зашифрованные поля + const hasEncryptedFieldsInInsert = Object.keys(encryptedData).length > 0; - // Проходим по колонкам в порядке allData и добавляем соответствующие значения - for (const key of Object.keys(allData)) { - const placeholder = allData[key].toString(); - console.log(`🔍 Обрабатываем ключ: ${key}, placeholder: ${placeholder}`); - // Извлекаем все номера параметров из плейсхолдера (может быть $1 в encrypt_text) - const paramMatches = placeholder.match(/\$(\d+)/g); - console.log(`🔍 paramMatches для ${key}:`, paramMatches); - if (paramMatches) { - // Для зашифрованных колонок нас интересует второй параметр ($3, $4 и т.д.) - // Для незашифрованных - первый параметр ($2, $3 и т.д.) - if (encryptedData[key]) { - // Это зашифрованная колонка - берем первый параметр (это значение для шифрования) - const originalKey = key.replace('_encrypted', ''); - console.log(`🔍 Это зашифрованная колонка, originalKey: ${originalKey}, filteredData[originalKey]:`, filteredData[originalKey]); - if (filteredData[originalKey] !== undefined && paramMatches.length > 0) { - // Первый параметр это значение для шифрования - const valueParam = paramMatches[0]; - const paramNum = parseInt(valueParam.substring(1)); - console.log(`🔍 Устанавливаем paramMap[${paramNum}] =`, filteredData[originalKey]); - paramMap.set(paramNum, filteredData[originalKey]); - } - } else if (unencryptedData[key]) { - // Это незашифрованная колонка - берем параметр из плейсхолдера - const valueParam = paramMatches[0]; - const paramNum = parseInt(valueParam.substring(1)); - console.log(`🔍 Это незашифрованная колонка, устанавливаем paramMap[${paramNum}] =`, filteredData[key]); - paramMap.set(paramNum, filteredData[key]); - } + for (const key of Object.keys(filteredData)) { + const encryptedKey = `${key}_encrypted`; + if (encryptedData[encryptedKey]) { + // Зашифрованное поле + const dataParamIndex = insertParamIndex++; + insertParams.push(filteredData[key]); + columns.push(quoteReservedWord(encryptedKey)); + // Используем плейсхолдер, который заменим позже + valuesParts.push(`encrypt_text($${dataParamIndex}, $ENCRYPTION_KEY)`); + } else if (unencryptedData.hasOwnProperty(key)) { + // Незашифрованное поле + const dataParamIndex = insertParamIndex++; + insertParams.push(filteredData[key]); + columns.push(quoteReservedWord(key)); + valuesParts.push(`$${dataParamIndex}`); } } - console.log(`🔍 paramMap после цикла:`, Array.from(paramMap.entries())); + // Определяем номер параметра для encryptionKey (последний) + if (hasEncryptedFieldsInInsert) { + encryptionKeyParamIndex = insertParamIndex; + } - // Создаем массив параметров в правильном порядке (от $1 до максимального номера) - const maxParamNum = Math.max(...Array.from(paramMap.keys())); - const params = []; - for (let i = 1; i <= maxParamNum; i++) { - if (!paramMap.has(i)) { - throw new Error(`Отсутствует параметр $${i} для запроса`); - } - params.push(paramMap.get(i)); + // Заменяем плейсхолдер ENCRYPTION_KEY на реальный номер + const placeholdersFinal = valuesParts.map(ph => + ph.replace(/\$ENCRYPTION_KEY/g, encryptionKeyParamIndex ? `$${encryptionKeyParamIndex}` : 'NULL') + ).join(', '); + + const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholdersFinal}) RETURNING *`; + + // Собираем параметры: сначала все значения из insertParams, затем encryptionKey (если есть) + const allParams = encryptionKeyParamIndex + ? [...insertParams, this.encryptionKey] + : insertParams; + + // Подсчитываем количество плейсхолдеров в запросе + const placeholderCount = (query.match(/\$\d+/g) || []).length; + const maxPlaceholder = Math.max(...(query.match(/\$\d+/g) || ['$0']).map(m => parseInt(m.replace('$', '')))); + + console.log(`🔍 INSERT запрос: ${query}`); + console.log(`🔍 columns (${columns.length}):`, columns); + console.log(`🔍 valuesParts (${valuesParts.length}):`, valuesParts); + console.log(`🔍 insertParams.length:`, insertParams.length); + console.log(`🔍 encryptionKeyParamIndex:`, encryptionKeyParamIndex); + console.log(`🔍 Параметры (${allParams.length}):`, allParams.map((p, i) => { + const val = typeof p === 'string' && p.length > 50 ? p.substring(0, 50) + '...' : p; + return `$${i+1}=${val}`; + })); + console.log(`🔍 Проверка: плейсхолдеров в запросе=${placeholderCount}, максимальный номер=${maxPlaceholder}, параметров=${allParams.length}`); + + if (maxPlaceholder !== allParams.length) { + const errorMsg = `Несоответствие параметров: в запросе используется $${maxPlaceholder}, но передано ${allParams.length} параметров. columns=${columns.length}, valuesParts=${valuesParts.length}, hasEncryptionKey=${!!encryptionKeyParamIndex}`; + console.error(`❌ ${errorMsg}`); + throw new Error(errorMsg); } - console.log(`🔍 Выполняем INSERT запрос:`, query); - console.log(`🔍 Параметры:`, params); - console.log(`🔍 Ключ шифрования:`, this.encryptionKey ? 'установлен' : 'не установлен'); - - const { rows } = await db.getQuery()(query, params); + const { rows } = await db.getQuery()(query, allParams); return rows[0]; } } catch (error) { @@ -408,7 +514,7 @@ class EncryptedDataService { if (encryptedColumn) { // Для зашифрованных колонок используем прямое сравнение с зашифрованным значением // Ключ шифрования всегда первый параметр ($1), затем значения - return `${key}_encrypted = encrypt_text($${index + 2}, $1)`; + return `${key}_encrypted = encrypt_text($${index + 2}, ($1)::text)`; } else { // Для незашифрованных колонок используем обычное сравнение const columnName = quoteReservedWord(key); diff --git a/backend/update_vds_no_password.js b/backend/update_vds_no_password.js new file mode 100644 index 0000000..932a7a2 --- /dev/null +++ b/backend/update_vds_no_password.js @@ -0,0 +1,41 @@ +/** + * Скрипт для обновления настроек VDS - удаление пароля (используем SSH ключи) + */ + +const encryptedDb = require('./services/encryptedDatabaseService'); + +async function updateVdsSettings() { + try { + console.log('🔧 Обновление настроек VDS (удаление пароля, используем SSH ключи)...'); + + // Получаем существующие настройки + const existing = await encryptedDb.getData('vds_settings', {}, 1); + + if (existing.length === 0) { + console.error('❌ Настройки VDS не найдены'); + process.exit(1); + } + + console.log('📝 Найдены настройки (id:', existing[0].id, ')'); + + // Обновляем только пароль - устанавливаем в null (будет пустая строка после расшифровки) + // Передаем пустую строку, чтобы encryptedDb не обновлял это поле + // Но лучше явно установить в null через SQL + const settings = { + updated_at: new Date() + }; + + // Обновляем через encryptedDb (пароль не передаем, значит не обновляется) + const result = await encryptedDb.saveData('vds_settings', settings, { id: existing[0].id }); + + console.log('✅ Настройки обновлены (пароль не изменен, будет использоваться SSH ключ)'); + console.log('ℹ️ Если пароль все еще в БД, он будет игнорироваться, так как код проверяет sshPassword && sshPassword.trim()'); + process.exit(0); + } catch (error) { + console.error('❌ Ошибка обновления настроек:', error); + process.exit(1); + } +} + +updateVdsSettings(); + diff --git a/docker-compose.yml b/docker-compose.yml index 268bc06..572eff2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -135,6 +135,7 @@ services: - ./frontend/dist:/app/frontend_dist:ro - ./ssl:/app/ssl - ./shared:/app/shared:ro + - ~/.ssh:/root/.ssh:ro # SSH ключи для подключения к VDS environment: - NODE_ENV=${NODE_ENV:-production} - PORT=${PORT:-8000} diff --git a/frontend/src/components/WebSshForm.vue b/frontend/src/components/WebSshForm.vue index 179e864..6f403f6 100644 --- a/frontend/src/components/WebSshForm.vue +++ b/frontend/src/components/WebSshForm.vue @@ -270,19 +270,35 @@ const handleSubmit = async () => { // Сохраняем ВСЕ настройки на сервере try { - await axios.post('/api/vds/settings', { + addLog('info', 'Сохранение настроек VDS на сервере...'); + const response = await axios.post('/api/vds/settings', { domain: form.domain, email: form.email, ubuntuUser: form.ubuntuUser, dockerUser: form.dockerUser, sshHost: form.sshHost, - sshPort: form.sshPort, + sshPort: parseInt(form.sshPort, 10) || 22, // Преобразуем в число sshUser: form.sshUser, sshPassword: form.sshPassword }); - addLog('info', 'Настройки VDS сохранены на сервере'); + + if (response.data && response.data.success) { + addLog('success', '✅ Настройки VDS успешно сохранены на сервере'); + } else { + addLog('error', `❌ Ошибка сохранения настроек: ${response.data?.error || 'Неизвестная ошибка'}`); + } } catch (error) { - addLog('error', `Ошибка сохранения настроек на сервере: ${error.message}`); + console.error('[WebSSH] Ошибка сохранения настроек:', error); + const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка'; + addLog('error', `❌ Ошибка сохранения настроек на сервере: ${errorMessage}`); + // Показываем детали ошибки в консоли для отладки + if (error.response) { + console.error('[WebSSH] Детали ошибки:', { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data + }); + } } // Отправляем событие об изменении статуса VDS diff --git a/frontend/src/views/VdsManagementView.vue b/frontend/src/views/VdsManagementView.vue index 4d7ca0a..0125b1e 100644 --- a/frontend/src/views/VdsManagementView.vue +++ b/frontend/src/views/VdsManagementView.vue @@ -652,8 +652,11 @@ const loadStats = async () => { try { const response = await axios.get('/vds/stats'); if (response.data.success) { + console.log('[VDS] Получена статистика:', response.data.stats); stats.value = response.data.stats; updateCharts(); + } else { + console.warn('[VDS] Статистика не успешна:', response.data); } } catch (error) { console.error('Ошибка загрузки статистики:', error); @@ -1218,9 +1221,11 @@ const updateCharts = () => { const now = new Date().toLocaleTimeString(); // CPU - if (cpuChartInstance && stats.value.cpu?.usage !== undefined) { + if (cpuChartInstance && stats.value.cpu?.usage !== undefined && stats.value.cpu?.usage !== null) { + const cpuValue = parseFloat(stats.value.cpu.usage) || 0; + console.log('[VDS] Обновление графика CPU:', cpuValue); chartData.cpu.labels.push(now); - chartData.cpu.data.push(stats.value.cpu.usage); + chartData.cpu.data.push(cpuValue); if (chartData.cpu.labels.length > 20) { chartData.cpu.labels.shift(); chartData.cpu.data.shift(); @@ -1228,12 +1233,20 @@ const updateCharts = () => { cpuChartInstance.data.labels = chartData.cpu.labels; cpuChartInstance.data.datasets[0].data = chartData.cpu.data; cpuChartInstance.update('none'); + } else { + console.warn('[VDS] CPU график не обновлен:', { + hasInstance: !!cpuChartInstance, + usage: stats.value.cpu?.usage, + statsValue: stats.value + }); } // RAM - if (ramChartInstance && stats.value.ram?.usage !== undefined) { + if (ramChartInstance && stats.value.ram?.usage !== undefined && stats.value.ram?.usage !== null) { + const ramValue = parseFloat(stats.value.ram.usage) || 0; + console.log('[VDS] Обновление графика RAM:', ramValue); chartData.ram.labels.push(now); - chartData.ram.data.push(stats.value.ram.usage); + chartData.ram.data.push(ramValue); if (chartData.ram.labels.length > 20) { chartData.ram.labels.shift(); chartData.ram.data.shift(); @@ -1241,12 +1254,19 @@ const updateCharts = () => { ramChartInstance.data.labels = chartData.ram.labels; ramChartInstance.data.datasets[0].data = chartData.ram.data; ramChartInstance.update('none'); + } else { + console.warn('[VDS] RAM график не обновлен:', { + hasInstance: !!ramChartInstance, + usage: stats.value.ram?.usage + }); } // Traffic (в MB) - if (trafficChartInstance && stats.value.traffic?.total !== undefined) { + if (trafficChartInstance && stats.value.traffic?.total !== undefined && stats.value.traffic?.total !== null) { + const trafficValue = parseFloat(stats.value.traffic.total) || 0; + console.log('[VDS] Обновление графика Traffic:', trafficValue); chartData.traffic.labels.push(now); - chartData.traffic.data.push(stats.value.traffic.total); + chartData.traffic.data.push(trafficValue); if (chartData.traffic.labels.length > 20) { chartData.traffic.labels.shift(); chartData.traffic.data.shift(); @@ -1254,6 +1274,11 @@ const updateCharts = () => { trafficChartInstance.data.labels = chartData.traffic.labels; trafficChartInstance.data.datasets[0].data = chartData.traffic.data; trafficChartInstance.update('none'); + } else { + console.warn('[VDS] Traffic график не обновлен:', { + hasInstance: !!trafficChartInstance, + total: stats.value.traffic?.total + }); } }; diff --git a/sync-to-vds.sh b/sync-to-vds.sh new file mode 100755 index 0000000..18cef90 --- /dev/null +++ b/sync-to-vds.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# Скрипт для синхронизации кода с localhost на VDS + +# Цвета для вывода +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🔄 Синхронизация кода с VDS...${NC}" + +# Параметры VDS (из настроек) +VDS_HOST="185.221.214.140" +VDS_USER="root" +VDS_PORT="22" +VDS_PATH="/home/docker/dapp" + +# SSH опции +SSH_OPTS="-p $VDS_PORT -o StrictHostKeyChecking=no" + +# Проверяем наличие rsync на удаленном сервере +echo -e "${YELLOW}🔍 Проверка наличия rsync на VDS...${NC}" +if ssh $SSH_OPTS $VDS_USER@$VDS_HOST "command -v rsync >/dev/null 2>&1"; then + USE_RSYNC=true + echo -e "${GREEN}✅ rsync найден на VDS${NC}" +else + USE_RSYNC=false + echo -e "${YELLOW}⚠️ rsync не найден на VDS, будет использован метод tar/scp${NC}" + read -p "Установить rsync на VDS? (y/n): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}📦 Установка rsync на VDS...${NC}" + ssh $SSH_OPTS $VDS_USER@$VDS_HOST "apt-get update && apt-get install -y rsync" + if ssh $SSH_OPTS $VDS_USER@$VDS_HOST "command -v rsync >/dev/null 2>&1"; then + USE_RSYNC=true + echo -e "${GREEN}✅ rsync успешно установлен${NC}" + else + echo -e "${RED}❌ Не удалось установить rsync, используется tar/scp${NC}" + fi + fi +fi + +# Функция синхронизации через rsync +sync_with_rsync() { + local SRC=$1 + local DST=$2 + rsync -avz --progress -e "ssh $SSH_OPTS" \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='*.log' \ + --exclude='.env' \ + --exclude='dist' \ + --exclude='build' \ + --exclude='.next' \ + --exclude='coverage' \ + --exclude='.nyc_output' \ + --exclude='sessions' \ + --exclude='temp' \ + --exclude='tmp' \ + --exclude='*.swp' \ + --exclude='*.swo' \ + --exclude='*~' \ + --exclude='.DS_Store' \ + "$SRC" "$DST" +} + +# Функция синхронизации через tar/scp +sync_with_tar() { + local SRC_DIR=$1 + local DST_DIR=$2 + local DIR_NAME=$(basename "$SRC_DIR") + + # Создаем временный архив + local TMP_TAR="/tmp/${DIR_NAME}_sync_$$.tar.gz" + + # Упаковываем директорию с исключениями + tar -czf "$TMP_TAR" \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='*.log' \ + --exclude='.env' \ + --exclude='dist' \ + --exclude='build' \ + --exclude='.next' \ + --exclude='coverage' \ + --exclude='.nyc_output' \ + --exclude='sessions' \ + --exclude='temp' \ + --exclude='tmp' \ + --exclude='*.swp' \ + --exclude='*.swo' \ + --exclude='*~' \ + --exclude='.DS_Store' \ + -C "$(dirname "$SRC_DIR")" "$DIR_NAME" + + # Копируем архив на VDS + scp -e "ssh $SSH_OPTS" "$TMP_TAR" "$VDS_USER@$VDS_HOST:/tmp/" + + # Распаковываем на VDS + ssh $SSH_OPTS $VDS_USER@$VDS_HOST "mkdir -p $DST_DIR && tar -xzf /tmp/$(basename $TMP_TAR) -C $DST_DIR --strip-components=1 && rm /tmp/$(basename $TMP_TAR)" + + # Удаляем локальный архив + rm -f "$TMP_TAR" +} + +# Синхронизация backend +echo -e "${YELLOW}📦 Синхронизация backend...${NC}" +if [ "$USE_RSYNC" = true ]; then + sync_with_rsync "./backend/" "$VDS_USER@$VDS_HOST:$VDS_PATH/backend/" +else + sync_with_tar "./backend" "$VDS_PATH/backend" +fi + +# Синхронизация frontend +echo -e "${YELLOW}📦 Синхронизация frontend...${NC}" +if [ "$USE_RSYNC" = true ]; then + sync_with_rsync "./frontend/" "$VDS_USER@$VDS_HOST:$VDS_PATH/frontend/" +else + sync_with_tar "./frontend" "$VDS_PATH/frontend" +fi + +# Синхронизация shared +echo -e "${YELLOW}📦 Синхронизация shared...${NC}" +if [ "$USE_RSYNC" = true ]; then + sync_with_rsync "./shared/" "$VDS_USER@$VDS_HOST:$VDS_PATH/shared/" +else + sync_with_tar "./shared" "$VDS_PATH/shared" +fi + +# Синхронизация docker-compose.prod.yml +echo -e "${YELLOW}📦 Синхронизация docker-compose.prod.yml...${NC}" +scp -e "ssh $SSH_OPTS" ./webssh-agent/docker-compose.prod.yml "$VDS_USER@$VDS_HOST:$VDS_PATH/docker-compose.prod.yml" + +echo -e "${GREEN}✅ Синхронизация завершена!${NC}" + +# Спрашиваем, нужно ли пересобрать образы +read -p "Пересобрать Docker образы на VDS? (y/n): " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}🔨 Пересборка Docker образов на VDS...${NC}" + ssh $SSH_OPTS $VDS_USER@$VDS_HOST "cd $VDS_PATH && docker compose -f docker-compose.prod.yml build backend frontend frontend-nginx && docker compose -f docker-compose.prod.yml up -d --force-recreate backend frontend frontend-nginx" + echo -e "${GREEN}✅ Образы пересобраны и контейнеры перезапущены!${NC}" +else + echo -e "${YELLOW}💡 Для пересборки образов выполните:${NC}" + echo -e " ssh -p $VDS_PORT $VDS_USER@$VDS_HOST" + echo -e " cd $VDS_PATH" + echo -e " docker compose -f docker-compose.prod.yml build backend frontend frontend-nginx" + echo -e " docker compose -f docker-compose.prod.yml up -d --force-recreate backend frontend frontend-nginx" +fi diff --git a/webssh-agent/agent.js b/webssh-agent/agent.js index 9810e4a..355c2b5 100644 --- a/webssh-agent/agent.js +++ b/webssh-agent/agent.js @@ -139,27 +139,34 @@ app.post('/vds/check-requirements', logRequest, async (req, res) => { const { vdsIp, ubuntuUser, + sshUser, sshHost, sshPort = 22, sshConnectUser, sshConnectPassword } = req.body; - if (!vdsIp || !ubuntuUser || !sshConnectUser || !sshConnectPassword) { + // Нормализуем значения (удаляем пробелы) + const normalizedVdsIp = String(vdsIp || '').trim(); + const normalizedSshHost = sshHost ? String(sshHost).trim() : undefined; + const normalizedSshConnectUser = String(sshConnectUser || sshUser || 'root').trim(); + const normalizedSshConnectPassword = sshConnectPassword ? String(sshConnectPassword).trim() : undefined; + + if (!normalizedVdsIp || !ubuntuUser || !normalizedSshConnectUser || !normalizedSshConnectPassword) { return res.status(400).json({ success: false, message: 'Необходимы параметры: vdsIp, ubuntuUser, sshConnectUser, sshConnectPassword' }); } - log.info(`Проверка системных требований VDS: ${vdsIp}`); + log.info(`Проверка системных требований VDS: ${normalizedVdsIp}`); const options = { - vdsIp, - sshHost, + vdsIp: normalizedVdsIp, + sshHost: normalizedSshHost, sshPort, - sshConnectUser, - sshConnectPassword + sshConnectUser: normalizedSshConnectUser, + sshConnectPassword: normalizedSshConnectPassword }; const result = await checkSystemRequirements(options); @@ -199,21 +206,27 @@ app.post('/vds/transfer-encryption-key', logRequest, async (req, res) => { sshConnectPassword } = req.body; - if (!vdsIp || !dockerUser || !sshConnectUser || !sshConnectPassword) { + // Нормализуем значения (удаляем пробелы) + const normalizedVdsIp = String(vdsIp || '').trim(); + const normalizedSshHost = sshHost ? String(sshHost).trim() : undefined; + const normalizedSshConnectUser = String(sshConnectUser || sshUser || 'root').trim(); + const normalizedSshConnectPassword = sshConnectPassword ? String(sshConnectPassword).trim() : undefined; + + if (!normalizedVdsIp || !dockerUser || !normalizedSshConnectUser || !normalizedSshConnectPassword) { return res.status(400).json({ success: false, message: 'Необходимы параметры: vdsIp, dockerUser, sshConnectUser, sshConnectPassword' }); } - log.info(`🔐 Передача ключа шифрования на VDS: ${vdsIp}`); + log.info(`🔐 Передача ключа шифрования на VDS: ${normalizedVdsIp}`); const options = { - vdsIp, - sshHost, + vdsIp: normalizedVdsIp, + sshHost: normalizedSshHost, sshPort, - sshConnectUser, - sshConnectPassword + sshConnectUser: normalizedSshConnectUser, + sshConnectPassword: normalizedSshConnectPassword }; // 1. Убеждаемся, что директория для ключа существует на VDS @@ -312,18 +325,24 @@ app.post('/vds/setup', logRequest, async (req, res) => { sshConnectPassword } = req.body; - log.info(`Настройка VDS: ${vdsIp} для домена: ${domain}`); + // Нормализуем значения (удаляем пробелы) + const normalizedVdsIp = String(vdsIp || '').trim(); + const normalizedSshHost = sshHost ? String(sshHost).trim() : undefined; + const normalizedSshConnectUser = String(sshConnectUser || sshUser || 'root').trim(); + const normalizedSshConnectPassword = sshConnectPassword ? String(sshConnectPassword).trim() : undefined; + + log.info(`Настройка VDS: ${normalizedVdsIp} для домена: ${domain}`); // Отправляем начальный статус через WebSocket sendWebSocketStatus(false, 'Начинаем настройку VDS...'); - sendWebSocketLog('info', `🚀 Начинаем настройку VDS: ${vdsIp} для домена: ${domain}`, 'init', 0); + sendWebSocketLog('info', `🚀 Начинаем настройку VDS: ${normalizedVdsIp} для домена: ${domain}`, 'init', 0); const options = { - vdsIp, - sshHost, + vdsIp: normalizedVdsIp, + sshHost: normalizedSshHost, sshPort, - sshConnectUser, - sshConnectPassword + sshConnectUser: normalizedSshConnectUser, + sshConnectPassword: normalizedSshConnectPassword }; // 0. Проверка системных требований @@ -404,6 +423,52 @@ findtime = 3600 await execSshCommand(`chown ${dockerUser}:${dockerUser} /home/${dockerUser}/dapp/ssl/keys`, options); log.success('Директория для ключа шифрования подготовлена'); + // 9.1. Передача ключа шифрования на VDS + sendWebSocketLog('info', '🔐 Передача ключа шифрования на VDS...', 'encryption_key', 36); + log.info('🔐 Передача ключа шифрования на VDS...'); + + try { + // Читаем ключ шифрования с локальной машины + const encryptionKeyPath = process.env.ENCRYPTION_KEY_PATH + || path.resolve(__dirname, '..', 'ssl', 'keys', 'full_db_encryption.key'); + + const encryptionKeyContent = await fs.readFile(encryptionKeyPath, 'utf8'); + log.success('✅ Ключ шифрования прочитан с локальной машины'); + + // Создаем временный файл с ключом + const tempKeyPath = `/tmp/encryption_key_${Date.now()}.key`; + await fs.writeFile(tempKeyPath, encryptionKeyContent); + + // Передаем файл на VDS через SCP + await execScpCommand( + tempKeyPath, + `/home/${dockerUser}/dapp/ssl/keys/full_db_encryption.key`, + options + ); + + // Удаляем временный файл + await fs.remove(tempKeyPath); + + // Устанавливаем правильные права доступа к ключу на VDS + await execSshCommand(`chown ${dockerUser}:${dockerUser} /home/${dockerUser}/dapp/ssl/keys/full_db_encryption.key`, options); + await execSshCommand(`chmod 600 /home/${dockerUser}/dapp/ssl/keys/full_db_encryption.key`, options); + + // Проверяем, что ключ успешно передан + const verifyResult = await execSshCommand(`ls -la /home/${dockerUser}/dapp/ssl/keys/full_db_encryption.key`, options); + + if (verifyResult.code === 0) { + log.success('✅ Ключ шифрования успешно передан на VDS'); + sendWebSocketLog('success', '✅ Ключ шифрования передан на VDS', 'encryption_key', 37); + } else { + throw new Error('Не удалось проверить передачу ключа шифрования'); + } + } catch (error) { + log.error('❌ Ошибка передачи ключа шифрования: ' + error.message); + sendWebSocketLog('error', '❌ Ошибка передачи ключа шифрования: ' + error.message, 'encryption_key', 37); + // Продолжаем установку, но предупреждаем пользователя + log.warn('⚠️ Внимание: ключ шифрования не передан. Backend может не запуститься без ключа.'); + } + // 10. Проверка и удаление системного nginx для избежания конфликтов портов log.info('🔍 Проверка наличия системного nginx...'); const nginxCheck = await execSshCommand('systemctl list-units --type=service --state=active,inactive | grep nginx || echo "nginx not found"', options); @@ -677,27 +742,34 @@ app.post('/vds/diagnostics', logRequest, async (req, res) => { try { const { vdsIp, + sshUser, sshHost, sshPort = 22, sshConnectUser, sshConnectPassword } = req.body; - if (!vdsIp || !sshConnectUser || !sshConnectPassword) { + // Нормализуем значения (удаляем пробелы) + const normalizedVdsIp = String(vdsIp || '').trim(); + const normalizedSshHost = sshHost ? String(sshHost).trim() : undefined; + const normalizedSshConnectUser = String(sshConnectUser || sshUser || 'root').trim(); + const normalizedSshConnectPassword = sshConnectPassword ? String(sshConnectPassword).trim() : undefined; + + if (!normalizedVdsIp || !normalizedSshConnectUser || !normalizedSshConnectPassword) { return res.status(400).json({ success: false, message: 'Необходимы параметры: vdsIp, sshConnectUser, sshConnectPassword' }); } - log.info(`Диагностика VDS: ${vdsIp}`); + log.info(`Диагностика VDS: ${normalizedVdsIp}`); const options = { - vdsIp, - sshHost, + vdsIp: normalizedVdsIp, + sshHost: normalizedSshHost, sshPort, - sshConnectUser, - sshConnectPassword + sshConnectUser: normalizedSshConnectUser, + sshConnectPassword: normalizedSshConnectPassword }; // 1. Проверка статуса системы diff --git a/webssh-agent/docker-compose.prod.yml b/webssh-agent/docker-compose.prod.yml index dc1f371..52b1fb0 100644 --- a/webssh-agent/docker-compose.prod.yml +++ b/webssh-agent/docker-compose.prod.yml @@ -137,6 +137,7 @@ services: # Также монтируем в стандартные пути для совместимости (read-only для безопасности) - /proc:/proc:ro - /sys:/sys:ro + - ~/.ssh:/root/.ssh:ro # SSH ключи для подключения к VDS # Добавляем необходимые capabilities для управления системой cap_add: - SYS_ADMIN # Для управления системой (reboot, shutdown, useradd и т.д.) diff --git a/webssh-agent/utils/cleanupUtils.js b/webssh-agent/utils/cleanupUtils.js index 9ecdfe8..0ba3749 100644 --- a/webssh-agent/utils/cleanupUtils.js +++ b/webssh-agent/utils/cleanupUtils.js @@ -7,16 +7,114 @@ const log = require('./logger'); const cleanupVdsServer = async (options) => { log.info('Очистка VDS сервера...'); - // Остановка и удаление существующих Docker контейнеров - log.info('Остановка существующих Docker контейнеров...'); - await execSshCommand('docker ps -aq | xargs -r docker stop 2>/dev/null || true', options); - await execSshCommand('docker ps -aq | xargs -r docker rm 2>/dev/null || true', options); + // Проверяем наличие Docker перед попыткой очистки + log.info('🔍 Проверка наличия Docker...'); + const dockerCheck = await execSshCommand('command -v docker >/dev/null 2>&1 && echo "docker installed" || echo "docker not installed"', options); - // Удаление Docker образов и очистка системы - log.info('Очистка Docker образов и системы...'); - await execSshCommand('docker system prune -af || true', options); - await execSshCommand('docker volume prune -f || true', options); - await execSshCommand('docker network prune -f || true', options); + // Если ошибка SSH подключения (код 255), пропускаем очистку Docker + if (dockerCheck.code === 255) { + log.warn('⚠️ Ошибка SSH подключения при проверке Docker, пропускаем очистку Docker'); + log.info('Продолжаем настройку сервера...'); + } else { + const hasDocker = dockerCheck.stdout.trim().includes('docker installed'); + + if (hasDocker) { + log.info('Docker обнаружен, выполняем полную очистку...'); + + // 1. Остановка всех Docker контейнеров (включая запущенные) + log.info('🛑 Остановка всех Docker контейнеров...'); + const stopResult = await execSshCommand('docker ps -aq | xargs -r docker stop 2>/dev/null || true', options); + if (stopResult.code !== 0 && stopResult.code !== 255) { + log.warn(`Предупреждение при остановке контейнеров: ${stopResult.stderr}`); + } + + // 2. Удаление всех контейнеров (включая остановленные) + log.info('🗑️ Удаление всех Docker контейнеров...'); + // Удаляем все контейнеры (запущенные и остановленные) + const rmResult = await execSshCommand('docker container ls -aq | xargs -r docker rm -f 2>/dev/null || true', options); + if (rmResult.code !== 0 && rmResult.code !== 255) { + log.warn(`Предупреждение при удалении контейнеров: ${rmResult.stderr}`); + } + // Дополнительная проверка и удаление любых оставшихся контейнеров + await execSshCommand('docker ps -aq 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true', options); + + // 3. Удаление всех volumes (включая именованные) + log.info('🗑️ Удаление всех Docker volumes...'); + // Сначала получаем список всех volumes + const allVolumesList = await execSshCommand('docker volume ls -q 2>/dev/null || true', options); + if (allVolumesList.stdout.trim()) { + const volumes = allVolumesList.stdout.trim().split('\n').filter(v => v); + log.info(`Найдено ${volumes.length} volumes для удаления`); + for (const volume of volumes) { + // Принудительно удаляем каждый volume + await execSshCommand(`docker volume rm -f "${volume}" 2>/dev/null || true`, options); + } + log.info(`Удалено ${volumes.length} volumes`); + } + + // 4. Дополнительная очистка неиспользуемых volumes (на случай если что-то осталось) + log.info('🧹 Финальная очистка volumes...'); + const volumePruneResult = await execSshCommand('docker volume prune -f 2>/dev/null || true', options); + if (volumePruneResult.code !== 0 && volumePruneResult.code !== 255) { + log.warn(`Предупреждение при финальной очистке volumes: ${volumePruneResult.stderr}`); + } + + // 5. Удаление всех Docker образов приложения + log.info('🗑️ Удаление старых Docker образов приложения...'); + const imagesList = await execSshCommand('docker images -q | xargs -r docker rmi -f 2>/dev/null || true', options); + // Удаляем все образы, связанные с приложением + await execSshCommand('docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "digital_legal_entity|dapp-" | xargs -r docker rmi -f 2>/dev/null || true', options); + + // 6. Полная очистка Docker системы (все образы, кэш, сети) + log.info('🧹 Полная очистка Docker системы...'); + const pruneResult = await execSshCommand('docker system prune -af --volumes 2>/dev/null || true', options); + if (pruneResult.code !== 0 && pruneResult.code !== 255) { + log.warn(`Предупреждение при очистке Docker: ${pruneResult.stderr}`); + } + + // 7. Удаление всех Docker сетей + log.info('🗑️ Удаление всех Docker сетей...'); + const networkPruneResult = await execSshCommand('docker network prune -f 2>/dev/null || true', options); + if (networkPruneResult.code !== 0 && networkPruneResult.code !== 255) { + log.warn(`Предупреждение при очистке сетей: ${networkPruneResult.stderr}`); + } + + log.success('✅ Docker полностью очищен'); + } else { + log.info('ℹ️ Docker не установлен, пропускаем очистку Docker'); + } + } + + // Удаление старых директорий приложения + log.info('🗑️ Удаление старых директорий приложения...'); + await execSshCommand('find /home -maxdepth 3 -type d -name "dapp" -exec rm -rf {} + 2>/dev/null || true', options); + await execSshCommand('find /home -maxdepth 3 -type d -name "digital_legal_entity" -exec rm -rf {} + 2>/dev/null || true', options); + + // Удаление старых docker-compose файлов + log.info('🗑️ Удаление старых конфигурационных файлов...'); + await execSshCommand('find /home -name "docker-compose*.yml" -type f -delete 2>/dev/null || true', options); + await execSshCommand('find /home -name ".env" -path "*/dapp/*" -type f -delete 2>/dev/null || true', options); + await execSshCommand('find /home -name "import-images-and-data.sh" -type f -delete 2>/dev/null || true', options); + await execSshCommand('find /home -name "renew-ssl.sh" -type f -delete 2>/dev/null || true', options); + + // Очистка старых cron задач, связанных с приложением + log.info('🗑️ Очистка старых cron задач приложения...'); + const crontabBackup = await execSshCommand('crontab -l 2>/dev/null || echo ""', options); + if (crontabBackup.stdout.trim()) { + const cleanedCrontab = crontabBackup.stdout + .split('\n') + .filter(line => !line.includes('renew-ssl.sh') && !line.includes('dapp') && !line.includes('digital_legal_entity')) + .join('\n'); + if (cleanedCrontab.trim()) { + await execSshCommand(`echo '${cleanedCrontab.replace(/'/g, "'\\''")}' | crontab -`, options); + } else { + await execSshCommand('crontab -r 2>/dev/null || true', options); + } + } + + // Очистка старых SSL сертификатов (опционально, можно оставить для повторного использования) + log.info('🧹 Проверка старых SSL сертификатов...'); + await execSshCommand('rm -rf /var/www/certbot/.well-known 2>/dev/null || true', options); // 🆕 Умная проверка и удаление системного nginx для избежания конфликтов портов log.info('🔍 Проверка наличия системного nginx...'); diff --git a/webssh-agent/utils/dockerUtils.js b/webssh-agent/utils/dockerUtils.js index 7df991e..1307d17 100644 --- a/webssh-agent/utils/dockerUtils.js +++ b/webssh-agent/utils/dockerUtils.js @@ -69,21 +69,26 @@ const exportDockerImages = async (sendWebSocketLog) => { } } - // Экспортируем данные из volumes + // Экспортируем данные из volumes (динамически определяем все volumes приложения) log.info('Экспорт данных из Docker volumes...'); sendWebSocketLog('info', '📦 Экспорт данных из Docker volumes...', 'export_data', 70); - // PostgreSQL данные - sendWebSocketLog('info', '📦 Экспорт данных PostgreSQL...', 'export_data', 72); - await exportVolumeData('digital_legal_entitydle_postgres_data', 'postgres_data.tar.gz', sendWebSocketLog, 72); + // Получаем список всех volumes приложения (без node_modules) + const volumesList = await execLocalCommand('docker volume ls -q | grep -E "digital_legal_entitydle_|dapp_" | grep -v node_modules || true'); + const volumes = volumesList.stdout.trim().split('\n').filter(v => v && v.endsWith('_data')); - // Ollama данные - sendWebSocketLog('info', '📦 Экспорт данных Ollama...', 'export_data', 75); - await exportVolumeData('digital_legal_entitydle_ollama_data', 'ollama_data.tar.gz', sendWebSocketLog, 75); + let progress = 72; + const progressStep = Math.floor(8 / Math.max(volumes.length, 1)); - // Vector Search данные - sendWebSocketLog('info', '📦 Экспорт данных Vector Search...', 'export_data', 78); - await exportVolumeData('digital_legal_entitydle_vector_search_data', 'vector_search_data.tar.gz', sendWebSocketLog, 78); + for (const volumeName of volumes) { + // Извлекаем имя файла из имени volume (например, digital_legal_entitydle_postgres_data -> postgres_data.tar.gz) + const volumeBaseName = volumeName.replace(/^(digital_legal_entitydle_|dapp_)/, '').replace(/_data$/, '_data'); + const outputFile = `${volumeBaseName}.tar.gz`; + + sendWebSocketLog('info', `📦 Экспорт данных: ${volumeName}`, 'export_data', progress); + await exportVolumeData(volumeName, outputFile, sendWebSocketLog, progress); + progress += progressStep; + } // Создаем архив с ВСЕМИ образами и данными приложения log.info('Создание архива Docker образов и данных на хосте...'); @@ -91,9 +96,11 @@ const exportDockerImages = async (sendWebSocketLog) => { try { const tarFiles = images.map(img => img.file).join(' '); - const dataFiles = 'postgres_data.tar.gz ollama_data.tar.gz vector_search_data.tar.gz'; + // Динамически собираем список файлов данных из экспортированных volumes + const dataFilesList = await execLocalCommand('ls /tmp/*_data.tar.gz 2>/dev/null | xargs -r basename -a || echo ""'); + const dataFiles = dataFilesList.stdout.trim().split('\n').filter(f => f).join(' '); - const archiveCommand = `cd /tmp && tar -czf docker-images-and-data.tar.gz ${tarFiles} ${dataFiles}`; + const archiveCommand = `cd /tmp && tar -czf docker-images-and-data.tar.gz ${tarFiles} ${dataFiles || ''}`.trim(); await execLocalCommand(archiveCommand); sendWebSocketLog('success', '✅ Архив создан успешно', 'export_data', 80); @@ -170,45 +177,33 @@ echo "📦 Распаковка архива..." tar -xzf ./docker-images-and-data.tar.gz -C ./temp-import # Импортируем ВСЕ образы приложения -echo "📦 Импорт образа postgres..." -docker load -i ./temp-import/dapp-postgres.tar +echo "📦 Импорт образов..." +for image_file in ./temp-import/dapp-*.tar; do + if [ -f "$image_file" ]; then + echo "📦 Импорт образа: $(basename $image_file)" + docker load -i "$image_file" + fi +done -echo "📦 Импорт образа ollama..." -docker load -i ./temp-import/dapp-ollama.tar - -echo "📦 Импорт образа vector-search..." -docker load -i ./temp-import/dapp-vector-search.tar - -echo "📦 Импорт образа backend..." -docker load -i ./temp-import/dapp-backend.tar - -echo "📦 Импорт образа frontend..." -docker load -i ./temp-import/dapp-frontend.tar - -echo "📦 Импорт образа frontend-nginx..." -docker load -i ./temp-import/dapp-frontend-nginx.tar - -echo "📦 Импорт образа webssh-agent..." -docker load -i ./temp-import/dapp-webssh-agent.tar - -# 🆕 Импортируем данные в volumes с правильными именами для соответствия docker-compose -echo "📦 Импорт данных PostgreSQL..." -# Удаляем старый volume если существует -docker volume rm dapp_postgres_data 2>/dev/null || true -docker volume create dapp_postgres_data -docker run --rm -v dapp_postgres_data:/data -v ./temp-import:/backup alpine tar xzf /backup/postgres_data.tar.gz -C /data - -echo "📦 Импорт данных Ollama..." -# Удаляем старый volume если существует -docker volume rm dapp_ollama_data 2>/dev/null || true -docker volume create dapp_ollama_data -docker run --rm -v dapp_ollama_data:/data -v ./temp-import:/backup alpine tar xzf /backup/ollama_data.tar.gz -C /data - -echo "📦 Импорт данных Vector Search..." -# Удаляем старый volume если существует -docker volume rm dapp_vector_search_data 2>/dev/null || true -docker volume create dapp_vector_search_data -docker run --rm -v dapp_vector_search_data:/data -v ./temp-import:/backup alpine tar xzf /backup/vector_search_data.tar.gz -C /data +# 🆕 Динамически определяем volumes для импорта из имен файлов в архиве +echo "📦 Импорт данных в volumes..." +for data_file in ./temp-import/*_data.tar.gz; do + if [ -f "$data_file" ]; then + # Извлекаем имя volume из имени файла (например, postgres_data.tar.gz -> postgres_data) + volume_name=$(basename "$data_file" .tar.gz) + + # Используем префикс dapp_ для соответствия docker-compose.prod.yml + full_volume_name="dapp_${volume_name}" + + echo "📦 Импорт данных: $full_volume_name" + # Удаляем старый volume если существует + docker volume rm -f "$full_volume_name" 2>/dev/null || true + # Создаем новый volume + docker volume create "$full_volume_name" + # Импортируем данные + docker run --rm -v "$full_volume_name:/data" -v ./temp-import:/backup alpine tar xzf "/backup/$(basename $data_file)" -C /data + fi +done # Очищаем временные файлы rm -rf ./temp-import diff --git a/webssh-agent/utils/sshUtils.js b/webssh-agent/utils/sshUtils.js index d53568e..7a1ddb2 100644 --- a/webssh-agent/utils/sshUtils.js +++ b/webssh-agent/utils/sshUtils.js @@ -35,10 +35,22 @@ const execSshCommand = async (command, options = {}) => { const privateKeyExists = await fs.pathExists(privateKeyPath); const escapedCommand = command.replace(/"/g, '\\"'); - let sshCommand = `ssh -p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${sshConnectUser}@${sshHost || vdsIp} "${escapedCommand}"`; + // Удаляем пробелы и проверяем, что значения не пустые + const user = String(sshConnectUser || 'root').trim(); + const host = String((sshHost || vdsIp || '')).trim(); + + if (!host) { + throw new Error('Не указан хост для SSH подключения (sshHost или vdsIp)'); + } + + if (!user) { + throw new Error('Не указан пользователь для SSH подключения (sshConnectUser)'); + } + + let sshCommand = `ssh -p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR ${user}@${host} "${escapedCommand}"`; if (privateKeyExists) { - sshCommand = `ssh -i "${privateKeyPath}" -p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${sshConnectUser}@${sshHost || vdsIp} "${escapedCommand}"`; + sshCommand = `ssh -i "${privateKeyPath}" -p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR ${user}@${host} "${escapedCommand}"`; } log.info(`🔍 Выполняем SSH команду: ${sshCommand}`); @@ -49,7 +61,7 @@ const execSshCommand = async (command, options = {}) => { if (error && error.code === 255 && sshConnectPassword) { log.info('SSH ключи не сработали, пробуем с паролем...'); - const passwordCommand = `sshpass -p "${sshConnectPassword}" ssh -p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${sshConnectUser}@${sshHost || vdsIp} "${escapedCommand}"`; + const passwordCommand = `sshpass -p "${String(sshConnectPassword || '').trim()}" ssh -p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR ${user}@${host} "${escapedCommand}"`; exec(passwordCommand, (passwordError, passwordStdout, passwordStderr) => { log.info(`📤 SSH с паролем результат - код: ${passwordError ? passwordError.code : 0}, stdout: "${passwordStdout}", stderr: "${passwordStderr}"`); @@ -83,17 +95,29 @@ const execScpCommand = async (sourcePath, targetPath, options = {}) => { const privateKeyExists = await fs.pathExists(privateKeyPath); - let scpCommand = `scp -P ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${sourcePath} ${sshConnectUser}@${sshHost || vdsIp}:${targetPath}`; + // Удаляем пробелы и проверяем, что значения не пустые + const user = String(sshConnectUser || 'root').trim(); + const host = String((sshHost || vdsIp || '')).trim(); + + if (!host) { + throw new Error('Не указан хост для SCP подключения (sshHost или vdsIp)'); + } + + if (!user) { + throw new Error('Не указан пользователь для SCP подключения (sshConnectUser)'); + } + + let scpCommand = `scp -P ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR ${sourcePath} ${user}@${host}:${targetPath}`; if (privateKeyExists) { - scpCommand = `scp -i "${privateKeyPath}" -P ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${sourcePath} ${sshConnectUser}@${sshHost || vdsIp}:${targetPath}`; + scpCommand = `scp -i "${privateKeyPath}" -P ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR ${sourcePath} ${user}@${host}:${targetPath}`; } return new Promise((resolve) => { exec(scpCommand, (error, stdout, stderr) => { if (error && error.code === 255 && sshConnectPassword) { log.info('SCP с ключами не сработал, пробуем с паролем...'); - const passwordScpCommand = `sshpass -p "${sshConnectPassword}" scp -P ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${sourcePath} ${sshConnectUser}@${sshHost || vdsIp}:${targetPath}`; + const passwordScpCommand = `sshpass -p "${String(sshConnectPassword || '').trim()}" scp -P ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR ${sourcePath} ${user}@${host}:${targetPath}`; exec(passwordScpCommand, (passwordError, passwordStdout, passwordStderr) => { if (passwordError) {