Initial commit
This commit is contained in:
commit
e3238bebf1
|
|
@ -0,0 +1,5 @@
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
bot.log
|
||||||
|
http:/
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
|
||||||
|
|
||||||
|
from config import BOT_TOKEN, ADMIN_ID, HOSTS_LIST
|
||||||
|
from monitor import collect_metrics
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
# Кеш для хранения последних собранных данных (чтобы не дергать SSH каждый раз при нажатии кнопки)
|
||||||
|
CACHE = {}
|
||||||
|
|
||||||
|
def format_metrics(data):
|
||||||
|
"""Формирует красивое сообщение из метрик"""
|
||||||
|
if "error" in data:
|
||||||
|
return f"❌ Ошибка подключения к {data['host']}:\n{data['error']}"
|
||||||
|
|
||||||
|
m = data['metrics']
|
||||||
|
host = data['host']
|
||||||
|
ip = data['ip']
|
||||||
|
|
||||||
|
# Блок CPU
|
||||||
|
cpu_bar = "█" * int(m['cpu'] // 5) + "░" * (20 - int(m['cpu'] // 5))
|
||||||
|
text = f"🖥️ *{host} ({ip})*\n"
|
||||||
|
text += f"🕒 *Uptime:* {m.get('uptime', 'N/A')}\n"
|
||||||
|
text += f"📊 *Load Avg:* {m.get('load_1', '0')}, {m.get('load_5', '0')}, {m.get('load_15', '0')}\n\n"
|
||||||
|
|
||||||
|
text += f"🔥 *CPU:* {m['cpu']:.1f}%\n`{cpu_bar}`\n"
|
||||||
|
|
||||||
|
mem_bar = "█" * int(m['mem_percent'] // 5) + "░" * (20 - int(m['mem_percent'] // 5))
|
||||||
|
text += f"🧠 *RAM:* {m['mem_used_mb']} MB ({m['mem_percent']}%)\n`{mem_bar}`\n"
|
||||||
|
|
||||||
|
# Сеть (за час)
|
||||||
|
text += f"🌐 *Network (per hour):* 📥 {m['net_rx_mb']} MB | 📤 {m['net_tx_mb']} MB\n"
|
||||||
|
|
||||||
|
# Диски
|
||||||
|
text += "\n💾 *Disks (top 3 by %util):*\n"
|
||||||
|
for disk in m.get('disks', []):
|
||||||
|
text += f" └─ {disk['name']}: util {disk['util']:.1f}% (r: {disk['rps']:.1f}/s, w: {disk['wps']:.1f}/s)\n"
|
||||||
|
|
||||||
|
# Температуры
|
||||||
|
if m.get('temps'):
|
||||||
|
text += "\n🌡️ *Temperatures:*\n"
|
||||||
|
for i, temp in enumerate(m['temps']):
|
||||||
|
text += f" └─ Sensor {i+1}: {temp:.1f}°C\n"
|
||||||
|
|
||||||
|
# Топ процессов
|
||||||
|
text += "\n🏆 *Top 5 CPU:*\n```\n"
|
||||||
|
text += m.get('top_cpu', 'N/A') + "\n```"
|
||||||
|
text += "🏆 *Top 5 RAM:*\n```\n"
|
||||||
|
text += m.get('top_mem', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
# Логи и безопасность
|
||||||
|
text += "\n⚠️ *Recent Errors (journalctl -p 3):*\n```\n"
|
||||||
|
text += m.get('logs_errors', 'N/A')[:300] + "\n```"
|
||||||
|
|
||||||
|
text += "\n🛡️ *Fail2ban Status:*\n```\n"
|
||||||
|
text += m.get('fail2ban_status', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
text += "\n🚫 *Fail2ban Logs (last bans):*\n```\n"
|
||||||
|
text += m.get('fail2ban_logs', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
text += f"\n⏱️ *Обновлено:* {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
# OOM проверка
|
||||||
|
text += "\n💀 *Out of Memory Events:*\n```\n"
|
||||||
|
text += m.get('oom', 'Нет данных') + "\n```"
|
||||||
|
|
||||||
|
async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработчик команды /status"""
|
||||||
|
if update.effective_user.id != ADMIN_ID:
|
||||||
|
await update.message.reply_text("⛔ Доступ запрещен. Ты не мой хозяин.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Создаем клавиатуру с хостами
|
||||||
|
keyboard = []
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
keyboard.append([InlineKeyboardButton(host['name'], callback_data=f"host_{host['ip']}")])
|
||||||
|
keyboard.append([InlineKeyboardButton("🔄 Все хосты", callback_data="host_all")])
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🔍 Выбери хост для мониторинга или нажми 'Все хосты':",
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
|
||||||
|
async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработка нажатий на кнопки"""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
if update.effective_user.id != ADMIN_ID:
|
||||||
|
await query.edit_message_text("⛔ Недостаточно прав.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = query.data
|
||||||
|
if data == "host_all":
|
||||||
|
await query.edit_message_text("⏳ Собираю данные со всех хостов...")
|
||||||
|
full_report = ""
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
full_report += report + "\n\n" + "="*30 + "\n\n"
|
||||||
|
# Telegram имеет лимит 4096 символов, разобьем если что
|
||||||
|
await query.edit_message_text(full_report[:4000])
|
||||||
|
elif data.startswith("host_"):
|
||||||
|
ip = data.replace("host_", "")
|
||||||
|
host = next((h for h in HOSTS_LIST if h['ip'] == ip), None)
|
||||||
|
if not host:
|
||||||
|
await query.edit_message_text("Хост не найден")
|
||||||
|
return
|
||||||
|
await query.edit_message_text(f"⏳ Собираю данные с {host['name']}...")
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
await query.edit_message_text(report, parse_mode="Markdown")
|
||||||
|
|
||||||
|
async def scheduled_job(context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Функция, вызываемая раз в час"""
|
||||||
|
bot: Bot = context.bot
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
try:
|
||||||
|
await bot.send_message(chat_id=ADMIN_ID, text=report, parse_mode="Markdown")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.send_message(chat_id=ADMIN_ID, text=f"Ошибка отправки для {host['name']}: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = Application.builder().token(BOT_TOKEN).build()
|
||||||
|
|
||||||
|
# Регистрируем команды
|
||||||
|
app.add_handler(CommandHandler("status", status_command))
|
||||||
|
app.add_handler(CallbackQueryHandler(button_callback))
|
||||||
|
|
||||||
|
# Настройка шедулера (раз в час)
|
||||||
|
job_queue = app.job_queue
|
||||||
|
# Запускаем сразу при старте, потом каждый час
|
||||||
|
job_queue.run_repeating(scheduled_job, interval=3600, first=10) # first=10 (через 10 сек после запуска)
|
||||||
|
|
||||||
|
print("🤖 Бот запущен и готов к работе!")
|
||||||
|
app.run_polling()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||||
|
ADMIN_ID = int(os.getenv("ADMIN_ID"))
|
||||||
|
|
||||||
|
# Парсим хосты
|
||||||
|
HOSTS_RAW = os.getenv("HOSTS", "")
|
||||||
|
HOSTS_LIST = []
|
||||||
|
if HOSTS_RAW:
|
||||||
|
for item in HOSTS_RAW.split(";"):
|
||||||
|
if item.strip():
|
||||||
|
name, ip, port, user, key_path = item.strip().split(",")
|
||||||
|
HOSTS_LIST.append({
|
||||||
|
"name": name,
|
||||||
|
"ip": ip,
|
||||||
|
"port": int(port),
|
||||||
|
"user": user,
|
||||||
|
"key_path": key_path
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# Глобальный словарь для хранения предыдущих значений RX/TX (для расчета за час)
|
||||||
|
# Ключ: (host_ip, interface) -> {'rx': int, 'tx': int, 'time': timestamp}
|
||||||
|
prev_net_stats = {}
|
||||||
|
|
||||||
|
def get_ssh_client(host_config):
|
||||||
|
"""Создает SSH подключение"""
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
hostname=host_config['ip'],
|
||||||
|
port=host_config['port'],
|
||||||
|
username=host_config['user'],
|
||||||
|
key_filename=host_config['key_path'],
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
except Exception as e:
|
||||||
|
return f"SSH Error: {str(e)}"
|
||||||
|
|
||||||
|
def exec_cmd(client, command):
|
||||||
|
"""Выполняет команду и возвращает вывод"""
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command(command, timeout=10)
|
||||||
|
return stdout.read().decode('utf-8').strip()
|
||||||
|
except Exception as e:
|
||||||
|
return f"CMD Error: {str(e)}"
|
||||||
|
|
||||||
|
def collect_metrics(host_config):
|
||||||
|
"""Сбор всех метрик с одного хоста"""
|
||||||
|
client = get_ssh_client(host_config)
|
||||||
|
if isinstance(client, str):
|
||||||
|
return {"error": client}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"host": host_config['name'],
|
||||||
|
"ip": host_config['ip'],
|
||||||
|
"metrics": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. CPU загрузка (%)
|
||||||
|
cpu_output = exec_cmd(client, "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
|
||||||
|
try:
|
||||||
|
result["metrics"]["cpu"] = float(cpu_output.replace(',', '.'))
|
||||||
|
except:
|
||||||
|
result["metrics"]["cpu"] = 0.0
|
||||||
|
|
||||||
|
# 2. ОЗУ (используется в MB и %)
|
||||||
|
mem_output = exec_cmd(client, "free -m | grep Mem")
|
||||||
|
parts = re.split(r'\s+', mem_output)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
total_mem = int(parts[1])
|
||||||
|
used_mem = int(parts[2])
|
||||||
|
result["metrics"]["mem_used_mb"] = used_mem
|
||||||
|
result["metrics"]["mem_percent"] = round((used_mem / total_mem) * 100, 1)
|
||||||
|
else:
|
||||||
|
result["metrics"]["mem_used_mb"] = 0
|
||||||
|
result["metrics"]["mem_percent"] = 0
|
||||||
|
|
||||||
|
# 3. Uptime и Load Average
|
||||||
|
uptime_output = exec_cmd(client, "uptime")
|
||||||
|
# Парсим load average (1,5,15 мин)
|
||||||
|
load_avg = re.search(r'load average:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)', uptime_output)
|
||||||
|
if load_avg:
|
||||||
|
result["metrics"]["load_1"] = load_avg.group(1)
|
||||||
|
result["metrics"]["load_5"] = load_avg.group(2)
|
||||||
|
result["metrics"]["load_15"] = load_avg.group(3)
|
||||||
|
# Парсим uptime (дни/часы)
|
||||||
|
uptime_seconds = exec_cmd(client, "cat /proc/uptime | awk '{print $1}'")
|
||||||
|
try:
|
||||||
|
uptime_seconds = float(uptime_seconds.split('.')[0])
|
||||||
|
days = uptime_seconds // 86400
|
||||||
|
hours = (uptime_seconds % 86400) // 3600
|
||||||
|
minutes = (uptime_seconds % 3600) // 60
|
||||||
|
result["metrics"]["uptime"] = f"{int(days)}d {int(hours)}h {int(minutes)}m"
|
||||||
|
except:
|
||||||
|
result["metrics"]["uptime"] = "N/A"
|
||||||
|
|
||||||
|
# 4. Сеть (трафик за последний час)
|
||||||
|
# Сначала собираем текущие счетчики по всем интерфейсам (кроме lo)
|
||||||
|
net_output = exec_cmd(client, "cat /proc/net/dev | tail -n +3 | grep -v lo")
|
||||||
|
current_net = {}
|
||||||
|
for line in net_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
iface = parts[0].replace(':', '')
|
||||||
|
rx_bytes = int(parts[1])
|
||||||
|
tx_bytes = int(parts[9])
|
||||||
|
current_net[iface] = {'rx': rx_bytes, 'tx': tx_bytes}
|
||||||
|
|
||||||
|
# Расчет за час (используем глобальный кеш)
|
||||||
|
global prev_net_stats
|
||||||
|
host_key = host_config['ip']
|
||||||
|
total_rx_hour = 0
|
||||||
|
total_tx_hour = 0
|
||||||
|
|
||||||
|
if host_key in prev_net_stats:
|
||||||
|
old_data = prev_net_stats[host_key]
|
||||||
|
for iface, vals in current_net.items():
|
||||||
|
if iface in old_data:
|
||||||
|
# Считаем разницу. Если перезагрузили сервер - счетчики сбросятся, это ок.
|
||||||
|
delta_rx = vals['rx'] - old_data[iface]['rx']
|
||||||
|
delta_tx = vals['tx'] - old_data[iface]['tx']
|
||||||
|
if delta_rx > 0:
|
||||||
|
total_rx_hour += delta_rx
|
||||||
|
if delta_tx > 0:
|
||||||
|
total_tx_hour += delta_tx
|
||||||
|
|
||||||
|
# Переводим в Мегабайты (для удобства)
|
||||||
|
result["metrics"]["net_rx_mb"] = round(total_rx_hour / (1024 * 1024), 2)
|
||||||
|
result["metrics"]["net_tx_mb"] = round(total_tx_hour / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
# Сохраняем текущие значения для следующего замера
|
||||||
|
prev_net_stats[host_key] = current_net
|
||||||
|
|
||||||
|
# 5. Диски (iostat %util)
|
||||||
|
iostat_output = exec_cmd(client, "iostat -x -k 1 2 | grep -E 'sd[a-z]|vd[a-z]|nvme' | tail -n +3")
|
||||||
|
disk_stats = []
|
||||||
|
# Собираем все диски, показываем топ по нагрузке
|
||||||
|
for line in iostat_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
if len(parts) >= 14:
|
||||||
|
disk_name = parts[0]
|
||||||
|
try:
|
||||||
|
util = float(parts[-1].replace(',', '.'))
|
||||||
|
rps = float(parts[3].replace(',', '.')) # read/s
|
||||||
|
wps = float(parts[4].replace(',', '.')) # write/s
|
||||||
|
disk_stats.append({
|
||||||
|
"name": disk_name,
|
||||||
|
"util": util,
|
||||||
|
"rps": rps,
|
||||||
|
"wps": wps
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Сортируем по утилизации и берем топ-3 самых нагруженных
|
||||||
|
disk_stats.sort(key=lambda x: x['util'], reverse=True)
|
||||||
|
result["metrics"]["disks"] = disk_stats[:3]
|
||||||
|
|
||||||
|
# 6. Температуры (если есть sensors)
|
||||||
|
temp_output = exec_cmd(client, "sensors -u 2>/dev/null | grep -E 'temp[0-9]+_input|Core' | head -5")
|
||||||
|
temps = []
|
||||||
|
if temp_output and "Error" not in temp_output:
|
||||||
|
# Парсим цифры
|
||||||
|
for line in temp_output.split('\n'):
|
||||||
|
if '_input:' in line:
|
||||||
|
val = re.search(r'([\d.]+)', line)
|
||||||
|
if val:
|
||||||
|
temps.append(float(val.group(1)))
|
||||||
|
result["metrics"]["temps"] = temps
|
||||||
|
|
||||||
|
# 7. Топ-5 процессов по CPU и RAM
|
||||||
|
top_cpu = exec_cmd(client, "ps aux --sort=-%cpu | head -6 | tail -5")
|
||||||
|
top_mem = exec_cmd(client, "ps aux --sort=-%mem | head -6 | tail -5")
|
||||||
|
result["metrics"]["top_cpu"] = top_cpu
|
||||||
|
result["metrics"]["top_mem"] = top_mem
|
||||||
|
|
||||||
|
# 8. Логи (ошибки, fail2ban)
|
||||||
|
# Проверяем системный журнал на панику/ошибки за последнюю минуту (чтобы не спамить)
|
||||||
|
last_logs = exec_cmd(client, "journalctl -p 3 -xn 5 --no-pager 2>/dev/null || dmesg | tail -5")
|
||||||
|
# Fail2ban статус
|
||||||
|
fail2ban_status = exec_cmd(client, "fail2ban-client status 2>/dev/null | grep 'Number of failed'")
|
||||||
|
if not fail2ban_status:
|
||||||
|
fail2ban_status = "Fail2ban not installed or inactive"
|
||||||
|
|
||||||
|
# Кто пытался прорваться (последние записи из логов)
|
||||||
|
fail2ban_logs = exec_cmd(client, "tail -10 /var/log/fail2ban.log 2>/dev/null | grep -i banned || echo 'No recent bans'")
|
||||||
|
|
||||||
|
result["metrics"]["logs_errors"] = last_logs
|
||||||
|
result["metrics"]["fail2ban_status"] = fail2ban_status
|
||||||
|
result["metrics"]["fail2ban_logs"] = fail2ban_logs
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Глобальный словарь для хранения предыдущих значений RX/TX (для расчета за час)
|
||||||
|
prev_net_stats = {}
|
||||||
|
|
||||||
|
def get_ssh_client(host_config):
|
||||||
|
"""Создает SSH подключение с детальным логированием"""
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
logger.info(f"🔌 Подключаюсь к {host_config['ip']}:{host_config['port']} как {host_config['user']}")
|
||||||
|
client.connect(
|
||||||
|
hostname=host_config['ip'],
|
||||||
|
port=host_config['port'],
|
||||||
|
username=host_config['user'],
|
||||||
|
key_filename=host_config['key_path'],
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Подключено к {host_config['ip']}")
|
||||||
|
return client
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
error_msg = f"❌ Ошибка аутентификации для {host_config['ip']}! Проверь логин или ключ."
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
error_msg = f"❌ SSH ошибка для {host_config['ip']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Неизвестная ошибка для {host_config['ip']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
def exec_cmd(client, command):
|
||||||
|
"""Выполняет команду и возвращает вывод"""
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command(command, timeout=10)
|
||||||
|
return stdout.read().decode('utf-8').strip()
|
||||||
|
except Exception as e:
|
||||||
|
return f"CMD Error: {str(e)}"
|
||||||
|
|
||||||
|
def collect_metrics(host_config):
|
||||||
|
"""Сбор всех метрик с одного хоста"""
|
||||||
|
client = get_ssh_client(host_config)
|
||||||
|
if isinstance(client, str):
|
||||||
|
return {"error": client, "host": host_config['name'], "ip": host_config['ip']}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"host": host_config['name'],
|
||||||
|
"ip": host_config['ip'],
|
||||||
|
"metrics": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. CPU загрузка (%)
|
||||||
|
cpu_output = exec_cmd(client, "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
|
||||||
|
try:
|
||||||
|
result["metrics"]["cpu"] = float(cpu_output.replace(',', '.'))
|
||||||
|
except:
|
||||||
|
result["metrics"]["cpu"] = 0.0
|
||||||
|
|
||||||
|
# 2. ОЗУ (используется в MB и %)
|
||||||
|
mem_output = exec_cmd(client, "free -m | grep Mem")
|
||||||
|
parts = re.split(r'\s+', mem_output)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
total_mem = int(parts[1])
|
||||||
|
used_mem = int(parts[2])
|
||||||
|
result["metrics"]["mem_used_mb"] = used_mem
|
||||||
|
result["metrics"]["mem_percent"] = round((used_mem / total_mem) * 100, 1)
|
||||||
|
else:
|
||||||
|
result["metrics"]["mem_used_mb"] = 0
|
||||||
|
result["metrics"]["mem_percent"] = 0
|
||||||
|
|
||||||
|
# 3. Uptime и Load Average
|
||||||
|
uptime_output = exec_cmd(client, "uptime")
|
||||||
|
load_avg = re.search(r'load average:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)', uptime_output)
|
||||||
|
if load_avg:
|
||||||
|
result["metrics"]["load_1"] = load_avg.group(1)
|
||||||
|
result["metrics"]["load_5"] = load_avg.group(2)
|
||||||
|
result["metrics"]["load_15"] = load_avg.group(3)
|
||||||
|
|
||||||
|
uptime_seconds = exec_cmd(client, "cat /proc/uptime | awk '{print $1}'")
|
||||||
|
try:
|
||||||
|
uptime_seconds = float(uptime_seconds.split('.')[0])
|
||||||
|
days = uptime_seconds // 86400
|
||||||
|
hours = (uptime_seconds % 86400) // 3600
|
||||||
|
minutes = (uptime_seconds % 3600) // 60
|
||||||
|
result["metrics"]["uptime"] = f"{int(days)}d {int(hours)}h {int(minutes)}m"
|
||||||
|
except:
|
||||||
|
result["metrics"]["uptime"] = "N/A"
|
||||||
|
|
||||||
|
# 4. Сеть (трафик за последний час)
|
||||||
|
net_output = exec_cmd(client, "cat /proc/net/dev | tail -n +3 | grep -v lo")
|
||||||
|
current_net = {}
|
||||||
|
for line in net_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
iface = parts[0].replace(':', '')
|
||||||
|
rx_bytes = int(parts[1])
|
||||||
|
tx_bytes = int(parts[9])
|
||||||
|
current_net[iface] = {'rx': rx_bytes, 'tx': tx_bytes}
|
||||||
|
|
||||||
|
global prev_net_stats
|
||||||
|
host_key = host_config['ip']
|
||||||
|
total_rx_hour = 0
|
||||||
|
total_tx_hour = 0
|
||||||
|
|
||||||
|
if host_key in prev_net_stats:
|
||||||
|
old_data = prev_net_stats[host_key]
|
||||||
|
for iface, vals in current_net.items():
|
||||||
|
if iface in old_data:
|
||||||
|
delta_rx = vals['rx'] - old_data[iface]['rx']
|
||||||
|
delta_tx = vals['tx'] - old_data[iface]['tx']
|
||||||
|
if delta_rx > 0:
|
||||||
|
total_rx_hour += delta_rx
|
||||||
|
if delta_tx > 0:
|
||||||
|
total_tx_hour += delta_tx
|
||||||
|
|
||||||
|
result["metrics"]["net_rx_mb"] = round(total_rx_hour / (1024 * 1024), 2)
|
||||||
|
result["metrics"]["net_tx_mb"] = round(total_tx_hour / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
prev_net_stats[host_key] = current_net
|
||||||
|
|
||||||
|
# 5. Диски (iostat %util)
|
||||||
|
iostat_output = exec_cmd(client, "iostat -x -k 1 2 | grep -E 'sd[a-z]|vd[a-z]|nvme' | tail -n +3")
|
||||||
|
disk_stats = []
|
||||||
|
for line in iostat_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
if len(parts) >= 14:
|
||||||
|
disk_name = parts[0]
|
||||||
|
try:
|
||||||
|
util = float(parts[-1].replace(',', '.'))
|
||||||
|
rps = float(parts[3].replace(',', '.'))
|
||||||
|
wps = float(parts[4].replace(',', '.'))
|
||||||
|
disk_stats.append({
|
||||||
|
"name": disk_name,
|
||||||
|
"util": util,
|
||||||
|
"rps": rps,
|
||||||
|
"wps": wps
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
disk_stats.sort(key=lambda x: x['util'], reverse=True)
|
||||||
|
result["metrics"]["disks"] = disk_stats[:3]
|
||||||
|
|
||||||
|
# 6. Температуры
|
||||||
|
temp_output = exec_cmd(client, "sensors -u 2>/dev/null | grep -E 'temp[0-9]+_input|Core' | head -5")
|
||||||
|
temps = []
|
||||||
|
if temp_output and "Error" not in temp_output:
|
||||||
|
for line in temp_output.split('\n'):
|
||||||
|
if '_input:' in line:
|
||||||
|
val = re.search(r'([\d.]+)', line)
|
||||||
|
if val:
|
||||||
|
temps.append(float(val.group(1)))
|
||||||
|
result["metrics"]["temps"] = temps
|
||||||
|
|
||||||
|
# 7. Топ-5 процессов по CPU и RAM
|
||||||
|
top_cpu = exec_cmd(client, "ps aux --sort=-%cpu | head -6 | tail -5")
|
||||||
|
top_mem = exec_cmd(client, "ps aux --sort=-%mem | head -6 | tail -5")
|
||||||
|
result["metrics"]["top_cpu"] = top_cpu
|
||||||
|
result["metrics"]["top_mem"] = top_mem
|
||||||
|
|
||||||
|
# 8. Логи (ошибки)
|
||||||
|
last_logs = exec_cmd(client, "journalctl -p 3 -xn 5 --no-pager 2>/dev/null || dmesg | tail -5")
|
||||||
|
result["metrics"]["logs_errors"] = last_logs
|
||||||
|
|
||||||
|
# 9. Fail2ban (УЛУЧШЕННАЯ ВЕРСИЯ)
|
||||||
|
# Проверяем статус
|
||||||
|
fail2ban_status = exec_cmd(client, "fail2ban-client status 2>/dev/null")
|
||||||
|
if not fail2ban_status or "not found" in fail2ban_status:
|
||||||
|
# Проверяем, запущен ли процесс
|
||||||
|
ps_fail2ban = exec_cmd(client, "ps aux | grep -v grep | grep fail2ban-server")
|
||||||
|
if ps_fail2ban:
|
||||||
|
fail2ban_status = "✅ Fail2ban запущен (проверь джейлы командой fail2ban-client status)"
|
||||||
|
else:
|
||||||
|
fail2ban_status = "❌ Fail2ban не установлен или не запущен"
|
||||||
|
else:
|
||||||
|
# Парсим статус для красивого вывода
|
||||||
|
lines = fail2ban_status.split('\n')
|
||||||
|
jails_info = []
|
||||||
|
for line in lines:
|
||||||
|
if '|- Number of jail:' in line or '`- Number of jail:' in line:
|
||||||
|
jails_info.append(line.strip())
|
||||||
|
if 'Currently banned:' in line:
|
||||||
|
jails_info.append(line.strip())
|
||||||
|
if jails_info:
|
||||||
|
fail2ban_status = "✅ Fail2ban активен\n" + "\n".join(jails_info)
|
||||||
|
|
||||||
|
# Логи банов
|
||||||
|
fail2ban_logs = exec_cmd(client, "tail -20 /var/log/fail2ban.log 2>/dev/null | grep -E 'BAN|UNBAN' | tail -5 || echo 'Нет записей о банах'")
|
||||||
|
|
||||||
|
# Активные баны
|
||||||
|
active_bans = exec_cmd(client, "fail2ban-client status 2>/dev/null | grep -A 5 'Currently banned:'")
|
||||||
|
if active_bans and "Currently banned:" in active_bans:
|
||||||
|
fail2ban_logs += "\n\n🔒 Активные баны:\n" + active_bans
|
||||||
|
|
||||||
|
result["metrics"]["fail2ban_status"] = fail2ban_status
|
||||||
|
result["metrics"]["fail2ban_logs"] = fail2ban_logs
|
||||||
|
|
||||||
|
# 10. Проверка на OOM
|
||||||
|
oom_check = exec_cmd(client, "dmesg | grep -i 'out of memory' | tail -3 || echo 'Нет OOM-событий'")
|
||||||
|
if oom_check and "Нет OOM-событий" not in oom_check:
|
||||||
|
result["metrics"]["oom"] = oom_check
|
||||||
|
else:
|
||||||
|
result["metrics"]["oom"] = "✅ Нет OOM-событий"
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Глобальный словарь для хранения предыдущих значений RX/TX
|
||||||
|
prev_net_stats = {}
|
||||||
|
|
||||||
|
def get_ssh_client(host_config):
|
||||||
|
"""Создает SSH подключение с детальным логированием"""
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
logger.info(f"🔌 Подключаюсь к {host_config['ip']}:{host_config['port']} как {host_config['user']}")
|
||||||
|
client.connect(
|
||||||
|
hostname=host_config['ip'],
|
||||||
|
port=host_config['port'],
|
||||||
|
username=host_config['user'],
|
||||||
|
key_filename=host_config['key_path'],
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Подключено к {host_config['ip']}")
|
||||||
|
return client
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
error_msg = f"❌ Ошибка аутентификации для {host_config['ip']}! Проверь логин или ключ."
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
error_msg = f"❌ SSH ошибка для {host_config['ip']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Неизвестная ошибка для {host_config['ip']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
def exec_cmd(client, command):
|
||||||
|
"""Выполняет команду и возвращает вывод"""
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command(command, timeout=10)
|
||||||
|
return stdout.read().decode('utf-8').strip()
|
||||||
|
except Exception as e:
|
||||||
|
return f"CMD Error: {str(e)}"
|
||||||
|
|
||||||
|
def detect_host_type(client):
|
||||||
|
"""Определяет тип хоста: physical, vm, lxc, container"""
|
||||||
|
# Проверка на LXC
|
||||||
|
lxc_check = exec_cmd(client, "cat /proc/1/environ 2>/dev/null | tr '\\0' '\\n' | grep -i lxc || echo ''")
|
||||||
|
if lxc_check:
|
||||||
|
return "lxc"
|
||||||
|
|
||||||
|
# Проверка на контейнер (Docker и др)
|
||||||
|
container_check = exec_cmd(client, "cat /proc/1/environ 2>/dev/null | tr '\\0' '\\n' | grep -i container || echo ''")
|
||||||
|
if container_check:
|
||||||
|
return "container"
|
||||||
|
|
||||||
|
# Проверка на виртуальную машину
|
||||||
|
virt_check = exec_cmd(client, "systemd-detect-virt 2>/dev/null || echo 'none'")
|
||||||
|
if virt_check and virt_check not in ['none', '']:
|
||||||
|
return "vm"
|
||||||
|
|
||||||
|
# Проверка через dmidecode (если есть)
|
||||||
|
virt_check2 = exec_cmd(client, "dmidecode -s system-manufacturer 2>/dev/null || echo 'Unknown'")
|
||||||
|
if virt_check2:
|
||||||
|
virt_vendors = ['vmware', 'virtualbox', 'kvm', 'qemu', 'xen', 'microsoft', 'parallels']
|
||||||
|
if any(vendor in virt_check2.lower() for vendor in virt_vendors):
|
||||||
|
return "vm"
|
||||||
|
|
||||||
|
# Если ничего не найдено - считаем физическим железом
|
||||||
|
return "physical"
|
||||||
|
|
||||||
|
def check_alerts(metrics, host_type):
|
||||||
|
"""Проверяет критические метрики и возвращает список алертов"""
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
# CPU алерт (>80%)
|
||||||
|
cpu = metrics.get('cpu', 0)
|
||||||
|
if cpu > 80:
|
||||||
|
alerts.append(f"🔴 ВЫСОКАЯ ЗАГРУЗКА CPU: {cpu:.1f}%")
|
||||||
|
elif cpu > 60:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННАЯ ЗАГРУЗКА CPU: {cpu:.1f}%")
|
||||||
|
|
||||||
|
# RAM алерт (>85%)
|
||||||
|
mem_percent = metrics.get('mem_percent', 0)
|
||||||
|
if mem_percent > 85:
|
||||||
|
alerts.append(f"🔴 НЕХВАТКА ОЗУ: {mem_percent:.1f}% (использовано {metrics.get('mem_used_mb', 0)} MB)")
|
||||||
|
elif mem_percent > 70:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННОЕ ПОТРЕБЛЕНИЕ ОЗУ: {mem_percent:.1f}%")
|
||||||
|
|
||||||
|
# Диск алерт (>80%)
|
||||||
|
disk_usage = metrics.get('disk_usage', 0)
|
||||||
|
if disk_usage > 80:
|
||||||
|
alerts.append(f"🔴 МАЛО МЕСТА НА ДИСКЕ: {disk_usage:.1f}%")
|
||||||
|
elif disk_usage > 70:
|
||||||
|
alerts.append(f"🟡 ЗАКАНЧИВАЕТСЯ МЕСТО НА ДИСКЕ: {disk_usage:.1f}%")
|
||||||
|
|
||||||
|
# OOM алерт
|
||||||
|
oom = metrics.get('oom', '')
|
||||||
|
if oom and "Нет OOM" not in oom:
|
||||||
|
alerts.append(f"💀 OOM KILLER СРАБОТАЛ! Проверь логи!")
|
||||||
|
|
||||||
|
# Load Average алерт (если > количество ядер)
|
||||||
|
load_1 = float(metrics.get('load_1', 0))
|
||||||
|
if load_1 > 4:
|
||||||
|
alerts.append(f"🔴 ВЫСОКИЙ LOAD AVERAGE: {load_1}")
|
||||||
|
elif load_1 > 2:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННЫЙ LOAD AVERAGE: {load_1}")
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
def collect_metrics(host_config):
|
||||||
|
"""Сбор всех метрик с одного хоста"""
|
||||||
|
client = get_ssh_client(host_config)
|
||||||
|
if isinstance(client, str):
|
||||||
|
return {"error": client, "host": host_config['name'], "ip": host_config['ip']}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"host": host_config['name'],
|
||||||
|
"ip": host_config['ip'],
|
||||||
|
"metrics": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Определяем тип хоста
|
||||||
|
host_type = detect_host_type(client)
|
||||||
|
result["host_type"] = host_type
|
||||||
|
result["metrics"]["host_type"] = host_type
|
||||||
|
|
||||||
|
# 2. CPU загрузка (%)
|
||||||
|
cpu_output = exec_cmd(client, "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
|
||||||
|
try:
|
||||||
|
result["metrics"]["cpu"] = float(cpu_output.replace(',', '.'))
|
||||||
|
except:
|
||||||
|
result["metrics"]["cpu"] = 0.0
|
||||||
|
|
||||||
|
# 3. ОЗУ (используется в MB и %)
|
||||||
|
mem_output = exec_cmd(client, "free -m | grep Mem")
|
||||||
|
parts = re.split(r'\s+', mem_output)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
total_mem = int(parts[1])
|
||||||
|
used_mem = int(parts[2])
|
||||||
|
result["metrics"]["mem_used_mb"] = used_mem
|
||||||
|
result["metrics"]["mem_percent"] = round((used_mem / total_mem) * 100, 1)
|
||||||
|
result["metrics"]["mem_total_mb"] = total_mem
|
||||||
|
else:
|
||||||
|
result["metrics"]["mem_used_mb"] = 0
|
||||||
|
result["metrics"]["mem_percent"] = 0
|
||||||
|
result["metrics"]["mem_total_mb"] = 0
|
||||||
|
|
||||||
|
# 4. Uptime и Load Average
|
||||||
|
uptime_output = exec_cmd(client, "uptime")
|
||||||
|
load_avg = re.search(r'load average:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)', uptime_output)
|
||||||
|
if load_avg:
|
||||||
|
result["metrics"]["load_1"] = load_avg.group(1)
|
||||||
|
result["metrics"]["load_5"] = load_avg.group(2)
|
||||||
|
result["metrics"]["load_15"] = load_avg.group(3)
|
||||||
|
|
||||||
|
uptime_seconds = exec_cmd(client, "cat /proc/uptime | awk '{print $1}'")
|
||||||
|
try:
|
||||||
|
uptime_seconds = float(uptime_seconds.split('.')[0])
|
||||||
|
days = uptime_seconds // 86400
|
||||||
|
hours = (uptime_seconds % 86400) // 3600
|
||||||
|
minutes = (uptime_seconds % 3600) // 60
|
||||||
|
result["metrics"]["uptime"] = f"{int(days)}d {int(hours)}h {int(minutes)}m"
|
||||||
|
except:
|
||||||
|
result["metrics"]["uptime"] = "N/A"
|
||||||
|
|
||||||
|
# 5. Сеть (трафик за последний час)
|
||||||
|
net_output = exec_cmd(client, "cat /proc/net/dev | tail -n +3 | grep -v lo")
|
||||||
|
current_net = {}
|
||||||
|
for line in net_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
iface = parts[0].replace(':', '')
|
||||||
|
rx_bytes = int(parts[1])
|
||||||
|
tx_bytes = int(parts[9])
|
||||||
|
current_net[iface] = {'rx': rx_bytes, 'tx': tx_bytes}
|
||||||
|
|
||||||
|
global prev_net_stats
|
||||||
|
host_key = host_config['ip']
|
||||||
|
total_rx_hour = 0
|
||||||
|
total_tx_hour = 0
|
||||||
|
|
||||||
|
if host_key in prev_net_stats:
|
||||||
|
old_data = prev_net_stats[host_key]
|
||||||
|
for iface, vals in current_net.items():
|
||||||
|
if iface in old_data:
|
||||||
|
delta_rx = vals['rx'] - old_data[iface]['rx']
|
||||||
|
delta_tx = vals['tx'] - old_data[iface]['tx']
|
||||||
|
if delta_rx > 0:
|
||||||
|
total_rx_hour += delta_rx
|
||||||
|
if delta_tx > 0:
|
||||||
|
total_tx_hour += delta_tx
|
||||||
|
|
||||||
|
result["metrics"]["net_rx_mb"] = round(total_rx_hour / (1024 * 1024), 2)
|
||||||
|
result["metrics"]["net_tx_mb"] = round(total_tx_hour / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
prev_net_stats[host_key] = current_net
|
||||||
|
|
||||||
|
# 6. Диски (iostat %util)
|
||||||
|
iostat_output = exec_cmd(client, "iostat -x -k 1 2 | grep -E 'sd[a-z]|vd[a-z]|nvme' | tail -n +3")
|
||||||
|
disk_stats = []
|
||||||
|
for line in iostat_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
if len(parts) >= 14:
|
||||||
|
disk_name = parts[0]
|
||||||
|
try:
|
||||||
|
util = float(parts[-1].replace(',', '.'))
|
||||||
|
rps = float(parts[3].replace(',', '.'))
|
||||||
|
wps = float(parts[4].replace(',', '.'))
|
||||||
|
disk_stats.append({
|
||||||
|
"name": disk_name,
|
||||||
|
"util": util,
|
||||||
|
"rps": rps,
|
||||||
|
"wps": wps
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
disk_stats.sort(key=lambda x: x['util'], reverse=True)
|
||||||
|
result["metrics"]["disks"] = disk_stats[:3]
|
||||||
|
|
||||||
|
# 7. Свободное место на диске (%)
|
||||||
|
disk_usage_output = exec_cmd(client, "df -h / | tail -1 | awk '{print $5}' | sed 's/%//'")
|
||||||
|
try:
|
||||||
|
result["metrics"]["disk_usage"] = float(disk_usage_output.replace(',', '.'))
|
||||||
|
except:
|
||||||
|
result["metrics"]["disk_usage"] = 0
|
||||||
|
|
||||||
|
# 8. Температуры (ТОЛЬКО ДЛЯ ФИЗИЧЕСКИХ ХОСТОВ!)
|
||||||
|
temps = []
|
||||||
|
if host_type == "physical":
|
||||||
|
temp_output = exec_cmd(client, "sensors -u 2>/dev/null | grep -E 'temp[0-9]+_input|Core' | head -5")
|
||||||
|
if temp_output and "Error" not in temp_output and "command not found" not in temp_output:
|
||||||
|
for line in temp_output.split('\n'):
|
||||||
|
if '_input:' in line:
|
||||||
|
val = re.search(r'([\d.]+)', line)
|
||||||
|
if val:
|
||||||
|
temps.append(float(val.group(1)))
|
||||||
|
else:
|
||||||
|
# Если sensors не установлен - пробуем через /sys/class/thermal
|
||||||
|
temp_sys = exec_cmd(client, "cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | head -3")
|
||||||
|
if temp_sys and "No such file" not in temp_sys:
|
||||||
|
for line in temp_sys.split('\n'):
|
||||||
|
try:
|
||||||
|
temps.append(float(line) / 1000)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
result["metrics"]["temps"] = temps
|
||||||
|
|
||||||
|
# 9. Топ-5 процессов по CPU и RAM
|
||||||
|
top_cpu = exec_cmd(client, "ps aux --sort=-%cpu | head -6 | tail -5")
|
||||||
|
top_mem = exec_cmd(client, "ps aux --sort=-%mem | head -6 | tail -5")
|
||||||
|
result["metrics"]["top_cpu"] = top_cpu
|
||||||
|
result["metrics"]["top_mem"] = top_mem
|
||||||
|
|
||||||
|
# 10. Логи (ошибки)
|
||||||
|
last_logs = exec_cmd(client, "journalctl -p 3 -xn 5 --no-pager 2>/dev/null || dmesg | tail -5")
|
||||||
|
result["metrics"]["logs_errors"] = last_logs
|
||||||
|
|
||||||
|
# 11. Fail2ban
|
||||||
|
fail2ban_status = exec_cmd(client, "fail2ban-client status 2>/dev/null")
|
||||||
|
if not fail2ban_status or "not found" in fail2ban_status:
|
||||||
|
ps_fail2ban = exec_cmd(client, "ps aux | grep -v grep | grep fail2ban-server")
|
||||||
|
if ps_fail2ban:
|
||||||
|
fail2ban_status = "✅ Fail2ban запущен"
|
||||||
|
else:
|
||||||
|
fail2ban_status = "❌ Fail2ban не установлен или не запущен"
|
||||||
|
else:
|
||||||
|
lines = fail2ban_status.split('\n')
|
||||||
|
jails_info = []
|
||||||
|
for line in lines:
|
||||||
|
if '|- Number of jail:' in line or '`- Number of jail:' in line:
|
||||||
|
jails_info.append(line.strip())
|
||||||
|
if 'Currently banned:' in line:
|
||||||
|
jails_info.append(line.strip())
|
||||||
|
if jails_info:
|
||||||
|
fail2ban_status = "✅ Fail2ban активен\n" + "\n".join(jails_info)
|
||||||
|
|
||||||
|
fail2ban_logs = exec_cmd(client, "tail -20 /var/log/fail2ban.log 2>/dev/null | grep -E 'BAN|UNBAN' | tail -5 || echo 'Нет записей о банах'")
|
||||||
|
|
||||||
|
active_bans = exec_cmd(client, "fail2ban-client status 2>/dev/null | grep -A 5 'Currently banned:'")
|
||||||
|
if active_bans and "Currently banned:" in active_bans:
|
||||||
|
fail2ban_logs += "\n\n🔒 Активные баны:\n" + active_bans
|
||||||
|
|
||||||
|
result["metrics"]["fail2ban_status"] = fail2ban_status
|
||||||
|
result["metrics"]["fail2ban_logs"] = fail2ban_logs
|
||||||
|
|
||||||
|
# 12. OOM
|
||||||
|
oom_check = exec_cmd(client, "dmesg | grep -i 'out of memory' | tail -3 || echo 'Нет OOM-событий'")
|
||||||
|
if oom_check and "Нет OOM-событий" not in oom_check:
|
||||||
|
result["metrics"]["oom"] = oom_check
|
||||||
|
else:
|
||||||
|
result["metrics"]["oom"] = "✅ Нет OOM-событий"
|
||||||
|
|
||||||
|
# 13. Проверка алертов
|
||||||
|
result["metrics"]["alerts"] = check_alerts(result["metrics"], host_type)
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Глобальный словарь для хранения предыдущих значений RX/TX
|
||||||
|
prev_net_stats = {}
|
||||||
|
|
||||||
|
def get_ssh_client(host_config):
|
||||||
|
"""Создает SSH подключение с детальным логированием"""
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
logger.info(f"🔌 Подключаюсь к {host_config['ip']}:{host_config['port']} как {host_config['user']}")
|
||||||
|
client.connect(
|
||||||
|
hostname=host_config['ip'],
|
||||||
|
port=host_config['port'],
|
||||||
|
username=host_config['user'],
|
||||||
|
key_filename=host_config['key_path'],
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Подключено к {host_config['ip']}")
|
||||||
|
return client
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
error_msg = f"❌ Ошибка аутентификации для {host_config['ip']}! Проверь логин или ключ."
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
error_msg = f"❌ SSH ошибка для {host_config['ip']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Неизвестная ошибка для {host_config['ip']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
def exec_cmd(client, command):
|
||||||
|
"""Выполняет команду и возвращает вывод"""
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command(command, timeout=10)
|
||||||
|
return stdout.read().decode('utf-8').strip()
|
||||||
|
except Exception as e:
|
||||||
|
return f"CMD Error: {str(e)}"
|
||||||
|
|
||||||
|
def detect_host_type(client):
|
||||||
|
"""Определяет тип хоста: physical, vm, lxc, container"""
|
||||||
|
# Проверка на LXC
|
||||||
|
lxc_check = exec_cmd(client, "cat /proc/1/environ 2>/dev/null | tr '\\0' '\\n' | grep -i lxc || echo ''")
|
||||||
|
if lxc_check:
|
||||||
|
return "lxc"
|
||||||
|
|
||||||
|
# Проверка на контейнер (Docker и др)
|
||||||
|
container_check = exec_cmd(client, "cat /proc/1/environ 2>/dev/null | tr '\\0' '\\n' | grep -i container || echo ''")
|
||||||
|
if container_check:
|
||||||
|
return "container"
|
||||||
|
|
||||||
|
# Проверка на виртуальную машину
|
||||||
|
virt_check = exec_cmd(client, "systemd-detect-virt 2>/dev/null || echo 'none'")
|
||||||
|
if virt_check and virt_check not in ['none', '']:
|
||||||
|
return "vm"
|
||||||
|
|
||||||
|
# Проверка через dmidecode (если есть)
|
||||||
|
virt_check2 = exec_cmd(client, "dmidecode -s system-manufacturer 2>/dev/null || echo 'Unknown'")
|
||||||
|
if virt_check2:
|
||||||
|
virt_vendors = ['vmware', 'virtualbox', 'kvm', 'qemu', 'xen', 'microsoft', 'parallels']
|
||||||
|
if any(vendor in virt_check2.lower() for vendor in virt_vendors):
|
||||||
|
return "vm"
|
||||||
|
|
||||||
|
# Если ничего не найдено - считаем физическим железом
|
||||||
|
return "physical"
|
||||||
|
|
||||||
|
def check_alerts(metrics, host_type):
|
||||||
|
"""Проверяет критические метрики и возвращает список алертов"""
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
# CPU алерт (>80%)
|
||||||
|
cpu = metrics.get('cpu', 0)
|
||||||
|
if cpu > 80:
|
||||||
|
alerts.append(f"🔴 ВЫСОКАЯ ЗАГРУЗКА CPU: {cpu:.1f}%")
|
||||||
|
elif cpu > 60:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННАЯ ЗАГРУЗКА CPU: {cpu:.1f}%")
|
||||||
|
|
||||||
|
# RAM алерт (>85%)
|
||||||
|
mem_percent = metrics.get('mem_percent', 0)
|
||||||
|
if mem_percent > 85:
|
||||||
|
alerts.append(f"🔴 НЕХВАТКА ОЗУ: {mem_percent:.1f}% (использовано {metrics.get('mem_used_mb', 0)} MB)")
|
||||||
|
elif mem_percent > 70:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННОЕ ПОТРЕБЛЕНИЕ ОЗУ: {mem_percent:.1f}%")
|
||||||
|
|
||||||
|
# Диск алерт (>80%)
|
||||||
|
disk_usage = metrics.get('disk_usage', 0)
|
||||||
|
if disk_usage > 80:
|
||||||
|
alerts.append(f"🔴 МАЛО МЕСТА НА ДИСКЕ: {disk_usage:.1f}%")
|
||||||
|
elif disk_usage > 70:
|
||||||
|
alerts.append(f"🟡 ЗАКАНЧИВАЕТСЯ МЕСТО НА ДИСКЕ: {disk_usage:.1f}%")
|
||||||
|
|
||||||
|
# OOM алерт
|
||||||
|
oom = metrics.get('oom', '')
|
||||||
|
if oom and "Нет OOM" not in oom:
|
||||||
|
alerts.append(f"💀 OOM KILLER СРАБОТАЛ! Проверь логи!")
|
||||||
|
|
||||||
|
# Load Average алерт (если > количество ядер)
|
||||||
|
load_1 = float(metrics.get('load_1', 0))
|
||||||
|
if load_1 > 4:
|
||||||
|
alerts.append(f"🔴 ВЫСОКИЙ LOAD AVERAGE: {load_1}")
|
||||||
|
elif load_1 > 2:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННЫЙ LOAD AVERAGE: {load_1}")
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
def collect_metrics(host_config):
|
||||||
|
"""Сбор всех метрик с одного хоста"""
|
||||||
|
client = get_ssh_client(host_config)
|
||||||
|
if isinstance(client, str):
|
||||||
|
return {"error": client, "host": host_config['name'], "ip": host_config['ip']}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"host": host_config['name'],
|
||||||
|
"ip": host_config['ip'],
|
||||||
|
"metrics": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Определяем тип хоста
|
||||||
|
host_type = detect_host_type(client)
|
||||||
|
result["host_type"] = host_type
|
||||||
|
result["metrics"]["host_type"] = host_type
|
||||||
|
|
||||||
|
# 2. CPU загрузка (%)
|
||||||
|
cpu_output = exec_cmd(client, "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
|
||||||
|
try:
|
||||||
|
result["metrics"]["cpu"] = float(cpu_output.replace(',', '.'))
|
||||||
|
except:
|
||||||
|
result["metrics"]["cpu"] = 0.0
|
||||||
|
|
||||||
|
# 3. ОЗУ (используется в MB и %)
|
||||||
|
mem_output = exec_cmd(client, "free -m | grep Mem")
|
||||||
|
parts = re.split(r'\s+', mem_output)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
total_mem = int(parts[1])
|
||||||
|
used_mem = int(parts[2])
|
||||||
|
result["metrics"]["mem_used_mb"] = used_mem
|
||||||
|
result["metrics"]["mem_percent"] = round((used_mem / total_mem) * 100, 1)
|
||||||
|
result["metrics"]["mem_total_mb"] = total_mem
|
||||||
|
else:
|
||||||
|
result["metrics"]["mem_used_mb"] = 0
|
||||||
|
result["metrics"]["mem_percent"] = 0
|
||||||
|
result["metrics"]["mem_total_mb"] = 0
|
||||||
|
|
||||||
|
# 4. Uptime и Load Average
|
||||||
|
uptime_output = exec_cmd(client, "uptime")
|
||||||
|
load_avg = re.search(r'load average:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)', uptime_output)
|
||||||
|
if load_avg:
|
||||||
|
result["metrics"]["load_1"] = load_avg.group(1)
|
||||||
|
result["metrics"]["load_5"] = load_avg.group(2)
|
||||||
|
result["metrics"]["load_15"] = load_avg.group(3)
|
||||||
|
|
||||||
|
uptime_seconds = exec_cmd(client, "cat /proc/uptime | awk '{print $1}'")
|
||||||
|
try:
|
||||||
|
uptime_seconds = float(uptime_seconds.split('.')[0])
|
||||||
|
days = uptime_seconds // 86400
|
||||||
|
hours = (uptime_seconds % 86400) // 3600
|
||||||
|
minutes = (uptime_seconds % 3600) // 60
|
||||||
|
result["metrics"]["uptime"] = f"{int(days)}d {int(hours)}h {int(minutes)}m"
|
||||||
|
except:
|
||||||
|
result["metrics"]["uptime"] = "N/A"
|
||||||
|
|
||||||
|
# 5. Сеть (трафик за последний час)
|
||||||
|
net_output = exec_cmd(client, "cat /proc/net/dev | tail -n +3 | grep -v lo")
|
||||||
|
current_net = {}
|
||||||
|
for line in net_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
iface = parts[0].replace(':', '')
|
||||||
|
rx_bytes = int(parts[1])
|
||||||
|
tx_bytes = int(parts[9])
|
||||||
|
current_net[iface] = {'rx': rx_bytes, 'tx': tx_bytes}
|
||||||
|
|
||||||
|
global prev_net_stats
|
||||||
|
host_key = host_config['ip']
|
||||||
|
total_rx_hour = 0
|
||||||
|
total_tx_hour = 0
|
||||||
|
|
||||||
|
if host_key in prev_net_stats:
|
||||||
|
old_data = prev_net_stats[host_key]
|
||||||
|
for iface, vals in current_net.items():
|
||||||
|
if iface in old_data:
|
||||||
|
delta_rx = vals['rx'] - old_data[iface]['rx']
|
||||||
|
delta_tx = vals['tx'] - old_data[iface]['tx']
|
||||||
|
if delta_rx > 0:
|
||||||
|
total_rx_hour += delta_rx
|
||||||
|
if delta_tx > 0:
|
||||||
|
total_tx_hour += delta_tx
|
||||||
|
|
||||||
|
result["metrics"]["net_rx_mb"] = round(total_rx_hour / (1024 * 1024), 2)
|
||||||
|
result["metrics"]["net_tx_mb"] = round(total_tx_hour / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
prev_net_stats[host_key] = current_net
|
||||||
|
|
||||||
|
# 6. Диски (iostat %util)
|
||||||
|
iostat_output = exec_cmd(client, "iostat -x -k 1 2 | grep -E 'sd[a-z]|vd[a-z]|nvme' | tail -n +3")
|
||||||
|
disk_stats = []
|
||||||
|
for line in iostat_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
if len(parts) >= 14:
|
||||||
|
disk_name = parts[0]
|
||||||
|
try:
|
||||||
|
util = float(parts[-1].replace(',', '.'))
|
||||||
|
rps = float(parts[3].replace(',', '.'))
|
||||||
|
wps = float(parts[4].replace(',', '.'))
|
||||||
|
disk_stats.append({
|
||||||
|
"name": disk_name,
|
||||||
|
"util": util,
|
||||||
|
"rps": rps,
|
||||||
|
"wps": wps
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
disk_stats.sort(key=lambda x: x['util'], reverse=True)
|
||||||
|
result["metrics"]["disks"] = disk_stats[:3]
|
||||||
|
|
||||||
|
# 7. Свободное место на диске (%)
|
||||||
|
disk_usage_output = exec_cmd(client, "df -h / | tail -1 | awk '{print $5}' | sed 's/%//'")
|
||||||
|
try:
|
||||||
|
result["metrics"]["disk_usage"] = float(disk_usage_output.replace(',', '.'))
|
||||||
|
except:
|
||||||
|
result["metrics"]["disk_usage"] = 0
|
||||||
|
|
||||||
|
# 8. Температуры (ТОЛЬКО ДЛЯ ФИЗИЧЕСКИХ ХОСТОВ!)
|
||||||
|
temps = []
|
||||||
|
if host_type == "physical":
|
||||||
|
temp_output = exec_cmd(client, "sensors -u 2>/dev/null | grep -E 'temp[0-9]+_input|Core' | head -5")
|
||||||
|
if temp_output and "Error" not in temp_output and "command not found" not in temp_output:
|
||||||
|
for line in temp_output.split('\n'):
|
||||||
|
if '_input:' in line:
|
||||||
|
val = re.search(r'([\d.]+)', line)
|
||||||
|
if val:
|
||||||
|
temps.append(float(val.group(1)))
|
||||||
|
else:
|
||||||
|
# Если sensors не установлен - пробуем через /sys/class/thermal
|
||||||
|
temp_sys = exec_cmd(client, "cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | head -3")
|
||||||
|
if temp_sys and "No such file" not in temp_sys:
|
||||||
|
for line in temp_sys.split('\n'):
|
||||||
|
try:
|
||||||
|
temps.append(float(line) / 1000)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
result["metrics"]["temps"] = temps
|
||||||
|
|
||||||
|
# 9. Топ-5 процессов по CPU и RAM
|
||||||
|
top_cpu = exec_cmd(client, "ps aux --sort=-%cpu | head -6 | tail -5")
|
||||||
|
top_mem = exec_cmd(client, "ps aux --sort=-%mem | head -6 | tail -5")
|
||||||
|
result["metrics"]["top_cpu"] = top_cpu
|
||||||
|
result["metrics"]["top_mem"] = top_mem
|
||||||
|
|
||||||
|
# 10. Логи (ошибки)
|
||||||
|
last_logs = exec_cmd(client, "journalctl -p 3 -xn 5 --no-pager 2>/dev/null || dmesg | tail -5")
|
||||||
|
result["metrics"]["logs_errors"] = last_logs
|
||||||
|
|
||||||
|
# 11. Fail2ban
|
||||||
|
fail2ban_status = exec_cmd(client, "fail2ban-client status 2>/dev/null")
|
||||||
|
if not fail2ban_status or "not found" in fail2ban_status:
|
||||||
|
ps_fail2ban = exec_cmd(client, "ps aux | grep -v grep | grep fail2ban-server")
|
||||||
|
if ps_fail2ban:
|
||||||
|
fail2ban_status = "✅ Fail2ban запущен"
|
||||||
|
else:
|
||||||
|
fail2ban_status = "❌ Fail2ban не установлен или не запущен"
|
||||||
|
else:
|
||||||
|
lines = fail2ban_status.split('\n')
|
||||||
|
jails_info = []
|
||||||
|
for line in lines:
|
||||||
|
if '|- Number of jail:' in line or '`- Number of jail:' in line:
|
||||||
|
jails_info.append(line.strip())
|
||||||
|
if 'Currently banned:' in line:
|
||||||
|
jails_info.append(line.strip())
|
||||||
|
if jails_info:
|
||||||
|
fail2ban_status = "✅ Fail2ban активен\n" + "\n".join(jails_info)
|
||||||
|
|
||||||
|
fail2ban_logs = exec_cmd(client, "tail -20 /var/log/fail2ban.log 2>/dev/null | grep -E 'BAN|UNBAN' | tail -5 || echo 'Нет записей о банах'")
|
||||||
|
|
||||||
|
active_bans = exec_cmd(client, "fail2ban-client status 2>/dev/null | grep -A 5 'Currently banned:'")
|
||||||
|
if active_bans and "Currently banned:" in active_bans:
|
||||||
|
fail2ban_logs += "\n\n🔒 Активные баны:\n" + active_bans
|
||||||
|
|
||||||
|
result["metrics"]["fail2ban_status"] = fail2ban_status
|
||||||
|
result["metrics"]["fail2ban_logs"] = fail2ban_logs
|
||||||
|
|
||||||
|
# 12. OOM
|
||||||
|
oom_check = exec_cmd(client, "dmesg | grep -i 'out of memory' | tail -3 || echo 'Нет OOM-событий'")
|
||||||
|
if oom_check and "Нет OOM-событий" not in oom_check:
|
||||||
|
result["metrics"]["oom"] = oom_check
|
||||||
|
else:
|
||||||
|
result["metrics"]["oom"] = "✅ Нет OOM-событий"
|
||||||
|
|
||||||
|
# 13. Проверка алертов
|
||||||
|
result["metrics"]["alerts"] = check_alerts(result["metrics"], host_type)
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
|
||||||
|
|
||||||
|
from config import BOT_TOKEN, ADMIN_ID, HOSTS_LIST
|
||||||
|
from monitor import collect_metrics
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler("bot.log"),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Кеш для хранения последних собранных данных
|
||||||
|
CACHE = {}
|
||||||
|
|
||||||
|
def format_metrics(data):
|
||||||
|
"""Формирует красивое сообщение из метрик"""
|
||||||
|
if "error" in data:
|
||||||
|
return f"❌ Ошибка подключения к {data['host']}:\n{data['error']}"
|
||||||
|
|
||||||
|
m = data['metrics']
|
||||||
|
host = data['host']
|
||||||
|
ip = data['ip']
|
||||||
|
host_type = data.get('host_type', 'unknown')
|
||||||
|
|
||||||
|
# Эмодзи для типа хоста
|
||||||
|
type_emoji = {
|
||||||
|
'physical': '🖥️',
|
||||||
|
'vm': '🖥️',
|
||||||
|
'lxc': '📦',
|
||||||
|
'container': '🐳'
|
||||||
|
}.get(host_type, '🖥️')
|
||||||
|
|
||||||
|
type_label = {
|
||||||
|
'physical': 'Железо',
|
||||||
|
'vm': 'ВМ',
|
||||||
|
'lxc': 'LXC',
|
||||||
|
'container': 'Контейнер'
|
||||||
|
}.get(host_type, 'Неизвестно')
|
||||||
|
|
||||||
|
text = f"{type_emoji} *{host} ({ip})* - `{type_label}`\n"
|
||||||
|
text += f"🕒 *Uptime:* {m.get('uptime', 'N/A')}\n"
|
||||||
|
text += f"📊 *Load Avg:* {m.get('load_1', '0')}, {m.get('load_5', '0')}, {m.get('load_15', '0')}\n\n"
|
||||||
|
|
||||||
|
# Алерты (если есть)
|
||||||
|
alerts = m.get('alerts', [])
|
||||||
|
if alerts:
|
||||||
|
text += "🚨 *АЛЕРТЫ!*\n"
|
||||||
|
for alert in alerts:
|
||||||
|
text += f" {alert}\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
# CPU
|
||||||
|
cpu_bar = "█" * int(m['cpu'] // 5) + "░" * (20 - int(m['cpu'] // 5))
|
||||||
|
cpu_color = "🔴" if m['cpu'] > 80 else "🟡" if m['cpu'] > 60 else "🟢"
|
||||||
|
text += f"{cpu_color} *CPU:* {m['cpu']:.1f}%\n`{cpu_bar}`\n"
|
||||||
|
|
||||||
|
# RAM
|
||||||
|
mem_bar = "█" * int(m['mem_percent'] // 5) + "░" * (20 - int(m['mem_percent'] // 5))
|
||||||
|
mem_color = "🔴" if m['mem_percent'] > 85 else "🟡" if m['mem_percent'] > 70 else "🟢"
|
||||||
|
text += f"{mem_color} *RAM:* {m['mem_used_mb']} MB / {m.get('mem_total_mb', '?')} MB ({m['mem_percent']}%)\n`{mem_bar}`\n"
|
||||||
|
|
||||||
|
# Диск (свободное место)
|
||||||
|
disk_color = "🔴" if m.get('disk_usage', 0) > 80 else "🟡" if m.get('disk_usage', 0) > 70 else "🟢"
|
||||||
|
text += f"{disk_color} *Disk (/):* {m.get('disk_usage', 0):.1f}% used\n"
|
||||||
|
|
||||||
|
# Сеть
|
||||||
|
text += f"🌐 *Network (per hour):* 📥 {m['net_rx_mb']} MB | 📤 {m['net_tx_mb']} MB\n"
|
||||||
|
|
||||||
|
# Диски iostat
|
||||||
|
text += "\n💾 *Disks (top 3 by %util):*\n"
|
||||||
|
for disk in m.get('disks', []):
|
||||||
|
text += f" └─ {disk['name']}: util {disk['util']:.1f}% (r: {disk['rps']:.1f}/s, w: {disk['wps']:.1f}/s)\n"
|
||||||
|
|
||||||
|
# Температуры (только для железа)
|
||||||
|
if host_type == "physical" and m.get('temps'):
|
||||||
|
text += "\n🌡️ *Temperatures:*\n"
|
||||||
|
for i, temp in enumerate(m['temps']):
|
||||||
|
temp_color = "🔴" if temp > 75 else "🟡" if temp > 60 else "🟢"
|
||||||
|
text += f" {temp_color} Sensor {i+1}: {temp:.1f}°C\n"
|
||||||
|
elif host_type != "physical":
|
||||||
|
text += "\n🌡️ *Temperatures:* Не требуется (ВМ/LXC)\n"
|
||||||
|
|
||||||
|
# Топ процессов
|
||||||
|
text += "\n🏆 *Top 5 CPU:*\n```\n"
|
||||||
|
text += m.get('top_cpu', 'N/A') + "\n```"
|
||||||
|
text += "🏆 *Top 5 RAM:*\n```\n"
|
||||||
|
text += m.get('top_mem', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
# Логи и безопасность
|
||||||
|
text += "\n⚠️ *Recent Errors (journalctl -p 3):*\n```\n"
|
||||||
|
text += m.get('logs_errors', 'N/A')[:300] + "\n```"
|
||||||
|
|
||||||
|
# OOM
|
||||||
|
text += "\n💀 *Out of Memory Events:*\n```\n"
|
||||||
|
text += m.get('oom', 'Нет данных') + "\n```"
|
||||||
|
|
||||||
|
# Fail2ban
|
||||||
|
text += "\n🛡️ *Fail2ban Status:*\n```\n"
|
||||||
|
text += m.get('fail2ban_status', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
text += "\n🚫 *Fail2ban Logs (last bans):*\n```\n"
|
||||||
|
text += m.get('fail2ban_logs', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
text += f"\n⏱️ *Обновлено:* {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработчик команды /status"""
|
||||||
|
if update.effective_user.id != ADMIN_ID:
|
||||||
|
await update.message.reply_text("⛔ Доступ запрещен. Ты не мой хозяин.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Создаем клавиатуру с хостами
|
||||||
|
keyboard = []
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
keyboard.append([InlineKeyboardButton(host['name'], callback_data=f"host_{host['ip']}")])
|
||||||
|
keyboard.append([InlineKeyboardButton("🔄 Все хосты", callback_data="host_all")])
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🔍 Выбери хост для мониторинга или нажми 'Все хосты':",
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
|
||||||
|
async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработка нажатий на кнопки"""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
if update.effective_user.id != ADMIN_ID:
|
||||||
|
await query.edit_message_text("⛔ Недостаточно прав.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = query.data
|
||||||
|
if data == "host_all":
|
||||||
|
await query.edit_message_text("⏳ Собираю данные со всех хостов...")
|
||||||
|
full_report = ""
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
full_report += report + "\n\n" + "="*30 + "\n\n"
|
||||||
|
# Обрезаем, если слишком длинное
|
||||||
|
if len(full_report) > 4000:
|
||||||
|
full_report = full_report[:4000] + "\n\n... (обрезано)"
|
||||||
|
await query.edit_message_text(full_report, parse_mode="Markdown")
|
||||||
|
elif data.startswith("host_"):
|
||||||
|
ip = data.replace("host_", "")
|
||||||
|
host = next((h for h in HOSTS_LIST if h['ip'] == ip), None)
|
||||||
|
if not host:
|
||||||
|
await query.edit_message_text("Хост не найден")
|
||||||
|
return
|
||||||
|
await query.edit_message_text(f"⏳ Собираю данные с {host['name']}...")
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
await query.edit_message_text(report, parse_mode="Markdown")
|
||||||
|
|
||||||
|
async def scheduled_job(context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Функция, вызываемая раз в час"""
|
||||||
|
bot: Bot = context.bot
|
||||||
|
logger.info("🔄 Запущен плановый сбор метрик (каждый час)")
|
||||||
|
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
try:
|
||||||
|
logger.info(f"📊 Собираю данные с {host['name']}")
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
await bot.send_message(chat_id=ADMIN_ID, text=report, parse_mode="Markdown")
|
||||||
|
logger.info(f"✅ Отправлен отчет для {host['name']}")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Ошибка при сборе/отправке для {host['name']}: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
await bot.send_message(chat_id=ADMIN_ID, text=error_msg)
|
||||||
|
|
||||||
|
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Команда /start - проверка, что бот жив"""
|
||||||
|
if update.effective_user.id != ADMIN_ID:
|
||||||
|
await update.message.reply_text("Привет! Я бот для мониторинга серверов, но ты не мой хозяин.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🤖 *Бот мониторинга запущен!*\n\n"
|
||||||
|
"📌 Используй команду `/status`, чтобы получить актуальные метрики с серверов.\n"
|
||||||
|
"⏰ Каждый час я буду присылать автоматический отчет.\n\n"
|
||||||
|
f"📋 Настроено хостов: {len(HOSTS_LIST)}",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Главная функция запуска бота"""
|
||||||
|
logger.info("🚀 Запуск бота...")
|
||||||
|
logger.info(f"📋 Загружено хостов: {len(HOSTS_LIST)}")
|
||||||
|
|
||||||
|
# Проверяем хосты при старте
|
||||||
|
if not HOSTS_LIST:
|
||||||
|
logger.error("❌ Нет хостов в конфигурации! Проверь .env файл!")
|
||||||
|
return
|
||||||
|
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
logger.info(f" - {host['name']} ({host['ip']}:{host['port']})")
|
||||||
|
|
||||||
|
app = Application.builder().token(BOT_TOKEN).build()
|
||||||
|
|
||||||
|
# Регистрируем команды
|
||||||
|
app.add_handler(CommandHandler("start", start_command))
|
||||||
|
app.add_handler(CommandHandler("status", status_command))
|
||||||
|
app.add_handler(CallbackQueryHandler(button_callback))
|
||||||
|
|
||||||
|
# Настройка шедулера (раз в час)
|
||||||
|
job_queue = app.job_queue
|
||||||
|
if job_queue:
|
||||||
|
# Запускаем через 10 секунд после старта, потом каждый час
|
||||||
|
job_queue.run_repeating(scheduled_job, interval=3600, first=10)
|
||||||
|
logger.info("⏰ Шедулер настроен: отчеты будут приходить каждый час")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ JobQueue не доступна! Автоматические отчеты не будут работать.")
|
||||||
|
logger.warning("⚠️ Установи: pip install 'python-telegram-bot[job-queue]'")
|
||||||
|
|
||||||
|
logger.info("🤖 Бот запущен и готов к работе! Напиши /start в Telegram")
|
||||||
|
|
||||||
|
try:
|
||||||
|
app.run_polling()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("🛑 Бот остановлен пользователем")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при работе бота: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||||
|
ADMIN_ID = int(os.getenv("ADMIN_ID", 0))
|
||||||
|
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
logger.error("❌ BOT_TOKEN не найден в .env!")
|
||||||
|
if ADMIN_ID == 0:
|
||||||
|
logger.warning("⚠️ ADMIN_ID не установлен или равен 0!")
|
||||||
|
|
||||||
|
# Парсим хосты
|
||||||
|
HOSTS_RAW = os.getenv("HOSTS", "")
|
||||||
|
HOSTS_LIST = []
|
||||||
|
|
||||||
|
if HOSTS_RAW:
|
||||||
|
for item in HOSTS_RAW.split(";"):
|
||||||
|
if item.strip():
|
||||||
|
try:
|
||||||
|
parts = item.strip().split(",")
|
||||||
|
if len(parts) != 5:
|
||||||
|
logger.warning(f"⚠️ Неверный формат хоста: {item}. Ожидается: имя,ip,порт,логин,путь_к_ключу")
|
||||||
|
continue
|
||||||
|
name, ip, port, user, key_path = parts
|
||||||
|
# Проверяем существование ключа
|
||||||
|
if not os.path.exists(key_path):
|
||||||
|
logger.warning(f"⚠️ Файл ключа {key_path} не существует для хоста {name}!")
|
||||||
|
HOSTS_LIST.append({
|
||||||
|
"name": name,
|
||||||
|
"ip": ip,
|
||||||
|
"port": int(port),
|
||||||
|
"user": user,
|
||||||
|
"key_path": key_path
|
||||||
|
})
|
||||||
|
logger.info(f"✅ Добавлен хост: {name} ({ip})")
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"❌ Ошибка парсинга хоста {item}: {e}")
|
||||||
|
else:
|
||||||
|
logger.error("❌ HOSTS не задан в .env!")
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Глобальный словарь для хранения предыдущих значений RX/TX
|
||||||
|
prev_net_stats = {}
|
||||||
|
|
||||||
|
def get_ssh_client(host_config):
|
||||||
|
"""Создает SSH подключение через ssh-agent или ключ"""
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"🔌 Подключаюсь к {host_config['ip']}:{host_config['port']} как {host_config['user']}")
|
||||||
|
|
||||||
|
# Сначала пробуем ssh-agent
|
||||||
|
try:
|
||||||
|
from paramiko.agent import Agent
|
||||||
|
agent = Agent()
|
||||||
|
if agent.get_keys():
|
||||||
|
logger.info(f"🔑 Использую ssh-agent для {host_config['ip']}")
|
||||||
|
client.connect(
|
||||||
|
hostname=host_config['ip'],
|
||||||
|
port=host_config['port'],
|
||||||
|
username=host_config['user'],
|
||||||
|
timeout=10,
|
||||||
|
allow_agent=True,
|
||||||
|
look_for_keys=True
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Подключено к {host_config['ip']} через ssh-agent")
|
||||||
|
return client
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"ssh-agent не доступен: {e}")
|
||||||
|
|
||||||
|
# Если ssh-agent не работает, пробуем через ключ
|
||||||
|
if os.path.exists(host_config['key_path']):
|
||||||
|
logger.info(f"🔑 Использую ключ: {host_config['key_path']}")
|
||||||
|
client.connect(
|
||||||
|
hostname=host_config['ip'],
|
||||||
|
port=host_config['port'],
|
||||||
|
username=host_config['user'],
|
||||||
|
key_filename=host_config['key_path'],
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Подключено к {host_config['ip']} по ключу")
|
||||||
|
return client
|
||||||
|
else:
|
||||||
|
return f"❌ Файл ключа не найден: {host_config['key_path']}"
|
||||||
|
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
error_msg = f"❌ Ошибка аутентификации для {host_config['ip']}! Проверь логин, ключ или ssh-agent."
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
error_msg = f"❌ SSH ошибка для {host_config['ip']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Неизвестная ошибка для {host_config['ip']}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
def exec_cmd(client, command):
|
||||||
|
"""Выполняет команду и возвращает вывод"""
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command(command, timeout=10)
|
||||||
|
return stdout.read().decode('utf-8').strip()
|
||||||
|
except Exception as e:
|
||||||
|
return f"CMD Error: {str(e)}"
|
||||||
|
|
||||||
|
def detect_host_type(client):
|
||||||
|
"""Определяет тип хоста: physical, vm, lxc, container"""
|
||||||
|
# Проверка на LXC
|
||||||
|
lxc_check = exec_cmd(client, "cat /proc/1/environ 2>/dev/null | tr '\\0' '\\n' | grep -i lxc || echo ''")
|
||||||
|
if lxc_check:
|
||||||
|
return "lxc"
|
||||||
|
|
||||||
|
# Проверка на контейнер (Docker и др)
|
||||||
|
container_check = exec_cmd(client, "cat /proc/1/environ 2>/dev/null | tr '\\0' '\\n' | grep -i container || echo ''")
|
||||||
|
if container_check:
|
||||||
|
return "container"
|
||||||
|
|
||||||
|
# Проверка на виртуальную машину
|
||||||
|
virt_check = exec_cmd(client, "systemd-detect-virt 2>/dev/null || echo 'none'")
|
||||||
|
if virt_check and virt_check not in ['none', '']:
|
||||||
|
return "vm"
|
||||||
|
|
||||||
|
# Проверка через dmidecode (если есть)
|
||||||
|
virt_check2 = exec_cmd(client, "dmidecode -s system-manufacturer 2>/dev/null || echo 'Unknown'")
|
||||||
|
if virt_check2:
|
||||||
|
virt_vendors = ['vmware', 'virtualbox', 'kvm', 'qemu', 'xen', 'microsoft', 'parallels']
|
||||||
|
if any(vendor in virt_check2.lower() for vendor in virt_vendors):
|
||||||
|
return "vm"
|
||||||
|
|
||||||
|
# Если ничего не найдено - считаем физическим железом
|
||||||
|
return "physical"
|
||||||
|
|
||||||
|
def check_alerts(metrics, host_type):
|
||||||
|
"""Проверяет критические метрики и возвращает список алертов"""
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
# CPU алерт (>80%)
|
||||||
|
cpu = metrics.get('cpu', 0)
|
||||||
|
if cpu > 80:
|
||||||
|
alerts.append(f"🔴 ВЫСОКАЯ ЗАГРУЗКА CPU: {cpu:.1f}%")
|
||||||
|
elif cpu > 60:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННАЯ ЗАГРУЗКА CPU: {cpu:.1f}%")
|
||||||
|
|
||||||
|
# RAM алерт (>85%)
|
||||||
|
mem_percent = metrics.get('mem_percent', 0)
|
||||||
|
if mem_percent > 85:
|
||||||
|
alerts.append(f"🔴 НЕХВАТКА ОЗУ: {mem_percent:.1f}% (использовано {metrics.get('mem_used_mb', 0)} MB)")
|
||||||
|
elif mem_percent > 70:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННОЕ ПОТРЕБЛЕНИЕ ОЗУ: {mem_percent:.1f}%")
|
||||||
|
|
||||||
|
# Диск алерт (>80%)
|
||||||
|
disk_usage = metrics.get('disk_usage', 0)
|
||||||
|
if disk_usage > 80:
|
||||||
|
alerts.append(f"🔴 МАЛО МЕСТА НА ДИСКЕ: {disk_usage:.1f}%")
|
||||||
|
elif disk_usage > 70:
|
||||||
|
alerts.append(f"🟡 ЗАКАНЧИВАЕТСЯ МЕСТО НА ДИСКЕ: {disk_usage:.1f}%")
|
||||||
|
|
||||||
|
# OOM алерт
|
||||||
|
oom = metrics.get('oom', '')
|
||||||
|
if oom and "Нет OOM" not in oom:
|
||||||
|
alerts.append(f"💀 OOM KILLER СРАБОТАЛ! Проверь логи!")
|
||||||
|
|
||||||
|
# Load Average алерт (если > количество ядер)
|
||||||
|
load_1 = float(metrics.get('load_1', 0))
|
||||||
|
if load_1 > 4:
|
||||||
|
alerts.append(f"🔴 ВЫСОКИЙ LOAD AVERAGE: {load_1}")
|
||||||
|
elif load_1 > 2:
|
||||||
|
alerts.append(f"🟡 ПОВЫШЕННЫЙ LOAD AVERAGE: {load_1}")
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
def collect_metrics(host_config):
|
||||||
|
"""Сбор всех метрик с одного хоста"""
|
||||||
|
client = get_ssh_client(host_config)
|
||||||
|
if isinstance(client, str):
|
||||||
|
return {"error": client, "host": host_config['name'], "ip": host_config['ip']}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"host": host_config['name'],
|
||||||
|
"ip": host_config['ip'],
|
||||||
|
"metrics": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Определяем тип хоста
|
||||||
|
host_type = detect_host_type(client)
|
||||||
|
result["host_type"] = host_type
|
||||||
|
result["metrics"]["host_type"] = host_type
|
||||||
|
|
||||||
|
# 2. CPU загрузка (%)
|
||||||
|
cpu_output = exec_cmd(client, "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
|
||||||
|
try:
|
||||||
|
result["metrics"]["cpu"] = float(cpu_output.replace(',', '.'))
|
||||||
|
except:
|
||||||
|
result["metrics"]["cpu"] = 0.0
|
||||||
|
|
||||||
|
# 3. ОЗУ (используется в MB и %)
|
||||||
|
mem_output = exec_cmd(client, "free -m | grep Mem")
|
||||||
|
parts = re.split(r'\s+', mem_output)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
total_mem = int(parts[1])
|
||||||
|
used_mem = int(parts[2])
|
||||||
|
result["metrics"]["mem_used_mb"] = used_mem
|
||||||
|
result["metrics"]["mem_percent"] = round((used_mem / total_mem) * 100, 1)
|
||||||
|
result["metrics"]["mem_total_mb"] = total_mem
|
||||||
|
else:
|
||||||
|
result["metrics"]["mem_used_mb"] = 0
|
||||||
|
result["metrics"]["mem_percent"] = 0
|
||||||
|
result["metrics"]["mem_total_mb"] = 0
|
||||||
|
|
||||||
|
# 4. Uptime и Load Average
|
||||||
|
uptime_output = exec_cmd(client, "uptime")
|
||||||
|
load_avg = re.search(r'load average:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)', uptime_output)
|
||||||
|
if load_avg:
|
||||||
|
result["metrics"]["load_1"] = load_avg.group(1)
|
||||||
|
result["metrics"]["load_5"] = load_avg.group(2)
|
||||||
|
result["metrics"]["load_15"] = load_avg.group(3)
|
||||||
|
|
||||||
|
uptime_seconds = exec_cmd(client, "cat /proc/uptime | awk '{print $1}'")
|
||||||
|
try:
|
||||||
|
uptime_seconds = float(uptime_seconds.split('.')[0])
|
||||||
|
days = uptime_seconds // 86400
|
||||||
|
hours = (uptime_seconds % 86400) // 3600
|
||||||
|
minutes = (uptime_seconds % 3600) // 60
|
||||||
|
result["metrics"]["uptime"] = f"{int(days)}d {int(hours)}h {int(minutes)}m"
|
||||||
|
except:
|
||||||
|
result["metrics"]["uptime"] = "N/A"
|
||||||
|
|
||||||
|
# 5. Сеть (трафик за последний час)
|
||||||
|
net_output = exec_cmd(client, "cat /proc/net/dev | tail -n +3 | grep -v lo")
|
||||||
|
current_net = {}
|
||||||
|
for line in net_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
iface = parts[0].replace(':', '')
|
||||||
|
rx_bytes = int(parts[1])
|
||||||
|
tx_bytes = int(parts[9])
|
||||||
|
current_net[iface] = {'rx': rx_bytes, 'tx': tx_bytes}
|
||||||
|
|
||||||
|
global prev_net_stats
|
||||||
|
host_key = host_config['ip']
|
||||||
|
total_rx_hour = 0
|
||||||
|
total_tx_hour = 0
|
||||||
|
|
||||||
|
if host_key in prev_net_stats:
|
||||||
|
old_data = prev_net_stats[host_key]
|
||||||
|
for iface, vals in current_net.items():
|
||||||
|
if iface in old_data:
|
||||||
|
delta_rx = vals['rx'] - old_data[iface]['rx']
|
||||||
|
delta_tx = vals['tx'] - old_data[iface]['tx']
|
||||||
|
if delta_rx > 0:
|
||||||
|
total_rx_hour += delta_rx
|
||||||
|
if delta_tx > 0:
|
||||||
|
total_tx_hour += delta_tx
|
||||||
|
|
||||||
|
result["metrics"]["net_rx_mb"] = round(total_rx_hour / (1024 * 1024), 2)
|
||||||
|
result["metrics"]["net_tx_mb"] = round(total_tx_hour / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
prev_net_stats[host_key] = current_net
|
||||||
|
|
||||||
|
# 6. Диски (iostat %util)
|
||||||
|
iostat_output = exec_cmd(client, "iostat -x -k 1 2 | grep -E 'sd[a-z]|vd[a-z]|nvme' | tail -n +3")
|
||||||
|
disk_stats = []
|
||||||
|
for line in iostat_output.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\s+', line.strip())
|
||||||
|
if len(parts) >= 14:
|
||||||
|
disk_name = parts[0]
|
||||||
|
try:
|
||||||
|
util = float(parts[-1].replace(',', '.'))
|
||||||
|
rps = float(parts[3].replace(',', '.'))
|
||||||
|
wps = float(parts[4].replace(',', '.'))
|
||||||
|
disk_stats.append({
|
||||||
|
"name": disk_name,
|
||||||
|
"util": util,
|
||||||
|
"rps": rps,
|
||||||
|
"wps": wps
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
disk_stats.sort(key=lambda x: x['util'], reverse=True)
|
||||||
|
result["metrics"]["disks"] = disk_stats[:3]
|
||||||
|
|
||||||
|
# 7. Свободное место на диске (%)
|
||||||
|
disk_usage_output = exec_cmd(client, "df -h / | tail -1 | awk '{print $5}' | sed 's/%//'")
|
||||||
|
try:
|
||||||
|
result["metrics"]["disk_usage"] = float(disk_usage_output.replace(',', '.'))
|
||||||
|
except:
|
||||||
|
result["metrics"]["disk_usage"] = 0
|
||||||
|
|
||||||
|
# 8. Температуры (ТОЛЬКО ДЛЯ ФИЗИЧЕСКИХ ХОСТОВ!)
|
||||||
|
temps = []
|
||||||
|
if host_type == "physical":
|
||||||
|
temp_output = exec_cmd(client, "sensors -u 2>/dev/null | grep -E 'temp[0-9]+_input|Core' | head -5")
|
||||||
|
if temp_output and "Error" not in temp_output and "command not found" not in temp_output:
|
||||||
|
for line in temp_output.split('\n'):
|
||||||
|
if '_input:' in line:
|
||||||
|
val = re.search(r'([\d.]+)', line)
|
||||||
|
if val:
|
||||||
|
temps.append(float(val.group(1)))
|
||||||
|
else:
|
||||||
|
# Если sensors не установлен - пробуем через /sys/class/thermal
|
||||||
|
temp_sys = exec_cmd(client, "cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | head -3")
|
||||||
|
if temp_sys and "No such file" not in temp_sys:
|
||||||
|
for line in temp_sys.split('\n'):
|
||||||
|
try:
|
||||||
|
temps.append(float(line) / 1000)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
result["metrics"]["temps"] = temps
|
||||||
|
|
||||||
|
# 9. Топ-5 процессов по CPU и RAM
|
||||||
|
top_cpu = exec_cmd(client, "ps aux --sort=-%cpu | head -6 | tail -5")
|
||||||
|
top_mem = exec_cmd(client, "ps aux --sort=-%mem | head -6 | tail -5")
|
||||||
|
result["metrics"]["top_cpu"] = top_cpu
|
||||||
|
result["metrics"]["top_mem"] = top_mem
|
||||||
|
|
||||||
|
# 10. Логи (ошибки)
|
||||||
|
last_logs = exec_cmd(client, "journalctl -p 3 -xn 5 --no-pager 2>/dev/null || dmesg | tail -5")
|
||||||
|
result["metrics"]["logs_errors"] = last_logs
|
||||||
|
|
||||||
|
# 11. Fail2ban
|
||||||
|
fail2ban_status = exec_cmd(client, "fail2ban-client status 2>/dev/null")
|
||||||
|
if not fail2ban_status or "not found" in fail2ban_status:
|
||||||
|
ps_fail2ban = exec_cmd(client, "ps aux | grep -v grep | grep fail2ban-server")
|
||||||
|
if ps_fail2ban:
|
||||||
|
fail2ban_status = "✅ Fail2ban запущен"
|
||||||
|
else:
|
||||||
|
fail2ban_status = "❌ Fail2ban не установлен или не запущен"
|
||||||
|
else:
|
||||||
|
lines = fail2ban_status.split('\n')
|
||||||
|
jails_info = []
|
||||||
|
for line in lines:
|
||||||
|
if '|- Number of jail:' in line or '`- Number of jail:' in line:
|
||||||
|
jails_info.append(line.strip())
|
||||||
|
if 'Currently banned:' in line:
|
||||||
|
jails_info.append(line.strip())
|
||||||
|
if jails_info:
|
||||||
|
fail2ban_status = "✅ Fail2ban активен\n" + "\n".join(jails_info)
|
||||||
|
|
||||||
|
fail2ban_logs = exec_cmd(client, "tail -20 /var/log/fail2ban.log 2>/dev/null | grep -E 'BAN|UNBAN' | tail -5 || echo 'Нет записей о банах'")
|
||||||
|
|
||||||
|
active_bans = exec_cmd(client, "fail2ban-client status 2>/dev/null | grep -A 5 'Currently banned:'")
|
||||||
|
if active_bans and "Currently banned:" in active_bans:
|
||||||
|
fail2ban_logs += "\n\n🔒 Активные баны:\n" + active_bans
|
||||||
|
|
||||||
|
result["metrics"]["fail2ban_status"] = fail2ban_status
|
||||||
|
result["metrics"]["fail2ban_logs"] = fail2ban_logs
|
||||||
|
|
||||||
|
# 12. OOM
|
||||||
|
oom_check = exec_cmd(client, "dmesg | grep -i 'out of memory' | tail -3 || echo 'Нет OOM-событий'")
|
||||||
|
if oom_check and "Нет OOM-событий" not in oom_check:
|
||||||
|
result["metrics"]["oom"] = oom_check
|
||||||
|
else:
|
||||||
|
result["metrics"]["oom"] = "✅ Нет OOM-событий"
|
||||||
|
|
||||||
|
# 13. Проверка алертов
|
||||||
|
result["metrics"]["alerts"] = check_alerts(result["metrics"], host_type)
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
python-telegram-bot==20.7
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
paramiko==3.4.0
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import paramiko
|
||||||
|
import os
|
||||||
|
from config import HOSTS_LIST
|
||||||
|
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
print(f"\n🔍 Проверяю {host['name']} ({host['ip']})")
|
||||||
|
print(f" Пользователь: {host['user']}")
|
||||||
|
print(f" Ключ: {host['key_path']}")
|
||||||
|
|
||||||
|
# Проверяем существование ключа
|
||||||
|
if not os.path.exists(host['key_path']):
|
||||||
|
print(f" ❌ Файл ключа НЕ СУЩЕСТВУЕТ: {host['key_path']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Пробуем подключиться
|
||||||
|
try:
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(
|
||||||
|
hostname=host['ip'],
|
||||||
|
port=host['port'],
|
||||||
|
username=host['user'],
|
||||||
|
key_filename=host['key_path'],
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
print(" ✅ Подключено успешно!")
|
||||||
|
|
||||||
|
# Проверяем тип хоста
|
||||||
|
stdin, stdout, stderr = client.exec_command("uname -a")
|
||||||
|
print(f" Система: {stdout.read().decode().strip()}")
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
print(" ❌ Ошибка аутентификации! Неверный логин или ключ")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Ошибка: {e}")
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
|
||||||
|
|
||||||
|
from config import BOT_TOKEN, ADMIN_ID, HOSTS_LIST
|
||||||
|
from monitor import collect_metrics
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler("bot.log"),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Кеш для хранения последних собранных данных
|
||||||
|
CACHE = {}
|
||||||
|
|
||||||
|
def format_metrics(data):
|
||||||
|
"""Формирует красивое сообщение из метрик"""
|
||||||
|
if "error" in data:
|
||||||
|
return f"❌ Ошибка подключения к {data['host']}:\n{data['error']}"
|
||||||
|
|
||||||
|
m = data['metrics']
|
||||||
|
host = data['host']
|
||||||
|
ip = data['ip']
|
||||||
|
host_type = data.get('host_type', 'unknown')
|
||||||
|
|
||||||
|
# Эмодзи для типа хоста
|
||||||
|
type_emoji = {
|
||||||
|
'physical': '🖥️',
|
||||||
|
'vm': '🖥️',
|
||||||
|
'lxc': '📦',
|
||||||
|
'container': '🐳'
|
||||||
|
}.get(host_type, '🖥️')
|
||||||
|
|
||||||
|
type_label = {
|
||||||
|
'physical': 'Железо',
|
||||||
|
'vm': 'ВМ',
|
||||||
|
'lxc': 'LXC',
|
||||||
|
'container': 'Контейнер'
|
||||||
|
}.get(host_type, 'Неизвестно')
|
||||||
|
|
||||||
|
text = f"{type_emoji} *{host} ({ip})* - `{type_label}`\n"
|
||||||
|
text += f"🕒 *Uptime:* {m.get('uptime', 'N/A')}\n"
|
||||||
|
text += f"📊 *Load Avg:* {m.get('load_1', '0')}, {m.get('load_5', '0')}, {m.get('load_15', '0')}\n\n"
|
||||||
|
|
||||||
|
# Алерты (если есть)
|
||||||
|
alerts = m.get('alerts', [])
|
||||||
|
if alerts:
|
||||||
|
text += "🚨 *АЛЕРТЫ!*\n"
|
||||||
|
for alert in alerts:
|
||||||
|
text += f" {alert}\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
# CPU
|
||||||
|
cpu_bar = "█" * int(m['cpu'] // 5) + "░" * (20 - int(m['cpu'] // 5))
|
||||||
|
cpu_color = "🔴" if m['cpu'] > 80 else "🟡" if m['cpu'] > 60 else "🟢"
|
||||||
|
text += f"{cpu_color} *CPU:* {m['cpu']:.1f}%\n`{cpu_bar}`\n"
|
||||||
|
|
||||||
|
# RAM
|
||||||
|
mem_bar = "█" * int(m['mem_percent'] // 5) + "░" * (20 - int(m['mem_percent'] // 5))
|
||||||
|
mem_color = "🔴" if m['mem_percent'] > 85 else "🟡" if m['mem_percent'] > 70 else "🟢"
|
||||||
|
text += f"{mem_color} *RAM:* {m['mem_used_mb']} MB / {m.get('mem_total_mb', '?')} MB ({m['mem_percent']}%)\n`{mem_bar}`\n"
|
||||||
|
|
||||||
|
# Диск (свободное место)
|
||||||
|
disk_color = "🔴" if m.get('disk_usage', 0) > 80 else "🟡" if m.get('disk_usage', 0) > 70 else "🟢"
|
||||||
|
text += f"{disk_color} *Disk (/):* {m.get('disk_usage', 0):.1f}% used\n"
|
||||||
|
|
||||||
|
# Сеть
|
||||||
|
text += f"🌐 *Network (per hour):* 📥 {m['net_rx_mb']} MB | 📤 {m['net_tx_mb']} MB\n"
|
||||||
|
|
||||||
|
# Диски iostat
|
||||||
|
text += "\n💾 *Disks (top 3 by %util):*\n"
|
||||||
|
for disk in m.get('disks', []):
|
||||||
|
text += f" └─ {disk['name']}: util {disk['util']:.1f}% (r: {disk['rps']:.1f}/s, w: {disk['wps']:.1f}/s)\n"
|
||||||
|
|
||||||
|
# Температуры (только для железа)
|
||||||
|
if host_type == "physical" and m.get('temps'):
|
||||||
|
text += "\n🌡️ *Temperatures:*\n"
|
||||||
|
for i, temp in enumerate(m['temps']):
|
||||||
|
temp_color = "🔴" if temp > 75 else "🟡" if temp > 60 else "🟢"
|
||||||
|
text += f" {temp_color} Sensor {i+1}: {temp:.1f}°C\n"
|
||||||
|
elif host_type != "physical":
|
||||||
|
text += "\n🌡️ *Temperatures:* Не требуется (ВМ/LXC)\n"
|
||||||
|
|
||||||
|
# Топ процессов
|
||||||
|
text += "🏆 *Top 5 CPU:*\n```\n"
|
||||||
|
top_cpu = m.get('top_cpu', 'N/A')
|
||||||
|
# Экранируем опасные символы для Markdown
|
||||||
|
top_cpu = top_cpu.replace('_', '\\_').replace('*', '\\*').replace('`', '\\`')
|
||||||
|
text += top_cpu + "\n```"
|
||||||
|
text += "🏆 *Top 5 RAM:*\n```\n"
|
||||||
|
top_mem = m.get('top_mem', 'N/A')
|
||||||
|
top_mem = top_mem.replace('_', '\\_').replace('*', '\\*').replace('`', '\\`')
|
||||||
|
text += top_mem + "\n```"
|
||||||
|
# text += "\n🏆 *Top 5 CPU:*\n```\n"
|
||||||
|
# text += m.get('top_cpu', 'N/A') + "\n```"
|
||||||
|
# text += "🏆 *Top 5 RAM:*\n```\n"
|
||||||
|
# text += m.get('top_mem', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
# Логи и безопасность
|
||||||
|
text += "\n⚠️ *Recent Errors (journalctl -p 3):*\n```\n"
|
||||||
|
text += m.get('logs_errors', 'N/A')[:300] + "\n```"
|
||||||
|
|
||||||
|
# OOM
|
||||||
|
text += "\n💀 *Out of Memory Events:*\n```\n"
|
||||||
|
text += m.get('oom', 'Нет данных') + "\n```"
|
||||||
|
|
||||||
|
# Fail2ban
|
||||||
|
text += "\n🛡️ *Fail2ban Status:*\n```\n"
|
||||||
|
text += m.get('fail2ban_status', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
text += "\n🚫 *Fail2ban Logs (last bans):*\n```\n"
|
||||||
|
text += m.get('fail2ban_logs', 'N/A') + "\n```"
|
||||||
|
|
||||||
|
text += f"\n⏱️ *Обновлено:* {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработчик команды /status"""
|
||||||
|
if update.effective_user.id != ADMIN_ID:
|
||||||
|
await update.message.reply_text("⛔ Доступ запрещен. Ты не мой хозяин.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Создаем клавиатуру с хостами
|
||||||
|
keyboard = []
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
keyboard.append([InlineKeyboardButton(host['name'], callback_data=f"host_{host['ip']}")])
|
||||||
|
keyboard.append([InlineKeyboardButton("🔄 Все хосты", callback_data="host_all")])
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🔍 Выбери хост для мониторинга или нажми 'Все хосты':",
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
|
||||||
|
async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработка нажатий на кнопки"""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
if update.effective_user.id != ADMIN_ID:
|
||||||
|
await query.edit_message_text("⛔ Недостаточно прав.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = query.data
|
||||||
|
if data == "host_all":
|
||||||
|
await query.edit_message_text("⏳ Собираю данные со всех хостов...")
|
||||||
|
full_report = ""
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
full_report += report + "\n\n" + "="*30 + "\n\n"
|
||||||
|
# Обрезаем, если слишком длинное
|
||||||
|
if len(full_report) > 4000:
|
||||||
|
full_report = full_report[:4000] + "\n\n... (обрезано)"
|
||||||
|
await query.edit_message_text(full_report, parse_mode="Markdown")
|
||||||
|
elif data.startswith("host_"):
|
||||||
|
ip = data.replace("host_", "")
|
||||||
|
host = next((h for h in HOSTS_LIST if h['ip'] == ip), None)
|
||||||
|
if not host:
|
||||||
|
await query.edit_message_text("Хост не найден")
|
||||||
|
return
|
||||||
|
await query.edit_message_text(f"⏳ Собираю данные с {host['name']}...")
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
await query.edit_message_text(report, parse_mode="Markdown")
|
||||||
|
|
||||||
|
async def scheduled_job(context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Функция, вызываемая раз в час"""
|
||||||
|
bot: Bot = context.bot
|
||||||
|
logger.info("🔄 Запущен плановый сбор метрик (каждый час)")
|
||||||
|
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
try:
|
||||||
|
logger.info(f"📊 Собираю данные с {host['name']}")
|
||||||
|
result = collect_metrics(host)
|
||||||
|
report = format_metrics(result)
|
||||||
|
await bot.send_message(chat_id=ADMIN_ID, text=report, parse_mode="Markdown")
|
||||||
|
logger.info(f"✅ Отправлен отчет для {host['name']}")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Ошибка при сборе/отправке для {host['name']}: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
await bot.send_message(chat_id=ADMIN_ID, text=error_msg)
|
||||||
|
|
||||||
|
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Команда /start - проверка, что бот жив"""
|
||||||
|
if update.effective_user.id != ADMIN_ID:
|
||||||
|
await update.message.reply_text("Привет! Я бот для мониторинга серверов, но ты не мой хозяин.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🤖 *Бот мониторинга запущен!*\n\n"
|
||||||
|
"📌 Используй команду `/status`, чтобы получить актуальные метрики с серверов.\n"
|
||||||
|
"⏰ Каждый час я буду присылать автоматический отчет.\n\n"
|
||||||
|
f"📋 Настроено хостов: {len(HOSTS_LIST)}",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Главная функция запуска бота"""
|
||||||
|
logger.info("🚀 Запуск бота...")
|
||||||
|
logger.info(f"📋 Загружено хостов: {len(HOSTS_LIST)}")
|
||||||
|
|
||||||
|
# Проверяем хосты при старте
|
||||||
|
if not HOSTS_LIST:
|
||||||
|
logger.error("❌ Нет хостов в конфигурации! Проверь .env файл!")
|
||||||
|
return
|
||||||
|
|
||||||
|
for host in HOSTS_LIST:
|
||||||
|
logger.info(f" - {host['name']} ({host['ip']}:{host['port']})")
|
||||||
|
|
||||||
|
app = Application.builder().token(BOT_TOKEN).build()
|
||||||
|
|
||||||
|
# Регистрируем команды
|
||||||
|
app.add_handler(CommandHandler("start", start_command))
|
||||||
|
app.add_handler(CommandHandler("status", status_command))
|
||||||
|
app.add_handler(CallbackQueryHandler(button_callback))
|
||||||
|
|
||||||
|
# Настройка шедулера (раз в час)
|
||||||
|
job_queue = app.job_queue
|
||||||
|
if job_queue:
|
||||||
|
# Запускаем через 10 секунд после старта, потом каждый час
|
||||||
|
job_queue.run_repeating(scheduled_job, interval=3600, first=10)
|
||||||
|
logger.info("⏰ Шедулер настроен: отчеты будут приходить каждый час")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ JobQueue не доступна! Автоматические отчеты не будут работать.")
|
||||||
|
logger.warning("⚠️ Установи: pip install 'python-telegram-bot[job-queue]'")
|
||||||
|
|
||||||
|
logger.info("🤖 Бот запущен и готов к работе! Напиши /start в Telegram")
|
||||||
|
|
||||||
|
try:
|
||||||
|
app.run_polling()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("🛑 Бот остановлен пользователем")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при работе бота: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue