Refactor bot and integrate services
This commit is contained in:
16
app.py
Normal file
16
app.py
Normal 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
10
auth.py
Normal 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
|
||||||
27
backups.py
27
backups.py
@@ -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
782
bot.py
@@ -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
31
config.example.yaml
Normal 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
24
config.py
Normal 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
|
||||||
@@ -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
1
handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
74
handlers/artifacts.py
Normal file
74
handlers/artifacts.py
Normal 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
163
handlers/backup.py
Normal 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
87
handlers/callbacks.py
Normal 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
81
handlers/docker.py
Normal 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
24
handlers/help.py
Normal 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
41
handlers/menu.py
Normal 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
69
handlers/status.py
Normal 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
18
handlers/system.py
Normal 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
68
keyboards.py
Normal 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
23
lock_utils.py
Normal 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()
|
||||||
17
locks.py
17
locks.py
@@ -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
36
main.py
Normal 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())
|
||||||
1
services/__init__.py
Normal file
1
services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
@@ -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
58
services/backup.py
Normal 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
114
services/docker.py
Normal 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)
|
||||||
@@ -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}")
|
||||||
@@ -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
24
services/runner.py
Normal 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
69
services/system.py
Normal 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
3
state.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
DOCKER_MAP: Dict[str, str] = {}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user