From 588127c076613dcbfb1e7021acf34c4a6dea48d7 Mon Sep 17 00:00:00 2001 From: benya Date: Sat, 7 Feb 2026 22:10:08 +0300 Subject: [PATCH] Refactor bot and integrate services --- app.py | 16 + auth.py | 10 + backups.py | 27 - bot.py | 782 +------------------------- config.example.yaml | 31 + config.py | 24 + docker_watchdog.py | 13 - handlers/__init__.py | 1 + handlers/artifacts.py | 74 +++ handlers/backup.py | 163 ++++++ handlers/callbacks.py | 87 +++ handlers/docker.py | 81 +++ handlers/help.py | 24 + handlers/menu.py | 41 ++ handlers/status.py | 69 +++ handlers/system.py | 18 + keyboards.py | 68 +++ lock_utils.py | 23 + locks.py | 17 - main.py | 36 ++ req.txt | 3 + services/__init__.py | 1 + artifacts.py => services/artifacts.py | 5 +- services/backup.py | 58 ++ services/docker.py | 114 ++++ health.py => services/health.py | 25 +- notify.py => services/notify.py | 1 + services/runner.py | 24 + services/system.py | 69 +++ state.py | 3 + system_checks.py | 2 +- 31 files changed, 1061 insertions(+), 849 deletions(-) create mode 100644 app.py create mode 100644 auth.py delete mode 100644 backups.py create mode 100644 config.example.yaml create mode 100644 config.py delete mode 100644 docker_watchdog.py create mode 100644 handlers/__init__.py create mode 100644 handlers/artifacts.py create mode 100644 handlers/backup.py create mode 100644 handlers/callbacks.py create mode 100644 handlers/docker.py create mode 100644 handlers/help.py create mode 100644 handlers/menu.py create mode 100644 handlers/status.py create mode 100644 handlers/system.py create mode 100644 keyboards.py create mode 100644 lock_utils.py delete mode 100644 locks.py create mode 100644 main.py create mode 100644 req.txt create mode 100644 services/__init__.py rename artifacts.py => services/artifacts.py (86%) create mode 100644 services/backup.py create mode 100644 services/docker.py rename health.py => services/health.py (51%) rename notify.py => services/notify.py (99%) create mode 100644 services/runner.py create mode 100644 services/system.py create mode 100644 state.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..c98e79c --- /dev/null +++ b/app.py @@ -0,0 +1,16 @@ +from aiogram import Bot, Dispatcher +from config import load_cfg, load_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() diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..750f3ee --- /dev/null +++ b/auth.py @@ -0,0 +1,10 @@ +from aiogram.types import Message, CallbackQuery +from app import ADMIN_ID + + +def is_admin_msg(msg: Message) -> bool: + return msg.from_user and msg.from_user.id == ADMIN_ID + + +def is_admin_cb(cb: CallbackQuery) -> bool: + return cb.from_user and cb.from_user.id == ADMIN_ID diff --git a/backups.py b/backups.py deleted file mode 100644 index 9e90ba1..0000000 --- a/backups.py +++ /dev/null @@ -1,27 +0,0 @@ -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 index 1613ac4..bc14e86 100755 --- a/bot.py +++ b/bot.py @@ -1,787 +1,7 @@ #!/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, -) +from main import main -# ===================== 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/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..0c7976b --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,31 @@ +telegram: + token: "YOUR_TELEGRAM_BOT_TOKEN" + admin_id: 123456789 + +paths: + # JSON state file for artifacts + artifact_state: "/opt/tg-bot/state.json" + # Optional env file with RESTIC_* variables + restic_env: "/etc/restic/restic.env" + +thresholds: + disk_warn: 80 + load_warn: 2.0 + +docker: + # If true, discover containers by name/label + autodiscovery: true + # Enable docker watchdog notifications + watchdog: true + # Optional label filter: "key=value" + label: "" + # Name substrings used for discovery + match: + - "tg-" + - "bot" + # Alias -> real container name (overrides autodiscovery) + aliases: + tg-admin-bot: "tg-admin-bot" + # Explicit list used by legacy modules + containers: + tg-admin-bot: "tg-admin-bot" diff --git a/config.py b/config.py new file mode 100644 index 0000000..c1c07e7 --- /dev/null +++ b/config.py @@ -0,0 +1,24 @@ +from typing import Dict +import yaml + +CONFIG_FILE = "/opt/tg-bot/config.yaml" + + +def load_cfg(path: str = CONFIG_FILE) -> dict: + with open(path) as f: + return yaml.safe_load(f) + + +def load_env(env_file: str) -> Dict[str, str]: + env: Dict[str, str] = {} + 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 diff --git a/docker_watchdog.py b/docker_watchdog.py deleted file mode 100644 index ada2e97..0000000 --- a/docker_watchdog.py +++ /dev/null @@ -1,13 +0,0 @@ -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/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/handlers/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/handlers/artifacts.py b/handlers/artifacts.py new file mode 100644 index 0000000..aae7f3a --- /dev/null +++ b/handlers/artifacts.py @@ -0,0 +1,74 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from aiogram import F +from aiogram.types import Message +from app import dp, ARTIFACT_STATE +from auth import is_admin_msg +from keyboards import artifacts_kb +from lock_utils import acquire_lock, release_lock +from services.artifacts import artifact_last +from services.runner import run_cmd + + +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_last(msg: Message): + p = Path(ARTIFACT_STATE) + if not p.exists(): + await msg.answer("❌ state.json не найден", reply_markup=artifacts_kb) + return + try: + text = await asyncio.to_thread(artifact_last, ARTIFACT_STATE) + except Exception as e: + await msg.answer(f"❌ Last artifact failed: {type(e).__name__}: {e}", reply_markup=artifacts_kb) + return + await msg.answer(text, 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()) + + +@dp.message(F.text == "🧉 Status") +async def ars(msg: Message): + if is_admin_msg(msg): + await cmd_artifacts_status(msg) + + +@dp.message(F.text == "🧉 Last artifact") +async def ala(msg: Message): + if is_admin_msg(msg): + await cmd_artifacts_last(msg) + + +@dp.message(F.text == "📤 Upload") +async def aru(msg: Message): + if is_admin_msg(msg): + await cmd_artifacts_upload(msg) diff --git a/handlers/backup.py b/handlers/backup.py new file mode 100644 index 0000000..13a290c --- /dev/null +++ b/handlers/backup.py @@ -0,0 +1,163 @@ +import asyncio +import json +from datetime import datetime +from aiogram import F +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton +from app import dp +from auth import is_admin_msg +from keyboards import backup_kb +from lock_utils import acquire_lock, release_lock +from services.backup import backup_badge, last_backup, restore_help +from services.runner import run_cmd + + +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 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_last_backup(msg: Message): + try: + text = await asyncio.to_thread(last_backup) + except Exception as e: + await msg.answer(f"❌ Last backup failed: {type(e).__name__}: {e}", reply_markup=backup_kb) + return + await msg.answer(text, reply_markup=backup_kb) + + +@dp.message(F.text == "📦 Status") +async def bs(msg: Message): + if is_admin_msg(msg): + await cmd_backup_status(msg) + + +@dp.message(F.text == "📊 Repo stats") +async def rs(msg: Message): + if is_admin_msg(msg): + await cmd_repo_stats(msg) + + +@dp.message(F.text == "📦 Last backup") +async def lb(msg: Message): + if is_admin_msg(msg): + await cmd_last_backup(msg) + + +@dp.message(F.text == "▶️ Run backup") +async def br(msg: Message): + if is_admin_msg(msg): + await cmd_backup_now(msg) + + +@dp.message(F.text == "🧯 Restore help") +async def rh(msg: Message): + if is_admin_msg(msg): + await msg.answer(restore_help(), reply_markup=backup_kb) diff --git a/handlers/callbacks.py b/handlers/callbacks.py new file mode 100644 index 0000000..af100d2 --- /dev/null +++ b/handlers/callbacks.py @@ -0,0 +1,87 @@ +import json +from aiogram import F +from aiogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from app import dp, ADMIN_ID +from services.runner import run_cmd +from state import DOCKER_MAP +from handlers.backup import cmd_backup_status + + +@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) diff --git a/handlers/docker.py b/handlers/docker.py new file mode 100644 index 0000000..117b22a --- /dev/null +++ b/handlers/docker.py @@ -0,0 +1,81 @@ +from aiogram import F +from aiogram.types import Message +from app import dp +from auth import is_admin_msg +from keyboards import docker_kb, docker_inline_kb +from services.docker import container_uptime +from services.runner import run_cmd +from state import DOCKER_MAP + + +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", + ) + + +@dp.message(F.text == "🔄 Restart") +async def dr(msg: Message): + if is_admin_msg(msg): + await msg.answer( + "🔄 Выберите контейнер для рестарта:", + reply_markup=docker_inline_kb("restart") + ) + + +@dp.message(F.text == "📜 Logs") +async def dl(msg: Message): + if is_admin_msg(msg): + await msg.answer( + "📜 Выберите контейнер для логов:", + reply_markup=docker_inline_kb("logs") + ) + + +@dp.message(F.text == "🐳 Status") +async def ds(msg: Message): + if is_admin_msg(msg): + await cmd_docker_status(msg) diff --git a/handlers/help.py b/handlers/help.py new file mode 100644 index 0000000..272b803 --- /dev/null +++ b/handlers/help.py @@ -0,0 +1,24 @@ +from aiogram import F +from aiogram.types import Message +from app import dp +from auth import is_admin_msg +from keyboards import menu_kb + + +@dp.message(F.text == "ℹ️ Help") +async def help_cmd(msg: Message): + if not is_admin_msg(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", + ) diff --git a/handlers/menu.py b/handlers/menu.py new file mode 100644 index 0000000..5647b9c --- /dev/null +++ b/handlers/menu.py @@ -0,0 +1,41 @@ +from aiogram import F +from aiogram.types import Message +from app import dp +from auth import is_admin_msg +from keyboards import menu_kb, docker_kb, backup_kb, artifacts_kb, system_kb + + +@dp.message(F.text == "/start") +async def start(msg: Message): + if is_admin_msg(msg): + await msg.answer("🏠 Главное меню", reply_markup=menu_kb) + + +@dp.message(F.text == "⬅️ Назад") +async def back(msg: Message): + if is_admin_msg(msg): + await msg.answer("🏠 Главное меню", reply_markup=menu_kb) + + +@dp.message(F.text == "🐳 Docker") +async def dm(msg: Message): + if is_admin_msg(msg): + await msg.answer("🐳 Docker", reply_markup=docker_kb) + + +@dp.message(F.text == "📦 Backup") +async def bm(msg: Message): + if is_admin_msg(msg): + await msg.answer("📦 Backup", reply_markup=backup_kb) + + +@dp.message(F.text == "🧉 Artifacts") +async def am(msg: Message): + if is_admin_msg(msg): + await msg.answer("🧉 Artifacts", reply_markup=artifacts_kb) + + +@dp.message(F.text == "⚙️ System") +async def sm(msg: Message): + if is_admin_msg(msg): + await msg.answer("⚙️ System", reply_markup=system_kb) diff --git a/handlers/status.py b/handlers/status.py new file mode 100644 index 0000000..87d9e90 --- /dev/null +++ b/handlers/status.py @@ -0,0 +1,69 @@ +import asyncio +import socket +import time +import psutil +from aiogram import F +from aiogram.types import Message +from app import dp, cfg +from auth import is_admin_msg +from keyboards import menu_kb +from services.system import format_disks +from services.health import health +from state import DOCKER_MAP + + +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(): + try: + text = await asyncio.to_thread(health, cfg, DOCKER_MAP) + except Exception as e: + await msg.answer(f"❌ Health failed: {type(e).__name__}: {e}", reply_markup=menu_kb) + return + await msg.answer(text, reply_markup=menu_kb) + + asyncio.create_task(worker()) + + +@dp.message(F.text == "🩺 Health") +async def h(msg: Message): + if is_admin_msg(msg): + await cmd_health(msg) + + +@dp.message(F.text == "📊 Статус") +async def st(msg: Message): + if is_admin_msg(msg): + await cmd_status(msg) diff --git a/handlers/system.py b/handlers/system.py new file mode 100644 index 0000000..c62eefe --- /dev/null +++ b/handlers/system.py @@ -0,0 +1,18 @@ +from aiogram import F +from aiogram.types import Message +from app import dp +from auth import is_admin_msg +from keyboards import system_kb +from system_checks import security, disks + + +@dp.message(F.text == "💽 Disks") +async def sd(msg: Message): + if is_admin_msg(msg): + await msg.answer(disks(), reply_markup=system_kb) + + +@dp.message(F.text == "🔐 Security") +async def sec(msg: Message): + if is_admin_msg(msg): + await msg.answer(security(), reply_markup=system_kb) diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..fa65393 --- /dev/null +++ b/keyboards.py @@ -0,0 +1,68 @@ +from aiogram.types import ( + ReplyKeyboardMarkup, + KeyboardButton, + InlineKeyboardMarkup, + InlineKeyboardButton, +) +from state import DOCKER_MAP + +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="📦 Last backup")], + [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="🧉 Last artifact")], + [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) diff --git a/lock_utils.py b/lock_utils.py new file mode 100644 index 0000000..0770639 --- /dev/null +++ b/lock_utils.py @@ -0,0 +1,23 @@ +from pathlib import Path +import time + +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() diff --git a/locks.py b/locks.py deleted file mode 100644 index 6fd6d7c..0000000 --- a/locks.py +++ /dev/null @@ -1,17 +0,0 @@ -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/main.py b/main.py new file mode 100644 index 0000000..559c120 --- /dev/null +++ b/main.py @@ -0,0 +1,36 @@ +import asyncio +import socket +from datetime import datetime +from app import bot, dp, cfg, ADMIN_ID +from keyboards import menu_kb +from services.docker import discover_containers, docker_watchdog +from services.notify import notify +import state +import handlers.menu +import handlers.status +import handlers.docker +import handlers.backup +import handlers.artifacts +import handlers.system +import handlers.help +import handlers.callbacks + + +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(): + state.DOCKER_MAP = await discover_containers(cfg) + if cfg.get("docker", {}).get("watchdog", True): + asyncio.create_task(docker_watchdog(state.DOCKER_MAP, notify, bot, ADMIN_ID)) + await notify_start() + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/req.txt b/req.txt new file mode 100644 index 0000000..89626fb --- /dev/null +++ b/req.txt @@ -0,0 +1,3 @@ +aiogram +psutil +PyYAML diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/artifacts.py b/services/artifacts.py similarity index 86% rename from artifacts.py rename to services/artifacts.py index 1d3d667..9024dc5 100644 --- a/artifacts.py +++ b/services/artifacts.py @@ -2,7 +2,8 @@ import json from datetime import datetime from pathlib import Path -def artifact_last(state_file): + +def artifact_last(state_file: str) -> str: data = json.loads(Path(state_file).read_text()) items = sorted( data.items(), @@ -14,7 +15,7 @@ def artifact_last(state_file): age_h = int((datetime.now() - t).total_seconds() / 3600) return ( - "🧊 Last artifact\n\n" + "🧉 Last artifact\n\n" f"{name}\n" f"Updated: {t:%Y-%m-%d %H:%M}\n" f"Age: {age_h}h" diff --git a/services/backup.py b/services/backup.py new file mode 100644 index 0000000..da92723 --- /dev/null +++ b/services/backup.py @@ -0,0 +1,58 @@ +from datetime import datetime, timezone +from typing import Optional +import json +import subprocess +from services.runner import run_cmd + + +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" + + +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] + + +def last_backup() -> str: + 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() -> str: + return ( + "🧯 Restore help\n\n" + "Example:\n" + "restic restore --target /restore" + ) diff --git a/services/docker.py b/services/docker.py new file mode 100644 index 0000000..7ed9c4e --- /dev/null +++ b/services/docker.py @@ -0,0 +1,114 @@ +import asyncio +from datetime import datetime, timezone +from typing import Dict +from services.runner import run_cmd + + +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" + + +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 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 docker_watchdog(container_map, notify, bot, chat_id): + last = {} + while True: + for alias, real in container_map.items(): + rc, state = await run_cmd( + ["docker", "inspect", "-f", "{{.State.Status}}", real], + timeout=10 + ) + if rc != 0: + state = "error" + state = state.strip() + 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/services/health.py similarity index 51% rename from health.py rename to services/health.py index 23b75df..783c43f 100644 --- a/health.py +++ b/services/health.py @@ -1,6 +1,13 @@ -import subprocess, psutil +import subprocess +import psutil +from services.system import worst_disk_usage -def health(cfg): + +def _containers_from_cfg(cfg) -> dict: + return cfg.get("docker", {}).get("containers", {}) + + +def health(cfg, container_map: dict | None = None) -> str: lines = ["🩺 Health check\n"] try: @@ -9,7 +16,8 @@ def health(cfg): except Exception: lines.append("🔴 Backup repo unreachable") - for alias, real in cfg["docker"]["containers"].items(): + containers = container_map if container_map is not None else _containers_from_cfg(cfg) + for alias, real in containers.items(): out = subprocess.getoutput( f"docker inspect -f '{{{{.State.Status}}}}' {real}" ) @@ -18,12 +26,13 @@ def health(cfg): 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}%") + usage, mount = worst_disk_usage() + if usage is None: + lines.append("⚠️ Disk n/a") + elif usage > cfg["thresholds"]["disk_warn"]: + lines.append(f"🟡 Disk {usage}% ({mount})") else: - lines.append(f"🟢 Disk {usage}%") + lines.append(f"🟢 Disk {usage}% ({mount})") load = psutil.getloadavg()[0] lines.append(f"{'🟢' if load < cfg['thresholds']['load_warn'] else '🟡'} Load {load}") diff --git a/notify.py b/services/notify.py similarity index 99% rename from notify.py rename to services/notify.py index 49e34c6..675bc68 100644 --- a/notify.py +++ b/services/notify.py @@ -1,5 +1,6 @@ from aiogram import Bot + async def notify(bot: Bot, chat_id: int, text: str): try: await bot.send_message(chat_id, text) diff --git a/services/runner.py b/services/runner.py new file mode 100644 index 0000000..4cf1b45 --- /dev/null +++ b/services/runner.py @@ -0,0 +1,24 @@ +import asyncio +import os +from app import RESTIC_ENV + + +async def run_cmd(cmd: list[str], *, use_restic_env: bool = False, timeout: int = 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" diff --git a/services/system.py b/services/system.py new file mode 100644 index 0000000..462676b --- /dev/null +++ b/services/system.py @@ -0,0 +1,69 @@ +import psutil + + +def format_disks() -> str: + 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) + + +def worst_disk_usage() -> tuple[int | None, str | None]: + parts = psutil.disk_partitions(all=False) + skip_prefixes = ( + "/snap", + "/proc", + "/sys", + "/run", + "/boot/efi", + ) + + worst_percent = None + worst_mount = None + + for p in parts: + mp = p.mountpoint + if mp.startswith(skip_prefixes): + continue + try: + usage = psutil.disk_usage(mp) + except PermissionError: + continue + if worst_percent is None or usage.percent > worst_percent: + worst_percent = int(usage.percent) + worst_mount = mp + + return worst_percent, worst_mount diff --git a/state.py b/state.py new file mode 100644 index 0000000..690e695 --- /dev/null +++ b/state.py @@ -0,0 +1,3 @@ +from typing import Dict + +DOCKER_MAP: Dict[str, str] = {} diff --git a/system_checks.py b/system_checks.py index d86f4f8..43b2934 100644 --- a/system_checks.py +++ b/system_checks.py @@ -1,4 +1,3 @@ -# system_checks.py import subprocess @@ -82,6 +81,7 @@ def disk_temperature(dev: str) -> str: return "n/a" + def disks() -> str: disks = list_disks()