#!/usr/bin/env python3 """ PCA Phobos — Web Panel for Phobos (Obfuscated WireGuard VPN) Management panel: clients, sessions, labels, subscriptions, Telegram alerts. """ import json, os, subprocess, threading, time, secrets, hashlib, re from datetime import datetime, timedelta from pathlib import Path from flask import Flask, request, redirect, url_for, session, make_response app = Flask(__name__) PHOBOS_DIR = "/opt/Phobos" CLIENTS_DIR = f"{PHOBOS_DIR}/clients" SERVER_ENV = f"{PHOBOS_DIR}/server/server.env" PANEL_DIR = "/opt/phobos-panel" SETTINGS_FILE = f"{PANEL_DIR}/settings.json" SECRET_FILE = f"{PANEL_DIR}/.secret_key" SERVER_IP = subprocess.getoutput("curl -s https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'").strip() os.makedirs(PANEL_DIR, exist_ok=True) if os.path.exists(SECRET_FILE): app.secret_key = open(SECRET_FILE).read().strip() else: app.secret_key = secrets.token_hex(32) with open(SECRET_FILE, "w") as f: f.write(app.secret_key) DEFAULT_SETTINGS = { "admin_pass": "OcAdmin2026!", "tg_bot_token": "", "tg_chat_id": "", "monitor_interval": 30, "labels": {}, "subscriptions": {} } def load_settings(): if os.path.exists(SETTINGS_FILE): with open(SETTINGS_FILE) as f: s = json.load(f) for k, v in DEFAULT_SETTINGS.items(): s.setdefault(k, v) return s return dict(DEFAULT_SETTINGS) def save_settings(s): with open(SETTINGS_FILE, "w") as f: json.dump(s, f, indent=2, ensure_ascii=False) def tg_send(text): s = load_settings() token, chat = s.get("tg_bot_token", ""), s.get("tg_chat_id", "") if not token or not chat: return try: import urllib.request url = f"https://api.telegram.org/bot{token}/sendMessage" data = json.dumps({"chat_id": chat, "text": text, "parse_mode": "HTML"}).encode() req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) urllib.request.urlopen(req, timeout=10) except Exception: pass def get_wg_peers(): """Parse `wg show wg0` to get active peers with transfer/handshake info.""" try: out = subprocess.check_output(["wg", "show", "wg0"], text=True, timeout=5) except Exception: return {} peers = {} current_pub = None for line in out.split("\n"): line = line.strip() if line.startswith("peer:"): current_pub = line.split("peer:")[1].strip() peers[current_pub] = {} elif current_pub and ":" in line: key, val = line.split(":", 1) peers[current_pub][key.strip()] = val.strip() return peers def get_clients(): """Read all clients from /opt/Phobos/clients/*/metadata.json.""" clients = [] clients_path = Path(CLIENTS_DIR) if not clients_path.exists(): return clients for d in sorted(clients_path.iterdir()): meta_file = d / "metadata.json" if meta_file.exists(): try: with open(meta_file) as f: meta = json.load(f) meta["_dir"] = str(d) clients.append(meta) except Exception: pass return clients def get_active_sessions(): """Combine WG peers with client metadata to build session list.""" peers = get_wg_peers() clients = get_clients() pub_to_client = {} for c in clients: pub_to_client[c.get("public_key", "")] = c sessions = [] for pub_key, info in peers.items(): handshake = info.get("latest handshake", "") if not handshake: continue client = pub_to_client.get(pub_key, {}) client_id = client.get("client_id", "unknown") tunnel_ip = client.get("tunnel_ip_v4", "") endpoint = info.get("endpoint", "") real_ip = endpoint.split(":")[0] if endpoint else "" rx = info.get("transfer", "") rx_bytes = rx.split("received,")[0].strip() if "received," in rx else "" tx_bytes = rx.split("received,")[1].strip().replace("sent", "").strip() if "received," in rx else "" sessions.append({ "client_id": client_id, "public_key": pub_key, "tunnel_ip": tunnel_ip, "real_ip": real_ip, "endpoint": endpoint, "handshake": handshake, "rx": rx_bytes, "tx": tx_bytes, }) return sessions def is_peer_online(handshake_str): """Check if peer had a handshake within last 3 minutes.""" try: parts = handshake_str.split(",") total_seconds = 0 for p in parts: p = p.strip() if "minute" in p: total_seconds += int(re.search(r"(\d+)", p).group(1)) * 60 elif "second" in p: total_seconds += int(re.search(r"(\d+)", p).group(1)) elif "hour" in p: total_seconds += int(re.search(r"(\d+)", p).group(1)) * 3600 return total_seconds < 180 except Exception: return False def kick_peer(public_key): """Remove and re-add peer to force disconnect.""" try: out = subprocess.check_output(["wg", "show", "wg0"], text=True, timeout=5) allowed = "" found = False for line in out.split("\n"): if line.strip().startswith("peer:") and public_key in line: found = True elif found and "allowed ips:" in line: allowed = line.split("allowed ips:")[1].strip() break subprocess.run(["wg", "set", "wg0", "peer", public_key, "remove"], timeout=5) if allowed: subprocess.run(["wg", "set", "wg0", "peer", public_key, "allowed-ips", allowed], timeout=5) return True except Exception: return False def check_expiry(): """Check subscription expiry, lock expired clients.""" s = load_settings() subs = s.get("subscriptions", {}) today = datetime.now().date() changed = False for client_id, info in list(subs.items()): if not info.get("expiry"): continue try: exp_date = datetime.strptime(info["expiry"], "%Y-%m-%d").date() except ValueError: continue days_left = (exp_date - today).days if days_left <= 0 and not info.get("locked"): info["locked"] = True changed = True kick_client_by_id(client_id) tg_send(f"⛔ {client_id} — подписка истекла! Клиент заблокирован.") elif days_left == 3 and not info.get("warn3"): info["warn3"] = True changed = True tg_send(f"⚠️ {client_id} — подписка истекает через 3 дня ({info['expiry']})") elif days_left == 1 and not info.get("warn1"): info["warn1"] = True changed = True tg_send(f"⚠️ {client_id} — подписка истекает ЗАВТРА ({info['expiry']})") if changed: save_settings(s) def kick_client_by_id(client_id): """Find client's public key and kick them.""" clients = get_clients() for c in clients: if c.get("client_id") == client_id: kick_peer(c.get("public_key", "")) return True return False prev_session_keys = None def session_monitor(): """Background thread: monitor sessions, send Telegram alerts.""" global prev_session_keys while True: try: s = load_settings() interval = s.get("monitor_interval", 30) sessions = get_active_sessions() current_keys = set() for sess in sessions: if is_peer_online(sess.get("handshake", "")): key = (sess["client_id"], sess["real_ip"]) current_keys.add(key) if prev_session_keys is not None: labels = s.get("labels", {}) for key in current_keys - prev_session_keys: client_id, real_ip = key label = labels.get(real_ip, "") name = f"{label} ({real_ip})" if label else real_ip tg_send(f"🟢 {client_id} подключился — {name}") for key in prev_session_keys - current_keys: client_id, real_ip = key label = labels.get(real_ip, "") name = f"{label} ({real_ip})" if label else real_ip tg_send(f"🔴 {client_id} отключился — {name}") prev_session_keys = current_keys check_expiry() time.sleep(interval) except Exception: time.sleep(30) monitor_thread = threading.Thread(target=session_monitor, daemon=True) monitor_thread.start() PAGE = """ Phobos VPN Panel

🛡️ Phobos VPN Panel

Obfuscated WireGuard · """ + SERVER_IP + """
CONTENT """ def render(content): return PAGE.replace("CONTENT", content) @app.route("/login", methods=["GET", "POST"]) def login(): s = load_settings() msg = "" if request.method == "POST": if request.form.get("password") == s["admin_pass"]: session["auth"] = True return redirect(url_for("dashboard")) msg = '
Неверный пароль
' html = f"""

Вход в панель

{msg}
""" return render(html) @app.route("/logout") def logout(): session.clear() return redirect(url_for("login")) def auth_required(f): from functools import wraps @wraps(f) def decorated(*args, **kwargs): if not session.get("auth"): return redirect(url_for("login")) return f(*args, **kwargs) return decorated @app.route("/") @auth_required def dashboard(): return redirect(url_for("sessions_page")) @app.route("/sessions") @auth_required def sessions_page(): s = load_settings() labels = s.get("labels", {}) sessions = get_active_sessions() rows = "" online_count = 0 for sess in sessions: online = is_peer_online(sess.get("handshake", "")) if online: online_count += 1 status = 'Online' if online else 'Offline' real_ip = sess["real_ip"] label = labels.get(real_ip, "") label_display = f"{label} " if label else "" rows += f""" {sess['client_id']} {sess['tunnel_ip']} {label_display}{real_ip} {sess['handshake']} {sess['rx']} / {sess['tx']} {status} Kick """ if not rows: rows = 'Нет активных сессий' html = f"""

Активные сессии

{rows}
КлиентVPN IPReal IPHandshakeRX / TXСтатус
""" return render(html) @app.route("/kick/") @auth_required def kick(pub_key): kick_peer(pub_key) return redirect(url_for("sessions_page")) @app.route("/clients", methods=["GET", "POST"]) @auth_required def clients_page(): s = load_settings() subs = s.get("subscriptions", {}) msg = "" if request.method == "POST": action = request.form.get("action") if action == "add": name = request.form.get("name", "").strip() if name and re.match(r"^[a-zA-Z0-9_-]+$", name): try: out = subprocess.check_output( [f"{PHOBOS_DIR}/repo/server/scripts/phobos-client.sh", "add", name], text=True, timeout=30, stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()} ) msg = f'
Клиент {name} создан
' except subprocess.CalledProcessError as e: msg = f'
{e.output}
' else: msg = '
Имя: буквы, цифры, _ и -
' elif action == "delete": client_id = request.form.get("client_id", "").strip() if client_id: try: out = subprocess.check_output( [f"{PHOBOS_DIR}/repo/server/scripts/phobos-client.sh", "remove", client_id], text=True, timeout=15, stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()} ) msg = f'
Клиент {client_id} удалён
' except subprocess.CalledProcessError as e: msg = f'
{e.output}
' elif action == "set_expiry": client_id = request.form.get("client_id", "").strip() expiry = request.form.get("expiry", "").strip() if client_id: if client_id not in subs: subs[client_id] = {} subs[client_id]["expiry"] = expiry subs[client_id].pop("locked", None) subs[client_id].pop("warn3", None) subs[client_id].pop("warn1", None) save_settings(s) msg = f'
Срок для {client_id} обновлён
' clients = get_clients() peers = get_wg_peers() online_pubs = set() for pub, info in peers.items(): if is_peer_online(info.get("latest handshake", "")): online_pubs.add(pub) rows = "" today = datetime.now().date() for c in clients: cid = c.get("client_id", "") pub = c.get("public_key", "") ip = c.get("tunnel_ip_v4", "") created = c.get("created_at", "")[:10] is_online = pub in online_pubs status = 'Online' if is_online else 'Offline' sub = subs.get(cid, {}) expiry = sub.get("expiry", "") locked = sub.get("locked", False) expiry_badge = "" if expiry: try: exp_date = datetime.strptime(expiry, "%Y-%m-%d").date() days = (exp_date - today).days if locked: expiry_badge = f'⛔ истёк' elif days <= 1: expiry_badge = f'⚠️ {days}д' elif days <= 3: expiry_badge = f'{days}д' else: expiry_badge = f'{days}д' except ValueError: pass rows += f""" {cid} {ip} {created} {status}
{expiry_badge}
""" html = f"""
{msg}

Клиенты VPN

{rows}
КлиентVPN IPСозданСтатусПодписка
""" return render(html) @app.route("/labels", methods=["GET", "POST"]) @auth_required def labels_page(): s = load_settings() msg = "" if request.method == "POST": action = request.form.get("action") if action == "add": ip = request.form.get("ip", "").strip() label = request.form.get("label", "").strip() if ip and label: s["labels"][ip] = label save_settings(s) msg = f'
Метка добавлена: {ip} → {label}
' elif action == "delete": ip = request.form.get("ip", "").strip() s["labels"].pop(ip, None) save_settings(s) msg = f'
Метка удалена
' labels = s.get("labels", {}) rows = "" for ip, label in sorted(labels.items()): rows += f""" {ip}{label}
""" html = f"""
{msg}

Метки по Real IP

{rows}
Real IPМетка
""" return render(html) @app.route("/settings", methods=["GET", "POST"]) @auth_required def settings_page(): s = load_settings() msg = "" if request.method == "POST": new_pass = request.form.get("admin_pass", "").strip() if new_pass: s["admin_pass"] = new_pass s["tg_bot_token"] = request.form.get("tg_bot_token", "").strip() s["tg_chat_id"] = request.form.get("tg_chat_id", "").strip() try: s["monitor_interval"] = max(10, int(request.form.get("monitor_interval", 30))) except ValueError: pass save_settings(s) msg = '
Настройки сохранены
' html = f"""
{msg}

Настройки панели

Информация о сервере

VPS IP{SERVER_IP}
WireGuard порт51820 (localhost)
Обфускатор порт51821
Панель порт8443
Phobos клиенты{CLIENTS_DIR}

Установка на роутер

Keenetic/Netcraze с Entware — выполнить по SSH на роутере:

curl -s http://{SERVER_IP}/init/TOKEN.sh | sh
""" return render(html) def _load_server_env(): """Load server.env as dict for subprocess env.""" env = {} if os.path.exists(SERVER_ENV): with open(SERVER_ENV) as f: for line in f: line = line.strip() if "=" in line and not line.startswith("#"): k, v = line.split("=", 1) env[k] = v return env if __name__ == "__main__": app.run(host="0.0.0.0", port=8443, debug=False)