From 794cf1dcee80f91ceb728a22f5ca40a2f82eb0e4 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 14 Nov 2025 21:51:09 +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/app.js | 38 +- backend/routes/auth.js | 50 +- backend/routes/vds.js | 822 +++++++ backend/services/UniversalGuestService.js | 3 +- backend/services/consentService.js | 67 +- backend/services/unifiedMessageProcessor.js | 3 +- frontend/package.json | 1 + frontend/src/components/WebSshForm.vue | 26 + frontend/src/router/index.js | 7 +- frontend/src/views/CrmView.vue | 2 +- frontend/src/views/VdsManagementView.vue | 2034 +++++++++++++++++ frontend/src/views/VdsMockView.vue | 477 ---- .../src/views/settings/WebSshSettingsView.vue | 2 +- frontend/yarn.lock | 12 + webssh-agent/docker-compose.prod.yml | 13 + 15 files changed, 3057 insertions(+), 500 deletions(-) create mode 100644 backend/routes/vds.js create mode 100644 frontend/src/views/VdsManagementView.vue delete mode 100644 frontend/src/views/VdsMockView.vue diff --git a/backend/app.js b/backend/app.js index c6c9268..613cc01 100644 --- a/backend/app.js +++ b/backend/app.js @@ -22,12 +22,32 @@ const errorHandler = require('./middleware/errorHandler'); // const { version } = require('./package.json'); // Закомментировано, так как не используется const db = require('./db'); // Добавляем импорт db const aiAssistant = require('./services/ai-assistant'); // Добавляем импорт aiAssistant +const encryptedDb = require('./services/encryptedDatabaseService'); // Добавляем импорт encryptedDb // Инициализация AI Assistant из БД aiAssistant.initPromise.catch(error => { logger.error('[app.js] AI Assistant не инициализирован:', error.message); }); +// Загрузка домена из БД при старте backend +async function loadDomainFromDB() { + try { + const settings = await encryptedDb.getData('vds_settings', {}, 1); + if (settings.length > 0 && settings[0].domain) { + const domain = settings[0].domain; + process.env.BASE_URL = `https://${domain}`; + logger.info(`[app.js] Домен загружен из БД: ${process.env.BASE_URL}`); + } else { + logger.info('[app.js] Домен не найден в БД, используется значение по умолчанию'); + } + } catch (error) { + logger.error('[app.js] Ошибка загрузки домена из БД:', error); + } +} + +// Загружаем домен при старте +loadDomainFromDB(); + const deploymentWebSocketService = require('./services/deploymentWebSocketService'); // WebSocket для деплоя const fs = require('fs'); const path = require('path'); @@ -109,13 +129,16 @@ const compileRoutes = require('./routes/compile'); // Компиляция ко const { router: dleHistoryRoutes } = require('./routes/dleHistory'); // Расширенная история const systemRoutes = require('./routes/system'); // Добавляем импорт маршрутов системного мониторинга const consentRoutes = require('./routes/consent'); // Добавляем импорт маршрутов согласий +const vdsRoutes = require('./routes/vds'); // Добавляем импорт маршрутов VDS управления const app = express(); // Указываем хост явно app.set('host', '0.0.0.0'); app.set('port', process.env.PORT || 8000); -app.set('trust proxy', true); +// Настраиваем trust proxy: в продакшне доверяем nginx (1 прокси), в dev - не доверяем +const isProduction = process.env.NODE_ENV === 'production'; +app.set('trust proxy', isProduction ? 1 : false); // Настройка CORS const corsOrigins = process.env.NODE_ENV === 'production' @@ -181,8 +204,7 @@ app.use((req, res, next) => { app.use(express.json()); app.use(express.urlencoded({ extended: true })); -// Определяем режим работы -const isProduction = process.env.NODE_ENV === 'production'; +// Режим работы уже определен выше (при настройке trust proxy) // Rate limiting const limiter = rateLimit({ @@ -194,7 +216,8 @@ const limiter = rateLimit({ }, standardHeaders: true, legacyHeaders: false, - trustProxy: true, // Доверяем nginx proxy + // Настраиваем trust proxy правильно: 1 означает доверять одному прокси (nginx) + trustProxy: isProduction ? 1 : false, // В продакшне доверяем nginx, в dev - нет }); // Применяем rate limiting ко всем запросам (временно отключено для тестирования) @@ -210,7 +233,8 @@ const strictLimiter = rateLimit({ }, standardHeaders: true, legacyHeaders: false, - trustProxy: true, // Доверяем nginx proxy + // Настраиваем trust proxy правильно: 1 означает доверять одному прокси (nginx) + trustProxy: isProduction ? 1 : false, // В продакшне доверяем nginx, в dev - нет }); // Мягкий rate limiting для RPC настроек (часто запрашиваемых данных) @@ -223,7 +247,8 @@ const rpcSettingsLimiter = rateLimit({ }, standardHeaders: true, legacyHeaders: false, - trustProxy: true, + // Настраиваем trust proxy правильно: 1 означает доверять одному прокси (nginx) + trustProxy: isProduction ? 1 : false, // В продакшне доверяем nginx, в dev - нет }); // Статическая раздача загруженных файлов (для dev и prod) @@ -287,6 +312,7 @@ app.use('/api/monitoring', monitoringRoutes); app.use('/api/pages', pagesRoutes); // Подключаем роутер страниц app.use('/api/consent', consentRoutes); // Добавляем маршрут согласий app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга +app.use('/api/vds', vdsRoutes); // Добавляем маршрут VDS управления app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы) app.use('/api/ens', ensRoutes); // ENS utilities app.use('/api', sshRoutes); // SSH роуты diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 2bd1b48..d8cc247 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -172,9 +172,47 @@ router.post('/verify', async (req, res) => { return res.status(401).json({ success: false, error: 'Invalid nonce' }); } - // Создаем SIWE сообщение для проверки подписи - const origin = req.get('origin') || 'http://localhost:5173'; - const domain = new URL(origin).host; // Извлекаем домен из origin + // Получаем базовый 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}`; + } + } + baseUrlForResources = `${protocol}://${host}`; + } + + // Извлекаем домен и origin из baseUrlForResources для SIWE сообщения + const baseUrlObj = new URL(baseUrlForResources); + // Используем host (включает порт, если он нестандартный) или hostname + port + let domain = baseUrlObj.host; // Домен для SIWE (например, "localhost:9000" или "example.com") + // Если порт стандартный (80 для http, 443 для https), он может не быть в host + // В этом случае добавляем порт явно для localhost или если порт указан в URL + if (!domain.includes(':')) { + if (baseUrlObj.port) { + // Порт есть в URL, но не в host (стандартный порт) + domain = `${baseUrlObj.hostname}:${baseUrlObj.port}`; + } else if (baseUrlObj.hostname === 'localhost' || baseUrlObj.hostname === '127.0.0.1') { + // Для localhost добавляем порт явно + const defaultPort = baseUrlObj.protocol === 'https:' ? '443' : '9000'; + domain = `${baseUrlObj.hostname}:${defaultPort}`; + } + } + const origin = baseUrlForResources; // URI для SIWE (полный URL) // Получаем список документов для подписания и добавляем их в resources const documentTitles = Object.keys(DOCUMENT_CONSENT_MAP); @@ -183,7 +221,7 @@ router.post('/verify', async (req, res) => { `SELECT to_regclass($1) as exists`, [tableName] ); - let resources = [`${origin}/api/auth/verify`]; + let resources = [`${baseUrlForResources}/api/auth/verify`]; if (tableExistsRes.rows[0].exists) { const { rows: documents } = await db.getQuery()(` SELECT id FROM ${tableName} @@ -192,9 +230,9 @@ router.post('/verify', async (req, res) => { AND title = ANY($1) `, [documentTitles]); - // Добавляем ссылки на документы в resources + // Добавляем ссылки на документы в resources (используем домен из БД) documents.forEach(doc => { - resources.push(`${origin}/content/published/${doc.id}`); + resources.push(`${baseUrlForResources}/content/published/${doc.id}`); }); } diff --git a/backend/routes/vds.js b/backend/routes/vds.js new file mode 100644 index 0000000..800a244 --- /dev/null +++ b/backend/routes/vds.js @@ -0,0 +1,822 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * 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 + */ +router.get('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { + try { + 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 не возвращаем по соображениям безопасности + } + }); + } catch (error) { + logger.error('[VDS] Ошибка получения настроек:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * Сохранить настройки VDS + */ +router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { + try { + const { + domain, + email, + ubuntuUser, + dockerUser, + sshHost, + sshPort, + sshUser, + sshPassword + } = req.body; + + // Если передан только домен (для обратной совместимости) + 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(); + + logger.info(`[VDS] Домен сохранен: ${normalizedDomain}`); + return res.json({ success: true, domain: normalizedDomain }); + } + + // Валидация обязательных полей (пароль опционален при обновлении) + if (!domain || !email || !ubuntuUser || !dockerUser || !sshHost || !sshPort || !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); + + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + // Подготавливаем данные для сохранения с правильными именами полей для шифрования + const settings = { + domain_encrypted: normalizedDomain, // encryptedDb автоматически зашифрует поля с _encrypted + email_encrypted: email.trim(), + ubuntu_user_encrypted: ubuntuUser.trim(), + docker_user_encrypted: dockerUser.trim(), + ssh_host_encrypted: sshHost.trim(), + ssh_port: parseInt(sshPort, 10), + ssh_user_encrypted: sshUser.trim(), + updated_at: new Date() + }; + + // Пароль добавляем только если он указан (при обновлении можно не менять) + if (sshPassword !== undefined && sshPassword !== null && sshPassword.trim() !== '') { + settings.ssh_password_encrypted = sshPassword; + } else if (existing.length === 0) { + // При создании пароль обязателен + return res.status(400).json({ + success: false, + error: 'SSH пароль обязателен при первой настройке' + }); + } + // Если пароль не указан (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(); + + logger.info(`[VDS] Настройки сохранены: ${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; + } +} + +/** + * Получить список контейнеров + */ +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 { 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 }; + }); + + 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: 'Имя контейнера обязательно' }); + } + + await execAsync(`docker restart ${name}`); + 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 { stdout } = await execAsync('docker ps -q'); + const containerIds = 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(' ')}`); + 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 { stdout: inspectOutput } = await execAsync(`docker inspect ${name} --format '{{.Config.Image}}'`); + const imageName = inspectOutput.trim(); + + if (!imageName) { + return res.status(404).json({ success: false, error: 'Контейнер не найден' }); + } + + // Останавливаем контейнер + await execAsync(`docker stop ${name}`).catch(() => {}); + + // Удаляем контейнер + await execAsync(`docker rm ${name}`).catch(() => {}); + + // Пересобираем образ (если есть Dockerfile) + // Для простоты просто пересоздаем контейнер из образа + await execAsync(`docker run -d --name ${name} ${imageName}`).catch(() => { + throw new 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 + let cpuUsage = 0; + let ramUsage = 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); + } + + // Получаем статистику по контейнерам (если Docker доступен) + let containers = []; + const dockerAvailable = await checkDockerAvailable(); + if (dockerAvailable) { + 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 }; + }); + } catch (error) { + logger.warn('[VDS] Не удалось получить статистику контейнеров:', error.message); + // Продолжаем без статистики контейнеров + } + } + + res.json({ + success: true, + stats: { + cpu: { + usage: cpuUsage, + cores: 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 + }, + traffic: { + total: totalTraffic, // MB + rx: rxBytes / 1024 / 1024, // MB + tx: txBytes / 1024 / 1024 // MB + }, + containers + }, + timestamp: new Date().toISOString() + }); + } 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; + await execAsync(`docker stop ${name}`); + 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; + await execAsync(`docker start ${name}`); + 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 execAsync(`docker stop ${name}`).catch(() => {}); + await execAsync(`docker rm ${name}`); + 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 { stdout } = await execAsync(`docker logs --tail ${tail} ${name}`); + res.json({ success: true, logs: 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 { stdout } = await execAsync(`docker stats --no-stream --format "{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}" ${name}`); + const [cpu, mem, net, block] = 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 }); + } +}); + +/** + * Получить историю статистики (для графиков) + */ +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; + diff --git a/backend/services/UniversalGuestService.js b/backend/services/UniversalGuestService.js index e8975f2..b0308de 100644 --- a/backend/services/UniversalGuestService.js +++ b/backend/services/UniversalGuestService.js @@ -565,8 +565,7 @@ class UniversalGuestService { const consentSystemMessage = await consentService.getConsentSystemMessage({ userId: null, walletAddress, - channel: channel === 'web' ? 'web' : channel, - baseUrl: process.env.BASE_URL || 'http://localhost:9000' + channel: channel === 'web' ? 'web' : channel }); if (consentSystemMessage && consentSystemMessage.consentRequired) { diff --git a/backend/services/consentService.js b/backend/services/consentService.js index c610127..e53528c 100644 --- a/backend/services/consentService.js +++ b/backend/services/consentService.js @@ -12,6 +12,7 @@ const db = require('../db'); const logger = require('../utils/logger'); +const encryptedDb = require('./encryptedDatabaseService'); // Маппинг названий документов на типы согласий const DOCUMENT_CONSENT_MAP = { @@ -20,6 +21,61 @@ const DOCUMENT_CONSENT_MAP = { 'Согласие на обработку персональных данных': 'personal_data_processing' }; +// Кэш для домена +let cachedDomain = null; +let domainCacheTime = 0; +const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 минут + +/** + * Сбросить кэш домена (вызывается при сохранении нового домена) + */ +function clearDomainCache() { + cachedDomain = null; + domainCacheTime = 0; +} + +/** + * Получить домен из настроек VDS + * @returns {Promise} - Базовый URL (https://domain.com) + */ +async function getBaseUrl() { + try { + // Проверяем кэш + const now = Date.now(); + if (cachedDomain && (now - domainCacheTime) < DOMAIN_CACHE_TTL) { + return cachedDomain; + } + + // Проверяем process.env + if (process.env.BASE_URL) { + cachedDomain = process.env.BASE_URL; + domainCacheTime = now; + return cachedDomain; + } + + // Загружаем из БД + const settings = await encryptedDb.getData('vds_settings', {}, 1); + + if (settings.length > 0 && settings[0].domain) { + const domain = settings[0].domain; + cachedDomain = `https://${domain}`; + domainCacheTime = now; + // Обновляем process.env для текущего процесса + process.env.BASE_URL = cachedDomain; + return cachedDomain; + } + + // Возвращаем дефолтное значение + const defaultUrl = 'http://localhost:9000'; + cachedDomain = defaultUrl; + domainCacheTime = now; + return defaultUrl; + } catch (error) { + logger.error('[ConsentService] Ошибка получения домена:', error); + return process.env.BASE_URL || 'http://localhost:9000'; + } +} + /** * Проверить согласия пользователя или гостя * @param {Object} params - Параметры проверки @@ -224,11 +280,16 @@ function formatConsentMessage({ channel = 'web', missingConsents = [], consentDo * @param {number|null} params.userId - ID пользователя * @param {string|null} params.walletAddress - Адрес кошелька или guest_ID * @param {string} params.channel - Канал (web/telegram/email) - * @param {string} params.baseUrl - Базовый URL сайта + * @param {string} params.baseUrl - Базовый URL сайта (опционально, если не указан - загружается из БД) * @returns {Promise} - Системное сообщение или null, если согласия есть */ -async function getConsentSystemMessage({ userId = null, walletAddress = null, channel = 'web', baseUrl = 'http://localhost:9000' }) { +async function getConsentSystemMessage({ userId = null, walletAddress = null, channel = 'web', baseUrl = null }) { try { + // Если baseUrl не указан, загружаем из БД + if (!baseUrl) { + baseUrl = await getBaseUrl(); + } + // Проверяем согласия const consentCheck = await checkConsents({ userId, walletAddress }); @@ -257,6 +318,8 @@ module.exports = { getConsentDocuments, formatConsentMessage, getConsentSystemMessage, + getBaseUrl, + clearDomainCache, DOCUMENT_CONSENT_MAP }; diff --git a/backend/services/unifiedMessageProcessor.js b/backend/services/unifiedMessageProcessor.js index 79374e7..62fb6d5 100644 --- a/backend/services/unifiedMessageProcessor.js +++ b/backend/services/unifiedMessageProcessor.js @@ -385,8 +385,7 @@ async function processMessage(messageData) { const consentSystemMessage = await consentService.getConsentSystemMessage({ userId, walletAddress: walletIdentity?.provider_id || null, - channel: channel === 'web' ? 'web' : channel, - baseUrl: process.env.BASE_URL || 'http://localhost:9000' + channel: channel === 'web' ? 'web' : channel }); // Формируем финальный ответ ИИ с системным сообщением, если нужно diff --git a/frontend/package.json b/frontend/package.json index 4cc4ac4..702c12f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "dependencies": { "axios": "^1.8.4", "buffer": "^6.0.3", + "chart.js": "^4.5.1", "connect-pg-simple": "^10.0.0", "dompurify": "^3.2.4", "element-plus": "^2.9.11", diff --git a/frontend/src/components/WebSshForm.vue b/frontend/src/components/WebSshForm.vue index ed87b66..179e864 100644 --- a/frontend/src/components/WebSshForm.vue +++ b/frontend/src/components/WebSshForm.vue @@ -141,9 +141,12 @@ + + + diff --git a/frontend/src/views/VdsMockView.vue b/frontend/src/views/VdsMockView.vue deleted file mode 100644 index 2ad06d6..0000000 --- a/frontend/src/views/VdsMockView.vue +++ /dev/null @@ -1,477 +0,0 @@ - - - - - - - diff --git a/frontend/src/views/settings/WebSshSettingsView.vue b/frontend/src/views/settings/WebSshSettingsView.vue index 64235cc..87938bf 100644 --- a/frontend/src/views/settings/WebSshSettingsView.vue +++ b/frontend/src/views/settings/WebSshSettingsView.vue @@ -151,7 +151,7 @@ const handleSubmit = async () => { // Перенаправляем на страницу VDS Mock через 3 секунды addLog('info', 'Перенаправление на страницу управления VDS через 3 секунды...'); setTimeout(() => { - router.push({ name: 'vds-mock' }); + router.push({ name: 'vds-management' }); }, 3000); } else { addLog('error', result.message || 'Ошибка при настройке VDS'); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 85cafa9..b5b33db 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -328,6 +328,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz#7358043433b2e5da569aa02cbc4c121da3af27d7" integrity sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw== +"@kurkle/color@^0.3.0": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf" + integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== + "@noble/curves@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.2.0.tgz#92d7e12e4e49b23105a2555c6984d41733d65c35" @@ -890,6 +895,13 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chart.js@^4.5.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.1.tgz#19dd1a9a386a3f6397691672231cb5fc9c052c35" + integrity sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw== + dependencies: + "@kurkle/color" "^0.3.0" + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" diff --git a/webssh-agent/docker-compose.prod.yml b/webssh-agent/docker-compose.prod.yml index 9803b80..dc1f371 100644 --- a/webssh-agent/docker-compose.prod.yml +++ b/webssh-agent/docker-compose.prod.yml @@ -128,6 +128,19 @@ services: volumes: - backend_node_modules:/app/node_modules - ./ssl:/app/ssl:ro + # Доступ к Docker socket для управления контейнерами на VDS + - /var/run/docker.sock:/var/run/docker.sock:ro + # Доступ к системным файловым системам для мониторинга и управления + # Монтируем напрямую для работы команд top, free, ps и т.д. + - /proc:/host/proc:ro + - /sys:/host/sys:ro + # Также монтируем в стандартные пути для совместимости (read-only для безопасности) + - /proc:/proc:ro + - /sys:/sys:ro + # Добавляем необходимые capabilities для управления системой + cap_add: + - SYS_ADMIN # Для управления системой (reboot, shutdown, useradd и т.д.) + - SYS_TIME # Для управления временем системы environment: - NODE_ENV=production - PORT=8000