version 1.4.1: per-user WARP флаг, fallback-установка PyPI

- 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
This commit is contained in:
2026-06-28 23:59:47 +03:00
parent 43d99ba867
commit 423ee4fdbb
6 changed files with 189 additions and 166 deletions
+3 -2
View File
@@ -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
### ⬜ Запланировано
+59 -137
View File
@@ -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)
+37
View File
@@ -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([
+24 -2
View File
@@ -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 "Установка бота"
+38 -2
View File
@@ -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 "Копирование файлов панели"
+28 -23
View File
@@ -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=`
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding:12px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:8px">
<label class="toggle" style="flex-shrink:0">
<input type="checkbox" id="wr-full-warp" ${r.full_warp?'checked':''}
onchange="toggleFullWarp(this.checked)">
<span class="toggle-slider"></span>
</label>
<div>
<div style="font-weight:600;font-size:.9rem">Весь трафик через WARP</div>
<div style="font-size:.75rem;color:var(--muted)">Если включено — правила ниже игнорируются, всё идёт через WARP</div>
</div>
</div>
<div id="wr-rules-section">
<p style="color:var(--muted);font-size:.85rem;margin-bottom:18px;line-height:1.6">
Трафик этого пользователя через <strong style="color:var(--accent)">WARP</strong> только для указанных доменов, IP и источников.<br>
Если «Весь трафик» выключен — через <strong style="color:var(--accent)">WARP</strong> пойдут только указанные домены и IP.<br>
Всё остальное идёт напрямую с IP вашего сервера.
</p>
@@ -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&#10;172.64.0.0/13">${escH(r.ips.join('\n'))}</textarea>
</div>
<div style="margin-bottom:20px">
<label style="font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;display:block;margin-bottom:8px">
Источники — geosite / geoip / URL (по одному на строку)
</label>
<textarea id="wr-sources" rows="4" style="width:100%;background:var(--bg);border:1px solid var(--border);
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="geosite:openai&#10;geosite:netflix&#10;geoip:us&#10;https://example.com/domains.txt">${escH(r.sources.join('\n'))}</textarea>
<div style="font-size:.75rem;color:var(--muted);margin-top:6px;line-height:1.7">
<strong style="color:var(--text)">geosite:категория</strong> — из базы v2fly (openai, google, netflix, category-ru…)<br>
<strong style="color:var(--text)">geoip:страна</strong> — IP-диапазоны страны (us, gb, de…)<br>
<strong style="color:var(--text)">https://…</strong> — прямая ссылка на текстовый файл с доменами или CIDR
</div>
</div>
<div style="display:flex;gap:10px">
<div style="display:flex;gap:10px;margin-top:16px">
<button class="btn btn-primary" onclick="saveWarpRules('${escH(name)}')">
Сохранить и применить
</button>
@@ -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='<span class="spinner"></span> Применяю…';
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='<div class="alert alert-ok">✓ Правила сохранены и применены в mita</div>';
showToast('Правила WARP обновлены');
el.innerHTML=`<div class="alert alert-ok">✓ ${fullWarp?'Весь трафик через WARP':'Правила сохранены'} и применены в mita</div>`;
showToast(fullWarp?'WARP: весь трафик для '+name:'Правила WARP обновлены');
loadUsers();
}else{
el.innerHTML=`<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
}