version 0.3 Добавлена инструкция для Karing, добавлена установка необходимых зависимостей

This commit is contained in:
2026-06-19 14:30:41 +03:00
parent a0f6ad2895
commit 23cd9a2c92
9 changed files with 3871 additions and 0 deletions
+866
View File
@@ -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}"'}
)
+334
View File
@@ -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
Executable
+636
View File
@@ -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
+170
View File
@@ -0,0 +1,170 @@
#!/bin/bash
# =============================================================================
# mita — управление пользователями
# Использование:
# ./manage-users.sh add <имя> — добавить (пароль генерируется автоматически)
# ./manage-users.sh remove <имя>
# ./manage-users.sh list
# ./manage-users.sh quota <имя> <дней> <GB>
# =============================================================================
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 <имя> <дней> <GB>"; 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
Executable
+834
View File
@@ -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
BIN
View File
Binary file not shown.
+188
View File
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}mita panel{% endblock %}</title>
<style>
:root{--bg:#0d0f14;--surface:#141720;--surface2:#1c2030;--border:#252a3a;
--accent:#4f8fff;--accent2:#7b5fff;--ok:#3ecf8e;--warn:#f5a623;
--danger:#ff5f6d;--text:#e2e8f4;--muted:#6b7594;
--mono:'JetBrains Mono','Fira Mono',monospace;--sans:'Inter',system-ui,sans-serif;
--radius:10px;--radius-lg:16px}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{height:100%;font-size:15px}
body{font-family:var(--sans);background:var(--bg);color:var(--text);min-height:100%;display:flex}
.sidebar{width:220px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);
display:flex;flex-direction:column;padding:28px 0 20px;position:fixed;top:0;left:0;height:100vh;z-index:100}
.sidebar-logo{font-size:1.2rem;font-weight:700;color:var(--text);padding:0 24px 28px;display:flex;align-items:center;gap:10px}
.logo-badge{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;
font-size:.75rem;font-weight:700;padding:4px 8px;border-radius:6px;letter-spacing:.5px}
.nav-item{display:flex;align-items:center;gap:12px;padding:10px 24px;color:var(--muted);
text-decoration:none;font-size:.875rem;font-weight:500;
border-top:none;border-right:none;border-bottom:none;border-left:3px solid transparent;
background:none;width:100%;text-align:left;transition:.15s;cursor:pointer}
.nav-item:hover{color:var(--text);background:var(--surface2)}
.nav-item.active{color:var(--accent);border-left-color:var(--accent);background:rgba(79,143,255,.08)}
.nav-icon{width:18px;height:18px;flex-shrink:0}
.sidebar-footer{margin-top:auto;padding:16px 24px;border-top:1px solid var(--border)}
.sidebar-footer a{color:var(--muted);font-size:.8rem;text-decoration:none}
.sidebar-footer a:hover{color:var(--danger)}
.main{margin-left:220px;flex:1;padding:36px 40px;min-height:100vh}
.page{display:none}.page.active{display:block}
h1{font-size:1.5rem;font-weight:700;margin-bottom:4px;letter-spacing:-.3px}
.subtitle{color:var(--muted);font-size:.875rem;margin-bottom:28px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;margin-bottom:20px}
.card-title{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin-bottom:16px}
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:20px}
.grid-2{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:20px}
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px}
.stat-label{font-size:.75rem;color:var(--muted);font-weight:500;margin-bottom:8px;text-transform:uppercase;letter-spacing:.6px}
.stat-value{font-size:1.75rem;font-weight:700;font-family:var(--mono);color:var(--text);line-height:1}
.stat-value.accent{color:var(--accent)}.stat-value.ok{color:var(--ok)}
.stat-sub{font-size:.75rem;color:var(--muted);margin-top:6px}
.ip-badge{font-family:var(--mono);font-size:.8rem;background:var(--surface2);border:1px solid var(--border);padding:3px 8px;border-radius:6px;color:var(--accent)}
.btn{display:inline-flex;align-items:center;gap:8px;padding:9px 18px;border-radius:var(--radius);
font-size:.875rem;font-weight:600;border:1px solid transparent;cursor:pointer;transition:.15s;text-decoration:none;white-space:nowrap;font-family:var(--sans)}
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent)}.btn-primary:hover{background:#3d7aff}
.btn-secondary{background:var(--surface2);color:var(--text);border-color:var(--border)}.btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
.btn-danger{background:transparent;color:var(--danger);border-color:var(--danger)}.btn-danger:hover{background:var(--danger);color:#fff}
.btn-ok{background:var(--ok);color:#0d0f14;border-color:var(--ok)}.btn-ok:hover{background:#2fb87a}
.btn-warn{background:var(--warn);color:#0d0f14;border-color:var(--warn)}
.btn-sm{padding:5px 12px;font-size:.8rem}.btn-group{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}
.form-row{display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:16px}
.form-group{display:flex;flex-direction:column;gap:6px;flex:1;min-width:160px}
label{font-size:.8rem;font-weight:500;color:var(--muted)}
input,select{background:var(--surface2);border:1px solid var(--border);color:var(--text);
padding:9px 12px;border-radius:var(--radius);font-size:.875rem;font-family:var(--sans);width:100%;outline:none;transition:.15s}
input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,143,255,.15)}
select option{background:var(--surface2)}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:.875rem}
th{text-align:left;color:var(--muted);font-size:.75rem;font-weight:600;text-transform:uppercase;
letter-spacing:.6px;padding:10px 12px;border-bottom:1px solid var(--border)}
td{padding:10px 12px;border-bottom:1px solid rgba(37,42,58,.6);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(255,255,255,.02)}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:.75rem;font-weight:600}
.badge-ok{background:rgba(62,207,142,.15);color:var(--ok)}
.badge-warn{background:rgba(245,166,35,.15);color:var(--warn)}
.badge-off{background:var(--surface2);color:var(--muted)}
.toggle{position:relative;display:inline-block;width:38px;height:22px}
.toggle input{opacity:0;width:0;height:0;position:absolute}
.toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:22px;cursor:pointer;transition:.2s}
.toggle-slider::before{content:'';position:absolute;width:16px;height:16px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s}
.toggle input:checked+.toggle-slider{background:var(--accent)}
.toggle input:checked+.toggle-slider::before{transform:translateX(16px)}
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;align-items:center;justify-content:center;padding:20px}
.modal-overlay.open{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);
padding:28px;max-width:700px;width:100%;max-height:90vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.6)}
.modal-title{font-size:1.1rem;font-weight:700;margin-bottom:20px;display:flex;justify-content:space-between;align-items:center}
.modal-close{background:none;border:none;color:var(--muted);font-size:1.3rem;cursor:pointer;padding:4px;line-height:1}
.modal-close:hover{color:var(--danger)}
.code-block{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);
padding:14px;font-family:var(--mono);font-size:.78rem;color:var(--ok);
overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow-y:auto}
.accordion-item{border:1px solid var(--border);border-radius:var(--radius);margin-bottom:8px;overflow:hidden}
.accordion-header{padding:12px 16px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;
background:var(--surface2);font-weight:600;font-size:.875rem;user-select:none}
.accordion-header:hover{background:var(--border)}
.accordion-body{display:none;padding:16px;background:var(--surface)}
.accordion-body.open{display:block}
.accordion-arrow{transition:.2s;color:var(--muted);display:inline-block}
.accordion-header.aopen .accordion-arrow{transform:rotate(180deg)}
.alert{padding:12px 16px;border-radius:var(--radius);font-size:.875rem;margin-bottom:16px}
.alert-ok{background:rgba(62,207,142,.12);border:1px solid rgba(62,207,142,.3);color:var(--ok)}
.alert-err{background:rgba(255,95,109,.12);border:1px solid rgba(255,95,109,.3);color:var(--danger)}
.alert-warn{background:rgba(245,166,35,.12);border:1px solid rgba(245,166,35,.3);color:var(--warn)}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.2);
border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.divider{height:1px;background:var(--border);margin:24px 0}
.row-actions{display:flex;gap:8px;align-items:center}
.section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}
</style>
</head>
<body>
<nav class="sidebar">
<div class="sidebar-logo">
<div class="logo-badge">MITA</div>Panel
</div>
<button class="nav-item active" onclick="showPage('dashboard',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
Дашборд
</button>
<button class="nav-item" onclick="showPage('users',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Пользователи
</button>
<button class="nav-item" onclick="showPage('ssl',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
SSL
</button>
<button class="nav-item" onclick="showPage('security',this)">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
Безопасность
</button>
<div class="sidebar-footer"><a href="{{ base }}/logout">Выйти</a></div>
</nav>
<main class="main">{% block content %}{% endblock %}</main>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-title">
<span id="modal-title-text"></span>
<button class="modal-close" onclick="closeModal()">&#x2715;</button>
</div>
<div id="modal-body"></div>
</div>
</div>
<div id="toast" style="position:fixed;bottom:24px;right:24px;z-index:999;display:none;
background:var(--surface2);border:1px solid var(--border);color:var(--ok);
padding:10px 18px;border-radius:8px;font-size:.85rem;transition:.2s"></div>
<script>
const BASE="{{ base }}";
function showPage(id,btn){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
document.getElementById('page-'+id).classList.add('active');
if(btn)btn.classList.add('active');
if(id==='dashboard')loadDashboard();
if(id==='users')loadUsers();
if(id==='ssl')loadSSL();
}
function openModal(title,html){
document.getElementById('modal-title-text').textContent=title;
document.getElementById('modal-body').innerHTML=html;
document.getElementById('modal').classList.add('open');
}
function closeModal(){document.getElementById('modal').classList.remove('open');}
document.getElementById('modal').addEventListener('click',e=>{if(e.target.id==='modal')closeModal();});
async function api(path,method='GET',body=null){
const opts={method,headers:{'Content-Type':'application/json'}};
if(body)opts.body=JSON.stringify(body);
const r=await fetch(BASE+path,opts);
if(r.status===401){location.href=BASE+'/login';return null;}
return r.json();
}
function showToast(msg,isErr=false){
const t=document.getElementById('toast');
t.textContent=msg;t.style.color=isErr?'var(--danger)':'var(--ok)';
t.style.display='block';setTimeout(()=>t.style.display='none',2500);
}
function copyText(text){navigator.clipboard.writeText(text).then(()=>showToast('Скопировано ✓'));}
function toggleAccordion(hdr){
hdr.classList.toggle('aopen');
hdr.nextElementSibling.classList.toggle('open');
}
function codeBlock(json){
const txt=JSON.stringify(json,null,2);
return `<div class="code-block" id="cb_${Math.random().toString(36).slice(2)}">${escHtml(txt)}</div>
<button class="btn btn-secondary btn-sm" style="margin-top:8px" onclick="copyText(${escAttr(txt)})">Копировать</button>`;
}
function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function escAttr(s){return "'"+s.replace(/\\/g,'\\\\').replace(/'/g,"\\'")+"'";}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+750
View File
@@ -0,0 +1,750 @@
{% extends "base.html" %}
{% block title %}mita panel{% endblock %}
{% block content %}
<!-- DASHBOARD -->
<div class="page active" id="page-dashboard">
<h1>Дашборд</h1>
<p class="subtitle">Обзор сервера в реальном времени</p>
<div class="grid-4" id="stat-cards">
<div class="stat-card"><div class="stat-label">IP сервера</div><div class="stat-value accent" style="font-size:1.1rem" id="s-server-ip"></div></div>
<div class="stat-card">
<div class="stat-label">IP WARP</div>
<div class="stat-value ok" style="font-size:1.1rem" id="s-warp-ip"></div>
<div id="s-warp-status" style="margin-top:6px"><span class="badge badge-off"></span></div>
</div>
<div class="stat-card"><div class="stat-label">Трафик за неделю</div><div class="stat-value accent" id="s-week"></div><div class="stat-sub">ГБ</div></div>
<div class="stat-card"><div class="stat-label">Трафик за месяц</div><div class="stat-value ok" id="s-month"></div><div class="stat-sub">ГБ</div></div>
</div>
<div class="grid-2">
<div class="stat-card">
<div class="stat-label">Порты mita</div>
<div class="stat-value" style="font-size:1.1rem" id="s-port"></div>
</div>
<div class="stat-card">
<div class="stat-label">Пользователей</div>
<div class="stat-value" id="s-users"></div>
</div>
<div class="stat-card">
<div class="stat-label">Статус mita</div>
<div id="s-mita-status" style="margin-top:6px"><span class="badge badge-off"></span></div>
</div>
</div>
<div class="card">
<div class="card-title">Быстрые действия</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="loadDashboard()">⟳ Обновить</button>
<button class="btn btn-secondary" onclick="showPage('users',document.querySelectorAll('.nav-item')[1])">Управление пользователями</button>
<button class="btn btn-secondary" onclick="showPage('ssl',document.querySelectorAll('.nav-item')[2])">Настройка SSL</button>
<button class="btn btn-secondary" onclick="showClients()">📱 Клиенты mieru</button>
</div>
</div>
<!-- Clients modal trigger -->
<div id="clients-info" style="display:none"></div>
</div>
<!-- USERS -->
<div class="page" id="page-users">
<div class="section-head">
<div><h1>Пользователи</h1><p class="subtitle">Создание и управление доступом</p></div>
</div>
<div class="card">
<div class="card-title">Создать пользователей</div>
<div class="form-row">
<div class="form-group" style="max-width:180px">
<label>Режим создания</label>
<select id="create-mode" onchange="toggleCreateMode()">
<option value="auto">Авто (случайные имена)</option>
<option value="manual">Вручную (свои имена)</option>
<option value="bulk-manual">Несколько вручную</option>
</select>
</div>
<div class="form-group" id="count-group" style="max-width:120px">
<label>Количество</label>
<input type="number" id="create-count" value="1" min="1" max="50">
</div>
<div class="form-group" id="single-name-group">
<label>Имя пользователя</label>
<input type="text" id="create-name" placeholder="например: vasya">
</div>
<div>
<button class="btn btn-primary" onclick="createUsers()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/></svg>
Создать
</button>
</div>
</div>
<div id="bulk-names-group" style="display:none">
<label style="font-size:.8rem;color:var(--muted);display:block;margin-bottom:6px">Имена (по одному на строку)</label>
<textarea id="bulk-names" rows="4" style="width:100%;background:var(--surface2);border:1px solid var(--border);
color:var(--text);padding:10px;border-radius:var(--radius);font-family:var(--mono);font-size:.85rem;resize:vertical;outline:none"
placeholder="alice&#10;bob&#10;charlie"></textarea>
</div>
</div>
<div class="card">
<div class="card-title">Список пользователей</div>
<div class="table-wrap">
<table>
<thead><tr><th>Имя</th><th>WARP вкл</th><th>Правила WARP</th><th>Конфиг клиента</th><th>Действия</th></tr></thead>
<tbody id="users-tbody"><tr><td colspan="4" style="color:var(--muted);text-align:center;padding:20px">Загрузка...</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- SSL -->
<div class="page" id="page-ssl">
<h1>SSL-сертификаты</h1>
<p class="subtitle">Защита панели управления</p>
<div class="card" id="ssl-status-card">
<div class="card-title">Текущий сертификат</div>
<div id="ssl-current"><div style="color:var(--muted)">Загрузка...</div></div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-title">Самоподписной сертификат</div>
<p style="color:var(--muted);font-size:.875rem;margin-bottom:16px;line-height:1.6">
Генерируется мгновенно, действует <strong style="color:var(--text)">10 лет</strong>.
Браузер покажет предупреждение — его можно принять.
Подходит для личного использования.
</p>
<button class="btn btn-secondary" onclick="genSelfSigned()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
Создать самоподписной
</button>
<div id="selfsigned-result" style="margin-top:12px"></div>
</div>
<div class="card">
<div class="card-title">Let's Encrypt (бесплатный)</div>
<p style="color:var(--muted);font-size:.875rem;margin-bottom:16px;line-height:1.6">
Доверенный сертификат на <strong style="color:var(--text)">90 дней</strong>, продлевается автоматически.
Требует домен, указывающий на этот сервер, и открытый порт 80.
</p>
<div class="form-group" style="margin-bottom:10px">
<label>Домен</label>
<input type="text" id="le-domain" placeholder="panel.example.com">
</div>
<div class="form-group" style="margin-bottom:14px">
<label>Email (необязательно)</label>
<input type="email" id="le-email" placeholder="admin@example.com">
</div>
<button class="btn btn-ok" onclick="getLetsEncrypt()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/></svg>
Получить сертификат
</button>
<div id="le-result" style="margin-top:12px"></div>
</div>
</div>
<div class="card">
<div class="card-title">Применение сертификата</div>
<div class="alert alert-warn">
После смены сертификата необходимо перезапустить панель:<br>
<code style="font-family:var(--mono);font-size:.85rem">systemctl restart mita-panel</code>
</div>
</div>
</div>
{% endblock %}
<!-- SECURITY / FAIL2BAN -->
<div class="page" id="page-security">
<h1>Безопасность</h1>
<p class="subtitle">Защита панели и настройка fail2ban</p>
<div class="card" id="f2b-status-card">
<div class="card-title">Статус fail2ban</div>
<div id="f2b-status-body"><div style="color:var(--muted)">Загрузка...</div></div>
</div>
<div class="card">
<div class="card-title">Настройка лимита попыток входа</div>
<p style="color:var(--muted);font-size:.875rem;margin-bottom:18px;line-height:1.6">
Лимит работает на двух уровнях: встроенный счётчик панели (мгновенно) и fail2ban (блокировка на сетевом уровне).
</p>
<div style="margin-bottom:16px">
<div class="card-title" style="margin-bottom:12px">Быстрые варианты</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="applyPreset(3,1800)">Строгий &mdash; 3 / 30 min</button>
<button class="btn btn-secondary" onclick="applyPreset(5,3600)">Стандарт &mdash; 5 / 1 chas</button>
<button class="btn btn-secondary" onclick="applyPreset(10,900)">Мягкий &mdash; 10 / 15 min</button>
<button class="btn btn-secondary" onclick="applyPreset(3,86400)">Жёсткий &mdash; 3 / 24 ch</button>
</div>
</div>
<div class="divider"></div>
<div class="card-title" style="margin-bottom:12px">Ручная настройка</div>
<div class="form-row">
<div class="form-group" style="max-width:200px">
<label>Попыток до блокировки</label>
<input type="number" id="f2b-maxretry" value="5" min="1" max="100">
</div>
<div class="form-group" style="max-width:220px">
<label>Время блокировки</label>
<select id="f2b-bantime" onchange="toggleCustomBantime()">
<option value="300">5 минут</option>
<option value="900">15 минут</option>
<option value="1800">30 минут</option>
<option value="3600" selected>1 chas</option>
<option value="7200">2 часа</option>
<option value="86400">24 часа</option>
<option value="604800">7 дней</option>
<option value="0">Указать вручную (сек)</option>
</select>
</div>
<div class="form-group" id="custom-bantime-group" style="display:none;max-width:160px">
<label>Секунды</label>
<input type="number" id="f2b-bantime-custom" value="3600" min="60">
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<label class="toggle">
<input type="checkbox" id="f2b-install" checked>
<span class="toggle-slider"></span>
</label>
<span style="font-size:.875rem;color:var(--muted)">Установить fail2ban если не установлен</span>
</div>
<button class="btn btn-primary" onclick="saveF2bConfig()">Применить</button>
<div id="f2b-result" style="margin-top:12px"></div>
</div>
</div>
{% block scripts %}
<script>
// ──────────────── DASHBOARD ────────────────
async function loadDashboard(){
const d=await api('/api/dashboard');
if(!d)return;
document.getElementById('s-server-ip').textContent=d.server_ip||'?';
document.getElementById('s-warp-ip').textContent=d.warp_ip||'?';
const warpEl=document.getElementById('s-warp-status');
if(warpEl){
const warpOk=d.warp_ip&&d.warp_ip!=='недоступен'&&d.warp_ip!=='?';
warpEl.innerHTML=warpOk
?'<span class="badge badge-ok">● Активен</span>'
:'<span class="badge" style="background:rgba(255,95,109,.15);color:var(--danger)">● Недоступен</span>';
}
document.getElementById('s-week').textContent=d.week_gb??'0';
document.getElementById('s-month').textContent=d.month_gb??'0';
document.getElementById('s-port').textContent=d.mita_port||'?';
document.getElementById('s-users').textContent=d.users_count??'0';
const el=document.getElementById('s-mita-status');
if(el) el.innerHTML=d.mita_running
?'<span class="badge badge-ok">● Работает</span>'
:'<span class="badge" style="background:rgba(255,95,109,.15);color:var(--danger)">● Остановлен</span>';
}
loadDashboard();
function showClients(){
const clients=[
{name:'mieru (официальный CLI)',url:'https://github.com/enfein/mieru',desc:'CLI-клиент для macOS, Linux, Windows, OpenWrt'},
{name:'Karing',url:'https://github.com/KaringX/karing',desc:'GUI клиент с поддержкой mieru, iOS/Android/Windows/macOS'},
{name:'NekoBox (NekoRay)',url:'https://github.com/MatsuriDayo/nekoray',desc:'GUI клиент для Windows/Linux, поддерживает mieru через sing-box'},
{name:'sing-box',url:'https://github.com/SagerNet/sing-box',desc:'Универсальное ядро, поддерживает mieru, все платформы'},
{name:'Hiddify',url:'https://github.com/hiddify/hiddify-app',desc:'Кросс-платформенный клиент на основе sing-box'},
{name:'FlClash',url:'https://github.com/chen08209/FlClash',desc:'Клиент на основе Clash, Android/Windows/Linux/macOS'},
];
const html=`
<p style="color:var(--muted);font-size:.85rem;margin-bottom:16px">
mieru — собственный протокол. Поддерживается в следующих клиентах:
</p>
${clients.map(c=>`
<div style="display:flex;justify-content:space-between;align-items:center;
padding:12px 0;border-bottom:1px solid var(--border)">
<div>
<div style="font-weight:600;margin-bottom:3px">${escH(c.name)}</div>
<div style="font-size:.8rem;color:var(--muted)">${escH(c.desc)}</div>
</div>
<a href="${escH(c.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="flex-shrink:0;margin-left:16px">
GitHub ↗
</a>
</div>`).join('')}
<p style="font-size:.75rem;color:var(--muted);margin-top:16px">
Для подключения используйте SOCKS5 127.0.0.1:1080 (mieru клиент) или встроенную поддержку протокола в Karing/sing-box.
</p>`;
openModal('Клиенты для mieru',html);
}
// ──────────────── USERS ────────────────
let warpUsers=new Set();
async function loadUsers(){
const [ud,wd]=await Promise.all([api('/api/users'),api('/api/users/warp_status')]);
if(!ud)return;
warpUsers=new Set(wd?.warp_users||[]);
const tbody=document.getElementById('users-tbody');
if(!ud.users.length){
tbody.innerHTML='<tr><td colspan="4" style="color:var(--muted);text-align:center;padding:20px">Нет пользователей</td></tr>';
return;
}
tbody.innerHTML=ud.users.map(u=>{
const wOn=warpUsers.has(u.name);
return `<tr>
<td><strong>${escH(u.name)}</strong></td>
<td>
<label class="toggle" title="Весь WARP для этого пользователя">
<input type="checkbox" ${wOn?'checked':''} onchange="setWarp('${escH(u.name)}',this.checked)">
<span class="toggle-slider"></span>
</label>
</td>
<td>
<button class="btn btn-secondary btn-sm" onclick="openWarpRules('${escH(u.name)}')" ${wOn?'':'style="opacity:.4;cursor:not-allowed" disabled'}>
⚙ Настроить
</button>
</td>
<td><button class="btn btn-secondary btn-sm" onclick="showConfig('${escH(u.name)}')">Показать конфиг</button></td>
<td><button class="btn btn-danger btn-sm" onclick="deleteUser('${escH(u.name)}')">Удалить</button></td>
</tr>`;
}).join('');
}
function toggleCreateMode(){
const mode=document.getElementById('create-mode').value;
document.getElementById('count-group').style.display=mode==='manual'?'none':'';
document.getElementById('single-name-group').style.display=mode==='manual'?'':'none';
document.getElementById('bulk-names-group').style.display=mode==='bulk-manual'?'':'none';
}
toggleCreateMode();
async function createUsers(){
const mode=document.getElementById('create-mode').value;
let body={};
if(mode==='auto'){
body={mode:'auto',count:parseInt(document.getElementById('create-count').value)||1};
}else if(mode==='manual'){
const n=document.getElementById('create-name').value.trim();
if(!n){showToast('Введите имя',true);return;}
body={mode:'manual',names:[n],count:1};
}else{
const lines=document.getElementById('bulk-names').value.split('\n').map(s=>s.trim()).filter(Boolean);
if(!lines.length){showToast('Введите имена',true);return;}
body={mode:'manual',names:lines,count:lines.length};
}
const d=await api('/api/users/create','POST',body);
if(!d||!d.created){showToast('Ошибка',true);return;}
showToast(`Создано: ${d.created.length}`);
showCreatedConfigs(d.created);
loadUsers();
}
function showCreatedConfigs(created){
if(!created.length)return;
let html='';
if(created.length===1){
const u=created[0];
html=`<p style="margin-bottom:12px;color:var(--muted);font-size:.875rem">
Пользователь <strong style="color:var(--text)">${escH(u.name)}</strong> создан.<br>
Пароль: <code style="font-family:var(--mono)">${escH(u.password)}</code></p>
${codeBlock(u.client_config)}`;
}else{
html=`<p style="margin-bottom:16px;color:var(--muted);font-size:.875rem">Создано ${created.length} пользователей. Выберите нужного:</p>`+
created.map(u=>`
<div class="accordion-item">
<div class="accordion-header" onclick="toggleAccordion(this)">
<span>${escH(u.name)}</span>
<span class="accordion-arrow">&#x25BC;</span>
</div>
<div class="accordion-body">
<p style="font-size:.8rem;color:var(--muted);margin-bottom:8px">
Пароль: <code style="font-family:var(--mono)">${escH(u.password)}</code>
</p>
${codeBlock(u.client_config)}
</div>
</div>`).join('');
}
openModal('Конфиги созданных пользователей', html);
}
async function showConfig(name){
const d=await api('/api/users/config?name='+encodeURIComponent(name));
if(!d||!d.ok){
openModal('Конфиг: '+name,'<div class="alert alert-err">'+(d?.error||'Ошибка загрузки')+'</div>');
return;
}
const enc=encodeURIComponent(name);
const dlMieru =BASE+'/api/users/config/download?name='+enc+'&format=mieru';
const dlSingbox=BASE+'/api/users/config/download?name='+enc+'&format=singbox';
// Tab IDs
const tid='cfg_'+Math.random().toString(36).slice(2);
const html=`
<div style="margin-bottom:12px">
<span style="font-size:.85rem;color:var(--muted)">Пользователь: </span>
<strong>${escH(d.name)}</strong>
<span style="margin-left:12px;font-size:.85rem;color:var(--muted)">Пароль: </span>
<code style="font-family:var(--mono);font-size:.82rem;color:var(--ok)">${escH(d.password)}</code>
</div>
<!-- Tabs -->
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:16px">
<button id="${tid}_tab_mieru"
onclick="switchConfigTab('${tid}','mieru')"
style="padding:8px 18px;font-size:.85rem;font-weight:600;border:none;background:none;
cursor:pointer;border-bottom:2px solid var(--accent);color:var(--accent)">
mieru JSON
</button>
<button id="${tid}_tab_singbox"
onclick="switchConfigTab('${tid}','singbox')"
style="padding:8px 18px;font-size:.85rem;font-weight:600;border:none;background:none;
cursor:pointer;border-bottom:2px solid transparent;color:var(--muted)">
sing-box / Karing
</button>
</div>
<!-- mieru tab -->
<div id="${tid}_pane_mieru">
<p style="font-size:.78rem;color:var(--muted);margin-bottom:8px">
Для официального клиента <strong style="color:var(--text)">mieru</strong> на macOS, Linux, Windows, OpenWrt.
</p>
${codeBlock(d.client_config)}
<a href="${dlMieru}" download="${escH(d.name)}_mieru.json"
class="btn btn-secondary btn-sm" style="margin-top:8px">
⬇ Скачать mieru.json
</a>
</div>
<!-- sing-box tab -->
<div id="${tid}_pane_singbox" style="display:none">
<p style="font-size:.78rem;color:var(--muted);margin-bottom:8px">
Для <strong style="color:var(--text)">Karing, Hiddify, NekoBox</strong> и других клиентов на базе sing-box.
Включает TUN-режим и правила для прямого выхода российских IP.
</p>
${codeBlock(d.singbox_config)}
<a href="${dlSingbox}" download="${escH(d.name)}_singbox.json"
class="btn btn-ok btn-sm" style="margin-top:8px">
⬇ Скачать singbox.json
</a>
<div class="divider"></div>
<div class="accordion-item">
<div class="accordion-header" onclick="toggleAccordion(this)">
<span>📋 Если импорт файла не работает — добавить вручную в Karing</span>
<span class="accordion-arrow">▼</span>
</div>
<div class="accordion-body">
${karingManualInstructions(d)}
</div>
</div>
</div>
`;
openModal('Конфиг: '+name, html);
}
function karingManualInstructions(d){
const ip = d.client_config.profiles[0].servers[0].ipAddress;
const pb = d.client_config.profiles[0].servers[0].portBindings[0];
const port = (pb.portRange||String(pb.port)).split('-')[0];
const user = escH(d.name);
const pass = escH(d.password);
const step = (n, html) => `
<div style="display:flex;gap:12px;margin-bottom:14px">
<div style="flex-shrink:0;width:24px;height:24px;border-radius:50%;background:var(--surface2);
border:1px solid var(--border);display:flex;align-items:center;justify-content:center;
font-size:.75rem;font-weight:700;color:var(--accent)">${n}</div>
<div style="font-size:.82rem;color:var(--text);line-height:1.6;padding-top:2px">${html}</div>
</div>`;
const field = (label, value) => `
<div style="margin-top:6px">
<span style="color:var(--muted)">${label}:</span>
<code style="font-family:var(--mono);color:var(--ok);background:var(--bg);
padding:2px 8px;border-radius:5px;margin-left:6px">${value}</code>
<button class="btn btn-secondary btn-sm" style="padding:2px 8px;margin-left:6px"
onclick="copyText('${value}')">копировать</button>
</div>`;
return `
<p style="font-size:.78rem;color:var(--muted);margin-bottom:16px">
Используйте если кнопка «Скачать singbox.json» → «Импорт» не подключается.
Karing требует ручного ввода полей mieru через интерфейс.
</p>
${step(1, `Откройте Karing → <strong>добавить профиль</strong>`)}
${step(2, `Выберите тип <strong>«Custom»</strong> и впишите любое имя профиля`)}
${step(3, `Перейдите в раздел <strong>«My profiles»</strong>`)}
${step(4, `Нажмите <strong>«+»</strong> в поле только что созданного профиля`)}
${step(5, `В выпадающем списке протоколов выберите <strong>«Mieru»</strong>`)}
${step(6, `В поле <strong>«tag»</strong> впишите любое название (например <code>proxy</code>)`)}
${step(7, `Заполните адрес сервера и порт:
${field('server', ip)}
${field('server port', port)}`)}
${step(8, `Заполните учётные данные:
${field('username', user)}
${field('password', pass)}`)}
${step(9, `В поле <strong>«multiplexing»</strong> выберите <strong>«multiplexing_low»</strong>`)}
${step(10, `В поле <strong>«transport»</strong> впишите заглавными буквами <code style="font-family:var(--mono);color:var(--ok)">TCP</code>,
затем нажмите ✓ в правом верхнем углу.<br>
Убедитесь что в «My profiles» тумблер профиля зелёный, затем нажмите кнопку подключения в главном меню Karing.`)}
<div class="divider"></div>
${step(11, `<strong>Diversion Rules (разделение трафика)</strong> — чтобы направить конкретные сайты/приложения мимо VPN или, наоборот, через него:
<ol style="margin-top:8px;padding-left:18px;color:var(--muted)">
<li>Нажмите карандаш (✏) в правом верхнем углу раздела «Diversion Rules»</li>
<li>Нажмите «⋮» (три точки) там же → <strong>«+ Add»</strong></li>
<li>Введите название правила, прокрутите список вниз и выберите созданное правило</li>
<li>Добавьте нужные адреса/приложения, нажмите ✓ в правом верхнем углу</li>
<li>Вернитесь в «Diversion Rules», найдите своё правило внизу списка</li>
<li>Нажмите на него и выберите <strong>«Direct»</strong> (трафик идёт мимо VPN)
или раскройте имя своего профиля и выберите имя VPN-конфига (трафик идёт через VPN)</li>
</ol>`)}
`;
}
function switchConfigTab(tid, tab) {
['mieru','singbox'].forEach(t => {
const isActive = t === tab;
const btn = document.getElementById(tid+'_tab_'+t);
const pane = document.getElementById(tid+'_pane_'+t);
if (!btn || !pane) return;
btn.style.borderBottomColor = isActive ? 'var(--accent)' : 'transparent';
btn.style.color = isActive ? 'var(--accent)' : 'var(--muted)';
pane.style.display = isActive ? '' : 'none';
});
}
async function deleteUser(name){
if(!confirm(`Удалить пользователя "${name}"?`))return;
await api('/api/users/delete','POST',{name});
showToast(`${name} удалён`);
loadUsers();
}
async function setWarp(name,enabled){
await api('/api/users/warp','POST',{name,enabled});
showToast(`WARP для ${name}: ${enabled?'включён':'выключен'}`);
loadUsers();
}
async function openWarpRules(name){
// Загрузить текущие правила
const d=await api('/api/users/warp_rules?name='+encodeURIComponent(name));
const r=d?.rules||{domains:[],ips:[],sources:[]};
const html=`
<p style="color:var(--muted);font-size:.85rem;margin-bottom:18px;line-height:1.6">
Трафик этого пользователя через <strong style="color:var(--accent)">WARP</strong> только для указанных доменов, IP и источников.<br>
Всё остальное идёт напрямую с IP вашего сервера.
</p>
<div style="margin-bottom:16px">
<label style="font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;display:block;margin-bottom:8px">
Домены (по одному на строку)
</label>
<textarea id="wr-domains" rows="5" style="width:100%;background:var(--bg);border:1px solid var(--border);
color:var(--text);padding:10px 12px;border-radius:8px;font-family:var(--mono);font-size:.82rem;
resize:vertical;outline:none;line-height:1.6" placeholder="openai.com&#10;chatgpt.com&#10;anthropic.com">${escH(r.domains.join('\n'))}</textarea>
<p style="font-size:.75rem;color:var(--muted);margin-top:4px">Субдомены включаются автоматически</p>
</div>
<div style="margin-bottom:16px">
<label style="font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;display:block;margin-bottom:8px">
IP-диапазоны / CIDR (по одному на строку)
</label>
<textarea id="wr-ips" rows="3" style="width:100%;background:var(--bg);border:1px solid var(--border);
color:var(--text);padding:10px 12px;border-radius:8px;font-family:var(--mono);font-size:.82rem;
resize:vertical;outline:none;line-height:1.6" placeholder="104.16.0.0/12&#10;172.64.0.0/13">${escH(r.ips.join('\n'))}</textarea>
</div>
<div style="margin-bottom:20px">
<label style="font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;display:block;margin-bottom:8px">
Источники — geosite / geoip / URL (по одному на строку)
</label>
<textarea id="wr-sources" rows="4" style="width:100%;background:var(--bg);border:1px solid var(--border);
color:var(--text);padding:10px 12px;border-radius:8px;font-family:var(--mono);font-size:.82rem;
resize:vertical;outline:none;line-height:1.6"
placeholder="geosite:openai&#10;geosite:netflix&#10;geoip:us&#10;https://example.com/domains.txt">${escH(r.sources.join('\n'))}</textarea>
<div style="font-size:.75rem;color:var(--muted);margin-top:6px;line-height:1.7">
<strong style="color:var(--text)">geosite:категория</strong> — из базы v2fly (openai, google, netflix, category-ru…)<br>
<strong style="color:var(--text)">geoip:страна</strong> — IP-диапазоны страны (us, gb, de…)<br>
<strong style="color:var(--text)">https://…</strong> — прямая ссылка на текстовый файл с доменами или CIDR
</div>
</div>
<div style="display:flex;gap:10px">
<button class="btn btn-primary" onclick="saveWarpRules('${escH(name)}')">
Сохранить и применить
</button>
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
</div>
<div id="wr-result" style="margin-top:12px"></div>
`;
openModal('WARP-правила: '+name, html);
}
async function saveWarpRules(name){
const btn=document.querySelector('#modal-body .btn-primary');
btn.disabled=true;
btn.innerHTML='<span class="spinner"></span> Применяю…';
const domains=document.getElementById('wr-domains').value.split('\n').map(s=>s.trim()).filter(Boolean);
const ips=document.getElementById('wr-ips').value.split('\n').map(s=>s.trim()).filter(Boolean);
const sources=document.getElementById('wr-sources').value.split('\n').map(s=>s.trim()).filter(Boolean);
const d=await api('/api/users/warp_rules','POST',{name,domains,ips,sources});
btn.disabled=false;
btn.textContent='Сохранить и применить';
const el=document.getElementById('wr-result');
if(d?.ok){
el.innerHTML='<div class="alert alert-ok">✓ Правила сохранены и применены в mita</div>';
showToast('Правила WARP обновлены');
}else{
el.innerHTML=`<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
}
}
// ──────────────── SSL ────────────────
async function loadSSL(){
const d=await api('/api/ssl/status');
if(!d)return;
const el=document.getElementById('ssl-current');
if(d.type==='none'){
el.innerHTML='<div class="alert alert-warn">Сертификат не настроен. Панель работает по HTTP.</div>';
}else{
const icon=d.type==='letsencrypt'?'🔒':'🔑';
const label=d.type==='letsencrypt'?"Let's Encrypt":'Самоподписной';
el.innerHTML=`
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
<div style="font-size:2rem">${icon}</div>
<div>
<div style="font-weight:700;margin-bottom:4px">${label}</div>
${d.domain?`<div style="color:var(--muted);font-size:.85rem">Домен: <span style="color:var(--accent)">${escH(d.domain)}</span></div>`:''}
<div style="color:var(--muted);font-size:.85rem">Файл: <code style="font-family:var(--mono)">${escH(d.cert)}</code></div>
<div style="color:var(--muted);font-size:.85rem;margin-top:4px">${escH(d.expires)}</div>
</div>
</div>`;
}
}
async function genSelfSigned(){
const btn=event.target;
btn.disabled=true;btn.innerHTML='<span class="spinner"></span> Генерация...';
const el=document.getElementById('selfsigned-result');
const d=await api('/api/ssl/selfsigned','POST');
btn.disabled=false;btn.textContent='Создать самоподписной';
if(!d)return;
if(d.ok){
el.innerHTML=`<div class="alert alert-ok">✓ Сертификат создан (10 лет)<br><small>${escH(d.expires||'')}</small><br><small style="opacity:.7">${escH(d.note||'')}</small></div>`;
loadSSL();
}else{
el.innerHTML=`<div class="alert alert-err">Ошибка: ${escH(d.error||'')}</div>`;
}
}
async function getLetsEncrypt(){
const domain=document.getElementById('le-domain').value.trim();
const email=document.getElementById('le-email').value.trim();
if(!domain){showToast('Укажите домен',true);return;}
const btn=event.target;
btn.disabled=true;btn.innerHTML='<span class="spinner"></span> Получение (может занять минуту)...';
const el=document.getElementById('le-result');
const d=await api('/api/ssl/letsencrypt','POST',{domain,email});
btn.disabled=false;btn.textContent="Получить сертификат";
if(!d)return;
if(d.ok){
el.innerHTML=`<div class="alert alert-ok">✓ Сертификат получен!<br><small>${escH(d.expires||'')}</small><br><small style="opacity:.7">${escH(d.note||'')}</small></div>`;
loadSSL();
}else{
el.innerHTML=`<div class="alert alert-err">Ошибка:<br><pre style="white-space:pre-wrap;font-size:.78rem;margin-top:6px">${escH(d.error||'')}</pre></div>`;
}
}
// ──────────────── SECURITY / FAIL2BAN ────────────────
async function loadSecurityPage() {
const d = await api('/api/fail2ban/status');
if (!d) return;
const el = document.getElementById('f2b-status-body');
const instBadge = d.installed
? '<span class="badge badge-ok">Установлен</span>'
: '<span class="badge badge-off">Не установлен</span>';
const activeBadge = d.active
? '<span class="badge badge-ok">Активен</span>'
: '<span class="badge badge-warn">Остановлен</span>';
const jailBadge = d.jail_active
? '<span class="badge badge-ok">Jail активен</span>'
: '<span class="badge badge-off">Jail не настроен</span>';
el.innerHTML = `
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px">
${instBadge} ${d.installed ? activeBadge : ''} ${d.active ? jailBadge : ''}
</div>
<div style="font-size:.85rem;color:var(--muted)">
Текущие лимиты:
<strong style="color:var(--text)">${d.max_retry} попыток</strong> /
<strong style="color:var(--text)">${fmtSeconds(d.ban_time)}</strong>
</div>`;
// Sync inputs
document.getElementById('f2b-maxretry').value = d.max_retry;
const sel = document.getElementById('f2b-bantime');
const opts = [...sel.options].map(o => o.value);
if (opts.includes(String(d.ban_time))) {
sel.value = d.ban_time;
} else {
sel.value = '0';
document.getElementById('f2b-bantime-custom').value = d.ban_time;
document.getElementById('custom-bantime-group').style.display = '';
}
}
function fmtSeconds(s) {
if (s >= 86400) return Math.round(s/86400) + ' дн.';
if (s >= 3600) return Math.round(s/3600) + ' ч.';
if (s >= 60) return Math.round(s/60) + ' мин.';
return s + ' сек.';
}
document.getElementById('f2b-bantime').addEventListener('change', function() {
document.getElementById('custom-bantime-group').style.display =
this.value === '0' ? '' : 'none';
});
function applyPreset(maxRetry, banTime) {
document.getElementById('f2b-maxretry').value = maxRetry;
const sel = document.getElementById('f2b-bantime');
const opts = [...sel.options].map(o => o.value);
if (opts.includes(String(banTime))) {
sel.value = banTime;
document.getElementById('custom-bantime-group').style.display = 'none';
} else {
sel.value = '0';
document.getElementById('f2b-bantime-custom').value = banTime;
document.getElementById('custom-bantime-group').style.display = '';
}
saveF2bConfig();
}
async function saveF2bConfig() {
const maxRetry = parseInt(document.getElementById('f2b-maxretry').value) || 5;
const selVal = document.getElementById('f2b-bantime').value;
const banTime = selVal === '0'
? parseInt(document.getElementById('f2b-bantime-custom').value) || 3600
: parseInt(selVal);
const install = document.getElementById('f2b-install').checked;
const btn = document.querySelector('#page-security .btn-primary');
const el = document.getElementById('f2b-result');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Применяю…';
const d = await api('/api/fail2ban/configure', 'POST', {max_retry: maxRetry, ban_time: banTime, install});
btn.disabled = false;
btn.textContent = 'Применить';
if (d?.ok) {
el.innerHTML = `<div class="alert alert-ok">✓ ${escH(d.message||'Применено')}</div>`;
loadSecurityPage();
} else {
el.innerHTML = `<div class="alert alert-err">Ошибка: ${escH(d?.error||'')}</div>`;
}
}
function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function codeBlock(json){
const txt=JSON.stringify(json,null,2);
const id='cb'+Math.random().toString(36).slice(2);
return `<div class="code-block" id="${id}">${escH(txt)}</div>
<button class="btn btn-secondary btn-sm" style="margin-top:8px" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>showToast('Скопировано ✓'))">Копировать</button>`;
}
</script>
{% endblock %}
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>mita panel — вход</title>
<style>
:root{--bg:#0d0f14;--surface:#141720;--border:#252a3a;--accent:#4f8fff;--accent2:#7b5fff;
--ok:#3ecf8e;--danger:#ff5f6d;--text:#e2e8f4;--muted:#6b7594;--sans:'Inter',system-ui,sans-serif;--radius:10px}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--sans);background:var(--bg);color:var(--text);min-height:100vh;
display:flex;align-items:center;justify-content:center}
.wrap{width:100%;max-width:380px;padding:20px}
.logo{text-align:center;margin-bottom:32px}
.logo-badge{display:inline-block;background:linear-gradient(135deg,var(--accent),var(--accent2));
color:#fff;font-size:.85rem;font-weight:700;padding:6px 14px;border-radius:8px;letter-spacing:.5px;margin-bottom:12px}
.logo h1{font-size:1.4rem;font-weight:700;letter-spacing:-.3px}
.logo p{color:var(--muted);font-size:.85rem;margin-top:4px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:28px}
.form-group{margin-bottom:16px}
label{display:block;font-size:.8rem;font-weight:500;color:var(--muted);margin-bottom:6px}
input{width:100%;background:#1c2030;border:1px solid var(--border);color:var(--text);
padding:10px 14px;border-radius:var(--radius);font-size:.9rem;outline:none;transition:.15s;font-family:var(--sans)}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,143,255,.15)}
.btn{width:100%;padding:11px;background:var(--accent);color:#fff;border:none;
border-radius:var(--radius);font-size:.9rem;font-weight:600;cursor:pointer;transition:.15s;font-family:var(--sans)}
.btn:hover{background:#3d7aff}.btn:disabled{opacity:.6;cursor:not-allowed}
.err{color:var(--danger);font-size:.83rem;margin-top:12px;text-align:center;display:none}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);
border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="wrap">
<div class="logo">
<div class="logo-badge">MITA</div>
<h1>Панель управления</h1>
<p>Войдите, чтобы продолжить</p>
</div>
<div class="card">
<div class="form-group">
<label>Логин</label>
<input type="text" id="u" autocomplete="username" placeholder="admin">
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" id="p" autocomplete="current-password" placeholder="••••••••">
</div>
<button class="btn" id="loginBtn" onclick="doLogin()">Войти</button>
<div class="err" id="err"></div>
<div id="attempts-wrap" style="display:none;margin-top:10px;text-align:center">
<span style="font-size:.8rem;color:var(--muted)">Осталось попыток: </span>
<span id="attempts-left" style="font-size:.8rem;font-weight:700;color:var(--warn)"></span>
</div>
</div>
</div>
<script>
const BASE="{{ base }}";
document.addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();});
async function doLogin(){
const btn=document.getElementById('loginBtn');
const err=document.getElementById('err');
const attWrap=document.getElementById('attempts-wrap');
const attLeft=document.getElementById('attempts-left');
btn.disabled=true;btn.innerHTML='<span class="spinner"></span>Вхожу...';err.style.display='none';
try{
const r=await fetch(BASE+'/login',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({username:document.getElementById('u').value,password:document.getElementById('p').value})});
const d=await r.json();
if(d.ok){
location.href=BASE+'/';
return;
}
err.textContent=d.error||'Ошибка';
err.style.display='block';
if(d.rate_limited){
attWrap.style.display='block';
attLeft.textContent='0';
btn.disabled=true;
btn.textContent='Заблокировано';
return;
}
if(typeof d.remaining==='number'){
attWrap.style.display='block';
attLeft.textContent=d.remaining;
attLeft.style.color=d.remaining<=1?'var(--danger)':'var(--warn)';
}
}catch(e){err.textContent='Ошибка соединения';err.style.display='block';}
btn.disabled=false;btn.textContent='Войти';
}
</script>
</body>
</html>