359 lines
11 KiB
JavaScript
359 lines
11 KiB
JavaScript
const express = require('express');
|
||
const cors = require('cors');
|
||
const { spawn } = require('child_process');
|
||
const fs = require('fs-extra');
|
||
const path = require('path');
|
||
const chalk = require('chalk');
|
||
|
||
const app = express();
|
||
const PORT = 12345;
|
||
|
||
// Middleware
|
||
app.use(cors({
|
||
origin: ['http://localhost:5173', 'http://localhost:8000'],
|
||
credentials: true
|
||
}));
|
||
app.use(express.json({ limit: '10mb' }));
|
||
|
||
// Состояние туннеля
|
||
let tunnelState = {
|
||
connected: false,
|
||
domain: null,
|
||
tunnelId: null,
|
||
sshProcess: null,
|
||
config: null
|
||
};
|
||
|
||
// Логирование
|
||
const log = {
|
||
info: (message) => console.log(chalk.blue('[INFO]'), message),
|
||
success: (message) => console.log(chalk.green('[SUCCESS]'), message),
|
||
error: (message) => console.log(chalk.red('[ERROR]'), message),
|
||
warn: (message) => console.log(chalk.yellow('[WARN]'), message)
|
||
};
|
||
|
||
// Проверка здоровья агента
|
||
app.get('/health', (req, res) => {
|
||
log.info('Health check requested');
|
||
res.json({
|
||
status: 'ok',
|
||
timestamp: new Date().toISOString(),
|
||
version: '1.0.0',
|
||
tunnelConnected: tunnelState.connected
|
||
});
|
||
});
|
||
|
||
// Создание туннеля
|
||
app.post('/tunnel/create', async (req, res) => {
|
||
try {
|
||
const { domain, email, sshHost, sshUser, sshKey, localPort, serverPort, sshPort } = req.body;
|
||
|
||
log.info(`Создание туннеля для домена: ${domain}`);
|
||
|
||
// Валидация входных данных
|
||
if (!domain || !email || !sshHost || !sshUser || !sshKey) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: 'Все обязательные поля должны быть заполнены'
|
||
});
|
||
}
|
||
|
||
// Если туннель уже подключен, отключаем его
|
||
if (tunnelState.connected) {
|
||
log.warn('Отключаем существующий туннель');
|
||
await disconnectTunnel();
|
||
}
|
||
|
||
// Создаем временную директорию для SSH ключей
|
||
const tempDir = path.join(__dirname, 'temp');
|
||
await fs.ensureDir(tempDir);
|
||
|
||
// Сохраняем SSH ключ во временный файл
|
||
const keyPath = path.join(tempDir, `ssh_key_${Date.now()}`);
|
||
let normalizedKey = sshKey
|
||
.replace(/[ \t]+$/gm, '') // убираем пробелы и табы в конце каждой строки
|
||
.replace(/\r\n/g, '\n'); // нормализуем переносы строк
|
||
|
||
// Гарантируем, что после END нет ничего, кроме перевода строки
|
||
normalizedKey = normalizedKey.replace(
|
||
/(-----END OPENSSH PRIVATE KEY-----)[^\n]*$/m,
|
||
'$1\n'
|
||
);
|
||
|
||
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
|
||
|
||
log.info('SSH ключ сохранен, подключаемся к серверу...');
|
||
|
||
// Функция для выполнения SSH команд
|
||
const execSshCommand = (command) => {
|
||
return new Promise((resolve, reject) => {
|
||
const sshArgs = [
|
||
'-i', keyPath,
|
||
'-p', (sshPort || 22).toString(),
|
||
'-o', 'StrictHostKeyChecking=no',
|
||
'-o', 'UserKnownHostsFile=/dev/null',
|
||
`${sshUser}@${sshHost}`,
|
||
command
|
||
];
|
||
|
||
const sshProcess = spawn('ssh', sshArgs);
|
||
let stdout = '';
|
||
let stderr = '';
|
||
|
||
sshProcess.stdout.on('data', (data) => {
|
||
stdout += data.toString();
|
||
});
|
||
|
||
sshProcess.stderr.on('data', (data) => {
|
||
stderr += data.toString();
|
||
});
|
||
|
||
sshProcess.on('close', (code) => {
|
||
resolve({ code, stdout, stderr });
|
||
});
|
||
|
||
sshProcess.on('error', (error) => {
|
||
reject(error);
|
||
});
|
||
});
|
||
};
|
||
|
||
log.info('Подключение к серверу установлено');
|
||
|
||
// Проверяем и устанавливаем NGINX и certbot
|
||
log.info('Проверка и установка NGINX...');
|
||
const installResult = await execSshCommand('which nginx || (apt-get update && apt-get install -y nginx certbot python3-certbot-nginx)');
|
||
if (installResult.code !== 0) {
|
||
log.error('Ошибка установки NGINX: ' + installResult.stderr);
|
||
}
|
||
|
||
// Создание конфигурации NGINX
|
||
const nginxConfig = `
|
||
server {
|
||
listen 80;
|
||
server_name ${domain};
|
||
|
||
location / {
|
||
proxy_pass http://localhost:${serverPort || 9000};
|
||
proxy_set_header Host \\$host;
|
||
proxy_set_header X-Real-IP \\$remote_addr;
|
||
proxy_set_header X-Forwarded-For \\$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \\$scheme;
|
||
proxy_set_header Upgrade \\$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_cache_bypass \\$http_upgrade;
|
||
}
|
||
}
|
||
`;
|
||
|
||
log.info('Создание конфигурации NGINX...');
|
||
await execSshCommand(`echo '${nginxConfig}' > /etc/nginx/sites-available/${domain}`);
|
||
await execSshCommand(`ln -sf /etc/nginx/sites-available/${domain} /etc/nginx/sites-enabled/`);
|
||
|
||
// Проверка и перезагрузка NGINX
|
||
const nginxTestResult = await execSshCommand('nginx -t');
|
||
if (nginxTestResult.code !== 0) {
|
||
log.error('Ошибка конфигурации NGINX: ' + nginxTestResult.stderr);
|
||
throw new Error('Ошибка конфигурации NGINX: ' + nginxTestResult.stderr);
|
||
}
|
||
|
||
await execSshCommand('systemctl reload nginx');
|
||
log.success('NGINX настроен и перезагружен');
|
||
|
||
// Получение SSL сертификата
|
||
log.info('Получение SSL сертификата...');
|
||
const certbotResult = await execSshCommand(
|
||
`certbot --nginx -d ${domain} --non-interactive --agree-tos --email ${email} --redirect`
|
||
);
|
||
|
||
if (certbotResult.code !== 0) {
|
||
log.warn('Предупреждение при получении SSL: ' + certbotResult.stderr);
|
||
// Продолжаем, даже если SSL не получен
|
||
} else {
|
||
log.success('SSL сертификат получен');
|
||
}
|
||
|
||
// Создание SSH туннеля
|
||
log.info('Создание SSH туннеля...');
|
||
const tunnelId = Date.now().toString();
|
||
const sshArgs = [
|
||
'-i', keyPath,
|
||
'-p', (sshPort || 22).toString(),
|
||
'-R', `${serverPort || 9000}:localhost:${localPort || 5173}`,
|
||
'-N',
|
||
'-o', 'StrictHostKeyChecking=no',
|
||
'-o', 'UserKnownHostsFile=/dev/null',
|
||
'-o', 'ServerAliveInterval=60',
|
||
'-o', 'ServerAliveCountMax=3',
|
||
`${sshUser}@${sshHost}`
|
||
];
|
||
|
||
const sshProcess = spawn('ssh', sshArgs);
|
||
|
||
sshProcess.stdout.on('data', (data) => {
|
||
log.info('SSH stdout: ' + data.toString());
|
||
});
|
||
|
||
sshProcess.stderr.on('data', (data) => {
|
||
log.warn('SSH stderr: ' + data.toString());
|
||
});
|
||
|
||
sshProcess.on('error', (error) => {
|
||
log.error('SSH процесс ошибка: ' + error.message);
|
||
tunnelState.connected = false;
|
||
});
|
||
|
||
sshProcess.on('close', (code) => {
|
||
log.info(`SSH процесс завершен с кодом: ${code}`);
|
||
tunnelState.connected = false;
|
||
});
|
||
|
||
// Даем время на установку соединения
|
||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||
|
||
// Обновляем состояние
|
||
tunnelState = {
|
||
connected: true,
|
||
domain,
|
||
tunnelId,
|
||
sshProcess,
|
||
config: { domain, email, sshHost, sshUser, localPort, serverPort, sshPort, keyPath }
|
||
};
|
||
|
||
log.success(`Туннель успешно создан для домена: ${domain}`);
|
||
|
||
res.json({
|
||
success: true,
|
||
message: 'Туннель успешно создан',
|
||
tunnelId,
|
||
domain,
|
||
url: `https://${domain}`
|
||
});
|
||
|
||
} catch (error) {
|
||
log.error('Ошибка создания туннеля: ' + error.message);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// Отключение туннеля
|
||
app.post('/tunnel/disconnect', async (req, res) => {
|
||
try {
|
||
const result = await disconnectTunnel();
|
||
res.json(result);
|
||
} catch (error) {
|
||
log.error('Ошибка отключения туннеля: ' + error.message);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// Функция отключения туннеля
|
||
async function disconnectTunnel() {
|
||
try {
|
||
if (tunnelState.sshProcess) {
|
||
tunnelState.sshProcess.kill('SIGTERM');
|
||
|
||
// Ждем завершения процесса
|
||
await new Promise((resolve) => {
|
||
tunnelState.sshProcess.on('close', resolve);
|
||
setTimeout(resolve, 5000); // Таймаут 5 сек
|
||
});
|
||
}
|
||
|
||
// Удаляем временный SSH ключ
|
||
if (tunnelState.config && tunnelState.config.keyPath) {
|
||
try {
|
||
await fs.remove(tunnelState.config.keyPath);
|
||
} catch (err) {
|
||
log.warn('Не удалось удалить временный SSH ключ: ' + err.message);
|
||
}
|
||
}
|
||
|
||
const previousDomain = tunnelState.domain;
|
||
|
||
tunnelState = {
|
||
connected: false,
|
||
domain: null,
|
||
tunnelId: null,
|
||
sshProcess: null,
|
||
config: null
|
||
};
|
||
|
||
log.success('Туннель отключен');
|
||
|
||
return {
|
||
success: true,
|
||
message: `Туннель для домена ${previousDomain} отключен`
|
||
};
|
||
} catch (error) {
|
||
log.error('Ошибка при отключении туннеля: ' + error.message);
|
||
return {
|
||
success: false,
|
||
message: error.message
|
||
};
|
||
}
|
||
}
|
||
|
||
// Статус туннеля
|
||
app.get('/tunnel/status', (req, res) => {
|
||
res.json({
|
||
connected: tunnelState.connected,
|
||
domain: tunnelState.domain,
|
||
tunnelId: tunnelState.tunnelId,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
});
|
||
|
||
// Получение логов
|
||
app.get('/tunnel/logs', (req, res) => {
|
||
// В реальной реализации здесь можно вернуть логи из файла
|
||
res.json({
|
||
logs: [
|
||
{
|
||
timestamp: new Date().toISOString(),
|
||
level: 'info',
|
||
message: 'Агент запущен и готов к работе'
|
||
}
|
||
]
|
||
});
|
||
});
|
||
|
||
// Graceful shutdown
|
||
process.on('SIGTERM', async () => {
|
||
log.info('Получен сигнал SIGTERM, завершаем работу...');
|
||
if (tunnelState.connected) {
|
||
await disconnectTunnel();
|
||
}
|
||
process.exit(0);
|
||
});
|
||
|
||
process.on('SIGINT', async () => {
|
||
log.info('Получен сигнал SIGINT, завершаем работу...');
|
||
if (tunnelState.connected) {
|
||
await disconnectTunnel();
|
||
}
|
||
process.exit(0);
|
||
});
|
||
|
||
// Запуск сервера
|
||
app.listen(PORT, 'localhost', () => {
|
||
log.success(`WebSSH Agent запущен на порту ${PORT}`);
|
||
log.info('Агент готов к приему команд от фронтенда');
|
||
});
|
||
|
||
// Обработка необработанных ошибок
|
||
process.on('uncaughtException', (error) => {
|
||
log.error('Необработанная ошибка: ' + error.message);
|
||
// console.error(error);
|
||
});
|
||
|
||
process.on('unhandledRejection', (reason, promise) => {
|
||
log.error('Необработанное отклонение промиса: ' + reason);
|
||
// console.error(reason);
|
||
});
|