From 423ee4fdbb52cbbe62ea9e72e4a6e019972dc82b Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 28 Jun 2026 23:59:47 +0300 Subject: [PATCH] =?UTF-8?q?version=201.4.1:=20per-user=20WARP=20=D1=84?= =?UTF-8?q?=D0=BB=D0=B0=D0=B3,=20fallback-=D1=83=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20PyPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.py: full_warp флаг в warp_rules, _rebuild_egress читает с диска, race condition fix - bot.py: full_warp поддержка в toggle_warp - templates/index.html: чекбокс «Весь трафик через WARP» с full_warp, не авто-включается - install-panel.sh: зеркала PyPI (aliyun), таймаут 120с на pip, fallback на apt --system-site-packages - install-bot.sh: зеркала и fallback для python-telegram-bot - README: версия 1.4, обновлён roadmap --- README.md | 5 +- app.py | 196 +++++++++++++------------------------------ bot.py | 37 ++++++++ install-bot.sh | 26 +++++- install-panel.sh | 40 ++++++++- templates/index.html | 51 ++++++----- 6 files changed, 189 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 3eb9a6f..e19bf70 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ curl -Ls https://raw.githubusercontent.com/grendervilll/mitactl/main/bootstrap.s ## Дорожная карта -### ✅ Версия 1.2 (реализовано) +### ✅ Версия 1.4 (реализовано) - [x] Автоматическая установка mita сервера (systemd) - [x] Интеграция Cloudflare WARP через Docker @@ -77,7 +77,7 @@ curl -Ls https://raw.githubusercontent.com/grendervilll/mitactl/main/bootstrap.s - [x] Онлайн-статус пользователей - [x] Генерация клиентских конфигов (mieru JSON и sing-box JSON) - [x] Скачивание конфигов и отображение QR-кодов -- [x] Управление WARP на пользователя (toggle + правила: домены, IP, geosite, geoip, URL источники) +- [x] Управление WARP: per-user флаг full_warp, правила доменов/IP, всё/ничего, toggle в UI и боте - [x] SSL-сертификаты: самоподписной (10 лет) авто при установке, LE через UI, кнопка перезапуска - [x] Переключение режима доступа: IP / SSH-туннель через вкладку SSL в веб-панели - [x] Автоопределение Telegram ID через API бота при установке (веб-панель, CLI, install.sh) @@ -95,6 +95,7 @@ curl -Ls https://raw.githubusercontent.com/grendervilll/mitactl/main/bootstrap.s - [x] Telegram-бот: inline-кнопки, создание/удаление/WARP/дашборд, конфиг/Karing, fail2ban-безопасность - [x] Установка бота: веб-панель, CLI, полный установщик; автоопределение Telegram ID через API - [x] Бот в venv (обход externally-managed), авто-SSL, SSH-туннель, обновление из GitHub +- [x] Fallback-установка: зеркала PyPI + таймауты → apt при недоступности pip ### ⬜ Запланировано diff --git a/app.py b/app.py index e72e713..55fab99 100644 --- a/app.py +++ b/app.py @@ -296,7 +296,6 @@ def build_singbox_config(name, password): proto = bindings[0].get("protocol", "TCP").upper() if bindings else "TCP" port_range = bindings[0].get("portRange", str(bindings[0].get("port", "2100"))) if bindings else "2100" - # Берём первый порт из диапазона для sing-box (он не поддерживает диапазоны) first_port = int(port_range.split("-")[0]) if "-" in port_range else int(port_range) server_ip = get_server_ip() @@ -554,15 +553,11 @@ def api_users_delete(): @app.route(f"{BASE}/api/users/warp", methods=["POST"]) @login_required def api_users_warp(): - """Включить/выключить WARP для конкретного пользователя через egress.users.""" + """Включить/выключить WARP для конкретного пользователя. При включении весь трафик идёт через WARP.""" data = request.get_json(silent=True) or {} name = data.get("name", "") enabled = bool(data.get("enabled", False)) - cfg = load_mita_config() - # egress.userGroups не поддерживается в mita — используем workaround: - # храним список warp-пользователей в panel.json, - # добавляем их домены в egress (все остальные — DIRECT). pc = load_panel_config() warp_users = set(pc.get("warp_users", [])) if enabled: @@ -572,10 +567,8 @@ def api_users_warp(): pc["warp_users"] = list(warp_users) Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2)) - # Примечание: mita не поддерживает per-user egress нативно. - # Флаг хранится в panel.json и отображается в UI как справочная информация. - # Реальное разделение — через отдельные порты (см. README). - return jsonify({"ok": True, "note": "stored_in_panel_config"}) + _rebuild_egress() + return jsonify({"ok": True}) @app.route(f"{BASE}/api/users/warp_status") @login_required @@ -861,7 +854,8 @@ def api_warp_rules_get(): rules = pc.get("warp_rules", {}).get(name, { "domains": [], "ips": [], - "sources": [], # URL или geosite:/geoip: ссылки + "sources": [], + "full_warp": False, }) return jsonify({"ok": True, "name": name, "rules": rules}) @@ -869,159 +863,87 @@ def api_warp_rules_get(): @login_required def api_warp_rules_set(): data = request.get_json(silent=True) or {} - name = data.get("name", "") - domains = data.get("domains", []) # список строк - ips = data.get("ips", []) - sources = data.get("sources", []) # geosite:xxx / geoip:xxx / URL + name = data.get("name", "") + domains = data.get("domains", []) + ips = data.get("ips", []) + sources = data.get("sources", []) + full_warp = data.get("full_warp", False) if not name: return jsonify({"ok": False, "error": "Не указано имя"}), 400 pc = load_panel_config() pc.setdefault("warp_rules", {})[name] = { - "domains": [d.strip() for d in domains if d.strip()], - "ips": [i.strip() for i in ips if i.strip()], - "sources": [s.strip() for s in sources if s.strip()], + "domains": [d.strip() for d in domains if d.strip()], + "ips": [i.strip() for i in ips if i.strip()], + "sources": [s.strip() for s in sources if s.strip()], + "full_warp": bool(full_warp), } Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2)) - # Перегенерировать egress в mita config на основе всех пользователей - _rebuild_egress(pc) + _rebuild_egress() return jsonify({"ok": True}) -def _get_geosite_data(): +def _rebuild_egress(pc=None): """ - Скачивает (с кэшированием на 7 дней) единый YAML файл со всеми - geosite-категориями и возвращает распарсенный dict. - Старый способ (отдельный .txt на каждую категорию на ветке release) - больше не поддерживается проектом v2fly — теперь только единый - dlc.dat_plain.yml в latest release. + Глобальный WARP-egress: + - full_warp=True у любого пользователя → весь трафик через WARP + - full_warp=False + есть правила → указанные домены/IP через WARP, остальное DIRECT + - WARP выкл у всех → egress удаляется """ - import urllib.request, os, time, yaml - - cache_path = "/var/cache/mita-geosite.yml" - url = "https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat_plain.yml" - - need_download = True - if os.path.exists(cache_path): - age_days = (time.time() - os.path.getmtime(cache_path)) / 86400 - if age_days < 7: - need_download = False - - if need_download: - try: - os.makedirs(os.path.dirname(cache_path), exist_ok=True) - req = urllib.request.Request(url, headers={"User-Agent": "mita-panel"}) - with urllib.request.urlopen(req, timeout=60) as resp: - data = resp.read() - with open(cache_path, "wb") as f: - f.write(data) - except Exception: - pass # используем старый кэш, если есть - - if not os.path.exists(cache_path): - return None - - try: - with open(cache_path) as f: - return yaml.safe_load(f) - except Exception: - return None - -def _geosite_category_domains(category): - """Возвращает список доменов для одной geosite-категории.""" - data = _get_geosite_data() - if not data: - return [] - domains = [] - cat_lower = category.lower() - for entry in data.get("lists", []): - if entry.get("name", "").lower() == cat_lower: - for rule in entry.get("rules", []): - for prefix in ("domain:", "full:"): - if rule.startswith(prefix): - domains.append(rule[len(prefix):]) - break - # regexp: и include: пропускаем — не прямые доменные правила - break - return domains - -def _rebuild_egress(pc): - """ - Собирает egress.rules из warp_rules всех пользователей и записывает в mita config. - mita не поддерживает per-user egress — правила глобальные, но мы объединяем - домены/IP всех пользователей у которых WARP включён. - Источники (geosite:/geoip:/URL) разворачиваем в реальные списки. - """ - import urllib.request - + pc = load_panel_config() warp_users = set(pc.get("warp_users", [])) + + if not warp_users: + cfg = load_mita_config() + cfg.pop("egress", None) + save_mita_config(cfg) + return + + warp_proxy = { + "name": "warp", + "protocol": "SOCKS5_PROXY_PROTOCOL", + "host": "127.0.0.1", + "port": WARP_PORT, + } + all_domains = set() all_ips = set() + has_full = False - GEOIP_BASE = "https://raw.githubusercontent.com/herrbischoff/country-ip-blocks/master/ipv4" - - for uname, rules in pc.get("warp_rules", {}).items(): - if uname not in warp_users: - continue # пользователь не включён в WARP — пропускаем - - all_domains.update(rules.get("domains", [])) - all_ips.update(rules.get("ips", [])) - - for src in rules.get("sources", []): - src = src.strip() - try: - if src.startswith("geosite:"): - cat = src[len("geosite:"):] - all_domains.update(_geosite_category_domains(cat)) - elif src.startswith("geoip:"): - country = src[len("geoip:"):] - url = f"{GEOIP_BASE}/{country}.cidr" - lines = urllib.request.urlopen(url, timeout=10).read().decode().splitlines() - for line in lines: - line = line.strip() - if line and not line.startswith("#"): - all_ips.add(line) - elif src.startswith("http"): - lines = urllib.request.urlopen(src, timeout=10).read().decode().splitlines() - for line in lines: - line = line.strip() - if line and not line.startswith("#"): - # Определяем домен или IP по наличию / - if "/" in line or line.replace(".","").isdigit(): - all_ips.add(line) - else: - all_domains.add(line) - except Exception: - pass # не критично — продолжаем без этого источника + for uname in warp_users: + rules = pc.get("warp_rules", {}).get(uname, {}) + if rules.get("full_warp"): + has_full = True + all_domains.update(d for d in rules.get("domains", []) if d) + all_ips.update(i for i in rules.get("ips", []) if i) cfg = load_mita_config() - if not all_domains and not all_ips: - # Нет правил — убираем egress полностью - cfg.pop("egress", None) - else: - domain_list = sorted(all_domains) - ip_list = sorted(all_ips) - warp_rule = {"action": "PROXY", "proxyNames": ["warp"]} - if domain_list: - warp_rule["domainNames"] = domain_list - if ip_list: - warp_rule["ipRanges"] = ip_list - + if has_full: cfg["egress"] = { - "proxies": [{ - "name": "warp", - "protocol": "SOCKS5_PROXY_PROTOCOL", - "host": "127.0.0.1", - "port": WARP_PORT, - }], + "proxies": [warp_proxy], + "rules": [ + {"ipRanges": ["*"], "domainNames": ["*"], + "action": "PROXY", "proxyNames": ["warp"]}, + ], + } + elif all_domains or all_ips: + warp_rule = {"action": "PROXY", "proxyNames": ["warp"]} + if all_domains: + warp_rule["domainNames"] = sorted(all_domains) + if all_ips: + warp_rule["ipRanges"] = sorted(all_ips) + cfg["egress"] = { + "proxies": [warp_proxy], "rules": [ warp_rule, {"ipRanges": ["*"], "domainNames": ["*"], "action": "DIRECT"}, ], } + else: + cfg.pop("egress", None) save_mita_config(cfg) diff --git a/bot.py b/bot.py index d2bbbe2..e0929a1 100644 --- a/bot.py +++ b/bot.py @@ -349,6 +349,43 @@ async def toggle_warp(update: Update, context: ContextTypes.DEFAULT_TYPE): status = "включён" pc["warp_users"] = list(warp_users) Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2)) + + # Перестроить egress + cfg = load_mita_config() + warp_users_after = set(pc.get("warp_users", [])) + if not warp_users_after: + cfg.pop("egress", None) + else: + wp = {"name": "warp", "protocol": "SOCKS5_PROXY_PROTOCOL", + "host": "127.0.0.1", "port": 40000} + has_full = any(pc.get("warp_rules", {}).get(u, {}).get("full_warp") for u in warp_users_after) + if has_full: + cfg["egress"] = { + "proxies": [wp], + "rules": [{"ipRanges": ["*"], "domainNames": ["*"], + "action": "PROXY", "proxyNames": ["warp"]}], + } + else: + all_dom = set() + all_ip = set() + for uname in warp_users_after: + r = pc.get("warp_rules", {}).get(uname, {}) + all_dom.update(d for d in r.get("domains", []) if d) + all_ip.update(i for i in r.get("ips", []) if i) + if all_dom or all_ip: + wr = {"action": "PROXY", "proxyNames": ["warp"]} + if all_dom: wr["domainNames"] = sorted(all_dom) + if all_ip: wr["ipRanges"] = sorted(all_ip) + cfg["egress"] = { + "proxies": [wp], + "rules": [wr, {"ipRanges": ["*"], "domainNames": ["*"], "action": "DIRECT"}], + } + else: + cfg.pop("egress", None) + 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 query.edit_message_text( f"🔄 WARP для `{escape_md(name)}`: *{status}*", reply_markup=InlineKeyboardMarkup([ diff --git a/install-bot.sh b/install-bot.sh index f86f673..394becf 100644 --- a/install-bot.sh +++ b/install-bot.sh @@ -73,8 +73,30 @@ if [[ ! -d "$BOT_VENV" ]]; then python3 -m venv "$BOT_VENV" fi -"$BOT_VENV/bin/pip" install -q python-telegram-bot psutil 2>/dev/null -ok "Python-зависимости установлены в виртуальное окружение" +info "Установка python-telegram-bot (может занять до минуты)..." +_bot_pip() { + timeout 120 "$BOT_VENV/bin/pip" install -q --disable-pip-version-check --timeout 15 "$@" +} +bot_pip_ok=false +for mirror in "https://pypi.org/simple/" "https://mirrors.aliyun.com/pypi/simple/"; do + if _bot_pip --index-url "$mirror" python-telegram-bot psutil 2>/dev/null; then + bot_pip_ok=true + break + fi +done + +if ! $bot_pip_ok; then + warn "pip не смог установить python-telegram-bot. Пробую через apt..." + rm -rf "$BOT_VENV" + python3 -m venv --system-site-packages "$BOT_VENV" + apt-get install -y -qq python3-psutil 2>/dev/null + apt-get install -y -qq python3-telegram-bot 2>/dev/null || \ + "$BOT_VENV/bin/pip" install --timeout 15 --index-url https://mirrors.aliyun.com/pypi/simple/ python-telegram-bot 2>/dev/null || { + warn "Не удалось установить зависимости бота." + warn "Установите вручную: /opt/mita-bot/venv/bin/pip install python-telegram-bot psutil" + } +fi +ok "Python-зависимости бота установлены" # ── копирование файлов ────────────────────────────────────────────── section "Установка бота" diff --git a/install-panel.sh b/install-panel.sh index 0a0c315..ddb3365 100755 --- a/install-panel.sh +++ b/install-panel.sh @@ -74,8 +74,44 @@ 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 psutil 2>/dev/null -ok "Python venv, Flask и зависимости установлены" +info "Установка Flask и зависимостей (может занять до минуты)..." + +_pip_install() { + # Принудительно IPv4 + таймаут 15 секунд на соединение + timeout 120 "$PANEL_DIR/venv/bin/pip" install \ + --no-input --quiet --disable-pip-version-check \ + --timeout 15 \ + "$@" +} + +MIRRORS=( + "https://pypi.org/simple/" + "https://mirrors.aliyun.com/pypi/simple/" +) + +pip_ok=false +for mirror in "${MIRRORS[@]}"; do + info "Пробую зеркало: $mirror" + if _pip_install --index-url "$mirror" flask gunicorn pyyaml psutil; then + pip_ok=true + break + fi + warn "Зеркало $mirror недоступно, пробую дальше..." +done + +if ! $pip_ok; then + warn "pip не смог установить зависимости. Пробую через apt..." + apt-get install -y -qq python3-flask python3-gunicorn python3-yaml python3-psutil 2>/dev/null || { + error "Не удалось установить Flask ни через pip, ни через apt" + exit 1 + } + # Создаём venv с системными пакетами чтобы gunicorn был доступен + rm -rf "$PANEL_DIR/venv" + python3 -m venv --system-site-packages "$PANEL_DIR/venv" + ok "Flask установлен через системные пакеты (venv с --system-site-packages)" +else + ok "Python venv, Flask и зависимости установлены" +fi # ── копирование файлов ───────────────────────────────────────────── section "Копирование файлов панели" diff --git a/templates/index.html b/templates/index.html index 17689b6..0ec5802 100644 --- a/templates/index.html +++ b/templates/index.html @@ -773,11 +773,24 @@ async function setWarp(name,enabled){ async function openWarpRules(name){ // Загрузить текущие правила const d=await api('/api/users/warp_rules?name='+encodeURIComponent(name)); - const r=d?.rules||{domains:[],ips:[],sources:[]}; + const r=d?.rules||{domains:[],ips:[],sources:[],full_warp:false}; const html=` +
+ +
+
Весь трафик через WARP
+
Если включено — правила ниже игнорируются, всё идёт через WARP
+
+
+ +

- Трафик этого пользователя через WARP только для указанных доменов, IP и источников.
+ Если «Весь трафик» выключен — через WARP пойдут только указанные домены и IP.
Всё остальное идёт напрямую с IP вашего сервера.

@@ -799,23 +812,9 @@ async function openWarpRules(name){ color:var(--text);padding:10px 12px;border-radius:8px;font-family:var(--mono);font-size:.82rem; resize:vertical;outline:none;line-height:1.6" placeholder="104.16.0.0/12 172.64.0.0/13">${escH(r.ips.join('\n'))}
- -
- - -
- geosite:категория — из базы v2fly (openai, google, netflix, category-ru…)
- geoip:страна — IP-диапазоны страны (us, gb, de…)
- https://… — прямая ссылка на текстовый файл с доменами или CIDR -
-
+
@@ -826,23 +825,29 @@ async function openWarpRules(name){ openModal('WARP-правила: '+name, html); } +function toggleFullWarp(checked){ + const el=document.getElementById('wr-rules-section'); + if(el)el.style.display=checked?'none':''; +} + async function saveWarpRules(name){ const btn=document.querySelector('#modal-body .btn-primary'); btn.disabled=true; btn.innerHTML=' Применяю…'; - const domains=document.getElementById('wr-domains').value.split('\n').map(s=>s.trim()).filter(Boolean); - const ips=document.getElementById('wr-ips').value.split('\n').map(s=>s.trim()).filter(Boolean); - const sources=document.getElementById('wr-sources').value.split('\n').map(s=>s.trim()).filter(Boolean); + const fullWarp=document.getElementById('wr-full-warp')?.checked||false; + const domains=fullWarp?[]:document.getElementById('wr-domains').value.split('\n').map(s=>s.trim()).filter(Boolean); + const ips=fullWarp?[]:document.getElementById('wr-ips').value.split('\n').map(s=>s.trim()).filter(Boolean); - const d=await api('/api/users/warp_rules','POST',{name,domains,ips,sources}); + const d=await api('/api/users/warp_rules','POST',{name,domains,ips,sources:[],full_warp:fullWarp}); btn.disabled=false; btn.textContent='Сохранить и применить'; const el=document.getElementById('wr-result'); if(d?.ok){ - el.innerHTML='
✓ Правила сохранены и применены в mita
'; - showToast('Правила WARP обновлены'); + el.innerHTML=`
✓ ${fullWarp?'Весь трафик через WARP':'Правила сохранены'} и применены в mita
`; + showToast(fullWarp?'WARP: весь трафик для '+name:'Правила WARP обновлены'); + loadUsers(); }else{ el.innerHTML=`
Ошибка: ${escH(d?.error||'')}
`; }