2026-06-19 14:30:41 +03:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
mita Web Panel — Flask backend
|
|
|
|
|
|
"""
|
2026-06-26 23:37:05 +03:00
|
|
|
|
import os, json, re, subprocess, secrets, string, random, ipaddress, socket, time, logging
|
|
|
|
|
|
import psutil
|
2026-06-19 14:30:41 +03:00
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
from functools import wraps
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-06-19 22:46:43 +03:00
|
|
|
|
logging.basicConfig(level=logging.INFO,
|
|
|
|
|
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
|
|
|
|
|
|
|
2026-06-19 14:30:41 +03:00
|
|
|
|
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"
|
2026-06-19 15:37:02 +03:00
|
|
|
|
# SESSION_COOKIE_SECURE не задаём статически — это ломает логин, если
|
|
|
|
|
|
# SSL_CERT прописан в env, но gunicorn по факту поднялся на HTTP
|
|
|
|
|
|
# (например, сертификат ещё не физически на диске, или используется
|
|
|
|
|
|
# SSH-туннель без TLS). Вместо этого выставляем Secure динамически
|
|
|
|
|
|
# по факту запроса — см. _set_cookie_secure_dynamically ниже.
|
2026-06-19 14:30:41 +03:00
|
|
|
|
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))
|
2026-06-19 22:46:43 +03:00
|
|
|
|
_apply_mita_config_safe()
|
|
|
|
|
|
|
|
|
|
|
|
_log = logging.getLogger("mita_panel")
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_mita_config_safe():
|
|
|
|
|
|
"""Apply mita config and restart the service.
|
|
|
|
|
|
Mirrors the shell _apply_mita_config logic: starts mita temporarily if stopped,
|
|
|
|
|
|
so mita apply config (which requires a running daemon) always succeeds."""
|
|
|
|
|
|
|
|
|
|
|
|
bg_proc = None
|
|
|
|
|
|
mita_was_stopped = False
|
|
|
|
|
|
|
|
|
|
|
|
if not _mita_running():
|
|
|
|
|
|
pb = Path("/etc/mita/server.conf.pb")
|
|
|
|
|
|
try:
|
|
|
|
|
|
pb.unlink(missing_ok=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
subprocess.run(["systemctl", "reset-failed", "mita"], capture_output=True)
|
|
|
|
|
|
bg_proc = subprocess.Popen(
|
|
|
|
|
|
["/usr/bin/mita", "run"],
|
|
|
|
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
|
|
|
|
)
|
|
|
|
|
|
# Installer uses sleep 3 — give mita enough time to open its RPC socket
|
|
|
|
|
|
time.sleep(3)
|
|
|
|
|
|
mita_was_stopped = True
|
|
|
|
|
|
|
|
|
|
|
|
r = subprocess.run(["mita", "apply", "config", MITA_CONFIG],
|
|
|
|
|
|
capture_output=True, text=True)
|
|
|
|
|
|
if r.returncode != 0:
|
|
|
|
|
|
_log.error("mita apply config failed (rc=%d): %s %s",
|
|
|
|
|
|
r.returncode, r.stdout.strip(), r.stderr.strip())
|
|
|
|
|
|
|
|
|
|
|
|
if mita_was_stopped and bg_proc is not None:
|
|
|
|
|
|
bg_proc.terminate()
|
|
|
|
|
|
try:
|
|
|
|
|
|
bg_proc.wait(timeout=3)
|
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
bg_proc.kill()
|
|
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
|
|
|
|
|
|
# Fix ownership so the mita systemd service (runs as mita user) can read the .pb
|
|
|
|
|
|
pb = Path("/etc/mita/server.conf.pb")
|
|
|
|
|
|
if pb.exists():
|
|
|
|
|
|
subprocess.run(["chown", "mita:mita", str(pb)], capture_output=True)
|
|
|
|
|
|
|
|
|
|
|
|
subprocess.run(["systemctl", "reset-failed", "mita"], capture_output=True)
|
|
|
|
|
|
rs = subprocess.run(["systemctl", "restart", "mita"], capture_output=True, text=True)
|
|
|
|
|
|
if rs.returncode != 0:
|
|
|
|
|
|
_log.error("systemctl restart mita failed (rc=%d): %s %s",
|
|
|
|
|
|
rs.returncode, rs.stdout.strip(), rs.stderr.strip())
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
|
|
|
|
|
def mita_cmd(*args):
|
|
|
|
|
|
r = subprocess.run(["mita", *args], capture_output=True, text=True)
|
|
|
|
|
|
return r.stdout.strip()
|
|
|
|
|
|
|
2026-06-28 15:11:17 +03:00
|
|
|
|
def gen_password(length=64, mode="hard"):
|
|
|
|
|
|
if mode == "easy":
|
|
|
|
|
|
chars = string.ascii_letters + string.digits + "-._~*+"
|
|
|
|
|
|
else:
|
|
|
|
|
|
chars = string.ascii_letters + string.digits + "!@#%^*_-=+?."
|
2026-06-19 14:30:41 +03:00
|
|
|
|
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():
|
2026-06-26 23:48:32 +03:00
|
|
|
|
"""Возвращает трафик и статус активности по всем пользователям.
|
|
|
|
|
|
Данные берутся напрямую из mita get users."""
|
2026-06-19 14:30:41 +03:00
|
|
|
|
raw = mita_cmd("get", "users")
|
|
|
|
|
|
week_total = month_total = 0.0
|
|
|
|
|
|
users_stats = []
|
2026-06-26 23:37:05 +03:00
|
|
|
|
today = datetime.now().date()
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
2026-06-26 23:48:32 +03:00
|
|
|
|
# mita get users output format (8 колонок):
|
2026-06-26 23:37:05 +03:00
|
|
|
|
# User LastActive 1DayDown 1DayUp 7DaysDown 7DaysUp 30DaysDown 30DaysUp
|
2026-06-26 23:48:32 +03:00
|
|
|
|
for line in raw.splitlines():
|
2026-06-19 14:30:41 +03:00
|
|
|
|
line = line.strip()
|
|
|
|
|
|
if not line or line.upper().startswith("USER"):
|
|
|
|
|
|
continue
|
2026-06-26 23:48:32 +03:00
|
|
|
|
parts = line.split()
|
|
|
|
|
|
if len(parts) < 8:
|
2026-06-19 14:30:41 +03:00
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
name = parts[0]
|
2026-06-26 23:48:32 +03:00
|
|
|
|
d1_down = _parse_traffic(parts[2])
|
|
|
|
|
|
d1_up = _parse_traffic(parts[3])
|
|
|
|
|
|
d7_down = _parse_traffic(parts[4])
|
|
|
|
|
|
d7_up = _parse_traffic(parts[5])
|
|
|
|
|
|
d30_down = _parse_traffic(parts[6])
|
|
|
|
|
|
d30_up = _parse_traffic(parts[7])
|
|
|
|
|
|
|
|
|
|
|
|
d1_bytes = d1_down + d1_up
|
|
|
|
|
|
d7_bytes = d7_down + d7_up
|
|
|
|
|
|
d30_bytes = d30_down + d30_up
|
|
|
|
|
|
|
|
|
|
|
|
month_total += d30_bytes
|
|
|
|
|
|
week_total += d7_bytes
|
2026-06-26 23:37:05 +03:00
|
|
|
|
|
|
|
|
|
|
online = False
|
|
|
|
|
|
last_active_display = "никогда"
|
2026-06-26 23:48:32 +03:00
|
|
|
|
last_active_raw = parts[1]
|
2026-06-26 23:37:05 +03:00
|
|
|
|
if last_active_raw and last_active_raw.lower() not in ("never", "-", "n/a", "никогда"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
la_date = datetime.strptime(last_active_raw[:10], "%Y-%m-%d").date()
|
|
|
|
|
|
delta = (today - la_date).days
|
|
|
|
|
|
if delta == 0:
|
|
|
|
|
|
online = True
|
|
|
|
|
|
last_active_display = "сегодня"
|
|
|
|
|
|
elif delta == 1:
|
|
|
|
|
|
last_active_display = "вчера"
|
|
|
|
|
|
elif delta < 7:
|
|
|
|
|
|
last_active_display = f"{delta} дн. назад"
|
|
|
|
|
|
elif delta < 30:
|
|
|
|
|
|
last_active_display = f"{delta // 7} нед. назад"
|
|
|
|
|
|
else:
|
|
|
|
|
|
last_active_display = f"{delta} дн. назад"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
last_active_display = last_active_raw
|
|
|
|
|
|
|
2026-06-19 14:30:41 +03:00
|
|
|
|
users_stats.append({
|
2026-06-26 23:37:05 +03:00
|
|
|
|
"name": name,
|
|
|
|
|
|
"online": online,
|
|
|
|
|
|
"last_active": last_active_display,
|
2026-06-26 23:48:32 +03:00
|
|
|
|
"day_mb": round(d1_bytes / 1024 / 1024, 2),
|
2026-06-26 23:37:05 +03:00
|
|
|
|
"week_mb": round(d7_bytes / 1024 / 1024, 2),
|
2026-06-26 23:48:32 +03:00
|
|
|
|
"month_mb": round(d30_bytes / 1024 / 1024, 2),
|
2026-06-19 14:30:41 +03:00
|
|
|
|
})
|
|
|
|
|
|
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"
|
|
|
|
|
|
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(),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-26 23:37:05 +03:00
|
|
|
|
@app.route(f"{BASE}/api/stats")
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_stats():
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"cpu": psutil.cpu_percent(interval=0.5),
|
|
|
|
|
|
"ram_pct": psutil.virtual_memory().percent,
|
|
|
|
|
|
"ram_used": psutil.virtual_memory().used,
|
|
|
|
|
|
"ram_total": psutil.virtual_memory().total,
|
|
|
|
|
|
"disk_pct": psutil.disk_usage("/").percent,
|
|
|
|
|
|
"disk_used": psutil.disk_usage("/").used,
|
|
|
|
|
|
"disk_total": psutil.disk_usage("/").total,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-19 14:30:41 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-26 23:37:05 +03:00
|
|
|
|
# ── API: users stats (traffic + online status) ────────────────────────────────
|
|
|
|
|
|
@app.route(f"{BASE}/api/users/stats")
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_users_stats():
|
|
|
|
|
|
try:
|
|
|
|
|
|
stats = get_traffic_stats()
|
|
|
|
|
|
return jsonify({"users": stats["users"]})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return jsonify({"users": [], "error": str(e)})
|
|
|
|
|
|
|
2026-06-19 14:30:41 +03:00
|
|
|
|
# ── 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
|
2026-06-28 15:11:17 +03:00
|
|
|
|
pwd_mode = data.get("password_mode", "hard") # easy | hard
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-28 15:11:17 +03:00
|
|
|
|
password = gen_password(mode=pwd_mode)
|
2026-06-19 14:30:41 +03:00
|
|
|
|
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():
|
2026-06-28 23:59:47 +03:00
|
|
|
|
"""Включить/выключить WARP для конкретного пользователя. При включении весь трафик идёт через WARP."""
|
2026-06-19 14:30:41 +03:00
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
|
|
name = data.get("name", "")
|
|
|
|
|
|
enabled = bool(data.get("enabled", False))
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
2026-06-28 23:59:47 +03:00
|
|
|
|
_rebuild_egress()
|
|
|
|
|
|
return jsonify({"ok": True})
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
|
|
|
|
|
@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"})
|
|
|
|
|
|
|
2026-06-26 23:44:53 +03:00
|
|
|
|
@app.route(f"{BASE}/api/panel/restart", methods=["POST"])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_panel_restart():
|
2026-06-27 00:57:16 +03:00
|
|
|
|
# Отложенный перезапуск — gunicorn убивает текущий процесc при restart,
|
|
|
|
|
|
# не давая ответу уйти. sleep 1 даёт Flask отдать ответ до перезапуска.
|
|
|
|
|
|
subprocess.Popen(
|
|
|
|
|
|
["bash","-c","sleep 1 && systemctl restart mita-panel"],
|
|
|
|
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
|
|
|
|
)
|
2026-06-26 23:44:53 +03:00
|
|
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
|
2026-07-02 18:43:02 +03:00
|
|
|
|
@app.route(f"{BASE}/api/panel/change-port", methods=["POST"])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_change_panel_port():
|
|
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
|
|
new_port = int(data.get("port", 0))
|
|
|
|
|
|
if new_port < 1024 or new_port > 65535:
|
|
|
|
|
|
return jsonify({"ok": False, "error": "Порт должен быть от 1024 до 65535"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
current_port = int(os.environ.get("PANEL_PORT", "8080"))
|
|
|
|
|
|
if new_port == current_port:
|
|
|
|
|
|
return jsonify({"ok": True, "port": new_port})
|
|
|
|
|
|
|
|
|
|
|
|
# Проверить, не занят ли порт
|
|
|
|
|
|
import socket
|
|
|
|
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
|
|
|
try:
|
|
|
|
|
|
s.bind(("0.0.0.0", new_port))
|
|
|
|
|
|
s.close()
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return jsonify({"ok": False, "error": f"Порт {new_port} уже занят"}), 409
|
|
|
|
|
|
|
|
|
|
|
|
pc = load_panel_config()
|
|
|
|
|
|
access_mode = pc.get("access_mode", "ip")
|
|
|
|
|
|
bind_host = "127.0.0.1" if access_mode == "ssh" else "0.0.0.0"
|
|
|
|
|
|
|
|
|
|
|
|
# Обновить panel.env
|
|
|
|
|
|
env_file = "/etc/mita/panel.env"
|
|
|
|
|
|
try:
|
|
|
|
|
|
lines = Path(env_file).read_text().splitlines()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
new_lines = []
|
|
|
|
|
|
found = False
|
|
|
|
|
|
for line in lines:
|
|
|
|
|
|
if line.startswith("PANEL_PORT="):
|
|
|
|
|
|
new_lines.append(f"PANEL_PORT={new_port}")
|
|
|
|
|
|
found = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
new_lines.append(line)
|
|
|
|
|
|
if not found:
|
|
|
|
|
|
new_lines.append(f"PANEL_PORT={new_port}")
|
|
|
|
|
|
Path(env_file).write_text("\n".join(new_lines) + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
# Обновить start.sh
|
|
|
|
|
|
start_sh = "/opt/mita-panel/start.sh"
|
|
|
|
|
|
try:
|
|
|
|
|
|
Path(start_sh).write_text(f"""#!/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 {bind_host}:{new_port} \\
|
|
|
|
|
|
--workers 2 --timeout 120 \\
|
|
|
|
|
|
--access-logfile /var/log/mita-panel-access.log \\
|
|
|
|
|
|
--error-logfile /var/log/mita-panel.log \\
|
|
|
|
|
|
$SSL_ARGS app:app
|
|
|
|
|
|
""")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Обновить firewall
|
|
|
|
|
|
if access_mode != "ssh":
|
|
|
|
|
|
_fw_close_port(current_port)
|
|
|
|
|
|
_fw_open_port(new_port)
|
|
|
|
|
|
|
|
|
|
|
|
secret = os.environ.get("SECRET_PATH", "")
|
|
|
|
|
|
proto = "https" if os.environ.get("SSL_CERT") else "http"
|
|
|
|
|
|
server_ip = get_server_ip()
|
|
|
|
|
|
new_url = f"{proto}://{server_ip}:{new_port}/{secret}"
|
|
|
|
|
|
|
|
|
|
|
|
# Отложенный перезапуск
|
|
|
|
|
|
subprocess.Popen(
|
|
|
|
|
|
["bash","-c","sleep 2 && systemctl restart mita-panel"],
|
|
|
|
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"port": new_port,
|
|
|
|
|
|
"new_url": new_url,
|
|
|
|
|
|
"old_port": current_port,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-27 00:05:09 +03:00
|
|
|
|
@app.route(f"{BASE}/api/panel/access")
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_panel_access_get():
|
|
|
|
|
|
pc = load_panel_config()
|
|
|
|
|
|
panel_port = os.environ.get("PANEL_PORT", "8080")
|
|
|
|
|
|
secret = os.environ.get("SECRET_PATH", "")
|
|
|
|
|
|
mode = pc.get("access_mode", "ip")
|
|
|
|
|
|
ssh_port = pc.get("ssh_port", 22)
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"mode": mode,
|
|
|
|
|
|
"panel_port": panel_port,
|
|
|
|
|
|
"secret": secret,
|
|
|
|
|
|
"ssh_port": ssh_port,
|
|
|
|
|
|
"server_ip": get_server_ip(),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
@app.route(f"{BASE}/api/panel/access", methods=["POST"])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_panel_access_set():
|
|
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
|
|
mode = data.get("mode", "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
if mode not in ("ip", "ssh"):
|
|
|
|
|
|
return jsonify({"ok": False, "error": "mode должен быть ip или ssh"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
pc = load_panel_config()
|
|
|
|
|
|
panel_port = os.environ.get("PANEL_PORT", "8080")
|
|
|
|
|
|
secret = os.environ.get("SECRET_PATH", "")
|
|
|
|
|
|
|
|
|
|
|
|
if mode == "ssh":
|
|
|
|
|
|
bind = "127.0.0.1"
|
|
|
|
|
|
# Закрыть порт в firewall
|
|
|
|
|
|
_fw_close_port(panel_port)
|
|
|
|
|
|
else:
|
|
|
|
|
|
bind = "0.0.0.0"
|
|
|
|
|
|
_fw_open_port(panel_port)
|
|
|
|
|
|
|
|
|
|
|
|
# Обновить start.sh с новым bind
|
|
|
|
|
|
start_sh = "/opt/mita-panel/start.sh"
|
|
|
|
|
|
try:
|
|
|
|
|
|
Path(start_sh).write_text(f"""#!/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 {bind}:$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
|
|
|
|
|
|
""")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Сохранить режим в panel.json
|
|
|
|
|
|
pc["access_mode"] = mode
|
|
|
|
|
|
Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2))
|
|
|
|
|
|
|
2026-06-27 00:57:16 +03:00
|
|
|
|
subprocess.Popen(
|
|
|
|
|
|
["bash","-c","sleep 1 && systemctl restart mita-panel"],
|
|
|
|
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
|
|
|
|
)
|
2026-06-27 00:05:09 +03:00
|
|
|
|
|
|
|
|
|
|
ssh_port = pc.get("ssh_port", 22)
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"mode": mode,
|
|
|
|
|
|
"panel_port": panel_port,
|
|
|
|
|
|
"secret": secret,
|
|
|
|
|
|
"server_ip": get_server_ip(),
|
|
|
|
|
|
"ssh_port": ssh_port,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
def _fw_close_port(port):
|
|
|
|
|
|
try:
|
|
|
|
|
|
if subprocess.run(["which","ufw"], capture_output=True).returncode == 0:
|
|
|
|
|
|
subprocess.run(["ufw","delete","allow",f"{port}/tcp"], capture_output=True)
|
|
|
|
|
|
elif subprocess.run(["which","iptables"], capture_output=True).returncode == 0:
|
|
|
|
|
|
subprocess.run(["iptables","-D","INPUT","-p","tcp","--dport",str(port),"-j","ACCEPT"], capture_output=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def _fw_open_port(port):
|
|
|
|
|
|
try:
|
|
|
|
|
|
if subprocess.run(["which","ufw"], capture_output=True).returncode == 0 \
|
|
|
|
|
|
and subprocess.run(["ufw","status"], capture_output=True, text=True).stdout.find("active") != -1:
|
|
|
|
|
|
subprocess.run(["ufw","allow",f"{port}/tcp","comment","mita-panel"], capture_output=True)
|
|
|
|
|
|
elif subprocess.run(["which","iptables"], capture_output=True).returncode == 0:
|
|
|
|
|
|
r = subprocess.run(["iptables","-C","INPUT","-p","tcp","--dport",str(port),"-j","ACCEPT"], capture_output=True)
|
|
|
|
|
|
if r.returncode != 0:
|
|
|
|
|
|
subprocess.run(["iptables","-I","INPUT","1","-p","tcp","--dport",str(port),"-j","ACCEPT"], capture_output=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-06-19 14:30:41 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-06-19 15:37:02 +03:00
|
|
|
|
# ── Динамическая правка Secure-флага на cookie сессии ────────────────────────
|
|
|
|
|
|
# Если выставить Secure статически по наличию SSL_CERT в env, можно словить
|
|
|
|
|
|
# ситуацию когда сертификат прописан, но запрос реально пришёл по HTTP
|
|
|
|
|
|
# (gunicorn не поднял TLS, SSH-туннель без TLS и т.п.) — тогда браузер
|
|
|
|
|
|
# тихо отбросит cookie с флагом Secure, и человек не сможет войти, видя
|
|
|
|
|
|
# при этом "успешный" логин без какой-либо ошибки. Поэтому проверяем
|
|
|
|
|
|
# request.is_secure на каждый ответ и правим флаг по факту.
|
|
|
|
|
|
@app.after_request
|
|
|
|
|
|
def fix_session_cookie_secure(response):
|
|
|
|
|
|
if not request.is_secure:
|
|
|
|
|
|
return response
|
|
|
|
|
|
set_cookie_headers = response.headers.getlist("Set-Cookie")
|
|
|
|
|
|
if not set_cookie_headers:
|
|
|
|
|
|
return response
|
|
|
|
|
|
response.headers.remove("Set-Cookie")
|
|
|
|
|
|
for header in set_cookie_headers:
|
|
|
|
|
|
if app.session_cookie_name in header and "Secure" not in header:
|
|
|
|
|
|
header += "; Secure"
|
|
|
|
|
|
response.headers.add("Set-Cookie", header)
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
2026-06-19 14:30:41 +03:00
|
|
|
|
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": [],
|
2026-06-28 23:59:47 +03:00
|
|
|
|
"sources": [],
|
|
|
|
|
|
"full_warp": False,
|
2026-06-19 14:30:41 +03:00
|
|
|
|
})
|
|
|
|
|
|
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 {}
|
2026-06-28 23:59:47 +03:00
|
|
|
|
name = data.get("name", "")
|
|
|
|
|
|
domains = data.get("domains", [])
|
|
|
|
|
|
ips = data.get("ips", [])
|
|
|
|
|
|
sources = data.get("sources", [])
|
|
|
|
|
|
full_warp = data.get("full_warp", False)
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
return jsonify({"ok": False, "error": "Не указано имя"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
pc = load_panel_config()
|
|
|
|
|
|
pc.setdefault("warp_rules", {})[name] = {
|
2026-06-28 23:59:47 +03:00
|
|
|
|
"domains": [d.strip() for d in domains if d.strip()],
|
|
|
|
|
|
"ips": [i.strip() for i in ips if i.strip()],
|
|
|
|
|
|
"sources": [s.strip() for s in sources if s.strip()],
|
|
|
|
|
|
"full_warp": bool(full_warp),
|
2026-06-19 14:30:41 +03:00
|
|
|
|
}
|
|
|
|
|
|
Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2))
|
|
|
|
|
|
|
2026-06-28 23:59:47 +03:00
|
|
|
|
_rebuild_egress()
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
|
|
|
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
|
2026-06-28 23:59:47 +03:00
|
|
|
|
def _rebuild_egress(pc=None):
|
2026-06-19 15:23:17 +03:00
|
|
|
|
"""
|
2026-06-28 23:59:47 +03:00
|
|
|
|
Глобальный WARP-egress:
|
|
|
|
|
|
- full_warp=True у любого пользователя → весь трафик через WARP
|
|
|
|
|
|
- full_warp=False + есть правила → указанные домены/IP через WARP, остальное DIRECT
|
|
|
|
|
|
- WARP выкл у всех → egress удаляется
|
2026-06-19 15:23:17 +03:00
|
|
|
|
"""
|
2026-06-28 23:59:47 +03:00
|
|
|
|
pc = load_panel_config()
|
|
|
|
|
|
warp_users = set(pc.get("warp_users", []))
|
2026-06-19 15:23:17 +03:00
|
|
|
|
|
2026-06-28 23:59:47 +03:00
|
|
|
|
if not warp_users:
|
|
|
|
|
|
cfg = load_mita_config()
|
|
|
|
|
|
cfg.pop("egress", None)
|
|
|
|
|
|
save_mita_config(cfg)
|
|
|
|
|
|
return
|
2026-06-19 15:23:17 +03:00
|
|
|
|
|
2026-06-28 23:59:47 +03:00
|
|
|
|
warp_proxy = {
|
|
|
|
|
|
"name": "warp",
|
|
|
|
|
|
"protocol": "SOCKS5_PROXY_PROTOCOL",
|
|
|
|
|
|
"host": "127.0.0.1",
|
|
|
|
|
|
"port": WARP_PORT,
|
|
|
|
|
|
}
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
|
|
|
|
|
all_domains = set()
|
|
|
|
|
|
all_ips = set()
|
2026-06-28 23:59:47 +03:00
|
|
|
|
has_full = False
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
2026-06-28 23:59:47 +03:00
|
|
|
|
for uname in warp_users:
|
|
|
|
|
|
rules = pc.get("warp_rules", {}).get(uname, {})
|
|
|
|
|
|
if rules.get("full_warp"):
|
|
|
|
|
|
has_full = True
|
|
|
|
|
|
all_domains.update(d for d in rules.get("domains", []) if d)
|
|
|
|
|
|
all_ips.update(i for i in rules.get("ips", []) if i)
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
|
|
|
|
|
cfg = load_mita_config()
|
|
|
|
|
|
|
2026-06-28 23:59:47 +03:00
|
|
|
|
if has_full:
|
2026-06-19 14:30:41 +03:00
|
|
|
|
cfg["egress"] = {
|
2026-06-28 23:59:47 +03:00
|
|
|
|
"proxies": [warp_proxy],
|
|
|
|
|
|
"rules": [
|
|
|
|
|
|
{"ipRanges": ["*"], "domainNames": ["*"],
|
|
|
|
|
|
"action": "PROXY", "proxyNames": ["warp"]},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
elif all_domains or all_ips:
|
|
|
|
|
|
warp_rule = {"action": "PROXY", "proxyNames": ["warp"]}
|
|
|
|
|
|
if all_domains:
|
|
|
|
|
|
warp_rule["domainNames"] = sorted(all_domains)
|
|
|
|
|
|
if all_ips:
|
|
|
|
|
|
warp_rule["ipRanges"] = sorted(all_ips)
|
|
|
|
|
|
cfg["egress"] = {
|
|
|
|
|
|
"proxies": [warp_proxy],
|
2026-06-19 14:30:41 +03:00
|
|
|
|
"rules": [
|
|
|
|
|
|
warp_rule,
|
|
|
|
|
|
{"ipRanges": ["*"], "domainNames": ["*"], "action": "DIRECT"},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
2026-06-28 23:59:47 +03:00
|
|
|
|
else:
|
|
|
|
|
|
cfg.pop("egress", None)
|
2026-06-19 14:30:41 +03:00
|
|
|
|
|
|
|
|
|
|
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]
|
2026-06-26 23:37:05 +03:00
|
|
|
|
failregex = ^<HOST> .+ "POST /[^"]+/login[^"]*" 4(?:01|29).*$
|
2026-06-19 14:30:41 +03:00
|
|
|
|
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}
|
|
|
|
|
|
"""
|
|
|
|
|
|
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)
|
2026-06-26 23:37:05 +03:00
|
|
|
|
r_reload = subprocess.run(["systemctl","restart","fail2ban"], capture_output=True)
|
|
|
|
|
|
r_active = subprocess.run(["systemctl","is-active","fail2ban"], capture_output=True, text=True)
|
|
|
|
|
|
if r_active.stdout.strip() == "active":
|
|
|
|
|
|
ok_msg = "Настройки сохранены и применены"
|
|
|
|
|
|
else:
|
|
|
|
|
|
ok_msg = "Настройки сохранены, но fail2ban не запустился — проверьте journalctl -u fail2ban"
|
2026-06-19 14:30:41 +03:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
ok_msg = f"Настройки сохранены (fail2ban: {e})"
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify({"ok": True, "message": ok_msg})
|
|
|
|
|
|
|
2026-06-26 23:37:05 +03:00
|
|
|
|
# ── API: Telegram Bot ─────────────────────────────────────────────────────────
|
|
|
|
|
|
BOT_CONFIG_PATH = "/etc/mita/bot.json"
|
|
|
|
|
|
BOT_DIR = "/opt/mita-bot"
|
|
|
|
|
|
BOT_SERVICE = "mita-bot"
|
|
|
|
|
|
BOT_INSTALLER = "/opt/mita-bot/install-bot.sh"
|
|
|
|
|
|
|
|
|
|
|
|
def _bot_installed():
|
|
|
|
|
|
return os.path.exists("/etc/systemd/system/mita-bot.service")
|
|
|
|
|
|
|
|
|
|
|
|
def _bot_running():
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = subprocess.run(["systemctl","is-active",BOT_SERVICE],
|
|
|
|
|
|
capture_output=True, text=True, timeout=3)
|
|
|
|
|
|
return r.stdout.strip() == "active"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def _load_bot_config():
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(Path(BOT_CONFIG_PATH).read_text())
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
@app.route(f"{BASE}/api/bot/status")
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_bot_status():
|
|
|
|
|
|
installed = _bot_installed()
|
|
|
|
|
|
running = _bot_running() if installed else False
|
|
|
|
|
|
cfg = _load_bot_config() if installed else {}
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"installed": installed,
|
|
|
|
|
|
"running": running,
|
|
|
|
|
|
"token": (cfg.get("token","")[:8]+"…") if cfg.get("token") else "",
|
|
|
|
|
|
"admin_ids": cfg.get("admin_ids", []),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
@app.route(f"{BASE}/api/bot/install", methods=["POST"])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_bot_install():
|
|
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
|
|
token = data.get("token","").strip()
|
|
|
|
|
|
admin_id = str(data.get("admin_id","")).strip()
|
|
|
|
|
|
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
return jsonify({"ok": False, "error": "Токен обязателен"}), 400
|
|
|
|
|
|
if not admin_id or not admin_id.isdigit():
|
|
|
|
|
|
return jsonify({"ok": False, "error": "Telegram ID должен быть числом"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
# Ищем install-bot.sh рядом с app.py или в /opt/mita-panel
|
|
|
|
|
|
installer = None
|
|
|
|
|
|
for candidate in [
|
|
|
|
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "install-bot.sh"),
|
|
|
|
|
|
"/opt/mita-panel/install-bot.sh",
|
|
|
|
|
|
]:
|
|
|
|
|
|
if os.path.exists(candidate):
|
|
|
|
|
|
installer = candidate
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if not installer:
|
|
|
|
|
|
return jsonify({"ok": False, "error": "install-bot.sh не найден. Поместите его в /opt/mita-panel/"}), 500
|
|
|
|
|
|
|
|
|
|
|
|
# Передаём в install-bot.sh через env
|
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
|
env["BOT_TOKEN_NONINTERACTIVE"] = token
|
|
|
|
|
|
env["BOT_ADMIN_NONINTERACTIVE"] = admin_id
|
|
|
|
|
|
|
|
|
|
|
|
r = subprocess.run(["bash", installer], capture_output=True, text=True,
|
|
|
|
|
|
env=env, timeout=120)
|
|
|
|
|
|
if r.returncode != 0:
|
|
|
|
|
|
return jsonify({"ok": False, "error": r.stderr.strip() or r.stdout.strip()[-500:]}), 500
|
|
|
|
|
|
|
|
|
|
|
|
running = _bot_running()
|
|
|
|
|
|
return jsonify({"ok": True, "running": running, "output": r.stdout.strip()[-500:]})
|
|
|
|
|
|
|
|
|
|
|
|
@app.route(f"{BASE}/api/bot/uninstall", methods=["POST"])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_bot_uninstall():
|
|
|
|
|
|
subprocess.run(["systemctl","stop",BOT_SERVICE], capture_output=True)
|
|
|
|
|
|
subprocess.run(["systemctl","disable",BOT_SERVICE], capture_output=True)
|
|
|
|
|
|
Path("/etc/systemd/system/mita-bot.service").unlink(missing_ok=True)
|
|
|
|
|
|
subprocess.run(["systemctl","daemon-reload"], capture_output=True)
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
if os.path.isdir(BOT_DIR):
|
|
|
|
|
|
shutil.rmtree(BOT_DIR)
|
|
|
|
|
|
Path(BOT_CONFIG_PATH).unlink(missing_ok=True)
|
|
|
|
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
|
|
|
|
|
|
@app.route(f"{BASE}/api/bot/config", methods=["GET","POST"])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_bot_config():
|
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
|
|
token = data.get("token","").strip()
|
|
|
|
|
|
admin_id = str(data.get("admin_id","")).strip()
|
|
|
|
|
|
|
|
|
|
|
|
if not _bot_installed():
|
|
|
|
|
|
return jsonify({"ok": False, "error": "Бот не установлен"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
cfg = _load_bot_config()
|
|
|
|
|
|
if token:
|
|
|
|
|
|
cfg["token"] = token
|
|
|
|
|
|
if admin_id:
|
|
|
|
|
|
if admin_id not in cfg.get("admin_ids", []):
|
|
|
|
|
|
cfg.setdefault("admin_ids", []).append(admin_id)
|
|
|
|
|
|
|
|
|
|
|
|
Path(BOT_CONFIG_PATH).write_text(json.dumps(cfg, indent=2))
|
|
|
|
|
|
subprocess.run(["systemctl","restart",BOT_SERVICE], capture_output=True)
|
|
|
|
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
|
|
|
|
|
|
cfg = _load_bot_config()
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"token": cfg.get("token",""),
|
|
|
|
|
|
"admin_ids": cfg.get("admin_ids", []),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-27 00:05:09 +03:00
|
|
|
|
@app.route(f"{BASE}/api/bot/detect-id", methods=["POST"])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_bot_detect_id():
|
|
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
|
|
token = data.get("token","").strip()
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
return jsonify({"ok": False, "error": "Токен обязателен"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
url = f"https://api.telegram.org/bot{token}/getUpdates"
|
|
|
|
|
|
# Очищаем pending updates перед опросом
|
|
|
|
|
|
try:
|
|
|
|
|
|
req = urllib.request.Request(url + "?offset=-1", headers={"User-Agent": "mita-panel"})
|
|
|
|
|
|
urllib.request.urlopen(req, timeout=5).read()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
admin_id = ""
|
|
|
|
|
|
for _ in range(20):
|
|
|
|
|
|
time.sleep(3)
|
|
|
|
|
|
try:
|
|
|
|
|
|
req = urllib.request.Request(url, headers={"User-Agent": "mita-panel"})
|
|
|
|
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
|
|
|
|
data_raw = json.loads(resp.read())
|
|
|
|
|
|
ids = [r["message"]["from"]["id"]
|
|
|
|
|
|
for r in data_raw.get("result", [])
|
|
|
|
|
|
if "message" in r and "from" in r["message"]]
|
|
|
|
|
|
if ids:
|
|
|
|
|
|
admin_id = str(ids[-1])
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if admin_id:
|
|
|
|
|
|
return jsonify({"ok": True, "admin_id": admin_id})
|
|
|
|
|
|
return jsonify({"ok": False, "error": "Не получено ни одного сообщения. Отправьте любое сообщение боту и попробуйте снова."}), 404
|
|
|
|
|
|
|
2026-06-19 14:30:41 +03:00
|
|
|
|
# ── 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}"'}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-19 22:46:43 +03:00
|
|
|
|
|
|
|
|
|
|
@app.route(f"{BASE}/api/mita/apply", methods=["POST"])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def api_mita_apply():
|
|
|
|
|
|
"""Диагностика: вручную применить конфиг mita и вернуть подробный результат."""
|
|
|
|
|
|
mita_running = _mita_running()
|
|
|
|
|
|
pb = Path("/etc/mita/server.conf.pb")
|
|
|
|
|
|
pb_exists_before = pb.exists()
|
|
|
|
|
|
|
|
|
|
|
|
r = subprocess.run(["mita", "apply", "config", MITA_CONFIG],
|
|
|
|
|
|
capture_output=True, text=True)
|
|
|
|
|
|
pb_exists_after = pb.exists()
|
|
|
|
|
|
|
|
|
|
|
|
# Chown после apply — нужно если .pb создавался root'ом
|
|
|
|
|
|
chown_result = None
|
|
|
|
|
|
if pb_exists_after:
|
|
|
|
|
|
cr = subprocess.run(["chown", "mita:mita", str(pb)], capture_output=True, text=True)
|
|
|
|
|
|
chown_result = {"rc": cr.returncode, "err": cr.stderr.strip()}
|
|
|
|
|
|
|
|
|
|
|
|
rs = subprocess.run(["systemctl", "restart", "mita"], capture_output=True, text=True)
|
|
|
|
|
|
|
|
|
|
|
|
cfg = load_mita_config()
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"mita_was_running": mita_running,
|
|
|
|
|
|
"apply_rc": r.returncode,
|
|
|
|
|
|
"apply_stdout": r.stdout.strip(),
|
|
|
|
|
|
"apply_stderr": r.stderr.strip(),
|
|
|
|
|
|
"pb_existed_before": pb_exists_before,
|
|
|
|
|
|
"pb_exists_after": pb_exists_after,
|
|
|
|
|
|
"chown": chown_result,
|
|
|
|
|
|
"restart_rc": rs.returncode,
|
|
|
|
|
|
"restart_stderr": rs.stderr.strip(),
|
|
|
|
|
|
"users_in_json": [u["name"] for u in cfg.get("users", [])],
|
|
|
|
|
|
})
|
|
|
|
|
|
|