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"}]'
```
## Использование