/** * Copyright (c) 2024-2026 Тарабанов Александр Викторович * All rights reserved. * * This software is proprietary and confidential. * Unauthorized copying, modification, or distribution is prohibited. * * For licensing inquiries: info@hb3-accelerator.com * Website: https://hb3-accelerator.com * GitHub: https://github.com/VC-HB3-Accelerator */ const express = require('express'); const router = express.Router(); const { exec } = require('child_process'); const { promisify } = require('util'); const logger = require('../utils/logger'); const { requireAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { PERMISSIONS } = require('../shared/permissions'); const db = require('../db'); 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 * encryptedDb.getData автоматически расшифровывает поля с суффиксом _encrypted * и возвращает их БЕЗ суффикса (например, domain_encrypted -> domain) */ router.get('/settings', async (req, res) => { try { const rows = await encryptedDb.getData('vds_settings', {}, 1); if (!rows || rows.length === 0) { return res.json({ success: true, settings: null }); } const row = rows[0]; // encryptedDb.getData возвращает расшифрованные поля БЕЗ суффикса _encrypted // Например: domain_encrypted в БД -> row.domain в результате return res.json({ success: true, settings: { domain: row.domain || '', email: row.email || '', ubuntuUser: row.ubuntu_user || 'ubuntu', dockerUser: row.docker_user || 'docker', sshHost: row.ssh_host || '', sshPort: row.ssh_port || 22, sshUser: row.ssh_user || 'root', sslProvider: row.ssl_provider || 'letsencrypt', dappPath: row.dapp_path || null // Будет вычисляться динамически на основе dockerUser // sshPassword не возвращаем по соображениям безопасности } }); } catch (error) { logger.error('[VDS] Ошибка получения настроек через encryptedDb:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Сохранить настройки VDS * ⚠️ ВРЕМЕННО без requireAuth/requirePermission, чтобы настройки из формы WebSSH * гарантированно сохранялись в таблицу vds_settings даже при проблемах с сессией. */ router.post('/settings', async (req, res) => { try { const { domain, email, ubuntuUser, dockerUser, sshHost, sshPort, sshUser, sshPassword, sslProvider, dappPath } = req.body; // Логируем входящие данные (без пароля), чтобы видеть попытки сохранения даже при LOG_LEVEL=warn logger.warn('[VDS] Запрос на сохранение настроек VDS (без пароля):', { domain, email, ubuntuUser, dockerUser, sshHost, sshPort, sshUser }); // Если передан только домен (для обратной совместимости) if (domain && !email && !sshHost) { const normalizedDomain = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, ''); const settings = { domain: normalizedDomain, // encryptedDb автоматически найдет domain_encrypted и зашифрует updated_at: new Date() }; await saveVdsSettingsToDb(settings); updateDomainCache(normalizedDomain); logger.info(`[VDS] Домен сохранен: ${normalizedDomain}`); return res.json({ success: true, domain: normalizedDomain }); } // Валидация обязательных полей (пароль опционален при обновлении) if (!domain || !email || !ubuntuUser || !dockerUser || !sshHost || !sshPort || !sshUser) { logger.warn('[VDS] Ошибка валидации настроек VDS: не заполнены обязательные поля', { hasDomain: !!domain, hasEmail: !!email, hasUbuntuUser: !!ubuntuUser, hasDockerUser: !!dockerUser, hasSshHost: !!sshHost, hasSshPort: !!sshPort, hasSshUser: !!sshUser }); return res.status(400).json({ success: false, error: 'Все поля обязательны для заполнения (кроме пароля при обновлении)' }); } // Нормализуем домен const normalizedDomain = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, ''); // Проверяем существующие настройки (для валидации пароля) const existing = await encryptedDb.getData('vds_settings', {}, 1); // Подготавливаем данные для сохранения // encryptedDb.saveData ожидает ключи БЕЗ суффикса _encrypted // Сервис автоматически определит зашифрованные колонки и добавит суффикс const settings = { domain: normalizedDomain, // encryptedDb автоматически найдет domain_encrypted и зашифрует email: email.trim(), ubuntu_user: ubuntuUser.trim(), docker_user: dockerUser.trim(), ssh_host: sshHost.trim(), ssh_port: parseInt(sshPort, 10), ssh_user: sshUser.trim(), ssl_provider: 'letsencrypt', // Используем только Let's Encrypt (работает без аккаунта) dapp_path: (dappPath && dappPath.trim()) ? dappPath.trim() : null, // null означает использование значения по умолчанию updated_at: new Date() }; // Пароль добавляем только если он указан (при обновлении можно не менять) if (sshPassword !== undefined && sshPassword !== null && sshPassword.trim() !== '') { settings.ssh_password = sshPassword; // encryptedDb автоматически найдет ssh_password_encrypted и зашифрует } else if (existing.length === 0) { // При создании пароль обязателен logger.warn('[VDS] Ошибка валидации настроек VDS: отсутствует SSH пароль при первой настройке'); return res.status(400).json({ success: false, error: 'SSH пароль обязателен при первой настройке' }); } // Если пароль не указан (undefined/null/пустая строка) и настройки уже есть - не обновляем пароль await saveVdsSettingsToDb(settings); updateDomainCache(normalizedDomain); logger.warn(`[VDS] Настройки VDS сохранены в таблицу vds_settings для домена: ${normalizedDomain}`); res.json({ success: true, settings }); } catch (error) { logger.error('[VDS] Ошибка сохранения настроек:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Проверка доступности Docker */ async function checkDockerAvailable() { try { await execAsync('docker --version'); return true; } catch (error) { return false; } } /** * Получить настройки 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, ssl_provider, dapp_path 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, sslProvider: rows[0].ssl_provider || 'letsencrypt', dappPath: rows[0].dapp_path || null // Будет вычисляться динамически на основе dockerUser }; } } 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 }; } } } /** * Проверить и добавить публичный ключ на VDS, если его нет * Это нужно делать только один раз при первой настройке */ async function ensureSshKeyOnVds(settings) { const { sshHost, sshPort = 22, sshPassword } = settings; const sshUser = 'root'; const privateKeyPath = '/root/.ssh/id_rsa'; const publicKeyPath = `${privateKeyPath}.pub`; const fs = require('fs'); // Проверяем наличие ключей локально if (!fs.existsSync(privateKeyPath) || !fs.existsSync(publicKeyPath)) { logger.warn(`[VDS] SSH ключи не найдены локально: ${privateKeyPath}`); return false; } // Читаем публичный ключ const publicKey = fs.readFileSync(publicKeyPath, 'utf8').trim(); // Пробуем проверить наличие ключа на VDS через SSH с ключом const sshOptions = `-p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5`; const checkCommand = `grep -Fx "${publicKey}" /root/.ssh/authorized_keys > /dev/null 2>&1 && echo "exists" || echo "not_found"`; const sshCheckCommand = `ssh -i "${privateKeyPath}" ${sshOptions} ${sshUser}@${sshHost} "${checkCommand}"`; try { const { stdout } = await execAsync(sshCheckCommand); if (stdout.trim() === 'exists') { logger.info(`[VDS] Публичный ключ уже присутствует на VDS для ${sshUser}@${sshHost}`); return true; } } catch (error) { // Если не удалось подключиться с ключом, значит ключ не добавлен logger.warn(`[VDS] Не удалось проверить наличие ключа на VDS: ${error.message}`); } // Если ключа нет и есть пароль, добавляем его if (sshPassword && sshPassword.trim() !== '') { logger.info(`[VDS] Публичный ключ отсутствует на VDS. Пытаемся добавить через пароль...`); const addKeyCommand = `mkdir -p /root/.ssh && chmod 700 /root/.ssh && echo "${publicKey}" >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && chown root:root /root/.ssh/authorized_keys && echo "success"`; const sshAddCommand = `sshpass -p "${sshPassword.replace(/"/g, '\\"')}" ssh ${sshOptions} ${sshUser}@${sshHost} "${addKeyCommand}"`; try { const { stdout, stderr } = await execAsync(sshAddCommand); if (stdout.includes('success')) { logger.success(`[VDS] Публичный ключ успешно добавлен на VDS для ${sshUser}@${sshHost}`); return true; } } catch (error) { logger.error(`[VDS] Не удалось добавить публичный ключ на VDS: ${error.message}`); } } else { logger.warn(`[VDS] Публичный ключ отсутствует на VDS, но пароль не указан. Невозможно добавить ключ автоматически.`); } return false; } /** * Выполнить SSH команду на VDS * Использует SSH ключ из /root/.ssh/id_rsa (монтируется из ~/.ssh хоста) * ВАЖНО: Всегда используем root для подключения, так как публичный ключ добавляется для root при настройке VDS */ async function execSshCommandOnVds(command, settings) { const { sshHost, sshPort = 22 } = settings; // ВСЕГДА используем root для SSH подключения, так как публичный ключ добавляется для root // при настройке VDS через setupRootSshKeys в webssh-agent const sshUser = 'root'; // Экранируем команду для 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`; // Явно указываем путь к приватному ключу // Ключ должен быть в /root/.ssh/id_rsa (монтируется из ~/.ssh хоста через docker-compose) const privateKeyPath = '/root/.ssh/id_rsa'; const fs = require('fs'); // Проверяем существование ключа и используем его явно if (fs.existsSync(privateKeyPath)) { // Проверяем права доступа к ключу const keyStats = fs.statSync(privateKeyPath); const keyMode = (keyStats.mode & parseInt('777', 8)).toString(8); logger.info(`[VDS] SSH ключ найден: ${privateKeyPath}, права: ${keyMode}`); // Используем явный путь к ключу с опцией -i // Публичный ключ добавляется для root при настройке VDS через setupRootSshKeys const sshCommand = `ssh -i "${privateKeyPath}" ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`; logger.info(`[VDS] Используем SSH ключ: ${privateKeyPath} для подключения к ${sshUser}@${sshHost}:${sshPort}`); // Читаем публичный ключ для диагностики const publicKeyPath = `${privateKeyPath}.pub`; if (fs.existsSync(publicKeyPath)) { const publicKey = fs.readFileSync(publicKeyPath, 'utf8').trim(); logger.info(`[VDS] Публичный ключ (первые 50 символов): ${publicKey.substring(0, 50)}...`); logger.info(`[VDS] ВАЖНО: Этот публичный ключ должен быть добавлен в /root/.ssh/authorized_keys на VDS сервере ${sshHost}`); } try { logger.info(`[VDS] Выполняем SSH команду (первые 200 символов): ${sshCommand.substring(0, 200)}...`); const { stdout, stderr } = await execAsync(sshCommand, { maxBuffer: 10 * 1024 * 1024 }); // 10MB буфер logger.info(`[VDS] SSH команда выполнена успешно. stdout длина: ${stdout?.length || 0}, stderr длина: ${stderr?.length || 0}`); return { code: 0, stdout, stderr }; } catch (error) { logger.error(`[VDS] Ошибка SSH подключения с ключом ${privateKeyPath}:`, error.message); logger.error(`[VDS] Пытаемся подключиться к: ${sshUser}@${sshHost}:${sshPort}`); logger.error(`[VDS] error.code: ${error.code || 'не указан'}`); logger.error(`[VDS] error.stdout: ${error.stdout || '(пусто)'}`); logger.error(`[VDS] error.stderr: ${error.stderr || '(пусто)'}`); logger.error(`[VDS] Полная команда SSH (первые 500 символов): ${sshCommand.substring(0, 500)}...`); // Если ошибка "Permission denied", возможно ключ не добавлен на VDS // Пробуем добавить ключ автоматически (если есть пароль) const errorMessage = (error.stderr || error.message || '').toLowerCase(); if (errorMessage.includes('permission denied') || errorMessage.includes('publickey')) { logger.warn(`[VDS] Permission denied. Пробуем добавить публичный ключ на VDS...`); const keyAdded = await ensureSshKeyOnVds(settings); if (keyAdded) { // Пробуем подключиться снова try { const { stdout, stderr } = await execAsync(sshCommand, { maxBuffer: 10 * 1024 * 1024 }); logger.success(`[VDS] Подключение успешно после добавления ключа`); return { code: 0, stdout, stderr }; } catch (retryError) { logger.error(`[VDS] Ошибка SSH подключения после добавления ключа:`, retryError.message); logger.error(`[VDS] retryError.stdout: ${retryError.stdout || '(пусто)'}`); logger.error(`[VDS] retryError.stderr: ${retryError.stderr || '(пусто)'}`); } } else { logger.error(`[VDS] Не удалось добавить публичный ключ на VDS. Убедитесь, что пароль указан в настройках или выполните настройку VDS через webssh-agent.`); } } logger.error(`[VDS] Убедитесь, что публичный ключ из ${privateKeyPath}.pub добавлен в /root/.ssh/authorized_keys на VDS`); return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message }; } } else { // Если ключа нет, пробуем без явного указания (SSH сам найдет) logger.warn(`[VDS] SSH ключ не найден в ${privateKeyPath}, пробуем без явного указания`); const sshCommand = `ssh ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`; try { const { stdout, stderr } = await execAsync(sshCommand, { maxBuffer: 10 * 1024 * 1024 }); return { code: 0, stdout, stderr }; } catch (error) { logger.error(`[VDS] Ошибка SSH подключения:`, error.message); logger.error(`[VDS] error.code: ${error.code || 'не указан'}`); logger.error(`[VDS] error.stdout: ${error.stdout || '(пусто)'}`); logger.error(`[VDS] error.stderr: ${error.stderr || '(пусто)'}`); 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 vdsSettings = await getVdsSettings(); // Проверяем, есть ли настройки VDS или локальный Docker доступен if (!vdsSettings && !(await checkDockerAvailable())) { return res.json({ success: true, containers: [], message: 'VDS не настроена и Docker недоступен локально' }); } 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) { logger.error('[VDS] Ошибка получения списка контейнеров:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Перезапустить контейнер */ router.post('/containers/:name/restart', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; if (!name) { return res.status(400).json({ success: false, error: 'Имя контейнера обязательно' }); } 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) { logger.error(`[VDS] Ошибка перезапуска контейнера ${req.params.name}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Перезапустить все контейнеры */ router.post('/containers/restart-all', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { 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 }); } 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) { logger.error('[VDS] Ошибка перезапуска всех контейнеров:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Пересобрать контейнер */ router.post('/containers/:name/rebuild', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; if (!name) { return res.status(400).json({ success: false, error: 'Имя контейнера обязательно' }); } // Получаем информацию о контейнере 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 execDockerCommand(`docker stop ${name}`); // Удаляем контейнер await execDockerCommand(`docker rm ${name}`); // Пересобираем образ (если есть Dockerfile) // Для простоты просто пересоздаем контейнер из образа 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} пересобран` }); } catch (error) { logger.error(`[VDS] Ошибка пересборки контейнера ${req.params.name}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Получить статистику системы (CPU, RAM, трафик) */ router.get('/stats', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { period = '1h' } = req.query; // 1h, 6h, 24h, 7d // Получаем текущую статистику CPU и RAM с VDS сервера let cpuUsage = 0; let ramUsage = 0; let ramTotal = 0; let ramUsed = 0; let totalTraffic = 0; let rxBytes = 0; let txBytes = 0; let cpuCores = 0; // Получаем статистику по контейнерам (если Docker доступен) let containers = []; const vdsSettings = await getVdsSettings(); // Если есть настройки VDS, выполняем команды на VDS сервере if (vdsSettings) { try { // 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; } const responseData = { success: true, stats: { cpu: { usage: cpuUsage, cores: cpuCores || require('os').cpus().length }, ram: { usage: ramUsage, 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 rx: rxBytes / 1024 / 1024, // MB tx: txBytes / 1024 / 1024 // MB }, 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 }); } }); /** * Остановить контейнер */ router.post('/containers/:name/stop', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; 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) { logger.error(`[VDS] Ошибка остановки контейнера ${req.params.name}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Запустить контейнер */ router.post('/containers/:name/start', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; 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) { logger.error(`[VDS] Ошибка запуска контейнера ${req.params.name}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Удалить контейнер */ router.delete('/containers/:name', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; 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) { logger.error(`[VDS] Ошибка удаления контейнера ${req.params.name}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Получить логи контейнера */ router.get('/containers/:name/logs', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; const { tail = 100 } = req.query; 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 }); } }); /** * Получить статистику контейнера */ router.get('/containers/:name/stats', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { name } = req.params; 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); res.status(500).json({ success: false, error: error.message }); } }); /** * Очистить Docker (удалить неиспользуемые образы, контейнеры, сети) */ router.post('/docker/cleanup', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { type = 'all' } = req.body; // all, images, containers, volumes, networks let command; switch (type) { case 'images': command = 'docker image prune -a -f'; break; case 'containers': command = 'docker container prune -f'; break; case 'volumes': command = 'docker volume prune -f'; break; case 'networks': command = 'docker network prune -f'; break; default: command = 'docker system prune -a -f'; } const { stdout } = await execAsync(command); logger.info(`[VDS] Docker очистка выполнена: ${type}`); res.json({ success: true, message: `Очистка Docker (${type}) выполнена`, output: stdout }); } catch (error) { logger.error('[VDS] Ошибка очистки Docker:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Перезагрузить сервер */ router.post('/server/reboot', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { res.json({ success: true, message: 'Сервер будет перезагружен через 5 секунд' }); setTimeout(() => { execAsync('reboot').catch(err => logger.error('[VDS] Ошибка перезагрузки:', err)); }, 5000); } catch (error) { logger.error('[VDS] Ошибка перезагрузки сервера:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Выключить сервер */ router.post('/server/shutdown', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { res.json({ success: true, message: 'Сервер будет выключен через 5 секунд' }); setTimeout(() => { execAsync('shutdown -h now').catch(err => logger.error('[VDS] Ошибка выключения:', err)); }, 5000); } catch (error) { logger.error('[VDS] Ошибка выключения сервера:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Обновить систему */ router.post('/server/update', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { stdout } = await execAsync('apt update && apt upgrade -y'); logger.info('[VDS] Система обновлена'); res.json({ success: true, message: 'Система обновлена', output: stdout }); } catch (error) { logger.error('[VDS] Ошибка обновления системы:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Получить системные логи */ router.get('/server/logs', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { type = 'syslog', lines = 100 } = req.query; // syslog, journalctl, auth let command; switch (type) { case 'journalctl': command = `journalctl -n ${lines} --no-pager`; break; case 'auth': command = `tail -n ${lines} /var/log/auth.log`; break; default: command = `tail -n ${lines} /var/log/syslog`; } const { stdout } = await execAsync(command); res.json({ success: true, logs: stdout, type }); } catch (error) { logger.error('[VDS] Ошибка получения системных логов:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Получить информацию о диске */ router.get('/server/disk', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { stdout: df } = await execAsync('df -h'); const { stdout: du } = await execAsync('du -sh / 2>/dev/null || echo "N/A"'); res.json({ success: true, disk: { df, du } }); } catch (error) { logger.error('[VDS] Ошибка получения информации о диске:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Получить список процессов */ router.get('/server/processes', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { stdout } = await execAsync('ps aux --sort=-%cpu | head -20'); res.json({ success: true, processes: stdout }); } catch (error) { logger.error('[VDS] Ошибка получения списка процессов:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Создать пользователя */ router.post('/users/create', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { username, password, addToDocker = false } = req.body; if (!username || !password) { return res.status(400).json({ success: false, error: 'Имя пользователя и пароль обязательны' }); } let command = `useradd -m -s /bin/bash ${username}`; if (addToDocker) { command += ` && usermod -aG docker ${username}`; } await execAsync(command); await execAsync(`echo "${username}:${password}" | chpasswd`); logger.info(`[VDS] Пользователь ${username} создан`); res.json({ success: true, message: `Пользователь ${username} создан` }); } catch (error) { logger.error('[VDS] Ошибка создания пользователя:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Удалить пользователя */ router.delete('/users/:username', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { username } = req.params; const { removeHome = false } = req.query; const command = removeHome ? `userdel -r ${username}` : `userdel ${username}`; await execAsync(command); logger.info(`[VDS] Пользователь ${username} удален`); res.json({ success: true, message: `Пользователь ${username} удален` }); } catch (error) { logger.error(`[VDS] Ошибка удаления пользователя ${req.params.username}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Изменить пароль пользователя */ router.post('/users/:username/password', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { username } = req.params; const { password } = req.body; if (!password) { return res.status(400).json({ success: false, error: 'Пароль обязателен' }); } await execAsync(`echo "${username}:${password}" | chpasswd`); logger.info(`[VDS] Пароль пользователя ${username} изменен`); res.json({ success: true, message: `Пароль пользователя ${username} изменен` }); } catch (error) { logger.error(`[VDS] Ошибка изменения пароля пользователя ${req.params.username}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Получить список пользователей */ router.get('/users', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { stdout } = await execAsync('cat /etc/passwd | awk -F: \'{print $1, $3, $7}\''); const users = stdout.trim().split('\n').map(line => { const [username, uid, shell] = line.split(' '); return { username, uid, shell }; }); res.json({ success: true, users }); } catch (error) { logger.error('[VDS] Ошибка получения списка пользователей:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Получить SSH ключи пользователя */ router.get('/users/:username/ssh-keys', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { username } = req.params; const { stdout } = await execAsync(`cat /home/${username}/.ssh/authorized_keys 2>/dev/null || echo ""`); const keys = stdout.trim().split('\n').filter(k => k.trim()).map((key, index) => ({ id: index + 1, key: key.trim() })); res.json({ success: true, keys }); } catch (error) { logger.error(`[VDS] Ошибка получения SSH ключей пользователя ${req.params.username}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Добавить SSH ключ пользователю */ router.post('/users/:username/ssh-keys', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { username } = req.params; const { key } = req.body; if (!key) { return res.status(400).json({ success: false, error: 'SSH ключ обязателен' }); } await execAsync(`mkdir -p /home/${username}/.ssh`); await execAsync(`chmod 700 /home/${username}/.ssh`); await execAsync(`echo "${key}" >> /home/${username}/.ssh/authorized_keys`); await execAsync(`chmod 600 /home/${username}/.ssh/authorized_keys`); await execAsync(`chown -R ${username}:${username} /home/${username}/.ssh`); logger.info(`[VDS] SSH ключ добавлен пользователю ${username}`); res.json({ success: true, message: `SSH ключ добавлен пользователю ${username}` }); } catch (error) { logger.error(`[VDS] Ошибка добавления SSH ключа пользователю ${req.params.username}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Удалить SSH ключ пользователя */ router.delete('/users/:username/ssh-keys/:keyId', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { username, keyId } = req.params; const keyIndex = parseInt(keyId, 10) - 1; const { stdout } = await execAsync(`cat /home/${username}/.ssh/authorized_keys`); const keys = stdout.trim().split('\n').filter(k => k.trim()); if (keyIndex < 0 || keyIndex >= keys.length) { return res.status(400).json({ success: false, error: 'Неверный ID ключа' }); } keys.splice(keyIndex, 1); await execAsync(`echo "${keys.join('\n')}" > /home/${username}/.ssh/authorized_keys`); await execAsync(`chmod 600 /home/${username}/.ssh/authorized_keys`); logger.info(`[VDS] SSH ключ удален у пользователя ${username}`); res.json({ success: true, message: `SSH ключ удален у пользователя ${username}` }); } catch (error) { logger.error(`[VDS] Ошибка удаления SSH ключа пользователя ${req.params.username}:`, error); res.status(500).json({ success: false, error: error.message }); } }); /** * Создать бэкап базы данных */ router.post('/backup/create', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupFile = `/tmp/backup-${timestamp}.sql`; // Получаем настройки БД из переменных окружения или конфига const dbName = process.env.DB_NAME || 'dapp_db'; const dbUser = process.env.DB_USER || 'dapp_user'; await execAsync(`PGPASSWORD=${process.env.DB_PASSWORD || ''} pg_dump -U ${dbUser} -d ${dbName} > ${backupFile}`); logger.info(`[VDS] Бэкап создан: ${backupFile}`); res.json({ success: true, message: 'Бэкап создан', file: backupFile }); } catch (error) { logger.error('[VDS] Ошибка создания бэкапа:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Отправить бэкап на локальную машину */ router.post('/backup/send', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { file, localHost, localUser, localPath, sshKeyPath } = req.body; if (!file || !localHost || !localUser || !localPath) { return res.status(400).json({ success: false, error: 'Файл, локальный хост, пользователь и путь обязательны' }); } let command; if (sshKeyPath) { command = `scp -i ${sshKeyPath} ${file} ${localUser}@${localHost}:${localPath}`; } else { command = `scp ${file} ${localUser}@${localHost}:${localPath}`; } const { stdout } = await execAsync(command); logger.info(`[VDS] Бэкап отправлен на ${localHost}:${localPath}`); res.json({ success: true, message: 'Бэкап отправлен', output: stdout }); } catch (error) { logger.error('[VDS] Ошибка отправки бэкапа:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Проверить и обновить 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; // Используем только Let's Encrypt (работает без аккаунта) const sslProvider = 'letsencrypt'; // Используем путь из настроек или значение по умолчанию на основе dockerUser let dappPath = vdsSettings.dappPath || `/home/${dockerUser}/dapp`; // Проверяем существование пути и файла docker-compose.prod.yml const pathCheckResult = await execDockerCommand(`test -d ${dappPath} && test -f ${dappPath}/docker-compose.prod.yml && echo "exists" || echo "not_exists"`); if (pathCheckResult.stdout && pathCheckResult.stdout.includes('not_exists')) { logger.warn(`[VDS] Путь ${dappPath} или файл docker-compose.prod.yml не найден, ищем...`); // Ищем docker-compose.prod.yml на VDS const findResult = await execDockerCommand(`find /home /root -name "docker-compose.prod.yml" -type f 2>/dev/null | head -1`); if (findResult.stdout && findResult.stdout.trim()) { const foundPath = findResult.stdout.trim().replace('/docker-compose.prod.yml', ''); logger.info(`[VDS] Найден docker-compose.prod.yml в: ${foundPath}`); dappPath = foundPath; } else { logger.error(`[VDS] docker-compose.prod.yml не найден на VDS сервере`); return res.status(400).json({ success: false, error: `Путь ${dappPath} не существует или файл docker-compose.prod.yml не найден. Проверьте настройки VDS и укажите правильный путь.` }); } } // Используем только Let's Encrypt (работает без аккаунта) logger.info(`[VDS] Используем провайдер SSL: Let's Encrypt, путь: ${dappPath}`); // Проверяем статус сертификата через Docker certbot const checkCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`; const checkResult = await execDockerCommand(checkCommand); if (checkResult.code !== 0) { logger.warn('[VDS] Ошибка проверки сертификатов:', checkResult.stderr); } let hasValidCert = false; if (checkResult.stdout && checkResult.stdout.includes(domain)) { const certLines = checkResult.stdout.split('\n'); for (let i = 0; i < certLines.length; i++) { if (certLines[i].includes('Domains:') && certLines[i].includes(domain)) { for (let j = i + 1; j < Math.min(i + 10, certLines.length); j++) { if (certLines[j].includes('Expiry Date:')) { const expiryDateStr = certLines[j].split('Expiry Date:')[1]?.trim(); if (expiryDateStr) { const expiryDate = new Date(expiryDateStr); const now = new Date(); if (expiryDate > new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)) { hasValidCert = true; logger.info(`[VDS] Найден действующий сертификат для ${domain}, истекает: ${expiryDateStr}`); } } break; } } break; } } } // Пытаемся обновить сертификат через Docker certbot logger.info('[VDS] Обновление SSL сертификата...'); // Сначала пробуем renew (без --force-renewal) для обновления существующего сертификата const renewCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot renew --non-interactive 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot renew --non-interactive 2>&1 || certbot renew --non-interactive 2>&1`; let renewResult = await execDockerCommand(renewCommand); if (hasValidCert && renewResult.code === 0) { logger.info('[VDS] Используем существующий валидный сертификат'); const reloadResult = await execDockerCommand(`cd ${dappPath} && (docker compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || docker-compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || systemctl reload nginx 2>&1)`); logger.info('[VDS] SSL сертификат обновлен (renew)'); return res.json({ success: true, message: 'SSL сертификат обновлен (использован существующий)', output: renewResult.stdout, reloadOutput: reloadResult.stdout }); } // Если renew не сработал (сертификат не найден или другая ошибка), создаем новый if (!hasValidCert && (renewResult.code !== 0 || renewResult.stdout.includes('No renewals were attempted') || renewResult.stdout.includes('No certs found'))) { logger.info('[VDS] Renew не сработал и нет валидного сертификата, создаем новый...'); // Удаляем только сертификаты с суффиксами, основной оставляем const certListResult = await execDockerCommand(checkCommand); if (certListResult.stdout) { const lines = certListResult.stdout.split('\n'); const certNames = []; for (let i = 0; i < lines.length; i++) { if (lines[i].includes('Certificate Name:')) { const certName = lines[i].split('Certificate Name:')[1]?.trim(); // Удаляем только сертификаты с суффиксами (например, hb3-accelerator.com-0001, hb3-accelerator.com-0002) if (certName && certName !== domain && certName.startsWith(domain + '-')) { certNames.push(certName); } } } // Удаляем только сертификаты с суффиксами for (const certName of certNames) { logger.info(`[VDS] Удаление старого сертификата с суффиксом: ${certName}`); const deleteCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || true`; await execDockerCommand(deleteCommand); } } // Создаем новый сертификат только если его нет const email = vdsSettings.email || 'admin@example.com'; const certCommand = `cd ${dappPath} && (docker compose -f docker-compose.prod.yml run --rm certbot certonly --webroot --webroot-path=/var/www/certbot --email ${email} --agree-tos --no-eff-email --non-interactive -d ${domain} 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot certonly --webroot --webroot-path=/var/www/certbot --email ${email} --agree-tos --no-eff-email --non-interactive -d ${domain} 2>&1 || certbot certonly --webroot --webroot-path=/var/www/certbot --email ${email} --agree-tos --no-eff-email --non-interactive -d ${domain} 2>&1)`; logger.info(`[VDS] Команда создания сертификата: ${certCommand.substring(0, 300)}...`); renewResult = await execDockerCommand(certCommand); logger.info(`[VDS] Результат создания сертификата: code=${renewResult.code}, stdout длина=${renewResult.stdout?.length || 0}, stderr длина=${renewResult.stderr?.length || 0}`); if (renewResult.code !== 0 && (renewResult.stderr || renewResult.stdout)) { const errorOutput = (renewResult.stderr || renewResult.stdout).toLowerCase(); if (errorOutput.includes('too many certificates') || errorOutput.includes('rate limit')) { logger.error('[VDS] Превышен лимит Let\'s Encrypt для домена'); return res.status(429).json({ success: false, error: 'Превышен лимит Let\'s Encrypt: слишком много сертификатов выпущено за последние 7 дней. Пожалуйста, подождите до указанной даты или используйте существующий сертификат.', details: renewResult.stderr || renewResult.stdout, rateLimit: true }); } } } else if (hasValidCert) { logger.info('[VDS] Используем существующий валидный сертификат (renew не требуется)'); return res.json({ success: true, message: 'Используется существующий валидный SSL сертификат', existingCert: true }); } if (renewResult.code === 0) { // Перезапускаем nginx для применения нового сертификата const reloadResult = await execDockerCommand(`cd ${dappPath} && (docker compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || docker-compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || systemctl reload nginx 2>&1)`); // Очищаем старые сертификаты с суффиксами, чтобы они не накапливались logger.info('[VDS] Очистка старых сертификатов с суффиксами...'); const certListAfter = await execDockerCommand(checkCommand); if (certListAfter.stdout) { const lines = certListAfter.stdout.split('\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].includes('Certificate Name:')) { const certName = lines[i].split('Certificate Name:')[1]?.trim(); // Удаляем сертификаты с суффиксами (например, hb3-accelerator.com-0001, hb3-accelerator.com-0002) if (certName && certName !== domain && certName.startsWith(domain + '-')) { logger.info(`[VDS] Удаление старого сертификата с суффиксом: ${certName}`); const deleteCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || true`; await execDockerCommand(deleteCommand); } } } } logger.info('[VDS] SSL сертификат обновлен'); res.json({ success: true, message: 'SSL сертификат обновлен', output: renewResult.stdout, reloadOutput: reloadResult.stdout }); } else { logger.error('[VDS] Ошибка обновления SSL сертификата:', renewResult.stderr || renewResult.stdout || 'Неизвестная ошибка'); const errorDetails = renewResult.stderr || renewResult.stdout || 'Неизвестная ошибка'; const errorMessage = `Command failed: ${errorDetails}`; const errorOutput = errorDetails.toLowerCase(); if (errorOutput.includes('too many certificates') || errorOutput.includes('rate limit')) { return res.status(429).json({ success: false, error: 'Превышен лимит Let\'s Encrypt: слишком много сертификатов выпущено за последние 7 дней. Пожалуйста, подождите до указанной даты или используйте существующий сертификат.', details: errorMessage, stdout: renewResult.stdout || '', stderr: renewResult.stderr || '', code: renewResult.code || 1, rateLimit: true }); } res.status(500).json({ success: false, error: 'Не удалось обновить SSL сертификат', details: errorMessage, stdout: renewResult.stdout || '', stderr: renewResult.stderr || '', code: renewResult.code || 1 }); } } catch (error) { logger.error('[VDS] Ошибка обновления SSL сертификата:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Проверить путь к docker-compose на VDS */ router.get('/check-dapp-path', 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 configuredPath = vdsSettings.dappPath || `/home/${dockerUser}/dapp`; // Проверяем указанный путь const pathCheck = await execDockerCommand(`test -d ${configuredPath} && test -f ${configuredPath}/docker-compose.prod.yml && echo "exists" || echo "not_exists"`); // Ищем docker-compose.prod.yml на VDS const findResult = await execDockerCommand(`find /home /root -name "docker-compose.prod.yml" -type f 2>/dev/null`); const foundPaths = findResult.stdout ? findResult.stdout.trim().split('\n').filter(p => p).map(p => p.replace('/docker-compose.prod.yml', '')) : []; res.json({ success: true, configuredPath, configuredPathExists: pathCheck.stdout && pathCheck.stdout.includes('exists'), foundPaths: foundPaths, recommendedPath: foundPaths.length > 0 ? foundPaths[0] : null }); } catch (error) { logger.error('[VDS] Ошибка проверки пути:', 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; // Используем путь из настроек или значение по умолчанию (проверено: /home/docker/dapp) let dappPath = vdsSettings.dappPath || `/home/${dockerUser}/dapp`; // Проверяем существование пути и файла docker-compose.prod.yml const pathCheckResult = await execDockerCommand(`test -d ${dappPath} && test -f ${dappPath}/docker-compose.prod.yml && echo "exists" || echo "not_exists"`); if (pathCheckResult.stdout && pathCheckResult.stdout.includes('not_exists')) { logger.warn(`[VDS] Путь ${dappPath} или файл docker-compose.prod.yml не найден, ищем...`); // Ищем docker-compose.prod.yml на VDS const findResult = await execDockerCommand(`find /home /root -name "docker-compose.prod.yml" -type f 2>/dev/null | head -1`); if (findResult.stdout && findResult.stdout.trim()) { const foundPath = findResult.stdout.trim().replace('/docker-compose.prod.yml', ''); logger.info(`[VDS] Найден docker-compose.prod.yml в: ${foundPath}`); dappPath = foundPath; } else { logger.error(`[VDS] docker-compose.prod.yml не найден на VDS сервере`); return res.status(400).json({ success: false, error: `Путь ${dappPath} не существует или файл docker-compose.prod.yml не найден. Проверьте настройки VDS и укажите правильный путь.` }); } } // Используем только Let's Encrypt (работает без аккаунта) // Проверяем статус сертификата через Docker certbot const checkCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`; const checkResult = await execDockerCommand(checkCommand); // Проверяем срок действия сертификата let certInfo = null; if (domain && checkResult.stdout) { // Парсим вывод certbot certificates для поиска сертификата по домену // Ищем строку с "Domains:" содержащую наш домен, затем ищем "Certificate Path:" const certLines = checkResult.stdout.split('\n'); let certPath = null; let certName = null; for (let i = 0; i < certLines.length; i++) { const line = certLines[i]; // Ищем сертификат по домену if (line.includes('Domains:') && line.includes(domain)) { // Нашли сертификат для нашего домена, ищем путь в следующих строках for (let j = i + 1; j < Math.min(i + 10, certLines.length); j++) { if (certLines[j].includes('Certificate Path:')) { certPath = certLines[j].split('Certificate Path:')[1]?.trim(); // Заменяем fullchain.pem на cert.pem для проверки certPath = certPath.replace('/fullchain.pem', '/cert.pem'); break; } if (certLines[j].includes('Certificate Name:')) { certName = certLines[j].split('Certificate Name:')[1]?.trim(); } } break; } } // Если нашли путь, проверяем сертификат if (certPath) { const certCheckResult = await execDockerCommand(`openssl x509 -in ${certPath} -noout -dates -subject -issuer 2>&1 || echo "Certificate not found"`); if (certCheckResult.code === 0 && !certCheckResult.stdout.includes('not found') && !certCheckResult.stdout.includes('No such file')) { certInfo = { exists: true, details: certCheckResult.stdout, certName: certName, certPath: certPath }; } else { certInfo = { exists: false, error: certCheckResult.stdout || 'Сертификат не найден' }; } } else { certInfo = { exists: false, error: 'Сертификат не найден для домена ' + domain + ' в выводе certbot certificates' }; } } // Парсим все сертификаты из вывода certbot certificates const allCertificates = []; if (checkResult.stdout) { const lines = checkResult.stdout.split('\n'); let currentCert = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.includes('Certificate Name:')) { if (currentCert) { allCertificates.push(currentCert); } currentCert = { name: line.split('Certificate Name:')[1]?.trim(), domains: [], expiryDate: null, certPath: null, keyPath: null }; } else if (currentCert) { if (line.includes('Domains:')) { currentCert.domains = line.split('Domains:')[1]?.trim().split(/\s+/); } else if (line.includes('Expiry Date:')) { currentCert.expiryDate = line.split('Expiry Date:')[1]?.trim(); } else if (line.includes('Certificate Path:')) { currentCert.certPath = line.split('Certificate Path:')[1]?.trim(); } else if (line.includes('Private Key Path:')) { currentCert.keyPath = line.split('Private Key Path:')[1]?.trim(); } } } if (currentCert) { allCertificates.push(currentCert); } } logger.info(`[VDS] SSL статус проверен: найдено сертификатов: ${allCertificates.length}, домен: ${domain}`); res.json({ success: true, certificates: checkResult.stdout, allCertificates: allCertificates, domain: domain, certInfo: certInfo, hasCertificates: allCertificates.length > 0 }); } catch (error) { logger.error('[VDS] Ошибка проверки SSL сертификата:', error); logger.error('[VDS] Детали ошибки:', { message: error.message, stack: error.stack, code: error.code }); res.status(500).json({ success: false, error: error.message, details: process.env.NODE_ENV === 'development' ? error.stack : undefined }); } }); /** * Получить историю статистики (для графиков) */ router.get('/stats/history', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { try { const { period = '1h' } = req.query; // 1h, 6h, 24h, 7d // Пока возвращаем пустую историю, так как нужно настроить сбор данных // В будущем можно использовать временную БД или файлы для хранения истории res.json({ success: true, history: { cpu: [], ram: [], traffic: [] }, period }); } catch (error) { logger.error('[VDS] Ошибка получения истории статистики:', error); res.status(500).json({ success: false, error: error.message }); } }); module.exports = router;