Files
MSWP/app.py
T

918 lines
35 KiB
Python

#!/usr/bin/env python3
"""
mita Web Panel — Flask backend
"""
import os, json, subprocess, secrets, string, random, ipaddress, socket
from datetime import datetime, timedelta
from functools import wraps
from pathlib import Path
from flask import (Flask, render_template, request, jsonify,
session, redirect, url_for, abort)
from collections import defaultdict
from threading import Lock
# ── конфиг ──────────────────────────────────────────────────────────────────
MITA_CONFIG = os.environ.get("MITA_CONFIG", "/etc/mita/server_config.json")
PANEL_CONFIG = os.environ.get("PANEL_CONFIG", "/etc/mita/panel.json")
SECRET_PATH = os.environ.get("SECRET_PATH", "") # задаётся при установке
WARP_PORT = int(os.environ.get("WARP_PORT", "40000"))
SSL_CERT = os.environ.get("SSL_CERT", "")
SSL_KEY = os.environ.get("SSL_KEY", "")
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET", secrets.token_hex(32))
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = bool(SSL_CERT)
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=8)
# ── Rate limiter ─────────────────────────────────────────────────────────────
class RateLimiter:
def __init__(self, max_attempts=5, window_seconds=3600):
self.max_attempts = max_attempts
self.window = window_seconds
self._attempts = defaultdict(list) # ip → [timestamps]
self._lock = Lock()
def _cleanup(self, ip):
now = datetime.now().timestamp()
self._attempts[ip] = [t for t in self._attempts[ip] if now - t < self.window]
def check(self, ip):
"""Returns (allowed: bool, remaining: int, reset_in: int)."""
with self._lock:
self._cleanup(ip)
attempts = len(self._attempts[ip])
remaining = max(0, self.max_attempts - attempts)
return remaining > 0, remaining, self.window
def record_failure(self, ip):
with self._lock:
self._attempts[ip].append(datetime.now().timestamp())
self._cleanup(ip)
def reset(self, ip):
with self._lock:
self._attempts.pop(ip, None)
def update_limits(self, max_attempts, window_seconds):
with self._lock:
self.max_attempts = max_attempts
self.window = window_seconds
_login_limiter = RateLimiter(max_attempts=5, window_seconds=3600)
# ── helpers ──────────────────────────────────────────────────────────────────
def load_panel_config():
try:
return json.loads(Path(PANEL_CONFIG).read_text())
except Exception:
return {}
def load_mita_config():
try:
return json.loads(Path(MITA_CONFIG).read_text())
except Exception:
return {}
def save_mita_config(cfg):
Path(MITA_CONFIG).write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
subprocess.run(["mita", "apply", "config", MITA_CONFIG], capture_output=True)
def mita_cmd(*args):
r = subprocess.run(["mita", *args], capture_output=True, text=True)
return r.stdout.strip()
def gen_password(length=64):
# Буквы и цифры только — спецсимволы могут ломать mita protobuf парсинг
# Используем расширенный безопасный набор без проблемных символов
chars = string.ascii_letters + string.digits + "!@#%^*_-=+?."
return "".join(secrets.choice(chars) for _ in range(length))
def gen_username():
adjectives = ["swift","brave","quiet","cool","sharp","calm","bright","dark","wild","free"]
nouns = ["fox","hawk","river","storm","ember","peak","orbit","tide","frost","spark"]
return f"{secrets.choice(adjectives)}_{secrets.choice(nouns)}_{secrets.randbelow(9000)+1000}"
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 get_warp_ip():
try:
r = subprocess.run(
["curl","-s","--max-time","8","--proxy",
f"socks5h://127.0.0.1:{WARP_PORT}",
"https://ifconfig.me"],
capture_output=True, text=True)
ip = r.stdout.strip()
# базовая валидация
ipaddress.ip_address(ip)
return ip
except Exception:
return "недоступен"
def get_traffic_stats():
"""Возвращает трафик по всем пользователям за 7 и 30 дней."""
raw = mita_cmd("get", "users")
week_total = month_total = 0.0
users_stats = []
# 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
lines = raw.splitlines()
data_lines = []
for line in lines:
line = line.strip()
if not line or line.upper().startswith("USER"):
continue
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())
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:
d30_down = _parse_traffic(traffic_parts[2])
d30_up = _parse_traffic(traffic_parts[3])
elif len(traffic_parts) >= 2:
d30_down = _parse_traffic(traffic_parts[0])
d30_up = _parse_traffic(traffic_parts[1])
else:
continue
d7_approx = (d30_down + d30_up) / 30 * 7
month_total += d30_down + d30_up
week_total += d7_approx
users_stats.append({
"name": name,
"month_mb": round((d30_down + d30_up) / 1024 / 1024, 1),
})
except Exception:
continue
return {
"week_gb": round(week_total / 1024**3, 2),
"month_gb": round(month_total / 1024**3, 2),
"users": users_stats,
}
def _parse_traffic(s: str) -> float:
"""Parse traffic string like '12.5GiB' or '1.2MiB' → bytes (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 get_port_info():
cfg = load_mita_config()
bindings = cfg.get("portBindings", [])
if not bindings:
return "?"
b = bindings[0]
return b.get("portRange", str(b.get("port", "?")))
def build_client_config(name, password):
cfg = load_mita_config()
bindings = cfg.get("portBindings", [])
proto = bindings[0].get("protocol","TCP") if bindings else "TCP"
port_range = bindings[0].get("portRange", str(bindings[0].get("port","?"))) if bindings else "?"
server_ip = get_server_ip()
return {
"profiles": [{
"profileName": "default",
"user": {"name": name, "password": password},
"servers": [{
"ipAddress": server_ip,
"portBindings": [{"portRange": port_range, "protocol": proto}]
}]
}],
"activeProfile": "default",
"rpcPort": 8964,
"socks5Port": 1080,
}
def build_singbox_config(name, password):
"""Генерирует sing-box совместимый конфиг для Karing, Hiddify, NekoBox и др."""
cfg = load_mita_config()
bindings = cfg.get("portBindings", [])
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()
return {
"log": {"level": "info", "timestamp": True},
"dns": {
"servers": [
{"tag": "remote", "address": "tls://8.8.8.8", "detour": "proxy"},
{"tag": "local", "address": "223.5.5.5", "detour": "direct"}
],
"rules": [{"outbound": "any", "server": "local"}],
"final": "remote"
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"inet4_address": "172.19.0.1/30",
"auto_route": True,
"strict_route": True,
"sniff": True
},
{
"type": "socks",
"tag": "socks-in",
"listen": "127.0.0.1",
"listen_port": 2080
},
{
"type": "http",
"tag": "http-in",
"listen": "127.0.0.1",
"listen_port": 2081
}
],
"outbounds": [
{
"type": "mieru",
"tag": "proxy",
"server": server_ip,
"server_port": first_port,
"transport": proto,
"username": name,
"password": password
},
{"type": "direct", "tag": "direct"},
{"type": "block", "tag": "block"},
{"type": "dns", "tag": "dns-out"}
],
"route": {
"rules": [
{"protocol": "dns", "outbound": "dns-out"},
{"ip_is_private": True, "outbound": "direct"},
{
"rule_set": ["geosite-cn", "geoip-cn"],
"outbound": "direct"
}
],
"rule_set": [
{
"tag": "geosite-cn",
"type": "remote",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs",
"download_detour": "proxy"
},
{
"tag": "geoip-cn",
"type": "remote",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs",
"download_detour": "proxy"
}
],
"final": "proxy",
"auto_detect_interface": True
}
}
# ── auth ─────────────────────────────────────────────────────────────────────
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("logged_in"):
return redirect(url_for("login_page"))
return f(*args, **kwargs)
return decorated
def secret_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if SECRET_PATH and request.path.rstrip("/") not in (
f"/{SECRET_PATH}", f"/{SECRET_PATH}/login"
) and not request.path.startswith(f"/{SECRET_PATH}/"):
abort(404)
return f(*args, **kwargs)
return decorated
# ── routes ────────────────────────────────────────────────────────────────────
BASE = f"/{SECRET_PATH}" if SECRET_PATH else ""
@app.route(f"{BASE}/")
@app.route(f"{BASE}")
@login_required
def index():
return render_template("index.html", base=BASE)
@app.route(f"{BASE}/login", methods=["GET","POST"])
def login_page():
if request.method == "POST":
ip = request.remote_addr or "unknown"
allowed, remaining, _ = _login_limiter.check(ip)
if not allowed:
return jsonify({
"ok": False,
"error": "Слишком много попыток. Попробуйте позже.",
"remaining": 0,
"rate_limited": True,
}), 429
data = request.get_json(silent=True) or {}
pc = load_panel_config()
if (data.get("username") == pc.get("admin_user") and
data.get("password") == pc.get("admin_pass")):
_login_limiter.reset(ip)
session.permanent = True
session["logged_in"] = True
return jsonify({"ok": True, "remaining": _login_limiter.max_attempts})
_login_limiter.record_failure(ip)
_, remaining, _ = _login_limiter.check(ip)
return jsonify({
"ok": False,
"error": "Неверный логин или пароль",
"remaining": remaining,
}), 401
return render_template("login.html", base=BASE)
@app.route(f"{BASE}/logout")
def logout():
session.clear()
return redirect(url_for("login_page"))
# ── API: dashboard ────────────────────────────────────────────────────────────
@app.route(f"{BASE}/api/dashboard")
@login_required
def api_dashboard():
try:
stats = get_traffic_stats()
except Exception:
stats = {"week_gb": 0, "month_gb": 0, "users": []}
try:
warp_ip = get_warp_ip()
except Exception:
warp_ip = "недоступен"
return jsonify({
"server_ip": get_server_ip(),
"warp_ip": warp_ip,
"week_gb": stats["week_gb"],
"month_gb": stats["month_gb"],
"mita_port": get_port_info(),
"users_count": len(load_mita_config().get("users", [])),
"mita_running": _mita_running(),
})
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
# ── API: users ────────────────────────────────────────────────────────────────
@app.route(f"{BASE}/api/users", methods=["GET"])
@login_required
def api_users_get():
cfg = load_mita_config()
users = cfg.get("users", [])
# egress rules для определения warp-пользователей
egress = cfg.get("egress", {})
return jsonify({"users": [{"name": u["name"]} for u in users], "egress": egress})
@app.route(f"{BASE}/api/users/create", methods=["POST"])
@login_required
def api_users_create():
data = request.get_json(silent=True) or {}
count = int(data.get("count", 1))
mode = data.get("mode", "manual") # manual | auto
names = data.get("names", []) # для manual
cfg = load_mita_config()
existing = {u["name"] for u in cfg.get("users", [])}
created = []
for i in range(count):
if mode == "manual" and i < len(names):
name = names[i].strip()
if not name:
continue
else:
name = gen_username()
while name in existing:
name = gen_username()
if name in existing:
continue
password = gen_password()
cfg.setdefault("users", []).append({"name": name, "password": password})
existing.add(name)
created.append({
"name": name,
"password": password,
"client_config": build_client_config(name, password),
})
save_mita_config(cfg)
return jsonify({"created": created})
@app.route(f"{BASE}/api/users/delete", methods=["POST"])
@login_required
def api_users_delete():
data = request.get_json(silent=True) or {}
name = data.get("name", "")
cfg = load_mita_config()
cfg["users"] = [u for u in cfg.get("users", []) if u["name"] != name]
save_mita_config(cfg)
return jsonify({"ok": True})
@app.route(f"{BASE}/api/users/warp", methods=["POST"])
@login_required
def api_users_warp():
"""Включить/выключить WARP для конкретного пользователя через egress.users."""
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:
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))
# Примечание: mita не поддерживает per-user egress нативно.
# Флаг хранится в panel.json и отображается в UI как справочная информация.
# Реальное разделение — через отдельные порты (см. README).
return jsonify({"ok": True, "note": "stored_in_panel_config"})
@app.route(f"{BASE}/api/users/warp_status")
@login_required
def api_users_warp_status():
pc = load_panel_config()
return jsonify({"warp_users": pc.get("warp_users", [])})
# ── API: SSL ──────────────────────────────────────────────────────────────────
@app.route(f"{BASE}/api/ssl/status")
@login_required
def api_ssl_status():
pc = load_panel_config()
return jsonify({
"type": pc.get("ssl_type", "none"),
"domain": pc.get("ssl_domain", ""),
"cert": pc.get("ssl_cert", ""),
"expires": pc.get("ssl_expires", ""),
})
@app.route(f"{BASE}/api/ssl/selfsigned", methods=["POST"])
@login_required
def api_ssl_selfsigned():
cert_dir = "/etc/mita/ssl"
os.makedirs(cert_dir, exist_ok=True)
cert = f"{cert_dir}/selfsigned.crt"
key = f"{cert_dir}/selfsigned.key"
r = subprocess.run([
"openssl","req","-x509","-newkey","rsa:4096","-sha256",
"-days","3650","-nodes",
"-keyout", key, "-out", cert,
"-subj", "/CN=mita-panel/O=mita/C=XX"
], capture_output=True, text=True)
if r.returncode != 0:
return jsonify({"ok": False, "error": r.stderr}), 500
# Прописать в env-файл панели
_update_env("SSL_CERT", cert)
_update_env("SSL_KEY", key)
# Получить дату истечения
exp = subprocess.run(
["openssl","x509","-noout","-enddate","-in",cert],
capture_output=True, text=True).stdout.strip()
pc = load_panel_config()
pc.update({"ssl_type":"selfsigned","ssl_cert":cert,"ssl_key":key,"ssl_expires":exp})
Path(PANEL_CONFIG).write_text(json.dumps(pc,indent=2))
return jsonify({"ok": True, "expires": exp,
"note": "Перезапустите панель: systemctl restart mita-panel"})
@app.route(f"{BASE}/api/ssl/letsencrypt", methods=["POST"])
@login_required
def api_ssl_letsencrypt():
data = request.get_json(silent=True) or {}
domain = data.get("domain","").strip()
email = data.get("email","").strip()
if not domain:
return jsonify({"ok": False, "error": "Укажите домен"}), 400
# Установить certbot если нет
if subprocess.run(["which","certbot"], capture_output=True).returncode != 0:
subprocess.run(["apt-get","install","-y","-qq","certbot"], capture_output=True)
panel_port = int(os.environ.get("PANEL_PORT", "8080"))
cmd = ["certbot","certonly","--standalone","--non-interactive",
"--agree-tos","--http-01-port","80",
"-d", domain]
if email:
cmd += ["--email", email]
else:
cmd += ["--register-unsafely-without-email"]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
return jsonify({"ok": False, "error": r.stdout + r.stderr}), 500
cert = f"/etc/letsencrypt/live/{domain}/fullchain.pem"
key = f"/etc/letsencrypt/live/{domain}/privkey.pem"
_update_env("SSL_CERT", cert)
_update_env("SSL_KEY", key)
exp = subprocess.run(
["openssl","x509","-noout","-enddate","-in",cert],
capture_output=True, text=True).stdout.strip()
pc = load_panel_config()
pc.update({"ssl_type":"letsencrypt","ssl_domain":domain,
"ssl_cert":cert,"ssl_key":key,"ssl_expires":exp})
Path(PANEL_CONFIG).write_text(json.dumps(pc,indent=2))
return jsonify({"ok": True, "expires": exp,
"note": "Перезапустите панель: systemctl restart mita-panel"})
def _update_env(key, value):
env_file = "/etc/mita/panel.env"
lines = []
found = False
if os.path.exists(env_file):
for line in Path(env_file).read_text().splitlines():
if line.startswith(f"{key}="):
lines.append(f"{key}={value}")
found = True
else:
lines.append(line)
if not found:
lines.append(f"{key}={value}")
Path(env_file).write_text("\n".join(lines) + "\n")
# ── 404 для всего вне секретного пути ────────────────────────────────────────
@app.before_request
def check_secret_path():
if not SECRET_PATH:
return
path = request.path.rstrip("/") or "/"
allowed_prefix = f"/{SECRET_PATH}"
if not (path == allowed_prefix or path.startswith(allowed_prefix + "/")):
abort(404)
if __name__ == "__main__":
port = int(os.environ.get("PANEL_PORT", "8080"))
ssl_ctx = None
if SSL_CERT and SSL_KEY and os.path.exists(SSL_CERT) and os.path.exists(SSL_KEY):
import ssl as _ssl
ssl_ctx = (SSL_CERT, SSL_KEY)
app.run(host="0.0.0.0", port=port, ssl_context=ssl_ctx)
# ── API: получить конфиг конкретного пользователя (пароль из server_config.json) ──
@app.route(f"{BASE}/api/users/config")
@login_required
def api_user_config():
name = request.args.get("name", "")
if not name:
return jsonify({"ok": False, "error": "Не указано имя"}), 400
cfg = load_mita_config()
user = next((u for u in cfg.get("users", []) if u["name"] == name), None)
if not user:
return jsonify({"ok": False, "error": "Пользователь не найден"}), 404
return jsonify({
"ok": True,
"name": user["name"],
"password": user["password"],
"client_config": build_client_config(user["name"], user["password"]),
"singbox_config": build_singbox_config(user["name"], user["password"]),
})
# ── API: WARP-правила конкретного пользователя ───────────────────────────────
@app.route(f"{BASE}/api/users/warp_rules")
@login_required
def api_warp_rules_get():
name = request.args.get("name", "")
if not name:
return jsonify({"ok": False, "error": "Не указано имя"}), 400
pc = load_panel_config()
rules = pc.get("warp_rules", {}).get(name, {
"domains": [],
"ips": [],
"sources": [], # URL или geosite:/geoip: ссылки
})
return jsonify({"ok": True, "name": name, "rules": rules})
@app.route(f"{BASE}/api/users/warp_rules", methods=["POST"])
@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
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()],
}
Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2))
# Перегенерировать egress в mita config на основе всех пользователей
_rebuild_egress(pc)
return jsonify({"ok": True})
def _get_geosite_data():
"""
Скачивает (с кэшированием на 7 дней) единый YAML файл со всеми
geosite-категориями и возвращает распарсенный dict.
Старый способ (отдельный .txt на каждую категорию на ветке release)
больше не поддерживается проектом v2fly — теперь только единый
dlc.dat_plain.yml в latest release.
"""
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
warp_users = set(pc.get("warp_users", []))
all_domains = set()
all_ips = set()
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 # не критично — продолжаем без этого источника
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
cfg["egress"] = {
"proxies": [{
"name": "warp",
"protocol": "SOCKS5_PROXY_PROTOCOL",
"host": "127.0.0.1",
"port": WARP_PORT,
}],
"rules": [
warp_rule,
{"ipRanges": ["*"], "domainNames": ["*"], "action": "DIRECT"},
],
}
save_mita_config(cfg)
# ── API: fail2ban config ──────────────────────────────────────────────────────
@app.route(f"{BASE}/api/fail2ban/status")
@login_required
def api_fail2ban_status():
installed = subprocess.run(["which","fail2ban-client"],
capture_output=True).returncode == 0
active = False
jail_active = False
max_retry = _login_limiter.max_attempts
ban_time = _login_limiter.window
if installed:
r = subprocess.run(["systemctl","is-active","fail2ban"],
capture_output=True, text=True)
active = r.stdout.strip() == "active"
if active:
r2 = subprocess.run(
["fail2ban-client","status","mita-panel"],
capture_output=True, text=True)
jail_active = r2.returncode == 0
# Read current limits from panel config
pc = load_panel_config()
max_retry = pc.get("login_max_attempts", 5)
ban_time = pc.get("login_ban_seconds", 3600)
return jsonify({
"installed": installed,
"active": active,
"jail_active": jail_active,
"max_retry": max_retry,
"ban_time": ban_time,
})
@app.route(f"{BASE}/api/fail2ban/configure", methods=["POST"])
@login_required
def api_fail2ban_configure():
data = request.get_json(silent=True) or {}
max_retry = int(data.get("max_retry", 5))
ban_time = int(data.get("ban_time", 3600))
install_f2b = data.get("install", False)
if max_retry < 1 or max_retry > 100:
return jsonify({"ok": False, "error": "max_retry должен быть от 1 до 100"}), 400
if ban_time < 60:
return jsonify({"ok": False, "error": "ban_time минимум 60 секунд"}), 400
# Update in-memory limiter
_login_limiter.update_limits(max_retry, ban_time)
# Save to panel config
pc = load_panel_config()
pc["login_max_attempts"] = max_retry
pc["login_ban_seconds"] = ban_time
Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2))
# Install fail2ban if requested
if install_f2b:
r = subprocess.run(["apt-get","install","-y","-qq","fail2ban"],
capture_output=True, text=True)
if r.returncode != 0:
return jsonify({"ok": False, "error": "Ошибка установки fail2ban: " + r.stderr}), 500
# Write fail2ban filter for mita-panel
filter_content = """[Definition]
failregex = ^.*"POST /[^"]+/login[^"]*" 4(01|29).*$
ignoreregex =
"""
jail_content = f"""[mita-panel]
enabled = true
filter = mita-panel
backend = auto
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 = "Настройки сохранены и применены"
except Exception as e:
ok_msg = f"Настройки сохранены (fail2ban: {e})"
return jsonify({"ok": True, "message": ok_msg})
# ── API: скачать конфиг как файл ─────────────────────────────────────────────
@app.route(f"{BASE}/api/users/config/download")
@login_required
def api_user_config_download():
from flask import Response
name = request.args.get("name", "")
fmt = request.args.get("format", "mieru") # mieru | singbox
if not name:
abort(400)
cfg = load_mita_config()
user = next((u for u in cfg.get("users", []) if u["name"] == name), None)
if not user:
abort(404)
if fmt == "singbox":
data = json.dumps(build_singbox_config(user["name"], user["password"]),
indent=2, ensure_ascii=False)
filename = f"{name}_singbox.json"
else:
data = json.dumps(build_client_config(user["name"], user["password"]),
indent=2, ensure_ascii=False)
filename = f"{name}_mieru.json"
return Response(
data,
mimetype="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)