diff --git a/app.py b/app.py index a8fceca..3db5039 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ Management panel: clients, sessions, labels, subscriptions, Telegram alerts. 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 +from flask import Flask, request, redirect, url_for, session, make_response, send_from_directory _settings_lock = threading.Lock() @@ -481,6 +481,7 @@ prev_session_keys = None prev_server_status = {} _down_streak = {} _last_fanout = 0 +_last_peer_sync = 0 server_stats_cache = {} server_handshakes_cache = {} @@ -809,6 +810,7 @@ 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", "") + ssh_port = str(access.get("ssh_port", "22") or "22") ips_to_try = [] if access.get("ssh_ip"): ips_to_try.append(access["ssh_ip"]) @@ -820,7 +822,7 @@ def _ssh_run(access, cmd, timeout=20): try: result = subprocess.run( ["sshpass", "-p", ssh_pass, "ssh", - "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", + "-p", ssh_port, "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", f"{ssh_user}@{ip}", cmd], capture_output=True, text=True, timeout=timeout ) @@ -847,7 +849,7 @@ def push_config_to_router(client_id): def session_monitor(): - global prev_session_keys, prev_server_status, server_stats_cache, _last_fanout, _down_streak + global prev_session_keys, prev_server_status, server_stats_cache, _last_fanout, _last_peer_sync, _down_streak while True: try: s = load_settings() @@ -911,6 +913,13 @@ def session_monitor(): for _c in get_clients(): fanout_router_config(_c.get("client_id", "")) + # periodic peer repair to secondaries. This is what makes router + # server-failover work without pressing "Sync" after old clients + # or newly added secondary servers. + if time.time() - _last_peer_sync >= 300: + _last_peer_sync = time.time() + sync_missing_peers_to_secondaries() + check_expiry() time.sleep(interval) except Exception: @@ -1565,13 +1574,14 @@ def _render_install_commands(): if not tokens: return '

Нет активных токенов. Добавьте клиента.

' + port = panel_port() lines = "" for t in tokens: client = t.get("client", "?") token = t.get("token", "") expires = t.get("expires", 0) exp_str = datetime.fromtimestamp(expires).strftime("%Y-%m-%d %H:%M") if expires else "?" - cmd = f"wget -O - http://{SERVER_IP}/init/{token}.sh | sh" + cmd = install_command(token, port) lines += f"""
{client} @@ -1596,6 +1606,36 @@ def save_servers(servers): json.dump(servers, f, indent=2) +def panel_port(): + try: + return open(os.path.join(PANEL_DIR, ".port")).read().strip() or "8443" + except Exception: + return "8443" + + +def install_command(token, port=None): + port = port or panel_port() + urls = [f"http://{SERVER_IP}:{port}/init/{token}.sh", f"http://{SERVER_IP}/init/{token}.sh"] + return ("tmp=/tmp/phobos-init-$$.sh; ok=0; " + f"for u in {' '.join(urls)}; do " + "rm -f $tmp; " + "(wget -q -O $tmp $u || curl -fsSL -o $tmp $u) 2>/dev/null || continue; " + "grep -qi '") +def serve_init(name): + return send_from_directory(f"{PHOBOS_DIR}/www/init", name, mimetype="application/x-sh") + + +@app.route("/packages/") +def serve_package(name): + return send_from_directory(f"{PHOBOS_DIR}/www/packages", name, mimetype="application/octet-stream") + + def get_main_server_info(): env = _load_server_env() return { @@ -1667,6 +1707,30 @@ def sync_peer_to_all_servers(public_key, allowed_ips, action="add"): sync_peer_to_server(srv, public_key, allowed_ips, action) +def sync_missing_peers_to_secondaries(server=None): + """Best-effort peer repair: every secondary must already know every client. + Router failover can only work if the target server has the client's WG peer. + """ + servers = [server] if server else [srv for srv in load_servers() if srv.get("enabled", True)] + clients = get_clients() + synced = 0 + for srv in servers: + health = check_server_health(srv) + if health.get("status") not in ("ok", None): + continue + remote_keys = set(health.get("peer_keys", []) or []) + for c in clients: + pub = c.get("public_key", "") + tip = (c.get("tunnel_ip_v4") or "").split("/")[0] + if not pub or not tip or pub in remote_keys: + continue + res = sync_peer_to_server(srv, pub, f"{tip}/32", "add") + if res.get("status") == "ok": + synced += 1 + remote_keys.add(pub) + return synced + + @app.route("/download/apk") @auth_required def download_apk(): @@ -1834,9 +1898,10 @@ def api_client_ensure(): os.chmod(f"{_www}/packages/{token}", 0o755) except Exception: pass - install_url = f"http://{SERVER_IP}/init/{token}.sh" if token else "" + install_url = f"http://{SERVER_IP}:{panel_port()}/init/{token}.sh" if token else "" return json.dumps({ "ok": True, "client_id": cid, "install_url": install_url, "token": token, + "install_command": install_command(token) if token else "", "pull_token": pull_token, "tunnel_ip": tip, "assigned_server": assigned, }), 200, {"Content-Type": "application/json"} @@ -1916,11 +1981,12 @@ def servers_page(): api_key = request.form.get("api_key", "").strip() ssh_user = request.form.get("ssh_user", "root").strip() or "root" ssh_pass = request.form.get("ssh_pass", "").strip() + ssh_port = request.form.get("ssh_port", "22").strip() or "22" if ip: servers.append({ "ip": ip, "api_key": api_key, "enabled": True, "last_seen": "", "wg_public_key": "", "obfuscator_key": "", "ports": "", - "ssh_user": ssh_user, "ssh_pass": ssh_pass + "ssh_user": ssh_user, "ssh_pass": ssh_pass, "ssh_port": ssh_port }) save_servers(servers) order = s.get("server_order", []) @@ -1928,7 +1994,8 @@ def servers_page(): order.append(ip) s["server_order"] = order save_settings(s) - msg = f'
Сервер {ip} добавлен
' + synced = sync_missing_peers_to_secondaries(servers[-1]) + msg = f'
Сервер {ip} добавлен, синхронизировано {synced} клиентов
' elif action == "remove": ip = request.form.get("ip", "").strip() @@ -2004,8 +2071,9 @@ def servers_page(): ssh_static = request.form.get(f"ssh_static_{cid}", "").strip() ssh_user = request.form.get(f"ssh_user_{cid}", "root").strip() or "root" ssh_pass = request.form.get(f"ssh_pass_{cid}", "").strip() + ssh_port = request.form.get(f"ssh_port_{cid}", "22").strip() or "22" if ssh_ip or ssh_pass: - ra[cid] = {"ssh_ip": ssh_ip, "ssh_static": ssh_static, "ssh_user": ssh_user, "ssh_pass": ssh_pass, "ssh_ok": ra.get(cid, {}).get("ssh_ok", False)} + ra[cid] = {"ssh_ip": ssh_ip, "ssh_static": ssh_static, "ssh_user": ssh_user, "ssh_pass": ssh_pass, "ssh_port": ssh_port, "ssh_ok": ra.get(cid, {}).get("ssh_ok", False)} elif cid in ra and not ssh_ip and not ssh_pass: del ra[cid] s["router_access"] = ra @@ -2025,7 +2093,7 @@ def servers_page(): try: r = subprocess.run( ["sshpass", "-p", acc.get("ssh_pass", ""), "ssh", - "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", + "-p", str(acc.get("ssh_port", "22") or "22"), "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", f"{acc.get('ssh_user','root')}@{ip}", "echo OK"], capture_output=True, text=True, timeout=10 ) @@ -2079,10 +2147,12 @@ def servers_page(): ip = srv["ip"] su = request.form.get(f"srv_ssh_user_{ip}", "").strip() sp = request.form.get(f"srv_ssh_pass_{ip}", "").strip() + sport = request.form.get(f"srv_ssh_port_{ip}", "22").strip() or "22" if su: srv["ssh_user"] = su if sp: srv["ssh_pass"] = sp + srv["ssh_port"] = sport save_servers(servers) msg = '
SSH доступ к серверам сохранён
' @@ -2216,6 +2286,7 @@ def servers_page(): ssh_static = acc.get("ssh_static", "") ssh_user = acc.get("ssh_user", "root") ssh_pass = acc.get("ssh_pass", "") + ssh_port = acc.get("ssh_port", "22") ssh_ok = acc.get("ssh_ok", False) ssh_tested = acc.get("ssh_tested", "") if ssh_ok: @@ -2232,6 +2303,7 @@ def servers_page(): + {badge_txt} """ @@ -2265,6 +2337,7 @@ def servers_page(): + {hlp("Добавить новый VPN-сервер в пул резервирования. Нужны: IP, API-ключ агента (порт 8444) и SSH-пароль для первичной настройки.", "Add a new VPN server to the failover pool. Needs: IP, agent API key (port 8444) and SSH password for initial setup.")} @@ -2276,7 +2349,7 @@ def servers_page():
- + {access_rows}
{tr("Клиент","Client")}Tunnel IP{tr("Статич. IP","Static IP")}{tr("Пользователь","User")}{tr("Пароль","Pass")}{tr("Статус","Status")}
{tr("Клиент","Client")}Tunnel IP{tr("Статич. IP","Static IP")}{tr("Пользователь","User")}{tr("Порт","Port")}{tr("Пароль","Pass")}{tr("Статус","Status")}
@@ -2305,10 +2378,11 @@ def servers_page(): - + {"".join(f''' + ''' for srv in servers)} diff --git a/overlay/phobos-client.sh b/overlay/phobos-client.sh index d825bea..65394de 100755 --- a/overlay/phobos-client.sh +++ b/overlay/phobos-client.sh @@ -344,20 +344,40 @@ action_link() { ln -s "$PACKAGES_DIR/phobos-$id.tar.gz" "$link_dir/phobos-$id.tar.gz" mkdir -p "$WWW_DIR/init" - local script_url="http://${SERVER_PUBLIC_IP_V4}:${HTTP_PORT:-80}/packages/$token/phobos-$id.tar.gz" + local panel_port="${HTTP_PORT:-}" + if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then + panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true) + fi + panel_port="${panel_port:-80}" + local primary_url="http://${SERVER_PUBLIC_IP_V4}:${panel_port}/packages/$token/phobos-$id.tar.gz" + local fallback_url="http://${SERVER_PUBLIC_IP_V4}:80/packages/$token/phobos-$id.tar.gz" cat > "$WWW_DIR/init/$token.sh" </dev/null; then - curl -L -s -o "\$dir/package.tar.gz" "\$url" -else - wget -q -O "\$dir/package.tar.gz" "\$url" -fi -if [ ! -f "\$dir/package.tar.gz" ]; then echo "Download failed"; exit 1; fi +ok=0 +for url in \$urls; do + rm -f "\$dir/package.tar.gz" + if command -v curl >/dev/null; then + curl -L -s -o "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue + else + wget -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue + fi + if grep -qi '/dev/null; then + echo "Bad response from \$url (HTML/proxy page), trying next..." + continue + fi + tar tzf "\$dir/package.tar.gz" >/dev/null 2>&1 || { + echo "Bad package from \$url, trying next..." + continue + } + ok=1 + break +done +[ "\$ok" = "1" ] || { echo "Download failed: no valid package"; exit 1; } cd "\$dir" tar xzf package.tar.gz cd "phobos-$id" @@ -372,7 +392,9 @@ EOF chmod 755 "$PACKAGES_DIR" 2>/dev/null chmod 644 "$PACKAGES_DIR/phobos-$id.tar.gz" 2>/dev/null - local cmd="curl -s http://${SERVER_PUBLIC_IP_V4}:${HTTP_PORT:-80}/init/$token.sh | sh" + local init_primary="http://${SERVER_PUBLIC_IP_V4}:${panel_port}/init/$token.sh" + local init_fallback="http://${SERVER_PUBLIC_IP_V4}:80/init/$token.sh" + local cmd="tmp=/tmp/phobos-init-\$\$.sh; ok=0; for u in $init_primary $init_fallback; do rm -f \$tmp; (wget -q -O \$tmp \$u || curl -fsSL -o \$tmp \$u) 2>/dev/null || continue; grep -qi '
ServerSSH UserSSH Pass
ServerSSH UserSSH PortSSH Pass
{srv.get("ip","")} {'OK' if srv.get('ssh_pass') else '—'}