423ee4fdbb
- app.py: full_warp флаг в warp_rules, _rebuild_egress читает с диска, race condition fix - bot.py: full_warp поддержка в toggle_warp - templates/index.html: чекбокс «Весь трафик через WARP» с full_warp, не авто-включается - install-panel.sh: зеркала PyPI (aliyun), таймаут 120с на pip, fallback на apt --system-site-packages - install-bot.sh: зеркала и fallback для python-telegram-bot - README: версия 1.4, обновлён roadmap
1258 lines
48 KiB
Python
1258 lines
48 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
mita Web Panel — Flask backend
|
||
"""
|
||
import os, json, re, subprocess, secrets, string, random, ipaddress, socket, time, logging
|
||
import psutil
|
||
from datetime import datetime, timedelta
|
||
from functools import wraps
|
||
from pathlib import Path
|
||
|
||
logging.basicConfig(level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
|
||
|
||
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"
|
||
# SESSION_COOKIE_SECURE не задаём статически — это ломает логин, если
|
||
# SSL_CERT прописан в env, но gunicorn по факту поднялся на HTTP
|
||
# (например, сертификат ещё не физически на диске, или используется
|
||
# SSH-туннель без TLS). Вместо этого выставляем Secure динамически
|
||
# по факту запроса — см. _set_cookie_secure_dynamically ниже.
|
||
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))
|
||
_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())
|
||
|
||
def mita_cmd(*args):
|
||
r = subprocess.run(["mita", *args], capture_output=True, text=True)
|
||
return r.stdout.strip()
|
||
|
||
def gen_password(length=64, mode="hard"):
|
||
if mode == "easy":
|
||
chars = string.ascii_letters + string.digits + "-._~*+"
|
||
else:
|
||
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():
|
||
"""Возвращает трафик и статус активности по всем пользователям.
|
||
Данные берутся напрямую из mita get users."""
|
||
raw = mita_cmd("get", "users")
|
||
week_total = month_total = 0.0
|
||
users_stats = []
|
||
today = datetime.now().date()
|
||
|
||
# mita get users output format (8 колонок):
|
||
# User LastActive 1DayDown 1DayUp 7DaysDown 7DaysUp 30DaysDown 30DaysUp
|
||
for line in raw.splitlines():
|
||
line = line.strip()
|
||
if not line or line.upper().startswith("USER"):
|
||
continue
|
||
parts = line.split()
|
||
if len(parts) < 8:
|
||
continue
|
||
try:
|
||
name = parts[0]
|
||
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
|
||
|
||
online = False
|
||
last_active_display = "никогда"
|
||
last_active_raw = parts[1]
|
||
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
|
||
|
||
users_stats.append({
|
||
"name": name,
|
||
"online": online,
|
||
"last_active": last_active_display,
|
||
"day_mb": round(d1_bytes / 1024 / 1024, 2),
|
||
"week_mb": round(d7_bytes / 1024 / 1024, 2),
|
||
"month_mb": round(d30_bytes / 1024 / 1024, 2),
|
||
})
|
||
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(),
|
||
})
|
||
|
||
@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,
|
||
})
|
||
|
||
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 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)})
|
||
|
||
# ── 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
|
||
pwd_mode = data.get("password_mode", "hard") # easy | hard
|
||
|
||
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(mode=pwd_mode)
|
||
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 для конкретного пользователя. При включении весь трафик идёт через WARP."""
|
||
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))
|
||
|
||
_rebuild_egress()
|
||
return jsonify({"ok": True})
|
||
|
||
@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"})
|
||
|
||
@app.route(f"{BASE}/api/panel/restart", methods=["POST"])
|
||
@login_required
|
||
def api_panel_restart():
|
||
# Отложенный перезапуск — gunicorn убивает текущий процесc при restart,
|
||
# не давая ответу уйти. sleep 1 даёт Flask отдать ответ до перезапуска.
|
||
subprocess.Popen(
|
||
["bash","-c","sleep 1 && systemctl restart mita-panel"],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||
)
|
||
return jsonify({"ok": True})
|
||
|
||
@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))
|
||
|
||
subprocess.Popen(
|
||
["bash","-c","sleep 1 && systemctl restart mita-panel"],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||
)
|
||
|
||
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
|
||
|
||
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)
|
||
|
||
# ── Динамическая правка 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
|
||
|
||
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": [],
|
||
"full_warp": False,
|
||
})
|
||
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", [])
|
||
full_warp = data.get("full_warp", False)
|
||
|
||
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()],
|
||
"full_warp": bool(full_warp),
|
||
}
|
||
Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2))
|
||
|
||
_rebuild_egress()
|
||
|
||
return jsonify({"ok": True})
|
||
|
||
def _rebuild_egress(pc=None):
|
||
"""
|
||
Глобальный WARP-egress:
|
||
- full_warp=True у любого пользователя → весь трафик через WARP
|
||
- full_warp=False + есть правила → указанные домены/IP через WARP, остальное DIRECT
|
||
- WARP выкл у всех → egress удаляется
|
||
"""
|
||
pc = load_panel_config()
|
||
warp_users = set(pc.get("warp_users", []))
|
||
|
||
if not warp_users:
|
||
cfg = load_mita_config()
|
||
cfg.pop("egress", None)
|
||
save_mita_config(cfg)
|
||
return
|
||
|
||
warp_proxy = {
|
||
"name": "warp",
|
||
"protocol": "SOCKS5_PROXY_PROTOCOL",
|
||
"host": "127.0.0.1",
|
||
"port": WARP_PORT,
|
||
}
|
||
|
||
all_domains = set()
|
||
all_ips = set()
|
||
has_full = False
|
||
|
||
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)
|
||
|
||
cfg = load_mita_config()
|
||
|
||
if has_full:
|
||
cfg["egress"] = {
|
||
"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],
|
||
"rules": [
|
||
warp_rule,
|
||
{"ipRanges": ["*"], "domainNames": ["*"], "action": "DIRECT"},
|
||
],
|
||
}
|
||
else:
|
||
cfg.pop("egress", None)
|
||
|
||
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 = ^<HOST> .+ "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}
|
||
"""
|
||
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)
|
||
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"
|
||
except Exception as e:
|
||
ok_msg = f"Настройки сохранены (fail2ban: {e})"
|
||
|
||
return jsonify({"ok": True, "message": ok_msg})
|
||
|
||
# ── 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", []),
|
||
})
|
||
|
||
@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
|
||
|
||
# ── 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}"'}
|
||
)
|
||
|
||
|
||
@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", [])],
|
||
})
|
||
|