diff --git a/README.md b/README.md new file mode 100644 index 0000000..911de68 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# MSWP — mita Server Web Panel + +Веб-панель и система развёртывания для прокси-протокола **[mieru/mita](https://github.com/enfein/mieru)**, с опциональной интеграцией **Cloudflare WARP** и CLI-утилитой `mita-ctl`. + +> Проект рассчитан на Ubuntu/Debian VPS. Состоит из трёх компонентов: серверная часть установки, веб-панель на Flask и CLI-утилита. + +--- + +## Архитектура + +``` +curl -Ls ... | sudo bash (bootstrap.sh) + │ + ▼ + install.sh — установка mita, WARP, веб-панели, cron + ├── mita (systemd) — прокси-сервер + ├── WARP Docker — SOCKS5-прокси (127.0.0.1:40000) + ├── mita-panel (Flask) — веб-интерфейс управления + ├── mita-ctl — CLI-меню + ├── manage-users.sh — CLI для пользователей + └── auto-update (cron) — ежедневное обновление списков WARP +``` + +## Технологии + +| Компонент | Технология | +|---|---| +| Бэкенд | Python 3 / Flask / Gunicorn | +| Фронтенд | Vanilla JS, Jinja2, QRCode.js | +| Прокси | mieru/mita | +| WARP | seiry/cloudflare-warp-proxy (Docker) | +| CLI | Bash | +| Защита | fail2ban, UFW/iptables, Let's Encrypt | + +## Быстрый старт + +```bash +curl -Ls https://raw.githubusercontent.com/grendervilll/mitactl/main/bootstrap.sh | sudo bash +``` + +--- + +## Структура проекта + +``` +. +├── app.py # Бэкенд Flask +├── bot.py # Telegram-бот +├── bootstrap.sh # Bootstrap-установщик +├── install.sh # Полная установка сервера +├── install-panel.sh # Установка только панели +├── install-bot.sh # Установка Telegram-бота +├── mita-ctl.sh # CLI-утилита +├── manage-users.sh # CLI управление пользователями +├── domains.txt # Список доменов для WARP +├── templates/ +│ ├── base.html # Базовый шаблон: сайдбар, модалы, toast +│ ├── index.html # SPA: Dashboard, Users, SSL, Security, Bot +│ └── login.html # Страница входа +└── static/ + └── qrcode.min.js # QRCode.js (minified) +``` + +--- + +## Дорожная карта + +### ✅ Версия 1.1 (реализовано) + +- [x] Автоматическая установка mita сервера (systemd) +- [x] Интеграция Cloudflare WARP через Docker +- [x] Веб-панель с секретным путём (Flask + Gunicorn, path-based security) +- [x] Тёмная тема, адаптивный UI: Dashboard, Users, SSL, Security +- [x] CRUD пользователей (создание, удаление, список) — авто/ручное/bulk +- [x] Статистика трафика: день, 7 дней, 30 дней +- [x] Онлайн-статус пользователей +- [x] Генерация клиентских конфигов (mieru JSON и sing-box JSON) +- [x] Скачивание конфигов и отображение QR-кодов +- [x] Управление WARP на пользователя (toggle + правила: домены, IP, geosite, geoip, URL источники) +- [x] SSL-сертификаты: self-signed (10 лет) и Let's Encrypt через веб-интерфейс +- [x] Интеграция fail2ban: rate-limiting панели + fail2ban jail (пресеты) +- [x] Управление файрволом (UFW/iptables) через CLI и панель +- [x] Режим SSH-туннеля: панель только на 127.0.0.1 +- [x] Ежедневное автообновление списков WARP (cron + dlc.dat v2fly) +- [x] CLI `mita-ctl` — интерактивное меню (10+ функций) +- [x] CLI `manage-users.sh` — add/remove/list/quota +- [x] Полное удаление всех компонентов (+ self-destruct утилиты) +- [x] Инструкция для клиента Karing (iOS/Android) +- [x] Защита сессий: динамический Secure cookie, in-memory rate limiter +- [x] Bootstrap-установщик (одна команда) +- [x] Telegram-бот: создание/удаление пользователей, трафик, WARP, CPU/RAM/диск +- [x] Установка бота через веб-панель, CLI и полный установщик + +### ⬜ Запланировано + +- [ ] Квоты пользователей в веб-панели (бэкенд готов, нужен UI) +- [ ] Per-user egress routing (обход ограничения mita) +- [ ] Поддержка нескольких протоколов (TCP + UDP) — доработка +- [ ] Автоматический импорт конфигов в Karing (упрощение ручной инструкции) +- [ ] Резервное копирование и восстановление конфигурации +- [ ] Мониторинг состояния сервера (CPU, RAM, диск) в Dashboard +- [ ] Уведомления (Telegram-бот / email) при событиях +- [ ] Мультиязычность интерфейса (i18n: RU/EN) +- [ ] API-ключи для внешней интеграции +- [ ] Графики использования трафика +- [ ] Автоматическое обновление mita бинарника +- [ ] Развёртывание панели в Docker +- [ ] Логирование действий администратора (audit log) + +--- + +## Лицензия + +Проект разработан **grendervill**. Репозиторий: `https://gt.netbird.mhspx.ru/grendervill/MSWP.git` diff --git a/app.py b/app.py index bb4fbc0..13a5ff8 100644 --- a/app.py +++ b/app.py @@ -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 = ^ .+ "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 diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..d93e07c --- /dev/null +++ b/bot.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +mita Telegram Bot — управление сервером через Telegram +""" +import os, json, re, subprocess, psutil, logging +from datetime import datetime +from pathlib import Path + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s [bot] %(message)s") +_log = logging.getLogger("mita_bot") + +BOT_CONFIG = os.environ.get("BOT_CONFIG", "/etc/mita/bot.json") +MITA_CONFIG = "/etc/mita/server_config.json" +PANEL_CONFIG = "/etc/mita/panel.json" + +try: + from telegram import Update + from telegram.ext import Application, CommandHandler, ContextTypes +except ImportError: + _log.error("python-telegram-bot не установлен. pip install python-telegram-bot") + raise SystemExit(1) + +# ── config ──────────────────────────────────────────────────────────────────── +def load_bot_config(): + try: + return json.loads(Path(BOT_CONFIG).read_text()) + except Exception: + return {} + +def load_mita_config(): + try: + return json.loads(Path(MITA_CONFIG).read_text()) + except Exception: + return {} + +def load_panel_config(): + try: + return json.loads(Path(PANEL_CONFIG).read_text()) + except Exception: + return {} + +# ── helpers ─────────────────────────────────────────────────────────────────── +def mita_cmd(*args): + r = subprocess.run(["mita", *args], capture_output=True, text=True) + return r.stdout.strip() + +def _parse_traffic(s: str) -> float: + s = s.strip() + mul = {"TiB":1024**4,"GiB":1024**3,"MiB":1024**2,"KiB":1024,"B":1} + for suffix, factor in mul.items(): + if s.endswith(suffix): + try: + return float(s[:-len(suffix)]) * factor + except ValueError: + return 0.0 + try: + return float(s) + except ValueError: + return 0.0 + +def fmt_bytes(b): + if b >= 1024**4: return f"{b/1024**4:.2f} TiB" + if b >= 1024**3: return f"{b/1024**3:.2f} GiB" + if b >= 1024**2: return f"{b/1024**2:.1f} MiB" + if b >= 1024: return f"{b/1024:.0f} KiB" + return f"{b:.0f} B" + +def get_server_ip(): + try: + return subprocess.run( + ["curl","-s","--max-time","5","ifconfig.me"], + capture_output=True, text=True).stdout.strip() or "?" + except Exception: + return "?" + +def _mita_running(): + try: + r = subprocess.run(["systemctl","is-active","mita"], + capture_output=True, text=True, timeout=3) + return r.stdout.strip() == "active" + except Exception: + return False + +# ── auth ────────────────────────────────────────────────────────────────────── +def admin_only(func): + async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_id = str(update.effective_user.id) + cfg = load_bot_config() + admins = cfg.get("admin_ids", []) + if user_id not in admins: + await update.message.reply_text("⛔ Доступ запрещён.") + return + return await func(update, context) + return wrapper + +# ── commands ────────────────────────────────────────────────────────────────── +@admin_only +async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + await update.message.reply_text( + "🤖 *mita Bot* готов к работе\\.\n\n" + "/users \\- список пользователей\n" + "/create \\[имя\\] \\- создать пользователя\n" + "/delete \\<имя\\> \\- удалить пользователя\n" + "/warp \\<имя\\> on\\|off \\- управление WARP\n" + "/stats \\- статистика сервера\n" + "/dashboard \\- полный дашборд\n" + "/help \\- все команды", + parse_mode="MarkdownV2" + ) + +@admin_only +async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): + await update.message.reply_text( + "📋 *Команды*\n\n" + "/users \\- список пользователей с трафиком и статусом\n" + "/create \\- создать пользователя \\(случайное имя\\)\n" + "/create `имя` \\- создать с указанным именем\n" + "/delete `имя` \\- удалить пользователя\n" + "/warp `имя` on \\- включить WARP\n" + "/warp `имя` off \\- выключить WARP\n" + "/stats \\- CPU, RAM, диск\n" + "/dashboard \\- полный обзор сервера\n" + "/config `имя` \\- клиентский конфиг", + parse_mode="MarkdownV2" + ) + +@admin_only +async def cmd_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + cfg = load_mita_config() + users = cfg.get("users", []) + if not users: + await update.message.reply_text("👤 Нет пользователей.") + return + + raw = mita_cmd("get", "users") + pc = load_panel_config() + warp_users = set(pc.get("warp_users", [])) + + lines = [] + today = datetime.now().date() + + data_lines = [] + for line in raw.splitlines(): + line = line.strip() + if not line or line.upper().startswith("USER"): + continue + data_lines.append(line) + + stats_map = {} + for line in data_lines: + parts = re.split(r"\s{2,}", line.strip()) + if len(parts) < 3: + parts = line.split() + if len(parts) < 3: + continue + try: + name = parts[0] + traffic_parts = [p for p in parts[1:] if any(u in p for u in ["TiB","GiB","MiB","KiB","iB","B"])] + d1 = d30 = 0.0 + if len(traffic_parts) >= 6: + d1 = _parse_traffic(traffic_parts[0]) + _parse_traffic(traffic_parts[1]) + d30= _parse_traffic(traffic_parts[4]) + _parse_traffic(traffic_parts[5]) + elif len(traffic_parts) >= 4: + d1 = _parse_traffic(traffic_parts[0]) + _parse_traffic(traffic_parts[1]) + d30= _parse_traffic(traffic_parts[2]) + _parse_traffic(traffic_parts[3]) + elif len(traffic_parts) >= 2: + d30= _parse_traffic(traffic_parts[0]) + _parse_traffic(traffic_parts[1]) + + online = False + la_raw = parts[1].strip() if len(parts) > 1 else "" + if la_raw and la_raw.lower() not in ("never","-","n/a","никогда"): + try: + la_date = datetime.strptime(la_raw[:10], "%Y-%m-%d").date() + if (today - la_date).days == 0: + online = True + except Exception: + pass + + stats_map[name] = {"online": online, "day_mb": round(d1/1024/1024, 2), + "month_mb": round(d30/1024/1024, 2)} + except Exception: + continue + + for u in users: + name = u["name"] + s = stats_map.get(name, {}) + w = "🟢" if name in warp_users else "⚪" + o = "🟢" if s.get("online") else "🔴" + day = f"{s.get('day_mb', 0):.1f} МБ" if s.get("day_mb") else "—" + mon = f"{s.get('month_mb', 0):.1f} МБ" if s.get("month_mb") else "—" + lines.append(f"{o} `{name}` {w} \\| день: {day} \\| мес: {mon}") + + await update.message.reply_text( + "👥 *Пользователи*\n\n" + "\n".join(lines), + parse_mode="MarkdownV2" + ) + +@admin_only +async def cmd_create(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = context.args + cfg = load_mita_config() + existing = {u["name"] for u in cfg.get("users", [])} + + if args: + name = args[0].strip() + else: + import secrets + adj = ["swift","brave","quiet","cool","sharp","calm","bright","dark","wild","free"] + nouns = ["fox","hawk","river","storm","ember","peak","orbit","tide","frost","spark"] + name = f"{secrets.choice(adj)}_{secrets.choice(nouns)}_{secrets.randbelow(9000)+1000}" + while name in existing: + name = f"{secrets.choice(adj)}_{secrets.choice(nouns)}_{secrets.randbelow(9000)+1000}" + + if name in existing: + await update.message.reply_text(f"❌ Пользователь `{name}` уже существует\\.") + return + + import string + chars = string.ascii_letters + string.digits + "!@#%^*_-=+?." + password = "".join(secrets.choice(chars) for _ in range(64)) + + cfg.setdefault("users", []).append({"name": name, "password": password}) + Path(MITA_CONFIG).write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) + + subprocess.run(["mita", "apply", "config", MITA_CONFIG], capture_output=True) + subprocess.run(["systemctl", "restart", "mita"], capture_output=True) + + ip = get_server_ip() + bindings = cfg.get("portBindings", [{}])[0] + port = bindings.get("portRange", str(bindings.get("port","?"))) + + await update.message.reply_text( + f"✅ Пользователь *{name}* создан\\.\n\n" + f"Сервер: `{ip}`\n" + f"Порт: `{port}`\n" + f"Пароль: `{password}`\n\n" + f"Получить JSON\\-конфиг: /config `{name}`", + parse_mode="MarkdownV2" + ) + +@admin_only +async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not context.args: + await update.message.reply_text("❌ Укажите имя: `/delete имя`", parse_mode="MarkdownV2") + return + name = context.args[0].strip() + cfg = load_mita_config() + cfg["users"] = [u for u in cfg.get("users", []) if u["name"] != name] + Path(MITA_CONFIG).write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) + subprocess.run(["mita", "apply", "config", MITA_CONFIG], capture_output=True) + subprocess.run(["systemctl", "restart", "mita"], capture_output=True) + await update.message.reply_text(f"🗑 Пользователь `{name}` удалён\\.", parse_mode="MarkdownV2") + +@admin_only +async def cmd_warp(update: Update, context: ContextTypes.DEFAULT_TYPE): + if len(context.args) < 2: + await update.message.reply_text( + "❌ Формат: `/warp имя on` или `/warp имя off`", parse_mode="MarkdownV2") + return + name = context.args[0].strip() + action = context.args[1].lower() + if action not in ("on", "off"): + await update.message.reply_text("❌ Укажите `on` или `off`\\.", parse_mode="MarkdownV2") + return + + pc = load_panel_config() + warp_users = set(pc.get("warp_users", [])) + if action == "on": + warp_users.add(name) + else: + warp_users.discard(name) + pc["warp_users"] = list(warp_users) + Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2)) + await update.message.reply_text( + f"🔄 WARP для `{name}`: *{action.upper()}* \\(сохранено в panel\\.json\\)", + parse_mode="MarkdownV2" + ) + +@admin_only +async def cmd_config(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not context.args: + await update.message.reply_text("❌ Укажите имя: `/config имя`", parse_mode="MarkdownV2") + return + name = context.args[0].strip() + cfg = load_mita_config() + user = next((u for u in cfg.get("users", []) if u["name"] == name), None) + if not user: + await update.message.reply_text(f"❌ Пользователь `{name}` не найден\\.", parse_mode="MarkdownV2") + return + ip = get_server_ip() + bindings = cfg.get("portBindings", [{}])[0] + port = bindings.get("portRange", str(bindings.get("port","?"))) + data = json.dumps({ + "profiles": [{"profileName": "default", + "user": {"name": user["name"], "password": user["password"]}, + "servers": [{"ipAddress": ip, + "portBindings": [{"portRange": port, + "protocol": bindings.get("protocol","TCP")}]}]}], + "activeProfile": "default", + "rpcPort": 8964, + "socks5Port": 1080, + }, indent=2, ensure_ascii=False) + + await update.message.reply_text(f"```json\n{data}\n```", parse_mode=None) + +@admin_only +async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): + cpu = psutil.cpu_percent(interval=1) + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + boot = datetime.fromtimestamp(psutil.boot_time()).strftime("%d.%m.%Y %H:%M") + + await update.message.reply_text( + f"📊 *Сервер*\n\n" + f"CPU: `{cpu:.1f}%`\n" + f"RAM: `{mem.percent:.1f}%` \\({fmt_bytes(mem.used)} / {fmt_bytes(mem.total)}\\)\n" + f"Диск: `{disk.percent:.1f}%` \\({fmt_bytes(disk.used)} / {fmt_bytes(disk.total)}\\)\n" + f"Аптайм: с `{boot}`\n" + f"mita: {'🟢 работает' if _mita_running() else '🔴 остановлен'}", + parse_mode="MarkdownV2" + ) + +@admin_only +async def cmd_dashboard(update: Update, context: ContextTypes.DEFAULT_TYPE): + cpu = psutil.cpu_percent(interval=1) + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + ip = get_server_ip() + mita_ok = _mita_running() + + warp_ip = "недоступен" + try: + r = subprocess.run(["curl","-s","--max-time","5","--proxy", + "socks5h://127.0.0.1:40000","https://ifconfig.me"], + capture_output=True, text=True) + warp_ip = r.stdout.strip() + except Exception: + pass + + cfg = load_mita_config() + user_count = len(cfg.get("users", [])) + bindings = cfg.get("portBindings", [{}])[0] + port = bindings.get("portRange", str(bindings.get("port","?"))) + + traffic = None + try: + raw = mita_cmd("get", "users") + d_lines = [l.strip() for l in raw.splitlines() if l.strip() and not l.upper().startswith("USER")] + week_total = month_total = 0.0 + for line in d_lines: + parts = re.split(r"\s{2,}", line.strip()) + if len(parts) < 3: + parts = line.split() + if len(parts) < 3: + continue + tp = [p for p in parts[1:] if any(u in p for u in ["TiB","GiB","MiB","KiB","iB","B"])] + if len(tp) >= 6: + week_total += _parse_traffic(tp[2]) + _parse_traffic(tp[3]) + month_total += _parse_traffic(tp[4]) + _parse_traffic(tp[5]) + traffic = (round(week_total/1024**3, 2), round(month_total/1024**3, 2)) + except Exception: + pass + + w, m = traffic or (0, 0) + + await update.message.reply_text( + f"📊 *Дашборд*\n\n" + f"IP сервера: `{ip}`\n" + f"IP WARP: `{warp_ip}`\n" + f"Порт mita: `{port}`\n" + f"Пользователей: `{user_count}`\n" + f"Трафик нед\\.: `{w} ГБ`\n" + f"Трафик мес\\.: `{m} ГБ`\n" + f"mita: {'🟢' if mita_ok else '🔴'}\n" + f"CPU: `{cpu:.1f}%` RAM: `{mem.percent:.1f}%` Диск: `{disk.percent:.1f}%`", + parse_mode="MarkdownV2" + ) + +# ── main ────────────────────────────────────────────────────────────────────── +def main(): + cfg = load_bot_config() + token = os.environ.get("BOT_TOKEN", cfg.get("token", "")) + if not token: + _log.error("BOT_TOKEN не задан — ни в env, ни в %s", BOT_CONFIG) + raise SystemExit(1) + + app = Application.builder().token(token).build() + app.add_handler(CommandHandler("start", cmd_start)) + app.add_handler(CommandHandler("help", cmd_help)) + app.add_handler(CommandHandler("users", cmd_users)) + app.add_handler(CommandHandler("create", cmd_create)) + app.add_handler(CommandHandler("delete", cmd_delete)) + app.add_handler(CommandHandler("warp", cmd_warp)) + app.add_handler(CommandHandler("config", cmd_config)) + app.add_handler(CommandHandler("stats", cmd_stats)) + app.add_handler(CommandHandler("dashboard", cmd_dashboard)) + + _log.info("Бот запущен") + app.run_polling() + +if __name__ == "__main__": + main() diff --git a/install-bot.sh b/install-bot.sh new file mode 100644 index 0000000..7ba98fb --- /dev/null +++ b/install-bot.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# ============================================================================= +# Установка Telegram-бота для управления mita +# ============================================================================= +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } +section() { echo -e "\n${CYAN}══════ $* ══════${NC}"; } + +[[ $EUID -ne 0 ]] && error "Запустите от root: sudo bash install-bot.sh" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BOT_DIR="/opt/mita-bot" +BOT_CONFIG="/etc/mita/bot.json" + +# ── поиск bot.py ──────────────────────────────────────────────────── +BOT_SRC="" +for candidate in "$SCRIPT_DIR/bot.py" "$SCRIPT_DIR/mita-bot-pkg/bot.py"; do + if [[ -f "$candidate" ]]; then + BOT_SRC="$candidate" + break + fi +done + +if [[ -z "$BOT_SRC" ]]; then + error "bot.py не найден рядом со скриптом. Убедитесь что bot.py лежит в одной папке с install-bot.sh" +fi + +# ═══════════════════════════════════════════════════════════════════════ +section "Установка Telegram-бота mita" + +# ── неинтерактивный режим (вызов из веб-панели или API) ──────────── +if [[ -n "${BOT_TOKEN_NONINTERACTIVE:-}" && -n "${BOT_ADMIN_NONINTERACTIVE:-}" ]]; then + BOT_TOKEN="$BOT_TOKEN_NONINTERACTIVE" + ADMIN_ID="$BOT_ADMIN_NONINTERACTIVE" + info "Неинтерактивный режим" +else + echo "" + echo -e " ${BOLD}Для работы бота нужны:${NC}" + echo -e " ${YELLOW}1.${NC} Токен бота от @BotFather" + echo -e " ${YELLOW}2.${NC} Ваш Telegram ID (узнать: @userinfobot)" + echo "" + + # ── токен ─────────────────────────────────────────────────────────── + read -r -p "Введите токен бота (от @BotFather): " BOT_TOKEN + if [[ -z "$BOT_TOKEN" ]]; then + error "Токен не может быть пустым" + fi + + # ── admin ID ───────────────────────────────────────────────────────── + read -r -p "Введите ваш Telegram ID (число, узнать через @userinfobot): " ADMIN_ID + if [[ -z "$ADMIN_ID" || ! "$ADMIN_ID" =~ ^[0-9]+$ ]]; then + error "Telegram ID должен быть числом" + fi +fi + +echo "" +info "Токен: ${BOT_TOKEN:0:8}..." +info "Admin ID: $ADMIN_ID" + +# ── зависимости ───────────────────────────────────────────────────── +section "Установка зависимостей" +apt-get update -qq +apt-get install -y -qq python3 python3-pip python3-venv + +if python3 -c "import psutil" 2>/dev/null; then + ok "psutil уже установлен" +else + info "Установка psutil..." + pip3 install psutil +fi + +if python3 -c "from telegram.ext import Application" 2>/dev/null; then + ok "python-telegram-bot уже установлен" +else + info "Установка python-telegram-bot..." + pip3 install python-telegram-bot +fi + +# ── копирование файлов ────────────────────────────────────────────── +section "Установка бота" +mkdir -p "$BOT_DIR" +cp "$BOT_SRC" "$BOT_DIR/bot.py" +chmod +x "$BOT_DIR/bot.py" +ok "bot.py скопирован в $BOT_DIR/" + +# ── конфиг ────────────────────────────────────────────────────────── +mkdir -p /etc/mita +if [[ -f "$BOT_CONFIG" ]]; then + python3 -c " +import json +with open('$BOT_CONFIG') as f: d=json.load(f) +d['token']='$BOT_TOKEN' +admin_ids=d.get('admin_ids',[]) +if '$ADMIN_ID' not in admin_ids: admin_ids.append('$ADMIN_ID') +d['admin_ids']=admin_ids +with open('$BOT_CONFIG','w') as f: json.dump(d,f,indent=2) +" + ok "Конфиг обновлён" +else + cat > "$BOT_CONFIG" < "$BOT_DIR/env" < /etc/systemd/system/mita-bot.service </dev/null -ok "Python venv и Flask установлены" +"$PANEL_DIR/venv/bin/pip" install -q flask gunicorn pyyaml psutil 2>/dev/null +ok "Python venv, Flask и зависимости установлены" # ── копирование файлов ───────────────────────────────────────────── section "Копирование файлов панели" diff --git a/install.sh b/install.sh index 933fff3..09b397a 100755 --- a/install.sh +++ b/install.sh @@ -629,12 +629,20 @@ bash "$UPDATE_SCRIPT" && ok "Списки обновлены, конфиг пр section "Настройка firewall" if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then + # UFW требует двоеточие для диапазонов: 3000:3010/tcp, а не 3000-3010/tcp if [[ "$MITA_PORT_RANGE" == *"-"* ]]; then - ufw allow "${MITA_PORT_RANGE}/${MITA_PROTOCOL,,}" comment "mita proxy" 2>/dev/null || true + _UFW_START="${MITA_PORT_RANGE%-*}"; _UFW_END="${MITA_PORT_RANGE#*-}" + ufw allow "${_UFW_START}:${_UFW_END}/${MITA_PROTOCOL,,}" comment "mita proxy" 2>/dev/null || true else ufw allow "${MITA_PORT_RANGE}/${MITA_PROTOCOL,,}" comment "mita proxy" 2>/dev/null || true fi ok "UFW: порты $MITA_PORT_RANGE/$MITA_PROTOCOL открыты" + echo "" + warn "ВАЖНО: UFW управляет только локальным фаерволом ОС." + warn "Если ваш VPS у облачного провайдера (Hetzner, AWS, GCP, DigitalOcean, Vultr и др.)," + warn "откройте порты ${MITA_PORT_RANGE} (${MITA_PROTOCOL}) ТАКЖЕ в консоли провайдера" + warn "(Security Groups / Firewall / Network Rules) — иначе подключения не будет." + echo "" elif command -v iptables &>/dev/null; then if [[ "$MITA_PORT_RANGE" == *"-"* ]]; then START_PORT="${MITA_PORT_RANGE%-*}"; END_PORT="${MITA_PORT_RANGE#*-}" @@ -732,6 +740,83 @@ else fi fi +# ═══════════════════════════════════════════════════════════════════════ +# УСТАНОВКА TELEGRAM-БОТА (опционально) +# ═══════════════════════════════════════════════════════════════════════ +section "Telegram-бот" + +BOT_INSTALLER="" +for candidate in \ + "$SCRIPT_DIR/install-bot.sh" \ + "$SCRIPT_DIR/mita-bot-pkg/install-bot.sh" +do + if [[ -f "$candidate" ]]; then + BOT_INSTALLER="$candidate" + break + fi +done + +if [[ -z "$BOT_INSTALLER" ]]; then + warn "install-bot.sh не найден рядом со скриптом." + warn "Поместите bot.py и install-bot.sh рядом с install.sh для установки бота." +else + echo -e "Установить Telegram-бота для управления сервером?" + echo -e " Бот позволит: создавать/удалять пользователей, смотреть трафик," + echo -e " включать WARP, мониторить CPU/RAM/диск — всё через Telegram." + echo "" + echo -e " ${GREEN}y${NC} — да, установить сейчас (потребуется токен и Telegram ID)" + echo -e " ${YELLOW}n${NC} — пропустить (установить позже: bash $BOT_INSTALLER или через веб-панель)" + echo "" + read -r -p "Установить бота? [y/N]: " INSTALL_BOT + echo "" + + if [[ "${INSTALL_BOT,,}" == "y" || "${INSTALL_BOT,,}" == "yes" ]]; then + echo "" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${CYAN} Инструкция по созданию бота${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo -e " ${BOLD}1.${NC} Откройте Telegram и найдите ${GREEN}@BotFather${NC}" + echo -e " Отправьте команду ${YELLOW}/newbot${NC}" + echo -e " Следуйте инструкциям: придумайте имя и username бота" + echo -e " В ответ вы получите ${BOLD}токен${NC} — длинную строку вида:" + echo -e " ${YELLOW}1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw${NC}" + echo "" + echo -e " ${BOLD}2.${NC} Найдите ${GREEN}@userinfobot${NC} и нажмите /start" + echo -e " Он покажет ваш ${BOLD}Telegram ID${NC} — число (например 123456789)" + echo "" + echo -e " ${BOLD}3.${NC} Когда токен и ID готовы — нажмите Enter для продолжения" + + while true; do + echo "" + read -r -p " Впишите токен от @BotFather: " BOT_TOKEN + if [[ -z "$BOT_TOKEN" ]]; then + warn "Токен не может быть пустым" + continue + fi + break + done + + while true; do + echo "" + read -r -p " Впишите ваш Telegram ID (число): " BOT_ADMIN + if [[ -z "$BOT_ADMIN" || ! "$BOT_ADMIN" =~ ^[0-9]+$ ]]; then + warn "Telegram ID должен быть числом (узнайте у @userinfobot)" + continue + fi + break + done + + echo "" + info "Запуск установки Telegram-бота..." + BOT_TOKEN_NONINTERACTIVE="$BOT_TOKEN" BOT_ADMIN_NONINTERACTIVE="$BOT_ADMIN" bash "$BOT_INSTALLER" + else + info "Пропускаем установку бота." + echo -e " Установить позже: ${YELLOW}bash $BOT_INSTALLER${NC}" + echo -e " Или через веб-панель: раздел «Telegram бот»" + fi +fi + # ── Установка mita-ctl как системной команды ────────────────────── section "Установка утилиты управления mita-ctl" CTL_SRC="$SCRIPT_DIR/mita-ctl.sh" diff --git a/mita-ctl.sh b/mita-ctl.sh index 5292e32..d5fe7a8 100755 --- a/mita-ctl.sh +++ b/mita-ctl.sh @@ -76,6 +76,7 @@ main_menu() { echo -e " ${BOLD}8.${NC} Рекомендации по безопасности VPS" echo -e " ${RED}${BOLD}9.${NC} ${RED}Полное удаление mita, панели и WARP${NC}" echo -e " ${BOLD}10.${NC} Показать текущую конфигурацию" + echo -e " ${BOLD}11.${NC} Управление Telegram-ботом" echo -e " ${BOLD}0.${NC} Выход" echo "" read -r -p " Выберите пункт: " choice @@ -90,6 +91,7 @@ main_menu() { 8) menu_security ;; 9) menu_uninstall ;; 10) menu_show_config ;; + 11) menu_bot ;; 0) exit 0 ;; *) warn "Неверный выбор" ;; esac @@ -332,16 +334,30 @@ print(b.get('portRange',b.get('port','2100-2110'))) _fw_open() { local port="$1" proto="${2:-tcp}" - command -v ufw &>/dev/null && ufw status | grep -q "Status: active" \ - && ufw allow "$port/$proto" 2>/dev/null || true - iptables -I INPUT -p "$proto" --dport "$port" -j ACCEPT 2>/dev/null || true + if [[ "$port" == *"-"* ]]; then + local _s="${port%-*}" _e="${port#*-}" + command -v ufw &>/dev/null && ufw status | grep -q "Status: active" \ + && ufw allow "${_s}:${_e}/${proto}" 2>/dev/null || true + iptables -I INPUT -p "$proto" --dport "${_s}:${_e}" -j ACCEPT 2>/dev/null || true + else + command -v ufw &>/dev/null && ufw status | grep -q "Status: active" \ + && ufw allow "${port}/${proto}" 2>/dev/null || true + iptables -I INPUT -p "$proto" --dport "$port" -j ACCEPT 2>/dev/null || true + fi } _fw_close() { local port="$1" proto="${2:-tcp}" - command -v ufw &>/dev/null && ufw status | grep -q "Status: active" \ - && ufw delete allow "$port/$proto" 2>/dev/null || true - iptables -D INPUT -p "$proto" --dport "$port" -j ACCEPT 2>/dev/null || true + if [[ "$port" == *"-"* ]]; then + local _s="${port%-*}" _e="${port#*-}" + command -v ufw &>/dev/null && ufw status | grep -q "Status: active" \ + && ufw delete allow "${_s}:${_e}/${proto}" 2>/dev/null || true + iptables -D INPUT -p "$proto" --dport "${_s}:${_e}" -j ACCEPT 2>/dev/null || true + else + command -v ufw &>/dev/null && ufw status | grep -q "Status: active" \ + && ufw delete allow "${port}/${proto}" 2>/dev/null || true + iptables -D INPUT -p "$proto" --dport "$port" -j ACCEPT 2>/dev/null || true + fi } # ════════════════════════════════════════════════════════════════════════════ @@ -1030,7 +1046,7 @@ with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) mkdir -p /etc/fail2ban/filter.d /etc/fail2ban/jail.d cat > /etc/fail2ban/filter.d/mita-panel.conf << EOF [Definition] -failregex = ^.*"POST /[^"]+/login[^"]*" 4(01|29).*$ +failregex = ^ .+ "POST /[^"]+/login[^"]*" 4(?:01|29).*$ ignoreregex = EOF cat > /etc/fail2ban/jail.d/mita-panel.conf << EOF @@ -1042,10 +1058,12 @@ 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] EOF - systemctl restart fail2ban 2>/dev/null || true - ok "fail2ban настроен: $max_retry попыток / ${ban_time}с" + if systemctl restart fail2ban 2>/dev/null && systemctl is-active --quiet fail2ban; then + ok "fail2ban настроен и запущен: $max_retry попыток / ${ban_time}с" + else + warn "fail2ban настроен, но не запустился. Проверьте: journalctl -u fail2ban -n 20" + fi else warn "fail2ban не установлен — лимиты сохранены только в panel.json" fi @@ -1055,6 +1073,141 @@ EOF ok "Настройки применены. Попыток: $max_retry, бан: ${ban_time}с" } +# ════════════════════════════════════════════════════════════════════════════ +# 11. TELEGRAM БОТ +# ════════════════════════════════════════════════════════════════════════════ +menu_bot() { + section "Управление Telegram-ботом" + + BOT_SERVICE="mita-bot" + BOT_DIR="/opt/mita-bot" + BOT_CONFIG="/etc/mita/bot.json" + + local installed=false running=false + if [[ -f "/etc/systemd/system/${BOT_SERVICE}.service" ]]; then + installed=true + systemctl is-active --quiet "$BOT_SERVICE" && running=true + fi + + echo "" + if $installed; then + echo -e " Статус: ${GREEN}● Установлен${NC}" + if $running; then + echo -e " Состояние: ${GREEN}● Активен${NC}" + else + echo -e " Состояние: ${RED}● Остановлен${NC}" + fi + if [[ -f "$BOT_CONFIG" ]]; then + local token + token=$(python3 -c "import json; d=json.load(open('$BOT_CONFIG')); print(d.get('token','')[:8]+'…')" 2>/dev/null || echo "—") + echo -e " Токен: ${YELLOW}${token}${NC}" + local admins + admins=$(python3 -c "import json; d=json.load(open('$BOT_CONFIG')); print(', '.join(d.get('admin_ids',[])))" 2>/dev/null || echo "—") + echo -e " Админы ID: ${YELLOW}${admins}${NC}" + fi + else + echo -e " Статус: ${YELLOW}● Не установлен${NC}" + fi + + echo "" + echo -e " ${BOLD}1.${NC} Установить / обновить бота" + echo -e " ${BOLD}2.${NC} Перезапустить бота" + echo -e " ${BOLD}3.${NC} Добавить админа (Telegram ID)" + echo -e " ${BOLD}4.${NC} Логи бота (journalctl)" + if $installed; then + echo -e " ${RED}${BOLD}5.${NC} ${RED}Удалить бота${NC}" + fi + echo -e " ${BOLD}0.${NC} Назад" + echo "" + read -r -p " Выбор: " choice + + case "$choice" in + 1) _bot_install ;; + 2) _bot_restart ;; + 3) _bot_add_admin ;; + 4) journalctl -u "$BOT_SERVICE" -f --no-pager -n 50 ;; + 5) + if $installed; then + read -r -p " Точно удалить бота? [y/N]: " del + if [[ "${del,,}" == "y" ]]; then + _bot_uninstall + fi + fi + ;; + esac +} + +_bot_install() { + section "Установка Telegram-бота" + + local installer="" + for candidate in /opt/mita-panel/install-bot.sh "$SCRIPT_DIR/install-bot.sh"; do + [[ -f "$candidate" ]] && { installer="$candidate"; break; } + done + + if [[ -z "$installer" ]]; then + error "install-bot.sh не найден. Поместите его в /opt/mita-panel/" + pause + return + fi + + read -r -p "Токен бота (от @BotFather): " token + [[ -z "$token" ]] && { error "Токен обязателен"; return; } + + read -r -p "Ваш Telegram ID (число, @userinfobot): " admin_id + [[ -z "$admin_id" || ! "$admin_id" =~ ^[0-9]+$ ]] && { error "ID должен быть числом"; return; } + + BOT_TOKEN_NONINTERACTIVE="$token" BOT_ADMIN_NONINTERACTIVE="$admin_id" bash "$installer" + pause +} + +_bot_restart() { + section "Перезапуск бота" + BOT_SERVICE="mita-bot" + systemctl restart "$BOT_SERVICE" 2>/dev/null || true + sleep 1 + if systemctl is-active --quiet "$BOT_SERVICE"; then + ok "Бот перезапущен" + else + warn "Бот не запустился. Проверьте: journalctl -u $BOT_SERVICE -f" + fi + pause +} + +_bot_add_admin() { + section "Добавление админа" + BOT_CONFIG="/etc/mita/bot.json" + [[ ! -f "$BOT_CONFIG" ]] && { error "Бот не установлен"; return; } + + read -r -p "Telegram ID нового админа (число): " new_id + [[ -z "$new_id" || ! "$new_id" =~ ^[0-9]+$ ]] && { error "ID должен быть числом"; return; } + + python3 -c " +import json +with open('$BOT_CONFIG') as f: d=json.load(f) +ids=d.get('admin_ids',[]) +if '$new_id' not in ids: ids.append('$new_id') +d['admin_ids']=ids +with open('$BOT_CONFIG','w') as f: json.dump(d,f,indent=2) +" + systemctl restart mita-bot 2>/dev/null || true + ok "Админ $new_id добавлен" + pause +} + +_bot_uninstall() { + section "Удаление Telegram-бота" + BOT_SERVICE="mita-bot" + systemctl stop "$BOT_SERVICE" 2>/dev/null || true + systemctl disable "$BOT_SERVICE" 2>/dev/null || true + rm -f "/etc/systemd/system/${BOT_SERVICE}.service" + systemctl daemon-reload 2>/dev/null || true + rm -rf /opt/mita-bot + rm -f /etc/mita/bot.json + ok "Бот удалён" + pause +} + # ════════════════════════════════════════════════════════════════════════════ # 9. ПОЛНОЕ УДАЛЕНИЕ # ════════════════════════════════════════════════════════════════════════════ @@ -1070,6 +1223,7 @@ menu_uninstall() { echo -e " ${YELLOW}•${NC} mita сервер (бинарник, systemd unit, конфиги)" echo -e " ${YELLOW}•${NC} Веб-панель (/opt/mita-panel, systemd unit)" echo -e " ${YELLOW}•${NC} Cloudflare WARP (Docker-контейнер)" + echo -e " ${YELLOW}•${NC} Telegram-бот (/opt/mita-bot, systemd unit)" echo -e " ${YELLOW}•${NC} Все пользователи и пароли (/etc/mita/)" echo -e " ${YELLOW}•${NC} SSL-сертификаты, выпущенные для панели" echo -e " ${YELLOW}•${NC} Правила fail2ban для панели" @@ -1200,12 +1354,30 @@ except: print('2100-2110') rm -f /usr/local/bin/mita-update-lists ok "Cron-задачи удалены" + # ── 7a. Удалить Telegram-бота ──────────────────────────────────── + if [[ -f "/etc/systemd/system/mita-bot.service" ]]; then + info "Удаление Telegram-бота..." + systemctl stop mita-bot 2>/dev/null || true + systemctl disable mita-bot 2>/dev/null || true + rm -f /etc/systemd/system/mita-bot.service + systemctl daemon-reload 2>/dev/null || true + rm -rf /opt/mita-bot + rm -f /etc/mita/bot.json + ok "Telegram-бот удалён" + fi + # ── 8. Закрыть порты в firewall ──────────────────────────────── info "Закрытие портов mita ($_MITA_RANGE) и панели ($_PANEL_PORT) в firewall..." - info "SSH порт $_SSH_PORT остаётся открытым." + info "SSH порт $_SSH_PORT будет явно сохранён открытым." + + _UFW_ACTIVE=false + command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active" && _UFW_ACTIVE=true # ---- UFW ---- - if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then + if $_UFW_ACTIVE; then + # Сначала явно гарантируем SSH, ПОТОМ удаляем остальные правила + ufw allow "$_SSH_PORT/tcp" comment "SSH" 2>/dev/null || true + # Удаляем правило mita (диапазон или одиночный порт) if [[ "$_MITA_RANGE" == *"-"* ]]; then _SP="${_MITA_RANGE%-*}"; _EP="${_MITA_RANGE#*-}" @@ -1218,14 +1390,14 @@ except: print('2100-2110') # Удаляем правило панели ufw delete allow "$_PANEL_PORT/tcp" 2>/dev/null || true # Дополнительно: чистим правила по комментарию (совместимость с разными версиями) - ufw status numbered 2>/dev/null | { grep -iE "mita|panel" || true; } | awk -F'[][]' '{print $2}' | sort -rn | while read -r num; do + ufw status numbered 2>/dev/null | { grep -iE "mita|panel" || true; } | awk -F'[][]' '{gsub(/[[:space:]]/, "", $2); print $2}' | sort -rn | while read -r num; do [[ -n "$num" ]] && yes | ufw delete "$num" 2>/dev/null || true done - ok "UFW: правила удалены" + ok "UFW: правила удалены, SSH ($_SSH_PORT) сохранён" fi - # ---- iptables ---- - if command -v iptables &>/dev/null; then + # ---- iptables (только когда UFW не управляет firewall) ---- + if ! $_UFW_ACTIVE && command -v iptables &>/dev/null; then if [[ "$_MITA_RANGE" == *"-"* ]]; then _SP="${_MITA_RANGE%-*}"; _EP="${_MITA_RANGE#*-}" iptables -D INPUT -p tcp --dport "${_SP}:${_EP}" -j ACCEPT 2>/dev/null || true @@ -1235,12 +1407,15 @@ except: print('2100-2110') iptables -D INPUT -p udp --dport "$_MITA_RANGE" -j ACCEPT 2>/dev/null || true fi iptables -D INPUT -p tcp --dport "$_PANEL_PORT" -j ACCEPT 2>/dev/null || true - # Сохраняем правила если есть iptables-save + # Явно обеспечиваем SSH-правило первым в цепочке + iptables -D INPUT -p tcp --dport "$_SSH_PORT" -j ACCEPT 2>/dev/null || true + iptables -I INPUT 1 -p tcp --dport "$_SSH_PORT" -j ACCEPT 2>/dev/null || true + # Сохраняем правила command -v iptables-save &>/dev/null && iptables-save > /etc/iptables/rules.v4 2>/dev/null || true - ok "iptables: правила удалены" + ok "iptables: правила удалены, SSH ($_SSH_PORT) сохранён" fi - ok "Firewall: порты mita и панели закрыты. SSH ($_SSH_PORT) не тронут." + ok "Firewall: порты mita и панели закрыты. SSH ($_SSH_PORT) открыт." systemctl daemon-reload 2>/dev/null || true diff --git a/templates/base.html b/templates/base.html index 2e4222c..f700fbd 100644 --- a/templates/base.html +++ b/templates/base.html @@ -125,6 +125,10 @@ tr:hover td{background:rgba(255,255,255,.02)} Безопасность +
{% block content %}{% endblock %}
@@ -148,10 +152,12 @@ function showPage(id,btn){ document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active')); document.getElementById('page-'+id).classList.add('active'); if(btn)btn.classList.add('active'); - if(id==='dashboard')loadDashboard(); + if(id==='dashboard'){loadDashboard();if(typeof startStatsPolling==='function')startStatsPolling();} + else{if(typeof stopStatsPolling==='function')stopStatsPolling();} if(id==='users')loadUsers(); if(id==='ssl')loadSSL(); if(id==='security')loadSecurityPage(); + if(id==='bot')loadBotPage(); } function openModal(title,html){ document.getElementById('modal-title-text').textContent=title; diff --git a/templates/index.html b/templates/index.html index f468af8..9a3a1b8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -30,6 +30,32 @@
+ +
+
Мониторинг сервера
+
+
+
CPU
+
+
%
+
+
+
RAM
+
+
+
+
+
Диск
+
+
+
+
+
Аптайм
+
+
+
+
+
Быстрые действия
@@ -88,8 +114,19 @@
Список пользователей
- - + + + + + + + + + + + + +
ИмяWARP вклПравила WARPКонфиг клиентаДействия
Загрузка...
ПользовательДень7 дней30 днейWARPПравила WARPКонфигДействия
Загрузка...
@@ -209,6 +246,63 @@
+ +
+

Telegram бот

+

Управление сервером через Telegram

+ +
+
Статус бота
+
Загрузка...
+
+ + + + +
+ {% endblock %} {% block scripts %} @@ -237,6 +331,54 @@ async function loadDashboard(){ } loadDashboard(); +let _statsTimer=null; +startStatsPolling(); + +function fmtBytes(b){ + if(b>=1024**4)return(b/1024**4).toFixed(1)+' ТБ'; + if(b>=1024**3)return(b/1024**3).toFixed(1)+' ГБ'; + if(b>=1024**2)return(b/1024**2).toFixed(0)+' МБ'; + if(b>=1024) return(b/1024).toFixed(0)+' КБ'; + return b+' Б'; +} + +async function loadServerStats(){ + const d=await api('/api/stats'); + if(!d)return; + + const cpuEl=document.getElementById('s-cpu'); + if(cpuEl){ + cpuEl.textContent=d.cpu.toFixed(1); + cpuEl.style.color=d.cpu>80?'var(--danger)':d.cpu>50?'var(--warn)':'var(--accent)'; + } + + const ramPctEl=document.getElementById('s-ram-pct'); + if(ramPctEl){ + ramPctEl.textContent=d.ram_pct.toFixed(1)+'%'; + ramPctEl.style.color=d.ram_pct>90?'var(--danger)':d.ram_pct>70?'var(--warn)':'var(--text)'; + } + const ramDetail=document.getElementById('s-ram-detail'); + if(ramDetail)ramDetail.textContent=fmtBytes(d.ram_used)+' / '+fmtBytes(d.ram_total); + + const diskPctEl=document.getElementById('s-disk-pct'); + if(diskPctEl){ + diskPctEl.textContent=d.disk_pct.toFixed(1)+'%'; + diskPctEl.style.color=d.disk_pct>90?'var(--danger)':d.disk_pct>75?'var(--warn)':'var(--text)'; + } + const diskDetail=document.getElementById('s-disk-detail'); + if(diskDetail)diskDetail.textContent=fmtBytes(d.disk_used)+' / '+fmtBytes(d.disk_total); +} + +function startStatsPolling(){ + stopStatsPolling(); + loadServerStats(); + _statsTimer=setInterval(loadServerStats,2000); +} + +function stopStatsPolling(){ + if(_statsTimer){clearInterval(_statsTimer);_statsTimer=null;} +} + function showClients(){ const clients=[ {name:'mieru (официальный CLI)',url:'https://github.com/enfein/mieru',desc:'CLI-клиент для macOS, Linux, Windows, OpenWrt'}, @@ -271,19 +413,46 @@ function showClients(){ // ──────────────── USERS ──────────────── let warpUsers=new Set(); +function fmtMB(mb){ + if(mb==null||mb===0)return'—'; + if(mb>=1024)return(mb/1024).toFixed(2)+' ГБ'; + if(mb>=1)return mb.toFixed(1)+' МБ'; + return Math.round(mb*1024)+' КБ'; +} + async function loadUsers(){ - const [ud,wd]=await Promise.all([api('/api/users'),api('/api/users/warp_status')]); + const [ud,wd,sd]=await Promise.all([ + api('/api/users'), + api('/api/users/warp_status'), + api('/api/users/stats'), + ]); if(!ud)return; warpUsers=new Set(wd?.warp_users||[]); + const statsMap={}; + (sd?.users||[]).forEach(s=>{statsMap[s.name]=s;}); + const tbody=document.getElementById('users-tbody'); if(!ud.users.length){ - tbody.innerHTML='Нет пользователей'; + tbody.innerHTML='Нет пользователей'; return; } tbody.innerHTML=ud.users.map(u=>{ const wOn=warpUsers.has(u.name); + const s=statsMap[u.name]||{}; + const statusBadge=s.online + ?'● онлайн' + :s.last_active&&s.last_active!=='никогда' + ?`● ${escH(s.last_active)}` + :'● никогда'; return ` - ${escH(u.name)} + +
+ ${escH(u.name)}${statusBadge} +
+ + ${fmtMB(s.day_mb)} + ${fmtMB(s.week_mb)} + ${fmtMB(s.month_mb)}