diff --git a/app.py b/app.py index cf0fa5e..083a82a 100644 --- a/app.py +++ b/app.py @@ -4,11 +4,13 @@ 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 +import json, os, subprocess, threading, time, secrets, hashlib, re, fcntl from datetime import datetime, timedelta from pathlib import Path from flask import Flask, request, redirect, url_for, session, make_response +_settings_lock = threading.Lock() + app = Flask(__name__) PHOBOS_DIR = "/opt/Phobos" @@ -39,18 +41,20 @@ DEFAULT_SETTINGS = { 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) + with _settings_lock: + 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) + with _settings_lock: + with open(SETTINGS_FILE, "w") as f: + json.dump(s, f, indent=2, ensure_ascii=False) def tg_send(text): @@ -187,7 +191,7 @@ def kick_peer(public_key): def check_expiry(): """Check subscription expiry, lock expired clients.""" - s = load_settings() + s = load_settings() # Always reload fresh to avoid overwriting concurrent changes subs = s.get("subscriptions", {}) today = datetime.now().date() changed = False @@ -231,16 +235,252 @@ def kick_client_by_id(client_id): prev_session_keys = None +prev_server_status = {} +_last_fanout = 0 +server_stats_cache = {} +server_handshakes_cache = {} + + +def count_client_peers(): + """Count only client peers (exclude secondary server peers).""" + clients = get_clients() + client_pubs = {c.get("public_key", "") for c in clients} + wg_peers = get_wg_peers() + return sum(1 for pub in wg_peers if pub in client_pubs) + + +def get_local_stats(): + try: + cpu = subprocess.getoutput("top -bn1 | grep 'Cpu(s)' | awk '{print $2}'").strip() + mem = subprocess.getoutput("free -m | awk '/Mem:/{printf \"%.0f/%dMB\", $3, $2}'").strip() + peers = count_client_peers() + return {"cpu": cpu + "%", "mem": mem, "peers": peers, "status": "ok"} + except Exception: + return {"cpu": "?", "mem": "?", "peers": 0, "status": "ok"} + + +def get_remote_stats(server): + try: + import urllib.request + url = f"http://{server['ip']}:8444/api/health" + req = urllib.request.Request(url, headers={"X-API-Key": server.get("api_key", "")}) + resp = urllib.request.urlopen(req, timeout=3) + data = json.loads(resp.read()) + # cache handshakes so page renders never block on remote HTTP + server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {} + # Filter peers: only count known client public keys + clients = get_clients() + client_pubs = {c.get("public_key", "") for c in clients} + remote_keys = data.get("peer_keys", []) + client_peers = sum(1 for k in remote_keys if k in client_pubs) + return {"cpu": data.get("cpu", "—"), "mem": data.get("mem", "—"), "peers": client_peers, "status": "ok" if data.get("status") == "ok" else "down"} + except Exception: + return {"cpu": "—", "mem": "—", "peers": 0, "status": "down"} + + +def server_load(stats): + """Estimate server load 0..1 from cached stats (CPU+RAM). Missing stats = + neutral 0.5; explicitly down = 1.0 (avoid). Used by load-aware rebalance.""" + if not stats: + return 0.5 + if stats.get("status") == "down": + return 1.0 + try: + cpu = float(str(stats.get("cpu", "")).replace("%", "").strip()) / 100.0 + except Exception: + cpu = 0.5 + mem = 0.5 + try: + used, total = str(stats.get("mem", "")).replace("MB", "").split("/") + mem = float(used) / max(float(total), 1.0) + except Exception: + pass + return max(0.0, min(1.0, 0.6 * cpu + 0.4 * mem)) + + +def remote_handshakes(server_ip): + # Pure cache read — the background session_monitor refreshes it every cycle. + # Page renders must NEVER block on remote HTTP (that caused multi-second hangs). + return server_handshakes_cache.get(server_ip, {}) + + +def auto_assign_server(client_id): + s = load_settings() + assignments = s.get("client_assignments", {}) + if client_id in assignments: + return assignments[client_id] + + all_servers = get_all_servers_ordered() + server_ips = [sv["ip"] for sv in all_servers] + if not server_ips: + return SERVER_IP + + counts = {ip: 0 for ip in server_ips} + for cid, sip in assignments.items(): + if sip in counts: + counts[sip] += 1 + + assigned = min(counts, key=counts.get) + assignments[client_id] = assigned + s["client_assignments"] = assignments + save_settings(s) + return assigned + + +def add_peer_to_server(server_ip, public_key, allowed_ips): + """Add WG peer to a server via its API (secondary) or locally (primary).""" + if server_ip == SERVER_IP: + # Primary — add locally + try: + subprocess.run(["wg", "set", "wg0", "peer", public_key, "allowed-ips", allowed_ips], check=True, timeout=5) + subprocess.run("wg-quick save wg0", shell=True, timeout=5) + return {"status": "ok"} + except Exception as e: + return {"status": "error", "msg": str(e)} + # Secondary — call API + servers = load_servers() + api_key = "" + for srv in servers: + if srv["ip"] == server_ip: + api_key = srv.get("api_key", "") + break + if not api_key: + return {"status": "error", "msg": f"No API key for {server_ip}"} + try: + import urllib.request + data = json.dumps({"public_key": public_key, "allowed_ips": allowed_ips}).encode() + req = urllib.request.Request( + f"http://{server_ip}:8444/api/peers/add", + data=data, + headers={"X-API-Key": api_key, "Content-Type": "application/json"}, + method="POST" + ) + resp = urllib.request.urlopen(req, timeout=10) + return json.loads(resp.read()) + except Exception as e: + return {"status": "error", "msg": str(e)[:200]} + + +def restart_router_obfuscator(client_id): + """Restart wg-obfuscator on router via SSH (tries tunnel, then static).""" + s = load_settings() + access = s.get("router_access", {}).get(client_id) + if not access: + return {"status": "error", "msg": f"No SSH for {client_id}"} + ok_ip, out = _ssh_run(access, "/opt/etc/init.d/S49wg-obfuscator restart 2>/dev/null; echo OK") + if ok_ip and "OK" in out: + return {"status": "ok"} + return {"status": "error", "msg": out[:200]} + + +def generate_failover_conf_for_client(client_id): + s = load_settings() + assignments = s.get("client_assignments", {}) + assigned_ip = assignments.get(client_id, SERVER_IP) + all_servers = get_all_servers_ordered() + + by_ip = {sv["ip"]: sv for sv in all_servers} + ordered = [] + if assigned_ip in by_ip: + ordered.append(by_ip[assigned_ip]) + for sv in all_servers: + if sv["ip"] != assigned_ip: + ordered.append(sv) + + # Get main server WG public key + try: + main_wg_pub = subprocess.check_output(["wg", "show", "wg0", "public-key"], text=True, timeout=5).strip() + except Exception: + main_wg_pub = "" + + lines = ["# Phobos Failover Configuration"] + for i, srv in enumerate(ordered, 1): + lines.append(f"SERVER_{i}={srv['ip']}:{srv.get('ports', '2083,5443,993')}") + lines.append(f"KEY_{i}={srv.get('obfuscator_key', '')}") + wg_pub = main_wg_pub if srv.get("is_primary") else srv.get("wg_public_key", "") + if wg_pub: + lines.append(f"WGKEY_{i}={wg_pub}") + return "\n".join(lines) + "\n" + + +def fanout_router_config(client_id): + """Push this client's failover.conf to every secondary server's agent so + routers can PULL it through the tunnel (10.25.0.1:8444) — survives a public + panel-IP ban and follows the active server. Best-effort, non-blocking.""" + try: + conf = generate_failover_conf_for_client(client_id) + except Exception: + return + import urllib.request + payload = json.dumps({"client_id": client_id, "conf": conf}).encode() + for srv in load_servers(): + ip = srv.get("ip", "") + key = srv.get("api_key", "") + if not ip: + continue + # skip servers the monitor already knows are down (avoid blocking) + if server_stats_cache.get(ip, {}).get("status") == "down": + continue + try: + req = urllib.request.Request( + f"http://{ip}:8444/api/router-config-set", + data=payload, + headers={"X-API-Key": key, "Content-Type": "application/json"}, + method="POST") + urllib.request.urlopen(req, timeout=3) + except Exception: + pass + + +def _ssh_run(access, cmd, timeout=20): + """Try SSH: tunnel IP first, then static IP as fallback.""" + ssh_user = access.get("ssh_user", "root") + ssh_pass = access.get("ssh_pass", "") + ips_to_try = [] + if access.get("ssh_ip"): + ips_to_try.append(access["ssh_ip"]) + if access.get("ssh_static"): + ips_to_try.append(access["ssh_static"]) + if not ips_to_try or not ssh_pass: + return None, "SSH IP/pass missing" + for ip in ips_to_try: + try: + result = subprocess.run( + ["sshpass", "-p", ssh_pass, "ssh", + "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", + f"{ssh_user}@{ip}", cmd], + capture_output=True, text=True, timeout=timeout + ) + if result.returncode == 0: + return ip, result.stdout + except Exception: + continue + return None, f"SSH failed on all IPs: {', '.join(ips_to_try)}" + + +def push_config_to_router(client_id): + s = load_settings() + access = s.get("router_access", {}).get(client_id) + if not access: + return {"status": "error", "msg": f"No SSH for {client_id}"} + + conf = generate_failover_conf_for_client(client_id) + write_cmd = f"mkdir -p /opt/etc/Phobos && cat > /opt/etc/Phobos/failover.conf << 'FAILCONF'\n{conf}FAILCONF" + + ok_ip, out = _ssh_run(access, write_cmd) + if ok_ip: + return {"status": "ok", "msg": f"Config pushed to {client_id} ({ok_ip})"} + return {"status": "error", "msg": out[:200]} def session_monitor(): - """Background thread: monitor sessions, send Telegram alerts.""" - global prev_session_keys + global prev_session_keys, prev_server_status, server_stats_cache, _last_fanout while True: try: s = load_settings() interval = s.get("monitor_interval", 30) + # Client session monitoring sessions = get_active_sessions() current_keys = set() for sess in sessions: @@ -254,18 +494,44 @@ def session_monitor(): 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}") + tg_send(f"🟢 {client_id} connected — {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}") + tg_send(f"🔴 {client_id} disconnected — {name}") prev_session_keys = current_keys - check_expiry() + # Server health monitoring + servers = load_servers() + local_stats = get_local_stats() + server_stats_cache[SERVER_IP] = local_stats + for srv in servers: + ip = srv["ip"] + stats = get_remote_stats(srv) + server_stats_cache[ip] = stats + was_up = prev_server_status.get(ip, "ok") + now_status = stats["status"] + + if was_up == "ok" and now_status == "down": + tg_send(f"🔴 Server {ip} DOWN!") + elif was_up == "down" and now_status == "ok": + tg_send(f"🟢 Server {ip} back ONLINE") + + prev_server_status[ip] = now_status + + # periodic config fan-out to secondaries (covers CLI-created clients + # and config drift; gated ~5 min, skips down servers) + global _last_fanout + if time.time() - _last_fanout >= 300: + _last_fanout = time.time() + for _c in get_clients(): + fanout_router_config(_c.get("client_id", "")) + + check_expiry() time.sleep(interval) except Exception: time.sleep(30) @@ -321,6 +587,10 @@ input:focus{outline:none;border-color:#4f46e5} .alert{padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:.85em} .alert-error{background:#7f1d1d;color:#fca5a5;border:1px solid #991b1b} .alert-ok{background:#065f46;color:#6ee7b7;border:1px solid #047857} +.help{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;background:#334155;color:#a5b4fc;font-size:11px;font-weight:700;cursor:pointer;margin-left:4px;user-select:none;flex:none} +.help:hover{background:#4f46e5;color:#fff} +.help-box{display:none;background:#0b1220;border:1px solid #4f46e5;color:#cbd5e1;padding:8px 11px;border-radius:8px;font-size:.78em;max-width:340px;margin-left:6px;line-height:1.4;vertical-align:middle} +.help-box.show{display:inline-block}
@@ -332,6 +602,9 @@ CONTENT +