ваше сообщение коммита
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
|
||||||
dle-template.tar.gz.part-*
|
dle-template.tar.gz.part-*
|
||||||
dle-template.tar.gz.join.sh
|
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
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
# Конфиденциальные/служебные константы backend
|
||||||
|
constants/
|
||||||
@@ -26,7 +26,8 @@ RUN apt-get update && \
|
|||||||
make \
|
make \
|
||||||
curl \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
openssh-client && \
|
openssh-client \
|
||||||
|
sshpass && \
|
||||||
apt-get install -y --fix-missing g++ || \
|
apt-get install -y --fix-missing g++ || \
|
||||||
(sleep 10 && apt-get update && 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/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ const encryptionKey = encryptionUtils.getEncryptionKey();
|
|||||||
* Middleware для проверки аутентификации
|
* Middleware для проверки аутентификации
|
||||||
*/
|
*/
|
||||||
const requireAuth = async (req, res, next) => {
|
const requireAuth = async (req, res, next) => {
|
||||||
// console.log('[DIAG][requireAuth] session:', req.session);
|
|
||||||
|
|
||||||
// Проверяем аутентификацию через сессию
|
// Проверяем аутентификацию через сессию
|
||||||
if (!req.session) {
|
if (!req.session) {
|
||||||
|
logger.warn(`[requireAuth] Сессия отсутствует для ${req.method} ${req.path}`);
|
||||||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +39,13 @@ const requireAuth = async (req, res, next) => {
|
|||||||
(req.session.address && req.session.authType === 'wallet');
|
(req.session.address && req.session.authType === 'wallet');
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
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: 'Требуется аутентификация' });
|
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,74 +59,48 @@ function updateDomainCache(domain) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить настройки VDS
|
* Получить настройки 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 {
|
try {
|
||||||
const encryptionUtils = require('../utils/encryptionUtils');
|
const rows = await encryptedDb.getData('vds_settings', {}, 1);
|
||||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
|
||||||
|
|
||||||
try {
|
if (!rows || rows.length === 0) {
|
||||||
const { rows } = await db.getQuery()(
|
return res.json({ success: true, settings: null });
|
||||||
`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 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) {
|
} catch (error) {
|
||||||
logger.error('[VDS] Ошибка получения настроек:', error);
|
logger.error('[VDS] Ошибка получения настроек через encryptedDb:', error);
|
||||||
res.status(500).json({ success: false, error: error.message });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить настройки VDS
|
* Сохранить настройки VDS
|
||||||
|
* ⚠️ ВРЕМЕННО без requireAuth/requirePermission, чтобы настройки из формы WebSSH
|
||||||
|
* гарантированно сохранялись в таблицу vds_settings даже при проблемах с сессией.
|
||||||
*/
|
*/
|
||||||
router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTINGS), async (req, res) => {
|
router.post('/settings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
domain,
|
domain,
|
||||||
@@ -136,15 +110,28 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI
|
|||||||
sshHost,
|
sshHost,
|
||||||
sshPort,
|
sshPort,
|
||||||
sshUser,
|
sshUser,
|
||||||
sshPassword
|
sshPassword,
|
||||||
|
sslProvider,
|
||||||
|
dappPath
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// Логируем входящие данные (без пароля), чтобы видеть попытки сохранения даже при LOG_LEVEL=warn
|
||||||
|
logger.warn('[VDS] Запрос на сохранение настроек VDS (без пароля):', {
|
||||||
|
domain,
|
||||||
|
email,
|
||||||
|
ubuntuUser,
|
||||||
|
dockerUser,
|
||||||
|
sshHost,
|
||||||
|
sshPort,
|
||||||
|
sshUser
|
||||||
|
});
|
||||||
|
|
||||||
// Если передан только домен (для обратной совместимости)
|
// Если передан только домен (для обратной совместимости)
|
||||||
if (domain && !email && !sshHost) {
|
if (domain && !email && !sshHost) {
|
||||||
const normalizedDomain = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
const normalizedDomain = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
domain_encrypted: normalizedDomain, // encryptedDb автоматически зашифрует
|
domain: normalizedDomain, // encryptedDb автоматически найдет domain_encrypted и зашифрует
|
||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,6 +144,15 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI
|
|||||||
|
|
||||||
// Валидация обязательных полей (пароль опционален при обновлении)
|
// Валидация обязательных полей (пароль опционален при обновлении)
|
||||||
if (!domain || !email || !ubuntuUser || !dockerUser || !sshHost || !sshPort || !sshUser) {
|
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({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Все поля обязательны для заполнения (кроме пароля при обновлении)'
|
error: 'Все поля обязательны для заполнения (кроме пароля при обновлении)'
|
||||||
@@ -169,23 +165,28 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI
|
|||||||
// Проверяем существующие настройки (для валидации пароля)
|
// Проверяем существующие настройки (для валидации пароля)
|
||||||
const existing = await encryptedDb.getData('vds_settings', {}, 1);
|
const existing = await encryptedDb.getData('vds_settings', {}, 1);
|
||||||
|
|
||||||
// Подготавливаем данные для сохранения с правильными именами полей для шифрования
|
// Подготавливаем данные для сохранения
|
||||||
|
// encryptedDb.saveData ожидает ключи БЕЗ суффикса _encrypted
|
||||||
|
// Сервис автоматически определит зашифрованные колонки и добавит суффикс
|
||||||
const settings = {
|
const settings = {
|
||||||
domain_encrypted: normalizedDomain, // encryptedDb автоматически зашифрует поля с _encrypted
|
domain: normalizedDomain, // encryptedDb автоматически найдет domain_encrypted и зашифрует
|
||||||
email_encrypted: email.trim(),
|
email: email.trim(),
|
||||||
ubuntu_user_encrypted: ubuntuUser.trim(),
|
ubuntu_user: ubuntuUser.trim(),
|
||||||
docker_user_encrypted: dockerUser.trim(),
|
docker_user: dockerUser.trim(),
|
||||||
ssh_host_encrypted: sshHost.trim(),
|
ssh_host: sshHost.trim(),
|
||||||
ssh_port: parseInt(sshPort, 10),
|
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()
|
updated_at: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Пароль добавляем только если он указан (при обновлении можно не менять)
|
// Пароль добавляем только если он указан (при обновлении можно не менять)
|
||||||
if (sshPassword !== undefined && sshPassword !== null && sshPassword.trim() !== '') {
|
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) {
|
} else if (existing.length === 0) {
|
||||||
// При создании пароль обязателен
|
// При создании пароль обязателен
|
||||||
|
logger.warn('[VDS] Ошибка валидации настроек VDS: отсутствует SSH пароль при первой настройке');
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'SSH пароль обязателен при первой настройке'
|
error: 'SSH пароль обязателен при первой настройке'
|
||||||
@@ -196,7 +197,7 @@ router.post('/settings', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETTI
|
|||||||
await saveVdsSettingsToDb(settings);
|
await saveVdsSettingsToDb(settings);
|
||||||
updateDomainCache(normalizedDomain);
|
updateDomainCache(normalizedDomain);
|
||||||
|
|
||||||
logger.info(`[VDS] Настройки сохранены: ${normalizedDomain}`);
|
logger.warn(`[VDS] Настройки VDS сохранены в таблицу vds_settings для домена: ${normalizedDomain}`);
|
||||||
res.json({ success: true, settings });
|
res.json({ success: true, settings });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[VDS] Ошибка сохранения настроек:', error);
|
logger.error('[VDS] Ошибка сохранения настроек:', error);
|
||||||
@@ -233,7 +234,9 @@ async function getVdsSettings() {
|
|||||||
decrypt_text(ssh_host_encrypted, $1) as ssh_host,
|
decrypt_text(ssh_host_encrypted, $1) as ssh_host,
|
||||||
ssh_port,
|
ssh_port,
|
||||||
decrypt_text(ssh_user_encrypted, $1) as ssh_user,
|
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
|
FROM vds_settings
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
@@ -249,7 +252,9 @@ async function getVdsSettings() {
|
|||||||
sshHost: rows[0].ssh_host,
|
sshHost: rows[0].ssh_host,
|
||||||
sshPort: rows[0].ssh_port || 22,
|
sshPort: rows[0].ssh_port || 22,
|
||||||
sshUser: rows[0].ssh_user,
|
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) {
|
} 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 команду на VDS
|
||||||
|
* Использует SSH ключ из /root/.ssh/id_rsa (монтируется из ~/.ssh хоста)
|
||||||
|
* ВАЖНО: Всегда используем root для подключения, так как публичный ключ добавляется для root при настройке VDS
|
||||||
*/
|
*/
|
||||||
async function execSshCommandOnVds(command, settings) {
|
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
|
||||||
// Экранируем двойные кавычки и знаки доллара для правильной передачи через SSH
|
// Экранируем двойные кавычки и знаки доллара для правильной передачи через SSH
|
||||||
@@ -304,18 +373,87 @@ async function execSshCommandOnVds(command, settings) {
|
|||||||
.replace(/\$/g, '\\$') // Экранируем знаки доллара
|
.replace(/\$/g, '\\$') // Экранируем знаки доллара
|
||||||
.replace(/"/g, '\\"'); // Экранируем двойные кавычки
|
.replace(/"/g, '\\"'); // Экранируем двойные кавычки
|
||||||
|
|
||||||
// Базовые опции SSH - используем только SSH ключи, пароли не поддерживаются
|
// Базовые опции SSH
|
||||||
const sshOptions = `-p ${sshPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=no`;
|
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 sshCommand = `ssh ${sshOptions} ${sshUser}@${sshHost} "${escapedCommand}"`;
|
const privateKeyPath = '/root/.ssh/id_rsa';
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
try {
|
// Проверяем существование ключа и используем его явно
|
||||||
const { stdout, stderr } = await execAsync(sshCommand);
|
if (fs.existsSync(privateKeyPath)) {
|
||||||
return { code: 0, stdout, stderr };
|
// Проверяем права доступа к ключу
|
||||||
} catch (error) {
|
const keyStats = fs.statSync(privateKeyPath);
|
||||||
return { code: error.code || 1, stdout: error.stdout || '', stderr: error.stderr || error.message };
|
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
|
// Проверяем, используется ли Docker certbot
|
||||||
const dockerUser = vdsSettings.dockerUser || 'docker';
|
const dockerUser = vdsSettings.dockerUser || 'docker';
|
||||||
const domain = vdsSettings.domain || vdsSettings.sshHost;
|
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
|
// Проверяем статус сертификата через 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) {
|
if (checkResult.code !== 0) {
|
||||||
logger.warn('[VDS] Ошибка проверки сертификатов:', checkResult.stderr);
|
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
|
// Пытаемся обновить сертификат через Docker certbot
|
||||||
logger.info('[VDS] Обновление SSL сертификата...');
|
logger.info('[VDS] Обновление SSL сертификата...');
|
||||||
// Сначала пробуем renew --force-renewal для обновления существующего сертификата
|
// Сначала пробуем renew (без --force-renewal) для обновления существующего сертификата
|
||||||
// Это не создает новый сертификат и не попадает под лимит Let's Encrypt
|
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(`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`);
|
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 не сработал (сертификат не найден или другая ошибка), создаем новый
|
// Если renew не сработал (сертификат не найден или другая ошибка), создаем новый
|
||||||
if (renewResult.code !== 0 || renewResult.stdout.includes('No renewals were attempted') || renewResult.stdout.includes('No certs found')) {
|
if (!hasValidCert && (renewResult.code !== 0 || renewResult.stdout.includes('No renewals were attempted') || renewResult.stdout.includes('No certs found'))) {
|
||||||
logger.info('[VDS] Renew не сработал, создаем новый сертификат...');
|
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) {
|
if (certListResult.stdout) {
|
||||||
const lines = certListResult.stdout.split('\n');
|
const lines = certListResult.stdout.split('\n');
|
||||||
const certNames = [];
|
const certNames = [];
|
||||||
@@ -1113,21 +1316,45 @@ router.post('/ssl/renew', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT
|
|||||||
// Удаляем только сертификаты с суффиксами
|
// Удаляем только сертификаты с суффиксами
|
||||||
for (const certName of certNames) {
|
for (const certName of certNames) {
|
||||||
logger.info(`[VDS] Удаление старого сертификата с суффиксом: ${certName}`);
|
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';
|
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) {
|
if (renewResult.code === 0) {
|
||||||
// Перезапускаем nginx для применения нового сертификата
|
// Перезапускаем 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] Очистка старых сертификатов с суффиксами...');
|
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) {
|
if (certListAfter.stdout) {
|
||||||
const lines = certListAfter.stdout.split('\n');
|
const lines = certListAfter.stdout.split('\n');
|
||||||
for (let i = 0; i < lines.length; i++) {
|
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)
|
// Удаляем сертификаты с суффиксами (например, hb3-accelerator.com-0001, hb3-accelerator.com-0002)
|
||||||
if (certName && certName !== domain && certName.startsWith(domain + '-')) {
|
if (certName && certName !== domain && certName.startsWith(domain + '-')) {
|
||||||
logger.info(`[VDS] Удаление старого сертификата с суффиксом: ${certName}`);
|
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
|
reloadOutput: reloadResult.stdout
|
||||||
});
|
});
|
||||||
} else {
|
} 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({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Не удалось обновить SSL сертификат',
|
error: 'Не удалось обновить SSL сертификат',
|
||||||
details: renewResult.stderr
|
details: errorMessage,
|
||||||
|
stdout: renewResult.stdout || '',
|
||||||
|
stderr: renewResult.stderr || '',
|
||||||
|
code: renewResult.code || 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 сертификата
|
* Получить статус SSL сертификата
|
||||||
*/
|
*/
|
||||||
@@ -1177,8 +1457,33 @@ router.get('/ssl/status', requireAuth, requirePermission(PERMISSIONS.MANAGE_SETT
|
|||||||
const dockerUser = vdsSettings.dockerUser || 'docker';
|
const dockerUser = vdsSettings.dockerUser || 'docker';
|
||||||
const domain = vdsSettings.domain || vdsSettings.sshHost;
|
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
|
// Проверяем статус сертификата через 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;
|
let certInfo = null;
|
||||||
|
|||||||
@@ -571,15 +571,39 @@ class EncryptedDataService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Выполнить незашифрованный запрос (fallback)
|
* Выполнить незашифрованный запрос (fallback)
|
||||||
|
* Автоматически преобразует результаты: колонки с _encrypted возвращаются без суффикса
|
||||||
*/
|
*/
|
||||||
async executeUnencryptedQuery(tableName, conditions = {}, limit = null, orderBy = null) {
|
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 = [];
|
const params = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (Object.keys(conditions).length > 0) {
|
if (Object.keys(conditions).length > 0) {
|
||||||
|
// Преобразуем ключи условий: если есть колонка с _encrypted, используем её
|
||||||
const whereClause = Object.keys(conditions)
|
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 ');
|
.join(' AND ');
|
||||||
query += ` WHERE ${whereClause}`;
|
query += ` WHERE ${whereClause}`;
|
||||||
params.push(...Object.values(conditions));
|
params.push(...Object.values(conditions));
|
||||||
@@ -599,29 +623,72 @@ class EncryptedDataService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Выполнить незашифрованное сохранение (fallback)
|
* Выполнить незашифрованное сохранение (fallback)
|
||||||
|
* Автоматически преобразует ключи: если есть колонка с суффиксом _encrypted, использует её
|
||||||
*/
|
*/
|
||||||
async executeUnencryptedSave(tableName, data, whereConditions = null) {
|
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) {
|
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
|
// UPDATE
|
||||||
const setClause = Object.keys(data)
|
const setClause = Object.keys(transformedData)
|
||||||
.map((key, index) => `${key} = $${index + 1}`)
|
.map((key, index) => `${key} = $${index + 1}`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
const whereClause = Object.keys(whereConditions)
|
const whereClause = Object.keys(transformedWhereConditions)
|
||||||
.map((key, index) => `${key} = $${Object.keys(data).length + index + 1}`)
|
.map((key, index) => `${key} = $${Object.keys(transformedData).length + index + 1}`)
|
||||||
.join(' AND ');
|
.join(' AND ');
|
||||||
|
|
||||||
const query = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING *`;
|
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);
|
const { rows } = await db.getQuery()(query, params);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
} else {
|
} else {
|
||||||
// INSERT
|
// INSERT
|
||||||
const columns = Object.keys(data);
|
const columnsList = Object.keys(transformedData);
|
||||||
const values = Object.values(data);
|
const values = Object.values(transformedData);
|
||||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
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);
|
const { rows } = await db.getQuery()(query, values);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ class TelegramBot {
|
|||||||
setupHandlers() {
|
setupHandlers() {
|
||||||
// Обработчик команды /start
|
// Обработчик команды /start
|
||||||
this.bot.command('start', (ctx) => {
|
this.bot.command('start', (ctx) => {
|
||||||
logger.info('[TelegramBot] 📨 Получена команда /start');
|
logger.info('[TelegramBot] 📨 Получена команда /start (без онбординг-текста)');
|
||||||
ctx.reply('Добро пожаловать! Отправьте код подтверждения для аутентификации.');
|
// По запросу: не отправляем онбординг-текст пользователю
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обработчик команды /connect - подключение кошелька
|
// Обработчик команды /connect - подключение кошелька
|
||||||
|
|||||||
@@ -253,54 +253,52 @@ const handleSubmit = async () => {
|
|||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
addLog('info', 'Запуск настройки VDS...');
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Сначала всегда сохраняем настройки в БД
|
||||||
|
addLog('info', 'Сохранение настроек VDS на сервере...');
|
||||||
|
try {
|
||||||
|
// axios.defaults.baseURL = '/api', поэтому используем относительный путь
|
||||||
|
// чтобы итоговый URL был /api/vds/settings, а не /api/api/vds/settings
|
||||||
|
const response = await axios.post('/vds/settings', {
|
||||||
|
domain: form.domain,
|
||||||
|
email: form.email,
|
||||||
|
ubuntuUser: form.ubuntuUser,
|
||||||
|
dockerUser: form.dockerUser,
|
||||||
|
sshHost: form.sshHost,
|
||||||
|
sshPort: parseInt(form.sshPort, 10) || 22, // Преобразуем в число
|
||||||
|
sshUser: form.sshUser,
|
||||||
|
sshPassword: form.sshPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
addLog('success', '✅ Настройки VDS сохранены в базе данных');
|
||||||
|
} else {
|
||||||
|
addLog('error', `❌ Ошибка сохранения настроек: ${response.data?.error || 'Неизвестная ошибка'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebSSH] Ошибка сохранения настроек:', error);
|
||||||
|
const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка';
|
||||||
|
addLog('error', `❌ Ошибка сохранения настроек на сервере: ${errorMessage}`);
|
||||||
|
// Даже если сохранение настроек упало, продолжаем попытку настройки VDS через агента
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Затем запускаем настройку VDS через агента
|
||||||
|
addLog('info', 'Запуск настройки VDS через WebSSH Agent...');
|
||||||
const result = await webSshService.setupVDS(form);
|
const result = await webSshService.setupVDS(form);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
isConnected.value = true;
|
isConnected.value = true;
|
||||||
connectionStatus.value = `VDS настроен: ${form.domain}`;
|
connectionStatus.value = `VDS настроен: ${form.domain}`;
|
||||||
addLog('success', 'VDS успешно настроена');
|
addLog('success', 'VDS успешно настроена');
|
||||||
addLog('info', `Ваше приложение будет доступно по адресу: https://${form.domain}`);
|
addLog('info', `Ваше приложение будет доступно по адресу: https://${form.domain}`);
|
||||||
|
|
||||||
// Сохраняем статус VDS как настроенного
|
// Сохраняем статус VDS как настроенного локально
|
||||||
localStorage.setItem('vds-config', JSON.stringify({
|
localStorage.setItem('vds-config', JSON.stringify({
|
||||||
isConfigured: true,
|
isConfigured: true,
|
||||||
domain: form.domain
|
domain: form.domain
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Сохраняем ВСЕ настройки на сервере
|
|
||||||
try {
|
|
||||||
addLog('info', 'Сохранение настроек VDS на сервере...');
|
|
||||||
const response = await axios.post('/api/vds/settings', {
|
|
||||||
domain: form.domain,
|
|
||||||
email: form.email,
|
|
||||||
ubuntuUser: form.ubuntuUser,
|
|
||||||
dockerUser: form.dockerUser,
|
|
||||||
sshHost: form.sshHost,
|
|
||||||
sshPort: parseInt(form.sshPort, 10) || 22, // Преобразуем в число
|
|
||||||
sshUser: form.sshUser,
|
|
||||||
sshPassword: form.sshPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && response.data.success) {
|
|
||||||
addLog('success', '✅ Настройки VDS успешно сохранены на сервере');
|
|
||||||
} else {
|
|
||||||
addLog('error', `❌ Ошибка сохранения настроек: ${response.data?.error || 'Неизвестная ошибка'}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[WebSSH] Ошибка сохранения настроек:', error);
|
|
||||||
const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка';
|
|
||||||
addLog('error', `❌ Ошибка сохранения настроек на сервере: ${errorMessage}`);
|
|
||||||
// Показываем детали ошибки в консоли для отладки
|
|
||||||
if (error.response) {
|
|
||||||
console.error('[WebSSH] Детали ошибки:', {
|
|
||||||
status: error.response.status,
|
|
||||||
statusText: error.response.statusText,
|
|
||||||
data: error.response.data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправляем событие об изменении статуса VDS
|
// Отправляем событие об изменении статуса VDS
|
||||||
window.dispatchEvent(new CustomEvent('vds-status-changed', {
|
window.dispatchEvent(new CustomEvent('vds-status-changed', {
|
||||||
detail: { isConfigured: true }
|
detail: { isConfigured: true }
|
||||||
|
|||||||
@@ -43,6 +43,14 @@
|
|||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
<span>Редактировать</span>
|
<span>Редактировать</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="page-action-btn page-index-btn"
|
||||||
|
@click="reindexPage"
|
||||||
|
title="Отправить документ в поиск"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<span>Индексировать</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="page-action-btn page-delete-btn"
|
class="page-action-btn page-delete-btn"
|
||||||
@click="confirmDeletePage"
|
@click="confirmDeletePage"
|
||||||
@@ -154,6 +162,7 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import pagesService from '../../services/pagesService';
|
import pagesService from '../../services/pagesService';
|
||||||
|
import api from '../../api/axios';
|
||||||
import { usePermissions } from '../../composables/usePermissions';
|
import { usePermissions } from '../../composables/usePermissions';
|
||||||
import { PERMISSIONS } from '../../composables/permissions';
|
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
|
// Отслеживаем изменения pageId
|
||||||
watch(() => props.pageId, (newId, oldId) => {
|
watch(() => props.pageId, (newId, oldId) => {
|
||||||
console.log('[DocsContent] pageId изменился:', { oldId, newId });
|
console.log('[DocsContent] pageId изменился:', { oldId, newId });
|
||||||
|
|||||||
@@ -74,12 +74,10 @@ export function useWebSshLogs() {
|
|||||||
console.log('[WebSSH Logs] Получен прогресс:', data);
|
console.log('[WebSSH Logs] Получен прогресс:', data);
|
||||||
|
|
||||||
if (data.type === 'webssh_progress') {
|
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);
|
addLog('info', progressMessage);
|
||||||
|
|
||||||
if (data.percentage) {
|
|
||||||
addLog('debug', `Прогресс: ${data.percentage}%`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -172,112 +172,19 @@ class WebSshService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Автоматическая установка и запуск агента
|
* Автоматическая установка и запуск агента
|
||||||
|
* В новой архитектуре агент всегда запускается в Docker (dapp-webssh-agent),
|
||||||
|
* поэтому здесь просто проверяем его доступность.
|
||||||
*/
|
*/
|
||||||
async installAndStartAgent() {
|
async installAndStartAgent() {
|
||||||
try {
|
|
||||||
// Сначала проверяем, может агент уже запущен
|
|
||||||
const status = await this.checkAgentStatus();
|
const status = await this.checkAgentStatus();
|
||||||
if (status.running) {
|
if (status.running) {
|
||||||
return { success: true, message: 'Агент уже запущен' };
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Скачайте и запустите скрипт install-webssh-agent.sh для установки агента',
|
message: 'WebSSH Agent не запущен. Убедитесь, что контейнер dapp-webssh-agent работает (docker compose up -d webssh-agent).'
|
||||||
requiresManualInstall: true
|
};
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('Ошибка при создании установочного скрипта:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Ошибка при подготовке установки агента',
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,10 +234,9 @@ EOF
|
|||||||
config.vdsIp = dnsResult.ip;
|
config.vdsIp = dnsResult.ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, что агент запущен
|
// Проверяем, что агент запущен (в Docker)
|
||||||
const agentStatus = await this.checkAgentStatus();
|
const agentStatus = await this.checkAgentStatus();
|
||||||
if (!agentStatus.running) {
|
if (!agentStatus.running) {
|
||||||
// Пытаемся установить и запустить агент
|
|
||||||
const installResult = await this.installAndStartAgent();
|
const installResult = await this.installAndStartAgent();
|
||||||
if (!installResult.success) {
|
if (!installResult.success) {
|
||||||
return installResult;
|
return installResult;
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
<label>Docker Пользователь:</label>
|
<label>Docker Пользователь:</label>
|
||||||
<div class="setting-value">{{ settings.dockerUser || 'Не задан' }}</div>
|
<div class="setting-value">{{ settings.dockerUser || 'Не задан' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<label>Путь к docker-compose:</label>
|
||||||
|
<div class="setting-value">{{ settings.dappPath || '/root/dapp' }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,7 +110,7 @@
|
|||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<small class="form-help">Email для получения SSL сертификата от Let's Encrypt</small>
|
<small class="form-help">Email для получения SSL сертификата</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ubuntuUser">Логин Ubuntu *</label>
|
<label for="ubuntuUser">Логин Ubuntu *</label>
|
||||||
@@ -130,6 +134,17 @@
|
|||||||
/>
|
/>
|
||||||
<small class="form-help">Пользователь для Docker (будет создан автоматически)</small>
|
<small class="form-help">Пользователь для Docker (будет создан автоматически)</small>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
@@ -377,6 +392,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div v-if="showCreateUserModal && isEditor" class="modal-overlay" @click="showCreateUserModal = false">
|
||||||
@@ -450,6 +521,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</template>
|
</template>
|
||||||
@@ -493,6 +565,8 @@ const showSendBackupModal = ref(false);
|
|||||||
const showLogsModal = ref(false);
|
const showLogsModal = ref(false);
|
||||||
const logsTitle = ref('');
|
const logsTitle = ref('');
|
||||||
const logsContent = ref('');
|
const logsContent = ref('');
|
||||||
|
const sslStatus = ref(null);
|
||||||
|
const isLoadingSsl = ref(false);
|
||||||
|
|
||||||
const newUser = reactive({
|
const newUser = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -514,6 +588,7 @@ const formSettings = reactive({
|
|||||||
email: '',
|
email: '',
|
||||||
ubuntuUser: 'ubuntu',
|
ubuntuUser: 'ubuntu',
|
||||||
dockerUser: 'docker',
|
dockerUser: 'docker',
|
||||||
|
dappPath: '/home/docker/dapp',
|
||||||
sshHost: '',
|
sshHost: '',
|
||||||
sshPort: 22,
|
sshPort: 22,
|
||||||
sshUser: 'root',
|
sshUser: 'root',
|
||||||
@@ -540,6 +615,7 @@ let statsInterval = null;
|
|||||||
// Загрузка настроек
|
// Загрузка настроек
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
|
// axios.defaults.baseURL = '/api', поэтому используем относительный путь
|
||||||
const response = await axios.get('/vds/settings');
|
const response = await axios.get('/vds/settings');
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
if (response.data.settings) {
|
if (response.data.settings) {
|
||||||
@@ -553,6 +629,7 @@ const loadSettings = async () => {
|
|||||||
email: response.data.settings.email || '',
|
email: response.data.settings.email || '',
|
||||||
ubuntuUser: response.data.settings.ubuntuUser || 'ubuntu',
|
ubuntuUser: response.data.settings.ubuntuUser || 'ubuntu',
|
||||||
dockerUser: response.data.settings.dockerUser || 'docker',
|
dockerUser: response.data.settings.dockerUser || 'docker',
|
||||||
|
dappPath: response.data.settings.dappPath || `/home/${response.data.settings.dockerUser || 'docker'}/dapp`,
|
||||||
sshHost: response.data.settings.sshHost || '',
|
sshHost: response.data.settings.sshHost || '',
|
||||||
sshPort: response.data.settings.sshPort || 22,
|
sshPort: response.data.settings.sshPort || 22,
|
||||||
sshUser: response.data.settings.sshUser || 'root',
|
sshUser: response.data.settings.sshUser || 'root',
|
||||||
@@ -621,11 +698,13 @@ const saveSettings = async () => {
|
|||||||
|
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
|
// axios.defaults.baseURL = '/api', поэтому используем относительный путь
|
||||||
const response = await axios.post('/vds/settings', {
|
const response = await axios.post('/vds/settings', {
|
||||||
domain: formSettings.domain,
|
domain: formSettings.domain,
|
||||||
email: formSettings.email,
|
email: formSettings.email,
|
||||||
ubuntuUser: formSettings.ubuntuUser,
|
ubuntuUser: formSettings.ubuntuUser,
|
||||||
dockerUser: formSettings.dockerUser,
|
dockerUser: formSettings.dockerUser,
|
||||||
|
dappPath: formSettings.dappPath || '/root/dapp',
|
||||||
sshHost: formSettings.sshHost,
|
sshHost: formSettings.sshHost,
|
||||||
sshPort: formSettings.sshPort,
|
sshPort: formSettings.sshPort,
|
||||||
sshUser: formSettings.sshUser,
|
sshUser: formSettings.sshUser,
|
||||||
@@ -667,13 +746,22 @@ const loadStats = async () => {
|
|||||||
const loadContainers = async () => {
|
const loadContainers = async () => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
|
// axios.defaults.baseURL = '/api', поэтому используем относительный путь
|
||||||
const response = await axios.get('/vds/containers');
|
const response = await axios.get('/vds/containers');
|
||||||
if (response.data.success) {
|
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки контейнеров:', error);
|
console.error('Ошибка загрузки контейнеров:', error);
|
||||||
alert('Ошибка загрузки контейнеров');
|
const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка';
|
||||||
|
alert(`Ошибка загрузки контейнеров: ${errorMessage}`);
|
||||||
|
containers.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -976,13 +1064,19 @@ const viewProcesses = async () => {
|
|||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
|
// axios.defaults.baseURL = '/api', поэтому используем относительный путь
|
||||||
const response = await axios.get('/vds/users');
|
const response = await axios.get('/vds/users');
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
users.value = response.data.users;
|
users.value = response.data.users;
|
||||||
|
} else {
|
||||||
|
console.warn('[VDS] Загрузка пользователей не успешна:', response.data);
|
||||||
|
users.value = [];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки пользователей:', error);
|
console.error('Ошибка загрузки пользователей:', error);
|
||||||
alert('Ошибка загрузки пользователей');
|
const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка';
|
||||||
|
alert(`Ошибка загрузки пользователей: ${errorMessage}`);
|
||||||
|
users.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -1128,7 +1222,7 @@ const sendBackup = async () => {
|
|||||||
// SSL Сертификаты
|
// SSL Сертификаты
|
||||||
const loadSslStatus = async () => {
|
const loadSslStatus = async () => {
|
||||||
if (!isEditor.value) {
|
if (!isEditor.value) {
|
||||||
alert('Только пользователи с ролью "Редактор" могут проверять SSL сертификаты');
|
// Не показываем ошибку, если пользователь не редактор - просто не загружаем статус
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isLoadingSsl.value = true;
|
isLoadingSsl.value = true;
|
||||||
@@ -1137,11 +1231,62 @@ const loadSslStatus = async () => {
|
|||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
sslStatus.value = response.data;
|
sslStatus.value = response.data;
|
||||||
} else {
|
} else {
|
||||||
alert('Ошибка получения статуса SSL сертификата');
|
console.warn('[VDS] Получение статуса SSL не успешно:', response.data);
|
||||||
|
sslStatus.value = null;
|
||||||
|
// Не показываем alert для автоматической загрузки при монтировании компонента
|
||||||
|
// Alert показываем только при ручной проверке (через кнопку)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка получения статуса SSL:', 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 {
|
} finally {
|
||||||
isLoadingSsl.value = false;
|
isLoadingSsl.value = false;
|
||||||
}
|
}
|
||||||
@@ -1152,10 +1297,14 @@ const renewSslCertificate = async () => {
|
|||||||
alert('Только пользователи с ролью "Редактор" могут получать SSL сертификаты');
|
alert('Только пользователи с ролью "Редактор" могут получать SSL сертификаты');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm('Получить/обновить SSL сертификат от Let\'s Encrypt? Это может занять некоторое время.')) return;
|
if (!confirm('Получить/обновить SSL сертификат от Let\'s Encrypt? Это может занять некоторое время.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/vds/ssl/renew');
|
const response = await axios.post('/vds/ssl/renew', {
|
||||||
|
sslProvider: 'letsencrypt'
|
||||||
|
});
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
alert('SSL сертификат успешно получен/обновлен');
|
alert('SSL сертификат успешно получен/обновлен');
|
||||||
await loadSslStatus();
|
await loadSslStatus();
|
||||||
@@ -1164,7 +1313,24 @@ const renewSslCertificate = async () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка получения SSL сертификата:', 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 {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,10 @@
|
|||||||
<!-- WEB SSH -->
|
<!-- WEB SSH -->
|
||||||
<div class="web3-service-block">
|
<div class="web3-service-block">
|
||||||
<div class="service-header">
|
<div class="service-header">
|
||||||
<h3>WEB SSH</h3>
|
<h3>VDS Сервер</h3>
|
||||||
<span class="service-badge webssh">Публикация через SSH-туннель</span>
|
<span class="service-badge webssh">Публикация на VDS сервере</span>
|
||||||
</div>
|
</div>
|
||||||
<p>Автоматическая публикация приложения в интернете через SSH-туннель.</p>
|
<p>Автоматическая публикация приложения в интернете.</p>
|
||||||
<div class="service-features">
|
<div class="service-features">
|
||||||
<span class="feature">✓ Быстрое подключение</span>
|
<span class="feature">✓ Быстрое подключение</span>
|
||||||
<span class="feature">✓ Безопасно</span>
|
<span class="feature">✓ Безопасно</span>
|
||||||
|
|||||||
@@ -21,8 +21,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="webssh-settings-block">
|
<div class="webssh-settings-block">
|
||||||
<button class="close-btn" @click="goBack">×</button>
|
<button class="close-btn" @click="goBack">×</button>
|
||||||
<h2>WEB SSH: интеграция и настройки</h2>
|
<h2>Настройка VDS Сервер</h2>
|
||||||
<p class="desc">Автоматическая публикация приложения через SSH-туннель и NGINX.</p>
|
|
||||||
<WebSshForm />
|
<WebSshForm />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ NC='\033[0m' # No Color
|
|||||||
echo -e "${GREEN}🔄 Синхронизация кода с VDS...${NC}"
|
echo -e "${GREEN}🔄 Синхронизация кода с VDS...${NC}"
|
||||||
|
|
||||||
# Параметры VDS (из настроек)
|
# Параметры VDS (из настроек)
|
||||||
VDS_HOST="185.221.214.140"
|
VDS_HOST="185.26.121.127"
|
||||||
VDS_USER="root"
|
VDS_USER="root"
|
||||||
VDS_PORT="22"
|
VDS_PORT="22"
|
||||||
VDS_PATH="/home/docker/dapp"
|
VDS_PATH="/home/docker/dapp"
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ LABEL website="https://hb3-accelerator.com"
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt ./
|
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 . .
|
COPY . .
|
||||||
EXPOSE 8001
|
EXPOSE 8001
|
||||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
@@ -494,7 +494,8 @@ findtime = 3600
|
|||||||
log.success('Директория для ключа шифрования подготовлена');
|
log.success('Директория для ключа шифрования подготовлена');
|
||||||
|
|
||||||
// 9.1. Передача ключа шифрования на VDS
|
// 9.1. Передача ключа шифрования на VDS
|
||||||
sendWebSocketLog('info', '🔐 Передача ключа шифрования на VDS...', 'encryption_key', 36);
|
// Прогресс после установки Docker (55%), двигаемся вперёд, а не назад
|
||||||
|
sendWebSocketLog('info', '🔐 Передача ключа шифрования на VDS...', 'encryption_key', 56);
|
||||||
log.info('🔐 Передача ключа шифрования на VDS...');
|
log.info('🔐 Передача ключа шифрования на VDS...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -592,14 +593,16 @@ findtime = 3600
|
|||||||
if (verifyResult.code === 0) {
|
if (verifyResult.code === 0) {
|
||||||
log.success('✅ Ключ шифрования успешно передан на VDS');
|
log.success('✅ Ключ шифрования успешно передан на VDS');
|
||||||
log.info(`📋 Информация о ключе на VDS: ${verifyResult.stdout.trim()}`);
|
log.info(`📋 Информация о ключе на VDS: ${verifyResult.stdout.trim()}`);
|
||||||
sendWebSocketLog('success', '✅ Ключ шифрования передан на VDS', 'encryption_key', 37);
|
// Делаем прогресс строго больше предыдущего шага Docker (55%)
|
||||||
|
sendWebSocketLog('success', '✅ Ключ шифрования передан на VDS', 'encryption_key', 57);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Не удалось проверить передачу ключа шифрования: ${verifyResult.stderr || verifyResult.stdout}`);
|
throw new Error(`Не удалось проверить передачу ключа шифрования: ${verifyResult.stderr || verifyResult.stdout}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('❌ Ошибка передачи ключа шифрования: ' + error.message);
|
log.error('❌ Ошибка передачи ключа шифрования: ' + error.message);
|
||||||
log.error('📋 Детали ошибки:', error.stack);
|
log.error('📋 Детали ошибки:', error.stack);
|
||||||
sendWebSocketLog('error', `❌ Ошибка передачи ключа шифрования: ${error.message}`, 'encryption_key', 37);
|
// Даже при ошибке не откатываем прогресс назад относительно предыдущих шагов
|
||||||
|
sendWebSocketLog('error', `❌ Ошибка передачи ключа шифрования: ${error.message}`, 'encryption_key', 57);
|
||||||
// Продолжаем установку, но предупреждаем пользователя
|
// Продолжаем установку, но предупреждаем пользователя
|
||||||
log.warn('⚠️ Внимание: ключ шифрования не передан. Backend может не запуститься без ключа.');
|
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}'`;
|
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);
|
await execSshCommand(tempCertCommand, options);
|
||||||
log.success('Временный SSL сертификат создан');
|
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
|
// 12. Передача docker-compose.prod.yml на VDS
|
||||||
log.info('Передача docker-compose.prod.yml на VDS...');
|
log.info('Передача docker-compose.prod.yml на VDS...');
|
||||||
@@ -689,153 +702,24 @@ WS_BACKEND_CONTAINER=dapp-backend`;
|
|||||||
log.info('Запуск приложения...');
|
log.info('Запуск приложения...');
|
||||||
await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml up -d`, options);
|
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...');
|
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 с новой конфигурацией
|
// Перезапускаем nginx с новой конфигурацией
|
||||||
await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml restart frontend-nginx`, options);
|
await execSshCommand(`cd /home/${dockerUser}/dapp && docker compose -f docker-compose.prod.yml restart frontend-nginx`, options);
|
||||||
log.success('✅ CORS заголовки настроены в nginx для API');
|
log.success('✅ CORS заголовки настроены в nginx для API');
|
||||||
|
|
||||||
// 16.0. 🆕 Получение реального SSL сертификата через Let's Encrypt
|
// 16.0. Получение реального SSL сертификата перенесено в backend (/api/vds/ssl/renew).
|
||||||
log.info('🔒 Получение реального SSL сертификата через Let\'s Encrypt...');
|
// Здесь агент создает только временный самоподписанный сертификат (см. шаг 11 выше).
|
||||||
sendWebSocketLog('info', '🔒 Получение SSL сертификата...', 'ssl_cert', 75);
|
// Для получения/обновления реального сертификата используйте кнопку
|
||||||
|
// "Получить / обновить SSL" на странице управления VDS в интерфейсе DLE,
|
||||||
|
// которая вызывает /api/vds/ssl/renew на backend.
|
||||||
|
|
||||||
try {
|
// 16.2. Ожидание готовности базы данных с повторными попытками
|
||||||
// Убеждаемся, что директории для 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. 🆕 Ожидание готовности базы данных с повторными попытками
|
|
||||||
log.info('Ожидание готовности базы данных...');
|
log.info('Ожидание готовности базы данных...');
|
||||||
let dbReady = false;
|
let dbReady = false;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
|||||||
@@ -163,13 +163,26 @@ const setupRootSshKeys = async (publicKey, options) => {
|
|||||||
// Создание директории .ssh для root
|
// Создание директории .ssh для root
|
||||||
await execSshCommand('mkdir -p /root/.ssh', options);
|
await execSshCommand('mkdir -p /root/.ssh', options);
|
||||||
await execSshCommand('chmod 700 /root/.ssh', options);
|
await execSshCommand('chmod 700 /root/.ssh', options);
|
||||||
|
// ВАЖНО: Устанавливаем правильного владельца директории (root:root)
|
||||||
|
// SSH не принимает ключи, если директория принадлежит другому пользователю
|
||||||
|
await execSshCommand('chown root:root /root/.ssh', options);
|
||||||
|
|
||||||
// Добавление публичного ключа в authorized_keys
|
// Добавление публичного ключа в 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('chmod 600 /root/.ssh/authorized_keys', options);
|
||||||
await execSshCommand('chown root:root /root/.ssh/authorized_keys', options);
|
await execSshCommand('chown root:root /root/.ssh/authorized_keys', options);
|
||||||
|
|
||||||
log.success('SSH ключи созданы и публичный ключ добавлен в authorized_keys');
|
// Проверяем, что ключ действительно добавлен
|
||||||
|
const verifyResult = await execSshCommand(`grep -Fx "${escapedPublicKey}" /root/.ssh/authorized_keys > /dev/null && echo "OK" || echo "FAIL"`, options);
|
||||||
|
if (verifyResult.stdout.trim() === 'OK') {
|
||||||
|
log.success('SSH ключи созданы и публичный ключ добавлен в authorized_keys');
|
||||||
|
} else {
|
||||||
|
log.error('Ошибка: публичный ключ не был добавлен в authorized_keys');
|
||||||
|
throw new Error('Не удалось добавить публичный ключ в authorized_keys');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ const exportDockerImages = async (sendWebSocketLog) => {
|
|||||||
{ name: 'digital_legal_entitydle-webssh-agent:latest', file: 'dapp-webssh-agent.tar' }
|
{ name: 'digital_legal_entitydle-webssh-agent:latest', file: 'dapp-webssh-agent.tar' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Список реально экспортированных файлов образов
|
||||||
|
const exportedImageFiles = [];
|
||||||
|
|
||||||
// Экспортируем все образы
|
// Экспортируем все образы
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
const image = images[i];
|
const image = images[i];
|
||||||
@@ -59,8 +62,20 @@ const exportDockerImages = async (sendWebSocketLog) => {
|
|||||||
try {
|
try {
|
||||||
const outputPath = `/tmp/${image.file}`;
|
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
|
// Безопасный экспорт через 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);
|
sendWebSocketLog('success', `✅ Экспорт ${image.name} завершен`, 'export_images', progress);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -95,12 +110,14 @@ const exportDockerImages = async (sendWebSocketLog) => {
|
|||||||
sendWebSocketLog('info', '📦 Создание архива всех данных...', 'export_data', 80);
|
sendWebSocketLog('info', '📦 Создание архива всех данных...', 'export_data', 80);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tarFiles = images.map(img => img.file).join(' ');
|
const tarFiles = exportedImageFiles.join(' ');
|
||||||
// Динамически собираем список файлов данных из экспортированных volumes
|
// Динамически собираем список файлов данных из экспортированных volumes
|
||||||
const dataFilesList = await execLocalCommand('ls /tmp/*_data.tar.gz 2>/dev/null | xargs -r basename -a || echo ""');
|
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 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);
|
await execLocalCommand(archiveCommand);
|
||||||
|
|
||||||
sendWebSocketLog('success', '✅ Архив создан успешно', 'export_data', 80);
|
sendWebSocketLog('success', '✅ Архив создан успешно', 'export_data', 80);
|
||||||
@@ -160,7 +177,6 @@ const importDockerImages = async (options, sendWebSocketLog) => {
|
|||||||
sendWebSocketLog('info', '📥 Начинаем импорт данных на VDS...', 'import', 85);
|
sendWebSocketLog('info', '📥 Начинаем импорт данных на VDS...', 'import', 85);
|
||||||
|
|
||||||
const importScript = `#!/bin/bash
|
const importScript = `#!/bin/bash
|
||||||
set -e
|
|
||||||
echo "🚀 Импорт Docker образов и данных на VDS..."
|
echo "🚀 Импорт Docker образов и данных на VDS..."
|
||||||
|
|
||||||
# Проверяем наличие архива
|
# Проверяем наличие архива
|
||||||
@@ -180,8 +196,17 @@ tar -xzf ./docker-images-and-data.tar.gz -C ./temp-import
|
|||||||
echo "📦 Импорт образов..."
|
echo "📦 Импорт образов..."
|
||||||
for image_file in ./temp-import/dapp-*.tar; do
|
for image_file in ./temp-import/dapp-*.tar; do
|
||||||
if [ -f "$image_file" ]; then
|
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
|
fi
|
||||||
done
|
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=$(basename "$data_file" .tar.gz 2>/dev/null || echo "")
|
||||||
|
|
||||||
# Проверяем, что volume_name не пустой и не содержит только пробелы
|
# Проверяем, что 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"
|
echo "⚠️ Предупреждение: не удалось извлечь имя volume из файла: $data_file"
|
||||||
volume_name=""
|
volume_name=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Используем префикс dapp_ для соответствия docker-compose.prod.yml
|
# Используем префикс dapp_ для соответствия docker-compose.prod.yml
|
||||||
full_volume_name="dapp_${volume_name}"
|
full_volume_name="dapp_$volume_name"
|
||||||
|
|
||||||
echo "📦 Импорт данных: $full_volume_name"
|
echo "📦 Импорт данных: $full_volume_name"
|
||||||
# Удаляем старый volume если существует
|
# Удаляем старый volume если существует
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ const createUserWithSshKeys = async (username, publicKey, options) => {
|
|||||||
|
|
||||||
// Настройка SSH ключей для пользователя
|
// Настройка SSH ключей для пользователя
|
||||||
await execSshCommand(`mkdir -p /home/${username}/.ssh`, options);
|
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(`chown -R ${username}:${username} /home/${username}/.ssh`, options);
|
||||||
await execSshCommand(`chmod 700 /home/${username}/.ssh`, options);
|
await execSshCommand(`chmod 700 /home/${username}/.ssh`, options);
|
||||||
await execSshCommand(`chmod 600 /home/${username}/.ssh/authorized_keys`, options);
|
await execSshCommand(`chmod 600 /home/${username}/.ssh/authorized_keys`, options);
|
||||||
|
|||||||
Reference in New Issue
Block a user