Refactor bot and integrate services

This commit is contained in:
2026-02-07 22:10:08 +03:00
parent 492e3bd3cf
commit 588127c076
31 changed files with 1061 additions and 849 deletions

16
app.py Normal file
View File

@@ -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()

10
auth.py Normal file
View File

@@ -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

View File

@@ -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 <snapshot_id> --target /restore"
)

782
bot.py
View File

@@ -1,787 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import json from main import main
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 <ID> --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__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

31
config.example.yaml Normal file
View File

@@ -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"

24
config.py Normal file
View File

@@ -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

View File

@@ -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)

1
handlers/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = []

74
handlers/artifacts.py Normal file
View File

@@ -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)

163
handlers/backup.py Normal file
View File

@@ -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)

87
handlers/callbacks.py Normal file
View File

@@ -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)

81
handlers/docker.py Normal file
View File

@@ -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)

24
handlers/help.py Normal file
View File

@@ -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",
)

41
handlers/menu.py Normal file
View File

@@ -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)

69
handlers/status.py Normal file
View File

@@ -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)

18
handlers/system.py Normal file
View File

@@ -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)

68
keyboards.py Normal file
View File

@@ -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)

23
lock_utils.py Normal file
View File

@@ -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()

View File

@@ -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()

36
main.py Normal file
View File

@@ -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())

3
req.txt Normal file
View File

@@ -0,0 +1,3 @@
aiogram
psutil
PyYAML

1
services/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = []

View File

@@ -2,7 +2,8 @@ import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
def artifact_last(state_file):
def artifact_last(state_file: str) -> str:
data = json.loads(Path(state_file).read_text()) data = json.loads(Path(state_file).read_text())
items = sorted( items = sorted(
data.items(), data.items(),
@@ -14,7 +15,7 @@ def artifact_last(state_file):
age_h = int((datetime.now() - t).total_seconds() / 3600) age_h = int((datetime.now() - t).total_seconds() / 3600)
return ( return (
"🧊 Last artifact\n\n" "🧉 Last artifact\n\n"
f"{name}\n" f"{name}\n"
f"Updated: {t:%Y-%m-%d %H:%M}\n" f"Updated: {t:%Y-%m-%d %H:%M}\n"
f"Age: {age_h}h" f"Age: {age_h}h"

58
services/backup.py Normal file
View File

@@ -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 <snapshot_id> --target /restore"
)

114
services/docker.py Normal file
View File

@@ -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)

View File

@@ -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"] lines = ["🩺 Health check\n"]
try: try:
@@ -9,7 +16,8 @@ def health(cfg):
except Exception: except Exception:
lines.append("🔴 Backup repo unreachable") 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( out = subprocess.getoutput(
f"docker inspect -f '{{{{.State.Status}}}}' {real}" f"docker inspect -f '{{{{.State.Status}}}}' {real}"
) )
@@ -18,12 +26,13 @@ def health(cfg):
else: else:
lines.append(f"🟢 {alias} OK") lines.append(f"🟢 {alias} OK")
disk = psutil.disk_usage("/mnt/data") usage, mount = worst_disk_usage()
usage = disk.percent if usage is None:
if usage > cfg["thresholds"]["disk_warn"]: lines.append("⚠️ Disk n/a")
lines.append(f"🟡 Disk usage {usage}%") elif usage > cfg["thresholds"]["disk_warn"]:
lines.append(f"🟡 Disk {usage}% ({mount})")
else: else:
lines.append(f"🟢 Disk {usage}%") lines.append(f"🟢 Disk {usage}% ({mount})")
load = psutil.getloadavg()[0] load = psutil.getloadavg()[0]
lines.append(f"{'🟢' if load < cfg['thresholds']['load_warn'] else '🟡'} Load {load}") lines.append(f"{'🟢' if load < cfg['thresholds']['load_warn'] else '🟡'} Load {load}")

View File

@@ -1,5 +1,6 @@
from aiogram import Bot from aiogram import Bot
async def notify(bot: Bot, chat_id: int, text: str): async def notify(bot: Bot, chat_id: int, text: str):
try: try:
await bot.send_message(chat_id, text) await bot.send_message(chat_id, text)

24
services/runner.py Normal file
View File

@@ -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"

69
services/system.py Normal file
View File

@@ -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

3
state.py Normal file
View File

@@ -0,0 +1,3 @@
from typing import Dict
DOCKER_MAP: Dict[str, str] = {}

View File

@@ -1,4 +1,3 @@
# system_checks.py
import subprocess import subprocess
@@ -82,6 +81,7 @@ def disk_temperature(dev: str) -> str:
return "n/a" return "n/a"
def disks() -> str: def disks() -> str:
disks = list_disks() disks = list_disks()