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
+114
View File
@@ -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`
+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
+403
View File
@@ -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()
+170
View File
@@ -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" <<JSON
{
"token": "$BOT_TOKEN",
"admin_ids": ["$ADMIN_ID"]
}
JSON
ok "Конфиг создан: $BOT_CONFIG"
fi
# ── env файл ────────────────────────────────────────────────────────
cat > "$BOT_DIR/env" <<EOF
BOT_CONFIG=$BOT_CONFIG
BOT_TOKEN=$BOT_TOKEN
EOF
# ── systemd unit ────────────────────────────────────────────────────
cat > /etc/systemd/system/mita-bot.service <<UNIT
[Unit]
Description=mita Telegram Bot
After=network.target
[Service]
Type=simple
WorkingDirectory=$BOT_DIR
EnvironmentFile=$BOT_DIR/env
ExecStart=/usr/bin/python3 $BOT_DIR/bot.py
Restart=always
RestartSec=10
User=root
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable mita-bot
systemctl restart mita-bot
sleep 2
if systemctl is-active --quiet mita-bot; then
ok "Бот запущен и добавлен в автозагрузку"
else
warn "Бот не запустился. Проверьте: journalctl -u mita-bot -f"
fi
# ═══════════════════════════════════════════════════════════════════════
section "Установка завершена"
echo ""
echo -e " ${GREEN}Бот установлен!${NC}"
echo ""
echo -e " ${BOLD}Команды управления:${NC}"
echo -e " ${YELLOW}systemctl status mita-bot${NC} — статус бота"
echo -e " ${YELLOW}systemctl restart mita-bot${NC} — перезапустить"
echo -e " ${YELLOW}journalctl -u mita-bot -f${NC} — логи"
echo ""
echo -e " ${BOLD}Файлы:${NC}"
echo -e " Бот: ${YELLOW}$BOT_DIR/bot.py${NC}"
echo -e " Конфиг: ${YELLOW}$BOT_CONFIG${NC}"
echo -e " Unit: ${YELLOW}/etc/systemd/system/mita-bot.service${NC}"
echo ""
echo -e " ${BOLD}Для добавления других админов:${NC}"
echo -e " Отредактируйте ${YELLOW}$BOT_CONFIG${NC} и добавьте ID в массив admin_ids,"
echo -e " затем: ${YELLOW}systemctl restart mita-bot${NC}"
echo ""
+2 -2
View File
@@ -74,8 +74,8 @@ PANEL_DIR="/opt/mita-panel"
mkdir -p "$PANEL_DIR"
python3 -m venv "$PANEL_DIR/venv"
"$PANEL_DIR/venv/bin/pip" install -q flask gunicorn pyyaml 2>/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 "Копирование файлов панели"
+86 -1
View File
@@ -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"
+194 -19
View File
@@ -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 = ^<HOST> .+ "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
+7 -1
View File
@@ -125,6 +125,10 @@ tr:hover td{background:rgba(255,255,255,.02)}
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
Безопасность
</button>
<button class="nav-item" onclick="showPage('bot',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
Telegram бот
</button>
<div class="sidebar-footer"><a href="{{ base }}/logout">Выйти</a></div>
</nav>
<main class="main">{% block content %}{% endblock %}</main>
@@ -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;
+258 -6
View File
@@ -30,6 +30,32 @@
<div id="s-mita-status" style="margin-top:6px"><span class="badge badge-off"></span></div>
</div>
</div>
<div class="card">
<div class="card-title">Мониторинг сервера</div>
<div class="grid-4" style="margin-bottom:0">
<div class="stat-card">
<div class="stat-label">CPU</div>
<div class="stat-value accent" id="s-cpu"></div>
<div class="stat-sub">%</div>
</div>
<div class="stat-card">
<div class="stat-label">RAM</div>
<div class="stat-value" id="s-ram-pct"></div>
<div class="stat-sub" id="s-ram-detail" style="white-space:nowrap"></div>
</div>
<div class="stat-card">
<div class="stat-label">Диск</div>
<div class="stat-value" id="s-disk-pct"></div>
<div class="stat-sub" id="s-disk-detail" style="white-space:nowrap"></div>
</div>
<div class="stat-card">
<div class="stat-label">Аптайм</div>
<div class="stat-value" style="font-size:1.1rem" id="s-uptime"></div>
</div>
</div>
</div>
<div class="card">
<div class="card-title">Быстрые действия</div>
<div class="btn-group">
@@ -88,8 +114,19 @@
<div class="card-title">Список пользователей</div>
<div class="table-wrap">
<table>
<thead><tr><th>Имя</th><th>WARP вкл</th><th>Правила WARP</th><th>Конфиг клиента</th><th>Действия</th></tr></thead>
<tbody id="users-tbody"><tr><td colspan="4" style="color:var(--muted);text-align:center;padding:20px">Загрузка...</td></tr></tbody>
<thead>
<tr>
<th>Пользователь</th>
<th title="Трафик за последние 24 часа">День</th>
<th title="Примерный трафик за 7 дней (на основе 30-дневных данных)">7 дней</th>
<th title="Трафик за последние 30 дней">30 дней</th>
<th>WARP</th>
<th>Правила WARP</th>
<th>Конфиг</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="users-tbody"><tr><td colspan="8" style="color:var(--muted);text-align:center;padding:20px">Загрузка...</td></tr></tbody>
</table>
</div>
</div>
@@ -209,6 +246,63 @@
</div>
</div>
<!-- TELEGRAM BOT -->
<div class="page" id="page-bot">
<h1>Telegram бот</h1>
<p class="subtitle">Управление сервером через Telegram</p>
<div class="card" id="bot-status-card">
<div class="card-title">Статус бота</div>
<div id="bot-status-body"><div style="color:var(--muted)">Загрузка...</div></div>
</div>
<div class="card" id="bot-install-card" style="display:none">
<div class="card-title">Установка бота</div>
<p style="color:var(--muted);font-size:.875rem;margin-bottom:18px;line-height:1.6">
Создайте бота через <strong style="color:var(--accent)">@BotFather</strong> в Telegram,
скопируйте токен и узнайте ваш Telegram ID через <strong style="color:var(--accent)">@userinfobot</strong>.
</p>
<div class="form-row">
<div class="form-group">
<label>Токен бота (от @BotFather)</label>
<input type="text" id="bot-token" placeholder="123456:ABC-DEF1234gh...">
</div>
<div class="form-group" style="max-width:200px">
<label>Ваш Telegram ID</label>
<input type="text" id="bot-admin-id" placeholder="123456789">
</div>
</div>
<button class="btn btn-primary" onclick="installBot()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/></svg>
Установить / обновить
</button>
<div id="bot-install-result" style="margin-top:12px"></div>
</div>
<div class="card" id="bot-manage-card" style="display:none">
<div class="card-title">Управление</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="restartBot()">⟳ Перезапустить</button>
<button class="btn btn-danger" onclick="uninstallBot()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Удалить бота
</button>
</div>
<div class="divider"></div>
<div class="card-title" style="margin-bottom:12px">Добавить админа</div>
<div class="form-row">
<div class="form-group" style="max-width:220px">
<label>Telegram ID</label>
<input type="text" id="bot-add-admin-id" placeholder="123456789">
</div>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="addBotAdmin()">Добавить</button>
</div>
<div id="bot-admin-result" style="margin-top:12px"></div>
</div>
</div>
{% 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='<tr><td colspan="4" style="color:var(--muted);text-align:center;padding:20px">Нет пользователей</td></tr>';
tbody.innerHTML='<tr><td colspan="8" style="color:var(--muted);text-align:center;padding:20px">Нет пользователей</td></tr>';
return;
}
tbody.innerHTML=ud.users.map(u=>{
const wOn=warpUsers.has(u.name);
const s=statsMap[u.name]||{};
const statusBadge=s.online
?'<span class="badge badge-ok" style="margin-left:6px">● онлайн</span>'
:s.last_active&&s.last_active!=='никогда'
?`<span class="badge badge-off" style="margin-left:6px;white-space:nowrap">● ${escH(s.last_active)}</span>`
:'<span class="badge badge-off" style="margin-left:6px">● никогда</span>';
return `<tr>
<td><strong>${escH(u.name)}</strong></td>
<td>
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px">
<strong>${escH(u.name)}</strong>${statusBadge}
</div>
</td>
<td style="white-space:nowrap;font-size:.82rem;color:var(--muted)">${fmtMB(s.day_mb)}</td>
<td style="white-space:nowrap;font-size:.82rem;color:var(--muted)">${fmtMB(s.week_mb)}</td>
<td style="white-space:nowrap;font-size:.82rem;color:var(--muted)">${fmtMB(s.month_mb)}</td>
<td>
<label class="toggle" title="Весь WARP для этого пользователя">
<input type="checkbox" ${wOn?'checked':''} onchange="setWarp('${escH(u.name)}',this.checked)">
@@ -717,7 +886,90 @@ async function getLetsEncrypt(){
}
}
// ──────────────── SECURITY / FAIL2BAN ────────────────
// ──────────────── TELEGRAM BOT ────────────────
async function loadBotPage() {
const d = await api('/api/bot/status');
if (!d) return;
const statusEl = document.getElementById('bot-status-body');
const installEl = document.getElementById('bot-install-card');
const manageEl = document.getElementById('bot-manage-card');
if (d.installed) {
statusEl.innerHTML = `
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px">
<span class="badge badge-ok">Установлен</span>
${d.running
? '<span class="badge badge-ok">● Активен</span>'
: '<span class="badge" style="background:rgba(255,95,109,.15);color:var(--danger)">● Остановлен</span>'}
</div>
<div style="font-size:.85rem;color:var(--muted)">
Токен: <code style="color:var(--accent)">${escH(d.token||'—')}</code>
</div>
<div style="font-size:.85rem;color:var(--muted);margin-top:4px">
Админы (Telegram ID): <strong style="color:var(--text)">${escH((d.admin_ids||[]).join(', ')||'—')}</strong>
</div>`;
installEl.style.display = 'none';
manageEl.style.display = '';
} else {
statusEl.innerHTML = '<div style="color:var(--warn)">Бот не установлен</div>';
installEl.style.display = '';
manageEl.style.display = 'none';
}
}
async function installBot() {
const token = document.getElementById('bot-token').value.trim();
const adminId = document.getElementById('bot-admin-id').value.trim();
if (!token) { showToast('Введите токен', true); return; }
if (!adminId) { showToast('Введите Telegram ID', true); return; }
const btn = document.querySelector('#bot-install-card .btn-primary');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Установка...';
const d = await api('/api/bot/install', 'POST', {token, admin_id: adminId});
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/></svg> Установить / обновить';
const el = document.getElementById('bot-install-result');
if (d?.ok) {
el.innerHTML = `<div class="alert alert-ok">✓ Бот установлен и ${d.running ? 'запущен' : 'установлен (проверьте логи)'}</div>`;
showToast('Бот установлен');
loadBotPage();
} else {
el.innerHTML = `<div class="alert alert-err">Ошибка: ${escH(d?.error||d?.output||'')}</div>`;
}
}
async function uninstallBot() {
if (!confirm('Удалить Telegram-бота? Это действие необратимо.')) return;
await api('/api/bot/uninstall', 'POST');
showToast('Бот удалён');
loadBotPage();
}
async function restartBot() {
const d = await api('/api/bot/config', 'POST', {});
if (d?.ok) {
showToast('Бот перезапущен');
setTimeout(loadBotPage, 1500);
} else {
showToast('Ошибка перезапуска', true);
}
}
async function addBotAdmin() {
const id = document.getElementById('bot-add-admin-id').value.trim();
if (!id) { showToast('Введите Telegram ID', true); return; }
const d = await api('/api/bot/config', 'POST', {admin_id: id});
const el = document.getElementById('bot-admin-result');
if (d?.ok) {
el.innerHTML = '<div class="alert alert-ok">✓ Админ добавлен</div>';
document.getElementById('bot-add-admin-id').value = '';
loadBotPage();
} else {
el.innerHTML = `<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
}
}
async function loadSecurityPage() {
const d = await api('/api/fail2ban/status');
if (!d) return;