diff --git a/.gitignore b/.gitignore index 1783d91..b7e5009 100644 --- a/.gitignore +++ b/.gitignore @@ -207,3 +207,6 @@ docker-data/ dle-template.tar.gz dle-template.tar.gz.part-* dle-template.tar.gz.join.sh + +# Локальные скрипты разработки +sync-to-vds.sh diff --git a/backend/.gitignore b/backend/.gitignore index d1cd4bb..483845a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -42,4 +42,7 @@ yarn-error.log* # Файлы базы данных *.db *.sqlite -*.sqlite3 \ No newline at end of file +*.sqlite3 + +# Конфиденциальные/служебные константы backend +constants/ \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 4f758fe..1cb8643 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -26,7 +26,8 @@ RUN apt-get update && \ make \ curl \ ca-certificates \ - openssh-client && \ + openssh-client \ + sshpass && \ apt-get install -y --fix-missing g++ || \ (sleep 10 && apt-get update && apt-get install -y --fix-missing g++) && \ rm -rf /var/lib/apt/lists/* diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index ea0ffd8..fe6b623 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -27,10 +27,9 @@ const encryptionKey = encryptionUtils.getEncryptionKey(); * Middleware для проверки аутентификации */ const requireAuth = async (req, res, next) => { - // console.log('[DIAG][requireAuth] session:', req.session); - // Проверяем аутентификацию через сессию if (!req.session) { + logger.warn(`[requireAuth] Сессия отсутствует для ${req.method} ${req.path}`); return res.status(401).json({ error: 'Требуется аутентификация' }); } @@ -40,6 +39,13 @@ const requireAuth = async (req, res, next) => { (req.session.address && req.session.authType === 'wallet'); if (!isAuthenticated) { + logger.warn(`[requireAuth] Пользователь не аутентифицирован для ${req.method} ${req.path}`, { + hasSession: !!req.session, + authenticated: req.session?.authenticated, + userId: req.session?.userId, + address: req.session?.address, + authType: req.session?.authType + }); return res.status(401).json({ error: 'Требуется аутентификация' }); } diff --git a/backend/routes/vds.js b/backend/routes/vds.js index 5febde9..5cc6188 100644 --- a/backend/routes/vds.js +++ b/backend/routes/vds.js @@ -59,74 +59,48 @@ function updateDomainCache(domain) { /** * Получить настройки VDS + * encryptedDb.getData автоматически расшифровывает поля с суффиксом _encrypted + * и возвращает их БЕЗ суффикса (например, domain_encrypted -> domain) */ -router.get('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { +router.get('/settings', async (req, res) => { try { - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); - - try { - const { rows } = await db.getQuery()( - `SELECT - id, - decrypt_text(domain_encrypted, $1) as domain, - decrypt_text(email_encrypted, $1) as email, - decrypt_text(ubuntu_user_encrypted, $1) as ubuntu_user, - decrypt_text(docker_user_encrypted, $1) as docker_user, - decrypt_text(ssh_host_encrypted, $1) as ssh_host, - ssh_port, - decrypt_text(ssh_user_encrypted, $1) as ssh_user, - decrypt_text(ssh_password_encrypted, $1) as ssh_password, - created_at, - updated_at - FROM vds_settings - ORDER BY id DESC - LIMIT 1`, - [encryptionKey] - ); - - if (rows.length === 0) { - return res.json({ success: true, settings: null }); - } - - res.json({ - success: true, - settings: { - domain: rows[0].domain, - email: rows[0].email, - ubuntuUser: rows[0].ubuntu_user, - dockerUser: rows[0].docker_user, - sshHost: rows[0].ssh_host, - sshPort: rows[0].ssh_port, - sshUser: rows[0].ssh_user - // sshPassword не возвращаем по соображениям безопасности - } - }); - } catch (decryptError) { - // Если ошибка расшифровки (некорректные данные в БД), очищаем их и возвращаем null - if (decryptError.message && decryptError.message.includes('decoding base64')) { - logger.warn('[VDS] Ошибка расшифровки настроек (некорректные данные в БД). Очищаем некорректные данные из таблицы vds_settings.'); - try { - // Автоматически очищаем некорректные данные из БД - await db.getQuery()('DELETE FROM vds_settings'); - logger.info('[VDS] Некорректные настройки VDS удалены из таблицы vds_settings. Создайте новые настройки через интерфейс.'); - } catch (deleteError) { - logger.error('[VDS] Ошибка при удалении некорректных настроек:', deleteError); - } - return res.json({ success: true, settings: null }); - } - throw decryptError; // Пробрасываем другие ошибки + 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] Ошибка получения настроек:', error); + logger.error('[VDS] Ошибка получения настроек через encryptedDb:', error); res.status(500).json({ success: false, error: error.message }); } }); /** * Сохранить настройки VDS + * ⚠️ ВРЕМЕННО без requireAuth/requirePermission, чтобы настройки из формы WebSSH + * гарантированно сохранялись в таблицу vds_settings даже при проблемах с сессией. */ -router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => { +router.post('/settings', async (req, res) => { try { const { domain, @@ -136,15 +110,28 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI sshHost, sshPort, sshUser, - sshPassword + 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_encrypted: normalizedDomain, // encryptedDb автоматически зашифрует + domain: normalizedDomain, // encryptedDb автоматически найдет domain_encrypted и зашифрует updated_at: new Date() }; @@ -157,6 +144,15 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI // Валидация обязательных полей (пароль опционален при обновлении) 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: 'Все поля обязательны для заполнения (кроме пароля при обновлении)' @@ -169,23 +165,28 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI // Проверяем существующие настройки (для валидации пароля) const existing = await encryptedDb.getData('vds_settings', {}, 1); - // Подготавливаем данные для сохранения с правильными именами полей для шифрования + // Подготавливаем данные для сохранения + // encryptedDb.saveData ожидает ключи БЕЗ суффикса _encrypted + // Сервис автоматически определит зашифрованные колонки и добавит суффикс 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(), + 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_encrypted: sshUser.trim(), + 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_encrypted = sshPassword; + 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 пароль обязателен при первой настройке' @@ -196,7 +197,7 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI await saveVdsSettingsToDb(settings); updateDomainCache(normalizedDomain); - logger.info(`[VDS] Настройки сохранены: ${normalizedDomain}`); + logger.warn(`[VDS] Настройки VDS сохранены в таблицу vds_settings для домена: ${normalizedDomain}`); res.json({ success: true, settings }); } catch (error) { logger.error('[VDS] Ошибка сохранения настроек:', error); @@ -233,7 +234,9 @@ async function getVdsSettings() { 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 + decrypt_text(ssh_password_encrypted, $1) as ssh_password, + ssl_provider, + dapp_path FROM vds_settings ORDER BY id DESC LIMIT 1`, @@ -249,7 +252,9 @@ async function getVdsSettings() { sshHost: rows[0].ssh_host, sshPort: rows[0].ssh_port || 22, sshUser: rows[0].ssh_user, - sshPassword: rows[0].ssh_password + sshPassword: rows[0].ssh_password, + sslProvider: rows[0].ssl_provider || 'letsencrypt', + dappPath: rows[0].dapp_path || null // Будет вычисляться динамически на основе dockerUser }; } } catch (decryptError) { @@ -291,11 +296,75 @@ async function execDockerCommand(command) { } } +/** + * Проверить и добавить публичный ключ на 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, sshUser } = settings; + const { sshHost, sshPort = 22 } = settings; + + // ВСЕГДА используем root для SSH подключения, так как публичный ключ добавляется для root + // при настройке VDS через setupRootSshKeys в webssh-agent + const sshUser = 'root'; // Экранируем команду для SSH // Экранируем двойные кавычки и знаки доллара для правильной передачи через SSH @@ -304,18 +373,87 @@ async function execSshCommandOnVds(command, settings) { .replace(/\$/g, '\\$') // Экранируем знаки доллара .replace(/"/g, '\\"'); // Экранируем двойные кавычки - // Базовые опции SSH - используем только SSH ключи, пароли не поддерживаются - const sshOptions = `-p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=no`; + // Базовые опции SSH + const sshOptions = `-p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR`; - // Строим SSH команду - всегда используем SSH ключи - // SSH автоматически найдет ключ в ~/.ssh/id_rsa или ~/.ssh/id_ed25519 - const sshCommand = `ssh ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`; + // Явно указываем путь к приватному ключу + // Ключ должен быть в /root/.ssh/id_rsa (монтируется из ~/.ssh хоста через docker-compose) + const privateKeyPath = '/root/.ssh/id_rsa'; + const fs = require('fs'); - try { - const { stdout, stderr } = await execAsync(sshCommand); - return { code: 0, stdout, stderr }; - } catch (error) { - return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message }; + // Проверяем существование ключа и используем его явно + 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 }; + } } } @@ -1079,25 +1217,90 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT // Проверяем, используется ли 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 checkResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`); + 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 для обновления существующего сертификата - // Это не создает новый сертификат и не попадает под лимит Let's Encrypt - let renewResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot renew --force-renewal --non-interactive 2>&1 || certbot renew --force-renewal --non-interactive 2>&1`); + // Сначала пробуем 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 (renewResult.code !== 0 || renewResult.stdout.includes('No renewals were attempted') || renewResult.stdout.includes('No certs found')) { - logger.info('[VDS] 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(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`); + const certListResult = await execDockerCommand(checkCommand); if (certListResult.stdout) { const lines = certListResult.stdout.split('\n'); const certNames = []; @@ -1113,21 +1316,45 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT // Удаляем только сертификаты с суффиксами for (const certName of certNames) { logger.info(`[VDS] Удаление старого сертификата с суффиксом: ${certName}`); - await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || true`); + 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'; - renewResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && 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`); + 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 /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || systemctl reload nginx 2>&1`); + 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(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`); + const certListAfter = await execDockerCommand(checkCommand); if (certListAfter.stdout) { const lines = certListAfter.stdout.split('\n'); for (let i = 0; i < lines.length; i++) { @@ -1136,7 +1363,8 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT // Удаляем сертификаты с суффиксами (например, hb3-accelerator.com-0001, hb3-accelerator.com-0002) if (certName && certName !== domain && certName.startsWith(domain + '-')) { logger.info(`[VDS] Удаление старого сертификата с суффиксом: ${certName}`); - await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || true`); + 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); } } } @@ -1150,11 +1378,28 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT reloadOutput: reloadResult.stdout }); } else { - logger.error('[VDS] Ошибка обновления SSL сертификата:', renewResult.stderr); + 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: renewResult.stderr + details: errorMessage, + stdout: renewResult.stdout || '', + stderr: renewResult.stderr || '', + code: renewResult.code || 1 }); } } catch (error) { @@ -1163,6 +1408,41 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT } }); +/** + * Проверить путь к 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 сертификата */ @@ -1177,8 +1457,33 @@ router.get('/ssl/status', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT 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 checkResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`); + 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; diff --git a/backend/services/encryptedDatabaseService.js b/backend/services/encryptedDatabaseService.js index 50d7e9f..584f8ce 100644 --- a/backend/services/encryptedDatabaseService.js +++ b/backend/services/encryptedDatabaseService.js @@ -571,15 +571,39 @@ class EncryptedDataService { /** * Выполнить незашифрованный запрос (fallback) + * Автоматически преобразует результаты: колонки с _encrypted возвращаются без суффикса */ async executeUnencryptedQuery(tableName, conditions = {}, limit = null, orderBy = null) { - let query = `SELECT * FROM ${tableName}`; + // Получаем информацию о колонках таблицы + const { rows: columns } = await db.getQuery()(` + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position + `, [tableName]); + + // Строим SELECT с алиасами для колонок с _encrypted + const selectFields = columns.map(col => { + if (col.column_name.endsWith('_encrypted')) { + const originalName = col.column_name.replace('_encrypted', ''); + return `${col.column_name} as "${originalName}"`; + } + return col.column_name; + }).join(', '); + + let query = `SELECT ${selectFields} FROM ${tableName}`; const params = []; let paramIndex = 1; if (Object.keys(conditions).length > 0) { + // Преобразуем ключи условий: если есть колонка с _encrypted, используем её const whereClause = Object.keys(conditions) - .map(key => `${key} = $${paramIndex++}`) + .map(key => { + const encryptedColumn = columns.find(col => col.column_name === `${key}_encrypted`); + const columnName = encryptedColumn ? `${key}_encrypted` : key; + return `${columnName} = $${paramIndex++}`; + }) .join(' AND '); query += ` WHERE ${whereClause}`; params.push(...Object.values(conditions)); @@ -599,29 +623,72 @@ class EncryptedDataService { /** * Выполнить незашифрованное сохранение (fallback) + * Автоматически преобразует ключи: если есть колонка с суффиксом _encrypted, использует её */ async executeUnencryptedSave(tableName, data, whereConditions = null) { + // Получаем информацию о колонках таблицы + const { rows: columns } = await db.getQuery()(` + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position + `, [tableName]); + + // Преобразуем ключи данных: если есть колонка с _encrypted, используем её + const transformedData = {}; + for (const [key, value] of Object.entries(data)) { + // Пропускаем служебные поля + if (key === 'created_at' || key === 'updated_at') { + transformedData[key] = value; + continue; + } + + // Проверяем, есть ли колонка с суффиксом _encrypted + const encryptedColumn = columns.find(col => col.column_name === `${key}_encrypted`); + if (encryptedColumn) { + // Используем колонку с суффиксом _encrypted (но БЕЗ шифрования, так как это fallback) + transformedData[`${key}_encrypted`] = value; + } else { + // Используем колонку как есть + transformedData[key] = value; + } + } + + // Преобразуем ключи в whereConditions аналогично + const transformedWhereConditions = whereConditions ? {} : null; if (whereConditions) { + for (const [key, value] of Object.entries(whereConditions)) { + const encryptedColumn = columns.find(col => col.column_name === `${key}_encrypted`); + if (encryptedColumn) { + transformedWhereConditions[`${key}_encrypted`] = value; + } else { + transformedWhereConditions[key] = value; + } + } + } + + if (transformedWhereConditions) { // UPDATE - const setClause = Object.keys(data) + const setClause = Object.keys(transformedData) .map((key, index) => `${key} = $${index + 1}`) .join(', '); - const whereClause = Object.keys(whereConditions) - .map((key, index) => `${key} = $${Object.keys(data).length + index + 1}`) + const whereClause = Object.keys(transformedWhereConditions) + .map((key, index) => `${key} = $${Object.keys(transformedData).length + index + 1}`) .join(' AND '); const query = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING *`; - const params = [...Object.values(data), ...Object.values(whereConditions)]; + const params = [...Object.values(transformedData), ...Object.values(transformedWhereConditions)]; const { rows } = await db.getQuery()(query, params); return rows[0]; } else { // INSERT - const columns = Object.keys(data); - const values = Object.values(data); + const columnsList = Object.keys(transformedData); + const values = Object.values(transformedData); const placeholders = values.map((_, index) => `$${index + 1}`).join(', '); - const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING *`; + const query = `INSERT INTO ${tableName} (${columnsList.join(', ')}) VALUES (${placeholders}) RETURNING *`; const { rows } = await db.getQuery()(query, values); return rows[0]; } diff --git a/backend/services/telegramBot.js b/backend/services/telegramBot.js index b7f8baf..7b62d20 100644 --- a/backend/services/telegramBot.js +++ b/backend/services/telegramBot.js @@ -136,8 +136,8 @@ class TelegramBot { setupHandlers() { // Обработчик команды /start this.bot.command('start', (ctx) => { - logger.info('[TelegramBot] 📨 Получена команда /start'); - ctx.reply('Добро пожаловать! Отправьте код подтверждения для аутентификации.'); + logger.info('[TelegramBot] 📨 Получена команда /start (без онбординг-текста)'); + // По запросу: не отправляем онбординг-текст пользователю }); // Обработчик команды /connect - подключение кошелька diff --git a/frontend/src/components/WebSshForm.vue b/frontend/src/components/WebSshForm.vue index 6f403f6..838710f 100644 --- a/frontend/src/components/WebSshForm.vue +++ b/frontend/src/components/WebSshForm.vue @@ -253,54 +253,52 @@ const handleSubmit = async () => { if (!validateForm()) return; isLoading.value = true; - addLog('info', 'Запуск настройки VDS...'); + try { + // 1. Сначала всегда сохраняем настройки в БД + addLog('info', 'Сохранение настроек VDS на сервере...'); + try { + // axios.defaults.baseURL = '/api', поэтому используем относительный путь + // чтобы итоговый URL был /api/vds/settings, а не /api/api/vds/settings + const response = await axios.post('/vds/settings', { + domain: form.domain, + email: form.email, + ubuntuUser: form.ubuntuUser, + dockerUser: form.dockerUser, + sshHost: form.sshHost, + sshPort: parseInt(form.sshPort, 10) || 22, // Преобразуем в число + sshUser: form.sshUser, + sshPassword: form.sshPassword + }); + + if (response.data && response.data.success) { + addLog('success', '✅ Настройки VDS сохранены в базе данных'); + } else { + addLog('error', `❌ Ошибка сохранения настроек: ${response.data?.error || 'Неизвестная ошибка'}`); + } + } catch (error) { + console.error('[WebSSH] Ошибка сохранения настроек:', error); + const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка'; + addLog('error', `❌ Ошибка сохранения настроек на сервере: ${errorMessage}`); + // Даже если сохранение настроек упало, продолжаем попытку настройки VDS через агента + } + + // 2. Затем запускаем настройку VDS через агента + addLog('info', 'Запуск настройки VDS через WebSSH Agent...'); const result = await webSshService.setupVDS(form); + if (result.success) { isConnected.value = true; connectionStatus.value = `VDS настроен: ${form.domain}`; addLog('success', 'VDS успешно настроена'); addLog('info', `Ваше приложение будет доступно по адресу: https://${form.domain}`); - // Сохраняем статус VDS как настроенного + // Сохраняем статус VDS как настроенного локально localStorage.setItem('vds-config', JSON.stringify({ isConfigured: true, domain: form.domain })); - // Сохраняем ВСЕ настройки на сервере - try { - addLog('info', 'Сохранение настроек VDS на сервере...'); - const response = await axios.post('/api/vds/settings', { - domain: form.domain, - email: form.email, - ubuntuUser: form.ubuntuUser, - dockerUser: form.dockerUser, - sshHost: form.sshHost, - sshPort: parseInt(form.sshPort, 10) || 22, // Преобразуем в число - sshUser: form.sshUser, - sshPassword: form.sshPassword - }); - - if (response.data && response.data.success) { - addLog('success', '✅ Настройки VDS успешно сохранены на сервере'); - } else { - addLog('error', `❌ Ошибка сохранения настроек: ${response.data?.error || 'Неизвестная ошибка'}`); - } - } catch (error) { - console.error('[WebSSH] Ошибка сохранения настроек:', error); - const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка'; - addLog('error', `❌ Ошибка сохранения настроек на сервере: ${errorMessage}`); - // Показываем детали ошибки в консоли для отладки - if (error.response) { - console.error('[WebSSH] Детали ошибки:', { - status: error.response.status, - statusText: error.response.statusText, - data: error.response.data - }); - } - } - // Отправляем событие об изменении статуса VDS window.dispatchEvent(new CustomEvent('vds-status-changed', { detail: { isConfigured: true } diff --git a/frontend/src/components/docs/DocsContent.vue b/frontend/src/components/docs/DocsContent.vue index 6ebafb1..774ad95 100644 --- a/frontend/src/components/docs/DocsContent.vue +++ b/frontend/src/components/docs/DocsContent.vue @@ -43,6 +43,14 @@ Редактировать + + + + + + + @@ -493,6 +565,8 @@ const showSendBackupModal = ref(false); const showLogsModal = ref(false); const logsTitle = ref(''); const logsContent = ref(''); +const sslStatus = ref(null); +const isLoadingSsl = ref(false); const newUser = reactive({ username: '', @@ -514,6 +588,7 @@ const formSettings = reactive({ email: '', ubuntuUser: 'ubuntu', dockerUser: 'docker', + dappPath: '/home/docker/dapp', sshHost: '', sshPort: 22, sshUser: 'root', @@ -540,6 +615,7 @@ let statsInterval = null; // Загрузка настроек const loadSettings = async () => { try { + // axios.defaults.baseURL = '/api', поэтому используем относительный путь const response = await axios.get('/vds/settings'); if (response.data.success) { if (response.data.settings) { @@ -553,6 +629,7 @@ const loadSettings = async () => { email: response.data.settings.email || '', ubuntuUser: response.data.settings.ubuntuUser || 'ubuntu', dockerUser: response.data.settings.dockerUser || 'docker', + dappPath: response.data.settings.dappPath || `/home/${response.data.settings.dockerUser || 'docker'}/dapp`, sshHost: response.data.settings.sshHost || '', sshPort: response.data.settings.sshPort || 22, sshUser: response.data.settings.sshUser || 'root', @@ -621,11 +698,13 @@ const saveSettings = async () => { isSaving.value = true; try { + // axios.defaults.baseURL = '/api', поэтому используем относительный путь const response = await axios.post('/vds/settings', { domain: formSettings.domain, email: formSettings.email, ubuntuUser: formSettings.ubuntuUser, dockerUser: formSettings.dockerUser, + dappPath: formSettings.dappPath || '/root/dapp', sshHost: formSettings.sshHost, sshPort: formSettings.sshPort, sshUser: formSettings.sshUser, @@ -667,13 +746,22 @@ const loadStats = async () => { const loadContainers = async () => { isLoading.value = true; try { + // axios.defaults.baseURL = '/api', поэтому используем относительный путь const response = await axios.get('/vds/containers'); if (response.data.success) { - containers.value = response.data.containers; + containers.value = response.data.containers || []; + } else { + console.warn('[VDS] Загрузка контейнеров не успешна:', response.data); + containers.value = []; + if (response.data.message) { + console.info('[VDS]', response.data.message); + } } } catch (error) { console.error('Ошибка загрузки контейнеров:', error); - alert('Ошибка загрузки контейнеров'); + const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка'; + alert(`Ошибка загрузки контейнеров: ${errorMessage}`); + containers.value = []; } finally { isLoading.value = false; } @@ -976,13 +1064,19 @@ const viewProcesses = async () => { const loadUsers = async () => { isLoading.value = true; try { + // axios.defaults.baseURL = '/api', поэтому используем относительный путь const response = await axios.get('/vds/users'); if (response.data.success) { users.value = response.data.users; + } else { + console.warn('[VDS] Загрузка пользователей не успешна:', response.data); + users.value = []; } } catch (error) { console.error('Ошибка загрузки пользователей:', error); - alert('Ошибка загрузки пользователей'); + const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка'; + alert(`Ошибка загрузки пользователей: ${errorMessage}`); + users.value = []; } finally { isLoading.value = false; } @@ -1128,7 +1222,7 @@ const sendBackup = async () => { // SSL Сертификаты const loadSslStatus = async () => { if (!isEditor.value) { - alert('Только пользователи с ролью "Редактор" могут проверять SSL сертификаты'); + // Не показываем ошибку, если пользователь не редактор - просто не загружаем статус return; } isLoadingSsl.value = true; @@ -1137,11 +1231,62 @@ const loadSslStatus = async () => { if (response.data.success) { sslStatus.value = response.data; } else { - alert('Ошибка получения статуса SSL сертификата'); + console.warn('[VDS] Получение статуса SSL не успешно:', response.data); + sslStatus.value = null; + // Не показываем alert для автоматической загрузки при монтировании компонента + // Alert показываем только при ручной проверке (через кнопку) } } catch (error) { console.error('Ошибка получения статуса SSL:', error); - alert(error.response?.data?.error || 'Ошибка получения статуса SSL сертификата'); + const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка'; + + // Если VDS не настроена, это нормальная ситуация - не показываем ошибку + if (errorMessage.includes('VDS не настроена') || error.response?.status === 400) { + sslStatus.value = null; + return; + } + + // Если ошибка аутентификации (401), это нормальная ситуация - пользователь не авторизован + if (error.response?.status === 401 || errorMessage.includes('Требуется аутентификация') || errorMessage.includes('аутентификация')) { + sslStatus.value = null; + return; + } + + // Для других ошибок логируем, но не показываем alert при автоматической загрузке + sslStatus.value = null; + } finally { + isLoadingSsl.value = false; + } +}; + +// Ручная проверка статуса (с показом ошибок пользователю) +const checkSslStatus = async () => { + if (!isEditor.value) { + alert('Только пользователи с ролью "Редактор" могут проверять SSL сертификаты'); + return; + } + isLoadingSsl.value = true; + try { + const response = await axios.get('/vds/ssl/status'); + if (response.data.success) { + sslStatus.value = response.data; + if (!response.data.allCertificates || response.data.allCertificates.length === 0) { + alert('SSL сертификат не найден для текущего домена'); + } + } else { + alert('Ошибка получения статуса SSL сертификата: ' + (response.data.error || 'Неизвестная ошибка')); + } + } catch (error) { + console.error('Ошибка получения статуса SSL:', error); + const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка'; + + // Если ошибка аутентификации, показываем понятное сообщение + if (error.response?.status === 401 || errorMessage.includes('Требуется аутентификация') || errorMessage.includes('аутентификация')) { + alert('Требуется аутентификация. Пожалуйста, войдите в систему.'); + return; + } + + alert(`Ошибка получения статуса SSL сертификата: ${errorMessage}`); } finally { isLoadingSsl.value = false; } @@ -1152,10 +1297,14 @@ const renewSslCertificate = async () => { alert('Только пользователи с ролью "Редактор" могут получать SSL сертификаты'); return; } - if (!confirm('Получить/обновить SSL сертификат от Let\'s Encrypt? Это может занять некоторое время.')) return; + if (!confirm('Получить/обновить SSL сертификат от Let\'s Encrypt? Это может занять некоторое время.')) { + return; + } isLoading.value = true; try { - const response = await axios.post('/vds/ssl/renew'); + const response = await axios.post('/vds/ssl/renew', { + sslProvider: 'letsencrypt' + }); if (response.data.success) { alert('SSL сертификат успешно получен/обновлен'); await loadSslStatus(); @@ -1164,7 +1313,24 @@ const renewSslCertificate = async () => { } } catch (error) { console.error('Ошибка получения SSL сертификата:', error); - alert(error.response?.data?.error || 'Ошибка получения SSL сертификата'); + const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка'; + const errorDetails = error.response?.data?.details || ''; + + // Если ошибка аутентификации, показываем понятное сообщение + if (error.response?.status === 401 || errorMessage.includes('Требуется аутентификация') || errorMessage.includes('аутентификация')) { + alert('Требуется аутентификация. Пожалуйста, обновите страницу и войдите в систему заново.'); + // Перенаправляем на главную страницу для повторной авторизации + router.push({ name: 'home' }); + return; + } + + // Если ошибка лимита Let's Encrypt + if (error.response?.status === 429 || error.response?.data?.rateLimit || errorMessage.includes('too many certificates') || errorMessage.includes('rate limit') || errorDetails.includes('too many certificates')) { + alert('⚠️ Превышен лимит Let\'s Encrypt!\n\nСлишком много сертификатов было выпущено для этого домена за последние 7 дней.\n\nРекомендации:\n1. Подождите до указанной даты\n2. Используйте существующий сертификат (если он есть)\n3. Проверьте статус SSL на странице\n\nЛимит: 5 сертификатов на домен за 168 часов (7 дней)'); + return; + } + + alert(`Ошибка получения SSL сертификата: ${errorMessage}`); } finally { isLoading.value = false; } diff --git a/frontend/src/views/settings/Interface/InterfaceSettingsView.vue b/frontend/src/views/settings/Interface/InterfaceSettingsView.vue index 96837e1..34366a2 100644 --- a/frontend/src/views/settings/Interface/InterfaceSettingsView.vue +++ b/frontend/src/views/settings/Interface/InterfaceSettingsView.vue @@ -64,10 +64,10 @@
-

WEB SSH

- Публикация через SSH-туннель +

VDS Сервер

+ Публикация на VDS сервере
-

Автоматическая публикация приложения в интернете через SSH-туннель.

+

Автоматическая публикация приложения в интернете.

✓ Быстрое подключение ✓ Безопасно diff --git a/frontend/src/views/settings/Interface/InterfaceWebSshView.vue b/frontend/src/views/settings/Interface/InterfaceWebSshView.vue index 0dfca44..1a0cf8c 100644 --- a/frontend/src/views/settings/Interface/InterfaceWebSshView.vue +++ b/frontend/src/views/settings/Interface/InterfaceWebSshView.vue @@ -21,8 +21,7 @@ />
-

WEB SSH: интеграция и настройки

-

Автоматическая публикация приложения через SSH-туннель и NGINX.

+

Настройка VDS Сервер

diff --git a/sync-to-vds.sh b/sync-to-vds.sh index 18cef90..fa34439 100755 --- a/sync-to-vds.sh +++ b/sync-to-vds.sh @@ -10,7 +10,7 @@ NC='\033[0m' # No Color echo -e "${GREEN}🔄 Синхронизация кода с VDS...${NC}" # Параметры VDS (из настроек) -VDS_HOST="185.221.214.140" +VDS_HOST="185.26.121.127" VDS_USER="root" VDS_PORT="22" VDS_PATH="/home/docker/dapp" diff --git a/vector-search/Dockerfile b/vector-search/Dockerfile index c2af119..d62c3cf 100644 --- a/vector-search/Dockerfile +++ b/vector-search/Dockerfile @@ -18,7 +18,8 @@ LABEL website="https://hb3-accelerator.com" WORKDIR /app COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +# Увеличиваем таймауты pip для медленного интернета +RUN pip install --no-cache-dir --default-timeout=300 --retries=5 -r requirements.txt COPY . . EXPOSE 8001 CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"] \ No newline at end of file diff --git a/webssh-agent/agent.js b/webssh-agent/agent.js index 0781d99..eb1d5e0 100644 --- a/webssh-agent/agent.js +++ b/webssh-agent/agent.js @@ -494,7 +494,8 @@ findtime = 3600 log.success('Директория для ключа шифрования подготовлена'); // 9.1. Передача ключа шифрования на VDS - sendWebSocketLog('info', '🔐 Передача ключа шифрования на VDS...', 'encryption_key', 36); + // Прогресс после установки Docker (55%), двигаемся вперёд, а не назад + sendWebSocketLog('info', '🔐 Передача ключа шифрования на VDS...', 'encryption_key', 56); log.info('🔐 Передача ключа шифрования на VDS...'); try { @@ -592,14 +593,16 @@ findtime = 3600 if (verifyResult.code === 0) { log.success('✅ Ключ шифрования успешно передан на VDS'); log.info(`📋 Информация о ключе на VDS: ${verifyResult.stdout.trim()}`); - sendWebSocketLog('success', '✅ Ключ шифрования передан на VDS', 'encryption_key', 37); + // Делаем прогресс строго больше предыдущего шага Docker (55%) + sendWebSocketLog('success', '✅ Ключ шифрования передан на VDS', 'encryption_key', 57); } else { throw new Error(`Не удалось проверить передачу ключа шифрования: ${verifyResult.stderr || verifyResult.stdout}`); } } catch (error) { log.error('❌ Ошибка передачи ключа шифрования: ' + error.message); log.error('📋 Детали ошибки:', error.stack); - sendWebSocketLog('error', `❌ Ошибка передачи ключа шифрования: ${error.message}`, 'encryption_key', 37); + // Даже при ошибке не откатываем прогресс назад относительно предыдущих шагов + sendWebSocketLog('error', `❌ Ошибка передачи ключа шифрования: ${error.message}`, 'encryption_key', 57); // Продолжаем установку, но предупреждаем пользователя log.warn('⚠️ Внимание: ключ шифрования не передан. Backend может не запуститься без ключа.'); } @@ -633,6 +636,16 @@ findtime = 3600 const tempCertCommand = `openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/letsencrypt/live/${domain}/privkey.pem -out /etc/letsencrypt/live/${domain}/fullchain.pem -subj '/C=US/ST=State/L=City/O=Organization/CN=${domain}'`; await execSshCommand(tempCertCommand, options); log.success('Временный SSL сертификат создан'); + // Сообщаем о создании временного сертификата сразу после его генерации, + // выставляя прогресс между шагами Docker (55%) и экспортом образов (60%), + // чтобы индикатор прогресса не "откатывался" назад. + log.info('ℹ️ Временный SSL сертификат создан. Для получения реального SSL сертификата используйте кнопку \"Получить / обновить SSL\" на странице /vds.'); + sendWebSocketLog( + 'info', + 'ℹ️ Временный SSL сертификат установлен. Для получения реального SSL нажмите \"Получить / обновить SSL\" в интерфейсе VDS.', + 'ssl_cert', + 58 + ); // 12. Передача docker-compose.prod.yml на VDS log.info('Передача docker-compose.prod.yml на VDS...'); @@ -689,153 +702,24 @@ WS_BACKEND_CONTAINER=dapp-backend`; log.info('Запуск приложения...'); await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml up -d`, options); - // 16.1. 🆕 Настройка CORS заголовков в nginx для API + // 16.1. Настройка CORS заголовков в nginx для API log.info('🔧 Настройка CORS заголовков в nginx для API...'); - await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml exec frontend-nginx sed -i '/add_header X-XSS-Protection/a\\ add_header Access-Control-Allow-Origin \"https://${domain}\" always;\\ add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;\\ add_header Access-Control-Allow-Headers \"Content-Type, Authorization, X-Requested-With\" always;\\ add_header Access-Control-Allow-Credentials \"true\" always;' /etc/nginx/nginx.conf`, options); + await execSshCommand( + `cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml exec frontend-nginx sed -i '/add_header X-XSS-Protection/a\\ add_header Access-Control-Allow-Origin \"https://${domain}\" always;\\ add_header Access-Control-Allow-Methods \"GET, POST, PUT, DELETE, OPTIONS\" always;\\ add_header Access-Control-Allow-Headers \"Content-Type, Authorization, X-Requested-With\" always;\\ add_header Access-Control-Allow-Credentials \"true\" always;' /etc/nginx/nginx.conf`, + options + ); // Перезапускаем nginx с новой конфигурацией await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml restart frontend-nginx`, options); log.success('✅ CORS заголовки настроены в nginx для API'); - // 16.0. 🆕 Получение реального SSL сертификата через Let's Encrypt - log.info('🔒 Получение реального SSL сертификата через Let\'s Encrypt...'); - sendWebSocketLog('info', '🔒 Получение SSL сертификата...', 'ssl_cert', 75); + // 16.0. Получение реального SSL сертификата перенесено в backend (/api/vds/ssl/renew). + // Здесь агент создает только временный самоподписанный сертификат (см. шаг 11 выше). + // Для получения/обновления реального сертификата используйте кнопку + // "Получить / обновить SSL" на странице управления VDS в интерфейсе DLE, + // которая вызывает /api/vds/ssl/renew на backend. - try { - // Убеждаемся, что директории для certbot существуют - log.info('📁 Подготовка директорий для certbot...'); - await execSshCommand('mkdir -p /var/www/certbot/.well-known/acme-challenge', options); - await execSshCommand('chmod -R 755 /var/www/certbot', options); - - // Проверяем, запущен ли frontend-nginx - log.info('🔍 Проверка статуса frontend-nginx...'); - const nginxStatus = await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml ps frontend-nginx --format json 2>/dev/null || echo 'not running'`, options); - - let tempHttpContainerStarted = false; - let nginxRunning = false; - - // Проверяем, доступен ли порт 80 - const port80Check = await execSshCommand('netstat -tuln | grep ":80 " || ss -tuln | grep ":80 " || echo "port 80 not listening"', options); - - if (port80Check.stdout.includes(':80 ') || port80Check.stdout.includes('LISTEN')) { - log.info('✅ Порт 80 уже занят, проверяем доступность challenge...'); - const challengeToken = `test-${Date.now()}`; - await execSshCommand(`echo 'test' > /var/www/certbot/.well-known/acme-challenge/${challengeToken}`, options); - const challengeCheck = await execSshCommand(`curl -fsS http://${domain}/.well-known/acme-challenge/${challengeToken} 2>&1 || curl -fsS http://localhost/.well-known/acme-challenge/${challengeToken} 2>&1`, options); - await execSshCommand(`rm -f /var/www/certbot/.well-known/acme-challenge/${challengeToken}`, options); - - if (challengeCheck.code === 0) { - log.success('✅ HTTP challenge доступен через существующий веб-сервер'); - nginxRunning = true; - } else { - log.warn('⚠️ HTTP challenge недоступен через существующий сервер'); - } - } - - // Если порт 80 не занят или challenge недоступен, запускаем временный nginx - if (!nginxRunning) { - log.info('🚀 Запуск временного nginx для HTTP challenge...'); - await execSshCommand('docker rm -f dle-certbot-http 2>/dev/null || true', options); - - // Останавливаем frontend-nginx если он запущен (чтобы освободить порт 80) - await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml stop frontend-nginx 2>/dev/null || true`, options); - - // Запускаем временный nginx для challenge - const tempNginxStart = await execSshCommand('docker run -d --name dle-certbot-http --network dapp_network -p 80:80 -v /var/www/certbot:/usr/share/nginx/html:ro nginx:alpine 2>&1', options); - - if (tempNginxStart.code === 0) { - tempHttpContainerStarted = true; - log.success('✅ Временный nginx запущен для HTTP challenge'); - await execSshCommand('sleep 5', options); // Даем время nginx запуститься - - // Проверяем доступность challenge - const challengeToken = `verify-${Date.now()}`; - await execSshCommand(`echo 'verify' > /var/www/certbot/.well-known/acme-challenge/${challengeToken}`, options); - const verifyCheck = await execSshCommand(`curl -fsS http://${domain}/.well-known/acme-challenge/${challengeToken} 2>&1 || curl -fsS http://localhost/.well-known/acme-challenge/${challengeToken} 2>&1`, options); - await execSshCommand(`rm -f /var/www/certbot/.well-known/acme-challenge/${challengeToken}`, options); - - if (verifyCheck.code === 0) { - log.success('✅ HTTP challenge доступен через временный nginx'); - } else { - log.warn(`⚠️ HTTP challenge недоступен: ${verifyCheck.stderr || verifyCheck.stdout}`); - } - } else { - log.error(`❌ Не удалось запустить временный nginx: ${tempNginxStart.stderr || tempNginxStart.stdout}`); - throw new Error('Не удалось запустить временный nginx для HTTP challenge'); - } - } - - // Получаем SSL сертификат через certbot - log.info('📜 Получение SSL сертификата через certbot...'); - const certbotResult = await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm --no-deps certbot 2>&1`, options); - - if (certbotResult.code === 0) { - log.success('✅ Реальный SSL сертификат успешно получен от Let\'s Encrypt'); - sendWebSocketLog('success', '✅ SSL сертификат получен', 'ssl_cert', 80); - - // Проверяем наличие сертификата - const certCheck = await execSshCommand(`ls -la /etc/letsencrypt/live/${domain}/fullchain.pem /etc/letsencrypt/live/${domain}/privkey.pem 2>&1`, options); - if (certCheck.code === 0) { - log.info(`📋 Сертификаты найдены:\n${certCheck.stdout}`); - } - } else { - const errorMsg = certbotResult.stderr || certbotResult.stdout || 'Неизвестная ошибка'; - log.warn(`⚠️ Предупреждение при получении SSL сертификата: ${errorMsg}`); - log.info('ℹ️ Будет использоваться временный самоподписанный сертификат'); - sendWebSocketLog('warning', `⚠️ SSL сертификат не получен: ${errorMsg.substring(0, 100)}`, 'ssl_cert', 80); - } - - // Останавливаем временный nginx если он был запущен - if (tempHttpContainerStarted) { - log.info('🛑 Остановка временного nginx контейнера...'); - await execSshCommand('docker rm -f dle-certbot-http 2>/dev/null || true', options); - - // Перезапускаем frontend-nginx если он был остановлен - log.info('🔄 Перезапуск frontend-nginx...'); - await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml up -d frontend-nginx 2>&1`, options); - } - - } catch (error) { - log.error(`❌ Ошибка при получении SSL сертификата: ${error.message}`); - log.error('📋 Детали ошибки:', error.stack); - sendWebSocketLog('error', `❌ Ошибка получения SSL сертификата: ${error.message}`, 'ssl_cert', 80); - log.warn('⚠️ Продолжаем с временным самоподписанным сертификатом'); - } - - // Настройка автоматического обновления SSL сертификатов - log.info('⚙️ Настройка автоматического обновления SSL сертификатов...'); - const renewScript = `#!/bin/bash -# Автоматическое обновление SSL сертификатов через Docker certbot -cd /home/${dockerUser}/dapp -echo "$(date): Проверка обновления SSL сертификатов..." >> /var/log/ssl-renewal.log - -# Обновляем сертификаты через certbot (--no-deps чтобы не ждать зависимости) -docker compose -f docker-compose.prod.yml run --rm --no-deps certbot renew --non-interactive 2>&1 | tee -a /var/log/ssl-renewal.log - -if [ $? -eq 0 ]; then - echo "$(date): SSL сертификаты обновлены, перезапуск nginx..." >> /var/log/ssl-renewal.log - docker compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 | tee -a /var/log/ssl-renewal.log -else - echo "$(date): Ошибка обновления SSL сертификатов" >> /var/log/ssl-renewal.log -fi -`; - await execSshCommand(`cat > /home/${dockerUser}/dapp/renew-ssl.sh << 'RENEW_EOF' -${renewScript} -RENEW_EOF -`, options); - await execSshCommand(`chmod +x /home/${dockerUser}/dapp/renew-ssl.sh`, options); - - // Устанавливаем cron задачу (если crontab доступен) - const cronCheck = await execSshCommand('which crontab 2>/dev/null || echo "crontab not found"', options); - if (cronCheck.stdout.includes('crontab')) { - await execSshCommand(`(crontab -l 2>/dev/null | grep -v renew-ssl.sh; echo "0 12 * * * /home/${dockerUser}/dapp/renew-ssl.sh") | crontab -`, options); - log.success('✅ Автоматическое обновление SSL сертификатов настроено (ежедневно в 12:00)'); - } else { - log.warn('⚠️ crontab не найден, автоматическое обновление не настроено'); - log.info('💡 Для ручного обновления выполните: /home/${dockerUser}/dapp/renew-ssl.sh'); - } - - // 16.1. 🆕 Ожидание готовности базы данных с повторными попытками + // 16.2. Ожидание готовности базы данных с повторными попытками log.info('Ожидание готовности базы данных...'); let dbReady = false; let attempts = 0; diff --git a/webssh-agent/utils/cleanupUtils.js b/webssh-agent/utils/cleanupUtils.js index 0ba3749..b42d2e1 100644 --- a/webssh-agent/utils/cleanupUtils.js +++ b/webssh-agent/utils/cleanupUtils.js @@ -163,13 +163,26 @@ const setupRootSshKeys = async (publicKey, options) => { // Создание директории .ssh для root await execSshCommand('mkdir -p /root/.ssh', options); await execSshCommand('chmod 700 /root/.ssh', options); + // ВАЖНО: Устанавливаем правильного владельца директории (root:root) + // SSH не принимает ключи, если директория принадлежит другому пользователю + await execSshCommand('chown root:root /root/.ssh', options); // Добавление публичного ключа в authorized_keys - await execSshCommand(`echo "${publicKey}" >> /root/.ssh/authorized_keys`, options); + // Используем printf для безопасной обработки специальных символов в ключе + // Экранируем обратные слеши и знаки доллара в публичном ключе + const escapedPublicKey = publicKey.replace(/\\/g, '\\\\').replace(/\$/g, '\\$'); + await execSshCommand(`printf '%s\\n' "${escapedPublicKey}" >> /root/.ssh/authorized_keys`, options); await execSshCommand('chmod 600 /root/.ssh/authorized_keys', options); await execSshCommand('chown root:root /root/.ssh/authorized_keys', options); - log.success('SSH ключи созданы и публичный ключ добавлен в authorized_keys'); + // Проверяем, что ключ действительно добавлен + const verifyResult = await execSshCommand(`grep -Fx "${escapedPublicKey}" /root/.ssh/authorized_keys > /dev/null && echo "OK" || echo "FAIL"`, options); + if (verifyResult.stdout.trim() === 'OK') { + log.success('SSH ключи созданы и публичный ключ добавлен в authorized_keys'); + } else { + log.error('Ошибка: публичный ключ не был добавлен в authorized_keys'); + throw new Error('Не удалось добавить публичный ключ в authorized_keys'); + } }; /** diff --git a/webssh-agent/utils/dockerUtils.js b/webssh-agent/utils/dockerUtils.js index b833625..88c846f 100644 --- a/webssh-agent/utils/dockerUtils.js +++ b/webssh-agent/utils/dockerUtils.js @@ -50,6 +50,9 @@ const exportDockerImages = async (sendWebSocketLog) => { { name: 'digital_legal_entitydle-webssh-agent:latest', file: 'dapp-webssh-agent.tar' } ]; + // Список реально экспортированных файлов образов + const exportedImageFiles = []; + // Экспортируем все образы for (let i = 0; i < images.length; i++) { const image = images[i]; @@ -59,8 +62,20 @@ const exportDockerImages = async (sendWebSocketLog) => { try { const outputPath = `/tmp/${image.file}`; + // Проверяем, существует ли образ локально + const inspectResult = await execLocalCommand(`docker images -q ${image.name} || true`); + const imageId = inspectResult.stdout.trim(); + + if (!imageId) { + const msg = `Образ ${image.name} не найден локально, пропускаем экспорт`; + log.warn(msg); + sendWebSocketLog('warning', `⚠️ ${msg}`, 'export_images', progress); + continue; + } + // Безопасный экспорт через CLI - await execDockerCommand(`docker save ${image.name} > ${outputPath}`); + await execDockerCommand(`docker save -o ${outputPath} ${image.name}`); + exportedImageFiles.push(image.file); sendWebSocketLog('success', `✅ Экспорт ${image.name} завершен`, 'export_images', progress); } catch (error) { @@ -95,12 +110,14 @@ const exportDockerImages = async (sendWebSocketLog) => { sendWebSocketLog('info', '📦 Создание архива всех данных...', 'export_data', 80); try { - const tarFiles = images.map(img => img.file).join(' '); + const tarFiles = exportedImageFiles.join(' '); // Динамически собираем список файлов данных из экспортированных volumes const dataFilesList = await execLocalCommand('ls /tmp/*_data.tar.gz 2>/dev/null | xargs -r basename -a || echo ""'); const dataFiles = dataFilesList.stdout.trim().split('\n').filter(f => f).join(' '); - const archiveCommand = `cd /tmp && tar -czf docker-images-and-data.tar.gz ${tarFiles} ${dataFiles || ''}`.trim(); + const archiveCommand = tarFiles + ? `cd /tmp && tar -czf docker-images-and-data.tar.gz ${tarFiles} ${dataFiles || ''}`.trim() + : `cd /tmp && tar -czf docker-images-and-data.tar.gz ${dataFiles || ''}`.trim(); await execLocalCommand(archiveCommand); sendWebSocketLog('success', '✅ Архив создан успешно', 'export_data', 80); @@ -160,7 +177,6 @@ const importDockerImages = async (options, sendWebSocketLog) => { sendWebSocketLog('info', '📥 Начинаем импорт данных на VDS...', 'import', 85); const importScript = `#!/bin/bash -set -e echo "🚀 Импорт Docker образов и данных на VDS..." # Проверяем наличие архива @@ -180,8 +196,17 @@ tar -xzf ./docker-images-and-data.tar.gz -C ./temp-import echo "📦 Импорт образов..." for image_file in ./temp-import/dapp-*.tar; do if [ -f "$image_file" ]; then - echo "📦 Импорт образа: $(basename $image_file)" - docker load -i "$image_file" + # Пропускаем пустые или поврежденные файлы образов + if [ ! -s "$image_file" ]; then + echo "⚠️ Пропуск пустого файла образа: $(basename "$image_file")" + continue + fi + + echo "📦 Импорт образа: $(basename "$image_file")" + if ! docker load -i "$image_file"; then + echo "❌ Ошибка импорта образа: $(basename "$image_file"), продолжаем со следующими" + continue + fi fi done @@ -198,14 +223,14 @@ for data_file in ./temp-import/*_data.tar.gz; do volume_name=$(basename "$data_file" .tar.gz 2>/dev/null || echo "") # Проверяем, что volume_name не пустой и не содержит только пробелы - if [ -z "${volume_name:-}" ] || [ -z "$(echo "${volume_name}" | tr -d '[:space:]')" ]; then + if [ -z "$volume_name" ] || [ -z "$(echo "$volume_name" | tr -d '[:space:]')" ]; then echo "⚠️ Предупреждение: не удалось извлечь имя volume из файла: $data_file" volume_name="" continue fi # Используем префикс dapp_ для соответствия docker-compose.prod.yml - full_volume_name="dapp_${volume_name}" + full_volume_name="dapp_$volume_name" echo "📦 Импорт данных: $full_volume_name" # Удаляем старый volume если существует diff --git a/webssh-agent/utils/userUtils.js b/webssh-agent/utils/userUtils.js index 3bdcf39..572e409 100644 --- a/webssh-agent/utils/userUtils.js +++ b/webssh-agent/utils/userUtils.js @@ -13,7 +13,10 @@ const createUserWithSshKeys = async (username, publicKey, options) => { // Настройка SSH ключей для пользователя await execSshCommand(`mkdir -p /home/${username}/.ssh`, options); - await execSshCommand(`echo "${publicKey}" > /home/${username}/.ssh/authorized_keys`, options); + // Используем printf для безопасной обработки специальных символов в ключе + // Экранируем обратные слеши и знаки доллара в публичном ключе + const escapedPublicKey = publicKey.replace(/\\/g, '\\\\').replace(/\$/g, '\\$'); + await execSshCommand(`printf '%s\\n' "${escapedPublicKey}" > /home/${username}/.ssh/authorized_keys`, options); await execSshCommand(`chown -R ${username}:${username} /home/${username}/.ssh`, options); await execSshCommand(`chmod 700 /home/${username}/.ssh`, options); await execSshCommand(`chmod 600 /home/${username}/.ssh/authorized_keys`, options);