commit 492e3bd3cf47d83d84ae1ba2f329fbb0ccb8ba42 Author: root Date: Sat Feb 7 21:34:24 2026 +0300 Initial version of Telegram admin bot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eacffa1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.pyc +*.pyo + +# Virtual env +.venv/ + +# Secrets +config.yaml +*.env + +# Logs +*.log + +# Runtime +/var/run/tg-bot/ diff --git a/artifacts.py b/artifacts.py new file mode 100644 index 0000000..1d3d667 --- /dev/null +++ b/artifacts.py @@ -0,0 +1,21 @@ +import json +from datetime import datetime +from pathlib import Path + +def artifact_last(state_file): + data = json.loads(Path(state_file).read_text()) + items = sorted( + data.items(), + key=lambda x: x[1]["updated_at"], + reverse=True + ) + name, info = items[0] + t = datetime.fromisoformat(info["updated_at"]) + age_h = int((datetime.now() - t).total_seconds() / 3600) + + return ( + "🧊 Last artifact\n\n" + f"{name}\n" + f"Updated: {t:%Y-%m-%d %H:%M}\n" + f"Age: {age_h}h" + ) diff --git a/backups.py b/backups.py new file mode 100644 index 0000000..9e90ba1 --- /dev/null +++ b/backups.py @@ -0,0 +1,27 @@ +import json +from datetime import datetime +from pathlib import Path + +def last_backup(): + import subprocess + out = subprocess.check_output( + ["restic", "snapshots", "--json"], + env=None + ).decode() + snaps = json.loads(out) + snaps.sort(key=lambda s: s["time"], reverse=True) + s = snaps[0] + t = datetime.fromisoformat(s["time"].replace("Z", "")) + return ( + "📦 Last backup\n\n" + f"🕒 {t:%Y-%m-%d %H:%M}\n" + f"🧊 ID: {s['short_id']}\n" + f"📁 Paths: {len(s['paths'])}" + ) + +def restore_help(): + return ( + "🧯 Restore help\n\n" + "Example:\n" + "restic restore --target /restore" + ) diff --git a/bot.py b/bot.py new file mode 100755 index 0000000..1613ac4 --- /dev/null +++ b/bot.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 +import asyncio +import json +import os +import socket +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Optional +from system_checks import security, disks +import psutil +import yaml +from aiogram import Bot, Dispatcher, F +from aiogram.types import ( + Message, + CallbackQuery, + ReplyKeyboardMarkup, + KeyboardButton, + InlineKeyboardMarkup, + InlineKeyboardButton, +) + +# ===================== CONFIG ===================== + +CONFIG_FILE = "/opt/tg-bot/config.yaml" + +def load_cfg(): + with open(CONFIG_FILE) as f: + return yaml.safe_load(f) + +def load_env(env_file: str) -> Dict[str, str]: + env = {} + with open(env_file) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export "):] + if "=" in line: + k, v = line.split("=", 1) + env[k] = v.strip().strip('"') + return env + +cfg = load_cfg() + +TOKEN = cfg["telegram"]["token"] +ADMIN_ID = cfg["telegram"]["admin_id"] + +ARTIFACT_STATE = cfg["paths"]["artifact_state"] +RESTIC_ENV = load_env(cfg["paths"].get("restic_env", "/etc/restic/restic.env")) + +DISK_WARN = int(cfg.get("thresholds", {}).get("disk_warn", 80)) +LOAD_WARN = float(cfg.get("thresholds", {}).get("load_warn", 2.0)) + +bot = Bot(TOKEN) +dp = Dispatcher() + +DOCKER_MAP: Dict[str, str] = {} + +def is_admin(msg: Message) -> bool: + return msg.from_user and msg.from_user.id == ADMIN_ID + +def container_uptime(started_at: str) -> str: + """ + started_at: 2026-02-06T21:14:33.123456789Z + """ + try: + start = datetime.fromisoformat( + started_at.replace("Z", "+00:00") + ).astimezone(timezone.utc) + delta = datetime.now(timezone.utc) - start + days = delta.days + hours = delta.seconds // 3600 + minutes = (delta.seconds % 3600) // 60 + + if days > 0: + return f"{days}d {hours}h" + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + except Exception: + return "unknown" + +def backup_badge(last_time: datetime) -> str: + age = datetime.now(timezone.utc) - last_time + hours = age.total_seconds() / 3600 + + if hours < 24: + return "🟢 Backup: OK" + if hours < 72: + return "🟡 Backup: stale" + return "🔴 Backup: OLD" + +def format_disks(): + parts = psutil.disk_partitions(all=False) + lines = [] + + skip_prefixes = ( + "/snap", + "/proc", + "/sys", + "/run", + "/boot/efi", + ) + + for p in parts: + mp = p.mountpoint + if mp.startswith(skip_prefixes): + continue + + try: + usage = psutil.disk_usage(mp) + except PermissionError: + continue + + icon = "🟢" + if usage.percent > 90: + icon = "🔴" + elif usage.percent > 80: + icon = "🟡" + + lines.append( + f"{icon} **{mp}**: " + f"{usage.used // (1024**3)} / {usage.total // (1024**3)} GiB " + f"({usage.percent}%)" + ) + + if not lines: + return "💾 Disks: n/a" + + return "💾 **Disks**\n" + "\n".join(lines) + +# ===================== KEYBOARDS ===================== + +menu_kb = ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="🩺 Health"), KeyboardButton(text="📊 Статус")], + [KeyboardButton(text="🐳 Docker"), KeyboardButton(text="📦 Backup")], + [KeyboardButton(text="🧊 Artifacts"), KeyboardButton(text="⚙️ System")], + [KeyboardButton(text="ℹ️ Help")], + ], + resize_keyboard=True, +) + +docker_kb = ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="🐳 Status")], + [KeyboardButton(text="🔄 Restart"), KeyboardButton(text="📜 Logs")], + [KeyboardButton(text="⬅️ Назад")], + ], + resize_keyboard=True, +) + +backup_kb = ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="📦 Status")], + [KeyboardButton(text="📊 Repo stats")], + [KeyboardButton(text="▶️ Run backup")], + [KeyboardButton(text="🧯 Restore help")], + [KeyboardButton(text="⬅️ Назад")], + ], + resize_keyboard=True, +) + +artifacts_kb = ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="🧊 Status")], + [KeyboardButton(text="📤 Upload")], + [KeyboardButton(text="⬅️ Назад")], + ], + resize_keyboard=True, +) + +system_kb = ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="💽 Disks"), KeyboardButton(text="🔐 Security")], + [KeyboardButton(text="🔄 Reboot")], + [KeyboardButton(text="⬅️ Назад")], + ], + resize_keyboard=True, +) + +def docker_inline_kb(action: str) -> InlineKeyboardMarkup: + rows = [] + for alias in DOCKER_MAP.keys(): + rows.append([ + InlineKeyboardButton( + text=alias, + callback_data=f"docker:{action}:{alias}" + ) + ]) + return InlineKeyboardMarkup(inline_keyboard=rows) + +# ===================== LOCKS ===================== + +LOCK_DIR = Path("/var/run/tg-bot") +LOCK_DIR.mkdir(parents=True, exist_ok=True) + +def lock_path(name: str) -> Path: + return LOCK_DIR / f"{name}.lock" + +def acquire_lock(name: str) -> bool: + p = lock_path(name) + if p.exists(): + return False + p.write_text(str(time.time())) + return True + +def release_lock(name: str): + p = lock_path(name) + if p.exists(): + p.unlink() + +# ===================== COMMAND RUNNER ===================== + +async def run_cmd(cmd: list[str], *, use_restic_env=False, timeout=60): + env = os.environ.copy() + env["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + if use_restic_env: + env.update(RESTIC_ENV) + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=env, + ) + + try: + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + return proc.returncode, out.decode(errors="ignore")[-3500:] + except asyncio.TimeoutError: + proc.kill() + return 124, "❌ timeout" + +async def build_docker_map(cfg) -> Dict[str, str]: + docker_cfg = cfg.get("docker", {}) + + result: Dict[str, str] = {} + + # 1. autodiscovery + if docker_cfg.get("autodiscovery"): + rc, raw = await run_cmd( + ["sudo", "docker", "ps", "--format", "{{.Names}}"], + timeout=20 + ) + if rc == 0: + names = raw.splitlines() + patterns = docker_cfg.get("match", []) + for name in names: + if any(p in name for p in patterns): + result[name] = name + + # 2. aliases override + aliases = docker_cfg.get("aliases", {}) + for alias, real in aliases.items(): + result[alias] = real + + return result + +async def get_last_snapshot() -> Optional[dict]: + rc, raw = await run_cmd( + ["restic", "snapshots", "--json"], + use_restic_env=True, + timeout=20 + ) + if rc != 0: + return None + + snaps = json.loads(raw) + if not snaps: + return None + + snaps.sort(key=lambda s: s["time"], reverse=True) + return snaps[0] + + +# ===================== CORE COMMANDS ===================== +async def cmd_repo_stats(msg: Message): + await msg.answer("⏳ Loading repo stats…", reply_markup=backup_kb) + + # --- restore-size stats --- + rc1, raw1 = await run_cmd( + ["restic", "stats", "--json"], + use_restic_env=True, + timeout=30 + ) + if rc1 != 0: + await msg.answer(raw1, reply_markup=backup_kb) + return + + restore = json.loads(raw1) + + # --- raw-data stats --- + rc2, raw2 = await run_cmd( + ["restic", "stats", "--json", "--mode", "raw-data"], + use_restic_env=True, + timeout=30 + ) + if rc2 != 0: + await msg.answer(raw2, reply_markup=backup_kb) + return + + raw = json.loads(raw2) + + # --- snapshots count --- + rc3, raw_snaps = await run_cmd( + ["restic", "snapshots", "--json"], + use_restic_env=True, + timeout=20 + ) + snaps = len(json.loads(raw_snaps)) if rc3 == 0 else "n/a" + + msg_text = ( + "📦 **Repository stats**\n\n" + f"🧊 Snapshots: {snaps}\n" + f"📁 Files: {restore.get('total_file_count', 'n/a')}\n" + f"💾 Logical size: {restore.get('total_size', 0) / (1024**3):.2f} GiB\n" + f"🧱 Stored data: {raw.get('total_pack_size', 0) / (1024**2):.2f} MiB\n" + ) + + await msg.answer(msg_text, reply_markup=backup_kb, parse_mode="Markdown") + + +async def discover_containers(cfg) -> Dict[str, str]: + """ + returns: alias -> real container name + """ + docker_cfg = cfg.get("docker", {}) + result: Dict[str, str] = {} + + # --- autodiscovery --- + if docker_cfg.get("autodiscovery"): + rc, raw = await run_cmd( + ["sudo", "docker", "ps", "--format", "{{.Names}}"], + timeout=20 + ) + + if rc == 0: + found = raw.splitlines() + + label = docker_cfg.get("label") + patterns = docker_cfg.get("match", []) + + for name in found: + # label-based discovery + if label: + key, val = label.split("=", 1) + rc2, lbl = await run_cmd([ + "sudo", "docker", "inspect", + "-f", f"{{{{ index .Config.Labels \"{key}\" }}}}", + name + ]) + if rc2 == 0 and lbl.strip() == val: + result[name] = name + continue + + # name-pattern discovery + if any(p in name for p in patterns): + result[name] = name + + # --- manual aliases ALWAYS override --- + aliases = docker_cfg.get("aliases", {}) + for alias, real in aliases.items(): + result[alias] = real + + return result + + +async def cmd_status(msg: Message): + now = time.time() + uptime_sec = int(now - psutil.boot_time()) + + days, rem = divmod(uptime_sec, 86400) + hours, rem = divmod(rem, 3600) + minutes, _ = divmod(rem, 60) + + load1 = psutil.getloadavg()[0] + cpu_icon = "🟢" + if load1 > 2.0: + cpu_icon = "🔴" + elif load1 > 1.0: + cpu_icon = "🟡" + + mem = psutil.virtual_memory() + + disks = format_disks() + + await msg.answer( + "📊 **Server status**\n\n" + f"🖥 **Host:** `{socket.gethostname()}`\n" + f"⏱ **Uptime:** {days}d {hours}h {minutes}m\n" + f"{cpu_icon} **Load (1m):** {load1:.2f}\n" + f"🧠 **RAM:** {mem.used // (1024**3)} / {mem.total // (1024**3)} GiB ({mem.percent}%)\n\n" + f"{disks}", + reply_markup=menu_kb, + parse_mode="Markdown", + ) + +async def cmd_health(msg: Message): + await msg.answer("⏳ Health-check…", reply_markup=menu_kb) + + async def worker(): + lines = ["🩺 Health\n"] + rc, _ = await run_cmd(["restic", "snapshots", "--latest", "1"], use_restic_env=True, timeout=20) + lines.append("🟢 Backup repo OK" if rc == 0 else "🔴 Backup repo FAIL") + + bad = [] + for alias, real in DOCKER_MAP.items(): + rc2, state = await run_cmd(["sudo", "docker", "inspect", "-f", "{{.State.Status}}", real], timeout=10) + if rc2 != 0 or state.strip() != "running": + bad.append(alias) + lines.append("🟢 Docker OK" if not bad else f"🔴 Docker down: {', '.join(bad)}") + + disk = psutil.disk_usage("/mnt/data") + lines.append(("🟡" if disk.percent >= DISK_WARN else "🟢") + f" Disk {disk.percent}%") + + load = psutil.getloadavg()[0] + lines.append(("🟡" if load >= LOAD_WARN else "🟢") + f" Load {load:.2f}") + + await msg.answer("\n".join(lines), reply_markup=menu_kb) + + asyncio.create_task(worker()) + +async def cmd_backup_status(msg: Message): + await msg.answer("⏳ Loading snapshots…", reply_markup=backup_kb) + + async def worker(): + rc, raw = await run_cmd( + ["restic", "snapshots", "--json"], + use_restic_env=True, + timeout=30 + ) + if rc != 0: + await msg.answer(raw, reply_markup=backup_kb) + return + + snaps = json.loads(raw) + if not snaps: + await msg.answer("📦 Snapshots: none", reply_markup=backup_kb) + return + + snaps.sort(key=lambda s: s["time"], reverse=True) + + # --- badge --- + last = snaps[0] + last_time = datetime.fromisoformat( + last["time"].replace("Z", "+00:00") + ) + badge = backup_badge(last_time) + + # --- buttons --- + rows = [] + for s in snaps[:5]: + t = datetime.fromisoformat( + s["time"].replace("Z", "+00:00") + ) + rows.append([ + InlineKeyboardButton( + text=f"🧊 {s['short_id']} | {t:%Y-%m-%d %H:%M}", + callback_data=f"snap:{s['short_id']}" + ) + ]) + + kb = InlineKeyboardMarkup(inline_keyboard=rows) + + await msg.answer( + f"📦 Snapshots ({len(snaps)})\n{badge}", + reply_markup=kb + ) + + asyncio.create_task(worker()) + +async def cmd_backup_now(msg: Message): + if not acquire_lock("backup"): + await msg.answer("⛔ Backup уже выполняется", reply_markup=backup_kb) + return + + await msg.answer("▶️ Backup запущен", reply_markup=backup_kb) + + async def worker(): + try: + rc, out = await run_cmd(["sudo", "/usr/local/bin/backup.py", "restic-backup"], timeout=6*3600) + await msg.answer(("✅ OK\n" if rc == 0 else "❌ FAIL\n") + out, reply_markup=backup_kb) + finally: + release_lock("backup") + + asyncio.create_task(worker()) + +async def cmd_artifacts_status(msg: Message): + p = Path(ARTIFACT_STATE) + if not p.exists(): + await msg.answer("❌ state.json не найден", reply_markup=artifacts_kb) + return + + data = json.loads(p.read_text()) + lines = [f"🧊 Artifacts ({len(data)})\n"] + for name, info in data.items(): + t = datetime.fromisoformat(info["updated_at"]) + lines.append(f"• {name} — {t:%Y-%m-%d %H:%M}") + await msg.answer("\n".join(lines), reply_markup=artifacts_kb) + +async def cmd_artifacts_upload(msg: Message): + if not acquire_lock("artifacts"): + await msg.answer("⛔ Upload уже идёт", reply_markup=artifacts_kb) + return + + await msg.answer("📤 Upload…", reply_markup=artifacts_kb) + + async def worker(): + try: + rc, out = await run_cmd(["sudo", "/usr/local/bin/backup.py", "artifact-upload"], timeout=12*3600) + await msg.answer(("✅ OK\n" if rc == 0 else "❌ FAIL\n") + out, reply_markup=artifacts_kb) + finally: + release_lock("artifacts") + + asyncio.create_task(worker()) + +async def cmd_docker_status(msg: Message): + try: + if not DOCKER_MAP: + await msg.answer( + "⚠️ DOCKER_MAP пуст.\n" + "Контейнеры не обнаружены.", + reply_markup=docker_kb, + ) + return + + lines = ["🐳 Docker containers\n"] + + for alias, real in DOCKER_MAP.items(): + rc, raw = await run_cmd( + [ + "sudo", "docker", "inspect", + "-f", "{{.State.Status}}|{{.State.StartedAt}}", + real + ], + timeout=10, + ) + + if rc != 0: + lines.append(f"🔴 {alias}: inspect error") + continue + + raw = raw.strip() + if "|" not in raw: + lines.append(f"🟡 {alias}: invalid inspect output") + continue + + status, started = raw.split("|", 1) + up = container_uptime(started) + + icon = "🟢" if status == "running" else "🔴" + lines.append(f"{icon} {alias}: {status} ({up})") + + await msg.answer("\n".join(lines), reply_markup=docker_kb) + + except Exception as e: + # ⬅️ КРИТИЧЕСКИ ВАЖНО + await msg.answer( + "❌ Docker status crashed:\n" + f"```{type(e).__name__}: {e}```", + reply_markup=docker_kb, + parse_mode="Markdown", + ) + + +async def cmd_security(msg: Message): + await msg.answer(security(), reply_markup=system_kb) + +async def cmd_disks(msg: Message): + await msg.answer(disks(), reply_markup=system_kb) +# ===================== MENU HANDLERS ===================== + +@dp.message(F.text == "/start") +async def start(msg: Message): + if is_admin(msg): + await msg.answer("🏠 Главное меню", reply_markup=menu_kb) + +@dp.message(F.text == "⬅️ Назад") +async def back(msg: Message): + if is_admin(msg): + await msg.answer("🏠 Главное меню", reply_markup=menu_kb) + +@dp.message(F.text == "🩺 Health") +async def h(msg: Message): + if is_admin(msg): await cmd_health(msg) + +@dp.message(F.text == "📊 Статус") +async def st(msg: Message): + if is_admin(msg): await cmd_status(msg) + +@dp.message(F.text == "🐳 Docker") +async def dm(msg: Message): + if is_admin(msg): + await msg.answer("🐳 Docker", reply_markup=docker_kb) + +@dp.message(F.text == "📦 Backup") +async def bm(msg: Message): + if is_admin(msg): + await msg.answer("📦 Backup", reply_markup=backup_kb) + +@dp.message(F.text == "🧊 Artifacts") +async def am(msg: Message): + if is_admin(msg): + await msg.answer("🧊 Artifacts", reply_markup=artifacts_kb) + +@dp.message(F.text == "⚙️ System") +async def sm(msg: Message): + if is_admin(msg): + await msg.answer("⚙️ System", reply_markup=system_kb) + +@dp.message(F.text == "🔄 Restart") +async def dr(msg: Message): + if is_admin(msg): + await msg.answer( + "🔄 Выберите контейнер для рестарта:", + reply_markup=docker_inline_kb("restart") + ) + +@dp.message(F.text == "📜 Logs") +async def dl(msg: Message): + if is_admin(msg): + await msg.answer( + "📜 Выберите контейнер для логов:", + reply_markup=docker_inline_kb("logs") + ) + +@dp.message(F.text == "🐳 Status") +async def ds(msg: Message): + if is_admin(msg): await cmd_docker_status(msg) + +@dp.message(F.text == "📦 Status") +async def bs(msg: Message): + if is_admin(msg): await cmd_backup_status(msg) + +@dp.message(F.text == "📊 Repo stats") +async def rs(msg: Message): + if is_admin(msg): + await cmd_repo_stats(msg) + +@dp.message(F.text == "▶️ Run backup") +async def br(msg: Message): + if is_admin(msg): await cmd_backup_now(msg) + +@dp.message(F.text == "🧯 Restore help") +async def rh(msg: Message): + if is_admin(msg): + await msg.answer( + "🧯 Restore help\n\nrestic restore --target /restore", + reply_markup=backup_kb, + ) + +@dp.message(F.text == "🧊 Status") +async def ars(msg: Message): + if is_admin(msg): await cmd_artifacts_status(msg) + +@dp.message(F.text == "📤 Upload") +async def aru(msg: Message): + if is_admin(msg): await cmd_artifacts_upload(msg) + +@dp.message(F.text == "💽 Disks") +async def sd(msg: Message): + if is_admin(msg): await cmd_disks(msg) + +@dp.message(F.text == "🔐 Security") +async def sec(msg: Message): + if is_admin(msg): await cmd_security(msg) + +@dp.message(F.text == "ℹ️ Help") +async def help_cmd(msg: Message): + if not is_admin(msg): + return + + await msg.answer( + "ℹ️ **Help / Справка**\n\n" + "🩺 Health — быстрый health-check сервера\n" + "📊 Статус — общая загрузка сервера\n" + "🐳 Docker — управление контейнерами\n" + "📦 Backup — restic бэкапы\n" + "🧊 Artifacts — критичные образы (Clonezilla, NAND)\n" + "⚙️ System — диски, безопасность, reboot\n\n" + "Inline-кнопки используются для выбора контейнеров.", + reply_markup=menu_kb, + parse_mode="Markdown", + ) + + +# ===================== INLINE CALLBACKS ===================== + +@dp.callback_query(F.data.startswith("docker:")) +async def docker_callback(cb: CallbackQuery): + if cb.from_user.id != ADMIN_ID: + return + + _, action, alias = cb.data.split(":", 2) + real = DOCKER_MAP[alias] + + if action == "restart": + await cb.answer("Restarting…") + rc, out = await run_cmd(["sudo", "docker", "restart", real]) + + await cb.message.answer( + f"🔄 **{alias} restarted**\n```{out}```", + parse_mode="Markdown" + ) + + elif action == "logs": + await cb.answer("Loading logs…") + rc, out = await run_cmd( + ["sudo", "docker", "logs", "--tail", "80", real] + ) + + await cb.message.answer( + f"📜 **Logs: {alias}**\n```{out}```", + parse_mode="Markdown" + ) + +@dp.callback_query(F.data.startswith("snap:")) +async def snapshot_details(cb: CallbackQuery): + if cb.from_user.id != ADMIN_ID: + return + + snap_id = cb.data.split(":", 1)[1] + await cb.answer("Loading snapshot…") + + # получаем статистику snapshot + rc, raw = await run_cmd( + ["restic", "stats", snap_id, "--json"], + use_restic_env=True, + timeout=20 + ) + + if rc != 0: + await cb.message.answer(raw) + return + + stats = json.loads(raw) + + msg = ( + f"🧊 **Snapshot {snap_id}**\n\n" + f"📁 Files: {stats.get('total_file_count', 'n/a')}\n" + f"💾 Size: {stats.get('total_size', 0) / (1024**3):.2f} GiB\n\n" + "🧯 Restore:\n" + f"`restic restore {snap_id} --target /restore`\n" + ) + + back_kb = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="⬅️ Back to snapshots", + callback_data="snapback" + ) + ] + ] + ) + + await cb.message.answer(msg, reply_markup=back_kb, parse_mode="Markdown") + +@dp.callback_query(F.data == "snapback") +async def snapshot_back(cb: CallbackQuery): + await cb.answer() + # просто вызываем статус снова + fake_msg = cb.message + await cmd_backup_status(fake_msg) + + +# ===================== WATCHDOG / START ===================== + +async def notify_start(): + await bot.send_message( + ADMIN_ID, + f"🤖 Bot started\n🖥 {socket.gethostname()}\n🕒 {datetime.now():%Y-%m-%d %H:%M}", + reply_markup=menu_kb, + ) + +async def main(): + global DOCKER_MAP + + DOCKER_MAP = await discover_containers(cfg) + await notify_start() + await dp.start_polling(bot) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docker_watchdog.py b/docker_watchdog.py new file mode 100644 index 0000000..ada2e97 --- /dev/null +++ b/docker_watchdog.py @@ -0,0 +1,13 @@ +import asyncio, subprocess + +async def docker_watchdog(cfg, notify, bot, chat_id): + last = {} + while True: + for alias, real in cfg["docker"]["containers"].items(): + state = subprocess.getoutput( + f"docker inspect -f '{{{{.State.Status}}}}' {real}" + ) + if last.get(alias) != state: + await notify(bot, chat_id, f"🐳 {alias}: {state}") + last[alias] = state + await asyncio.sleep(120) diff --git a/health.py b/health.py new file mode 100644 index 0000000..23b75df --- /dev/null +++ b/health.py @@ -0,0 +1,31 @@ +import subprocess, psutil + +def health(cfg): + lines = ["🩺 Health check\n"] + + try: + subprocess.check_output(["restic", "snapshots"], timeout=10) + lines.append("🟢 Backup repo reachable") + except Exception: + lines.append("🔴 Backup repo unreachable") + + for alias, real in cfg["docker"]["containers"].items(): + out = subprocess.getoutput( + f"docker inspect -f '{{{{.State.Status}}}}' {real}" + ) + if out.strip() != "running": + lines.append(f"🔴 {alias} down") + else: + lines.append(f"🟢 {alias} OK") + + disk = psutil.disk_usage("/mnt/data") + usage = disk.percent + if usage > cfg["thresholds"]["disk_warn"]: + lines.append(f"🟡 Disk usage {usage}%") + else: + lines.append(f"🟢 Disk {usage}%") + + load = psutil.getloadavg()[0] + lines.append(f"{'🟢' if load < cfg['thresholds']['load_warn'] else '🟡'} Load {load}") + + return "\n".join(lines) diff --git a/locks.py b/locks.py new file mode 100644 index 0000000..6fd6d7c --- /dev/null +++ b/locks.py @@ -0,0 +1,17 @@ +from pathlib import Path +import time + +LOCK_DIR = Path("/var/run/tg-bot") +LOCK_DIR.mkdir(exist_ok=True) + +def acquire(name: str) -> bool: + path = LOCK_DIR / f"{name}.lock" + if path.exists(): + return False + path.write_text(str(time.time())) + return True + +def release(name: str): + path = LOCK_DIR / f"{name}.lock" + if path.exists(): + path.unlink() diff --git a/notify.py b/notify.py new file mode 100644 index 0000000..49e34c6 --- /dev/null +++ b/notify.py @@ -0,0 +1,7 @@ +from aiogram import Bot + +async def notify(bot: Bot, chat_id: int, text: str): + try: + await bot.send_message(chat_id, text) + except Exception: + pass diff --git a/system_checks.py b/system_checks.py new file mode 100644 index 0000000..d86f4f8 --- /dev/null +++ b/system_checks.py @@ -0,0 +1,107 @@ +# system_checks.py +import subprocess + + +def _cmd(cmd: str) -> str: + """ + Safe shell command runner + """ + try: + return subprocess.getoutput(cmd) + except Exception as e: + return f"ERROR: {e}" + + +# ---------- SECURITY ---------- + +def security() -> str: + out = _cmd("sshd -T | grep -i '^permitrootlogin'") + + if not out or "ERROR:" in out: + return "🔐 Security\n\n⚠️ permitrootlogin not found" + + if "no" in out.lower(): + return "🔐 Security\n\n🟢 Root login disabled" + + return "🔐 Security\n\n🔴 Root login ENABLED" + + +# ---------- DISKS ---------- + +def list_disks() -> list[str]: + out = _cmd("lsblk -dn -o NAME,TYPE") + + disks = [] + for line in out.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + name, typ = parts + if typ == "disk": + disks.append(f"/dev/{name}") + + return disks + + +def smart_health(dev: str) -> str: + out = _cmd(f"smartctl -H {dev}") + + if not out or "ERROR:" in out: + return "⚠️ ERROR" + + if "PASSED" in out: + return "🟢 PASSED" + if "FAILED" in out: + return "🔴 FAILED" + + return "⚠️ UNKNOWN" + + +def disk_temperature(dev: str) -> str: + out = _cmd(f"smartctl -A {dev}") + + if not out or "ERROR:" in out: + return "n/a" + + # NVMe + for line in out.splitlines(): + if "Temperature:" in line and "Celsius" in line: + try: + temp = int("".join(filter(str.isdigit, line))) + return f"{temp}°C" + except Exception: + pass + + # SATA attributes + for line in out.splitlines(): + if line.strip().startswith(("194", "190")): + parts = line.split() + for p in parts[::-1]: + if p.isdigit(): + return f"{p}°C" + + return "n/a" + +def disks() -> str: + disks = list_disks() + + if not disks: + return "💽 Disks\n\n❌ No disks found" + + lines = ["💽 Disks (SMART)\n"] + + for d in disks: + health = smart_health(d) + temp = disk_temperature(d) + + icon = "🟢" + if temp != "n/a": + t = int(temp.replace("°C", "")) + if t > 50: + icon = "🔴" + elif t > 40: + icon = "🟡" + + lines.append(f"{icon} {d} — {health}, 🌡 {temp}") + + return "\n".join(lines)