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

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

5
backend/.gitignore vendored
View File

@@ -42,4 +42,7 @@ yarn-error.log*
# Файлы базы данных
*.db
*.sqlite
*.sqlite3
*.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();
try {
const { rows } = await db.getQuery()(
`SELECT
id,
decrypt_text(domain_encrypted, $1) as domain,
decrypt_text(email_encrypted, $1) as email,
decrypt_text(ubuntu_user_encrypted, $1) as ubuntu_user,
decrypt_text(docker_user_encrypted, $1) as docker_user,
decrypt_text(ssh_host_encrypted, $1) as ssh_host,
ssh_port,
decrypt_text(ssh_user_encrypted, $1) as ssh_user,
decrypt_text(ssh_password_encrypted, $1) as ssh_password,
created_at,
updated_at
FROM vds_settings
ORDER BY id DESC
LIMIT 1`,
[encryptionKey]
);
if (rows.length === 0) {
return res.json({ success: true, settings: null });
}
res.json({
success: true,
settings: {
domain: rows[0].domain,
email: rows[0].email,
ubuntuUser: rows[0].ubuntu_user,
dockerUser: rows[0].docker_user,
sshHost: rows[0].ssh_host,
sshPort: rows[0].ssh_port,
sshUser: rows[0].ssh_user
// sshPassword не возвращаем по соображениям безопасности
}
});
} catch (decryptError) {
// Если ошибка расшифровки (некорректные данные в БД), очищаем их и возвращаем null
if (decryptError.message && decryptError.message.includes('decoding base64')) {
logger.warn('[VDS] Ошибка расшифровки настроек (некорректные данные в БД). Очищаем некорректные данные из таблицы vds_settings.');
try {
// Автоматически очищаем некорректные данные из БД
await db.getQuery()('DELETE FROM vds_settings');
logger.info('[VDS] Некорректные настройки VDS удалены из таблицы vds_settings. Создайте новые настройки через интерфейс.');
} catch (deleteError) {
logger.error('[VDS] Ошибка при удалении некорректных настроек:', deleteError);
}
return res.json({ success: true, settings: null });
}
throw decryptError; // Пробрасываем другие ошибки
const rows = await encryptedDb.getData('vds_settings', {}, 1);
if (!rows || rows.length === 0) {
return res.json({ success: true, settings: null });
}
const row = rows[0];
// encryptedDb.getData возвращает расшифрованные поля БЕЗ суффикса _encrypted
// Например: domain_encrypted в БД -> row.domain в результате
return res.json({
success: true,
settings: {
domain: row.domain || '',
email: row.email || '',
ubuntuUser: row.ubuntu_user || 'ubuntu',
dockerUser: row.docker_user || 'docker',
sshHost: row.ssh_host || '',
sshPort: row.ssh_port || 22,
sshUser: row.ssh_user || 'root',
sslProvider: row.ssl_provider || 'letsencrypt',
dappPath: row.dapp_path || null // Будет вычисляться динамически на основе dockerUser
// sshPassword не возвращаем по соображениям безопасности
}
});
} catch (error) {
logger.error('[VDS] Ошибка получения настроек:', error);
logger.error('[VDS] Ошибка получения настроек через encryptedDb:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* Сохранить настройки VDS
* ⚠️ ВРЕМЕННО без requireAuth/requirePermission, чтобы настройки из формы WebSSH
* гарантированно сохранялись в таблицу vds_settings даже при проблемах с сессией.
*/
router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => {
router.post('/settings', async (req, res) => {
try {
const {
domain,
@@ -136,15 +110,28 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI
sshHost,
sshPort,
sshUser,
sshPassword
sshPassword,
sslProvider,
dappPath
} = req.body;
// Логируем входящие данные (без пароля), чтобы видеть попытки сохранения даже при LOG_LEVEL=warn
logger.warn('[VDS] Запрос на сохранение настроек VDS (без пароля):', {
domain,
email,
ubuntuUser,
dockerUser,
sshHost,
sshPort,
sshUser
});
// Если передан только домен (для обратной совместимости)
if (domain && !email && !sshHost) {
const normalizedDomain = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, '');
const settings = {
domain_encrypted: normalizedDomain, // encryptedDb автоматически зашифрует
domain: normalizedDomain, // encryptedDb автоматически найдет domain_encrypted и зашифрует
updated_at: new Date()
};
@@ -157,6 +144,15 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI
// Валидация обязательных полей (пароль опционален при обновлении)
if (!domain || !email || !ubuntuUser || !dockerUser || !sshHost || !sshPort || !sshUser) {
logger.warn('[VDS] Ошибка валидации настроек VDS: не заполнены обязательные поля', {
hasDomain: !!domain,
hasEmail: !!email,
hasUbuntuUser: !!ubuntuUser,
hasDockerUser: !!dockerUser,
hasSshHost: !!sshHost,
hasSshPort: !!sshPort,
hasSshUser: !!sshUser
});
return res.status(400).json({
success: false,
error: 'Все поля обязательны для заполнения (кроме пароля при обновлении)'
@@ -169,23 +165,28 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI
// Проверяем существующие настройки (для валидации пароля)
const existing = await encryptedDb.getData('vds_settings', {}, 1);
// Подготавливаем данные для сохранения с правильными именами полей для шифрования
// Подготавливаем данные для сохранения
// encryptedDb.saveData ожидает ключи БЕЗ суффикса _encrypted
// Сервис автоматически определит зашифрованные колонки и добавит суффикс
const settings = {
domain_encrypted: normalizedDomain, // encryptedDb автоматически зашифрует поля с _encrypted
email_encrypted: email.trim(),
ubuntu_user_encrypted: ubuntuUser.trim(),
docker_user_encrypted: dockerUser.trim(),
ssh_host_encrypted: sshHost.trim(),
domain: normalizedDomain, // encryptedDb автоматически найдет domain_encrypted и зашифрует
email: email.trim(),
ubuntu_user: ubuntuUser.trim(),
docker_user: dockerUser.trim(),
ssh_host: sshHost.trim(),
ssh_port: parseInt(sshPort, 10),
ssh_user_encrypted: sshUser.trim(),
ssh_user: sshUser.trim(),
ssl_provider: 'letsencrypt', // Используем только Let's Encrypt (работает без аккаунта)
dapp_path: (dappPath && dappPath.trim()) ? dappPath.trim() : null, // null означает использование значения по умолчанию
updated_at: new Date()
};
// Пароль добавляем только если он указан (при обновлении можно не менять)
if (sshPassword !== undefined && sshPassword !== null && sshPassword.trim() !== '') {
settings.ssh_password_encrypted = sshPassword;
settings.ssh_password = sshPassword; // encryptedDb автоматически найдет ssh_password_encrypted и зашифрует
} else if (existing.length === 0) {
// При создании пароль обязателен
logger.warn('[VDS] Ошибка валидации настроек VDS: отсутствует SSH пароль при первой настройке');
return res.status(400).json({
success: false,
error: 'SSH пароль обязателен при первой настройке'
@@ -196,7 +197,7 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI
await saveVdsSettingsToDb(settings);
updateDomainCache(normalizedDomain);
logger.info(`[VDS] Настройки сохранены: ${normalizedDomain}`);
logger.warn(`[VDS] Настройки VDS сохранены в таблицу vds_settings для домена: ${normalizedDomain}`);
res.json({ success: true, settings });
} catch (error) {
logger.error('[VDS] Ошибка сохранения настроек:', error);
@@ -233,7 +234,9 @@ async function getVdsSettings() {
decrypt_text(ssh_host_encrypted, $1) as ssh_host,
ssh_port,
decrypt_text(ssh_user_encrypted, $1) as ssh_user,
decrypt_text(ssh_password_encrypted, $1) as ssh_password
decrypt_text(ssh_password_encrypted, $1) as ssh_password,
ssl_provider,
dapp_path
FROM vds_settings
ORDER BY id DESC
LIMIT 1`,
@@ -249,7 +252,9 @@ async function getVdsSettings() {
sshHost: rows[0].ssh_host,
sshPort: rows[0].ssh_port || 22,
sshUser: rows[0].ssh_user,
sshPassword: rows[0].ssh_password
sshPassword: rows[0].ssh_password,
sslProvider: rows[0].ssl_provider || 'letsencrypt',
dappPath: rows[0].dapp_path || null // Будет вычисляться динамически на основе dockerUser
};
}
} catch (decryptError) {
@@ -291,11 +296,75 @@ async function execDockerCommand(command) {
}
}
/**
* Проверить и добавить публичный ключ на VDS, если его нет
* Это нужно делать только один раз при первой настройке
*/
async function ensureSshKeyOnVds(settings) {
const { sshHost, sshPort = 22, sshPassword } = settings;
const sshUser = 'root';
const privateKeyPath = '/root/.ssh/id_rsa';
const publicKeyPath = `${privateKeyPath}.pub`;
const fs = require('fs');
// Проверяем наличие ключей локально
if (!fs.existsSync(privateKeyPath) || !fs.existsSync(publicKeyPath)) {
logger.warn(`[VDS] SSH ключи не найдены локально: ${privateKeyPath}`);
return false;
}
// Читаем публичный ключ
const publicKey = fs.readFileSync(publicKeyPath, 'utf8').trim();
// Пробуем проверить наличие ключа на VDS через SSH с ключом
const sshOptions = `-p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5`;
const checkCommand = `grep -Fx "${publicKey}" /root/.ssh/authorized_keys > /dev/null 2>&1 && echo "exists" || echo "not_found"`;
const sshCheckCommand = `ssh -i "${privateKeyPath}" ${sshOptions} ${sshUser}@${sshHost} "${checkCommand}"`;
try {
const { stdout } = await execAsync(sshCheckCommand);
if (stdout.trim() === 'exists') {
logger.info(`[VDS] Публичный ключ уже присутствует на VDS для ${sshUser}@${sshHost}`);
return true;
}
} catch (error) {
// Если не удалось подключиться с ключом, значит ключ не добавлен
logger.warn(`[VDS] Не удалось проверить наличие ключа на VDS: ${error.message}`);
}
// Если ключа нет и есть пароль, добавляем его
if (sshPassword && sshPassword.trim() !== '') {
logger.info(`[VDS] Публичный ключ отсутствует на VDS. Пытаемся добавить через пароль...`);
const addKeyCommand = `mkdir -p /root/.ssh && chmod 700 /root/.ssh && echo "${publicKey}" >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && chown root:root /root/.ssh/authorized_keys && echo "success"`;
const sshAddCommand = `sshpass -p "${sshPassword.replace(/"/g, '\\"')}" ssh ${sshOptions} ${sshUser}@${sshHost} "${addKeyCommand}"`;
try {
const { stdout, stderr } = await execAsync(sshAddCommand);
if (stdout.includes('success')) {
logger.success(`[VDS] Публичный ключ успешно добавлен на VDS для ${sshUser}@${sshHost}`);
return true;
}
} catch (error) {
logger.error(`[VDS] Не удалось добавить публичный ключ на VDS: ${error.message}`);
}
} else {
logger.warn(`[VDS] Публичный ключ отсутствует на VDS, но пароль не указан. Невозможно добавить ключ автоматически.`);
}
return false;
}
/**
* Выполнить SSH команду на VDS
* Использует SSH ключ из /root/.ssh/id_rsa (монтируется из ~/.ssh хоста)
* ВАЖНО: Всегда используем root для подключения, так как публичный ключ добавляется для root при настройке VDS
*/
async function execSshCommandOnVds(command, settings) {
const { sshHost, sshPort = 22, sshUser } = settings;
const { sshHost, sshPort = 22 } = settings;
// ВСЕГДА используем root для SSH подключения, так как публичный ключ добавляется для root
// при настройке VDS через setupRootSshKeys в webssh-agent
const sshUser = 'root';
// Экранируем команду для SSH
// Экранируем двойные кавычки и знаки доллара для правильной передачи через SSH
@@ -304,18 +373,87 @@ async function execSshCommandOnVds(command, settings) {
.replace(/\$/g, '\\$') // Экранируем знаки доллара
.replace(/"/g, '\\"'); // Экранируем двойные кавычки
// Базовые опции SSH - используем только SSH ключи, пароли не поддерживаются
const sshOptions = `-p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=no`;
// Базовые опции SSH
const sshOptions = `-p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR`;
// Строим SSH команду - всегда используем SSH ключи
// SSH автоматически найдет ключ в ~/.ssh/id_rsa или ~/.ssh/id_ed25519
const sshCommand = `ssh ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`;
// Явно указываем путь к приватному ключу
// Ключ должен быть в /root/.ssh/id_rsa (монтируется из ~/.ssh хоста через docker-compose)
const privateKeyPath = '/root/.ssh/id_rsa';
const fs = require('fs');
try {
const { stdout, stderr } = await execAsync(sshCommand);
return { code: 0, stdout, stderr };
} catch (error) {
return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message };
// Проверяем существование ключа и используем его явно
if (fs.existsSync(privateKeyPath)) {
// Проверяем права доступа к ключу
const keyStats = fs.statSync(privateKeyPath);
const keyMode = (keyStats.mode & parseInt('777', 8)).toString(8);
logger.info(`[VDS] SSH ключ найден: ${privateKeyPath}, права: ${keyMode}`);
// Используем явный путь к ключу с опцией -i
// Публичный ключ добавляется для root при настройке VDS через setupRootSshKeys
const sshCommand = `ssh -i "${privateKeyPath}" ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`;
logger.info(`[VDS] Используем SSH ключ: ${privateKeyPath} для подключения к ${sshUser}@${sshHost}:${sshPort}`);
// Читаем публичный ключ для диагностики
const publicKeyPath = `${privateKeyPath}.pub`;
if (fs.existsSync(publicKeyPath)) {
const publicKey = fs.readFileSync(publicKeyPath, 'utf8').trim();
logger.info(`[VDS] Публичный ключ (первые 50 символов): ${publicKey.substring(0, 50)}...`);
logger.info(`[VDS] ВАЖНО: Этот публичный ключ должен быть добавлен в /root/.ssh/authorized_keys на VDS сервере ${sshHost}`);
}
try {
logger.info(`[VDS] Выполняем SSH команду (первые 200 символов): ${sshCommand.substring(0, 200)}...`);
const { stdout, stderr } = await execAsync(sshCommand, { maxBuffer: 10 * 1024 * 1024 }); // 10MB буфер
logger.info(`[VDS] SSH команда выполнена успешно. stdout длина: ${stdout?.length || 0}, stderr длина: ${stderr?.length || 0}`);
return { code: 0, stdout, stderr };
} catch (error) {
logger.error(`[VDS] Ошибка SSH подключения с ключом ${privateKeyPath}:`, error.message);
logger.error(`[VDS] Пытаемся подключиться к: ${sshUser}@${sshHost}:${sshPort}`);
logger.error(`[VDS] error.code: ${error.code || 'не указан'}`);
logger.error(`[VDS] error.stdout: ${error.stdout || '(пусто)'}`);
logger.error(`[VDS] error.stderr: ${error.stderr || '(пусто)'}`);
logger.error(`[VDS] Полная команда SSH (первые 500 символов): ${sshCommand.substring(0, 500)}...`);
// Если ошибка "Permission denied", возможно ключ не добавлен на VDS
// Пробуем добавить ключ автоматически (если есть пароль)
const errorMessage = (error.stderr || error.message || '').toLowerCase();
if (errorMessage.includes('permission denied') || errorMessage.includes('publickey')) {
logger.warn(`[VDS] Permission denied. Пробуем добавить публичный ключ на VDS...`);
const keyAdded = await ensureSshKeyOnVds(settings);
if (keyAdded) {
// Пробуем подключиться снова
try {
const { stdout, stderr } = await execAsync(sshCommand, { maxBuffer: 10 * 1024 * 1024 });
logger.success(`[VDS] Подключение успешно после добавления ключа`);
return { code: 0, stdout, stderr };
} catch (retryError) {
logger.error(`[VDS] Ошибка SSH подключения после добавления ключа:`, retryError.message);
logger.error(`[VDS] retryError.stdout: ${retryError.stdout || '(пусто)'}`);
logger.error(`[VDS] retryError.stderr: ${retryError.stderr || '(пусто)'}`);
}
} else {
logger.error(`[VDS] Не удалось добавить публичный ключ на VDS. Убедитесь, что пароль указан в настройках или выполните настройку VDS через webssh-agent.`);
}
}
logger.error(`[VDS] Убедитесь, что публичный ключ из ${privateKeyPath}.pub добавлен в /root/.ssh/authorized_keys на VDS`);
return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message };
}
} else {
// Если ключа нет, пробуем без явного указания (SSH сам найдет)
logger.warn(`[VDS] SSH ключ не найден в ${privateKeyPath}, пробуем без явного указания`);
const sshCommand = `ssh ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`;
try {
const { stdout, stderr } = await execAsync(sshCommand, { maxBuffer: 10 * 1024 * 1024 });
return { code: 0, stdout, stderr };
} catch (error) {
logger.error(`[VDS] Ошибка SSH подключения:`, error.message);
logger.error(`[VDS] error.code: ${error.code || 'не указан'}`);
logger.error(`[VDS] error.stdout: ${error.stdout || '(пусто)'}`);
logger.error(`[VDS] error.stderr: ${error.stderr || '(пусто)'}`);
return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message };
}
}
}
@@ -1079,25 +1217,90 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT
// Проверяем, используется ли Docker certbot
const dockerUser = vdsSettings.dockerUser || 'docker';
const domain = vdsSettings.domain || vdsSettings.sshHost;
// Используем только Let's Encrypt (работает без аккаунта)
const sslProvider = 'letsencrypt';
// Используем путь из настроек или значение по умолчанию на основе dockerUser
let dappPath = vdsSettings.dappPath || `/home/${dockerUser}/dapp`;
// Проверяем существование пути и файла docker-compose.prod.yml
const pathCheckResult = await execDockerCommand(`test -d ${dappPath} && test -f ${dappPath}/docker-compose.prod.yml && echo "exists" || echo "not_exists"`);
if (pathCheckResult.stdout && pathCheckResult.stdout.includes('not_exists')) {
logger.warn(`[VDS] Путь ${dappPath} или файл docker-compose.prod.yml не найден, ищем...`);
// Ищем docker-compose.prod.yml на VDS
const findResult = await execDockerCommand(`find /home /root -name "docker-compose.prod.yml" -type f 2>/dev/null | head -1`);
if (findResult.stdout && findResult.stdout.trim()) {
const foundPath = findResult.stdout.trim().replace('/docker-compose.prod.yml', '');
logger.info(`[VDS] Найден docker-compose.prod.yml в: ${foundPath}`);
dappPath = foundPath;
} else {
logger.error(`[VDS] docker-compose.prod.yml не найден на VDS сервере`);
return res.status(400).json({
success: false,
error: `Путь ${dappPath} не существует или файл docker-compose.prod.yml не найден. Проверьте настройки VDS и укажите правильный путь.`
});
}
}
// Используем только Let's Encrypt (работает без аккаунта)
logger.info(`[VDS] Используем провайдер SSL: Let's Encrypt, путь: ${dappPath}`);
// Проверяем статус сертификата через Docker certbot
const checkResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`);
const checkCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`;
const checkResult = await execDockerCommand(checkCommand);
if (checkResult.code !== 0) {
logger.warn('[VDS] Ошибка проверки сертификатов:', checkResult.stderr);
}
let hasValidCert = false;
if (checkResult.stdout && checkResult.stdout.includes(domain)) {
const certLines = checkResult.stdout.split('\n');
for (let i = 0; i < certLines.length; i++) {
if (certLines[i].includes('Domains:') && certLines[i].includes(domain)) {
for (let j = i + 1; j < Math.min(i + 10, certLines.length); j++) {
if (certLines[j].includes('Expiry Date:')) {
const expiryDateStr = certLines[j].split('Expiry Date:')[1]?.trim();
if (expiryDateStr) {
const expiryDate = new Date(expiryDateStr);
const now = new Date();
if (expiryDate > new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)) {
hasValidCert = true;
logger.info(`[VDS] Найден действующий сертификат для ${domain}, истекает: ${expiryDateStr}`);
}
}
break;
}
}
break;
}
}
}
// Пытаемся обновить сертификат через Docker certbot
logger.info('[VDS] Обновление SSL сертификата...');
// Сначала пробуем renew --force-renewal для обновления существующего сертификата
// Это не создает новый сертификат и не попадает под лимит Let's Encrypt
let renewResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot renew --force-renewal --non-interactive 2>&1 || certbot renew --force-renewal --non-interactive 2>&1`);
// Сначала пробуем renew (без --force-renewal) для обновления существующего сертификата
const renewCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot renew --non-interactive 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot renew --non-interactive 2>&1 || certbot renew --non-interactive 2>&1`;
let renewResult = await execDockerCommand(renewCommand);
if (hasValidCert && renewResult.code === 0) {
logger.info('[VDS] Используем существующий валидный сертификат');
const reloadResult = await execDockerCommand(`cd ${dappPath} && (docker compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || docker-compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || systemctl reload nginx 2>&1)`);
logger.info('[VDS] SSL сертификат обновлен (renew)');
return res.json({
success: true,
message: 'SSL сертификат обновлен (использован существующий)',
output: renewResult.stdout,
reloadOutput: reloadResult.stdout
});
}
// Если renew не сработал (сертификат не найден или другая ошибка), создаем новый
if (renewResult.code !== 0 || renewResult.stdout.includes('No renewals were attempted') || renewResult.stdout.includes('No certs found')) {
logger.info('[VDS] Renew не сработал, создаем новый сертификат...');
if (!hasValidCert && (renewResult.code !== 0 || renewResult.stdout.includes('No renewals were attempted') || renewResult.stdout.includes('No certs found'))) {
logger.info('[VDS] Renew не сработал и нет валидного сертификата, создаем новый...');
// Удаляем только сертификаты с суффиксами, основной оставляем
const certListResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`);
const certListResult = await execDockerCommand(checkCommand);
if (certListResult.stdout) {
const lines = certListResult.stdout.split('\n');
const certNames = [];
@@ -1113,21 +1316,45 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT
// Удаляем только сертификаты с суффиксами
for (const certName of certNames) {
logger.info(`[VDS] Удаление старого сертификата с суффиксом: ${certName}`);
await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || true`);
const deleteCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || true`;
await execDockerCommand(deleteCommand);
}
}
// Создаем новый сертификат только если его нет
const email = vdsSettings.email || 'admin@example.com';
renewResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certonly --webroot --webroot-path=/var/www/certbot --email ${email} --agree-tos --no-eff-email --non-interactive -d ${domain} 2>&1 || certbot certonly --webroot --webroot-path=/var/www/certbot --email ${email} --agree-tos --no-eff-email --non-interactive -d ${domain} 2>&1`);
const certCommand = `cd ${dappPath} && (docker compose -f docker-compose.prod.yml run --rm certbot certonly --webroot --webroot-path=/var/www/certbot --email ${email} --agree-tos --no-eff-email --non-interactive -d ${domain} 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot certonly --webroot --webroot-path=/var/www/certbot --email ${email} --agree-tos --no-eff-email --non-interactive -d ${domain} 2>&1 || certbot certonly --webroot --webroot-path=/var/www/certbot --email ${email} --agree-tos --no-eff-email --non-interactive -d ${domain} 2>&1)`;
logger.info(`[VDS] Команда создания сертификата: ${certCommand.substring(0, 300)}...`);
renewResult = await execDockerCommand(certCommand);
logger.info(`[VDS] Результат создания сертификата: code=${renewResult.code}, stdout длина=${renewResult.stdout?.length || 0}, stderr длина=${renewResult.stderr?.length || 0}`);
if (renewResult.code !== 0 && (renewResult.stderr || renewResult.stdout)) {
const errorOutput = (renewResult.stderr || renewResult.stdout).toLowerCase();
if (errorOutput.includes('too many certificates') || errorOutput.includes('rate limit')) {
logger.error('[VDS] Превышен лимит Let\'s Encrypt для домена');
return res.status(429).json({
success: false,
error: 'Превышен лимит Let\'s Encrypt: слишком много сертификатов выпущено за последние 7 дней. Пожалуйста, подождите до указанной даты или используйте существующий сертификат.',
details: renewResult.stderr || renewResult.stdout,
rateLimit: true
});
}
}
} else if (hasValidCert) {
logger.info('[VDS] Используем существующий валидный сертификат (renew не требуется)');
return res.json({
success: true,
message: 'Используется существующий валидный SSL сертификат',
existingCert: true
});
}
if (renewResult.code === 0) {
// Перезапускаем nginx для применения нового сертификата
const reloadResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || systemctl reload nginx 2>&1`);
const reloadResult = await execDockerCommand(`cd ${dappPath} && (docker compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || docker-compose -f docker-compose.prod.yml restart frontend-nginx 2>&1 || systemctl reload nginx 2>&1)`);
// Очищаем старые сертификаты с суффиксами, чтобы они не накапливались
logger.info('[VDS] Очистка старых сертификатов с суффиксами...');
const certListAfter = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`);
const certListAfter = await execDockerCommand(checkCommand);
if (certListAfter.stdout) {
const lines = certListAfter.stdout.split('\n');
for (let i = 0; i < lines.length; i++) {
@@ -1136,7 +1363,8 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT
// Удаляем сертификаты с суффиксами (например, hb3-accelerator.com-0001, hb3-accelerator.com-0002)
if (certName && certName !== domain && certName.startsWith(domain + '-')) {
logger.info(`[VDS] Удаление старого сертификата с суффиксом: ${certName}`);
await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || true`);
const deleteCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot delete --cert-name ${certName} --non-interactive 2>&1 || true`;
await execDockerCommand(deleteCommand);
}
}
}
@@ -1150,11 +1378,28 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT
reloadOutput: reloadResult.stdout
});
} else {
logger.error('[VDS] Ошибка обновления SSL сертификата:', renewResult.stderr);
logger.error('[VDS] Ошибка обновления SSL сертификата:', renewResult.stderr || renewResult.stdout || 'Неизвестная ошибка');
const errorDetails = renewResult.stderr || renewResult.stdout || 'Неизвестная ошибка';
const errorMessage = `Command failed: ${errorDetails}`;
const errorOutput = errorDetails.toLowerCase();
if (errorOutput.includes('too many certificates') || errorOutput.includes('rate limit')) {
return res.status(429).json({
success: false,
error: 'Превышен лимит Let\'s Encrypt: слишком много сертификатов выпущено за последние 7 дней. Пожалуйста, подождите до указанной даты или используйте существующий сертификат.',
details: errorMessage,
stdout: renewResult.stdout || '',
stderr: renewResult.stderr || '',
code: renewResult.code || 1,
rateLimit: true
});
}
res.status(500).json({
success: false,
error: 'Не удалось обновить SSL сертификат',
details: renewResult.stderr
details: errorMessage,
stdout: renewResult.stdout || '',
stderr: renewResult.stderr || '',
code: renewResult.code || 1
});
}
} catch (error) {
@@ -1163,6 +1408,41 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT
}
});
/**
* Проверить путь к docker-compose на VDS
*/
router.get('/check-dapp-path', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => {
try {
const vdsSettings = await getVdsSettings();
if (!vdsSettings) {
return res.status(400).json({ success: false, error: 'VDS не настроена' });
}
const dockerUser = vdsSettings.dockerUser || 'docker';
const configuredPath = vdsSettings.dappPath || `/home/${dockerUser}/dapp`;
// Проверяем указанный путь
const pathCheck = await execDockerCommand(`test -d ${configuredPath} && test -f ${configuredPath}/docker-compose.prod.yml && echo "exists" || echo "not_exists"`);
// Ищем docker-compose.prod.yml на VDS
const findResult = await execDockerCommand(`find /home /root -name "docker-compose.prod.yml" -type f 2>/dev/null`);
const foundPaths = findResult.stdout ? findResult.stdout.trim().split('\n').filter(p => p).map(p => p.replace('/docker-compose.prod.yml', '')) : [];
res.json({
success: true,
configuredPath,
configuredPathExists: pathCheck.stdout && pathCheck.stdout.includes('exists'),
foundPaths: foundPaths,
recommendedPath: foundPaths.length > 0 ? foundPaths[0] : null
});
} catch (error) {
logger.error('[VDS] Ошибка проверки пути:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* Получить статус SSL сертификата
*/
@@ -1177,8 +1457,33 @@ router.get('/ssl/status', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT
const dockerUser = vdsSettings.dockerUser || 'docker';
const domain = vdsSettings.domain || vdsSettings.sshHost;
// Используем путь из настроек или значение по умолчанию (проверено: /home/docker/dapp)
let dappPath = vdsSettings.dappPath || `/home/${dockerUser}/dapp`;
// Проверяем существование пути и файла docker-compose.prod.yml
const pathCheckResult = await execDockerCommand(`test -d ${dappPath} && test -f ${dappPath}/docker-compose.prod.yml && echo "exists" || echo "not_exists"`);
if (pathCheckResult.stdout && pathCheckResult.stdout.includes('not_exists')) {
logger.warn(`[VDS] Путь ${dappPath} или файл docker-compose.prod.yml не найден, ищем...`);
// Ищем docker-compose.prod.yml на VDS
const findResult = await execDockerCommand(`find /home /root -name "docker-compose.prod.yml" -type f 2>/dev/null | head -1`);
if (findResult.stdout && findResult.stdout.trim()) {
const foundPath = findResult.stdout.trim().replace('/docker-compose.prod.yml', '');
logger.info(`[VDS] Найден docker-compose.prod.yml в: ${foundPath}`);
dappPath = foundPath;
} else {
logger.error(`[VDS] docker-compose.prod.yml не найден на VDS сервере`);
return res.status(400).json({
success: false,
error: `Путь ${dappPath} не существует или файл docker-compose.prod.yml не найден. Проверьте настройки VDS и укажите правильный путь.`
});
}
}
// Используем только Let's Encrypt (работает без аккаунта)
// Проверяем статус сертификата через Docker certbot
const checkResult = await execDockerCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`);
const checkCommand = `cd ${dappPath} && docker compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || docker-compose -f docker-compose.prod.yml run --rm certbot certificates 2>&1 || certbot certificates 2>&1`;
const checkResult = await execDockerCommand(checkCommand);
// Проверяем срок действия сертификата
let certInfo = null;

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 - подключение кошелька