ваше сообщение коммита
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
3
backend/.gitignore
vendored
@@ -43,3 +43,6 @@ yarn-error.log*
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Конфиденциальные/служебные константы backend
|
||||
constants/
|
||||
@@ -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/*
|
||||
|
||||
@@ -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: 'Требуется аутентификация' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
if (!rows || rows.length === 0) {
|
||||
return res.json({ success: true, settings: null });
|
||||
}
|
||||
|
||||
res.json({
|
||||
const row = rows[0];
|
||||
|
||||
// encryptedDb.getData возвращает расшифрованные поля БЕЗ суффикса _encrypted
|
||||
// Например: domain_encrypted в БД -> row.domain в результате
|
||||
return 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
|
||||
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 (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; // Пробрасываем другие ошибки
|
||||
}
|
||||
} 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,20 +373,89 @@ 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
|
||||
// Явно указываем путь к приватному ключу
|
||||
// Ключ должен быть в /root/.ssh/id_rsa (монтируется из ~/.ssh хоста через docker-compose)
|
||||
const privateKeyPath = '/root/.ssh/id_rsa';
|
||||
const fs = require('fs');
|
||||
|
||||
// Проверяем существование ключа и используем его явно
|
||||
if (fs.existsSync(privateKeyPath)) {
|
||||
// Проверяем права доступа к ключу
|
||||
const keyStats = fs.statSync(privateKeyPath);
|
||||
const keyMode = (keyStats.mode & parseInt('777', 8)).toString(8);
|
||||
logger.info(`[VDS] SSH ключ найден: ${privateKeyPath}, права: ${keyMode}`);
|
||||
|
||||
// Используем явный путь к ключу с опцией -i
|
||||
// Публичный ключ добавляется для root при настройке VDS через setupRootSshKeys
|
||||
const sshCommand = `ssh -i "${privateKeyPath}" ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`;
|
||||
logger.info(`[VDS] Используем SSH ключ: ${privateKeyPath} для подключения к ${sshUser}@${sshHost}:${sshPort}`);
|
||||
|
||||
// Читаем публичный ключ для диагностики
|
||||
const publicKeyPath = `${privateKeyPath}.pub`;
|
||||
if (fs.existsSync(publicKeyPath)) {
|
||||
const publicKey = fs.readFileSync(publicKeyPath, 'utf8').trim();
|
||||
logger.info(`[VDS] Публичный ключ (первые 50 символов): ${publicKey.substring(0, 50)}...`);
|
||||
logger.info(`[VDS] ВАЖНО: Этот публичный ключ должен быть добавлен в /root/.ssh/authorized_keys на VDS сервере ${sshHost}`);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`[VDS] Выполняем SSH команду (первые 200 символов): ${sshCommand.substring(0, 200)}...`);
|
||||
const { stdout, stderr } = await execAsync(sshCommand, { maxBuffer: 10 * 1024 * 1024 }); // 10MB буфер
|
||||
logger.info(`[VDS] SSH команда выполнена успешно. stdout длина: ${stdout?.length || 0}, stderr длина: ${stderr?.length || 0}`);
|
||||
return { code: 0, stdout, stderr };
|
||||
} catch (error) {
|
||||
logger.error(`[VDS] Ошибка SSH подключения с ключом ${privateKeyPath}:`, error.message);
|
||||
logger.error(`[VDS] Пытаемся подключиться к: ${sshUser}@${sshHost}:${sshPort}`);
|
||||
logger.error(`[VDS] error.code: ${error.code || 'не указан'}`);
|
||||
logger.error(`[VDS] error.stdout: ${error.stdout || '(пусто)'}`);
|
||||
logger.error(`[VDS] error.stderr: ${error.stderr || '(пусто)'}`);
|
||||
logger.error(`[VDS] Полная команда SSH (первые 500 символов): ${sshCommand.substring(0, 500)}...`);
|
||||
|
||||
// Если ошибка "Permission denied", возможно ключ не добавлен на VDS
|
||||
// Пробуем добавить ключ автоматически (если есть пароль)
|
||||
const errorMessage = (error.stderr || error.message || '').toLowerCase();
|
||||
if (errorMessage.includes('permission denied') || errorMessage.includes('publickey')) {
|
||||
logger.warn(`[VDS] Permission denied. Пробуем добавить публичный ключ на VDS...`);
|
||||
const keyAdded = await ensureSshKeyOnVds(settings);
|
||||
if (keyAdded) {
|
||||
// Пробуем подключиться снова
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(sshCommand, { maxBuffer: 10 * 1024 * 1024 });
|
||||
logger.success(`[VDS] Подключение успешно после добавления ключа`);
|
||||
return { code: 0, stdout, stderr };
|
||||
} catch (retryError) {
|
||||
logger.error(`[VDS] Ошибка SSH подключения после добавления ключа:`, retryError.message);
|
||||
logger.error(`[VDS] retryError.stdout: ${retryError.stdout || '(пусто)'}`);
|
||||
logger.error(`[VDS] retryError.stderr: ${retryError.stderr || '(пусто)'}`);
|
||||
}
|
||||
} else {
|
||||
logger.error(`[VDS] Не удалось добавить публичный ключ на VDS. Убедитесь, что пароль указан в настройках или выполните настройку VDS через webssh-agent.`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[VDS] Убедитесь, что публичный ключ из ${privateKeyPath}.pub добавлен в /root/.ssh/authorized_keys на VDS`);
|
||||
return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message };
|
||||
}
|
||||
} else {
|
||||
// Если ключа нет, пробуем без явного указания (SSH сам найдет)
|
||||
logger.warn(`[VDS] SSH ключ не найден в ${privateKeyPath}, пробуем без явного указания`);
|
||||
const sshCommand = `ssh ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(sshCommand);
|
||||
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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -136,8 +136,8 @@ class TelegramBot {
|
||||
setupHandlers() {
|
||||
// Обработчик команды /start
|
||||
this.bot.command('start', (ctx) => {
|
||||
logger.info('[TelegramBot] 📨 Получена команда /start');
|
||||
ctx.reply('Добро пожаловать! Отправьте код подтверждения для аутентификации.');
|
||||
logger.info('[TelegramBot] 📨 Получена команда /start (без онбординг-текста)');
|
||||
// По запросу: не отправляем онбординг-текст пользователю
|
||||
});
|
||||
|
||||
// Обработчик команды /connect - подключение кошелька
|
||||
|
||||
@@ -253,25 +253,14 @@ const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
isLoading.value = true;
|
||||
addLog('info', 'Запуск настройки VDS...');
|
||||
try {
|
||||
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 как настроенного
|
||||
localStorage.setItem('vds-config', JSON.stringify({
|
||||
isConfigured: true,
|
||||
domain: form.domain
|
||||
}));
|
||||
|
||||
// Сохраняем ВСЕ настройки на сервере
|
||||
try {
|
||||
// 1. Сначала всегда сохраняем настройки в БД
|
||||
addLog('info', 'Сохранение настроек VDS на сервере...');
|
||||
const response = await axios.post('/api/vds/settings', {
|
||||
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,
|
||||
@@ -283,7 +272,7 @@ const handleSubmit = async () => {
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
addLog('success', '✅ Настройки VDS успешно сохранены на сервере');
|
||||
addLog('success', '✅ Настройки VDS сохранены в базе данных');
|
||||
} else {
|
||||
addLog('error', `❌ Ошибка сохранения настроек: ${response.data?.error || 'Неизвестная ошибка'}`);
|
||||
}
|
||||
@@ -291,16 +280,25 @@ const handleSubmit = async () => {
|
||||
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 через агента
|
||||
}
|
||||
|
||||
// 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 как настроенного локально
|
||||
localStorage.setItem('vds-config', JSON.stringify({
|
||||
isConfigured: true,
|
||||
domain: form.domain
|
||||
}));
|
||||
|
||||
// Отправляем событие об изменении статуса VDS
|
||||
window.dispatchEvent(new CustomEvent('vds-status-changed', {
|
||||
detail: { isConfigured: true }
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}%`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
message: 'WebSSH Agent не запущен. Убедитесь, что контейнер dapp-webssh-agent работает (docker compose up -d webssh-agent).'
|
||||
};
|
||||
} catch (error) {
|
||||
// console.error('Ошибка при создании установочного скрипта:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Ошибка при подготовке установки агента',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
// Проверяем, что ключ действительно добавлен
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 если существует
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user