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():
+
{tr("Добавить сервер","Add Server")}
{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():