diff --git a/README.md b/README.md index 1c99099..09bfd7c 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,9 @@ curl -Ls https://raw.githubusercontent.com/grendervilll/mitactl/main/bootstrap.s - [x] Генерация клиентских конфигов (mieru JSON и sing-box JSON) - [x] Скачивание конфигов и отображение QR-кодов - [x] Управление WARP на пользователя (toggle + правила: домены, IP, geosite, geoip, URL источники) -- [x] SSL-сертификаты: самоподписной (10 лет) создаётся автоматически при установке, Let's Encrypt через UI -- [x] Кнопка перезапуска панели на вкладке SSL +- [x] SSL-сертификаты: самоподписной (10 лет) авто при установке, LE через UI, кнопка перезапуска +- [x] Переключение режима доступа: IP / SSH-туннель через вкладку SSL в веб-панели +- [x] Автоопределение Telegram ID через API бота при установке (веб-панель, CLI, install.sh) - [x] Интеграция fail2ban: rate-limiting панели + fail2ban jail (пресеты) - [x] Управление файрволом (UFW/iptables) через CLI и панель - [x] Режим SSH-туннеля: панель только на 127.0.0.1 diff --git a/app.py b/app.py index e997bc4..3d777ac 100644 --- a/app.py +++ b/app.py @@ -677,6 +677,99 @@ def api_panel_restart(): return jsonify({"ok": False, "error": r.stderr.strip()}), 500 return jsonify({"ok": True}) +@app.route(f"{BASE}/api/panel/access") +@login_required +def api_panel_access_get(): + pc = load_panel_config() + panel_port = os.environ.get("PANEL_PORT", "8080") + secret = os.environ.get("SECRET_PATH", "") + mode = pc.get("access_mode", "ip") + ssh_port = pc.get("ssh_port", 22) + return jsonify({ + "mode": mode, + "panel_port": panel_port, + "secret": secret, + "ssh_port": ssh_port, + "server_ip": get_server_ip(), + }) + +@app.route(f"{BASE}/api/panel/access", methods=["POST"]) +@login_required +def api_panel_access_set(): + data = request.get_json(silent=True) or {} + mode = data.get("mode", "").strip() + + if mode not in ("ip", "ssh"): + return jsonify({"ok": False, "error": "mode должен быть ip или ssh"}), 400 + + pc = load_panel_config() + panel_port = os.environ.get("PANEL_PORT", "8080") + secret = os.environ.get("SECRET_PATH", "") + + if mode == "ssh": + bind = "127.0.0.1" + # Закрыть порт в firewall + _fw_close_port(panel_port) + else: + bind = "0.0.0.0" + _fw_open_port(panel_port) + + # Обновить start.sh с новым bind + start_sh = "/opt/mita-panel/start.sh" + try: + Path(start_sh).write_text(f"""#!/bin/bash +set -a; source /etc/mita/panel.env; set +a +SSL_ARGS="" +if [[ -n "$SSL_CERT" && -n "$SSL_KEY" && -f "$SSL_CERT" && -f "$SSL_KEY" ]]; then + SSL_ARGS="--certfile=$SSL_CERT --keyfile=$SSL_KEY" +fi +exec /opt/mita-panel/venv/bin/gunicorn \\ + --bind {bind}:$PANEL_PORT \\ + --workers 2 --timeout 120 \\ + --access-logfile /var/log/mita-panel-access.log \\ + --error-logfile /var/log/mita-panel.log \\ + $SSL_ARGS app:app +""") + except Exception: + pass + + # Сохранить режим в panel.json + pc["access_mode"] = mode + Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2)) + + subprocess.run(["systemctl","restart","mita-panel"], capture_output=True, timeout=10) + + ssh_port = pc.get("ssh_port", 22) + return jsonify({ + "ok": True, + "mode": mode, + "panel_port": panel_port, + "secret": secret, + "server_ip": get_server_ip(), + "ssh_port": ssh_port, + }) + +def _fw_close_port(port): + try: + if subprocess.run(["which","ufw"], capture_output=True).returncode == 0: + subprocess.run(["ufw","delete","allow",f"{port}/tcp"], capture_output=True) + elif subprocess.run(["which","iptables"], capture_output=True).returncode == 0: + subprocess.run(["iptables","-D","INPUT","-p","tcp","--dport",str(port),"-j","ACCEPT"], capture_output=True) + except Exception: + pass + +def _fw_open_port(port): + try: + if subprocess.run(["which","ufw"], capture_output=True).returncode == 0 \ + and subprocess.run(["ufw","status"], capture_output=True, text=True).stdout.find("active") != -1: + subprocess.run(["ufw","allow",f"{port}/tcp","comment","mita-panel"], capture_output=True) + elif subprocess.run(["which","iptables"], capture_output=True).returncode == 0: + r = subprocess.run(["iptables","-C","INPUT","-p","tcp","--dport",str(port),"-j","ACCEPT"], capture_output=True) + if r.returncode != 0: + subprocess.run(["iptables","-I","INPUT","1","-p","tcp","--dport",str(port),"-j","ACCEPT"], capture_output=True) + except Exception: + pass + def _update_env(key, value): env_file = "/etc/mita/panel.env" lines = [] @@ -1132,6 +1225,43 @@ def api_bot_config(): "admin_ids": cfg.get("admin_ids", []), }) +@app.route(f"{BASE}/api/bot/detect-id", methods=["POST"]) +@login_required +def api_bot_detect_id(): + data = request.get_json(silent=True) or {} + token = data.get("token","").strip() + if not token: + return jsonify({"ok": False, "error": "Токен обязателен"}), 400 + + import urllib.request + url = f"https://api.telegram.org/bot{token}/getUpdates" + # Очищаем pending updates перед опросом + try: + req = urllib.request.Request(url + "?offset=-1", headers={"User-Agent": "mita-panel"}) + urllib.request.urlopen(req, timeout=5).read() + except Exception: + pass + + admin_id = "" + for _ in range(20): + time.sleep(3) + try: + req = urllib.request.Request(url, headers={"User-Agent": "mita-panel"}) + with urllib.request.urlopen(req, timeout=5) as resp: + data_raw = json.loads(resp.read()) + ids = [r["message"]["from"]["id"] + for r in data_raw.get("result", []) + if "message" in r and "from" in r["message"]] + if ids: + admin_id = str(ids[-1]) + break + except Exception: + continue + + if admin_id: + return jsonify({"ok": True, "admin_id": admin_id}) + return jsonify({"ok": False, "error": "Не получено ни одного сообщения. Отправьте любое сообщение боту и попробуйте снова."}), 404 + # ── API: скачать конфиг как файл ───────────────────────────────────────────── @app.route(f"{BASE}/api/users/config/download") @login_required diff --git a/install-panel.sh b/install-panel.sh index 7fd1f3e..0a0c315 100755 --- a/install-panel.sh +++ b/install-panel.sh @@ -84,6 +84,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cp "$SCRIPT_DIR/app.py" "$PANEL_DIR/" cp -r "$SCRIPT_DIR/templates" "$PANEL_DIR/" [[ -d "$SCRIPT_DIR/static" ]] && cp -r "$SCRIPT_DIR/static" "$PANEL_DIR/" +# Копируем установщик бота рядом — чтобы работала установка через веб-панель и mita-ctl +[[ -f "$SCRIPT_DIR/install-bot.sh" ]] && cp "$SCRIPT_DIR/install-bot.sh" "$PANEL_DIR/" +[[ -f "$SCRIPT_DIR/bot.py" ]] && cp "$SCRIPT_DIR/bot.py" "$PANEL_DIR/" ok "Файлы скопированы в $PANEL_DIR" # ── генерация секретов ───────────────────────────────────────────── diff --git a/install.sh b/install.sh index 09b397a..026752a 100755 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ========================= ЦВЕТА ДЛЯ ВЫВОДА ========================= RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' +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} $*"; } @@ -782,10 +782,10 @@ else 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 -e " ${BOLD}2.${NC} Откройте своего бота в Telegram и отправьте любое сообщение" + echo -e " (например «привет»). ID определится автоматически." echo "" - echo -e " ${BOLD}3.${NC} Когда токен и ID готовы — нажмите Enter для продолжения" + echo -e " ${BOLD}3.${NC} Когда токен готов — нажмите Enter для продолжения" while true; do echo "" @@ -797,16 +797,40 @@ else 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 + echo "" + info "Определение вашего Telegram ID..." + echo -e " Отправьте ${YELLOW}любое сообщение${NC} своему боту в Telegram прямо сейчас." + echo -e " Ожидание сообщения (макс. 60 сек)..." + + BOT_ADMIN="" + for i in $(seq 1 30); do + BOT_ADMIN=$(curl -s --max-time 5 "https://api.telegram.org/bot${BOT_TOKEN}/getUpdates" 2>/dev/null | \ + python3 -c " +import sys,json +try: + d=json.load(sys.stdin) + ids=[r['message']['from']['id'] for r in d.get('result',[]) if 'message' in r and 'from' in r['message']] + print(ids[-1] if ids else '') +except: pass +" 2>/dev/null) + [[ -n "$BOT_ADMIN" ]] && break + sleep 2 done + if [[ -z "$BOT_ADMIN" || ! "$BOT_ADMIN" =~ ^[0-9]+$ ]]; then + warn "Не удалось определить ID автоматически." + while true; do + echo "" + read -r -p " Впишите ваш Telegram ID вручную (число): " BOT_ADMIN + if [[ -n "$BOT_ADMIN" && "$BOT_ADMIN" =~ ^[0-9]+$ ]]; then + break + fi + warn "Telegram ID должен быть числом" + done + else + ok "Telegram ID определён: ${GREEN}${BOT_ADMIN}${NC}" + fi + echo "" info "Запуск установки Telegram-бота..." BOT_TOKEN_NONINTERACTIVE="$BOT_TOKEN" BOT_ADMIN_NONINTERACTIVE="$BOT_ADMIN" bash "$BOT_INSTALLER" diff --git a/mita-ctl.sh b/mita-ctl.sh index f367c59..bba3a08 100755 --- a/mita-ctl.sh +++ b/mita-ctl.sh @@ -1151,7 +1151,7 @@ _bot_install() { section "Установка Telegram-бота" local installer="" - for candidate in /opt/mita-panel/install-bot.sh "$SCRIPT_DIR/install-bot.sh"; do + for candidate in /opt/mita-panel/install-bot.sh; do [[ -f "$candidate" ]] && { installer="$candidate"; break; } done @@ -1164,7 +1164,32 @@ _bot_install() { read -r -p "Токен бота (от @BotFather): " token [[ -z "$token" ]] && { error "Токен обязателен"; return; } - read -r -p "Ваш Telegram ID (число, @userinfobot): " admin_id + echo "" + info "Определение вашего Telegram ID..." + echo -e " Отправьте ${YELLOW}любое сообщение${NC} своему боту в Telegram прямо сейчас." + echo -e " Ожидание сообщения (макс. 60 сек)..." + + admin_id="" + for i in $(seq 1 30); do + admin_id=$(curl -s --max-time 5 "https://api.telegram.org/bot${token}/getUpdates" 2>/dev/null | \ + python3 -c " +import sys,json +try: + d=json.load(sys.stdin) + ids=[r['message']['from']['id'] for r in d.get('result',[]) if 'message' in r and 'from' in r['message']] + print(ids[-1] if ids else '') +except: pass +" 2>/dev/null) + [[ -n "$admin_id" ]] && break + sleep 2 + done + + if [[ -z "$admin_id" || ! "$admin_id" =~ ^[0-9]+$ ]]; then + warn "Не удалось определить ID автоматически." + read -r -p "Впишите ваш Telegram ID вручную (число): " admin_id + else + ok "Telegram ID определён: ${GREEN}${admin_id}${NC}" + fi [[ -z "$admin_id" || ! "$admin_id" =~ ^[0-9]+$ ]] && { error "ID должен быть числом"; return; } BOT_TOKEN_NONINTERACTIVE="$token" BOT_ADMIN_NONINTERACTIVE="$admin_id" bash "$installer" diff --git a/templates/index.html b/templates/index.html index d98de6c..6a7be30 100644 --- a/templates/index.html +++ b/templates/index.html @@ -52,9 +52,8 @@
Аптайм
-
- +
Быстрые действия
@@ -144,17 +143,19 @@
-
Самоподписной сертификат
+
SSH-туннель

- Генерируется мгновенно, действует 10 лет. - Браузер покажет предупреждение — его можно принять. - Подходит для личного использования. + Панель будет слушать только 127.0.0.1 — недоступна снаружи. + Подключение через зашифрованный SSH-туннель с вашего компьютера.

- -
+
Let's Encrypt (бесплатный)
@@ -264,17 +265,21 @@ `; } + loadPanelAccess(); } -async function genSelfSigned(){ - const btn=event.target; - btn.disabled=true;btn.innerHTML=' Генерация...'; - const el=document.getElementById('selfsigned-result'); - const d=await api('/api/ssl/selfsigned','POST'); - btn.disabled=false;btn.textContent='Создать самоподписной'; - if(!d)return; - if(d.ok){ - el.innerHTML=`
✓ Сертификат создан (10 лет)
${escH(d.expires||'')}
${escH(d.note||'')}
`; - loadSSL(); - }else{ - el.innerHTML=`
Ошибка: ${escH(d.error||'')}
`; +async function loadPanelAccess() { + const d = await api('/api/panel/access'); + if (!d) return; + const el = document.getElementById('ssh-tunnel-info'); + if (!el) return; + const icon = d.mode === 'ssh' ? '🔒' : '🌐'; + const label = d.mode === 'ssh' ? 'SSH-туннель (только 127.0.0.1)' : 'Доступ по IP (0.0.0.0)'; + const sshCmd = `ssh -L ${d.panel_port}:127.0.0.1:${d.panel_port} -p ${d.ssh_port} root@${d.server_ip} -N`; + el.innerHTML = ` +
${icon} ${label}
+
Сервер: ${escH(d.server_ip)}
+
Порт: ${d.panel_port}
+
Команда для туннеля:
+
${escH(sshCmd)}
+ `; +} + +async function switchToSSHTunnel() { + const btn = document.querySelector('#page-ssl .btn-warn'); + btn.disabled = true; + btn.innerHTML = ' Переключение...'; + + const el = document.getElementById('ssh-result'); + const d = await api('/api/panel/access', 'POST', {mode: 'ssh'}); + + btn.disabled = false; + btn.innerHTML = ' Переключить на SSH-туннель'; + + if (d?.ok) { + el.innerHTML = '
✓ Панель переключена на SSH-туннель. Используйте команду ниже для подключения.
'; + showToast('SSH-туннель активирован'); + loadPanelAccess(); + } else { + el.innerHTML = `
Ошибка: ${escH(d?.error||'')}
`; } } @@ -942,6 +970,35 @@ async function loadBotPage() { } } +async function detectBotAdminId() { + const token = document.getElementById('bot-token').value.trim(); + if (!token) { showToast('Введите токен бота', true); return; } + + const el = document.getElementById('bot-detect-status'); + const btn = document.querySelector('#bot-install-card .btn-ok'); + + el.textContent = 'Отправьте любое сообщение боту в Telegram. Ожидание (60 сек)...'; + el.style.color = 'var(--warn)'; + if (btn) { btn.disabled = true; btn.textContent = '⏳ Ожидание...'; } + + try { + const d = await api('/api/bot/detect-id', 'POST', {token}); + if (d?.ok) { + document.getElementById('bot-admin-id').value = d.admin_id; + el.textContent = '✓ ID определён: ' + d.admin_id; + el.style.color = 'var(--ok)'; + showToast('Telegram ID определён'); + } else { + el.textContent = '⚠ ' + (d?.error || 'Не удалось'); + el.style.color = 'var(--danger)'; + } + } catch(e) { + el.textContent = '⚠ Ошибка: ' + e.message; + el.style.color = 'var(--danger)'; + } + if (btn) { btn.disabled = false; btn.textContent = '🔍 Определить ID'; } +} + async function installBot() { const token = document.getElementById('bot-token').value.trim(); const adminId = document.getElementById('bot-admin-id').value.trim();