From 23cd9a2c922ac2177b5e430b4a07573366d65f8c Mon Sep 17 00:00:00 2001 From: grendervill Date: Fri, 19 Jun 2026 14:30:41 +0300 Subject: [PATCH] =?UTF-8?q?version=200.3=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20Karing,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B8=D0=BC=D1=8B=D1=85=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 866 +++++++++++++++++++++++++++++++++++++++++++ install-panel.sh | 334 +++++++++++++++++ install.sh | 636 +++++++++++++++++++++++++++++++ manage-users.sh | 170 +++++++++ mita-ctl.sh | 834 +++++++++++++++++++++++++++++++++++++++++ mswp.zip | Bin 49876 -> 0 bytes templates/base.html | 188 ++++++++++ templates/index.html | 750 +++++++++++++++++++++++++++++++++++++ templates/login.html | 93 +++++ 9 files changed, 3871 insertions(+) create mode 100644 app.py create mode 100755 install-panel.sh create mode 100755 install.sh create mode 100755 manage-users.sh create mode 100755 mita-ctl.sh delete mode 100644 mswp.zip create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/login.html diff --git a/app.py b/app.py new file mode 100644 index 0000000..70697ab --- /dev/null +++ b/app.py @@ -0,0 +1,866 @@ +#!/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 _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() + + GEOSITE_BASE = "https://raw.githubusercontent.com/v2fly/domain-list-community/release" + 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:"):] + url = f"{GEOSITE_BASE}/{cat}.txt" + lines = urllib.request.urlopen(url, timeout=10).read().decode().splitlines() + for line in lines: + line = line.strip() + if line and not line.startswith("#") and "include:" not in line and "regexp:" not in line: + all_domains.add(line.lstrip("full:")) + 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}"'} + ) + diff --git a/install-panel.sh b/install-panel.sh new file mode 100755 index 0000000..fb7974b --- /dev/null +++ b/install-panel.sh @@ -0,0 +1,334 @@ +#!/bin/bash +# ============================================================================= +# Установка mita Web Panel +# ============================================================================= +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-panel.sh" + +# ── проверка зависимостей ────────────────────────────────────────── +section "Проверка окружения" +command -v mita &>/dev/null || error "mita не установлен — сначала запустите install.sh" + +# ── выбор порта ──────────────────────────────────────────────────── +section "Выбор порта панели" + +find_free_port() { + local start=$1 + for port in $(seq $start 65535); do + if ! ss -tlnp 2>/dev/null | grep -q ":${port} " && \ + ! ss -tlnp 2>/dev/null | grep -q ":${port}$"; then + echo $port; return + fi + done + echo 8080 +} + +echo -e "Введите порт для панели (Enter = автовыбор из свободных начиная с 8080):" +read -r -p "Порт: " USER_PORT +if [[ -z "$USER_PORT" ]]; then + PANEL_PORT=$(find_free_port 8080) + info "Автовыбран свободный порт: $PANEL_PORT" +elif [[ "$USER_PORT" =~ ^[0-9]+$ ]] && [[ $USER_PORT -ge 1024 && $USER_PORT -le 65535 ]]; then + if ss -tlnp 2>/dev/null | grep -q ":${USER_PORT}[[:space:]]"; then + warn "Порт $USER_PORT занят, выбираем следующий свободный..." + PANEL_PORT=$(find_free_port $((USER_PORT+1))) + info "Выбран порт: $PANEL_PORT" + else + PANEL_PORT=$USER_PORT + ok "Порт: $PANEL_PORT" + fi +else + error "Неверный порт: $USER_PORT" +fi + +# ── установка Python, Flask и системных утилит ────────────────────── +section "Установка зависимостей" + +apt-get update -qq +apt-get install -y -qq \ + python3 python3-pip python3-venv \ + openssl curl wget jq \ + fail2ban \ + certbot \ + iptables ufw \ + ca-certificates gnupg lsb-release \ + net-tools \ + 2>/dev/null +ok "Системные пакеты установлены (Python, fail2ban, certbot, iptables, ufw)" + +# fail2ban не запускаем автоматически — пользователь настроит лимиты +# через раздел "Безопасность" в панели или mita-ctl → пункт 7 +systemctl enable fail2ban 2>/dev/null || true +ok "fail2ban установлен (настройка — через панель или mita-ctl)" + +PANEL_DIR="/opt/mita-panel" +mkdir -p "$PANEL_DIR" + +python3 -m venv "$PANEL_DIR/venv" +"$PANEL_DIR/venv/bin/pip" install -q flask gunicorn 2>/dev/null +ok "Python venv и Flask установлены" + +# ── копирование файлов ───────────────────────────────────────────── +section "Копирование файлов панели" + +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/" +ok "Файлы скопированы в $PANEL_DIR" + +# ── генерация секретов ───────────────────────────────────────────── +section "Генерация учётных данных" + +gen_pass() { + openssl rand -base64 32 | tr -d '/+=' | head -c 24 +} +gen_path() { + openssl rand -hex 10 +} + +ADMIN_USER="admin" +ADMIN_PASS=$(gen_pass) +SECRET_PATH=$(gen_path) +FLASK_SECRET=$(openssl rand -hex 32) + +ok "Логин и секретный путь сгенерированы" + +# ── выбор способа подключения ────────────────────────────────────── +section "Способ подключения к панели" +echo -e " ${BOLD}1.${NC} По IP-адресу (открытый доступ)" +echo -e " ${BOLD}2.${NC} SSH-туннель (панель только на 127.0.0.1, недоступна снаружи)" +echo -e " ${BOLD}3.${NC} По доменному имени с Let's Encrypt" +echo "" +read -r -p "Выберите способ [1]: " ACCESS_MODE_CHOICE +ACCESS_MODE_CHOICE=${ACCESS_MODE_CHOICE:-1} + +ACCESS_MODE="ip" +PANEL_BIND="0.0.0.0" +SSL_DOMAIN="" +SSH_ACCESS_PORT=22 +LE_DOMAIN="" + +case "$ACCESS_MODE_CHOICE" in + 2) + ACCESS_MODE="ssh" + PANEL_BIND="127.0.0.1" + read -r -p "SSH-порт вашего VPS [22]: " SSH_ACCESS_PORT + SSH_ACCESS_PORT=${SSH_ACCESS_PORT:-22} + info "Панель будет доступна только через SSH-туннель" + ;; + 3) + ACCESS_MODE="domain" + PANEL_BIND="0.0.0.0" + read -r -p "Домен для панели (например panel.example.com): " LE_DOMAIN + [[ -z "$LE_DOMAIN" ]] && { warn "Домен не указан, переключаемся на IP"; ACCESS_MODE="ip"; } + ;; +esac + +# ── конфиг панели ───────────────────────────────────────────────── +PANEL_CONFIG="/etc/mita/panel.json" +mkdir -p /etc/mita + +# Получить WARP_PORT из существующего env если есть +WARP_PORT=40000 +[[ -f /etc/mita/panel.env ]] && WARP_PORT=$(grep '^WARP_PORT=' /etc/mita/panel.env | cut -d= -f2 || echo 40000) + +cat > "$PANEL_CONFIG" << EOF +{ + "admin_user": "$ADMIN_USER", + "admin_pass": "$ADMIN_PASS", + "secret_path": "$SECRET_PATH", + "panel_port": $PANEL_PORT, + "access_mode": "$ACCESS_MODE", + "ssh_port": $SSH_ACCESS_PORT, + "ssl_type": "none", + "warp_users": [] +} +EOF +ok "Конфиг панели: $PANEL_CONFIG" + +# ── env файл ────────────────────────────────────────────────────── +cat > /etc/mita/panel.env << EOF +PANEL_PORT=$PANEL_PORT +SECRET_PATH=$SECRET_PATH +FLASK_SECRET=$FLASK_SECRET +PANEL_CONFIG=$PANEL_CONFIG +MITA_CONFIG=/etc/mita/server_config.json +WARP_PORT=$WARP_PORT +SSL_CERT= +SSL_KEY= +EOF +ok "Env файл: /etc/mita/panel.env" + +# ── враппер запуска (динамически подхватывает SSL из panel.env) ── +section "Создание systemd сервиса" + +cat > /opt/mita-panel/start.sh << 'STARTEOF' +#!/bin/bash +# Читаем актуальный panel.env каждый раз при старте +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 0.0.0.0:${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 +STARTEOF +chmod +x /opt/mita-panel/start.sh +ok "Враппер запуска создан: /opt/mita-panel/start.sh" + +cat > /etc/systemd/system/mita-panel.service << EOF +[Unit] +Description=mita Web Panel +After=network.target mita.service +Wants=mita.service + +[Service] +Type=simple +User=root +WorkingDirectory=$PANEL_DIR +ExecStart=/opt/mita-panel/start.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable mita-panel +systemctl restart mita-panel +sleep 2 + +if systemctl is-active --quiet mita-panel; then + ok "mita-panel запущен" +else + warn "Не удалось запустить — проверьте: journalctl -u mita-panel -n 30" +fi + +# ── firewall ────────────────────────────────────────────────────── +section "Открытие порта в firewall" + +if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then + ufw allow "$PANEL_PORT/tcp" comment "mita-panel" 2>/dev/null || true + ok "UFW: порт $PANEL_PORT открыт" +elif command -v iptables &>/dev/null; then + iptables -I INPUT -p tcp --dport "$PANEL_PORT" -j ACCEPT 2>/dev/null || true + ok "iptables: порт $PANEL_PORT открыт" +fi + +# ── итог ────────────────────────────────────────────────────────── +section "Установка завершена!" + +SERVER_IP=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}') +PROTO="http" + +echo "" +echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗" +echo -e "║ ПАНЕЛЬ УПРАВЛЕНИЯ УСТАНОВЛЕНА ║" +echo -e "╚══════════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${CYAN}Адрес панели:${NC}" +echo -e " ${BOLD}${YELLOW}${PROTO}://${SERVER_IP}:${PANEL_PORT}/${SECRET_PATH}/${NC}" +echo "" +echo -e "${CYAN}Учётные данные:${NC}" +echo -e " Логин: ${BOLD}${ADMIN_USER}${NC}" +echo -e " Пароль: ${BOLD}${YELLOW}${ADMIN_PASS}${NC}" +echo "" +echo -e "${CYAN}Секретный путь:${NC}" +echo -e " ${BOLD}/${SECRET_PATH}/${NC}" +echo -e " ${RED}Без него панель недоступна (возвращает 404)${NC}" +echo "" +echo -e "${CYAN}Полная ссылка для браузера:${NC}" +echo -e " ${BOLD}${GREEN}${PROTO}://${SERVER_IP}:${PANEL_PORT}/${SECRET_PATH}/${NC}" +echo "" +echo -e "${CYAN}Настройка SSL (опционально):${NC}" +echo -e " Войдите в панель → раздел SSL → выберите тип сертификата" +echo -e " После установки SSL: ${BOLD}systemctl restart mita-panel${NC}" +echo "" +echo -e "${CYAN}Полезные команды:${NC}" +echo -e " ${YELLOW}systemctl status mita-panel${NC} — статус" +echo -e " ${YELLOW}journalctl -u mita-panel -f${NC} — логи" +echo -e " ${YELLOW}systemctl restart mita-panel${NC} — перезапуск" +echo "" +echo -e "${RED}⚠ Сохраните ссылку и пароль — они больше не отобразятся!${NC}" +echo "" + +# ── SSH туннель: финальные инструкции ───────────────────────────── +if [[ "$ACCESS_MODE" == "ssh" ]]; then + echo "" + echo -e "${CYAN}┌──────────────────────────────────────────────────────────────┐${NC}" + echo -e "${CYAN}│ Подключение к панели через SSH-туннель │${NC}" + echo -e "${CYAN}└──────────────────────────────────────────────────────────────┘${NC}" + echo "" + echo -e " Выполните на ${BOLD}вашем компьютере${NC}:" + echo "" + echo -e " ${YELLOW}ssh -L ${PANEL_PORT}:127.0.0.1:${PANEL_PORT} -p ${SSH_ACCESS_PORT} root@${SERVER_IP} -N${NC}" + echo "" + echo -e " Затем откройте в браузере:" + echo -e " ${BOLD}${GREEN}http://127.0.0.1:${PANEL_PORT}/${SECRET_PATH}/${NC}" + echo "" + echo -e " Для фонового режима:" + echo -e " ${YELLOW}ssh -fN -L ${PANEL_PORT}:127.0.0.1:${PANEL_PORT} -p ${SSH_ACCESS_PORT} root@${SERVER_IP}${NC}" + echo "" +fi + +# ── Let's Encrypt ───────────────────────────────────────────────── +if [[ "$ACCESS_MODE" == "domain" && -n "$LE_DOMAIN" ]]; then + info "Получение Let's Encrypt сертификата для $LE_DOMAIN..." + command -v certbot &>/dev/null || apt-get install -y -qq certbot + if certbot certonly --standalone --non-interactive --agree-tos \ + --register-unsafely-without-email -d "$LE_DOMAIN" 2>/dev/null; then + LE_CERT="/etc/letsencrypt/live/${LE_DOMAIN}/fullchain.pem" + LE_KEY="/etc/letsencrypt/live/${LE_DOMAIN}/privkey.pem" + echo "SSL_CERT=$LE_CERT" >> /etc/mita/panel.env + echo "SSL_KEY=$LE_KEY" >> /etc/mita/panel.env + python3 -c " +import json +with open('$PANEL_CONFIG') as f: d=json.load(f) +d.update({'ssl_type':'letsencrypt','ssl_domain':'$LE_DOMAIN','ssl_cert':'$LE_CERT','ssl_key':'$LE_KEY'}) +with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) +" + (crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet && systemctl restart mita-panel") | sort -u | crontab - + systemctl restart mita-panel 2>/dev/null || true + ok "Сертификат получен" + PROTO="https" + else + warn "Не удалось получить сертификат Let's Encrypt. Убедитесь что $LE_DOMAIN указывает на этот сервер и порт 80 открыт." + PROTO="http" + fi +fi + +# ── Установка mita-ctl как системной команды ────────────────────── +section "Установка утилиты mita-ctl" + +SCRIPT_DIR_CTL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CTL_SRC="$SCRIPT_DIR_CTL/mita-ctl.sh" + +if [[ -f "$CTL_SRC" ]]; then + cp "$CTL_SRC" /usr/local/bin/mita-ctl + chmod +x /usr/local/bin/mita-ctl + ok "mita-ctl установлен. Запуск: sudo mita-ctl" +else + warn "mita-ctl.sh не найден рядом — пропускаем" + warn "Чтобы установить вручную: cp mita-ctl.sh /usr/local/bin/mita-ctl && chmod +x /usr/local/bin/mita-ctl" +fi diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1e10dfa --- /dev/null +++ b/install.sh @@ -0,0 +1,636 @@ +#!/bin/bash +# ============================================================================= +# mita + WARP Setup Script +# Ubuntu / Debian VPS +# ============================================================================= +set -euo pipefail + +# Директория, из которой запущен скрипт (нужна для поиска install-panel.sh рядом) +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' + +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}"; } + +# ============================================================================= +# ██████████████████████████████████████████████████████████████████████████ +# !! НАСТРОЙКИ — РЕДАКТИРОВАТЬ ЗДЕСЬ !! +# ██████████████████████████████████████████████████████████████████████████ +# ============================================================================= + +# ---- Порты mita (можно указать диапазон или одиночные порты) ---- +MITA_PORT_RANGE="2100-2110" # диапазон портов mita +MITA_PROTOCOL="TCP" # TCP или UDP + +# ---- Пользователи mita ---- +# Формат: "имя:пароль" +# Добавляйте сколько угодно строк +MITA_USERS=( + "alice:ЗАМЕНИТЕ_НА_СИЛЬНЫЙ_ПАРОЛЬ_1" + "bob:ЗАМЕНИТЕ_НА_СИЛЬНЫЙ_ПАРОЛЬ_2" +) + +# ---- WARP ---- +WARP_ENABLED=true # true — включить WARP; false — только mita без WARP +WARP_PORT=40000 # локальный порт SOCKS5 от WARP (не менять без причины) + +# ---- Логирование mita ---- +MITA_LOG_LEVEL="INFO" # INFO или DEBUG + +# ============================================================================= +# !! КОНЕЦ НАСТРОЕК !! +# ============================================================================= + +MITA_CONFIG_DIR="/etc/mita" +MITA_CONFIG_FILE="$MITA_CONFIG_DIR/server_config.json" +LISTS_DIR="/etc/mita/lists" +CUSTOM_DOMAINS_FILE="$LISTS_DIR/custom_domains.txt" +CUSTOM_IPS_FILE="$LISTS_DIR/custom_ips.txt" +AUTO_DOMAINS_FILE="$LISTS_DIR/auto_domains.txt" +AUTO_IPS_FILE="$LISTS_DIR/auto_ips.txt" +UPDATE_SCRIPT="/usr/local/bin/mita-update-lists" +CRON_FILE="/etc/cron.d/mita-update" + +# ============================================================================= +# ПРОВЕРКИ +# ============================================================================= +section "Проверка окружения" + +[[ $EUID -ne 0 ]] && error "Запустите скрипт от root: sudo bash install.sh" + +OS=$(. /etc/os-release && echo "$ID") +[[ "$OS" != "ubuntu" && "$OS" != "debian" ]] && error "Поддерживается только Ubuntu/Debian" +ok "ОС: $OS" + +# Проверка пользователей +for entry in "${MITA_USERS[@]}"; do + name="${entry%%:*}"; pass="${entry##*:}" + [[ "$pass" == *"ЗАМЕНИТЕ"* ]] && error "Замените пароль для пользователя '$name' в начале скрипта!" + [[ ${#pass} -lt 8 ]] && error "Пароль пользователя '$name' слишком короткий (минимум 8 символов)" +done +ok "Пользователи настроены корректно (${#MITA_USERS[@]} шт.)" + +# ============================================================================= +# ЗАВИСИМОСТИ +# ============================================================================= +section "Установка зависимостей" + +apt-get update -qq +apt-get install -y -qq \ + curl wget jq cron \ + ca-certificates gnupg lsb-release \ + iptables ipset ufw \ + fail2ban openssl \ + net-tools \ + 2>/dev/null +ok "Базовые пакеты установлены (включая fail2ban, ufw, ipset)" + +# ============================================================================= +# УСТАНОВКА MITA +# ============================================================================= +section "Установка mita" + +MITA_VERSION=$(curl -s https://api.github.com/repos/enfein/mieru/releases/latest \ + | jq -r '.tag_name' | tr -d 'v') +[[ -z "$MITA_VERSION" ]] && error "Не удалось получить версию mita с GitHub" +info "Последняя версия mita: $MITA_VERSION" + +ARCH=$(uname -m) +case "$ARCH" in + x86_64) DEB_ARCH="amd64" ;; + aarch64) DEB_ARCH="arm64" ;; + armv7l) DEB_ARCH="armhf" ;; + *) error "Неподдерживаемая архитектура: $ARCH" ;; +esac + +DEB_FILE="mita_${MITA_VERSION}_${DEB_ARCH}.deb" +DL_URL="https://github.com/enfein/mieru/releases/download/v${MITA_VERSION}/${DEB_FILE}" + +info "Загрузка: $DL_URL" +wget -q --show-progress "$DL_URL" -O /tmp/"$DEB_FILE" || error "Ошибка загрузки mita" +dpkg -i /tmp/"$DEB_FILE" || apt-get install -f -y -qq +rm -f /tmp/"$DEB_FILE" +ok "mita $MITA_VERSION установлен" + +# ============================================================================= +# УСТАНОВКА DOCKER + WARP +# ============================================================================= +if $WARP_ENABLED; then + section "Установка Docker и Cloudflare WARP" + + if ! command -v docker &>/dev/null; then + info "Установка Docker..." + curl -fsSL https://get.docker.com | sh -s -- -q + systemctl enable docker --now + ok "Docker установлен" + else + ok "Docker уже установлен" + fi + + info "Запуск Cloudflare WARP в Docker..." + docker rm -f cloudflare-warp 2>/dev/null || true + docker run -d \ + --name cloudflare-warp \ + --restart unless-stopped \ + -p 127.0.0.1:${WARP_PORT}:40000 \ + seiry/cloudflare-warp-proxy + + # Подождать пока WARP поднимется + info "Ожидание запуска WARP (до 30 сек)..." + for i in $(seq 1 30); do + if curl -s --max-time 3 --proxy "socks5h://127.0.0.1:${WARP_PORT}" \ + "https://www.cloudflare.com/cdn-cgi/trace" 2>/dev/null | grep -q "warp=on"; then + ok "WARP активен и работает" + break + fi + sleep 1 + [[ $i -eq 30 ]] && warn "WARP не ответил за 30 сек — продолжаем, проверьте позже: docker logs cloudflare-warp" + done +fi + +# ============================================================================= +# СОЗДАНИЕ ДИРЕКТОРИЙ И ФАЙЛОВ СПИСКОВ +# ============================================================================= +section "Создание файлов конфигурации" + +mkdir -p "$MITA_CONFIG_DIR" "$LISTS_DIR" + +# ---- custom_domains.txt ---- +cat > "$CUSTOM_DOMAINS_FILE" << 'EOF' +# ================================================================ +# ПОЛЬЗОВАТЕЛЬСКИЙ СПИСОК ДОМЕНОВ → трафик пойдёт через WARP +# ================================================================ +# Добавляйте домены по одному на строку. +# Строки начинающиеся с # — комментарии, игнорируются. +# Субдомены включаются автоматически (openai.com → *.openai.com). +# +# Примеры: +# openai.com +# chatgpt.com +# anthropic.com +# claude.ai +# ================================================================ + +EOF +ok "Создан: $CUSTOM_DOMAINS_FILE" + +# ---- custom_ips.txt ---- +cat > "$CUSTOM_IPS_FILE" << 'EOF' +# ================================================================ +# ПОЛЬЗОВАТЕЛЬСКИЙ СПИСОК IP/CIDR → трафик пойдёт через WARP +# ================================================================ +# Формат: одна подсеть CIDR или IP на строку. +# Примеры: +# 104.16.0.0/12 +# 172.64.0.0/13 +# ================================================================ + +EOF +ok "Создан: $CUSTOM_IPS_FILE" + +# ---- auto_domains.txt (источники для автообновления) ---- +cat > "$AUTO_DOMAINS_FILE" << 'EOF' +# ================================================================ +# ИСТОЧНИКИ АВТООБНОВЛЯЕМЫХ ДОМЕНОВ (ссылки или geosite-категории) +# ================================================================ +# Поддерживаемые форматы строк: +# +# 1) URL на текстовый файл (один домен на строку): +# https://raw.githubusercontent.com/v2fly/domain-list-community/release/openai.txt +# +# 2) geosite-категория из репозитория v2fly: +# geosite:openai +# geosite:google +# geosite:netflix +# geosite:category-ru ← российские сайты +# (полный список: github.com/v2fly/domain-list-community/tree/master/data) +# +# Строки с # — комментарии. +# ================================================================ + +# --- OpenAI / AI сервисы --- +geosite:openai + +# --- Примеры (раскомментируйте нужное) --- +# geosite:google +# geosite:netflix +# https://raw.githubusercontent.com/nicholaswilde/blocklists/main/domain-lists/streaming.txt +EOF +ok "Создан: $AUTO_DOMAINS_FILE" + +# ---- auto_ips.txt ---- +cat > "$AUTO_IPS_FILE" << 'EOF' +# ================================================================ +# ИСТОЧНИКИ АВТООБНОВЛЯЕМЫХ IP-ДИАПАЗОНОВ +# ================================================================ +# Формат строк: +# +# 1) URL на файл с CIDR: +# https://raw.githubusercontent.com/herrbischoff/country-ip-blocks/master/ipv4/us.cidr +# +# 2) geoip-страна (ISO-код): +# geoip:us +# geoip:gb +# geoip:de +# +# ================================================================ + +# Пример: американские IP через WARP +# geoip:us +EOF +ok "Создан: $AUTO_IPS_FILE" + +# ============================================================================= +# СКРИПТ ОБНОВЛЕНИЯ СПИСКОВ +# ============================================================================= +section "Создание скрипта обновления списков" + +cat > "$UPDATE_SCRIPT" << UPDATEEOF +#!/bin/bash +# Автообновление списков доменов/IP и перегенерация конфига mita +# Запускается cron ежедневно + +set -euo pipefail +LISTS_DIR="$LISTS_DIR" +MITA_CONFIG_FILE="$MITA_CONFIG_FILE" +WARP_ENABLED=$WARP_ENABLED +WARP_PORT=$WARP_PORT +MITA_PORT_RANGE="$MITA_PORT_RANGE" +MITA_PROTOCOL="$MITA_PROTOCOL" +MITA_LOG_LEVEL="$MITA_LOG_LEVEL" +GEOSITE_BASE="https://raw.githubusercontent.com/v2fly/domain-list-community/release" +GEOIP_BASE="https://raw.githubusercontent.com/herrbischoff/country-ip-blocks/master/ipv4" + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] \$*" | tee -a /var/log/mita-update.log; } + +# ---------- Сбор доменов ---------- +log "Сбор доменов..." +TMP_DOMAINS=\$(mktemp) + +# Пользовательские домены +grep -v '^\s*#' "\$LISTS_DIR/custom_domains.txt" | grep -v '^\s*$' >> "\$TMP_DOMAINS" || true + +# Автоматические источники +while IFS= read -r line; do + line="\$(echo "\$line" | xargs)" + [[ -z "\$line" || "\$line" == \#* ]] && continue + + if [[ "\$line" == geosite:* ]]; then + category="\${line#geosite:}" + log " geosite:\$category" + curl -sf "\$GEOSITE_BASE/\${category}.txt" 2>/dev/null \ + | grep -v '^\s*#' | grep -v '^\s*$' | grep -v 'include:' | grep -v 'regexp:' \ + | sed 's/^full://' \ + >> "\$TMP_DOMAINS" || log " WARN: не удалось загрузить geosite:\$category" + + elif [[ "\$line" == http* ]]; then + log " URL: \$line" + curl -sf "\$line" 2>/dev/null \ + | grep -v '^\s*#' | grep -v '^\s*$' \ + >> "\$TMP_DOMAINS" || log " WARN: не удалось загрузить \$line" + fi +done < "\$LISTS_DIR/auto_domains.txt" + +# Дедупликация доменов +FINAL_DOMAINS=\$(sort -u "\$TMP_DOMAINS" | grep -v '^\s*$') +rm -f "\$TMP_DOMAINS" +DOMAIN_COUNT=\$(echo "\$FINAL_DOMAINS" | grep -c . || echo 0) +log "Доменов собрано: \$DOMAIN_COUNT" + +# ---------- Сбор IP-диапазонов ---------- +log "Сбор IP-диапазонов..." +TMP_IPS=\$(mktemp) + +grep -v '^\s*#' "\$LISTS_DIR/custom_ips.txt" | grep -v '^\s*$' >> "\$TMP_IPS" || true + +while IFS= read -r line; do + line="\$(echo "\$line" | xargs)" + [[ -z "\$line" || "\$line" == \#* ]] && continue + + if [[ "\$line" == geoip:* ]]; then + country="\${line#geoip:}" + log " geoip:\$country" + curl -sf "\$GEOIP_BASE/\${country}.cidr" 2>/dev/null \ + >> "\$TMP_IPS" || log " WARN: не удалось загрузить geoip:\$country" + + elif [[ "\$line" == http* ]]; then + log " URL: \$line" + curl -sf "\$line" 2>/dev/null | grep -v '^\s*#' | grep -v '^\s*$' \ + >> "\$TMP_IPS" || log " WARN: не удалось загрузить \$line" + fi +done < "\$LISTS_DIR/auto_ips.txt" + +FINAL_IPS=\$(sort -u "\$TMP_IPS" | grep -v '^\s*$') +rm -f "\$TMP_IPS" +IP_COUNT=\$(echo "\$FINAL_IPS" | grep -c . || echo 0) +log "IP-диапазонов собрано: \$IP_COUNT" + +# ---------- Сборка JSON egress-правил ---------- +build_egress() { + local domains="\$1" + local ips="\$2" + + if ! \$WARP_ENABLED; then + echo '{"rules": [{"ipRanges": ["*"], "domainNames": ["*"], "action": "DIRECT"}]}' + return + fi + + local domain_arr="[]" + local ip_arr="[]" + + [[ -n "\$domains" ]] && domain_arr=\$(echo "\$domains" | jq -R . | jq -s .) + [[ -n "\$ips" ]] && ip_arr=\$(echo "\$ips" | jq -R . | jq -s .) + + # Строим правила: сначала WARP-правило (если есть домены/IP), потом DIRECT для всего остального + local rules="[]" + + if [[ \$DOMAIN_COUNT -gt 0 || \$IP_COUNT -gt 0 ]]; then + warp_rule=\$(jq -n \ + --argjson d "\$domain_arr" \ + --argjson i "\$ip_arr" \ + '{action: "PROXY", proxyNames: ["warp"], domainNames: \$d, ipRanges: \$i}') + rules=\$(echo "[\$warp_rule]") + fi + + # Финальное правило — всё остальное напрямую + direct_rule='{"ipRanges": ["*"], "domainNames": ["*"], "action": "DIRECT"}' + rules=\$(echo "\$rules" | jq ". + [\$direct_rule]" --argjson direct "\$direct_rule") + + jq -n \ + --argjson rules "\$rules" \ + '{ + proxies: [{ + name: "warp", + protocol: "SOCKS5_PROXY_PROTOCOL", + host: "127.0.0.1", + port: \$ENV.WARP_PORT|tonumber + }], + rules: \$rules + }' <<< "" 2>/dev/null || jq -n \ + --argjson rules "\$rules" \ + --argjson port "\$WARP_PORT" \ + '{ + proxies: [{name: "warp", protocol: "SOCKS5_PROXY_PROTOCOL", host: "127.0.0.1", port: \$port}], + rules: \$rules + }' +} + +EGRESS_JSON=\$(build_egress "\$FINAL_DOMAINS" "\$FINAL_IPS") + +# ---------- Чтение текущих пользователей ---------- +CURRENT_USERS=\$(jq '.users' "\$MITA_CONFIG_FILE" 2>/dev/null || echo '[]') + +# ---------- Генерация итогового конфига ---------- +log "Генерация конфига mita..." + +# Разбор диапазона портов +if [[ "\$MITA_PORT_RANGE" == *"-"* ]]; then + PORT_BINDING=\$(jq -n --arg r "\$MITA_PORT_RANGE" --arg p "\$MITA_PROTOCOL" \ + '[{portRange: \$r, protocol: \$p}]') +else + PORT_BINDING=\$(jq -n --arg port "\$MITA_PORT_RANGE" --arg p "\$MITA_PROTOCOL" \ + '[{port: (\$port|tonumber), protocol: \$p}]') +fi + +jq -n \ + --argjson portBindings "\$PORT_BINDING" \ + --argjson users "\$CURRENT_USERS" \ + --arg logLevel "\$MITA_LOG_LEVEL" \ + --argjson egress "\$EGRESS_JSON" \ + '{ + portBindings: \$portBindings, + users: \$users, + loggingLevel: \$logLevel, + egress: \$egress + }' > "\${MITA_CONFIG_FILE}.tmp" + +mv "\${MITA_CONFIG_FILE}.tmp" "\$MITA_CONFIG_FILE" +log "Конфиг сохранён: \$MITA_CONFIG_FILE" + +# ---------- Применение конфига ---------- +log "Применение конфига mita..." +# Запустить mita если не запущен (нужен для apply config) +MITA_WAS_STOPPED=false +if ! systemctl is-active --quiet mita 2>/dev/null; then + log "mita не запущен — запускаем для применения конфига..." + systemctl reset-failed mita 2>/dev/null || true + /usr/bin/mita run & + MITA_BG_PID=\$! + sleep 3 + MITA_WAS_STOPPED=true +fi + +mita apply config "\$MITA_CONFIG_FILE" && log "Конфиг применён успешно" \ + || { log "ОШИБКА применения конфига"; exit 1; } + +if \$MITA_WAS_STOPPED; then + kill \$MITA_BG_PID 2>/dev/null || true + sleep 1 + systemctl reset-failed mita 2>/dev/null || true + systemctl start mita && log "mita запущен" || log "WARN: не удалось запустить mita" +else + systemctl restart mita + log "mita перезапущен" +fi + +log "Обновление завершено. Доменов: \$DOMAIN_COUNT, IP: \$IP_COUNT" +UPDATEEOF + +chmod +x "$UPDATE_SCRIPT" +ok "Скрипт обновления создан: $UPDATE_SCRIPT" + +# ============================================================================= +# ПЕРВОНАЧАЛЬНАЯ ГЕНЕРАЦИЯ КОНФИГА С ПОЛЬЗОВАТЕЛЯМИ +# ============================================================================= +section "Генерация начального конфига mita" + +# Собрать JSON пользователей +USERS_JSON="[" +for i in "${!MITA_USERS[@]}"; do + entry="${MITA_USERS[$i]}" + name="${entry%%:*}" + pass="${entry##*:}" + sep=$([[ $i -lt $((${#MITA_USERS[@]}-1)) ]] && echo "," || echo "") + USERS_JSON+=$(jq -n --arg n "$name" --arg p "$pass" \ + '{name: $n, password: $p}') + USERS_JSON+="$sep" +done +USERS_JSON+="]" + +# Разбор диапазона портов для начального конфига +if [[ "$MITA_PORT_RANGE" == *"-"* ]]; then + PORT_BINDING=$(jq -n --arg r "$MITA_PORT_RANGE" --arg p "$MITA_PROTOCOL" \ + '[{portRange: $r, protocol: $p}]') +else + PORT_BINDING=$(jq -n --arg port "$MITA_PORT_RANGE" --arg p "$MITA_PROTOCOL" \ + '[{port: ($port|tonumber), protocol: $p}]') +fi + +# Базовый конфиг без egress (применим после первого запуска update-lists) +jq -n \ + --argjson portBindings "$PORT_BINDING" \ + --argjson users "$USERS_JSON" \ + --arg logLevel "$MITA_LOG_LEVEL" \ + '{ + portBindings: $portBindings, + users: $users, + loggingLevel: $logLevel, + egress: {} + }' > "$MITA_CONFIG_FILE" + +ok "Начальный конфиг записан: $MITA_CONFIG_FILE" + +# Применить конфиг +mita apply config "$MITA_CONFIG_FILE" +ok "Конфиг mita применён" + +# ============================================================================= +# ЗАПУСК MITA +# ============================================================================= +section "Запуск mita" + +systemctl enable mita 2>/dev/null || true +systemctl restart mita +sleep 2 +if systemctl is-active --quiet mita; then + ok "mita запущен и работает" +else + warn "mita не запустился — проверьте: journalctl -u mita -n 50" +fi + +# ============================================================================= +# НАСТРОЙКА CRON +# ============================================================================= +section "Настройка автообновления (cron)" + +cat > "$CRON_FILE" << EOF +# Обновление списков доменов/IP для mita — каждый день в 04:00 +0 4 * * * root $UPDATE_SCRIPT >> /var/log/mita-update.log 2>&1 +EOF + +ok "Cron задача создана: $CRON_FILE" + +# ============================================================================= +# ПЕРВЫЙ ЗАПУСК ОБНОВЛЕНИЯ СПИСКОВ +# ============================================================================= +section "Первое обновление списков" + +info "Запуск $UPDATE_SCRIPT..." +bash "$UPDATE_SCRIPT" && ok "Списки обновлены, конфиг применён" \ + || warn "Ошибка при обновлении списков, проверьте /var/log/mita-update.log" + +# ============================================================================= +# ОТКРЫТИЕ ПОРТОВ В FIREWALL +# ============================================================================= +section "Настройка firewall" + +if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then + if [[ "$MITA_PORT_RANGE" == *"-"* ]]; then + ufw allow "${MITA_PORT_RANGE}/${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 открыты" +elif command -v iptables &>/dev/null; then + if [[ "$MITA_PORT_RANGE" == *"-"* ]]; then + START_PORT="${MITA_PORT_RANGE%-*}"; END_PORT="${MITA_PORT_RANGE#*-}" + iptables -I INPUT -p "${MITA_PROTOCOL,,}" --dport "$START_PORT:$END_PORT" -j ACCEPT 2>/dev/null || true + else + iptables -I INPUT -p "${MITA_PROTOCOL,,}" --dport "$MITA_PORT_RANGE" -j ACCEPT 2>/dev/null || true + fi + ok "iptables: порты открыты" +fi + +# ============================================================================= +# ИТОГОВЫЙ ОТЧЁТ +# ============================================================================= +section "Установка завершена!" + +SERVER_IP=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}') + +echo "" +echo -e "${GREEN}╔══════════════════════════════════════════════════════╗" +echo -e "║ УСТАНОВКА ЗАВЕРШЕНА УСПЕШНО ║" +echo -e "╚══════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${CYAN}Параметры сервера для клиентов:${NC}" +echo -e " IP сервера: ${YELLOW}$SERVER_IP${NC}" +echo -e " Порты mita: ${YELLOW}$MITA_PORT_RANGE ($MITA_PROTOCOL)${NC}" +echo "" +echo -e "${CYAN}Пользователи:${NC}" +for entry in "${MITA_USERS[@]}"; do + name="${entry%%:*}"; pass="${entry##*:}" + echo -e " ${YELLOW}$name${NC} / $pass" +done +echo "" +echo -e "${CYAN}Файлы конфигурации:${NC}" +echo -e " Конфиг mita: ${YELLOW}$MITA_CONFIG_FILE${NC}" +echo -e " Свои домены → WARP: ${YELLOW}$CUSTOM_DOMAINS_FILE${NC}" +echo -e " Свои IP → WARP: ${YELLOW}$CUSTOM_IPS_FILE${NC}" +echo -e " Авто-источники (dom): ${YELLOW}$AUTO_DOMAINS_FILE${NC}" +echo -e " Авто-источники (IP): ${YELLOW}$AUTO_IPS_FILE${NC}" +echo -e " Лог обновлений: ${YELLOW}/var/log/mita-update.log${NC}" +echo "" +echo -e "${CYAN}Полезные команды:${NC}" +echo -e " ${YELLOW}mita status${NC} — статус сервера" +echo -e " ${YELLOW}mita get users${NC} — список пользователей и их трафик" +echo -e " ${YELLOW}mita describe config${NC} — текущий конфиг" +echo -e " ${YELLOW}$UPDATE_SCRIPT${NC} — обновить списки вручную" +echo -e " ${YELLOW}journalctl -u mita -f${NC} — логи mita в реальном времени" +if $WARP_ENABLED; then + echo -e " ${YELLOW}docker logs cloudflare-warp${NC} — логи WARP" +fi +echo "" +echo -e "${CYAN}Следующий шаг — добавьте нужные домены в:${NC}" +echo -e " ${YELLOW}$CUSTOM_DOMAINS_FILE${NC} — ваши домены" +echo -e " ${YELLOW}$AUTO_DOMAINS_FILE${NC} — geosite/URL источники" +echo -e "Затем запустите: ${YELLOW}$UPDATE_SCRIPT${NC}" +echo "" + +# ============================================================================= +# УСТАНОВКА ВЕБ-ПАНЕЛИ (опционально) +# ============================================================================= +section "Веб-панель управления" + +PANEL_INSTALLER="$SCRIPT_DIR/mita-panel-pkg/install-panel.sh" + +if [[ ! -f "$PANEL_INSTALLER" ]]; then + warn "Файл install-panel.sh не найден рядом со скриптом." + warn "Ожидаемый путь: $PANEL_INSTALLER" + warn "Скачайте архив mita-panel.zip, распакуйте рядом с install.sh и запустите install-panel.sh вручную." +else + echo -e "Установить веб-панель управления mita? (логин/пароль и секретный путь генерируются автоматически)" + echo -e " ${GREEN}y${NC} — да, установить сейчас" + echo -e " ${YELLOW}n${NC} — пропустить (можно установить позже: bash $PANEL_INSTALLER)" + echo "" + read -r -p "Установить панель? [y/N]: " INSTALL_PANEL + echo "" + + if [[ "${INSTALL_PANEL,,}" == "y" || "${INSTALL_PANEL,,}" == "yes" ]]; then + info "Запуск установки веб-панели..." + echo "" + bash "$PANEL_INSTALLER" + else + info "Пропускаем установку панели." + echo -e " Запустить позже: ${YELLOW}bash $PANEL_INSTALLER${NC}" + fi +fi + +# ── Установка mita-ctl как системной команды ────────────────────── +section "Установка утилиты управления mita-ctl" +CTL_SRC="$SCRIPT_DIR/mita-ctl.sh" +if [[ -f "$CTL_SRC" ]]; then + cp "$CTL_SRC" /usr/local/bin/mita-ctl + chmod +x /usr/local/bin/mita-ctl + ok "mita-ctl установлен!" + echo -e " Управление сервером: ${YELLOW}sudo mita-ctl${NC}" +else + warn "mita-ctl.sh не найден рядом с install.sh" + warn "Установите вручную: cp mita-ctl.sh /usr/local/bin/mita-ctl && chmod +x /usr/local/bin/mita-ctl" +fi diff --git a/manage-users.sh b/manage-users.sh new file mode 100755 index 0000000..55efb19 --- /dev/null +++ b/manage-users.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# ============================================================================= +# mita — управление пользователями +# Использование: +# ./manage-users.sh add <имя> — добавить (пароль генерируется автоматически) +# ./manage-users.sh remove <имя> +# ./manage-users.sh list +# ./manage-users.sh quota <имя> <дней> +# ============================================================================= +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +CONFIG_FILE="/etc/mita/server_config.json" + +[[ $EUID -ne 0 ]] && { echo -e "${RED}Запустите от root${NC}"; exit 1; } +[[ ! -f "$CONFIG_FILE" ]] && { echo -e "${RED}Конфиг не найден: $CONFIG_FILE${NC}"; exit 1; } + +# ---------- Генерация пароля ---------- +# 48 случайных байт → base64 → убираем +/= → берём 64 символа +gen_password() { + # Расширенный набор без символов ломающих protobuf (&<>|()[]{}\") + openssl rand -base64 48 | tr -d '/+=' | head -c 48 +} + +# ---------- Получить параметры сервера из конфига ---------- +get_server_info() { + SERVER_IP=$(curl -s --max-time 5 ifconfig.me 2>/dev/null \ + || hostname -I | awk '{print $1}') + + # Читаем первый portBinding из конфига + FIRST_BINDING=$(jq -r '.portBindings[0]' "$CONFIG_FILE") + PORT_PROTOCOL=$(echo "$FIRST_BINDING" | jq -r '.protocol // "TCP"') + + # Может быть portRange или port + if echo "$FIRST_BINDING" | jq -e '.portRange' &>/dev/null; then + PORT_VALUE=$(echo "$FIRST_BINDING" | jq -r '.portRange') + PORT_JSON_KEY="portRange" + else + PORT_VALUE=$(echo "$FIRST_BINDING" | jq -r '.port | tostring') + PORT_JSON_KEY="port" + fi +} + +# ---------- Вывод клиентского конфига ---------- +print_client_config() { + local name="$1" pass="$2" + + # Строим portBindings для клиентского конфига + if [[ "$PORT_JSON_KEY" == "portRange" ]]; then + PORT_BINDING_JSON=$(jq -n \ + --arg r "$PORT_VALUE" --arg p "$PORT_PROTOCOL" \ + '[{portRange: $r, protocol: $p}]') + else + PORT_BINDING_JSON=$(jq -n \ + --argjson port "$PORT_VALUE" --arg p "$PORT_PROTOCOL" \ + '[{port: $port, protocol: $p}]') + fi + + CLIENT_JSON=$(jq -n \ + --arg name "$name" \ + --arg pass "$pass" \ + --arg ip "$SERVER_IP" \ + --argjson pb "$PORT_BINDING_JSON" \ + '{ + profiles: [{ + profileName: "default", + user: {name: $name, password: $pass}, + servers: [{ + ipAddress: $ip, + portBindings: $pb + }] + }], + activeProfile: "default", + rpcPort: 8964, + socks5Port: 1080 + }') + + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}║ КОНФИГ КЛИЕНТА — скопируйте целиком ║${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${BOLD}Файл:${NC} mieru_client_config.json" + echo "" + echo "$CLIENT_JSON" | jq . + echo "" + echo -e "${CYAN}┌──────────────────────────────────────────────────────────────┐${NC}" + echo -e "${CYAN}│ Команды на устройстве клиента: │${NC}" + echo -e "${CYAN}│${NC} mieru apply config mieru_client_config.json ${CYAN}│${NC}" + echo -e "${CYAN}│${NC} mieru start ${CYAN}│${NC}" + echo -e "${CYAN}└──────────────────────────────────────────────────────────────┘${NC}" + echo "" + echo -e "${YELLOW}⚠ Сохраните пароль — он нигде не хранится в открытом виде:${NC}" + echo -e " Пользователь: ${BOLD}$name${NC}" + echo -e " Пароль: ${BOLD}$pass${NC}" + echo "" +} + +cmd="${1:-list}" + +case "$cmd" in + + # ------------------------------------------------------------------ + add) + [[ $# -lt 2 ]] && { echo "Использование: $0 add <имя>"; exit 1; } + NAME="$2" + + # Проверить не существует ли уже + EXISTS=$(jq --arg n "$NAME" '.users[] | select(.name == $n)' "$CONFIG_FILE") + [[ -n "$EXISTS" ]] && { echo -e "${YELLOW}Пользователь '$NAME' уже существует${NC}"; exit 1; } + + # Сгенерировать пароль + PASS=$(gen_password) + + # Добавить пользователя в конфиг + jq --arg n "$NAME" --arg p "$PASS" \ + '.users += [{name: $n, password: $p}]' \ + "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" + + # Применить конфиг + mita apply config "$CONFIG_FILE" + echo -e "${GREEN}✓ Пользователь '${NAME}' добавлен${NC}" + + # Показать клиентский конфиг + get_server_info + print_client_config "$NAME" "$PASS" + ;; + + # ------------------------------------------------------------------ + remove) + [[ $# -lt 2 ]] && { echo "Использование: $0 remove <имя>"; exit 1; } + NAME="$2" + + jq --arg n "$NAME" 'del(.users[] | select(.name == $n))' \ + "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" + + mita apply config "$CONFIG_FILE" + echo -e "${GREEN}✓ Пользователь '$NAME' удалён${NC}" + ;; + + # ------------------------------------------------------------------ + list) + echo -e "${CYAN}Пользователи в конфиге:${NC}" + jq -r '.users[] | " \(.name)"' "$CONFIG_FILE" + echo "" + echo -e "${CYAN}Статистика (из mita):${NC}" + mita get users 2>/dev/null || echo " (mita не запущен)" + ;; + + # ------------------------------------------------------------------ + quota) + [[ $# -lt 4 ]] && { echo "Использование: $0 quota <имя> <дней> "; exit 1; } + NAME="$2"; DAYS="$3"; GB="$4" + MB=$(echo "$GB * 1024" | bc | cut -d. -f1) + + jq --arg n "$NAME" --argjson d "$DAYS" --argjson m "$MB" \ + '(.users[] | select(.name == $n)).quotas = [{days: $d, megabytes: $m}]' \ + "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" + + mita apply config "$CONFIG_FILE" + echo -e "${GREEN}✓ Квота для '$NAME': ${GB}GB / ${DAYS} дней${NC}" + ;; + + *) + echo "Команды: add | remove | list | quota" + exit 1 + ;; +esac diff --git a/mita-ctl.sh b/mita-ctl.sh new file mode 100755 index 0000000..750ff0d --- /dev/null +++ b/mita-ctl.sh @@ -0,0 +1,834 @@ +#!/bin/bash +# ============================================================================= +# mita-ctl — утилита управления mita сервером +# Запуск: sudo bash mita-ctl.sh +# ============================================================================= +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}[ERR]${NC} $*"; } +section() { echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; } +hr() { echo -e "${BLUE}─────────────────────────────────────────────────${NC}"; } +pause() { echo ""; read -r -p "Нажмите Enter для продолжения..." _; } + +[[ $EUID -ne 0 ]] && { echo -e "${RED}Запустите от root: sudo bash mita-ctl.sh${NC}"; exit 1; } + +PANEL_CONFIG="/etc/mita/panel.json" +PANEL_ENV="/etc/mita/panel.env" +MITA_CONFIG="/etc/mita/server_config.json" + +load_panel() { + [[ -f "$PANEL_CONFIG" ]] && cat "$PANEL_CONFIG" || echo "{}" +} +panel_value() { load_panel | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$1',''))" 2>/dev/null || echo ""; } +env_value() { [[ -f "$PANEL_ENV" ]] && grep "^$1=" "$PANEL_ENV" | cut -d= -f2- || echo ""; } + +gen_pass() { openssl rand -base64 48 | tr -d '/+=' | head -c 32; } + +# ── SSH tunnel helper ────────────────────────────────────────────────────── +print_ssh_instruction() { + local port="$1" + local secret="$2" + local ssh_port="${3:-22}" + local server_ip + server_ip=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}') + echo "" + echo -e "${CYAN}┌─────────────────────────────────────────────────────────┐${NC}" + echo -e "${CYAN}│ Подключение к панели через SSH-туннель │${NC}" + echo -e "${CYAN}└─────────────────────────────────────────────────────────┘${NC}" + echo "" + echo -e " Выполните на ${BOLD}вашем компьютере${NC}:" + echo "" + echo -e " ${YELLOW}ssh -L ${port}:127.0.0.1:${port} -p ${ssh_port} root@${server_ip} -N${NC}" + echo "" + echo -e " Затем откройте в браузере:" + echo -e " ${BOLD}${GREEN}http://127.0.0.1:${port}/${secret}/${NC}" + echo "" + echo -e " ${BLUE}Флаг -N означает что SSH не выполняет команды, только туннель.${NC}" + echo -e " ${BLUE}Для фонового режима добавьте -f:${NC}" + echo -e " ${YELLOW}ssh -fN -L ${port}:127.0.0.1:${port} -p ${ssh_port} root@${server_ip}${NC}" + echo "" +} + +# ════════════════════════════════════════════════════════════════════════════ +# ГЛАВНОЕ МЕНЮ +# ════════════════════════════════════════════════════════════════════════════ +main_menu() { + while true; do + clear + echo -e "${CYAN}" + echo " ╔══════════════════════════════════════════════════════╗" + echo " ║ mita-ctl — Управление сервером ║" + echo " ╚══════════════════════════════════════════════════════╝" + echo -e "${NC}" + echo -e " ${BOLD}1.${NC} Сменить способ подключения к панели" + echo -e " ${BOLD}2.${NC} Управление портами (firewall)" + echo -e " ${BOLD}3.${NC} Перевыпустить SSL-сертификат панели" + echo -e " ${BOLD}4.${NC} Управление пользователями mita" + echo -e " ${BOLD}5.${NC} Показать логин и пароль админа панели" + echo -e " ${BOLD}6.${NC} Изменить данные администратора панели" + echo -e " ${BOLD}7.${NC} Настройка fail2ban" + echo -e " ${BOLD}8.${NC} Рекомендации по безопасности VPS" + echo -e " ${BOLD}0.${NC} Выход" + echo "" + read -r -p " Выберите пункт: " choice + case "$choice" in + 1) menu_panel_access ;; + 2) menu_firewall ;; + 3) menu_ssl ;; + 4) menu_users ;; + 5) menu_show_admin ;; + 6) menu_change_admin ;; + 7) menu_fail2ban ;; + 8) menu_security ;; + 0) exit 0 ;; + *) warn "Неверный выбор" ;; + esac + done +} + +# ════════════════════════════════════════════════════════════════════════════ +# 1. СПОСОБ ПОДКЛЮЧЕНИЯ К ПАНЕЛИ +# ════════════════════════════════════════════════════════════════════════════ +menu_panel_access() { + section "Способ подключения к панели" + local panel_port secret ssh_port + panel_port=$(env_value "PANEL_PORT"); panel_port=${panel_port:-8080} + secret=$(panel_value "secret_path") + + echo -e " Текущий порт панели: ${YELLOW}${panel_port}${NC}" + echo -e " Текущий секретный путь: ${YELLOW}/${secret}/${NC}" + echo "" + echo -e " ${BOLD}1.${NC} SSH-туннель (панель недоступна снаружи)" + echo -e " ${BOLD}2.${NC} По IP-адресу (текущий способ)" + echo -e " ${BOLD}3.${NC} По доменному имени с Let's Encrypt" + echo -e " ${BOLD}0.${NC} Назад" + echo "" + read -r -p " Выбор: " choice + + case "$choice" in + 1) + section "SSH-туннель" + echo "Панель будет слушать только на 127.0.0.1 (недоступна снаружи)." + echo "" + read -r -p "SSH-порт вашего VPS [22]: " ssh_port + ssh_port=${ssh_port:-22} + + # Привязать gunicorn к 127.0.0.1 + _update_panel_bind "127.0.0.1" "$panel_port" + + # Закрыть порт панели в firewall + _fw_close "$panel_port" "tcp" + + # Обновить panel.json + python3 -c " +import json +with open('$PANEL_CONFIG') as f: d=json.load(f) +d['access_mode']='ssh' +d['ssh_port']=$ssh_port +with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) +" + systemctl restart mita-panel 2>/dev/null || true + ok "Панель переключена на SSH-туннель" + print_ssh_instruction "$panel_port" "$secret" "$ssh_port" + pause + ;; + + 2) + section "Доступ по IP" + local server_ip + server_ip=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}') + + _update_panel_bind "0.0.0.0" "$panel_port" + _fw_open "$panel_port" "tcp" + + python3 -c " +import json +with open('$PANEL_CONFIG') as f: d=json.load(f) +d['access_mode']='ip' +with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) +" + systemctl restart mita-panel 2>/dev/null || true + ok "Панель доступна по IP" + echo "" + echo -e " Адрес: ${BOLD}${GREEN}http://${server_ip}:${panel_port}/${secret}/${NC}" + pause + ;; + + 3) + section "Let's Encrypt" + read -r -p "Домен (например panel.example.com): " domain + read -r -p "Email для Let's Encrypt (Enter = пропустить): " email + [[ -z "$domain" ]] && { warn "Домен не указан"; pause; return; } + + # Установить certbot + command -v certbot &>/dev/null || apt-get install -y -qq certbot + + local le_args=("certbot" "certonly" "--standalone" "--non-interactive" "--agree-tos" "-d" "$domain") + [[ -n "$email" ]] && le_args+=("--email" "$email") || le_args+=("--register-unsafely-without-email") + + info "Получение сертификата Let's Encrypt..." + if "${le_args[@]}"; then + local cert="/etc/letsencrypt/live/${domain}/fullchain.pem" + local key="/etc/letsencrypt/live/${domain}/privkey.pem" + _update_env_val "SSL_CERT" "$cert" + _update_env_val "SSL_KEY" "$key" + _update_panel_bind "0.0.0.0" "$panel_port" + _fw_open "$panel_port" "tcp" + python3 -c " +import json +with open('$PANEL_CONFIG') as f: d=json.load(f) +d.update({'access_mode':'domain','ssl_type':'letsencrypt','ssl_domain':'$domain','ssl_cert':'$cert','ssl_key':'$key'}) +with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) +" + # Настроить автопродление + (crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet && systemctl restart mita-panel") | sort -u | crontab - + systemctl restart mita-panel 2>/dev/null || true + ok "Сертификат получен" + echo -e " Адрес: ${BOLD}${GREEN}https://${domain}:${panel_port}/${secret}/${NC}" + else + error "Не удалось получить сертификат. Убедитесь что домен указывает на этот сервер и порт 80 открыт." + fi + pause + ;; + 0) return ;; + esac +} + +_update_panel_bind() { + local host="$1" port="$2" + # Обновить ExecStart в systemd unit через враппер start.sh + sed -i "s/--bind [0-9.:]*:[0-9]*/--bind ${host}:${port}/" /opt/mita-panel/start.sh 2>/dev/null || true + # Обновить PANEL_PORT в env + _update_env_val "PANEL_BIND_HOST" "$host" + # Пересоздать start.sh с нужным bind + cat > /opt/mita-panel/start.sh << STARTEOF +#!/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 ${host}:\${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 +STARTEOF + chmod +x /opt/mita-panel/start.sh +} + +_update_env_val() { + local key="$1" val="$2" + if grep -q "^${key}=" "$PANEL_ENV" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${val}|" "$PANEL_ENV" + else + echo "${key}=${val}" >> "$PANEL_ENV" + fi +} + +# ════════════════════════════════════════════════════════════════════════════ +# 2. FIREWALL +# ════════════════════════════════════════════════════════════════════════════ +menu_firewall() { + section "Управление портами (Firewall)" + + # Определить активный firewall + local FW="none" + command -v ufw &>/dev/null && ufw status | grep -q "Status: active" && FW="ufw" + [[ "$FW" == "none" ]] && command -v iptables &>/dev/null && FW="iptables" + + info "Активный firewall: $FW" + echo "" + echo -e " ${BOLD}1.${NC} Открыть порт" + echo -e " ${BOLD}2.${NC} Закрыть порт" + echo -e " ${BOLD}3.${NC} Применить рекомендуемые правила (только нужные порты)" + echo -e " ${BOLD}4.${NC} Показать текущие правила" + echo -e " ${BOLD}0.${NC} Назад" + echo "" + read -r -p " Выбор: " choice + + case "$choice" in + 1) + read -r -p "Порт: " p; read -r -p "Протокол [tcp]: " proto; proto=${proto:-tcp} + _fw_open "$p" "$proto" && ok "Порт $p/$proto открыт" + pause ;; + 2) + read -r -p "Порт: " p; read -r -p "Протокол [tcp]: " proto; proto=${proto:-tcp} + _fw_close "$p" "$proto" && ok "Порт $p/$proto закрыт" + pause ;; + 3) + section "Рекомендуемые правила" + local panel_port mita_range + panel_port=$(env_value "PANEL_PORT"); panel_port=${panel_port:-8080} + mita_range=$(python3 -c " +import json +with open('$MITA_CONFIG') as f: d=json.load(f) +b=d.get('portBindings',[{}])[0] +print(b.get('portRange',b.get('port','2100-2110'))) +" 2>/dev/null || echo "2100-2110") + + read -r -p "SSH-порт вашего VPS [22]: " ssh_port + ssh_port=${ssh_port:-22} + + local access_mode + access_mode=$(panel_value "access_mode") + + info "Настраиваю правила:" + info " SSH: $ssh_port/tcp" + info " mita: $mita_range/tcp" + [[ "$access_mode" != "ssh" ]] && info " Панель: $panel_port/tcp" || info " Панель: закрыта (SSH-туннель)" + + if [[ "$FW" == "ufw" ]]; then + ufw --force reset 2>/dev/null + ufw default deny incoming + ufw default allow outgoing + ufw allow "$ssh_port/tcp" comment "SSH" + # mita ports + local start_p end_p + if [[ "$mita_range" == *"-"* ]]; then + start_p="${mita_range%-*}"; end_p="${mita_range#*-}" + ufw allow "${start_p}:${end_p}/tcp" comment "mita" + else + ufw allow "$mita_range/tcp" comment "mita" + fi + [[ "$access_mode" != "ssh" ]] && ufw allow "$panel_port/tcp" comment "mita-panel" + ufw --force enable + else + iptables -F INPUT 2>/dev/null + iptables -P INPUT DROP + iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + iptables -A INPUT -i lo -j ACCEPT + iptables -A INPUT -p tcp --dport "$ssh_port" -j ACCEPT + if [[ "$mita_range" == *"-"* ]]; then + start_p="${mita_range%-*}"; end_p="${mita_range#*-}" + iptables -A INPUT -p tcp --dport "${start_p}:${end_p}" -j ACCEPT + else + iptables -A INPUT -p tcp --dport "$mita_range" -j ACCEPT + fi + [[ "$access_mode" != "ssh" ]] && iptables -A INPUT -p tcp --dport "$panel_port" -j ACCEPT + command -v iptables-save &>/dev/null && iptables-save > /etc/iptables/rules.v4 2>/dev/null || true + fi + ok "Правила применены" + pause ;; + 4) + echo "" + if [[ "$FW" == "ufw" ]]; then ufw status numbered + else iptables -L INPUT -n --line-numbers; fi + pause ;; + 0) return ;; + esac +} + +_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 +} + +_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 +} + +# ════════════════════════════════════════════════════════════════════════════ +# 3. SSL +# ════════════════════════════════════════════════════════════════════════════ +menu_ssl() { + section "Перевыпуск SSL-сертификата" + echo -e " ${BOLD}1.${NC} Самоподписной (10 лет)" + echo -e " ${BOLD}2.${NC} Let's Encrypt" + echo -e " ${BOLD}0.${NC} Назад" + echo "" + read -r -p " Выбор: " choice + + case "$choice" in + 1) + local cert_dir="/etc/mita/ssl" + mkdir -p "$cert_dir" + openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \ + -keyout "$cert_dir/selfsigned.key" -out "$cert_dir/selfsigned.crt" \ + -subj "/CN=mita-panel/O=mita/C=XX" 2>/dev/null + _update_env_val "SSL_CERT" "$cert_dir/selfsigned.crt" + _update_env_val "SSL_KEY" "$cert_dir/selfsigned.key" + python3 -c " +import json +with open('$PANEL_CONFIG') as f: d=json.load(f) +d.update({'ssl_type':'selfsigned','ssl_cert':'$cert_dir/selfsigned.crt','ssl_key':'$cert_dir/selfsigned.key'}) +with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) +" + _rebuild_start_sh + systemctl restart mita-panel 2>/dev/null || true + ok "Самоподписной сертификат создан и применён" + pause ;; + 2) + local domain + read -r -p "Домен: " domain + [[ -z "$domain" ]] && { warn "Не указан домен"; pause; return; } + command -v certbot &>/dev/null || apt-get install -y -qq certbot + if certbot renew --cert-name "$domain" --quiet 2>/dev/null || \ + certbot certonly --standalone --non-interactive --agree-tos \ + --register-unsafely-without-email -d "$domain"; then + local cert="/etc/letsencrypt/live/${domain}/fullchain.pem" + local key="/etc/letsencrypt/live/${domain}/privkey.pem" + _update_env_val "SSL_CERT" "$cert" + _update_env_val "SSL_KEY" "$key" + python3 -c " +import json +with open('$PANEL_CONFIG') as f: d=json.load(f) +d.update({'ssl_type':'letsencrypt','ssl_domain':'$domain','ssl_cert':'$cert','ssl_key':'$key'}) +with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) +" + _rebuild_start_sh + systemctl restart mita-panel 2>/dev/null || true + ok "Сертификат Let's Encrypt выпущен и применён" + else + error "Не удалось выпустить сертификат" + fi + pause ;; + 0) return ;; + esac +} + +_rebuild_start_sh() { + local bind_host + bind_host=$(panel_value "access_mode") + [[ "$bind_host" == "ssh" ]] && bind_host="127.0.0.1" || bind_host="0.0.0.0" + _update_panel_bind "$bind_host" "$(env_value PANEL_PORT)" +} + +# ════════════════════════════════════════════════════════════════════════════ +# 4. ПОЛЬЗОВАТЕЛИ +# ════════════════════════════════════════════════════════════════════════════ +menu_users() { + section "Управление пользователями mita" + + # Показать текущих пользователей + echo -e "${CYAN}Текущие пользователи:${NC}" + python3 -c " +import json +with open('$MITA_CONFIG') as f: d=json.load(f) +users=d.get('users',[]) +if not users: print(' (нет пользователей)') +for u in users: print(f' - {u[\"name\"]}') +" + echo "" + echo -e " ${BOLD}1.${NC} Создать одного пользователя (своё имя)" + echo -e " ${BOLD}2.${NC} Создать несколько с рандомными именами" + echo -e " ${BOLD}3.${NC} Удалить пользователя" + echo -e " ${BOLD}4.${NC} Показать конфиг существующего пользователя" + echo -e " ${BOLD}0.${NC} Назад" + echo "" + read -r -p " Выбор: " choice + + case "$choice" in + 1) + read -r -p "Имя пользователя: " uname + [[ -z "$uname" ]] && { warn "Имя не указано"; pause; return; } + _create_user "$uname" + pause ;; + 2) + read -r -p "Количество пользователей: " cnt + [[ ! "$cnt" =~ ^[0-9]+$ ]] && { warn "Неверное число"; pause; return; } + for i in $(seq 1 "$cnt"); do + local adj noun rnd uname + adj=$(shuf -n1 -e swift brave quiet cool sharp calm bright dark wild free 2>/dev/null || echo "user") + noun=$(shuf -n1 -e fox hawk river storm ember peak orbit tide frost spark 2>/dev/null || echo "node") + rnd=$((RANDOM % 9000 + 1000)) + uname="${adj}_${noun}_${rnd}" + _create_user "$uname" + done + pause ;; + 3) + read -r -p "Имя пользователя для удаления: " uname + python3 << PYEOF +import json, subprocess +with open('$MITA_CONFIG') as f: d=json.load(f) +before=len(d.get('users',[])) +d['users']=[u for u in d.get('users',[]) if u['name']!='$uname'] +after=len(d['users']) +with open('$MITA_CONFIG','w') as f: json.dump(d,f,indent=2) +if before>after: + subprocess.run(['mita','apply','config','$MITA_CONFIG'],capture_output=True) + print(f"Пользователь '$uname' удалён") +else: + print(f"Пользователь '$uname' не найден") +PYEOF + pause ;; + 4) + read -r -p "Имя пользователя: " uname + _show_user_config "$uname" + pause ;; + 0) return ;; + esac +} + +_show_user_config() { + local uname="$1" + python3 << PYEOF +import json +with open('$MITA_CONFIG') as f: d=json.load(f) +user = next((u for u in d.get('users',[]) if u['name']=='$uname'), None) +if not user: + print("Пользователь '$uname' не найден") +else: + import subprocess + server_ip=subprocess.run(['curl','-s','--max-time','5','ifconfig.me'],capture_output=True,text=True).stdout.strip() + cfg=d.get('portBindings',[{}])[0] + port=cfg.get('portRange',str(cfg.get('port','2100-2110'))) + first_port = port.split('-')[0] if '-' in port else port + print(f"\n Параметры для ручного ввода в Karing/sing-box клиентах:") + print(f" server: {server_ip}") + print(f" server port: {first_port}") + print(f" username: {user['name']}") + print(f" password: {user['password']}") + print(f" transport: TCP") + print(f" multiplexing: multiplexing_low") +PYEOF + echo "" + read -r -p "Показать подробную инструкцию по добавлению в Karing? [y/N]: " show_karing + [[ "${show_karing,,}" == "y" ]] && print_karing_instructions +} + +_create_user() { + local uname="$1" + local pass + pass=$(openssl rand -base64 48 | tr -d '/+=' | head -c 48) + + python3 << PYEOF +import json, subprocess +with open('$MITA_CONFIG') as f: d=json.load(f) +if any(u['name']=='$uname' for u in d.get('users',[])): + print(f"Пользователь '$uname' уже существует") +else: + d.setdefault('users',[]).append({'name':'$uname','password':'$pass'}) + with open('$MITA_CONFIG','w') as f: json.dump(d,f,indent=2) + r=subprocess.run(['mita','apply','config','$MITA_CONFIG'],capture_output=True,text=True) + print(f"✓ Создан: $uname") + print(f" Пароль: $pass") + # Показать клиентский конфиг + server_ip=subprocess.run(['curl','-s','--max-time','5','ifconfig.me'],capture_output=True,text=True).stdout.strip() + cfg=d.get('portBindings',[{}])[0] + port=cfg.get('portRange',str(cfg.get('port','2100-2110'))) + proto=cfg.get('protocol','TCP') + client={ + 'profiles':[{'profileName':'default','user':{'name':'$uname','password':'$pass'}, + 'servers':[{'ipAddress':server_ip,'portBindings':[{'portRange':port,'protocol':proto}]}]}], + 'activeProfile':'default','rpcPort':8964,'socks5Port':1080 + } + print(f"\n Клиентский конфиг (mieru CLI):") + print(json.dumps(client,indent=2,ensure_ascii=False)) + + first_port = port.split('-')[0] if '-' in port else port + print(f"\n Параметры для ручного ввода в Karing/sing-box клиентах:") + print(f" server: {server_ip}") + print(f" server port: {first_port}") + print(f" username: $uname") + print(f" password: $pass") + print(f" transport: TCP") + print(f" multiplexing: multiplexing_low") +PYEOF + + echo "" + read -r -p "Показать подробную инструкцию по добавлению в Karing? [y/N]: " show_karing + [[ "${show_karing,,}" == "y" ]] && print_karing_instructions +} + +print_karing_instructions() { + echo "" + echo -e "${CYAN}┌─────────────────────────────────────────────────────────────────┐${NC}" + echo -e "${CYAN}│ Ручное добавление сервера в Karing │${NC}" + echo -e "${CYAN}└─────────────────────────────────────────────────────────────────┘${NC}" + cat << 'KARINGEOF' + + 1. Откройте Karing → добавить профиль + 2. Выберите тип "Custom" и впишите любое имя + 3. Перейдите в раздел "My profiles" + 4. Нажмите "+" в поле только что созданного профиля + 5. В выпадающем списке протоколов выберите "Mieru" + 6. В поле "tag" впишите любое название (например: proxy) + 7. Заполните "server" и "server port" — значения выше (IP и порт) + 8. Заполните "username" и "password" — значения выше + 9. В поле "multiplexing" выберите "multiplexing_low" + 10. В поле "transport" впишите ЗАГЛАВНЫМИ БУКВАМИ: TCP + Затем нажмите ✓ в правом верхнем углу. + Убедитесь что тумблер профиля в "My profiles" зелёный, + затем нажмите кнопку подключения в главном меню Karing. + 11. Diversion Rules (разделение трафика) — направить конкретные + сайты/приложения мимо VPN или через него: + a) Нажмите карандаш (✏) в правом верхнем углу "Diversion Rules" + b) Нажмите "⋮" там же → "+ Add" + c) Введите название правила, прокрутите список вниз, + выберите созданное правило + d) Добавьте нужные адреса/приложения, нажмите ✓ вверху справа + e) Вернитесь в "Diversion Rules", найдите правило внизу списка + f) Нажмите на него → "Direct" (мимо VPN) + или раскройте имя профиля → выберите его (через VPN) + +KARINGEOF + pause +} + +# ════════════════════════════════════════════════════════════════════════════ +# 5. ПОКАЗАТЬ ДАННЫЕ АДМИНА +# ════════════════════════════════════════════════════════════════════════════ +menu_show_admin() { + section "Данные администратора панели" + local user pass secret panel_port access_mode + user=$(panel_value "admin_user") + pass=$(panel_value "admin_pass") + secret=$(panel_value "secret_path") + panel_port=$(env_value "PANEL_PORT"); panel_port=${panel_port:-8080} + access_mode=$(panel_value "access_mode") + local server_ip + server_ip=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}') + + echo "" + echo -e " Логин: ${BOLD}${YELLOW}${user}${NC}" + echo -e " Пароль: ${BOLD}${YELLOW}${pass}${NC}" + echo -e " Секретный путь: ${BOLD}/${secret}/${NC}" + echo "" + + if [[ "$access_mode" == "ssh" ]]; then + local ssh_port + ssh_port=$(panel_value "ssh_port"); ssh_port=${ssh_port:-22} + print_ssh_instruction "$panel_port" "$secret" "$ssh_port" + else + local proto="http" + [[ -n "$(panel_value ssl_cert)" ]] && proto="https" + local domain + domain=$(panel_value "ssl_domain") + if [[ -n "$domain" ]]; then + echo -e " Адрес: ${BOLD}${GREEN}${proto}://${domain}:${panel_port}/${secret}/${NC}" + else + echo -e " Адрес: ${BOLD}${GREEN}${proto}://${server_ip}:${panel_port}/${secret}/${NC}" + fi + fi + pause +} + +# ════════════════════════════════════════════════════════════════════════════ +# 6. ИЗМЕНИТЬ ДАННЫЕ АДМИНА +# ════════════════════════════════════════════════════════════════════════════ +menu_change_admin() { + section "Изменение данных администратора" + echo -e " ${BOLD}1.${NC} Сгенерировать новый пароль автоматически" + echo -e " ${BOLD}2.${NC} Задать свой пароль" + echo -e " ${BOLD}0.${NC} Назад" + echo "" + read -r -p " Выбор: " choice + + local new_pass + case "$choice" in + 1) new_pass=$(gen_pass) ;; + 2) + read -r -s -p "Новый пароль (мин. 12 символов): " new_pass; echo "" + [[ ${#new_pass} -lt 12 ]] && { warn "Пароль слишком короткий"; pause; return; } + ;; + 0) return ;; + *) warn "Неверный выбор"; pause; return ;; + esac + + python3 -c " +import json +with open('$PANEL_CONFIG') as f: d=json.load(f) +d['admin_pass']='$new_pass' +with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) +" + systemctl restart mita-panel 2>/dev/null || true + ok "Пароль изменён" + echo "" + echo -e " Новый пароль: ${BOLD}${YELLOW}${new_pass}${NC}" + echo -e " Логин: ${BOLD}$(panel_value admin_user)${NC}" + pause +} + +# ════════════════════════════════════════════════════════════════════════════ +# 7. БЕЗОПАСНОСТЬ VPS +# ════════════════════════════════════════════════════════════════════════════ +menu_security() { + section "Рекомендации по безопасности VPS" + echo "" + cat << 'SECEOF' + ┌─────────────────────────────────────────────────────────────────┐ + │ КРИТИЧЕСКИ ВАЖНО │ + └─────────────────────────────────────────────────────────────────┘ + + 1. ВХОД ПО SSH-КЛЮЧУ (отключить пароли) + # Сгенерируйте ключ на своём компьютере: + ssh-keygen -t ed25519 -C "vps-key" + # Скопируйте на сервер: + ssh-copy-id -p 22 root@YOUR_IP + # Отключите вход по паролю (/etc/ssh/sshd_config): + PasswordAuthentication no + PubkeyAuthentication yes + systemctl restart sshd + + 2. СМЕНИТЕ ПОРТ SSH (заменить 22 на нестандартный) + # В /etc/ssh/sshd_config: + Port 2222 # любой свободный порт + systemctl restart sshd + # Не забудьте открыть новый порт в firewall! + + 3. FAIL2BAN (блокировка перебора паролей) + apt-get install -y fail2ban + systemctl enable fail2ban --now + + ┌─────────────────────────────────────────────────────────────────┐ + │ ВАЖНО │ + └─────────────────────────────────────────────────────────────────┘ + + 4. АВТООБНОВЛЕНИЕ БЕЗОПАСНОСТИ + apt-get install -y unattended-upgrades + dpkg-reconfigure -plow unattended-upgrades + + 5. МИНИМУМ ОТКРЫТЫХ ПОРТОВ + Используйте пункт 2 этого меню → "Рекомендуемые правила". + Открытыми должны быть только: SSH, порты mita, порт панели (если не SSH-туннель). + + 6. ПАНЕЛЬ ЧЕРЕЗ SSH-ТУННЕЛЬ + Самый безопасный вариант — панель вообще не торчит наружу. + Используйте пункт 1 главного меню для переключения. + + 7. СЕКРЕТНЫЙ ПУТЬ ПАНЕЛИ + Никогда не делитесь ссылкой на панель. Без секретного пути + сервер возвращает 404 на все запросы. + + ┌─────────────────────────────────────────────────────────────────┐ + │ ДОПОЛНИТЕЛЬНО │ + └─────────────────────────────────────────────────────────────────┘ + + 8. МОНИТОРИНГ ВХОДОВ + last # последние входы + journalctl -u ssh --since "1 hour ago" + grep "Failed" /var/log/auth.log | tail -20 + + 9. ПРОВЕРКА ОТКРЫТЫХ ПОРТОВ + ss -tlnp # что слушает на сервере + nmap -p- YOUR_IP # что видно снаружи (запускать со своего ПК) + + 10. РЕГУЛЯРНЫЕ ОБНОВЛЕНИЯ + apt-get update && apt-get upgrade -y + +SECEOF + pause +} + +# ════════════════════════════════════════════════════════════════════════════ +# 8. FAIL2BAN +# ════════════════════════════════════════════════════════════════════════════ +menu_fail2ban() { + section "Настройка fail2ban для панели" + + local installed=false active=false + command -v fail2ban-client &>/dev/null && installed=true + $installed && systemctl is-active --quiet fail2ban && active=true + + echo -e " fail2ban установлен: $($installed && echo "${GREEN}да${NC}" || echo "${RED}нет${NC}")" + $installed && echo -e " fail2ban активен: $($active && echo "${GREEN}да${NC}" || echo "${RED}нет${NC}")" + echo "" + + local pc_maxretry pc_bantime + pc_maxretry=$(python3 -c "import json; d=json.load(open('$PANEL_CONFIG')); print(d.get('login_max_attempts',5))" 2>/dev/null || echo 5) + pc_bantime=$(python3 -c "import json; d=json.load(open('$PANEL_CONFIG')); print(d.get('login_ban_seconds',3600))" 2>/dev/null || echo 3600) + echo -e " Текущие лимиты: ${YELLOW}${pc_maxretry} попыток${NC} / ${YELLOW}${pc_bantime} сек${NC}" + echo "" + echo -e " ${BOLD}1.${NC} Строгий — 3 попытки / 30 минут" + echo -e " ${BOLD}2.${NC} Стандарт — 5 попыток / 1 час" + echo -e " ${BOLD}3.${NC} Мягкий — 10 попыток / 15 минут" + echo -e " ${BOLD}4.${NC} Жёсткий — 3 попытки / 24 часа" + echo -e " ${BOLD}5.${NC} Ручная настройка" + echo -e " ${BOLD}6.${NC} Установить/переустановить fail2ban" + echo -e " ${BOLD}0.${NC} Назад" + echo "" + read -r -p " Выбор: " choice + + local max_retry ban_time + case "$choice" in + 1) max_retry=3; ban_time=1800 ;; + 2) max_retry=5; ban_time=3600 ;; + 3) max_retry=10; ban_time=900 ;; + 4) max_retry=3; ban_time=86400 ;; + 5) + read -r -p " Попыток до блокировки [5]: " max_retry + max_retry=${max_retry:-5} + echo " Время блокировки:" + echo " 1) 5 минут 2) 15 минут 3) 30 минут" + echo " 4) 1 час 5) 24 часа 6) 7 дней 7) Своё (сек)" + read -r -p " Выбор [4]: " bt_choice + case "${bt_choice:-4}" in + 1) ban_time=300 ;; 2) ban_time=900 ;; 3) ban_time=1800 ;; + 4) ban_time=3600 ;; 5) ban_time=86400 ;; 6) ban_time=604800;; + 7) read -r -p " Секунды: " ban_time ;; + *) ban_time=3600 ;; + esac + ;; + 6) + info "Установка fail2ban..." + apt-get install -y -qq fail2ban + systemctl enable fail2ban --now + ok "fail2ban установлен и запущен" + pause; return ;; + 0) return ;; + *) warn "Неверный выбор"; pause; return ;; + esac + + # Применить + _apply_fail2ban "$max_retry" "$ban_time" + pause +} + +_apply_fail2ban() { + local max_retry="$1" ban_time="$2" + + # Обновить panel.json + python3 -c " +import json +with open('$PANEL_CONFIG') as f: d=json.load(f) +d['login_max_attempts']=$max_retry +d['login_ban_seconds']=$ban_time +with open('$PANEL_CONFIG','w') as f: json.dump(d,f,indent=2) +" + + # Записать fail2ban конфиг + if command -v fail2ban-client &>/dev/null; then + 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).*$ +ignoreregex = +EOF + cat > /etc/fail2ban/jail.d/mita-panel.conf << EOF +[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] +EOF + systemctl restart fail2ban 2>/dev/null || true + ok "fail2ban настроен: $max_retry попыток / ${ban_time}с" + else + warn "fail2ban не установлен — лимиты сохранены только в panel.json" + fi + + # Перезапустить панель чтобы подхватила новые лимиты + systemctl restart mita-panel 2>/dev/null || true + ok "Настройки применены. Попыток: $max_retry, бан: ${ban_time}с" +} + +# ── Точка входа ─────────────────────────────────────────────────────────── +main_menu + diff --git a/mswp.zip b/mswp.zip deleted file mode 100644 index 4f77073d49510745ca24c8b8a80c459ae110b63c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49876 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 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..0e5b11f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,188 @@ + + + + + +{% block title %}mita panel{% endblock %} + + + + +
{% block content %}{% endblock %}
+ + + +{% block scripts %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c84793f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,750 @@ +{% extends "base.html" %} +{% block title %}mita panel{% endblock %} +{% block content %} + + +
+

Дашборд

+

Обзор сервера в реальном времени

+
+
IP сервера
+
+
IP WARP
+
+
+
+
Трафик за неделю
ГБ
+
Трафик за месяц
ГБ
+
+
+
+
Порты mita
+
+
+
+
Пользователей
+
+
+
+
Статус mita
+
+
+
+
+
Быстрые действия
+
+ + + + +
+
+ + + +
+ + +
+
+

Пользователи

Создание и управление доступом

+
+ +
+
Создать пользователей
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
Список пользователей
+
+ + + +
ИмяWARP вклПравила WARPКонфиг клиентаДействия
Загрузка...
+
+
+
+ + +
+

SSL-сертификаты

+

Защита панели управления

+ +
+
Текущий сертификат
+
Загрузка...
+
+ +
+
+
Самоподписной сертификат
+

+ Генерируется мгновенно, действует 10 лет. + Браузер покажет предупреждение — его можно принять. + Подходит для личного использования. +

+ +
+
+
+
Let's Encrypt (бесплатный)
+

+ Доверенный сертификат на 90 дней, продлевается автоматически. + Требует домен, указывающий на этот сервер, и открытый порт 80. +

+
+ + +
+
+ + +
+ +
+
+
+ +
+
Применение сертификата
+
+ После смены сертификата необходимо перезапустить панель:
+ systemctl restart mita-panel +
+
+
+ +{% endblock %} + +
+

Безопасность

+

Защита панели и настройка fail2ban

+
+
Статус fail2ban
+
Загрузка...
+
+
+
Настройка лимита попыток входа
+

+ Лимит работает на двух уровнях: встроенный счётчик панели (мгновенно) и fail2ban (блокировка на сетевом уровне). +

+
+
Быстрые варианты
+
+ + + + +
+
+
+
Ручная настройка
+
+
+ + +
+
+ + +
+ +
+
+ + Установить fail2ban если не установлен +
+ +
+
+
+ + + +{% block scripts %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..cd03a7c --- /dev/null +++ b/templates/login.html @@ -0,0 +1,93 @@ + + + + +mita panel — вход + + + +
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +