From 1854c7ea90a1b774f2670b5f77d35dbb19ceee83 Mon Sep 17 00:00:00 2001 From: lavrov08 <5452894@gmail.com> Date: Tue, 16 Sep 2025 18:21:39 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(remote-serv):=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BC=D0=B5=D0=BD=D1=8E?= =?UTF-8?q?=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20remote-=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8:=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=B2.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 282 ++++++++++++++++++++++++++++++++++++++++++++++- requirements.txt | 1 + 2 files changed, 280 insertions(+), 3 deletions(-) diff --git a/bot.py b/bot.py index 248d28f..4cb5879 100644 --- a/bot.py +++ b/bot.py @@ -1,8 +1,10 @@ import os import asyncio +import io import docker +import paramiko from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes +from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters from dotenv import load_dotenv load_dotenv() @@ -10,6 +12,10 @@ class DockerBot: def __init__(self): self.bot_token = os.getenv('BOT_TOKEN') + # Состояние пользователей для пошагового ввода SSH данных + self.user_states = {} + # Сохраненные сервера по пользователям + self.user_servers = {} # Опционально: ограничить доступ определенным пользователям # self.allowed_users = [int(user_id) for user_id in os.getenv('ALLOWED_USERS', '').split(',') if user_id] # Настройка Docker клиента для работы с socket @@ -135,7 +141,8 @@ async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): keyboard = [ [InlineKeyboardButton("📋 Список контейнеров", callback_data="list")], - [InlineKeyboardButton("📊 Статистика", callback_data="stats")] + [InlineKeyboardButton("📊 Статистика", callback_data="stats")], + [InlineKeyboardButton("🔐 Серверы (remote)", callback_data="ssh_menu")] ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -155,6 +162,22 @@ async def button_handler(self, update: Update, context: ContextTypes.DEFAULT_TYP await self.show_stats(query) elif query.data == "back": await self.start_menu(query) + elif query.data == "ssh_menu": + await self.show_ssh_menu(query) + elif query.data == "ssh_add": + await self.start_add_ssh_server(query) + elif query.data.startswith("ssh_connect_"): + server_id = query.data.replace("ssh_connect_", "") + await self.show_remote_containers(query, server_id) + elif query.data.startswith("ssh_stats_"): + server_id = query.data.replace("ssh_stats_", "") + await self.show_remote_stats(query, server_id) + elif query.data.startswith("ssh_delete_confirm_"): + server_id = query.data.replace("ssh_delete_confirm_", "") + await self.delete_server(query, server_id) + elif query.data.startswith("ssh_delete_"): + server_id = query.data.replace("ssh_delete_", "") + await self.confirm_delete_server(query, server_id) elif query.data.startswith("container_"): await self.show_container_info(query) elif query.data.startswith("action_"): @@ -164,7 +187,8 @@ async def start_menu(self, query): """Показать главное меню""" keyboard = [ [InlineKeyboardButton("📋 Список контейнеров", callback_data="list")], - [InlineKeyboardButton("📊 Статистика", callback_data="stats")] + [InlineKeyboardButton("📊 Статистика", callback_data="stats")], + [InlineKeyboardButton("🔐 Серверы (SSH)", callback_data="ssh_menu")] ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -202,6 +226,257 @@ async def show_containers(self, query): reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text(message, reply_markup=reply_markup) + + async def show_ssh_menu(self, query): + """Меню SSH серверов""" + user_id = query.from_user.id + servers = self.user_servers.get(user_id, []) + + message = "🔐 *Серверы (SSH):*\n\n" + keyboard = [] + + if not servers: + message += "Нет сохраненных серверов. Добавьте новый.\n\n" + else: + for idx, srv in enumerate(servers): + label = f"{srv['username']}@{srv['host']}" + keyboard.append([InlineKeyboardButton(f"📋 {label}", callback_data=f"ssh_connect_{idx}")]) + keyboard.append([InlineKeyboardButton(f"📊 Статистика: {label}", callback_data=f"ssh_stats_{idx}")]) + keyboard.append([InlineKeyboardButton(f"🗑️ Удалить: {label}", callback_data=f"ssh_delete_{idx}")]) + + keyboard.append([InlineKeyboardButton("➕ Добавить сервер", callback_data="ssh_add")]) + keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="back")]) + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.edit_message_text(message, reply_markup=reply_markup) + + async def confirm_delete_server(self, query, server_id: str): + user_id = query.from_user.id + servers = self.user_servers.get(user_id, []) + try: + idx = int(server_id) + srv = servers[idx] + except Exception: + await query.edit_message_text("❌ Сервер не найден") + return + + label = f"{srv['username']}@{srv['host']}" + message = f"Удалить сервер {label}?" + keyboard = [ + [InlineKeyboardButton("✅ Да, удалить", callback_data=f"ssh_delete_confirm_{server_id}")], + [InlineKeyboardButton("❌ Отмена", callback_data="ssh_menu")] + ] + await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard)) + + async def delete_server(self, query, server_id: str): + user_id = query.from_user.id + servers = self.user_servers.get(user_id, []) + try: + idx = int(server_id) + removed = servers.pop(idx) + # Если список пуст, удаляем ключ + if not servers: + self.user_servers.pop(user_id, None) + except Exception: + await query.edit_message_text("❌ Не удалось удалить сервер") + return + + label = f"{removed['username']}@{removed['host']}" + await query.edit_message_text(f"✅ Сервер удален: {label}") + # Показать обновленное меню + await self.show_ssh_menu(query) + + async def start_add_ssh_server(self, query): + """Запустить мастер добавления SSH сервера и установки ключа""" + user_id = query.from_user.id + self.user_states[user_id] = { + 'flow': 'add_server', + 'step': 'host', + 'temp': {} + } + await query.edit_message_text("Введите host (ip/домен) сервера:") + + async def text_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка текстового ввода для сценариев SSH""" + user_id = update.effective_user.id + state = self.user_states.get(user_id) + if not state: + return + + if state.get('flow') == 'add_server': + if state.get('step') == 'host': + state['temp']['host'] = update.message.text.strip() + state['step'] = 'username' + await update.message.reply_text("Введите имя пользователя (например, root):") + return + if state.get('step') == 'username': + state['temp']['username'] = update.message.text.strip() + state['step'] = 'password' + await update.message.reply_text("Введите пароль пользователя (это разово, для установки ключа):") + return + if state.get('step') == 'password': + # Берём пароль и удаляем сообщение пользователя из чата + state['temp']['password'] = update.message.text.strip() + try: + await update.message.delete() + except Exception: + # Могут быть ограничения на удаление — просто игнорируем + pass + host = state['temp']['host'] + username = state['temp']['username'] + password = state['temp']['password'] + + await update.message.reply_text("Пробую установить ключ и сохранить сервер...") + try: + server_entry = await self._install_key_and_save_server(user_id, host, username, password) + except Exception as e: + self.user_states.pop(user_id, None) + await update.message.reply_text(f"❌ Не удалось установить ключ: {e}") + return + + self.user_states.pop(user_id, None) + label = f"{server_entry['username']}@{server_entry['host']}" + await update.message.reply_text(f"✅ Готово. Сервер сохранен: {label}") + # Показать меню SSH + keyboard = [ + [InlineKeyboardButton("📋 Открыть список серверов", callback_data="ssh_menu")] + ] + await update.message.reply_text("Что дальше?", reply_markup=InlineKeyboardMarkup(keyboard)) + return + + async def _install_key_and_save_server(self, user_id: int, host: str, username: str, password: str): + """Сгенерировать ключ, установить на сервер через пароль, сохранить запись""" + private_key_str, public_key_str = self._generate_ssh_keypair(comment=f"{username}@dockerbot") + + # Установим ключ на сервер, используя пароль + self._ssh_copy_id(host, username, password, public_key_str) + + # Сохраняем сервер + server_entry = { + 'host': host, + 'username': username, + 'private_key': private_key_str, + 'public_key': public_key_str + } + self.user_servers.setdefault(user_id, []).append(server_entry) + return server_entry + + def _generate_ssh_keypair(self, comment: str = "dockerbot"): + key = paramiko.RSAKey.generate(2048) + private_io = io.StringIO() + key.write_private_key(private_io) + private_key_str = private_io.getvalue() + public_key_str = f"{key.get_name()} {key.get_base64()} {comment}" + return private_key_str, public_key_str + + def _ssh_copy_id(self, host: str, username: str, password: str, public_key: str): + """Аналог ssh-copy-id: добавить ключ в authorized_keys""" + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(hostname=host, username=username, password=password, timeout=20) + try: + commands = [ + "mkdir -p ~/.ssh", + "chmod 700 ~/.ssh", + "touch ~/.ssh/authorized_keys", + "chmod 600 ~/.ssh/authorized_keys", + # Добавляем ключ, если его еще нет + f"grep -qxF '{public_key}' ~/.ssh/authorized_keys || echo '{public_key}' >> ~/.ssh/authorized_keys" + ] + for cmd in commands: + self._ssh_exec_client(ssh, cmd) + finally: + ssh.close() + + def _build_pkey(self, private_key_str: str): + return paramiko.RSAKey.from_private_key(io.StringIO(private_key_str)) + + def _ssh_exec(self, host: str, username: str, private_key_str: str, command: str, timeout: int = 20): + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + pkey = self._build_pkey(private_key_str) + ssh.connect(hostname=host, username=username, pkey=pkey, timeout=timeout) + try: + return self._ssh_exec_client(ssh, command, timeout) + finally: + ssh.close() + + def _ssh_exec_client(self, ssh: paramiko.SSHClient, command: str, timeout: int = 20): + stdin, stdout, stderr = ssh.exec_command(command, timeout=timeout) + out = stdout.read().decode('utf-8', errors='ignore').strip() + err = stderr.read().decode('utf-8', errors='ignore').strip() + if err and not out: + return err + return out + + async def show_remote_containers(self, query, server_id: str): + user_id = query.from_user.id + servers = self.user_servers.get(user_id, []) + try: + idx = int(server_id) + srv = servers[idx] + except Exception: + await query.edit_message_text("❌ Сервер не найден") + return + + output = self._ssh_exec( + srv['host'], srv['username'], srv['private_key'], + "docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}'" + ) + + lines = [l for l in output.split('\n') if l.strip()] + if not lines: + await query.edit_message_text("📋 Контейнеры не найдены (удаленно)") + return + + message = "📋 *Список контейнеров (удаленно):*\n\n" + for line in lines: + try: + name, status, image = line.split('|', 2) + except ValueError: + continue + status_emoji = "🟢" if status.lower().startswith('up') else "🔴" + message += f"{status_emoji} `{name}`\n" + message += f" Статус: {status}\n" + message += f" Образ: {image}\n\n" + + keyboard = [ + [InlineKeyboardButton("📊 Статистика", callback_data=f"ssh_stats_{server_id}")], + [InlineKeyboardButton("🔙 Назад", callback_data="ssh_menu")] + ] + await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard)) + + async def show_remote_stats(self, query, server_id: str): + user_id = query.from_user.id + servers = self.user_servers.get(user_id, []) + try: + idx = int(server_id) + srv = servers[idx] + except Exception: + await query.edit_message_text("❌ Сервер не найден") + return + + output = self._ssh_exec( + srv['host'], srv['username'], srv['private_key'], + "docker stats --no-stream --format '{{.Name}}|{{.CPUPerc}}|{{.MemPerc}}'" + ) + lines = [l for l in output.split('\n') if l.strip()] + if not lines: + await query.edit_message_text("Нет запущенных контейнеров (удаленно)") + return + + message = "📊 *Статистика сервера (удаленно):*\n\n" + for line in lines: + try: + name, cpu, mem = line.split('|', 2) + except ValueError: + continue + message += f"🟢 {name}\n" + message += f" CPU: {cpu}\n" + message += f" Память: {mem}\n\n" + + keyboard = [[InlineKeyboardButton("🔙 Назад", callback_data="ssh_menu")]] + await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard)) async def show_container_info(self, query): """Показать информацию о контейнере""" @@ -290,6 +565,7 @@ def run(self): application.add_handler(CommandHandler("start", self.start)) application.add_handler(CallbackQueryHandler(self.button_handler)) + application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.text_handler)) print("Бот запущен...") application.run_polling() diff --git a/requirements.txt b/requirements.txt index 530fd98..0dcf0d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ python-telegram-bot==20.7 python-dotenv==1.0.0 docker==6.1.3 requests==2.31.0 +paramiko==3.4.0 From d79379f25a97bb5ff858f45ab52f2e9ad600f6b3 Mon Sep 17 00:00:00 2001 From: lavrov08 <5452894@gmail.com> Date: Tue, 16 Sep 2025 18:44:11 +0300 Subject: [PATCH 2/6] =?UTF-8?q?feat(bot):=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=BC=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=B8=20=D0=B8=D0=B7=20=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F,=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=D0=BE=20=D0=BC=D0=B5=D0=BD=D1=8E=20SSH?= =?UTF-8?q?=20=D1=81=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D1=81=D0=BA=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0.=20=D0=A0=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=20=D1=81=20=D1=83=D1=87=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B8=D1=85=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 144 ++++++++++++++++++++++++++++++++++----------- docker-compose.yml | 1 + env.example | 10 ++++ 3 files changed, 122 insertions(+), 33 deletions(-) diff --git a/bot.py b/bot.py index 4cb5879..653aed7 100644 --- a/bot.py +++ b/bot.py @@ -1,13 +1,15 @@ import os import asyncio import io +import json +from pathlib import Path import docker import paramiko from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters from dotenv import load_dotenv -load_dotenv() +load_dotenv(dotenv_path=Path(__file__).with_name('.env')) class DockerBot: def __init__(self): @@ -16,6 +18,9 @@ def __init__(self): self.user_states = {} # Сохраненные сервера по пользователям self.user_servers = {} + # Глобальные сервера из ENV (общие для всех пользователей, без права удаления из меню) + self.env_servers = self._load_env_servers() + print(f"ENV servers loaded: {len(self.env_servers)}") # Опционально: ограничить доступ определенным пользователям # self.allowed_users = [int(user_id) for user_id in os.getenv('ALLOWED_USERS', '').split(',') if user_id] # Настройка Docker клиента для работы с socket @@ -230,19 +235,29 @@ async def show_containers(self, query): async def show_ssh_menu(self, query): """Меню SSH серверов""" user_id = query.from_user.id - servers = self.user_servers.get(user_id, []) + user_servers = self.user_servers.get(user_id, []) + env_servers = self.env_servers message = "🔐 *Серверы (SSH):*\n\n" keyboard = [] - if not servers: + if not env_servers and not user_servers: message += "Нет сохраненных серверов. Добавьте новый.\n\n" else: - for idx, srv in enumerate(servers): - label = f"{srv['username']}@{srv['host']}" - keyboard.append([InlineKeyboardButton(f"📋 {label}", callback_data=f"ssh_connect_{idx}")]) - keyboard.append([InlineKeyboardButton(f"📊 Статистика: {label}", callback_data=f"ssh_stats_{idx}")]) - keyboard.append([InlineKeyboardButton(f"🗑️ Удалить: {label}", callback_data=f"ssh_delete_{idx}")]) + if env_servers: + message += "Из окружения:\n" + for idx, srv in enumerate(env_servers): + label = f"{srv['username']}@{srv['host']}" + keyboard.append([InlineKeyboardButton(f"📋 {label}", callback_data=f"ssh_connect_env_{idx}")]) + keyboard.append([InlineKeyboardButton(f"📊 Статистика: {label}", callback_data=f"ssh_stats_env_{idx}")]) + message += "\n" + if user_servers: + message += "Ваши сервера:\n" + for idx, srv in enumerate(user_servers): + label = f"{srv['username']}@{srv['host']}" + keyboard.append([InlineKeyboardButton(f"📋 {label}", callback_data=f"ssh_connect_user_{idx}")]) + keyboard.append([InlineKeyboardButton(f"📊 Статистика: {label}", callback_data=f"ssh_stats_user_{idx}")]) + keyboard.append([InlineKeyboardButton(f"🗑️ Удалить: {label}", callback_data=f"ssh_delete_user_{idx}")]) keyboard.append([InlineKeyboardButton("➕ Добавить сервер", callback_data="ssh_add")]) keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="back")]) @@ -252,12 +267,9 @@ async def show_ssh_menu(self, query): async def confirm_delete_server(self, query, server_id: str): user_id = query.from_user.id - servers = self.user_servers.get(user_id, []) - try: - idx = int(server_id) - srv = servers[idx] - except Exception: - await query.edit_message_text("❌ Сервер не найден") + scope, srv = self._resolve_server_by_id(server_id, user_id) + if scope != 'user' or not srv: + await query.edit_message_text("❌ Этот сервер нельзя удалить") return label = f"{srv['username']}@{srv['host']}" @@ -270,16 +282,15 @@ async def confirm_delete_server(self, query, server_id: str): async def delete_server(self, query, server_id: str): user_id = query.from_user.id - servers = self.user_servers.get(user_id, []) - try: - idx = int(server_id) - removed = servers.pop(idx) - # Если список пуст, удаляем ключ - if not servers: - self.user_servers.pop(user_id, None) - except Exception: - await query.edit_message_text("❌ Не удалось удалить сервер") + scope, srv = self._resolve_server_by_id(server_id, user_id) + if scope != 'user' or not srv: + await query.edit_message_text("❌ Этот сервер нельзя удалить") return + servers = self.user_servers.get(user_id, []) + idx = int(server_id.split('_', 1)[1]) + removed = servers.pop(idx) + if not servers: + self.user_servers.pop(user_id, None) label = f"{removed['username']}@{removed['host']}" await query.edit_message_text(f"✅ Сервер удален: {label}") @@ -411,11 +422,8 @@ def _ssh_exec_client(self, ssh: paramiko.SSHClient, command: str, timeout: int = async def show_remote_containers(self, query, server_id: str): user_id = query.from_user.id - servers = self.user_servers.get(user_id, []) - try: - idx = int(server_id) - srv = servers[idx] - except Exception: + scope, srv = self._resolve_server_by_id(server_id, user_id) + if not srv: await query.edit_message_text("❌ Сервер не найден") return @@ -448,11 +456,8 @@ async def show_remote_containers(self, query, server_id: str): async def show_remote_stats(self, query, server_id: str): user_id = query.from_user.id - servers = self.user_servers.get(user_id, []) - try: - idx = int(server_id) - srv = servers[idx] - except Exception: + scope, srv = self._resolve_server_by_id(server_id, user_id) + if not srv: await query.edit_message_text("❌ Сервер не найден") return @@ -477,6 +482,79 @@ async def show_remote_stats(self, query, server_id: str): keyboard = [[InlineKeyboardButton("🔙 Назад", callback_data="ssh_menu")]] await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard)) + + def _load_env_servers(self): + # Только парольные сервера: SSH_SERVERS_PWD_JSON + raw_pwd = os.getenv('SSH_SERVERS_PWD_JSON', '') + if raw_pwd is None: + raw_pwd = '' + raw_pwd = raw_pwd.strip() + print(f"SSH_SERVERS_PWD_JSON present={bool(raw_pwd)} len={len(raw_pwd) if raw_pwd else 0}") + pwd_based = [] + if raw_pwd: + try: + data_pwd = json.loads(raw_pwd) + print(f"SSH_SERVERS_PWD_JSON parsed, type={type(data_pwd).__name__}") + if isinstance(data_pwd, list): + print(f"SSH_SERVERS_PWD_JSON list size={len(data_pwd)}") + for idx, item in enumerate(data_pwd): + if not isinstance(item, dict): + print(f"SSH_SERVERS_PWD_JSON[{idx}] skipped: not a dict") + continue + host = item.get('host') + username = item.get('username') + password = item.get('password') + if not host or not username or not password: + print(f"SSH_SERVERS_PWD_JSON[{idx}] missing required fields") + continue + try: + entry = self._install_key_for_env(host, username, password) + pwd_based.append(entry) + except Exception as e: + print(f"SSH_SERVERS_PWD_JSON[{idx}] install failed: {e}") + continue + except Exception as e: + print(f"SSH_SERVERS_PWD_JSON json error: {e}") + + return pwd_based + + def _install_key_for_env(self, host: str, username: str, password: str): + private_key_str, public_key_str = self._generate_ssh_keypair(comment=f"{username}@dockerbot-env") + self._ssh_copy_id(host, username, password, public_key_str) + return { + 'host': host, + 'username': username, + 'private_key': private_key_str, + 'public_key': public_key_str + } + + def _resolve_server_by_id(self, server_id: str, user_id: int): + # server_id может быть вида: "env_0" или "user_1" или старый int (совместимость) + if server_id.isdigit(): + servers = self.user_servers.get(user_id, []) + try: + idx = int(server_id) + return 'user', servers[idx] + except Exception: + return None, None + if '_' in server_id: + scope, idx_str = server_id.split('_', 1) + try: + idx = int(idx_str) + except Exception: + return None, None + if scope == 'env': + try: + return 'env', self.env_servers[idx] + except Exception: + return None, None + if scope == 'user': + servers = self.user_servers.get(user_id, []) + try: + return 'user', servers[idx] + except Exception: + return None, None + return None, None async def show_container_info(self, query): """Показать информацию о контейнере""" diff --git a/docker-compose.yml b/docker-compose.yml index a040fa4..75d9b4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: restart: unless-stopped environment: - BOT_TOKEN=${BOT_TOKEN} + - SSH_SERVERS_PWD_JSON=${SSH_SERVERS_PWD_JSON} volumes: - /var/run/docker.sock:/var/run/docker.sock - ./logs:/app/logs diff --git a/env.example b/env.example index 05d33be..dff5cb0 100644 --- a/env.example +++ b/env.example @@ -3,3 +3,13 @@ BOT_TOKEN=your_telegram_bot_token_here # Опционально: ограничить доступ определенным пользователям (ID через запятую) # ALLOWED_USERS=123456789,987654321 + +# Опционально: предзагруженные Remote-сервера в JSON-формате (в одну строку) +# Вариант — по паролю (на старте генерируется ключ и ставится на сервер, пароль не сохраняется): +# SSH_SERVERS_PWD_JSON='[ +# {"host":"srv.local","username":"root","password":"s3cr3t"} +# ]' +# Поля объекта: +# - host (обяз.) +# - username (обяз.) +# - password (обяз., используется один раз для установки ключа) From 786c77417d3664922831ec1280b6de32fa27ee53 Mon Sep 17 00:00:00 2001 From: lavrov08 <5452894@gmail.com> Date: Tue, 16 Sep 2025 18:56:14 +0300 Subject: [PATCH 3/6] =?UTF-8?q?feat(bot):=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20HTML=20=D0=B4=D0=BB=D1=8F=20=D1=83=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=85=20=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B0=D1=85.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/bot.py b/bot.py index 653aed7..f2fe32e 100644 --- a/bot.py +++ b/bot.py @@ -3,10 +3,12 @@ import io import json from pathlib import Path +import html import docker import paramiko from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters +from telegram.constants import ParseMode +from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters, Defaults from dotenv import load_dotenv load_dotenv(dotenv_path=Path(__file__).with_name('.env')) @@ -210,15 +212,15 @@ async def show_containers(self, query): await query.edit_message_text("📋 Контейнеры не найдены") return - message = "📋 *Список контейнеров:*\n\n" + message = "📋 Список контейнеров:\n\n" keyboard = [] for container in containers: status_emoji = "🟢" if container['status'] == 'running' else "🔴" - message += f"{status_emoji} `{container['name']}`\n" - message += f" Статус: {container['status']}\n" - message += f" Образ: {container['image']}\n\n" + message += f"{status_emoji} {html.escape(container['name'])}\n" + message += f" Статус: {html.escape(container['status'])}\n" + message += f" Образ: {html.escape(container['image'])}\n\n" keyboard.append([ InlineKeyboardButton( @@ -230,7 +232,7 @@ async def show_containers(self, query): keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="back")]) reply_markup = InlineKeyboardMarkup(keyboard) - await query.edit_message_text(message, reply_markup=reply_markup) + await query.edit_message_text(message, reply_markup=reply_markup, parse_mode=ParseMode.HTML) async def show_ssh_menu(self, query): """Меню SSH серверов""" @@ -437,22 +439,22 @@ async def show_remote_containers(self, query, server_id: str): await query.edit_message_text("📋 Контейнеры не найдены (удаленно)") return - message = "📋 *Список контейнеров (удаленно):*\n\n" + message = "📋 Список контейнеров (удаленно):\n\n" for line in lines: try: name, status, image = line.split('|', 2) except ValueError: continue status_emoji = "🟢" if status.lower().startswith('up') else "🔴" - message += f"{status_emoji} `{name}`\n" - message += f" Статус: {status}\n" - message += f" Образ: {image}\n\n" + message += f"{status_emoji} {html.escape(name)}\n" + message += f" Статус: {html.escape(status)}\n" + message += f" Образ: {html.escape(image)}\n\n" keyboard = [ [InlineKeyboardButton("📊 Статистика", callback_data=f"ssh_stats_{server_id}")], [InlineKeyboardButton("🔙 Назад", callback_data="ssh_menu")] ] - await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard)) + await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.HTML) async def show_remote_stats(self, query, server_id: str): user_id = query.from_user.id @@ -564,9 +566,9 @@ async def show_container_info(self, query): container = self.docker_client.containers.get(container_name) status = container.status - message = f"🐳 *{container_name}*\n\n" - message += f"Статус: {status}\n" - message += f"Образ: {container.image.tags[0] if container.image.tags else container.image.short_id}\n\n" + message = f"🐳 {html.escape(container_name)}\n\n" + message += f"Статус: {html.escape(status)}\n" + message += f"Образ: {html.escape(container.image.tags[0] if container.image.tags else container.image.short_id)}\n\n" keyboard = [] @@ -580,7 +582,7 @@ async def show_container_info(self, query): keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="list")]) reply_markup = InlineKeyboardMarkup(keyboard) - await query.edit_message_text(message, reply_markup=reply_markup) + await query.edit_message_text(message, reply_markup=reply_markup, parse_mode=ParseMode.HTML) except Exception as e: await query.edit_message_text(f"❌ Ошибка при получении информации о контейнере: {e}") @@ -612,12 +614,12 @@ async def handle_action(self, query): logs = await self.get_container_logs(container_name, 20) if len(logs) > 3000: logs = logs[-3000:] + "\n\n... (показаны последние 20 строк)" - - message = f"📝 *Логи {container_name}:*\n\n```\n{logs}\n```" + + message = f"📝 Логи {html.escape(container_name)}:\n\n
{html.escape(logs)}
" keyboard = [[InlineKeyboardButton("🔙 Назад", callback_data=f"container_{container_name}")]] reply_markup = InlineKeyboardMarkup(keyboard) - await query.edit_message_text(message, reply_markup=reply_markup) + await query.edit_message_text(message, reply_markup=reply_markup, parse_mode=ParseMode.HTML) async def show_stats(self, query): """Показать статистику""" @@ -639,7 +641,8 @@ async def show_stats(self, query): def run(self): """Запуск бота""" - application = Application.builder().token(self.bot_token).build() + defaults = Defaults(parse_mode=ParseMode.MARKDOWN) + application = Application.builder().token(self.bot_token).defaults(defaults).build() application.add_handler(CommandHandler("start", self.start)) application.add_handler(CallbackQueryHandler(self.button_handler)) From d7828001fcb3e83099a373c4f19c932038ffb621 Mon Sep 17 00:00:00 2001 From: lavrov08 <5452894@gmail.com> Date: Tue, 16 Sep 2025 19:01:30 +0300 Subject: [PATCH 4/6] =?UTF-8?q?feat(statistic):=20=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=D0=BC=20HTML=20=D0=B4=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot.py b/bot.py index f2fe32e..dc87ccc 100644 --- a/bot.py +++ b/bot.py @@ -472,18 +472,18 @@ async def show_remote_stats(self, query, server_id: str): await query.edit_message_text("Нет запущенных контейнеров (удаленно)") return - message = "📊 *Статистика сервера (удаленно):*\n\n" + message = "📊 Статистика сервера (удаленно):\n\n" for line in lines: try: name, cpu, mem = line.split('|', 2) except ValueError: continue - message += f"🟢 {name}\n" - message += f" CPU: {cpu}\n" - message += f" Память: {mem}\n\n" + message += f"🟢 {html.escape(name)}\n" + message += f" CPU: {html.escape(cpu)}\n" + message += f" Память: {html.escape(mem)}\n\n" keyboard = [[InlineKeyboardButton("🔙 Назад", callback_data="ssh_menu")]] - await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard)) + await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.HTML) def _load_env_servers(self): # Только парольные сервера: SSH_SERVERS_PWD_JSON From 99cd2cfc9c73234250491f49c161246c6a25dc8e Mon Sep 17 00:00:00 2001 From: lavrov08 <5452894@gmail.com> Date: Tue, 16 Sep 2025 19:05:05 +0300 Subject: [PATCH 5/6] =?UTF-8?q?feat(bot):=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D0=BC=D0=B8=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=20SSH,=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=BD=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=B2=D1=8B=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B8=D0=B9=20=D0=B8=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/bot.py b/bot.py index dc87ccc..9d5d4c3 100644 --- a/bot.py +++ b/bot.py @@ -4,6 +4,7 @@ import json from pathlib import Path import html +from urllib.parse import quote, unquote import docker import paramiko from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup @@ -185,6 +186,20 @@ async def button_handler(self, update: Update, context: ContextTypes.DEFAULT_TYP elif query.data.startswith("ssh_delete_"): server_id = query.data.replace("ssh_delete_", "") await self.confirm_delete_server(query, server_id) + elif query.data.startswith("sshc|"): + parts = query.data.split("|") + # Formats: + # sshc|info|{server_id}|{enc_name} + # sshc|action|{server_id}|{start|stop|restart}|{enc_name} + # sshc|logs|{server_id}|{enc_name} + if len(parts) >= 3: + cmd = parts[1] + if cmd == 'info' and len(parts) == 4: + await self.show_remote_container_info(query, parts[2], unquote(parts[3])) + elif cmd == 'action' and len(parts) == 5: + await self.handle_remote_action(query, parts[2], parts[3], unquote(parts[4])) + elif cmd == 'logs' and len(parts) == 4: + await self.show_remote_logs(query, parts[2], unquote(parts[3])) elif query.data.startswith("container_"): await self.show_container_info(query) elif query.data.startswith("action_"): @@ -440,6 +455,7 @@ async def show_remote_containers(self, query, server_id: str): return message = "📋 Список контейнеров (удаленно):\n\n" + keyboard = [] for line in lines: try: name, status, image = line.split('|', 2) @@ -449,11 +465,17 @@ async def show_remote_containers(self, query, server_id: str): message += f"{status_emoji} {html.escape(name)}\n" message += f" Статус: {html.escape(status)}\n" message += f" Образ: {html.escape(image)}\n\n" + enc = quote(name, safe='') + # Кнопка подробностей/управления + keyboard.append([ + InlineKeyboardButton( + f"{'⏹️' if status_emoji=='🟢' else '▶️'} {name}", + callback_data=f"sshc|info|{server_id}|{enc}" + ) + ]) - keyboard = [ - [InlineKeyboardButton("📊 Статистика", callback_data=f"ssh_stats_{server_id}")], - [InlineKeyboardButton("🔙 Назад", callback_data="ssh_menu")] - ] + keyboard.append([InlineKeyboardButton("📊 Статистика", callback_data=f"ssh_stats_{server_id}")]) + keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="ssh_menu")]) await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.HTML) async def show_remote_stats(self, query, server_id: str): @@ -485,6 +507,73 @@ async def show_remote_stats(self, query, server_id: str): keyboard = [[InlineKeyboardButton("🔙 Назад", callback_data="ssh_menu")]] await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.HTML) + async def show_remote_container_info(self, query, server_id: str, container_name: str): + user_id = query.from_user.id + scope, srv = self._resolve_server_by_id(server_id, user_id) + if not srv: + await query.edit_message_text("❌ Сервер не найден") + return + + # Получим статус, образ + info_line = self._ssh_exec( + srv['host'], srv['username'], srv['private_key'], + f"docker ps -a --filter name=^/{container_name}$ --format '{{{{.Status}}}}|{{{{.Image}}}}'" + ).strip() + status = "unknown" + image = "" + if info_line and '|' in info_line: + status, image = info_line.split('|', 1) + + message = f"🐳 {html.escape(container_name)}\n\n" + message += f"Статус: {html.escape(status)}\n" + message += f"Образ: {html.escape(image)}\n\n" + + enc = quote(container_name, safe='') + keyboard = [] + if status.lower().startswith('up'): + keyboard.append([InlineKeyboardButton("⏹️ Остановить", callback_data=f"sshc|action|{server_id}|stop|{enc}")]) + keyboard.append([InlineKeyboardButton("🔄 Перезапустить", callback_data=f"sshc|action|{server_id}|restart|{enc}")]) + else: + keyboard.append([InlineKeyboardButton("▶️ Запустить", callback_data=f"sshc|action|{server_id}|start|{enc}")]) + keyboard.append([InlineKeyboardButton("📝 Логи", callback_data=f"sshc|logs|{server_id}|{enc}")]) + keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data=f"ssh_connect_{server_id}")]) + + await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.HTML) + + async def handle_remote_action(self, query, server_id: str, action: str, container_name: str): + user_id = query.from_user.id + scope, srv = self._resolve_server_by_id(server_id, user_id) + if not srv: + await query.edit_message_text("❌ Сервер не найден") + return + cmd = None + if action == 'start': + cmd = f"docker start {container_name}" + elif action == 'stop': + cmd = f"docker stop {container_name}" + elif action == 'restart': + cmd = f"docker restart {container_name}" + else: + await query.edit_message_text("❌ Неизвестное действие") + return + out = self._ssh_exec(srv['host'], srv['username'], srv['private_key'], cmd) + del out + # Обновим карточку контейнера + await self.show_remote_container_info(query, server_id, container_name) + + async def show_remote_logs(self, query, server_id: str, container_name: str): + user_id = query.from_user.id + scope, srv = self._resolve_server_by_id(server_id, user_id) + if not srv: + await query.edit_message_text("❌ Сервер не найден") + return + logs = self._ssh_exec(srv['host'], srv['username'], srv['private_key'], f"docker logs --tail 50 {container_name}") + if len(logs) > 3000: + logs = logs[-3000:] + message = f"📝 Логи {html.escape(container_name)} (удаленно):\n\n
{html.escape(logs)}
" + keyboard = [[InlineKeyboardButton("🔙 Назад", callback_data=f"sshc|info|{server_id}|{quote(container_name, safe='')}")]] + await query.edit_message_text(message, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.HTML) + def _load_env_servers(self): # Только парольные сервера: SSH_SERVERS_PWD_JSON raw_pwd = os.getenv('SSH_SERVERS_PWD_JSON', '') From c2c43d8d68c4df100a4d0be62b5a774a23a3b158 Mon Sep 17 00:00:00 2001 From: lavrov08 <5452894@gmail.com> Date: Tue, 16 Sep 2025 19:19:38 +0300 Subject: [PATCH 6/6] =?UTF-8?q?feat(readme):=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=B8=20=D0=BF=D0=BE=20SSH=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=BF=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=B5=D0=B4=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B2=20=D0=B2=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 237e2cf..932f108 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - 📊 Статистика сервера (CPU, память) - 🔒 Безопасность через токены - 🚀 Асинхронная работа +- 🔗 Управление удалёнными серверами по SSH ## Установка @@ -32,6 +33,10 @@ Создайте файл `.env`: ``` BOT_TOKEN=your_telegram_bot_token + +# Опционально: предзагрузка SSH-серверов (одна строка JSON) +# Пример: +# SSH_SERVERS_PWD_JSON='[{"host":"srv.local","username":"root","password":"s3cr3t"}]' ``` ## Использование