ваше сообщение коммита

This commit is contained in:
2025-12-06 12:34:14 +03:00
parent 90da3a0d12
commit e9610881c8
20 changed files with 820 additions and 421 deletions

3
.gitignore vendored
View File

@@ -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

3
backend/.gitignore vendored
View File

@@ -43,3 +43,6 @@ yarn-error.log*
*.db
*.sqlite
*.sqlite3
# Конфиденциальные/служебные константы backend
constants/

View File

@@ -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/*

View File

@@ -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: 'Требуется аутентификация' });
}

View File

@@ -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();
const rows = await encryptedDb.getData('vds_settings', {}, 1);
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; // Пробрасываем другие ошибки
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;

View File

@@ -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];
}

View File

@@ -136,8 +136,8 @@ class TelegramBot {
setupHandlers() {
// Обработчик команды /start
this.bot.command('start', (ctx) => {
logger.info('[TelegramBot] 📨 Получена команда /start');
ctx.reply('Добро пожаловать! Отправьте код подтверждения для аутентификации.');
logger.info('[TelegramBot] 📨 Получена команда /start (без онбординг-текста)');
// По запросу: не отправляем онбординг-текст пользователю
});
// Обработчик команды /connect - подключение кошелька

View File

@@ -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 }

View File

@@ -43,6 +43,14 @@
<i class="fas fa-edit"></i>
<span>Редактировать</span>
</button>
<button
class="page-action-btn page-index-btn"
@click="reindexPage"
title="Отправить документ в поиск"
>
<i class="fas fa-search"></i>
<span>Индексировать</span>
</button>
<button
class="page-action-btn page-delete-btn"
@click="confirmDeletePage"
@@ -154,6 +162,7 @@ import { useRouter, useRoute } from 'vue-router';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import pagesService from '../../services/pagesService';
import api from '../../api/axios';
import { usePermissions } from '../../composables/usePermissions';
import { PERMISSIONS } from '../../composables/permissions';
@@ -403,6 +412,18 @@ async function confirmDeletePage() {
}
}
// Ручная переиндексация документа в векторный поиск
async function reindexPage() {
if (!page.value || !page.value.id) return;
try {
await api.post(`/pages/${page.value.id}/reindex`);
alert('Индексация выполнена');
} catch (error) {
console.error('[DocsContent] Ошибка индексации документа:', error);
alert('Ошибка индексации: ' + (error.response?.data?.error || error.message || 'Неизвестная ошибка'));
}
}
// Отслеживаем изменения pageId
watch(() => props.pageId, (newId, oldId) => {
console.log('[DocsContent] pageId изменился:', { oldId, newId });

View File

@@ -74,12 +74,10 @@ export function useWebSshLogs() {
console.log('[WebSSH Logs] Получен прогресс:', data);
if (data.type === 'webssh_progress') {
const progressMessage = `[${data.stage}] ${data.message}`;
const hasPercentage = data.percentage !== undefined && data.percentage !== null;
const progressSuffix = hasPercentage ? `${data.percentage}%` : '';
const progressMessage = `[${data.stage}] ${data.message}${progressSuffix}`;
addLog('info', progressMessage);
if (data.percentage) {
addLog('debug', `Прогресс: ${data.percentage}%`);
}
}
};

View File

@@ -172,112 +172,19 @@ class WebSshService {
/**
* Автоматическая установка и запуск агента
* В новой архитектуре агент всегда запускается в Docker (dapp-webssh-agent),
* поэтому здесь просто проверяем его доступность.
*/
async installAndStartAgent() {
try {
// Сначала проверяем, может агент уже запущен
const status = await this.checkAgentStatus();
if (status.running) {
return { success: true, message: 'Агент уже запущен' };
}
// Пытаемся запустить агент через системный вызов
const response = await fetch(`${LOCAL_AGENT_URL}/install`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'install_and_start'
})
});
if (response.ok) {
const result = await response.json();
this.isAgentRunning = true;
return { success: true, message: 'Агент успешно установлен и запущен' };
} else {
// Если агент не отвечает, пытаемся скачать и установить его
return await this.downloadAndInstallAgent();
}
} catch (error) {
// console.error('Ошибка при установке агента:', error);
return await this.downloadAndInstallAgent();
}
}
/**
* Скачивание и установка агента
*/
async downloadAndInstallAgent() {
try {
// Создаем скрипт для скачивания и установки агента
const installScript = `
#!/bin/bash
# Создаем директорию для агента
mkdir -p ~/.webssh-agent
cd ~/.webssh-agent
# Скачиваем агент (пока создаем локально)
cat > agent.js << 'EOF'
${this.getAgentCode()}
EOF
# Скачиваем package.json
cat > package.json << 'EOF'
{
"name": "webssh-agent",
"version": "1.0.0",
"description": "Local SSH tunnel agent",
"main": "agent.js",
"scripts": {
"start": "node agent.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"ssh2": "^1.14.0",
"node-ssh": "^13.1.0"
}
}
EOF
# Устанавливаем зависимости
npm install
# Запускаем агент в фоне
nohup node agent.js > agent.log 2>&1 &
echo "Агент установлен и запущен"
`;
// Создаем Blob со скриптом
const blob = new Blob([installScript], { type: 'application/x-sh' });
const url = URL.createObjectURL(blob);
// Создаем ссылку для скачивания
const a = document.createElement('a');
a.href = url;
a.download = 'install-webssh-agent.sh';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return {
success: false,
message: 'Скачайте и запустите скрипт install-webssh-agent.sh для установки агента',
requiresManualInstall: true
};
} catch (error) {
// console.error('Ошибка при создании установочного скрипта:', error);
return {
success: false,
message: 'Ошибка при подготовке установки агента',
error: error.message
};
}
message: 'WebSSH Agent не запущен. Убедитесь, что контейнер dapp-webssh-agent работает (docker compose up -d webssh-agent).'
};
}
/**
@@ -327,10 +234,9 @@ EOF
config.vdsIp = dnsResult.ip;
}
// Проверяем, что агент запущен
// Проверяем, что агент запущен (в Docker)
const agentStatus = await this.checkAgentStatus();
if (!agentStatus.running) {
// Пытаемся установить и запустить агент
const installResult = await this.installAndStartAgent();
if (!installResult.success) {
return installResult;

View File

@@ -75,6 +75,10 @@
<label>Docker Пользователь:</label>
<div class="setting-value">{{ settings.dockerUser || 'Не задан' }}</div>
</div>
<div class="setting-item">
<label>Путь к docker-compose:</label>
<div class="setting-value">{{ settings.dappPath || '/root/dapp' }}</div>
</div>
</div>
</div>
@@ -106,7 +110,7 @@
placeholder="admin@example.com"
required
/>
<small class="form-help">Email для получения SSL сертификата от Let's Encrypt</small>
<small class="form-help">Email для получения SSL сертификата</small>
</div>
<div class="form-group">
<label for="ubuntuUser">Логин Ubuntu *</label>
@@ -130,6 +134,17 @@
/>
<small class="form-help">Пользователь для Docker (будет создан автоматически)</small>
</div>
<div class="form-group">
<label for="dappPath">Путь к docker-compose *</label>
<input
id="dappPath"
v-model="formSettings.dappPath"
type="text"
placeholder="/home/docker/dapp"
required
/>
<small class="form-help">Путь к директории с docker-compose.prod.yml на VDS сервере (обычно /home/docker/dapp или /home/ubuntu/dapp)</small>
</div>
</div>
<div class="form-section">
@@ -377,6 +392,62 @@
</div>
</div>
<!-- SSL сертификаты -->
<div class="ssl-section">
<div class="section-header">
<h2>SSL сертификат</h2>
</div>
<div v-if="!isEditor" class="access-denied-message">
<p> Управление SSL доступно только пользователям с ролью "Редактор"</p>
</div>
<div v-else>
<div class="ssl-status">
<div v-if="isLoadingSsl">
Загрузка статуса SSL...
</div>
<div v-else>
<div v-if="sslStatus && sslStatus.success && sslStatus.allCertificates && sslStatus.allCertificates.length">
<div class="ssl-info">
<div
v-for="cert in sslStatus.allCertificates"
:key="cert.name"
class="ssl-info-item"
>
<label>{{ cert.name }}</label>
<span :class="{ 'expiring-soon': isCertExpiringSoon(cert.expiryDate) }">
{{ cert.expiryDate || 'Без данных' }}
</span>
</div>
</div>
</div>
<div v-else class="ssl-no-cert">
SSL сертификат не найден для текущего домена.
</div>
</div>
</div>
<div class="ssl-actions-grid">
<button
class="action-btn ssl-btn status"
:disabled="isLoadingSsl || isLoading"
@click="checkSslStatus"
>
🔍 Проверить статус SSL
</button>
<button
v-if="isEditor"
class="action-btn ssl-btn renew"
:disabled="isLoading"
@click="renewSslCertificate"
>
🔐 Получить / обновить SSL
</button>
</div>
</div>
</div>
<!-- Модальные окна -->
<!-- Модальное окно создания пользователя -->
<div v-if="showCreateUserModal && isEditor" class="modal-overlay" @click="showCreateUserModal = false">
@@ -450,6 +521,7 @@
</div>
</div>
</div>
</div>
</BaseLayout>
</template>
@@ -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;
}

View File

@@ -64,10 +64,10 @@
<!-- WEB SSH -->
<div class="web3-service-block">
<div class="service-header">
<h3>WEB SSH</h3>
<span class="service-badge webssh">Публикация через SSH-туннель</span>
<h3>VDS Сервер</h3>
<span class="service-badge webssh">Публикация на VDS сервере</span>
</div>
<p>Автоматическая публикация приложения в интернете через SSH-туннель.</p>
<p>Автоматическая публикация приложения в интернете.</p>
<div class="service-features">
<span class="feature"> Быстрое подключение</span>
<span class="feature"> Безопасно</span>

View File

@@ -21,8 +21,7 @@
/>
<div class="webssh-settings-block">
<button class="close-btn" @click="goBack">×</button>
<h2>WEB SSH: интеграция и настройки</h2>
<p class="desc">Автоматическая публикация приложения через SSH-туннель и NGINX.</p>
<h2>Настройка VDS Сервер</h2>
<WebSshForm />
</div>
</template>

View File

@@ -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"

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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');
}
};
/**

View File

@@ -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 если существует

View File

@@ -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);