From fff45ca342f081ae06bbd38b10744a1ad7db5340 Mon Sep 17 00:00:00 2001 From: phobos Date: Sat, 30 May 2026 13:39:49 +0300 Subject: [PATCH] turnkey installer: full primary stack one-command + session improvements - install.sh: rewritten as self-contained turnkey primary installer (deps, wg-obfuscator from Ground-Zerro, wg0, obfuscator services, Phobos repo + PCA overlay patches, web panel, nginx, router watchdog). - app.py: current panel (RU/EN, tunnel-pull config endpoint, fan-out, load-aware rebalance, online-anywhere status, '?' help). - overlay/: patched onboarding scripts (phobos-client.sh 403 fix, install-router.sh.template tunnel-pull+cron+client_id, router-configure-wireguard public WG, phobos-pull.sh tunnel-first). - server/: phobos-health.sh (self-heal+apply-server), phobos-pull.sh, phobos-router-watchdog.py, api.py (agent + /api/router-config). --- app.py | 1168 ++++++++++++++++++++++--- install.sh | 241 +++-- overlay/install-router.sh.template | 669 ++++++++++++++ overlay/phobos-client.sh | 399 +++++++++ overlay/phobos-pull.sh | 93 ++ overlay/router-configure-wireguard.sh | 429 +++++++++ server/api.py | 147 ++++ server/phobos-health.sh | 446 ++++++++++ server/phobos-pull.sh | 93 ++ server/phobos-router-watchdog.py | 203 +++++ server/secondary-setup.sh | 0 11 files changed, 3686 insertions(+), 202 deletions(-) mode change 100644 => 100755 install.sh create mode 100644 overlay/install-router.sh.template create mode 100755 overlay/phobos-client.sh create mode 100755 overlay/phobos-pull.sh create mode 100755 overlay/router-configure-wireguard.sh create mode 100644 server/api.py create mode 100755 server/phobos-health.sh create mode 100755 server/phobos-pull.sh create mode 100644 server/phobos-router-watchdog.py mode change 100644 => 100755 server/secondary-setup.sh 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 + """ @@ -339,6 +612,58 @@ def render(content): return PAGE.replace("CONTENT", content) +def cur_lang(): + try: + from flask import request as _rq + return "en" if _rq.cookies.get("lang", "ru") == "en" else "ru" + except Exception: + return "ru" + + +def tr(ru, en): + return ru if cur_lang() == "ru" else en + + +def hlp(ru, en): + """Inline '?' badge; click reveals a plain-language explanation (bilingual).""" + txt = (ru if cur_lang() == "ru" else en).replace('"', '"') + return ('?' + '' + txt + '') + + +def nav(active, sess_count=None): + L = cur_lang() + items = [ + ("sessions", "/sessions", "Сессии", "Sessions"), + ("clients", "/clients", "Клиенты", "Clients"), + ("labels", "/labels", "Метки", "Labels"), + ("servers", "/servers", "Серверы", "Servers"), + ("settings", "/settings", "Настройки", "Settings"), + ("logout", "/logout", "Выход", "Logout"), + ] + parts = ['') + return "".join(parts) + + +@app.route("/lang/") +def set_lang(code): + from flask import make_response + resp = make_response(redirect(request.referrer or url_for("dashboard"))) + resp.set_cookie("lang", "en" if code == "en" else "ru", max_age=31536000) + return resp + + + @app.route("/login", methods=["GET", "POST"]) def login(): s = load_settings() @@ -347,14 +672,14 @@ def login(): if request.form.get("password") == s["admin_pass"]: session["auth"] = True return redirect(url_for("dashboard")) - msg = '
Неверный пароль
' + msg = '
{tr("Неверный пароль","Wrong password")}
' html = f""" """ return render(html) @@ -388,45 +713,47 @@ def sessions_page(): s = load_settings() labels = s.get("labels", {}) sessions = get_active_sessions() + # Online = fresh handshake on ANY server (router may be on a backup) + online_all = set() + for _pub, _info in get_wg_peers().items(): + if is_peer_online(_info.get("latest handshake", "")): + online_all.add(_pub) + for _ip, _hs in server_handshakes_cache.items(): + for _pub, _ts in _hs.items(): + if _ts and (time.time() - _ts) < 180: + online_all.add(_pub) rows = "" online_count = 0 for sess in sessions: - online = is_peer_online(sess.get("handshake", "")) + online = sess["public_key"] in online_all 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 "" + label = labels.get(sess.get("tunnel_ip", ""), "") or labels.get(real_ip, "") rows += f""" {sess['client_id']} {sess['tunnel_ip']} - {label_display}{real_ip} + {real_ip} + {('' + label + '') if label else '—'} {sess['handshake']} {sess['rx']} / {sess['tx']} {status} - Kick + {tr("Отключить","Kick")}{hlp("Принудительно разорвать текущую сессию клиента (сбросить WG-пир). Клиент переподключится автоматически.","Force-drop the client current session (reset the WG peer). The client reconnects automatically.")} """ if not rows: - rows = 'Нет активных сессий' + rows = '{tr("Нет активных сессий","No active sessions")}' html = f"""
- + {nav('sessions', online_count)}
-

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

+

{tr("Активные сессии","Active sessions")}

- + {rows}
КлиентVPN IPReal IPHandshakeRX / TXСтатус
КлиентVPN IPReal IPМеткаHandshakeRX / TXСтатус
@@ -459,7 +786,9 @@ def clients_page(): text=True, timeout=30, stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()} ) - msg = f'
Клиент {name} создан
' + auto_assign_server(name) + fanout_router_config(name) # push conf to all servers (tunnel pull) + msg = f'
Client {name} created
' except subprocess.CalledProcessError as e: msg = f'
{e.output}
' else: @@ -491,6 +820,48 @@ def clients_page(): save_settings(s) msg = f'
Срок для {client_id} обновлён
' + elif action == "push_config": + client_id = request.form.get("client_id", "").strip() + res = push_config_to_router(client_id) + if res["status"] == "ok": + msg = f'
{res["msg"]}
' + else: + msg = f'
{client_id}: saved — router will pull config within ~2 min (SSH not needed for NAT routers)
' + + elif action == "assign_server": + client_id = request.form.get("client_id", "").strip() + server_ip = request.form.get("server_ip", "").strip() + if client_id and server_ip: + # 1. Find client public key + tunnel IP for WG peer + client_meta = None + for c in get_clients(): + if c.get("client_id") == client_id: + client_meta = c + break + errors = [] + # 2. Add WG peer on target server + if client_meta: + pub = client_meta.get("public_key", "") + tip = client_meta.get("tunnel_ip_v4", "").split("/")[0] + if pub and tip: + allowed = f"{tip}/32" + peer_res = add_peer_to_server(server_ip, pub, allowed) + if peer_res.get("status") != "ok": + errors.append(f"Peer add: {peer_res.get('msg', 'fail')}") + # 3. Save assignment + ca = s.get("client_assignments", {}) + ca[client_id] = server_ip + s["client_assignments"] = ca + save_settings(s) + fanout_router_config(client_id) # push conf to all servers (tunnel pull) + # 4. Router applies via pull (~15s). No synchronous SSH push: + # NAT routers can't be reached, and the SSH attempt blocked + # the click for seconds. Pull channel handles the apply. + if errors: + msg = f'
{client_id} → {server_ip}: {"; ".join(errors)}
' + else: + msg = f'
{client_id} → {server_ip} ✓ (saved — router applies within ~15s)
' + clients = get_clients() peers = get_wg_peers() online_pubs = set() @@ -498,6 +869,17 @@ def clients_page(): if is_peer_online(info.get("latest handshake", "")): online_pubs.add(pub) + all_srv = get_all_servers_ordered() + assignments = s.get("client_assignments", {}) + # Online = fresh handshake on ANY server (truthful during failover/transition) + online_all = set(online_pubs) + for _sv in all_srv: + if _sv.get("ip") == SERVER_IP: + continue + for _pub, _ts in remote_handshakes(_sv["ip"]).items(): + if _ts and (time.time() - _ts) < 180: + online_all.add(_pub) + rows = "" today = datetime.now().date() for c in clients: @@ -505,7 +887,7 @@ def clients_page(): pub = c.get("public_key", "") ip = c.get("tunnel_ip_v4", "") created = c.get("created_at", "")[:10] - is_online = pub in online_pubs + is_online = pub in online_all status = 'Online' if is_online else 'Offline' sub = subs.get(cid, {}) @@ -517,59 +899,78 @@ def clients_page(): exp_date = datetime.strptime(expiry, "%Y-%m-%d").date() days = (exp_date - today).days if locked: - expiry_badge = f'⛔ истёк' + expiry_badge = 'expired' elif days <= 1: - expiry_badge = f'⚠️ {days}д' + expiry_badge = f'{days}d' elif days <= 3: - expiry_badge = f'{days}д' + expiry_badge = f'{days}d' else: - expiry_badge = f'{days}д' + expiry_badge = f'{days}d' except ValueError: pass + assigned_ip = assignments.get(cid, SERVER_IP) + srv_opts = "" + for sv in all_srv: + sel = " selected" if sv["ip"] == assigned_ip else "" + label = f'{sv["ip"]} (Primary)' if sv.get("is_primary") else sv["ip"] + srv_opts += f'' + + set_lbl = tr("Задать", "Set") + h_set = hlp("Выбери сервер выхода для этого роутера и нажми «Задать». Роутер сам переключится за ~15 секунд. Кнопку Push нажимать НЕ нужно — конфигурация подтягивается автоматически.", + "Pick the exit server for this router and press Set. The router switches itself within ~15 seconds. You do NOT need a Push button — the config is pulled automatically.") + h_exp = hlp("Дата окончания подписки клиента. После этой даты клиент автоматически блокируется. Оставь пустым — без срока.", + "Client subscription expiry date. After this date the client is locked automatically. Leave empty for no expiry.") + h_del = hlp("Безвозвратно удалить клиента, его ключи и конфигурацию. Отменить нельзя.", + "Permanently delete the client, its keys and config. Cannot be undone.") + del_confirm = tr("Удалить " + cid + "?", "Delete " + cid + "?") rows += f""" {cid} {ip} - {created} {status} + +
+ + + + + {h_set} +
+
- - + + {expiry_badge} + {h_exp}
- -
+ + - +
+ {h_del} """ html = f"""
- + {nav('clients')} {msg}
-

Клиенты VPN

+

{tr("VPN-клиенты", "VPN Clients")}

- - + + + {hlp("Создать нового VPN-клиента (роутер/устройство). Имя — латиница, цифры, _ и -. После создания выдай команду установки на роутер.", "Create a new VPN client (router/device). Name: letters, digits, _ and -. After creation, run the install command on the router.")}
- + {rows}
КлиентVPN IPСозданСтатусПодписка
{tr("Клиент","Client")}VPN IP{tr("Статус","Status")}{tr("Сервер","Server")}{tr("Срок","Expiry")}{tr("Действия","Actions")}
@@ -610,23 +1011,16 @@ def labels_page(): html = f"""
- + {nav('labels')} {msg}
-

Метки по Real IP

+

{tr("Метки (по IP)","Labels (by IP)")} {hlp("Человекочитаемые имена для IP/туннелей — показываются в Сессиях вместо голого адреса.","Human-readable names for IPs/tunnels — shown in Sessions instead of the raw address.")}

Привяжите понятное имя (квартира, офис, дача) к внешнему IP адресу роутера. Метка отображается в таблице сессий и в Telegram уведомлениях вместо голого IP.

- - + +
@@ -657,39 +1051,33 @@ def settings_page(): html = f"""
- + {nav('settings')} {msg}
-

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

+

{tr("Настройки панели","Panel settings")}

-
-
+
{hlp("Новый пароль для входа в эту панель. Оставь пустым — пароль не изменится.","New password to sign in to this panel. Leave empty to keep current.")}
+
{hlp("Токен Telegram-бота для оповещений (падение/восстановление серверов, подключения, истечение подписок).","Telegram bot token for alerts (server up/down, connections, subscription expiry).")}
-
-
+
{hlp("Как часто фоновый монитор опрашивает серверы и обновляет статусы/оповещения. Меньше = свежее, но больше нагрузка.","How often the background monitor polls servers and refreshes statuses/alerts. Lower = fresher but more load.")}
+
-

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

+

{tr("Информация о сервере","Server info")}

Real IPМетка
- - - - + + + +
VPS IP{SERVER_IP}
WireGuard порт51820 (localhost)
Обфускатор порты{_load_server_env().get('OBFUSCATOR_PORTS', '2083,5443,993')}
Панель порт8443
Phobos клиенты{CLIENTS_DIR}
{tr("WireGuard порт","WireGuard port")}51820 (localhost)
{tr("Порты обфускатора","Obfuscator ports")}{_load_server_env().get('OBFUSCATOR_PORTS', '2083,5443,993')}
{tr("Порт панели","Panel port")}8443
{tr("Клиенты Phobos","Phobos clients")}{CLIENTS_DIR}
-

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

-

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

+

{tr("Установка на роутер","Router install")}

+

{tr("Keenetic/Netcraze с Entware — выполнить по SSH на роутере:","Keenetic/Netcraze with Entware — run over SSH on the router:")}

{_render_install_commands()}
""" @@ -744,6 +1132,41 @@ def save_servers(servers): json.dump(servers, f, indent=2) +def get_main_server_info(): + env = _load_server_env() + return { + "ip": SERVER_IP, + "ports": env.get("OBFUSCATOR_PORTS", "2083,5443,993"), + "obfuscator_key": env.get("OBFUSCATOR_KEY", ""), + "is_primary": True + } + + +def get_all_servers_ordered(): + s = load_settings() + servers = load_servers() + main_info = get_main_server_info() + order = s.get("server_order", []) + + by_ip = {} + by_ip[main_info["ip"]] = {"ip": main_info["ip"], "ports": main_info["ports"], + "obfuscator_key": main_info["obfuscator_key"], "is_primary": True} + for srv in servers: + by_ip[srv["ip"]] = {**srv, "is_primary": False} + + if not order or main_info["ip"] not in order: + order = [main_info["ip"]] + [sv["ip"] for sv in servers] + + result = [] + for ip in order: + if ip in by_ip: + result.append(by_ip[ip]) + for ip, sv in by_ip.items(): + if ip not in order: + result.append(sv) + return result + + def check_server_health(server): """Check secondary server health via API.""" try: @@ -780,6 +1203,20 @@ def sync_peer_to_all_servers(public_key, allowed_ips, action="add"): sync_peer_to_server(srv, public_key, allowed_ips, action) +@app.route("/api/router-config/") +def router_config(client_id): + """NAT-friendly config pull: router fetches its own failover.conf. + Auth via per-client pull_token (falls back to server_api_key).""" + st = load_settings() + token = request.args.get("token", "") + acc = st.get("router_access", {}).get(client_id, {}) + expected = acc.get("pull_token", "") or st.get("server_api_key", "") + if not expected or token != expected: + return ("forbidden", 403) + conf = generate_failover_conf_for_client(client_id) + return (conf, 200, {"Content-Type": "text/plain; charset=utf-8"}) + + @app.route("/api/servers/register", methods=["POST"]) def api_register_server(): s = load_settings() @@ -806,6 +1243,159 @@ def api_register_server(): return json.dumps({"status": "registered"}), 200, {"Content-Type": "application/json"} +# --------------------------------------------------------------------------- +# Client provisioning API — used by Keenetic Unified (KU) to auto-create a +# unique Phobos client per router (name "ku-") and return its +# install command. Each call guarantees a unique WG keypair + tunnel IP + +# peer on ALL servers, so per-router configs never collide. +# --------------------------------------------------------------------------- + +CLIENT_SCRIPT = f"{PHOBOS_DIR}/repo/server/scripts/phobos-client.sh" + + +def _normalize_client_id(name): + return name.strip().lower().replace(" ", "-") + + +def _latest_token_for_client(cid): + try: + with open(TOKENS_FILE) as f: + toks = json.load(f) + except Exception: + return "" + for t in reversed(toks): + if t.get("client") == cid: + return t.get("token", "") + return "" + + +@app.route("/api/client/ensure", methods=["POST"]) +def api_client_ensure(): + s = load_settings() + if request.headers.get("X-API-Key", "") != s.get("server_api_key", ""): + return json.dumps({"error": "unauthorized"}), 401, {"Content-Type": "application/json"} + data = request.json or {} + name = (data.get("name") or "").strip() + if not name or not re.match(r"^[a-zA-Z0-9_-]+$", name): + return json.dumps({"error": "bad_name"}), 400, {"Content-Type": "application/json"} + cid = _normalize_client_id(name) + env = {**os.environ, **_load_server_env()} + exists = Path(f"{CLIENTS_DIR}/{cid}").is_dir() + sub = "link" if exists else "add" + try: + out = subprocess.check_output([CLIENT_SCRIPT, sub, name], text=True, + timeout=60, stderr=subprocess.STDOUT, env=env) + except subprocess.CalledProcessError as e: + return json.dumps({"error": "script_failed", "output": (e.output or "")[-1500:]}), 500, {"Content-Type": "application/json"} + + meta = next((c for c in get_clients() if c.get("client_id") == cid), None) + if not meta: + return json.dumps({"error": "no_meta", "output": out[-800:]}), 500, {"Content-Type": "application/json"} + pub = meta.get("public_key", "") + tip = (meta.get("tunnel_ip_v4") or "").split("/")[0] + + # Peer on ALL servers so failover works on every server, not just primary. + if pub and tip: + try: + sync_peer_to_all_servers(pub, f"{tip}/32", "add") + except Exception: + pass + assigned = auto_assign_server(cid) + + # Per-client pull token for the NAT-friendly config pull channel. + s = load_settings() + ra = s.get("router_access", {}) + acc = ra.get(cid) or {} + if not acc.get("pull_token"): + acc["pull_token"] = secrets.token_hex(16) + ra[cid] = acc + s["router_access"] = ra + save_settings(s) + pull_token = ra[cid]["pull_token"] + + token = _latest_token_for_client(cid) + # phobos-client.sh writes these 0600 (umask 077 in action_add) → nginx (www-data) + # can't read them → 403 on /init and /packages. Make them world-readable. + if token: + _www = f"{PHOBOS_DIR}/www" + for pth in (f"{_www}/init/{token}.sh", + f"{_www}/packages/{token}/phobos-{cid}.tar.gz"): + try: + os.chmod(pth, 0o644) + except Exception: + pass + try: + os.chmod(f"{_www}/packages/{token}", 0o755) + except Exception: + pass + install_url = f"http://{SERVER_IP}/init/{token}.sh" if token else "" + return json.dumps({ + "ok": True, "client_id": cid, "install_url": install_url, "token": token, + "pull_token": pull_token, "tunnel_ip": tip, "assigned_server": assigned, + }), 200, {"Content-Type": "application/json"} + + +@app.route("/api/client/remove", methods=["POST"]) +def api_client_remove(): + s = load_settings() + if request.headers.get("X-API-Key", "") != s.get("server_api_key", ""): + return json.dumps({"error": "unauthorized"}), 401, {"Content-Type": "application/json"} + data = request.json or {} + cid = _normalize_client_id((data.get("name") or "").strip()) + if not cid: + return json.dumps({"error": "bad_name"}), 400, {"Content-Type": "application/json"} + meta = next((c for c in get_clients() if c.get("client_id") == cid), None) + pub = (meta or {}).get("public_key", "") + tip = ((meta or {}).get("tunnel_ip_v4") or "").split("/")[0] + env = {**os.environ, **_load_server_env()} + try: + subprocess.check_output([CLIENT_SCRIPT, "remove", cid], text=True, + timeout=30, stderr=subprocess.STDOUT, env=env) + except subprocess.CalledProcessError as e: + return json.dumps({"error": "script_failed", "output": (e.output or "")[-1500:]}), 500, {"Content-Type": "application/json"} + if pub and tip: + try: + sync_peer_to_all_servers(pub, f"{tip}/32", "remove") + except Exception: + pass + s = load_settings() + for key in ("router_access", "client_assignments"): + d = s.get(key, {}) + if cid in d: + del d[cid] + s[key] = d + save_settings(s) + return json.dumps({"ok": True, "client_id": cid}), 200, {"Content-Type": "application/json"} + + +@app.route("/api/obf-health") +def api_obf_health(): + """Liveness of the obfuscator path on THIS (primary) server. + Returns 200 only if all wg-obfuscator-* services are active, else 503. + Router check_primary uses this as a valid switchback signal (the panel + HTTP port stays up even when the tunnel path is dead, so it cannot be + used directly).""" + try: + listing = subprocess.check_output( + ["systemctl", "list-units", "--type=service", "--no-legend", + "wg-obfuscator-*"], text=True, timeout=5) + names = [ln.split()[0] for ln in listing.splitlines() if ln.strip()] + except Exception: + names = [] + if not names: + names = ["wg-obfuscator-2083.service", + "wg-obfuscator-5443.service", + "wg-obfuscator-993.service"] + bad = [] + for n in names: + st = subprocess.getoutput("systemctl is-active " + n).strip() + if st != "active": + bad.append(n + "=" + st) + if bad: + return "obf-down: " + ",".join(bad), 503, {"Content-Type": "text/plain"} + return "obf-ok " + str(len(names)), 200, {"Content-Type": "text/plain"} + + @app.route("/servers", methods=["GET", "POST"]) @auth_required def servers_page(): @@ -818,28 +1408,36 @@ def servers_page(): if action == "add": ip = request.form.get("ip", "").strip() 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() if ip: servers.append({ - "ip": ip, - "api_key": api_key, - "enabled": True, - "last_seen": "", - "wg_public_key": "", - "obfuscator_key": "", - "ports": "" + "ip": ip, "api_key": api_key, "enabled": True, + "last_seen": "", "wg_public_key": "", "obfuscator_key": "", "ports": "", + "ssh_user": ssh_user, "ssh_pass": ssh_pass }) save_servers(servers) + order = s.get("server_order", []) + if ip not in order: + order.append(ip) + s["server_order"] = order + save_settings(s) msg = f'
Сервер {ip} добавлен
' elif action == "remove": ip = request.form.get("ip", "").strip() - servers = [s for s in servers if s.get("ip") != ip] + servers = [sv for sv in servers if sv.get("ip") != ip] save_servers(servers) + order = s.get("server_order", []) + if ip in order: + order.remove(ip) + s["server_order"] = order + save_settings(s) msg = f'
Сервер удалён
' elif action == "sync": ip = request.form.get("ip", "").strip() - srv = next((s for s in servers if s["ip"] == ip), None) + srv = next((sv for sv in servers if sv["ip"] == ip), None) if srv: clients = get_clients() synced = 0 @@ -857,10 +1455,10 @@ def servers_page(): for srv in servers: if srv["ip"] == ip: try: - import urllib.request + import urllib.request as ul url = f"http://{ip}:8444/api/info" - req = urllib.request.Request(url, headers={"X-API-Key": srv.get("api_key", "")}) - resp = urllib.request.urlopen(req, timeout=5) + rq = ul.Request(url, headers={"X-API-Key": srv.get("api_key", "")}) + resp = ul.urlopen(rq, timeout=5) info = json.loads(resp.read()) srv["wg_public_key"] = info.get("wg_public_key", "") srv["obfuscator_key"] = info.get("obfuscator_key", "") @@ -877,72 +1475,354 @@ def servers_page(): save_settings(s) msg = '
API ключ обновлён
' - # Build server rows - rows = "" - for srv in servers: + elif action in ("move_up", "move_down"): + ip = request.form.get("ip", "").strip() + order = s.get("server_order", []) + if not order or SERVER_IP not in order: + order = [SERVER_IP] + [sv["ip"] for sv in servers] + if ip in order: + idx = order.index(ip) + if action == "move_up" and idx > 0: + order[idx], order[idx-1] = order[idx-1], order[idx] + elif action == "move_down" and idx < len(order) - 1: + order[idx], order[idx+1] = order[idx+1], order[idx] + s["server_order"] = order + save_settings(s) + + elif action == "save_router_access": + clients = get_clients() + ra = s.get("router_access", {}) + for c in clients: + cid = c.get("client_id", "") + ssh_ip = request.form.get(f"ssh_ip_{cid}", "").strip() + 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() + 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)} + elif cid in ra and not ssh_ip and not ssh_pass: + del ra[cid] + s["router_access"] = ra + save_settings(s) + msg = '
SSH доступ сохранён
' + + elif action == "test_ssh": + cid = request.form.get("client_id", "").strip() + ra = s.get("router_access", {}) + acc = ra.get(cid, {}) + results = [] + ok_ip = "" + # Try tunnel IP first, then static + for label, ip in [("tunnel", acc.get("ssh_ip", "")), ("static", acc.get("ssh_static", ""))]: + if not ip: + continue + try: + r = subprocess.run( + ["sshpass", "-p", acc.get("ssh_pass", ""), "ssh", + "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", + f"{acc.get('ssh_user','root')}@{ip}", "echo OK"], + capture_output=True, text=True, timeout=10 + ) + if "OK" in r.stdout: + results.append(f"{label} ({ip}): ✓") + if not ok_ip: + ok_ip = ip + else: + results.append(f"{label} ({ip}): ✗ {r.stderr[:80]}") + except Exception as e: + results.append(f"{label} ({ip}): ✗ {str(e)[:80]}") + if cid in ra: + ra[cid]["ssh_ok"] = bool(ok_ip) + ra[cid]["ssh_tested"] = ok_ip or "" + s["router_access"] = ra + save_settings(s) + # SSH is optional under the pull model. Frame the result around whether + # the router is actually reachable/managed, not raw SSH success. + client_pub = "" + for _c in get_clients(): + if _c.get("client_id") == cid: + client_pub = _c.get("public_key", "") + break + online = False + for _p, _i in get_wg_peers().items(): + if _p == client_pub and is_peer_online(_i.get("latest handshake", "")): + online = True + for _ip, _hs in server_handshakes_cache.items(): + _ts = _hs.get(client_pub, 0) + if _ts and (time.time() - _ts) < 180: + online = True + ssh_line = " | ".join(results) if results else tr("SSH не настроен", "SSH not configured") + if ok_ip: + msg = f'
{cid}: SSH ✓ {ok_ip} — {ssh_line}
' + elif online: + msg = f'
{cid}: {tr("роутер ОНЛАЙН, управляется через pull. SSH по туннелю недоступен — это нормально для роутера за NAT (входящий SSH закрыт); управление SSH не требует.", "router is ONLINE, managed via pull. Tunnel SSH unreachable — normal for a NAT router (inbound SSH closed); management does not need SSH.")}
' + else: + msg = f'
{cid}: {tr("роутер НЕ виден — нет свежего handshake ни на одном сервере, и SSH недоступен.", "router NOT visible — no fresh handshake on any server, and SSH unreachable.")} {ssh_line}
' + + elif action == "push_all": + clients = get_clients() + results = [] + for c in clients: + cid = c.get("client_id", "") + res = push_config_to_router(cid) + results.append(f"{cid}: {res['msg']}") + msg = '
' + '
'.join(results) + '
' + + elif action == "save_server_access": + for srv in servers: + ip = srv["ip"] + su = request.form.get(f"srv_ssh_user_{ip}", "").strip() + sp = request.form.get(f"srv_ssh_pass_{ip}", "").strip() + if su: + srv["ssh_user"] = su + if sp: + srv["ssh_pass"] = sp + save_servers(servers) + msg = '
SSH доступ к серверам сохранён
' + + elif action == "rebalance": + clients = get_clients() + all_srv = get_all_servers_ordered() + server_ips = [sv["ip"] for sv in all_srv] + # Load-aware greedy: base load from live CPU+RAM (cached by monitor), + # then each client goes to the least-loaded server, with a per-client + # penalty so load spreads evenly. Down servers excluded (unless all down). + base_load = {ip: server_load(server_stats_cache.get(ip, {})) for ip in server_ips} + usable = [ip for ip in server_ips if base_load[ip] < 0.99] or server_ips + placed = {ip: 0 for ip in server_ips} + PEN = 0.08 # extra load each assigned client adds to a server's score + assignments = {} + rb_err = [] + for c in clients: + cid = c["client_id"] + target = min(usable, key=lambda ip: base_load[ip] + placed[ip] * PEN) + placed[target] += 1 + assignments[cid] = target + # Provision the WG peer on the target server. Round-robin assignment + # alone is not enough — without the peer the handshake fails there and + # the router's health monitor just fails back. + pub = c.get("public_key", "") + tip = c.get("tunnel_ip_v4", "").split("/")[0] + if pub and tip: + r = add_peer_to_server(target, pub, f"{tip}/32") + if r.get("status") != "ok": + rb_err.append(f"{cid}->{target}: {r.get('msg', 'peer fail')}") + s["client_assignments"] = assignments + save_settings(s) + for _cid in assignments: + fanout_router_config(_cid) # push conf to all servers (tunnel pull) + if rb_err: + msg = f'
Rebalance: {"; ".join(rb_err)}
' + else: + msg = f'
{tr("Перераспределено", "Rebalanced")}: {len(clients)} -> {len(server_ips)} {tr("серв.; пиры добавлены, роутеры применят за ~15с", "servers; peers added, routers apply within ~15s")}
' + + # Build ordered server list + all_ordered = get_all_servers_ordered() + assignments = s.get("client_assignments", {}) + clients = get_clients() + + # Count assigned clients per server + assign_counts = {} + for cid, sip in assignments.items(): + assign_counts[sip] = assign_counts.get(sip, 0) + 1 + + # Stats dashboard cards + stats_cards = "" + for srv in all_ordered: ip = srv.get("ip", "") - health = check_server_health(srv) - status_class = "badge-on" if health.get("status") == "ok" else "badge-off" - status_text = "Online" if health.get("status") == "ok" else "Offline" - peers = health.get("peers", "?") + is_primary = srv.get("is_primary", False) + cached = server_stats_cache.get(ip, {}) ports = srv.get("ports", "?") + assigned = assign_counts.get(ip, 0) + + if is_primary: + local = get_local_stats() + cpu_val = local.get("cpu", "?") + mem_val = local.get("mem", "?") + peers_val = local.get("peers", 0) + st_class = "badge-on" + st_text = "Primary" + else: + cpu_val = cached.get("cpu", "—") + mem_val = cached.get("mem", "—") + peers_val = cached.get("peers", "?") + is_up = cached.get("status", "down") == "ok" + st_class = "badge-on" if is_up else "badge-off" + st_text = "Online" if is_up else "Offline" + + stats_cards += f""" +
+
+ {ip} + {st_text} +
+
+ CPU: {cpu_val}   RAM: {mem_val}
+ Peers: {peers_val}   Assigned: {assigned}   Ports: {ports} +
+
""" + + # Server priority rows + rows = "" + for idx, srv in enumerate(all_ordered): + ip = srv.get("ip", "") + is_primary = srv.get("is_primary", False) + ports = srv.get("ports", "?") + cached = server_stats_cache.get(ip, {}) + peers_val = cached.get("peers", get_local_stats().get("peers", "?") if is_primary else "?") + assigned = assign_counts.get(ip, 0) + + if is_primary: + st_class, st_text = "badge-on", "Primary" + else: + is_up = cached.get("status", "down") == "ok" + st_class = "badge-on" if is_up else "badge-off" + st_text = "Online" if is_up else "Offline" + + prio = f'#{idx+1}' + ptag = ' Primary' if is_primary else "" + + mv = f""" + """ + + acts = "" + if not is_primary: + acts = f""" + + """ rows += f""" - {ip} - {ports} - {peers} - {status_text} - -
- - - - -
- + {prio}{ip}{ptag}{ports}{peers_val}{assigned} + {st_text} +
{mv} {acts}
""" api_key = s.get("server_api_key", "") + # Router access rows — tunnel IP + optional static IP + router_access = s.get("router_access", {}) + access_rows = "" + for c in clients: + cid = c.get("client_id", "") + tunnel_ip = c.get("tunnel_ip_v4", "") + acc = router_access.get(cid, {}) + ssh_ip = acc.get("ssh_ip", tunnel_ip) + ssh_static = acc.get("ssh_static", "") + ssh_user = acc.get("ssh_user", "root") + ssh_pass = acc.get("ssh_pass", "") + ssh_ok = acc.get("ssh_ok", False) + ssh_tested = acc.get("ssh_tested", "") + if ssh_ok: + badge_cls = "badge-on" + badge_txt = f"✓ {ssh_tested}" if ssh_tested else "✓" + elif ssh_ip and ssh_pass: + badge_cls = "badge-warn" + badge_txt = "не проверен" + else: + badge_cls = "badge-off" + badge_txt = "—" + access_rows += f""" + {cid} + + + + + {badge_txt} + """ + html = f"""
- + {nav('servers')} {msg} +
-

API ключ для secondary серверов

-

Единый ключ аутентификации для связи с резервными серверами. Укажите его при установке secondary сервера.

-
- - - -
+

{tr("Панель серверов", "Server Dashboard")} {hlp("Живая статистика всех VPN-серверов. Peers = активные WG-подключения. Assigned = клиенты, у которых этот сервер основной. Фоновый монитор опрашивает каждые " + str(s.get('monitor_interval',30)) + " сек и шлёт оповещения в Telegram при падении/восстановлении сервера.", "Live stats for all VPN servers. Peers = active WG connections. Assigned = clients whose primary is this server. Background monitor polls every " + str(s.get('monitor_interval',30)) + "s and sends Telegram alerts on server up/down.")}

+
{stats_cards}
-

Серверы VPN

-

Резервные VPN серверы для автоматического переключения. Роутеры с health monitor автоматически переходят на backup если основной сервер недоступен. Кнопка Sync — синхронизирует всех клиентов на выбранный сервер, Info — запрашивает данные сервера.

+

{tr("Приоритет серверов и балансировка", "Server Priority & Load Balancing")} {hlp("У каждого клиента есть основной сервер. При сбое роутер сам перебирает серверы сверху вниз. Стрелки меняют общий порядок резервирования. Роутеры за NAT подтягивают конфиг сами — ручных действий не нужно.", "Each client has a primary server. On failure the router tries servers top-to-bottom by itself. Arrows change the global fallback order. NAT routers pull config themselves — no manual action needed.")}

- + {rows}
IPПортыPeersСтатус
{tr("Сервер","Server")}{tr("Порты","Ports")}Peers{tr("Назначено","Assigned")}{tr("Статус","Status")}{tr("Действия","Actions")}
-
+
+ + + {hlp("Равномерно распределить всех клиентов по серверам (round-robin). Меняет основной сервер у клиентов — они переключатся автоматически.", "Evenly redistribute all clients across servers (round-robin). Changes clients primary server — they switch automatically.")} +
+
+ {hlp("Принудительно отправить конфиг на роутеры с прямым SSH. Роутеры за NAT это игнорируют и сами подтягивают конфиг за ~15 сек — обычно эта кнопка не нужна.", "Force-push config to routers reachable by SSH. NAT routers ignore it and auto-pull within ~15s — you usually do not need this.")} +
+
- - - + + + + + + {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.")}
-

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

-

Одна команда разворачивает WireGuard + обфускатор + мини-API на новом VPS. Сервер автоматически зарегистрируется в панели. Выполнить по SSH на новом VPS:

+

{tr("SSH-доступ к роутерам (опционально)", "Router SSH Access (optional)")} {hlp("НЕ обязательно. Роутеры сами тянут конфиг с панели по HTTPS каждые ~15 сек (работает за любым NAT, входящий SSH не нужен). Эта секция — только для роутеров с прямым IP, если хочешь мгновенный push. У роутеров за NAT/KeenDNS тут будут таймауты SSH — это нормально и безвредно, pull-канал их обновляет.", "NOT required. Routers pull config from the panel over HTTPS every ~15s (works behind any NAT, no inbound SSH). This section is only for direct-IP routers if you want an instant push. NAT/KeenDNS routers will show SSH timeouts here — that is normal and harmless; the pull channel keeps them updated.")}

+
+ + + + {access_rows} +
{tr("Клиент","Client")}Tunnel IP{tr("Статич. IP","Static IP")}{tr("Пользователь","User")}{tr("Пароль","Pass")}{tr("Статус","Status")}
+ + {hlp("Сохранить SSH-доступ к роутерам. Нужно только для роутеров с прямым публичным IP. Для NAT-роутеров не требуется.", "Save SSH access to routers. Only needed for routers with a direct public IP. Not required for NAT routers.")} +
+
+ {"".join(f'
' for c in clients)} +
+
+ +
+

{tr("Команды для роутеров","Router commands")}

+

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

+ {_render_install_commands()} +

Удаление Phobos — выполнить по SSH на роутере:

+ /opt/etc/Phobos/phobos-uninstall.sh +

Скрипт остановит obfuscator, удалит cron, конфиги и бинарник. WireGuard интерфейс на роутере нужно удалить вручную через веб-панель.

+

Фикс SSH через туннель — если SSH по 10.25.0.x не работает (security-level):

+ wget -O - http://{SERVER_IP}/init/fix-security.sh | sh +

Автоматически найдёт WG интерфейс Phobos и установит security-level private для входящих подключений (SSH). Выполнять на роутере по SSH через LAN (192.168.1.1).

+
+ +
+

Server SSH Access

+

SSH credentials for secondary servers. Used by panel to add/remove WG peers when switching client servers. Main server uses local commands.

+
+ + + + {"".join(f''' + + + + + ''' for srv in servers)} +
ServerSSH UserSSH Pass
{srv.get("ip","")}{'OK' if srv.get('ssh_pass') else '—'}
+ +
+
+ +
+

API Key

+
+ + + +
+
+ +
+

Deploy Secondary Server

+

One command deploys WG + obfuscator + mini-API on a new VPS. Auto-registers in panel. Run via SSH on new VPS:

MAIN_SERVER={SERVER_IP} MAIN_API_KEY={api_key} bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)
""" diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 755178a..2a699b6 --- a/install.sh +++ b/install.sh @@ -1,59 +1,169 @@ #!/bin/bash # ============================================================ -# PCA Phobos — Web Panel Installer -# Requires: Phobos already installed (/opt/Phobos) +# PCA Phobos — TURNKEY installer (primary / panel node) # -# Usage: +# One command, all dependencies, from a clean VPS: # bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh) # -# Custom port: -# PANEL_PORT=39172 bash <(curl ...) +# Installs, in order: +# deps -> wg-obfuscator (Ground-Zerro) -> WireGuard wg0 -> +# obfuscator services -> Phobos repo (onboarding scripts) + +# PCA patches -> web panel -> nginx (/init,/packages) -> +# server-side router watchdog. +# +# Env (all optional): +# PANEL_PORT random 10000-59999 web panel port +# PANEL_PASS OcAdmin2026! panel admin password +# API_KEY random shared key (agents + router pull token) +# OBF_PORTS 2083,5443,993 obfuscator listen ports +# TG_TOKEN / TG_CHAT Telegram alerts +# PCA_BRANCH main branch to pull PCA files from # ============================================================ - set -e PANEL_PASS="${PANEL_PASS:-OcAdmin2026!}" TG_TOKEN="${TG_TOKEN:-}" TG_CHAT="${TG_CHAT:-}" +OBF_PORTS="${OBF_PORTS:-2083,5443,993}" +PCA_BRANCH="${PCA_BRANCH:-main}" +PHOBOS_DIR="/opt/Phobos" PANEL_DIR="/opt/phobos-panel" +RAW="https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}" + +[ "$EUID" -eq 0 ] || { echo "Run as root"; exit 1; } -# Generate random 5-digit port (10000-59999) if not specified if [ -z "$PANEL_PORT" ]; then PANEL_PORT=$(shuf -i 10000-59999 -n 1 2>/dev/null || awk 'BEGIN{srand(); print int(10000+rand()*50000)}') fi +API_KEY="${API_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24)}" -# ── Check Phobos is installed ── -if [ ! -d "/opt/Phobos" ]; then - echo "ERROR: Phobos not found at /opt/Phobos" - echo "Install Phobos first: https://git.zerrolabs.org/Ground-Zerro/Phobos" - exit 1 -fi +SERVER_IP=$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}') +IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i/dev/null +systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true -# ── 2. Install panel ── -echo "[2/3] Installing web panel..." +# ── 2. wg-obfuscator binary (Ground-Zerro) ── +echo "[2/9] wg-obfuscator..." +mkdir -p "$PHOBOS_DIR"/{server,clients,bin,tokens,www/init,www/packages,packages} +if [ ! -x /usr/local/bin/wg-obfuscator ]; then + R=/tmp/phobos-obf; rm -rf "$R"; mkdir -p "$R"; cd "$R" + git init -q; git remote add origin https://github.com/Ground-Zerro/Phobos.git + git config core.sparseCheckout true; echo "wg-obfuscator" > .git/info/sparse-checkout + git pull origin main -q + cp -f "wg-obfuscator/bin/wg-obfuscator-${ARCH}" "$PHOBOS_DIR/bin/" 2>/dev/null || true + chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-* 2>/dev/null || true + ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${ARCH}" /usr/local/bin/wg-obfuscator + cd /; rm -rf "$R" +fi +[ -x /usr/local/bin/wg-obfuscator ] || { echo "ERROR: obfuscator binary for $ARCH missing"; exit 1; } + +# ── 3. Phobos repo (onboarding scripts) ── +echo "[3/9] Phobos repo (onboarding scripts)..." +R="$PHOBOS_DIR/repo"; rm -rf "$R"; mkdir -p "$R"; cd "$R" +git init -q; git remote add origin https://github.com/Ground-Zerro/Phobos.git +git config core.sparseCheckout true +printf 'server\nclient\n' > .git/info/sparse-checkout +git pull origin main -q; rm -rf .git +find "$R" -name '*.sh' -exec chmod +x {} \; 2>/dev/null || true +cd / + +# ── 4. WireGuard wg0 (primary) ── +echo "[4/9] WireGuard wg0..." +if [ ! -f /etc/wireguard/wg0.conf ]; then + WG_PRIV=$(wg genkey); WG_PUB=$(echo "$WG_PRIV" | wg pubkey) + cat > /etc/wireguard/wg0.conf <> /etc/sysctl.conf +systemctl enable wg-quick@wg0 -q 2>/dev/null || true +systemctl restart wg-quick@wg0 + +# ── 5. obfuscator services (multi-port) ── +echo "[5/9] obfuscator services..." +OBF_KEY=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 32) +iptables -C INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null \ + || iptables -A INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP +IFS=',' read -ra PORTS <<< "$OBF_PORTS" +for PORT in "${PORTS[@]}"; do + cat > "$PHOBOS_DIR/server/wg-obfuscator-${PORT}.conf" < /etc/systemd/system/wg-obfuscator-${PORT}.service < "$PHOBOS_DIR/server/server.env" < "$PHOBOS_DIR/tokens/tokens.json" + +# ── 7. web panel ── +echo "[7/9] web panel..." mkdir -p "$PANEL_DIR" - -SERVER_IP=$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}') - -curl -fsSL "https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/app.py" \ - | sed "s|SERVER_IP = .*|SERVER_IP = \"$SERVER_IP\"|g" \ - > "$PANEL_DIR/app.py" - -# Create initial settings +fetch app.py "$PANEL_DIR/app.py" || { echo "ERROR: panel app.py fetch failed"; exit 1; } if [ ! -f "$PANEL_DIR/settings.json" ]; then cat > "$PANEL_DIR/settings.json" < "$PANEL_DIR/.port" - -# ── 3. Setup systemd service ── -echo "[3/3] Setting up service..." - cat > /etc/systemd/system/phobos-panel.service </dev/null || true +cat > /etc/nginx/sites-available/phobos <<'NGINX' +server { + listen 80 default_server; + listen [::]:80 default_server; + location /init/ { alias /opt/Phobos/www/init/; default_type application/x-sh; } + location /packages/ { alias /opt/Phobos/www/packages/; default_type application/octet-stream; } + location / { return 404; } +} +NGINX +ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos +chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages +nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed" + +# ── 9. router watchdog (auto reboot-recovery) ── +echo "[9/9] router watchdog..." +if [ -f "$PHOBOS_DIR/server/phobos-router-watchdog.py" ]; then + ( crontab -l 2>/dev/null | grep -v phobos-router-watchdog; \ + echo "*/3 * * * * /usr/bin/python3 $PHOBOS_DIR/server/phobos-router-watchdog.py >/dev/null 2>&1" ) | crontab - +fi -systemctl daemon-reload -systemctl enable phobos-panel -q -systemctl restart phobos-panel sleep 2 -systemctl is-active --quiet phobos-panel && echo " Panel running." || { echo "ERROR: panel failed!"; journalctl -u phobos-panel -n 20; exit 1; } - -echo "" -echo "╔══════════════════════════════════════════════════════╗" -echo "║ Installation Complete! ║" -echo "╠══════════════════════════════════════════════════════╣" -echo "║ Web Panel : http://$SERVER_IP:$PANEL_PORT" -echo "║ Admin login : admin" -echo "║ Admin pass : $PANEL_PASS" -echo "║ ║" -echo "║ ⚠ Запомните порт: $PANEL_PORT ║" -echo "╚══════════════════════════════════════════════════════╝" echo "" +echo "============================================" +echo " Installation complete" +echo " Panel : http://$SERVER_IP:$PANEL_PORT" +echo " Login : admin" +echo " Pass : $PANEL_PASS" +echo " API key (agents+pull): $API_KEY" +echo " WG pub: $WG_PUB" +echo "============================================" +echo "Status:" +for s in wg-quick@wg0 phobos-panel nginx; do printf " %-18s %s\n" "$s" "$(systemctl is-active $s 2>/dev/null)"; done +for PORT in "${PORTS[@]}"; do printf " %-18s %s\n" "wg-obfuscator-$PORT" "$(systemctl is-active wg-obfuscator-$PORT 2>/dev/null)"; done diff --git a/overlay/install-router.sh.template b/overlay/install-router.sh.template new file mode 100644 index 0000000..e9b3ea5 --- /dev/null +++ b/overlay/install-router.sh.template @@ -0,0 +1,669 @@ +#!/bin/sh +set -e + +CLIENT_NAME="{{CLIENT_NAME}}" +PHOBOS_DIR="" +ROUTER_PLATFORM="" + +. "$(dirname "$0")/lib-client.sh" +. "$(dirname "$0")/install-obfuscator.sh" +. "$(dirname "$0")/install-wireguard.sh" + +detect_3xui_mode() { + local platform="$1" + + if [ "$platform" = "linux" ] && [ -f /etc/x-ui/x-ui.db ]; then + echo "true" + else + echo "false" + fi +} + +check_dependencies() { + local platform="$1" + local missing="" + local base_deps="grep cut date tee tar curl jq" + local platform_deps="" + + if [ "$platform" = "openwrt" ]; then + platform_deps="uci" + fi + + for cmd in $base_deps $platform_deps; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing="$missing $cmd" + fi + done + + if [ -n "$missing" ]; then + log "ВНИМАНИЕ: Отсутствуют необходимые утилиты:$missing" + + if [ "$platform" = "linux" ]; then + log "Устанавливаю недостающие пакеты через apt-get..." + + if command -v apt-get >/dev/null 2>&1; then + if ! apt-get update; then + log "ОШИБКА: Не удалось обновить список пакетов apt-get" + return 1 + fi + + for cmd in $missing; do + log "Установка $cmd..." + if ! apt-get install -y "$cmd" 2>/dev/null; then + log "ПРЕДУПРЕЖДЕНИЕ: Не удалось установить $cmd через apt-get" + fi + done + else + log "ОШИБКА: apt-get не найден. Невозможно установить зависимости." + return 1 + fi + else + log "Устанавливаю недостающие пакеты через opkg..." + + if command -v opkg >/dev/null 2>&1; then + if ! opkg update; then + log "ОШИБКА: Не удалось обновить список пакетов opkg" + return 1 + fi + + for cmd in $missing; do + log "Установка $cmd..." + if ! opkg install "$cmd" 2>/dev/null; then + log "ПРЕДУПРЕЖДЕНИЕ: Не удалось установить $cmd через opkg" + fi + done + else + log "ОШИБКА: opkg не найден. Невозможно установить зависимости." + return 1 + fi + fi + fi + + return 0 +} + +setup_configs() { + log "Настройка конфигураций..." + + mkdir -p "$PHOBOS_DIR" + + cp wg-obfuscator.conf "$PHOBOS_DIR/${OBF_CONF_NAME}" + chmod 600 "$PHOBOS_DIR/${OBF_CONF_NAME}" + + cp "${CLIENT_NAME}.conf" "$PHOBOS_DIR/${CLIENT_NAME}.conf" + chmod 600 "$PHOBOS_DIR/${CLIENT_NAME}.conf" + + printf '%s' "${CLIENT_NAME}" > "$PHOBOS_DIR/${OBF_CONF_NAME%.conf}.link" + + if [ "$OBF_BINARY_NAME" != "wg-obfuscator" ]; then + local used_ports="" + for existing_conf in "$PHOBOS_DIR"/wg-obfuscator*.conf; do + [ -f "$existing_conf" ] || continue + [ "$(basename "$existing_conf")" = "${OBF_CONF_NAME}" ] && continue + local port + port=$(grep 'source-lport' "$existing_conf" 2>/dev/null | tr -d ' ' | cut -d'=' -f2) + [ -n "$port" ] && used_ports="${used_ports} ${port}" + done + + local new_port=13255 + local port_taken=1 + while [ "$port_taken" -eq 1 ]; do + port_taken=0 + for p in $used_ports; do + if [ "$p" = "$new_port" ]; then + port_taken=1 + new_port=$((new_port + 1)) + break + fi + done + done + + sed -i "s/^source-lport = [0-9]*/source-lport = ${new_port}/" "$PHOBOS_DIR/${OBF_CONF_NAME}" + sed -i "s/^Endpoint = 127\.0\.0\.1:[0-9]*/Endpoint = 127.0.0.1:${new_port}/" "$PHOBOS_DIR/${CLIENT_NAME}.conf" + + log " Назначен локальный порт obfuscator: $new_port" + fi + + log "Конфигурации установлены:" + log " - Obfuscator: $PHOBOS_DIR/${OBF_CONF_NAME}" + log " - WireGuard: $PHOBOS_DIR/${CLIENT_NAME}.conf" +} + +deploy_lib_client() { + if [ -f "lib-client.sh" ]; then + cp "lib-client.sh" "$PHOBOS_DIR/lib-client.sh" + fi +} + + +deploy_uninstall_script() { + log "Развертывание скрипта удаления Phobos..." + + if [ -f "phobos-uninstall.sh" ]; then + cp "phobos-uninstall.sh" "$PHOBOS_DIR/phobos-uninstall.sh" + chmod +x "$PHOBOS_DIR/phobos-uninstall.sh" + log "Скрипт удаления установлен: $PHOBOS_DIR/phobos-uninstall.sh" + else + log "ПРЕДУПРЕЖДЕНИЕ: phobos-uninstall.sh не найден в архиве" + fi +} + +deploy_3xui_script() { + log "Развертывание скрипта 3xui.sh..." + + if [ -f "3xui.sh" ]; then + cp "3xui.sh" "$PHOBOS_DIR/3xui.sh" + chmod +x "$PHOBOS_DIR/3xui.sh" + log "Скрипт 3xui.sh установлен: $PHOBOS_DIR/3xui.sh" + else + log "ПРЕДУПРЕЖДЕНИЕ: 3xui.sh не найден в архиве" + fi +} + +run_3xui_integration() { + log "" + log "==> Интеграция WireGuard конфигурации в 3x-ui..." + + if [ ! -f "$PHOBOS_DIR/3xui.sh" ]; then + log "ОШИБКА: Скрипт $PHOBOS_DIR/3xui.sh не найден" + return 1 + fi + + for cmd in jq sqlite3; do + if ! command -v "$cmd" >/dev/null 2>&1; then + log "Устанавливаю $cmd..." + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq "$cmd" >/dev/null 2>&1 + elif command -v yum >/dev/null 2>&1; then + yum install -y -q "${cmd/sqlite3/sqlite}" >/dev/null 2>&1 + elif command -v dnf >/dev/null 2>&1; then + dnf install -y -q "${cmd/sqlite3/sqlite}" >/dev/null 2>&1 + elif command -v apk >/dev/null 2>&1; then + apk add --quiet "${cmd/sqlite3/sqlite}" >/dev/null 2>&1 + fi + if ! command -v "$cmd" >/dev/null 2>&1; then + log "ОШИБКА: не удалось установить $cmd" + return 1 + fi + fi + done + + local wg_config="$PHOBOS_DIR/${CLIENT_NAME}.conf" + + if [ ! -f "$wg_config" ]; then + log "ОШИБКА: Конфигурация WireGuard не найдена: $wg_config" + return 1 + fi + + log "Запуск интеграции: $PHOBOS_DIR/3xui.sh $wg_config" + + if "$PHOBOS_DIR/3xui.sh" "$wg_config"; then + log "[OK] WireGuard конфигурация успешно интегрирована в 3x-ui" + log "[OK] Outbound 'Phobos' добавлен в конфигурацию 3x-ui" + return 0 + else + log "ОШИБКА: Не удалось интегрировать конфигурацию в 3x-ui" + return 1 + fi +} + +cleanup_3xui_script() { + local keep_script="$1" + + if [ "$keep_script" = "true" ]; then + log "Скрипт 3xui.sh сохранен для использования в 3x-ui режиме" + else + if [ -f "$PHOBOS_DIR/3xui.sh" ]; then + rm -f "$PHOBOS_DIR/3xui.sh" + log "Скрипт 3xui.sh удален (не требуется в обычном режиме)" + fi + fi +} + +install_wireguard_openwrt() { + log "Установка пакетов WireGuard для OpenWRT..." + + if ! command -v wg >/dev/null 2>&1; then + log "Установка kmod-wireguard и wireguard-tools..." + opkg update + opkg install kmod-wireguard wireguard-tools luci-proto-wireguard + else + log "WireGuard уже установлен" + fi + + log "Установка luci-app-wireguard для веб-интерфейса..." + opkg install luci-app-wireguard >/dev/null 2>&1 || log "ПРЕДУПРЕЖДЕНИЕ: luci-app-wireguard не удалось установить" + + log "✓ Пакеты WireGuard установлены" +} + +configure_wireguard_openwrt() { + log "" + log "==> Автоматическая настройка WireGuard через UCI..." + + extract_wireguard_params + + if [ ! -f "./router-configure-wireguard-openwrt.sh" ]; then + log "⚠ Скрипт router-configure-wireguard-openwrt.sh не найден" + log " Используйте ручную настройку через LuCI или UCI" + return 1 + fi + + chmod +x ./router-configure-wireguard-openwrt.sh + + if ./router-configure-wireguard-openwrt.sh \ + --client-name "$CLIENT_NAME" \ + --client-private-key "$WG_PRIVATE_KEY" \ + --client-ip "$CLIENT_IP" \ + --client-ipv6 "$CLIENT_IPV6" \ + --server-public-key "$WG_SERVER_PUBKEY" \ + --endpoint-port "$WG_ENDPOINT_PORT" \ + --keepalive 25 \ + --mtu 1420 \ + --fallback-config "$PHOBOS_DIR/${CLIENT_NAME}.conf"; then + + return 0 + else + return 1 + fi +} + +extract_wireguard_params() { + log "Извлечение параметров WireGuard из конфигурации..." + + local config_file="$PHOBOS_DIR/${CLIENT_NAME}.conf" + + WG_PRIVATE_KEY=$(grep '^PrivateKey' "$config_file" | cut -d'=' -f2- | tr -d ' \t\n\r') + WG_ADDRESS=$(grep '^Address' "$config_file" | cut -d'=' -f2- | tr -d ' \t\n\r') + WG_SERVER_PUBKEY=$(grep '^PublicKey' "$config_file" | cut -d'=' -f2- | tr -d ' \t\n\r') + WG_ENDPOINT_PORT=$(grep '^Endpoint' "$config_file" | cut -d':' -f2 | tr -d ' \t\n\r') + + CLIENT_IP=$(echo "$WG_ADDRESS" | cut -d',' -f1 | tr -d ' ') + CLIENT_IPV6=$(echo "$WG_ADDRESS" | cut -d',' -f2 | tr -d ' ') + + if [ -z "$CLIENT_IPV6" ] || [ "$CLIENT_IPV6" = "$CLIENT_IP" ]; then + CLIENT_IPV6=$(grep -A 10 '\[Interface\]' "$config_file" | grep '^Address' | grep -o 'fd[0-9a-f:\/]*' | head -1) + fi + + if [ -z "$CLIENT_IPV6" ]; then + CLIENT_IPV6="none" + fi + + if [ -z "$WG_ENDPOINT_PORT" ]; then + WG_ENDPOINT_PORT=13255 + fi + + log " Private Key: ${WG_PRIVATE_KEY:0:20}..." + log " IPv4: $CLIENT_IP" + log " IPv6: $CLIENT_IPV6" + log " Server PubKey: ${WG_SERVER_PUBKEY:0:20}..." + log " Endpoint port: $WG_ENDPOINT_PORT" +} + +configure_wireguard_rci() { + log "" + log "==> Автоматическая настройка WireGuard через RCI API..." + + extract_wireguard_params + + if [ ! -f "./router-configure-wireguard.sh" ]; then + log "⚠ Скрипт router-configure-wireguard.sh не найден" + log " Используйте ручной импорт (см. инструкции ниже)" + return 1 + fi + + chmod +x ./router-configure-wireguard.sh + + if ./router-configure-wireguard.sh \ + --client-name "$CLIENT_NAME" \ + --client-private-key "$WG_PRIVATE_KEY" \ + --client-ip "$CLIENT_IP" \ + --client-ipv6 "$CLIENT_IPV6" \ + --server-public-key "$WG_SERVER_PUBKEY" \ + --endpoint-port "$WG_ENDPOINT_PORT" \ + --keepalive 25 \ + --mtu 1420 \ + --fallback-config "$PHOBOS_DIR/${CLIENT_NAME}.conf"; then + + return 0 + else + return 1 + fi +} + +show_manual_instructions() { + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ Требуется ручной импорт WireGuard конфигурации ║" + log "╚════════════════════════════════════════════════════════════╝" + log "" + log "Выполните следующие шаги:" + log "" + + if [ "$ROUTER_PLATFORM" = "keenetic" ]; then + log "1. Откройте веб-панель администрирования роутера Keenetic" + log " (http://192.168.1.1 или http://my.keenetic.net)" + log "" + log "2. Перейдите в раздел: Интернет → Другие подключения" + log "" + log "3. Выберите 'Загрузить конфигурацию из файла' в разделе 'WireGuard'" + log "" + log "4. Укажите путь к файлу:" + log " $PHOBOS_DIR/${CLIENT_NAME}.conf" + log "" + log "5. Активируйте подключение" + elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then + log "1. Откройте веб-интерфейс LuCI роутера OpenWRT" + log " (обычно http://192.168.1.1)" + log "" + log "2. Перейдите в: Network → Interfaces" + log "" + log "3. Создайте новый интерфейс с протоколом WireGuard" + log "" + log "4. Используйте параметры из файла:" + log " $PHOBOS_DIR/${CLIENT_NAME}.conf" + log "" + log "5. Настройте файрволл зону для интерфейса" + fi + + log "" +} + +show_final_info() { + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ Информация об установке ║" + log "╚════════════════════════════════════════════════════════════╝" + log "" + log "Файлы установки:" + + if [ "$ROUTER_PLATFORM" = "keenetic" ]; then + log " Бинарник: /opt/bin/${OBF_BINARY_NAME}" + elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then + log " Бинарник: /usr/bin/${OBF_BINARY_NAME}" + elif [ "$ROUTER_PLATFORM" = "linux" ]; then + log " Бинарник: /usr/local/bin/${OBF_BINARY_NAME}" + fi + + log " Конфиг obfuscator: $PHOBOS_DIR/${OBF_CONF_NAME}" + log " Конфиг WireGuard: $PHOBOS_DIR/${CLIENT_NAME}.conf" + + if [ "$ROUTER_PLATFORM" = "keenetic" ] || [ -f /opt/etc/init.d/${OBF_INIT_NAME} ]; then + log " Init-скрипт: /opt/etc/init.d/${OBF_INIT_NAME}" + elif [ "$ROUTER_PLATFORM" = "openwrt" ] && [ -f /etc/init.d/${OBF_SERVICE_NAME} ]; then + log " Init-скрипт: /etc/init.d/${OBF_SERVICE_NAME}" + elif [ "$ROUTER_PLATFORM" = "linux" ]; then + log " Systemd service: /etc/systemd/system/${OBF_SERVICE_NAME}.service" + log " WireGuard конфиг: /etc/wireguard/${OBF_WG_IFACE}.conf" + fi + + log " Uninstall: $PHOBOS_DIR/phobos-uninstall.sh" + log " Health monitor: $PHOBOS_DIR/phobos-health.sh" + log " Failover config: $PHOBOS_DIR/failover.conf" + log "" + log "Управление:" + + if [ "$ROUTER_PLATFORM" = "keenetic" ] || [ -f /opt/etc/init.d/${OBF_INIT_NAME} ]; then + log " /opt/etc/init.d/${OBF_INIT_NAME} status # Проверить что obfuscator запущен" + elif [ "$ROUTER_PLATFORM" = "openwrt" ] && [ -f /etc/init.d/${OBF_SERVICE_NAME} ]; then + log " /etc/init.d/${OBF_SERVICE_NAME} status # Проверить что obfuscator запущен" + elif [ "$ROUTER_PLATFORM" = "linux" ]; then + log " systemctl status ${OBF_SERVICE_NAME} # Проверить что obfuscator запущен" + log " systemctl status wg-quick@${OBF_WG_IFACE} # Проверить что WireGuard запущен" + fi + + log " $PHOBOS_DIR/phobos-uninstall.sh # Удалить Phobos" + log "" +} + + +deploy_health_monitor() { + log "Развертывание монитора здоровья Phobos..." + + if [ ! -f "phobos-health.sh" ]; then + log "ПРЕДУПРЕЖДЕНИЕ: phobos-health.sh не найден в архиве" + return 0 + fi + + cp "phobos-health.sh" "$PHOBOS_DIR/phobos-health.sh" + chmod +x "$PHOBOS_DIR/phobos-health.sh" + + if [ -f "failover.conf" ]; then + cp "failover.conf" "$PHOBOS_DIR/failover.conf" + chmod 600 "$PHOBOS_DIR/failover.conf" + fi + + mkdir -p "$PHOBOS_DIR/state" + + # --- Phobos pull agent: panel -> router config sync (NAT-friendly) --- + # Routers behind NAT cannot be reached by the panel over SSH, so they PULL + # their failover.conf over HTTP. Ships client_id + pull_token from the package. + if [ -f "phobos-pull.sh" ]; then + cp "phobos-pull.sh" "$PHOBOS_DIR/phobos-pull.sh" + chmod +x "$PHOBOS_DIR/phobos-pull.sh" + echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id" + [ -f "pull_token" ] && cp "pull_token" "$PHOBOS_DIR/pull_token" + log " Pull-агент установлен (client_id={{CLIENT_NAME}})" + fi + + local cron_line="*/1 * * * * $PHOBOS_DIR/phobos-health.sh" + local pull_line="*/1 * * * * $PHOBOS_DIR/phobos-pull.sh" + [ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line="" + + if [ "$ROUTER_PLATFORM" = "keenetic" ]; then + # Install cron if not present (Entware) + if ! command -v crontab >/dev/null 2>&1 && command -v opkg >/dev/null 2>&1; then + log " Установка cron через opkg..." + opkg update >/dev/null 2>&1 + opkg install cron >/dev/null 2>&1 + /opt/etc/init.d/S10cron start >/dev/null 2>&1 || true + fi + local cron_file="/opt/etc/crontab" + # Create crontab file if missing + if [ ! -f "$cron_file" ]; then + mkdir -p /opt/etc + touch "$cron_file" + fi + if ! grep -q "phobos-health" "$cron_file" 2>/dev/null; then + echo "$cron_line" >> "$cron_file" + log " Cron (health) добавлен в $cron_file" + fi + if [ -n "$pull_line" ] && ! grep -q "phobos-pull" "$cron_file" 2>/dev/null; then + echo "$pull_line" >> "$cron_file" + log " Cron (pull) добавлен в $cron_file" + fi + /opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true + elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then + (crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab - + log " Cron добавлен через crontab (health + pull)" + elif [ "$ROUTER_PLATFORM" = "linux" ]; then + { echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line"; } > /etc/cron.d/phobos-health + chmod 644 /etc/cron.d/phobos-health + log " Cron добавлен в /etc/cron.d/phobos-health (health + pull)" + fi + + log " Монитор здоровья: $PHOBOS_DIR/phobos-health.sh" + log " Failover конфиг: $PHOBOS_DIR/failover.conf" + log " Проверка каждую минуту через cron" +} + +main() { + ROUTER_PLATFORM=$(detect_router_platform) + PHOBOS_DIR=$(detect_phobos_dir "$ROUTER_PLATFORM") + IS_3XUI_MODE=$(detect_3xui_mode "$ROUTER_PLATFORM") + + log "==> Определена платформа: $ROUTER_PLATFORM" + log "==> Директория Phobos: $PHOBOS_DIR" + + resolve_install_names + log "==> Режим установки obfuscator: binary=${OBF_BINARY_NAME}, conf=${OBF_CONF_NAME}, init=${OBF_INIT_NAME}, service=${OBF_SERVICE_NAME}" + + if [ "$IS_3XUI_MODE" = "true" ]; then + log "==> Обнаружен режим установки: 3x-ui" + log "==> В этом режиме WireGuard не устанавливается" + log "==> Будет развернут только wg-obfuscator и интеграция с 3x-ui" + fi + + if [ "$ROUTER_PLATFORM" = "unknown" ]; then + log "ОШИБКА: Неподдерживаемая платформа" + log "Вывод uname -a: $(uname -a)" + log "" + log "Поддерживаемые платформы:" + log " - Keenetic/Netcraze (определяется по 'Keenetic' или 'Netcraze' в uname)" + log " - OpenWRT (определяется по 'OpenWrt', 'LEDE' или 'ImmortalWrt' в uname)" + log " - Linux (Ubuntu/Debian)" + exit 1 + fi + + mkdir -p "$PHOBOS_DIR" + log "==> Начало установки Phobos на роутер $ROUTER_PLATFORM" + log "==> Клиент: $CLIENT_NAME" + + check_root + + if ! check_dependencies "$ROUTER_PLATFORM"; then + log "ОШИБКА: Не удалось установить зависимости" + exit 1 + fi + + ARCH=$(detect_arch) + log "Определена архитектура: $ARCH" + + if [ "$ARCH" = "unknown" ]; then + log "Ошибка: неподдерживаемая архитектура" + log "Вывод uname -m: $(uname -m)" + exit 1 + fi + + install_obfuscator "$ARCH" + setup_configs + deploy_lib_client + deploy_uninstall_script + deploy_health_monitor + + if [ "$IS_3XUI_MODE" = "true" ]; then + deploy_3xui_script + fi + + if [ "$ROUTER_PLATFORM" = "keenetic" ]; then + create_init_script + start_obfuscator + + if configure_wireguard_rci; then + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ ✓ Установка завершена успешно! ║" + log "║ ✓ WireGuard настроен автоматически через RCI API ║" + log "╚════════════════════════════════════════════════════════════╝" + show_final_info + else + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ ✓ Obfuscator установлен успешно ║" + log "║ ⚠ WireGuard требует ручной настройки ║" + log "╚════════════════════════════════════════════════════════════╝" + show_manual_instructions + show_final_info + fi + + elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then + install_wireguard_openwrt + + if [ -d "/opt/etc" ]; then + create_init_script + else + create_procd_init_script + fi + + start_obfuscator + + if configure_wireguard_openwrt; then + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ ✓ Установка завершена успешно! ║" + log "║ ✓ WireGuard настроен автоматически через UCI ║" + log "╚════════════════════════════════════════════════════════════╝" + show_final_info + else + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ ✓ Obfuscator установлен успешно ║" + log "║ ⚠ WireGuard требует ручной настройки ║" + log "╚════════════════════════════════════════════════════════════╝" + show_manual_instructions + show_final_info + fi + + elif [ "$ROUTER_PLATFORM" = "linux" ]; then + if [ "$IS_3XUI_MODE" = "true" ]; then + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ Режим установки: 3x-ui ║" + log "╚════════════════════════════════════════════════════════════╝" + log "" + + create_systemd_obfuscator_service + + if run_3xui_integration; then + cleanup_3xui_script "true" + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ ✓ Установка в режиме 3x-ui завершена успешно! ║" + log "║ ✓ Obfuscator настроен через systemd ║" + log "║ ✓ Outbound 'Phobos' добавлен в конфигурацию 3x-ui ║" + log "╚════════════════════════════════════════════════════════════╝" + log "" + log "Файлы установки:" + log " Бинарник: /usr/local/bin/wg-obfuscator" + log " Конфиг obfuscator: $PHOBOS_DIR/wg-obfuscator.conf" + log " Конфиг WireGuard: $PHOBOS_DIR/${CLIENT_NAME}.conf" + log " Скрипт 3xui.sh: $PHOBOS_DIR/3xui.sh" + log " Systemd service: /etc/systemd/system/phobos-obfuscator.service" + log "" + log "Управление:" + log " systemctl status phobos-obfuscator # Проверить что obfuscator запущен" + log "" + log "Примечание:" + log " WireGuard управляется через 3x-ui панель" + log " Скрипт 3xui.sh сохранен для повторной интеграции при необходимости" + log "" + else + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ ✗ Установка в режиме 3x-ui завершена с ошибками ║" + log "╚════════════════════════════════════════════════════════════╝" + exit 1 + fi + else + install_wireguard_linux || { + log "ОШИБКА: Не удалось установить WireGuard" + exit 1 + } + + create_systemd_obfuscator_service + + if configure_wireguard_linux; then + configure_ufw_linux + cleanup_3xui_script "false" + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ ✓ Установка завершена успешно! ║" + log "║ ✓ WireGuard и Obfuscator настроены через systemd ║" + log "╚════════════════════════════════════════════════════════════╝" + show_final_info + else + cleanup_3xui_script "false" + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ ⚠ Установка завершена с предупреждениями ║" + log "║ ⚠ Проверьте статус служб вручную ║" + log "╚════════════════════════════════════════════════════════════╝" + show_final_info + fi + fi + fi + + log "==> Установка завершена." +} + +main "$@" diff --git a/overlay/phobos-client.sh b/overlay/phobos-client.sh new file mode 100755 index 0000000..e611296 --- /dev/null +++ b/overlay/phobos-client.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash + +source "$(dirname "${BASH_SOURCE[0]}")/lib-core.sh" + +check_root +load_env +ensure_dirs + +CMD="${1:-help}" +CLIENT_ARG="${2:-}" +EXTRA_ARG="${3:-}" + +resolve_client() { + local name="$1" + local id=$(echo "$name" | tr ' ' '-' | tr '[:upper:]' '[:lower:]') + + if [[ -d "$CLIENTS_DIR/$id" ]]; then + echo "$id" + return 0 + fi + return 1 +} + +action_add() { + local name="$CLIENT_ARG" + local manual_ip="$EXTRA_ARG" + + if [[ -z "$name" ]]; then die "Использование: $0 add [ip]"; fi + + local id=$(echo "$name" | tr ' ' '-' | tr '[:upper:]' '[:lower:]') + local dir="$CLIENTS_DIR/$id" + + if [[ -d "$dir" ]]; then die "Клиент $id уже существует."; fi + + if [[ -z "$SERVER_WG_PUBLIC_KEY" ]]; then + die "Публичный ключ сервера не найден в server.env. Запустите установку." + fi + + local server_pub="$SERVER_WG_PUBLIC_KEY" + local server_ip_v4="${SERVER_PUBLIC_IP_V4:-}" + local server_ip_v6="${SERVER_PUBLIC_IP_V6:-}" + + local client_ip_v4="$manual_ip" + local ipv4_prefix_main=$(echo "${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}" | cut -d'/' -f1 | cut -d'.' -f1-2) + local ipv6_prefix_main=$(echo "${SERVER_WG_IPV6_NETWORK:-fd00:10:25::/48}" | cut -d'/' -f1 | sed 's/::.*//') + + if [[ -z "$client_ip_v4" ]]; then + log_info "Поиск свободного IP..." + declare -A used_ips + + for d in "$CLIENTS_DIR"/*; do + if [[ -d "$d" ]] && [[ -f "$d/metadata.json" ]]; then + local ip=$(jq -r '.tunnel_ip_v4 // empty' "$d/metadata.json" 2>/dev/null) + [[ -n "$ip" ]] && used_ips["$ip"]=1 + fi + done + + used_ips["${ipv4_prefix_main}.0.1"]=1 + + local found=false + for oct3 in {0..255}; do + local start_oct4=2 + for oct4 in $(seq $start_oct4 254); do + local candidate="${ipv4_prefix_main}.${oct3}.${oct4}" + if [[ -z "${used_ips[$candidate]:-}" ]]; then + client_ip_v4="$candidate" + found=true + break 2 + fi + done + done + + [[ "$found" == "false" ]] && die "Нет свободных IP в подсети." + fi + + local oct3=$(echo "$client_ip_v4" | cut -d. -f3) + local oct4=$(echo "$client_ip_v4" | cut -d. -f4) + local hex_part=$(printf "%x:%x" "$oct3" "$oct4") + local client_ip_v6="" + [[ -n "$server_ip_v6" ]] && client_ip_v6="${ipv6_prefix_main}::${hex_part}" + + log_info "Назначен IP: $client_ip_v4 $([[ -n $client_ip_v6 ]] && echo "/ $client_ip_v6")" + + mkdir -p "$dir" + umask 077 + wg genkey > "$dir/client_private.key" + wg pubkey < "$dir/client_private.key" > "$dir/client_public.key" + local priv_key=$(cat "$dir/client_private.key") + local pub_key=$(cat "$dir/client_public.key") + + local allowed_ips="0.0.0.0/0" + local addr_str="$client_ip_v4/32" + if [[ -n "$client_ip_v6" ]]; then + allowed_ips="0.0.0.0/0, ::/0" + addr_str="$client_ip_v4/32, $client_ip_v6/128" + fi + + cat > "$dir/${id}.conf" < "$dir/wg-obfuscator.conf" < "$dir/metadata.json" <> "$WG_CONFIG" </dev/null) 2>/dev/null + log_success "Клиент $name создан." + + CLIENT_ARG="$id" + action_package + action_link +} + +action_remove() { + local id=$(resolve_client "$CLIENT_ARG") + if [[ -z "$id" ]]; then die "Клиент не найден."; fi + local dir="$CLIENTS_DIR/$id" + + log_info "Удаление клиента $id..." + + if [[ -f "$dir/client_public.key" ]]; then + local pub=$(cat "$dir/client_public.key") + if grep -qF "$pub" "$WG_CONFIG"; then + awk -v key="$pub" ' + BEGIN {RS=""; ORS="\n\n"} + index($0, key) == 0 {print $0} + ' "$WG_CONFIG" > "$WG_CONFIG.tmp" && mv "$WG_CONFIG.tmp" "$WG_CONFIG" + + sed -i '/^$/N;/^\n$/D' "$WG_CONFIG" + + wg syncconf wg0 <(wg-quick strip wg0 2>/dev/null) 2>/dev/null + log_success "Peer удален из конфигурации." + fi + fi + + rm -rf "$dir" + rm -f "$PACKAGES_DIR/phobos-$id.tar.gz" + + if [[ -f "$TOKENS_FILE" ]] && command -v jq >/dev/null; then + local tokens=$(jq -r ".[] | select(.client == \"$id\") | .token" "$TOKENS_FILE") + for t in $tokens; do + rm -f "$WWW_DIR/init/$t.sh" + rm -rf "$WWW_DIR/packages/$t" + done + jq "map(select(.client != \"$id\"))" "$TOKENS_FILE" > "$TOKENS_FILE.tmp" && mv "$TOKENS_FILE.tmp" "$TOKENS_FILE" + fi + + log_success "Клиент $id полностью удален." +} + +action_package() { + local id=$(resolve_client "$CLIENT_ARG") + if [[ -z "$id" ]]; then die "Клиент не найден."; fi + + log_info "Сборка пакета для $id..." + local dir="$CLIENTS_DIR/$id" + local tmp=$(mktemp -d) + local pkg_root="$tmp/phobos-$id" + + mkdir -p "$pkg_root/bin" + + cp "$dir/${id}.conf" "$pkg_root/${id}.conf" + cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf" + + for arch in mipsel mips aarch64 armv7 x86_64; do + [[ -f "$PHOBOS_DIR/bin/wg-obfuscator-$arch" ]] && cp "$PHOBOS_DIR/bin/wg-obfuscator-$arch" "$pkg_root/bin/" + done + + local tpl_dir="$REPO_DIR/client/templates" + + if [[ -d "$tpl_dir" ]]; then + cp "$tpl_dir/install-router.sh.template" "$pkg_root/install-router.sh" + sed -i "s|{{CLIENT_NAME}}|${id}|g" "$pkg_root/install-router.sh" + chmod +x "$pkg_root/install-router.sh" + [[ -f "$tpl_dir/lib-client.sh" ]] && cp "$tpl_dir/lib-client.sh" "$pkg_root/lib-client.sh" + [[ -f "$tpl_dir/install-obfuscator.sh" ]] && cp "$tpl_dir/install-obfuscator.sh" "$pkg_root/install-obfuscator.sh" + [[ -f "$tpl_dir/install-wireguard.sh" ]] && cp "$tpl_dir/install-wireguard.sh" "$pkg_root/install-wireguard.sh" + for f in router-configure-wireguard router-configure-wireguard-openwrt phobos-uninstall 3xui; do + [[ -f "$tpl_dir/$f.sh" ]] && cp "$tpl_dir/$f.sh" "$pkg_root/$f.sh" && chmod +x "$pkg_root/$f.sh" + done + else + log_warn "Шаблоны не найдены в $tpl_dir" + fi + + echo "Phobos Client Package for $id" > "$pkg_root/README.txt" + echo "Date: $(date)" >> "$pkg_root/README.txt" + + # Health monitor + failover + if [[ -f "$PHOBOS_DIR/server/phobos-health.sh" ]]; then + cp "$PHOBOS_DIR/server/phobos-health.sh" "$pkg_root/phobos-health.sh" + chmod +x "$pkg_root/phobos-health.sh" + fi + # Pull agent: panel -> router config sync (NAT-friendly). Token = panel + # server_api_key (the /api/router-config endpoint accepts it as a fallback). + for src in "$tpl_dir/phobos-pull.sh" "$PHOBOS_DIR/server/phobos-pull.sh"; do + if [[ -f "$src" ]]; then cp "$src" "$pkg_root/phobos-pull.sh"; chmod +x "$pkg_root/phobos-pull.sh"; break; fi + done + local PULL_TOKEN="" + command -v jq >/dev/null && PULL_TOKEN=$(jq -r '.server_api_key // empty' /opt/phobos-panel/settings.json 2>/dev/null) + [[ -n "$PULL_TOKEN" ]] && echo "$PULL_TOKEN" > "$pkg_root/pull_token" + # Generate failover.conf with current server data + source "$PHOBOS_DIR/server/server.env" + cat > "$pkg_root/failover.conf" < $SERVER_PUBLIC_IP_V4") + [[ "$client_obf_key" != "$OBFUSCATOR_KEY" ]] && changes+=("Ключ обфускатора: изменен") + [[ "$client_obf_port" != "$OBFUSCATOR_PORT" ]] && changes+=("Порт обфускатора: $client_obf_port -> $OBFUSCATOR_PORT") + [[ "$client_obf_dummy" != "$OBFUSCATOR_DUMMY" ]] && changes+=("Max dummy: изменен") + [[ "$client_obf_idle" != "$OBFUSCATOR_IDLE" ]] && changes+=("Idle таймаут: изменен") + [[ -n "$client_wg_pubkey" && "$client_wg_pubkey" != "$SERVER_WG_PUBLIC_KEY" ]] && changes+=("Публичный ключ WG: изменен") + + if [[ ${#changes[@]} -gt 0 ]]; then + echo "ИЗМЕНЕНИЯ КОНФИГУРАЦИИ:" + for c in "${changes[@]}"; do + echo " - $c" + done + return 1 + fi + + return 0 +} + +action_link() { + local id=$(resolve_client "$CLIENT_ARG") + if [[ -z "$id" ]]; then die "Клиент не найден."; fi + local ttl="${EXTRA_ARG:-$TOKEN_TTL}" + + if ! command -v jq >/dev/null; then die "jq не установлен. Установите: apt-get install jq"; fi + + local token=$(head -c 16 /dev/urandom | md5sum | cut -d' ' -f1) + local exp=$(($(date +%s) + ttl)) + + if [[ ! -f "$TOKENS_FILE" ]]; then + echo "[]" > "$TOKENS_FILE" + fi + + local clean_json=$(jq "map(select(.client != \"$id\"))" "$TOKENS_FILE") + echo "$clean_json" | jq ". + [{\"client\": \"$id\", \"token\": \"$token\", \"expires\": $exp}]" > "$TOKENS_FILE.tmp" && mv "$TOKENS_FILE.tmp" "$TOKENS_FILE" + + local link_dir="$WWW_DIR/packages/$token" + rm -rf "$link_dir" + mkdir -p "$link_dir" + 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" + + 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 +cd "\$dir" +tar xzf package.tar.gz +cd "phobos-$id" +chmod +x install-router.sh +./install-router.sh +EOF + + # nginx (www-data) must read these; the builder runs with a strict root umask + # (files 600 / dirs 700) which makes the install URL return 403. Fix perms. + chmod 755 "$WWW_DIR" "$WWW_DIR/init" "$WWW_DIR/packages" "$link_dir" 2>/dev/null + chmod 644 "$WWW_DIR/init/$token.sh" 2>/dev/null + 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" + + echo "" + echo "==================================================" + echo "КОМАНДА ДЛЯ УСТАНОВКИ (Действительна $(($ttl / 3600))ч)" + echo "==================================================" + echo "$cmd" + echo "==================================================" + echo "" +} + +action_list() { + printf "% -20s % -20s % -20s\n" "CLIENT ID" "IPv4" "CREATED" + echo "------------------------------------------------------------" + for d in "$CLIENTS_DIR"/*; do + if [[ -d "$d" ]]; then + local id=$(basename "$d") + local ip="N/A" + local date="N/A" + if [[ -f "$d/metadata.json" ]]; then + ip=$(jq -r '.tunnel_ip_v4 // "N/A"' "$d/metadata.json") + date=$(jq -r '.created_at // "N/A"' "$d/metadata.json" | cut -d'T' -f1) + fi + printf "% -20s % -20s % -20s\n" "$id" "$ip" "$date" + fi + done +} + +case "$CMD" in + add) action_add ;; + remove) action_remove ;; + package) action_package ;; + link) action_link ;; + check) action_check ;; + list) action_list ;; + rebuild) + action_remove + action_add + ;; + *) + echo "Usage: $0 {add|remove|list|package|link|check|rebuild}" + exit 1 + ;; +esac diff --git a/overlay/phobos-pull.sh b/overlay/phobos-pull.sh new file mode 100755 index 0000000..6e06167 --- /dev/null +++ b/overlay/phobos-pull.sh @@ -0,0 +1,93 @@ +#!/bin/sh +# ============================================================ +# Phobos Config Pull — NAT-friendly management channel. +# Runs ON the router via cron (every 2 min). OUTBOUND ONLY: +# fetches this client's failover.conf from the panel over +# HTTPS/HTTP and applies it. No inbound SSH needed, so it works +# behind any NAT and regardless of which Phobos server is active. +# +# Why pull (not panel->router SSH): +# - routers sit behind NAT with no public IP (KeenDNS is HTTP- +# only, no port 22), +# - WG3 must be security-level PUBLIC for LAN client routing, +# which blocks inbound SSH on the tunnel, +# - on failover the router's 10.25.0.2 moves to another server's +# wg0, so a fixed panel host cannot reach it. +# Pulling sidesteps all three. +# +# Files (written by installer / bootstrap): +# /opt/etc/Phobos/client_id -> this router's client id (e.g. home) +# /opt/etc/Phobos/pull_token -> shared secret for the panel endpoint +# PANEL env or default below -> panel base URL +# ============================================================ +PHOBOS_DIR="/opt/etc/Phobos" +CONF="$PHOBOS_DIR/failover.conf" +HEALTH="$PHOBOS_DIR/phobos-health.sh" +LOG="$PHOBOS_DIR/health.log" +# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 is the wg0 of whatever +# server the tunnel currently terminates on, so management rides the same +# obfuscated channel as data — survives a public-IP ban and follows failover. +# - 10.25.0.1:8444 -> secondary server agent (phobos-api) +# - 10.25.0.1:10514 -> primary server panel +# - public panel IP -> bootstrap / if tunnel is down +PANEL="${PANEL:-http://212.118.52.193:10514}" +PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}" +CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home") +TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null) +TMP="/tmp/failover.conf.pull" +LOCK="/tmp/phobos-pull.lock" + +log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; } + +# single instance +if [ -f "$LOCK" ]; then + pid=$(cat "$LOCK" 2>/dev/null) + kill -0 "$pid" 2>/dev/null && exit 0 +fi +echo $$ > "$LOCK" +trap 'rm -f "$LOCK"' EXIT + +[ -z "$TOKEN" ] && exit 0 + +# One fetch + compare + apply pass. Returns 0 always (best-effort). +do_pull() { + # Try each source (tunnel first); accept first that returns a valid conf. + got=0 + for base in $PANEL_URLS; do + curl -s -m 8 -o "$TMP" "${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}" 2>/dev/null || continue + if grep -q "^SERVER_1=" "$TMP" 2>/dev/null; then got=1; break; fi + done + [ "$got" = 1 ] || { rm -f "$TMP"; return 0; } + + new=$(md5sum "$TMP" 2>/dev/null | cut -d' ' -f1) + old=$(md5sum "$CONF" 2>/dev/null | cut -d' ' -f1) + if [ "$new" = "$old" ]; then + rm -f "$TMP" + return 0 + fi + + old_s1=$(grep "^SERVER_1=" "$CONF" 2>/dev/null | cut -d= -f2- | cut -d: -f1) + new_s1=$(grep "^SERVER_1=" "$TMP" 2>/dev/null | cut -d= -f2- | cut -d: -f1) + + cp "$CONF" "$CONF.prev" 2>/dev/null + mv "$TMP" "$CONF" + log "PULL: failover.conf updated (SERVER_1 ${old_s1:-?} -> ${new_s1:-?})" + + # apply the new primary now (only if it actually changed) + if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then + sh "$HEALTH" apply-server 1 + fi +} + +# Inner loop: cron fires this every 60s, but we poll ~4x per minute so a +# panel "Set" applies within ~12-15s instead of up to a full minute. The +# loop stays UNDER 60s and exits so the next cron tick takes over cleanly +# (the lockfile blocks any overlap). POLL_INTERVAL/POLL_PASSES overridable. +POLL_INTERVAL="${POLL_INTERVAL:-12}" +POLL_PASSES="${POLL_PASSES:-4}" +i=1 +while [ "$i" -le "$POLL_PASSES" ]; do + do_pull + [ "$i" -lt "$POLL_PASSES" ] && sleep "$POLL_INTERVAL" + i=$((i + 1)) +done diff --git a/overlay/router-configure-wireguard.sh b/overlay/router-configure-wireguard.sh new file mode 100755 index 0000000..928b977 --- /dev/null +++ b/overlay/router-configure-wireguard.sh @@ -0,0 +1,429 @@ +#!/bin/sh +set -e + +RCI_URL="http://localhost:79/rci/" +MAX_INTERFACE_NUM=9 + +check_dependencies() { + local missing="" + + for cmd in curl jq date; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing="$missing $cmd" + fi + done + + if [ -n "$missing" ]; then + echo "ERROR: Missing required utilities:$missing" >&2 + echo "Please install them using: opkg update && opkg install$missing" >&2 + return 1 + fi + + return 0 +} + +CLIENT_NAME="" +CLIENT_PRIVATE_KEY="" +CLIENT_IP="" +CLIENT_IPV6="" +SERVER_PUBLIC_KEY="" +ENDPOINT_PORT=13255 +KEEPALIVE=25 +MTU=1420 +FALLBACK_CONFIG="" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +error() { + echo "[ERROR] $*" >&2 + log "ERROR: $*" +} + +usage() { + cat <&2 + + local i + for i in 0 1 2 3 4 5 6 7 8 9; do + local interface_json=$(curl -s "${RCI_URL}show/rc/interface/Wireguard${i}" 2>/dev/null) + + if [ -n "${interface_json}" ] && echo "${interface_json}" | jq -e . >/dev/null 2>&1; then + local desc=$(echo "${interface_json}" | jq -r '.description // empty' 2>/dev/null) + if [ "${desc}" = "${target_desc}" ]; then + log "Найден существующий интерфейс: Wireguard${i}" >&2 + echo "Wireguard${i}" + return 0 + fi + fi + done + + log "Существующий интерфейс Phobos не найден" >&2 + echo "" + return 0 +} + +find_free_wireguard_interface() { + log "Поиск свободного интерфейса WireGuard..." >&2 + + local i + for i in 0 1 2 3 4 5 6 7 8 9; do + local interface_json=$(curl -s "${RCI_URL}show/interface/Wireguard${i}" 2>/dev/null) + + if [ -z "${interface_json}" ] || ! echo "${interface_json}" | jq -e '.id' >/dev/null 2>&1; then + log "Найден свободный интерфейс: Wireguard${i}" >&2 + echo "Wireguard${i}" + return 0 + fi + done + + error "Нет свободных интерфейсов WireGuard (0-${MAX_INTERFACE_NUM})" + return 1 +} + +remove_wireguard_interface() { + local interface_name="$1" + + log "Удаление существующего интерфейса: ${interface_name}..." + + if command -v ndmc >/dev/null 2>&1; then + if ndmc -c "no interface ${interface_name}" >/dev/null 2>&1; then + log "Интерфейс ${interface_name} успешно удален ✓" + return 0 + else + log "Предупреждение: не удалось удалить интерфейс через ndmc" + return 1 + fi + else + log "Предупреждение: команда ndmc не найдена" + return 1 + fi +} + +configure_wireguard_interface() { + local interface_name="$1" + + local description="Phobos-${CLIENT_NAME}" + local client_ip_addr=$(echo "${CLIENT_IP}" | cut -d'/' -f1) + local client_ipv6_block="${CLIENT_IPV6}" + + log "Настройка интерфейса ${interface_name}..." + + local config_json=$(cat </dev/null) + + if echo "${result}" | jq -e '.status == "error"' >/dev/null 2>&1; then + local error_msg=$(echo "${result}" | jq -r '.message // "Unknown error"' 2>/dev/null) + error "RCI API отклонил конфигурацию: ${error_msg}" + log "JSON запрос:" + log "${config_json}" + return 1 + fi + + log "Интерфейс ${interface_name} создан ✓" + + return 0 +} + +save_configuration() { + log "Сохранение конфигурации..." + + local result=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -d '{"system":{"configuration":{"save":{}}}}' \ + "${RCI_URL}" 2>/dev/null) + + if echo "${result}" | grep -q '"status"[[:space:]]*:[[:space:]]*"message"'; then + log "Конфигурация сохранена ✓" + return 0 + else + error "Ошибка сохранения конфигурации" + return 1 + fi +} + +verify_interface_created() { + local client_name="$1" + local interface_description="Phobos-${client_name}" + + log "Проверка создания интерфейса WireGuard..." + + local interfaces=$(curl -s "http://127.0.0.1:79/rci/show/interface" 2>/dev/null || echo "") + + if [ -z "$interfaces" ]; then + error "Не удалось получить список интерфейсов через RCI API" + return 1 + fi + + if ! echo "$interfaces" | jq -e . >/dev/null 2>&1; then + error "Некорректный JSON ответ от RCI API" + return 1 + fi + + local found=$(echo "$interfaces" | jq -r "to_entries[] | select(.value.description == \"$interface_description\") | .key" 2>/dev/null) + + if [ -n "$found" ]; then + log "✓ Интерфейс $found (Phobos-${client_name}) успешно создан" + return 0 + else + error "Интерфейс с description '$interface_description' не найден" + return 1 + fi +} + +show_fallback_instructions() { + cat <&1) + if echo "${obf_status}" | grep -q "dead"; then + log "⚠ wg-obfuscator остановлен, перезапускаем..." + /opt/etc/init.d/S49wg-obfuscator start + sleep 2 + log "✓ wg-obfuscator перезапущен" + else + log "✓ wg-obfuscator работает" + fi + fi + + log "" + log "Ожидание применения конфигурации..." + sleep 5 + + if verify_interface_created "${CLIENT_NAME}"; then + log "" + log "╔════════════════════════════════════════════════════════════╗" + log "║ WireGuard успешно настроен! ║" + log "╚════════════════════════════════════════════════════════════╝" + log "" + exit 0 + else + log "" + log "⚠ Не удалось подтвердить создание интерфейса WireGuard" + log "" + log "Проверьте вручную в веб-панели Keenetic:" + log " Интернет → WireGuard" + log "" + exit 1 + fi +} + +main "$@" diff --git a/server/api.py b/server/api.py new file mode 100644 index 0000000..a25e88e --- /dev/null +++ b/server/api.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Phobos Secondary Server API — peer management + health.""" +import json, os, subprocess +from flask import Flask, request, jsonify + +app = Flask(__name__) + +def load_env(): + env = {} + with open("/opt/Phobos/server/server.env") as f: + for line in f: + if "=" in line and not line.startswith("#"): + k, v = line.strip().split("=", 1) + env[k] = v + return env + +def check_api_key(): + env = load_env() + key = request.headers.get("X-API-Key", "") + return key == env.get("MAIN_API_KEY", "") + +@app.route("/api/health") +def health(): + try: + out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5) + peer_keys = [] + handshakes = {} + for line in out.strip().split("\n")[1:]: + parts = line.split("\t") + if len(parts) >= 4: + pub = parts[0] + peer_keys.append(pub) + try: + handshakes[pub] = int(parts[4]) + except Exception: + handshakes[pub] = 0 + peers = len(peer_keys) + except Exception: + peers = 0 + peer_keys = [] + handshakes = {} + try: + out = subprocess.check_output("top -bn1 | grep Cpu", shell=True, text=True, timeout=5) + idle = float([x for x in out.split(",") if "id" in x][0].split()[0]) + cpu = f"{round(100 - idle, 1)}%" + except Exception: + cpu = "?" + try: + mem = subprocess.check_output("free -m", shell=True, text=True).split("\n")[1].split() + mem_str = f"{mem[2]}/{mem[1]}MB" + except Exception: + mem_str = "?" + return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str}) + +@app.route("/api/peers", methods=["GET"]) +def list_peers(): + if not check_api_key(): + return jsonify({"error": "unauthorized"}), 401 + try: + out = subprocess.check_output(["wg", "show", "wg0", "allowed-ips"], text=True, timeout=5) + peers = {} + for line in out.strip().split("\n"): + if "\t" in line: + pub, ips = line.split("\t", 1) + peers[pub.strip()] = ips.strip() + return jsonify({"peers": peers}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route("/api/peers/add", methods=["POST"]) +def add_peer(): + if not check_api_key(): + return jsonify({"error": "unauthorized"}), 401 + data = request.json + pub_key = data.get("public_key", "") + allowed_ips = data.get("allowed_ips", "") + if not pub_key or not allowed_ips: + return jsonify({"error": "missing public_key or allowed_ips"}), 400 + try: + subprocess.run(["wg", "set", "wg0", "peer", pub_key, "allowed-ips", allowed_ips], check=True, timeout=5) + subprocess.run(["wg-quick", "save", "wg0"], timeout=5) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route("/api/peers/remove", methods=["POST"]) +def remove_peer(): + if not check_api_key(): + return jsonify({"error": "unauthorized"}), 401 + pub_key = request.json.get("public_key", "") + if not pub_key: + return jsonify({"error": "missing public_key"}), 400 + try: + subprocess.run(["wg", "set", "wg0", "peer", pub_key, "remove"], check=True, timeout=5) + subprocess.run(["wg-quick", "save", "wg0"], timeout=5) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route("/api/info") +def info(): + if not check_api_key(): + return jsonify({"error": "unauthorized"}), 401 + env = load_env() + return jsonify({ + "ip": env.get("SERVER_PUBLIC_IP_V4"), + "wg_public_key": env.get("SERVER_WG_PUBLIC_KEY"), + "obfuscator_key": env.get("OBFUSCATOR_KEY"), + "ports": env.get("OBFUSCATOR_PORTS", "2083").split(","), + "role": "secondary" + }) + +ROUTER_CONFIGS_DIR = "/opt/Phobos/server/router-configs" + + +@app.route("/api/router-config/") +def router_config(client_id): + """Serve a client's failover.conf so routers can PULL via the tunnel + (http://10.25.0.1:8444/...). Token via ?token= or X-API-Key.""" + token = request.args.get("token", "") or request.headers.get("X-API-Key", "") + if token != load_env().get("MAIN_API_KEY", ""): + return ("forbidden", 403) + path = os.path.join(ROUTER_CONFIGS_DIR, client_id + ".conf") + if not os.path.exists(path): + return ("not found", 404) + with open(path) as fh: + return (fh.read(), 200, {"Content-Type": "text/plain; charset=utf-8"}) + + +@app.route("/api/router-config-set", methods=["POST"]) +def router_config_set(): + """Panel fan-out: store a client's failover.conf on this server.""" + if not check_api_key(): + return jsonify({"status": "error", "msg": "unauthorized"}), 403 + data = request.get_json(force=True, silent=True) or {} + cid = data.get("client_id", "") + conf = data.get("conf", "") + if not cid or "SERVER_1=" not in conf: + return jsonify({"status": "error", "msg": "bad payload"}), 400 + os.makedirs(ROUTER_CONFIGS_DIR, exist_ok=True) + with open(os.path.join(ROUTER_CONFIGS_DIR, cid + ".conf"), "w") as fh: + fh.write(conf) + return jsonify({"status": "ok"}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8444) diff --git a/server/phobos-health.sh b/server/phobos-health.sh new file mode 100755 index 0000000..d0c6d2c --- /dev/null +++ b/server/phobos-health.sh @@ -0,0 +1,446 @@ +#!/bin/sh +# ============================================================ +# Phobos Health Monitor v2 — Keenetic Edition +# Uses ndmc/RCI instead of wg-tools. Connectivity-based failover. +# Runs via cron every 60 seconds +# ============================================================ + +PHOBOS_DIR="/opt/etc/Phobos" +CONF="$PHOBOS_DIR/failover.conf" +STATE="$PHOBOS_DIR/state" +LOG="$PHOBOS_DIR/health.log" +OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf" +LOCKFILE="/tmp/phobos-health.lock" +WG_IF="Wireguard3" + +MAX_LOG_LINES=200 +# Thresholds (seconds) +HANDSHAKE_WARN=150 +HANDSHAKE_PORT_HOP=300 +HANDSHAKE_SERVER_SWITCH=600 +PRIMARY_CHECK_INTERVAL=300 +# Primary-alive probe: servers block ICMP and busybox `nc -z` is unreliable, +# so reachability is tested with curl against the panel's obfuscator-health +# endpoint on the primary VPS. It returns http 200 ONLY when all +# wg-obfuscator-* services are active — the bare panel port stays up even +# when the tunnel path is dead, so we must NOT switch back on panel liveness +# alone (that caused premature switchback to a dead primary). +PRIMARY_PROBE_PORT=10514 +PRIMARY_PROBE_PATH="/api/obf-health" +# Connectivity check targets (logged for context only) +CHECK_HOST_1="8.8.8.8" +CHECK_HOST_2="1.1.1.1" + +log() { + echo "$(date '+%H:%M:%S') $1" >> "$LOG" + if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then + tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG" + fi +} + +# Prevent concurrent runs +if [ -f "$LOCKFILE" ]; then + pid=$(cat "$LOCKFILE" 2>/dev/null) + if kill -0 "$pid" 2>/dev/null; then + exit 0 + fi +fi +echo $$ > "$LOCKFILE" +trap 'rm -f "$LOCKFILE"' EXIT + +mkdir -p "$STATE" + +# ── Detect WG interface ── +detect_wg_interface() { + for i in 0 1 2 3 4 5 6 7 8 9; do + desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1) + if echo "$desc" | grep -qi phobos; then + WG_IF="Wireguard${i}" + return 0 + fi + done + return 1 +} + +# ── Read failover config ── +if [ ! -f "$CONF" ]; then + log "ERROR: no failover.conf" + exit 1 +fi + +SERVER_COUNT=0 +idx=1 +while true; do + val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-) + [ -z "$val" ] && break + eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)" + eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)" + eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)" + eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)" + SERVER_COUNT=$idx + idx=$((idx + 1)) +done + +if [ "$SERVER_COUNT" -eq 0 ]; then + log "ERROR: no servers in failover.conf" + exit 1 +fi + +# Read state +CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1") +CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0") +FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0") +PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0") + +# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ── +detect_wg_interface || { + log "WARN: detect failed, using default $WG_IF" +} + +# ── Get handshake age via ndmc ── +# A peer switch can leave the removed peer's stale handshake listed in ndmc +# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum +# positive, sane) handshake across all peers — that is the live tunnel. +get_handshake_age() { + hs=$(ndmc -c "show interface $WG_IF" 2>/dev/null \ + | grep "last-handshake" \ + | awk '{v=$2} v>0 && v<86400 {print v}' \ + | sort -n | head -1) + if [ -z "$hs" ]; then + echo "9999" + else + echo "$hs" + fi +} + +# ── Check real connectivity ── +check_connectivity() { + ping -c 1 -W 3 "$CHECK_HOST_1" >/dev/null 2>&1 && return 0 + ping -c 1 -W 3 "$CHECK_HOST_2" >/dev/null 2>&1 && return 0 + return 1 +} + +# ── Get port by index ── +get_port() { + server_idx=$1; port_idx=$2 + eval "ports=\$SERVER_${server_idx}_PORTS" + echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p" +} + +get_port_count() { + server_idx=$1 + eval "ports=\$SERVER_${server_idx}_PORTS" + echo "$ports" | tr ',' '\n' | wc -l +} + +# ── Keys currently on the interface (includes the interface's OWN key) ── +# WG public keys are 43 base64 chars + '='. The interface's own public-key is +# also matched here — callers must only act on KNOWN server WGKEYs so the +# local key is never touched. +list_iface_keys() { + ndmc -c "show interface $WG_IF" 2>/dev/null \ + | grep -oE '[A-Za-z0-9+/]{43}=' | sort -u +} + +# Count how many KNOWN server WGKEYs are currently attached as peers. +count_server_peers() { + present=$(list_iface_keys) + n=0 + i=1 + while [ "$i" -le "$SERVER_COUNT" ]; do + eval "wk=\$SERVER_${i}_WGKEY" + if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then + n=$((n + 1)) + fi + i=$((i + 1)) + done + echo "$n" +} + +# ── Switch WG peer so EXACTLY ONE server peer (new_key) remains ── +# Failover adds a new peer; the RCI "remove" is unreliable on Keenetic and +# leaves dead peers behind. Multiple peers each with allow-ips 0.0.0.0/0 make +# egress routing ambiguous. We hard-purge every OTHER known server key via the +# ndmc CLI (reliable) — never the interface's own key — then ensure new_key. +switch_wg_peer() { + new_key=$1 + + if [ -z "$new_key" ]; then + log "WARN: no WGKEY for target server, skip peer switch" + return 1 + fi + + present=$(list_iface_keys) + + # Purge any OTHER known server key that is attached + i=1 + while [ "$i" -le "$SERVER_COUNT" ]; do + eval "wk=\$SERVER_${i}_WGKEY" + if [ -n "$wk" ] && [ "$wk" != "$new_key" ] && echo "$present" | grep -q "^${wk}$"; then + log "WG PEER purge: $wk" + ndmc -c "interface $WG_IF no wireguard peer $wk" >/dev/null 2>&1 + fi + i=$((i + 1)) + done + + # Add the target peer if it is not already present + if ! echo "$present" | grep -q "^${new_key}$"; then + log "WG PEER add: $new_key" + curl -s -X POST "http://localhost:79/rci/" \ + -H "Content-Type: application/json" \ + -d "{\"interface\":{\"${WG_IF}\":{\"wireguard\":{\"peer\":{\"key\":\"${new_key}\",\"comment\":\"Phobos VPS Server\",\"endpoint\":{\"address\":\"127.0.0.1:13255\"},\"keepalive-interval\":{\"interval\":25},\"allow-ips\":[{\"address\":\"0.0.0.0\",\"mask\":\"0.0.0.0\"},{\"address\":\"::\",\"mask\":\"0\"}]}}}}}" >/dev/null 2>&1 + fi + + # Persist config + ndmc -c "system configuration save" >/dev/null 2>&1 + return 0 +} + +# ── Switch to specific server:port ── +switch_endpoint() { + server_idx=$1 + port_idx=$2 + + eval "host=\$SERVER_${server_idx}_HOST" + eval "obf_key=\$SERVER_${server_idx}_KEY" + eval "wg_key=\$SERVER_${server_idx}_WGKEY" + port=$(get_port "$server_idx" "$port_idx") + + if [ -z "$host" ] || [ -z "$port" ]; then + log "ERROR: invalid server $server_idx port_idx $port_idx" + return 1 + fi + + log "SWITCH → server $server_idx ($host:$port)" + + # 1. Switch WG peer key if different server + switch_wg_peer "$wg_key" + + # 2. Update obfuscator config + if [ -f "$OBF_CONF" ]; then + sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF" + if [ -n "$obf_key" ]; then + sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF" + fi + fi + + # 3. Restart obfuscator + if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then + /opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1 + else + killall wg-obfuscator 2>/dev/null + sleep 1 + wg-obfuscator --config "$OBF_CONF" & + fi + + # 4. Save state (fail_count is managed by the caller, NOT reset here — + # resetting on a port-hop would prevent escalation to server failover) + echo "$server_idx" > "$STATE/current_server" + echo "$port_idx" > "$STATE/current_port_idx" +} + +# ── Try next port on current server ── +try_next_port() { + port_count=$(get_port_count "$CURRENT_SERVER") + next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count )) + [ "$next_idx" -eq 0 ] && return 1 + + log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER" + switch_endpoint "$CURRENT_SERVER" "$next_idx" + return 0 +} + +# ── Try next server ── +try_next_server() { + next=$((CURRENT_SERVER + 1)) + [ "$next" -gt "$SERVER_COUNT" ] && next=1 + [ "$next" -eq "$CURRENT_SERVER" ] && return 1 + + log "FAILOVER → server $next" + switch_endpoint "$next" "0" + return 0 +} + +# ── Check if primary is back ── +check_primary() { + [ "$CURRENT_SERVER" -eq 1 ] && return + + now=$(date +%s) + elapsed=$((now - PRIMARY_CHECK_TS)) + [ "$elapsed" -lt "$PRIMARY_CHECK_INTERVAL" ] && return + echo "$now" > "$STATE/primary_check_ts" + + eval "host=\$SERVER_1_HOST" + # Probe the obfuscator-health endpoint: http 200 ONLY when the primary's + # obfuscator path is actually up. Anything else (000 no-response, 503 + # obf-down, redirects) means the tunnel path is NOT viable → stay put. + code=$(curl -s -m 4 -o /dev/null -w '%{http_code}' "http://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" 2>/dev/null) + if [ "$code" = "200" ]; then + log "PRIMARY ($host obf-health http=200) alive, switching back" + switch_endpoint 1 0 + echo "0" > "$STATE/fail_count" + else + log "PRIMARY still down (obf-health http=${code:-none}), staying on server $CURRENT_SERVER" + fi +} + +# ── LAN routing self-heal ────────────────────── +# A router REBOOT silently drops the per-device `ip hotspot host policy` +# binding and can reset the WG interface security-level, so LAN clients leak to +# WAN (no VPN). This: +# 1) ensures WG_IF security-level = public (needed for masquerade), +# 2) LEARNS current host->policy bindings into state/lan-hosts (append-only), +# 3) RE-APPLIES any learned binding that is currently missing. +# Append-only learning means a reboot (which clears live bindings) never erases +# the record, so the next run restores them. ndmc show running-config is heavy, +# so the caller gates this to run only every few minutes. +heal_lan_routing() { + LANHOSTS="$STATE/lan-hosts" + rc=$(ndmc -c "show running-config" 2>/dev/null) + [ -z "$rc" ] && return 0 + + # 1) WG interface must be public + sl=$(echo "$rc" | awk -v ifc="interface $WG_IF" '$0~ifc{f=1} f&&/security-level/{print $2; exit}') + if [ -n "$sl" ] && [ "$sl" != "public" ]; then + ndmc -c "interface $WG_IF security-level public" >/dev/null 2>&1 + ndmc -c "system configuration save" >/dev/null 2>&1 + log "HEAL: $WG_IF security-level -> public" + fi + + # 2) learn live bindings (append-only) into lan-hosts + touch "$LANHOSTS" + echo "$rc" | grep -oE "host [0-9a-f:]+ policy [A-Za-z0-9_]+" | while read -r _h mac _p pol; do + grep -q "^$mac " "$LANHOSTS" 2>/dev/null || echo "$mac $pol" >> "$LANHOSTS" + done + + # 3) re-apply any learned binding that is missing live + changed=0 + while read -r mac pol; do + [ -z "$mac" ] && continue + case "$mac" in \#*) continue;; esac + if ! echo "$rc" | grep -q "host $mac policy $pol"; then + ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1 + log "HEAL: re-bound host $mac -> $pol" + changed=1 + fi + done < "$LANHOSTS" + [ "$changed" = 1 ] && ndmc -c "system configuration save" >/dev/null 2>&1 +} + +# ══════════════════════════════════════════════ +# Explicit apply hook (used by phobos-pull.sh after a config change). +# `phobos-health.sh apply-server N` re-points the tunnel to SERVER_N +# immediately, skipping the failure-escalation logic. Reuses +# switch_endpoint so the WG-peer/obfuscator/state changes stay identical +# to a normal failover. Resets fail_count so the new server starts clean. +# ══════════════════════════════════════════════ +if [ "$1" = "apply-server" ]; then + idx="${2:-1}" + eval "ahost=\$SERVER_${idx}_HOST" + if [ -z "$ahost" ]; then + log "APPLY: server $idx not in conf, ignore" + exit 1 + fi + log "APPLY: pull requested server $idx ($ahost)" + switch_endpoint "$idx" "0" + echo "0" > "$STATE/fail_count" + echo "$idx" > "$STATE/current_server" + exit 0 +fi + +# ══════════════════════════════════════════════ +# Pre-check: fix desync (obfuscator targeting wrong server) +# ══════════════════════════════════════════════ +if [ -f "$OBF_CONF" ]; then + cur_target=$(grep "^target = " "$OBF_CONF" 2>/dev/null | sed 's/target = //') + eval "expected_host=\$SERVER_${CURRENT_SERVER}_HOST" + if [ -n "$cur_target" ] && [ -n "$expected_host" ]; then + echo "$cur_target" | grep -q "$expected_host" || { + log "DESYNC: obf=$cur_target state=server${CURRENT_SERVER}($expected_host). Resync." + switch_endpoint "$CURRENT_SERVER" "$CURRENT_PORT_IDX" + sleep 5 + } + fi +fi + +# ── LAN routing self-heal (gated ~5 min; a reboot drops host->policy bindings) ── +HEAL_TS=$(cat "$STATE/heal_ts" 2>/dev/null || echo 0) +now_heal=$(date +%s) +if [ $((now_heal - HEAL_TS)) -ge 300 ]; then + echo "$now_heal" > "$STATE/heal_ts" + heal_lan_routing +fi + +# ── Peer hygiene: keep exactly ONE WG peer = current server's key ── +# Failovers can leave dead/duplicate peers; multiple 0.0.0.0/0 peers cause +# ambiguous egress routing. Self-heal here every run (cheap when already clean). +eval "cur_wgkey=\$SERVER_${CURRENT_SERVER}_WGKEY" +if [ -n "$cur_wgkey" ]; then + peer_n=$(count_server_peers) + if [ "$peer_n" -gt 1 ]; then + log "HYGIENE: $peer_n server peers present, purging to server${CURRENT_SERVER}" + switch_wg_peer "$cur_wgkey" + fi +fi + +# ══════════════════════════════════════════════ +# Main logic +# Tunnel health = WireGuard handshake age (authoritative: a fresh +# handshake only happens through the full obfuscator→server→WG path). +# WAN ping is logged for context ONLY — it does NOT gate decisions, +# because the router's default route is not the tunnel, so WAN can be +# up while the tunnel is dead (and a WAN blip must not cause failover). +# ══════════════════════════════════════════════ +AGE=$(get_handshake_age) +WAN=$(check_connectivity && echo "yes" || echo "no") + +# Tunnel healthy = fresh handshake +if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then + if [ "$FAIL_COUNT" -gt 0 ]; then + log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)" + echo "0" > "$STATE/fail_count" + fi + check_primary + exit 0 +fi + +# Stale handshake = tunnel down → escalate. +# fail_count is MONOTONIC: it climbs across stages and is only reset by the +# OK branch (real recovery) or after a successful failover to a NEW server +# (so the new server gets a fresh restart→port-hop→failover cycle). +FAIL_COUNT=$((FAIL_COUNT + 1)) +echo "$FAIL_COUNT" > "$STATE/fail_count" +log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PORT_IDX fails=$FAIL_COUNT" + +# Stage 1 (fail 1): Restart obfuscator +if [ "$FAIL_COUNT" -eq 1 ]; then + log "ACTION: restart obfuscator" + if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then + /opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1 + else + killall wg-obfuscator 2>/dev/null + sleep 1 + wg-obfuscator --config "$OBF_CONF" & + fi + exit 0 +fi + +# Stage 2 (fail 2): Port hop to an alternate port on the SAME server +if [ "$FAIL_COUNT" -eq 2 ]; then + if try_next_port; then + exit 0 + fi + # only one port → fall through to server failover + log "single port, escalate to failover" +fi + +# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server +# gets its own restart→port-hop→failover cycle next ticks. +log "ACTION: server failover (fail $FAIL_COUNT)" +if try_next_server; then + echo "0" > "$STATE/fail_count" +else + # Only one server — cycle back to port 0 and restart escalation + echo "0" > "$STATE/fail_count" + switch_endpoint "$CURRENT_SERVER" "0" +fi diff --git a/server/phobos-pull.sh b/server/phobos-pull.sh new file mode 100755 index 0000000..6e06167 --- /dev/null +++ b/server/phobos-pull.sh @@ -0,0 +1,93 @@ +#!/bin/sh +# ============================================================ +# Phobos Config Pull — NAT-friendly management channel. +# Runs ON the router via cron (every 2 min). OUTBOUND ONLY: +# fetches this client's failover.conf from the panel over +# HTTPS/HTTP and applies it. No inbound SSH needed, so it works +# behind any NAT and regardless of which Phobos server is active. +# +# Why pull (not panel->router SSH): +# - routers sit behind NAT with no public IP (KeenDNS is HTTP- +# only, no port 22), +# - WG3 must be security-level PUBLIC for LAN client routing, +# which blocks inbound SSH on the tunnel, +# - on failover the router's 10.25.0.2 moves to another server's +# wg0, so a fixed panel host cannot reach it. +# Pulling sidesteps all three. +# +# Files (written by installer / bootstrap): +# /opt/etc/Phobos/client_id -> this router's client id (e.g. home) +# /opt/etc/Phobos/pull_token -> shared secret for the panel endpoint +# PANEL env or default below -> panel base URL +# ============================================================ +PHOBOS_DIR="/opt/etc/Phobos" +CONF="$PHOBOS_DIR/failover.conf" +HEALTH="$PHOBOS_DIR/phobos-health.sh" +LOG="$PHOBOS_DIR/health.log" +# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 is the wg0 of whatever +# server the tunnel currently terminates on, so management rides the same +# obfuscated channel as data — survives a public-IP ban and follows failover. +# - 10.25.0.1:8444 -> secondary server agent (phobos-api) +# - 10.25.0.1:10514 -> primary server panel +# - public panel IP -> bootstrap / if tunnel is down +PANEL="${PANEL:-http://212.118.52.193:10514}" +PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}" +CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home") +TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null) +TMP="/tmp/failover.conf.pull" +LOCK="/tmp/phobos-pull.lock" + +log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; } + +# single instance +if [ -f "$LOCK" ]; then + pid=$(cat "$LOCK" 2>/dev/null) + kill -0 "$pid" 2>/dev/null && exit 0 +fi +echo $$ > "$LOCK" +trap 'rm -f "$LOCK"' EXIT + +[ -z "$TOKEN" ] && exit 0 + +# One fetch + compare + apply pass. Returns 0 always (best-effort). +do_pull() { + # Try each source (tunnel first); accept first that returns a valid conf. + got=0 + for base in $PANEL_URLS; do + curl -s -m 8 -o "$TMP" "${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}" 2>/dev/null || continue + if grep -q "^SERVER_1=" "$TMP" 2>/dev/null; then got=1; break; fi + done + [ "$got" = 1 ] || { rm -f "$TMP"; return 0; } + + new=$(md5sum "$TMP" 2>/dev/null | cut -d' ' -f1) + old=$(md5sum "$CONF" 2>/dev/null | cut -d' ' -f1) + if [ "$new" = "$old" ]; then + rm -f "$TMP" + return 0 + fi + + old_s1=$(grep "^SERVER_1=" "$CONF" 2>/dev/null | cut -d= -f2- | cut -d: -f1) + new_s1=$(grep "^SERVER_1=" "$TMP" 2>/dev/null | cut -d= -f2- | cut -d: -f1) + + cp "$CONF" "$CONF.prev" 2>/dev/null + mv "$TMP" "$CONF" + log "PULL: failover.conf updated (SERVER_1 ${old_s1:-?} -> ${new_s1:-?})" + + # apply the new primary now (only if it actually changed) + if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then + sh "$HEALTH" apply-server 1 + fi +} + +# Inner loop: cron fires this every 60s, but we poll ~4x per minute so a +# panel "Set" applies within ~12-15s instead of up to a full minute. The +# loop stays UNDER 60s and exits so the next cron tick takes over cleanly +# (the lockfile blocks any overlap). POLL_INTERVAL/POLL_PASSES overridable. +POLL_INTERVAL="${POLL_INTERVAL:-12}" +POLL_PASSES="${POLL_PASSES:-4}" +i=1 +while [ "$i" -le "$POLL_PASSES" ]; do + do_pull + [ "$i" -lt "$POLL_PASSES" ] && sleep "$POLL_INTERVAL" + i=$((i + 1)) +done diff --git a/server/phobos-router-watchdog.py b/server/phobos-router-watchdog.py new file mode 100644 index 0000000..accebd9 --- /dev/null +++ b/server/phobos-router-watchdog.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Phobos Router Watchdog (server-side). + +Problem it solves: on some Keenetic firmware (seen on 5.1 Beta), after a reboot +the Entware /opt disk mounts but the init hook (rc.unslung) does NOT run, so the +obfuscator + cron + dropbear never start and the router's Phobos tunnel stays +down. Because cron itself didn't start, the on-router self-heal can't help. + +This watchdog runs on the primary server (cron, every few minutes). For each +router that has KeenDNS web access configured, it checks whether the client has +a fresh WG handshake on ANY server. If a router has been offline past a grace +period, it logs into the router's web UI over KeenDNS (ndm challenge auth) and +re-triggers the opkg init (which runs rc.unslung -> starts everything). Sends a +Telegram note on down / recovery / action. + +Per-router config lives in /opt/phobos-panel/settings.json under +router_access[]: + keendns_host : e.g. "homesmart.netcraze.pro" + web_login : Keenetic web user + web_pass : Keenetic web password + opkg_disk : opkg disk id, e.g. "EXT4-XXXX:/" (default below) +Routers without these fields are skipped (watchdog is opt-in per router). +""" +import json, os, time, ssl, hashlib, http.cookiejar, urllib.request, urllib.error, subprocess + +SETTINGS = "/opt/phobos-panel/settings.json" +SERVERS_FILE = "/opt/phobos-panel/servers.json" +CLIENTS_DIR = "/opt/Phobos/clients" +STATE_FILE = "/opt/Phobos/server/watchdog-state.json" +LOG = "/opt/Phobos/server/watchdog.log" + +OFFLINE_SECS = int(os.environ.get("WD_OFFLINE_SECS", "300")) # offline if newest handshake older than this +COOLDOWN = int(os.environ.get("WD_COOLDOWN", "600")) # min seconds between recovery attempts per router +DEFAULT_DISK = "EXT4-V88axM0d:/" + + +def log(msg): + try: + with open(LOG, "a") as f: + f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n") + except Exception: + pass + + +def load(path, default): + try: + with open(path) as f: + return json.load(f) + except Exception: + return default + + +def tg(token, chat, text): + if not token or not chat: + return + try: + url = f"https://api.telegram.org/bot{token}/sendMessage" + data = json.dumps({"chat_id": chat, "text": text}).encode() + urllib.request.urlopen(urllib.request.Request( + url, data=data, headers={"Content-Type": "application/json"}), timeout=8) + except Exception: + pass + + +def client_pub(cid): + try: + return json.load(open(f"{CLIENTS_DIR}/{cid}/metadata.json")).get("public_key", "") + except Exception: + return "" + + +def newest_handshake_age(pub, servers): + """Smallest handshake age (s) for pub across local wg0 + secondary agents.""" + best = 99999 + try: + out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5) + for ln in out.strip().split("\n")[1:]: + f = ln.split("\t") + if f and f[0] == pub and len(f) >= 5 and f[4].isdigit() and int(f[4]) > 0: + best = min(best, int(time.time()) - int(f[4])) + except Exception: + pass + for srv in servers: + try: + req = urllib.request.Request(f"http://{srv['ip']}:8444/api/health", + headers={"X-API-Key": srv.get("api_key", "")}) + d = json.loads(urllib.request.urlopen(req, timeout=5).read()) + ts = d.get("handshakes", {}).get(pub, 0) + if ts: + best = min(best, int(time.time()) - int(ts)) + except Exception: + pass + return best + + +def rci_session(host, login, pw): + """Keenetic ndm challenge auth over KeenDNS. Returns (opener, base) or (None, None).""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + cj = http.cookiejar.CookieJar() + op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj), + urllib.request.HTTPSHandler(context=ctx)) + base = f"https://{host}" + realm = chal = None + try: + op.open(base + "/auth", timeout=10) + except urllib.error.HTTPError as e: + realm = e.headers.get("X-NDM-Realm") + chal = e.headers.get("X-NDM-Challenge") + except Exception: + return None, None + if not realm or not chal: + return None, None + md5 = hashlib.md5(f"{login}:{realm}:{pw}".encode()).hexdigest() + sha = hashlib.sha256((chal + md5).encode()).hexdigest() + body = json.dumps({"login": login, "password": sha}).encode() + try: + op.open(urllib.request.Request(base + "/auth", data=body, + headers={"Content-Type": "application/json"}, method="POST"), timeout=10) + except Exception: + return None, None + return op, base + + +def retrigger_opkg(op, base, disk): + """Force opkg 'disk changed' so Keenetic re-runs initrc (rc.unslung).""" + cur = "" + try: + cur = json.loads(op.open(base + "/rci/show/rc/opkg", timeout=8).read()).get("disk", {}).get("disk", "") + except Exception: + pass + newdisk = disk + if cur.strip() == disk.strip(): + newdisk = disk.rstrip("/") if disk.endswith("/") else disk + "/" + body = json.dumps([{"opkg": {"disk": newdisk}}, + {"system": {"configuration": {"save": {}}}}]).encode() + try: + op.open(urllib.request.Request(base + "/rci/", data=body, + headers={"Content-Type": "application/json"}, method="POST"), timeout=20) + return True + except Exception: + return False + + +def main(): + s = load(SETTINGS, {}) + st = load(STATE_FILE, {}) + token = s.get("tg_bot_token") + chat = s.get("tg_chat_id") + servers = load(SERVERS_FILE, []) + ra = s.get("router_access", {}) + now = int(time.time()) + changed = False + + for cid, acc in ra.items(): + host = acc.get("keendns_host") + login = acc.get("web_login") + pw = acc.get("web_pass") + disk = acc.get("opkg_disk", DEFAULT_DISK) + if not (host and login and pw): + continue + pub = client_pub(cid) + if not pub: + continue + age = newest_handshake_age(pub, servers) + rec = st.get(cid, {}) + + if age <= OFFLINE_SECS: + if rec.get("offline"): + log(f"{cid}: recovered (handshake {age}s)") + tg(token, chat, f"✅ Router {cid} recovered (handshake {age}s).") + st[cid] = {"offline": False, "last_recover": rec.get("last_recover", 0)} + changed = True + continue + + # offline + if now - rec.get("last_recover", 0) < COOLDOWN: + continue + op, base = rci_session(host, login, pw) + if not op: + if not rec.get("offline"): + log(f"{cid}: offline, web unreachable") + tg(token, chat, f"\U0001F534 Router {cid} OFFLINE, web unreachable (powered off / no internet?).") + st[cid] = {"offline": True, "last_recover": rec.get("last_recover", 0)} + changed = True + continue + ok = retrigger_opkg(op, base, disk) + log(f"{cid}: offline ({age}s), re-triggered opkg via RCI -> {'ok' if ok else 'FAIL'}") + tg(token, chat, f"\U0001F6E0 Router {cid} Entware down (reboot didn't autostart) — re-triggered via RCI ({'ok' if ok else 'FAILED'}).") + st[cid] = {"offline": True, "last_recover": now} + changed = True + + if changed: + try: + json.dump(st, open(STATE_FILE, "w")) + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/server/secondary-setup.sh b/server/secondary-setup.sh old mode 100644 new mode 100755