version 1.2: Telegram-бот, мониторинг сервера на дашборде, README

- bot.py: Telegram-бот (/users, /create, /delete, /warp, /stats, /dashboard)
- install-bot.sh: установщик бота (systemd, конфиг /etc/mita/bot.json)
- app.py: API /api/bot/{status,install,uninstall,config}, /api/stats (CPU/RAM/диск)
- templates: раздел «Telegram бот» в UI, мониторинг CPU/RAM/диск на дашборде (2s auto-refresh)
- mita-ctl.sh: пункт 11 «Telegram-бот», удаление бота в полном uninstall
- install.sh: опциональная установка бота с инструкцией
- README.md: описание проекта и дорожная карта
This commit is contained in:
2026-06-26 23:37:05 +03:00
parent d65f80cf9e
commit 33bdcdce68
9 changed files with 1432 additions and 49 deletions
+198 -20
View File
@@ -2,7 +2,8 @@
"""
mita Web Panel — Flask backend
"""
import os, json, subprocess, secrets, string, random, ipaddress, socket, time, logging
import os, json, re, subprocess, secrets, string, random, ipaddress, socket, time, logging
import psutil
from datetime import datetime, timedelta
from functools import wraps
from pathlib import Path
@@ -174,14 +175,14 @@ def get_warp_ip():
return "недоступен"
def get_traffic_stats():
"""Возвращает трафик по всем пользователям за 7 и 30 дней."""
"""Возвращает трафик и статус активности по всем пользователям."""
raw = mita_cmd("get", "users")
week_total = month_total = 0.0
users_stats = []
today = datetime.now().date()
# mita get users output format (v3.x):
# USER LAST ACTIVE 1 DAY DOWN 1 DAY UP 30 DAYS DOWN 30 DAYS UP
# username 2024-01-01 1.2MiB 0.5MiB 12.3GiB 4.5GiB
# mita get users output format:
# User LastActive 1DayDown 1DayUp 7DaysDown 7DaysUp 30DaysDown 30DaysUp
lines = raw.splitlines()
data_lines = []
for line in lines:
@@ -191,19 +192,50 @@ def get_traffic_stats():
data_lines.append(line)
for line in data_lines:
# Split on 2+ spaces to handle names with spaces
import re as _re
parts = _re.split(r"\s{2,}", line.strip())
parts = re.split(r"\s{2,}", line.strip())
if len(parts) < 3:
# Try single-space split as fallback
parts = line.split()
if len(parts) < 3:
continue
try:
name = parts[0]
# Find traffic columns — they contain units like MiB, GiB, B, KiB
traffic_parts = [p for p in parts[1:] if any(u in p for u in ["B","iB","KiB","MiB","GiB","TiB"])]
if len(traffic_parts) >= 4:
# Parse LAST ACTIVE field (parts[1])
last_active_raw = parts[1].strip() if len(parts) > 1 else ""
online = False
last_active_display = "никогда"
if last_active_raw and last_active_raw.lower() not in ("never", "-", "n/a", "никогда"):
try:
la_date = datetime.strptime(last_active_raw[:10], "%Y-%m-%d").date()
delta = (today - la_date).days
if delta == 0:
online = True
last_active_display = "сегодня"
elif delta == 1:
last_active_display = "вчера"
elif delta < 7:
last_active_display = f"{delta} дн. назад"
elif delta < 30:
last_active_display = f"{delta // 7} нед. назад"
else:
last_active_display = f"{delta} дн. назад"
except Exception:
last_active_display = last_active_raw
# Find traffic columns — contain units like MiB, GiB, KiB, B
# mita output: 1DayDown 1DayUp 7DaysDown 7DaysUp 30DaysDown 30DaysUp
traffic_parts = [p for p in parts[1:] if any(u in p for u in ["TiB","GiB","MiB","KiB","iB","B"])]
d1_down = d1_up = d7_down = d7_up = d30_down = d30_up = 0.0
if len(traffic_parts) >= 6:
d1_down = _parse_traffic(traffic_parts[0])
d1_up = _parse_traffic(traffic_parts[1])
d7_down = _parse_traffic(traffic_parts[2])
d7_up = _parse_traffic(traffic_parts[3])
d30_down = _parse_traffic(traffic_parts[4])
d30_up = _parse_traffic(traffic_parts[5])
elif len(traffic_parts) >= 4:
d1_down = _parse_traffic(traffic_parts[0])
d1_up = _parse_traffic(traffic_parts[1])
d30_down = _parse_traffic(traffic_parts[2])
d30_up = _parse_traffic(traffic_parts[3])
elif len(traffic_parts) >= 2:
@@ -211,12 +243,17 @@ def get_traffic_stats():
d30_up = _parse_traffic(traffic_parts[1])
else:
continue
d7_approx = (d30_down + d30_up) / 30 * 7
d7_bytes = d7_down + d7_up if (d7_down + d7_up) > 0 else (d30_down + d30_up) / 30 * 7
month_total += d30_down + d30_up
week_total += d7_approx
week_total += d7_bytes
users_stats.append({
"name": name,
"month_mb": round((d30_down + d30_up) / 1024 / 1024, 1),
"name": name,
"online": online,
"last_active": last_active_display,
"day_mb": round((d1_down + d1_up) / 1024 / 1024, 2),
"week_mb": round(d7_bytes / 1024 / 1024, 2),
"month_mb": round((d30_down + d30_up) / 1024 / 1024, 2),
})
except Exception:
continue
@@ -443,6 +480,19 @@ def api_dashboard():
"mita_running": _mita_running(),
})
@app.route(f"{BASE}/api/stats")
@login_required
def api_stats():
return jsonify({
"cpu": psutil.cpu_percent(interval=0.5),
"ram_pct": psutil.virtual_memory().percent,
"ram_used": psutil.virtual_memory().used,
"ram_total": psutil.virtual_memory().total,
"disk_pct": psutil.disk_usage("/").percent,
"disk_used": psutil.disk_usage("/").used,
"disk_total": psutil.disk_usage("/").total,
})
def _mita_running():
try:
r = subprocess.run(["systemctl","is-active","mita"],
@@ -451,6 +501,16 @@ def _mita_running():
except Exception:
return False
# ── API: users stats (traffic + online status) ────────────────────────────────
@app.route(f"{BASE}/api/users/stats")
@login_required
def api_users_stats():
try:
stats = get_traffic_stats()
return jsonify({"users": stats["users"]})
except Exception as e:
return jsonify({"users": [], "error": str(e)})
# ── API: users ────────────────────────────────────────────────────────────────
@app.route(f"{BASE}/api/users", methods=["GET"])
@login_required
@@ -940,7 +1000,7 @@ def api_fail2ban_configure():
# Write fail2ban filter for mita-panel
filter_content = """[Definition]
failregex = ^.*"POST /[^"]+/login[^"]*" 4(01|29).*$
failregex = ^<HOST> .+ "POST /[^"]+/login[^"]*" 4(?:01|29).*$
ignoreregex =
"""
jail_content = f"""[mita-panel]
@@ -951,19 +1011,137 @@ logpath = /var/log/mita-panel-access.log
maxretry = {max_retry}
bantime = {ban_time}
findtime = {ban_time}
action = iptables-multiport[name=mita-panel, port="http,https,8080,8443", protocol=tcp]
"""
try:
Path("/etc/fail2ban/filter.d/mita-panel.conf").write_text(filter_content)
Path("/etc/fail2ban/jail.d/mita-panel.conf").write_text(jail_content)
subprocess.run(["systemctl","enable","fail2ban","--now"], capture_output=True)
subprocess.run(["fail2ban-client","reload"], capture_output=True)
ok_msg = "Настройки сохранены и применены"
r_reload = subprocess.run(["systemctl","restart","fail2ban"], capture_output=True)
r_active = subprocess.run(["systemctl","is-active","fail2ban"], capture_output=True, text=True)
if r_active.stdout.strip() == "active":
ok_msg = "Настройки сохранены и применены"
else:
ok_msg = "Настройки сохранены, но fail2ban не запустился — проверьте journalctl -u fail2ban"
except Exception as e:
ok_msg = f"Настройки сохранены (fail2ban: {e})"
return jsonify({"ok": True, "message": ok_msg})
# ── API: Telegram Bot ─────────────────────────────────────────────────────────
BOT_CONFIG_PATH = "/etc/mita/bot.json"
BOT_DIR = "/opt/mita-bot"
BOT_SERVICE = "mita-bot"
BOT_INSTALLER = "/opt/mita-bot/install-bot.sh"
def _bot_installed():
return os.path.exists("/etc/systemd/system/mita-bot.service")
def _bot_running():
try:
r = subprocess.run(["systemctl","is-active",BOT_SERVICE],
capture_output=True, text=True, timeout=3)
return r.stdout.strip() == "active"
except Exception:
return False
def _load_bot_config():
try:
return json.loads(Path(BOT_CONFIG_PATH).read_text())
except Exception:
return {}
@app.route(f"{BASE}/api/bot/status")
@login_required
def api_bot_status():
installed = _bot_installed()
running = _bot_running() if installed else False
cfg = _load_bot_config() if installed else {}
return jsonify({
"installed": installed,
"running": running,
"token": (cfg.get("token","")[:8]+"") if cfg.get("token") else "",
"admin_ids": cfg.get("admin_ids", []),
})
@app.route(f"{BASE}/api/bot/install", methods=["POST"])
@login_required
def api_bot_install():
data = request.get_json(silent=True) or {}
token = data.get("token","").strip()
admin_id = str(data.get("admin_id","")).strip()
if not token:
return jsonify({"ok": False, "error": "Токен обязателен"}), 400
if not admin_id or not admin_id.isdigit():
return jsonify({"ok": False, "error": "Telegram ID должен быть числом"}), 400
# Ищем install-bot.sh рядом с app.py или в /opt/mita-panel
installer = None
for candidate in [
os.path.join(os.path.dirname(os.path.abspath(__file__)), "install-bot.sh"),
"/opt/mita-panel/install-bot.sh",
]:
if os.path.exists(candidate):
installer = candidate
break
if not installer:
return jsonify({"ok": False, "error": "install-bot.sh не найден. Поместите его в /opt/mita-panel/"}), 500
# Передаём в install-bot.sh через env
env = os.environ.copy()
env["BOT_TOKEN_NONINTERACTIVE"] = token
env["BOT_ADMIN_NONINTERACTIVE"] = admin_id
r = subprocess.run(["bash", installer], capture_output=True, text=True,
env=env, timeout=120)
if r.returncode != 0:
return jsonify({"ok": False, "error": r.stderr.strip() or r.stdout.strip()[-500:]}), 500
running = _bot_running()
return jsonify({"ok": True, "running": running, "output": r.stdout.strip()[-500:]})
@app.route(f"{BASE}/api/bot/uninstall", methods=["POST"])
@login_required
def api_bot_uninstall():
subprocess.run(["systemctl","stop",BOT_SERVICE], capture_output=True)
subprocess.run(["systemctl","disable",BOT_SERVICE], capture_output=True)
Path("/etc/systemd/system/mita-bot.service").unlink(missing_ok=True)
subprocess.run(["systemctl","daemon-reload"], capture_output=True)
import shutil
if os.path.isdir(BOT_DIR):
shutil.rmtree(BOT_DIR)
Path(BOT_CONFIG_PATH).unlink(missing_ok=True)
return jsonify({"ok": True})
@app.route(f"{BASE}/api/bot/config", methods=["GET","POST"])
@login_required
def api_bot_config():
if request.method == "POST":
data = request.get_json(silent=True) or {}
token = data.get("token","").strip()
admin_id = str(data.get("admin_id","")).strip()
if not _bot_installed():
return jsonify({"ok": False, "error": "Бот не установлен"}), 400
cfg = _load_bot_config()
if token:
cfg["token"] = token
if admin_id:
if admin_id not in cfg.get("admin_ids", []):
cfg.setdefault("admin_ids", []).append(admin_id)
Path(BOT_CONFIG_PATH).write_text(json.dumps(cfg, indent=2))
subprocess.run(["systemctl","restart",BOT_SERVICE], capture_output=True)
return jsonify({"ok": True})
cfg = _load_bot_config()
return jsonify({
"token": cfg.get("token",""),
"admin_ids": cfg.get("admin_ids", []),
})
# ── API: скачать конфиг как файл ─────────────────────────────────────────────
@app.route(f"{BASE}/api/users/config/download")
@login_required