version 0.3 Добавлена инструкция для Karing, добавлена установка необходимых зависимостей
This commit is contained in:
@@ -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}"'}
|
||||
)
|
||||
|
||||
Executable
+334
@@ -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
@@ -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
|
||||
Executable
+170
@@ -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
@@ -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
|
||||
|
||||
@@ -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()">✕</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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
function escAttr(s){return "'"+s.replace(/\\/g,'\\\\').replace(/'/g,"\\'")+"'";}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 bob 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)">Строгий — 3 / 30 min</button>
|
||||
<button class="btn btn-secondary" onclick="applyPreset(5,3600)">Стандарт — 5 / 1 chas</button>
|
||||
<button class="btn btn-secondary" onclick="applyPreset(10,900)">Мягкий — 10 / 15 min</button>
|
||||
<button class="btn btn-secondary" onclick="applyPreset(3,86400)">Жёсткий — 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">▼</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 chatgpt.com 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 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 geosite:netflix geoip:us 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
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 %}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user