Files
DLE/backend/routes/vds.js
2026-03-01 22:03:48 +03:00

1628 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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