%s" % cmd
return True, msg
def delete_client_cli(cid):
cid = (cid or "").strip()
if not cid or not os.path.isdir(os.path.join(CLIENTS_DIR, cid)):
return False, "Клиент %s не найден." % cid
script = client_script_path()
try:
subprocess.check_output([script, "remove", cid], text=True, timeout=25,
stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()})
except subprocess.CalledProcessError as e:
return False, "Ошибка удаления: %s" % (e.output or "")[-300:]
try:
st = load_settings()
st.get("client_assignments", {}).pop(cid, None)
st.get("client_notify", {}).pop(cid, None)
save_settings(st)
except Exception:
pass
return True, "\U0001F5D1 Клиент %s удалён." % cid
def tg_api(method, payload, token=None):
import urllib.request
if token is None:
token = load_settings().get("tg_bot_token", "")
if not token:
return None
try:
data = json.dumps(payload).encode()
req = urllib.request.Request("https://api.telegram.org/bot%s/%s" % (token, method),
data=data, headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=15).read())
except Exception:
return None
MAIN_KB = {
"keyboard": [
["➕ Создать клиента", "\U0001F4CB Список"],
["\U0001F5D1 Удалить", "\U0001F4CA Статус"],
["❓ Помощь"],
],
"resize_keyboard": True,
}
_bot_state = {}
def _bot_status_text():
servers = [{"ip": SERVER_IP, "is_primary": True}] + load_servers()
lines = ["\U0001F4CA Серверы:"]
for sv in servers:
ip = sv.get("ip", "")
st = "ok" if ip == SERVER_IP else server_stats_cache.get(ip, {}).get("status", "?")
emoji = "\U0001F7E2" if st == "ok" else ("\U0001F534" if st == "down" else "⚪")
lines.append("%s %s%s" % (emoji, ip, " (primary)" if sv.get("is_primary") else ""))
online = set()
try:
for p, info in get_wg_peers().items():
if is_peer_online(info.get("latest handshake", "")):
online.add(p)
for _ip, hs in server_handshakes_cache.items():
for p, tsv in hs.items():
if tsv and (time.time() - tsv) < 180:
online.add(p)
except Exception:
pass
lines.append("\n\U0001F4F1 Клиенты:")
cl = get_clients()
if not cl:
lines.append("нет")
for c in cl:
on = c.get("public_key", "") in online
lines.append(("\U0001F7E2 " if on else "⚪ ") + c.get("client_id", ""))
return "\n".join(lines)
def qr_png_bytes(data):
import qrcode, io
buf = io.BytesIO(); qrcode.make(data).save(buf, format="PNG"); return buf.getvalue()
def tg_send_photo(chat, png, caption=""):
import urllib.request, secrets as _sec
token = load_settings().get("tg_bot_token", "")
if not token:
return None
boundary = "----phobos" + _sec.token_hex(8)
def fld(name, val):
return ("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n" % (boundary, name, val)).encode()
body = fld("chat_id", str(chat)) + fld("caption", caption) + fld("parse_mode", "HTML")
body += ("--%s\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"qr.png\"\r\nContent-Type: image/png\r\n\r\n" % boundary).encode()
body += png + ("\r\n--%s--\r\n" % boundary).encode()
try:
req = urllib.request.Request("https://api.telegram.org/bot%s/sendPhoto" % token, data=body,
headers={"Content-Type": "multipart/form-data; boundary=%s" % boundary})
return urllib.request.urlopen(req, timeout=25).read()
except Exception:
return None
def bot_send_new_client(chat, name):
ok, rep = create_client_cli(name)
tg_api("sendMessage", {"chat_id": chat, "text": rep, "parse_mode": "HTML", "reply_markup": MAIN_KB})
if ok:
try:
conf, link = build_phone_config(name, "android")
if link:
tg_send_photo(chat, qr_png_bytes(link), "📱 Android (PhobosWG) — отсканируй QR в приложении")
tg_api("sendMessage", {"chat_id": chat, "text": "phobos:// ссылка (Android):\n%s" % link, "parse_mode": "HTML"})
except Exception:
pass
def telegram_bot():
offset = 0
import urllib.request
while True:
try:
s = load_settings()
token = s.get("tg_bot_token", "")
chat = str(s.get("tg_chat_id", ""))
if not token or not chat:
time.sleep(15); continue
url = "https://api.telegram.org/bot%s/getUpdates?timeout=30&offset=%d" % (token, offset)
data = json.loads(urllib.request.urlopen(url, timeout=40).read())
for upd in data.get("result", []):
offset = upd["update_id"] + 1
cq = upd.get("callback_query")
if cq:
if str(cq.get("message", {}).get("chat", {}).get("id", "")) != chat:
continue
cdata = cq.get("data", "")
if cdata.startswith("del:"):
_, rep = delete_client_cli(cdata[4:])
tg_api("answerCallbackQuery", {"callback_query_id": cq["id"], "text": "OK"})
tg_api("sendMessage", {"chat_id": chat, "text": rep, "parse_mode": "HTML", "reply_markup": MAIN_KB})
continue
m = upd.get("message") or upd.get("channel_post")
if not m:
continue
if str(m.get("chat", {}).get("id", "")) != chat:
continue
text = (m.get("text") or "").strip()
if not text:
continue
# detect reply-keyboard button by its emoji prefix (NOT a command/arg)
btn = None
if not text.startswith("/"):
if text.startswith("➕"):
btn = "add"
elif text.startswith("\U0001F5D1"):
btn = "del"
elif text.startswith("\U0001F4CB"):
btn = "list"
elif text.startswith("\U0001F4CA"):
btn = "status"
elif text.startswith("❓"):
btn = "help"
# conversation: awaiting a client name to create
if _bot_state.get(chat) == "add":
if btn is None and not text.startswith("/"):
_bot_state.pop(chat, None)
bot_send_new_client(chat, text)
continue
_bot_state.pop(chat, None) # a button/command cancels the prompt
# parse slash command + arg (buttons carry no arg)
cmd = ""
arg = ""
if text.startswith("/"):
toks = text.split()
cmd = toks[0].split("@")[0].lower()
arg = toks[1] if len(toks) > 1 else ""
if btn == "add" or cmd in ("/add", "/new", "/create"):
if arg:
bot_send_new_client(chat, arg)
else:
_bot_state[chat] = "add"
tg_api("sendMessage", {"chat_id": chat, "text": "Введите имя нового клиента (латиница, цифры, _ и -):"})
elif btn == "del" or cmd in ("/del", "/delete", "/remove", "/rm"):
if arg:
_, rep = delete_client_cli(arg)
tg_api("sendMessage", {"chat_id": chat, "text": rep, "parse_mode": "HTML", "reply_markup": MAIN_KB})
else:
cl = get_clients()
if not cl:
tg_api("sendMessage", {"chat_id": chat, "text": "Клиентов нет.", "reply_markup": MAIN_KB})
else:
kb = {"inline_keyboard": [[{"text": "\U0001F5D1 " + c.get("client_id", ""), "callback_data": "del:" + c.get("client_id", "")}] for c in cl]}
tg_api("sendMessage", {"chat_id": chat, "text": "Кого удалить? Нажми на клиента:", "reply_markup": kb})
elif btn == "list" or cmd in ("/list", "/ls"):
cl = [c.get("client_id", "") for c in get_clients()]
rep = ("Клиенты (%d):\n" % len(cl)) + "\n".join("• " + c for c in cl) if cl else "Клиентов нет."
tg_api("sendMessage", {"chat_id": chat, "text": rep, "reply_markup": MAIN_KB})
elif btn == "status" or cmd in ("/status", "/stat"):
tg_api("sendMessage", {"chat_id": chat, "text": _bot_status_text(), "parse_mode": "HTML", "reply_markup": MAIN_KB})
elif btn == "help" or cmd in ("/help", "/start"):
rep = ("PCA Phobos — бот\n\n"
"Кнопки внизу или команды:\n"
"➕ /add <имя> — создать клиента\n"
"\U0001F5D1 /del <имя> — удалить\n"
"\U0001F4CB /list — список\n"
"\U0001F4CA /status — статус")
tg_api("sendMessage", {"chat_id": chat, "text": rep, "parse_mode": "HTML", "reply_markup": MAIN_KB})
except Exception:
time.sleep(10)
def get_wg_peers():
"""Parse `wg show wg0` to get active peers with transfer/handshake info."""
try:
out = subprocess.check_output(["wg", "show", "wg0"], text=True, timeout=5)
except Exception:
return {}
peers = {}
current_pub = None
for line in out.split("\n"):
line = line.strip()
if line.startswith("peer:"):
current_pub = line.split("peer:")[1].strip()
peers[current_pub] = {}
elif current_pub and ":" in line:
key, val = line.split(":", 1)
peers[current_pub][key.strip()] = val.strip()
return peers
def get_wg_allowed_ips():
try:
out = subprocess.check_output(["wg", "show", "wg0", "allowed-ips"], text=True, timeout=5)
except Exception:
return {}
data = {}
for line in out.splitlines():
parts = line.split()
if len(parts) >= 2:
data[parts[0].strip()] = parts[1].strip()
return data
def _orphan_client_id(tunnel_ip):
tip = (tunnel_ip or "").split("/")[0].strip() or "unknown"
return "orphan-" + re.sub(r"[^a-zA-Z0-9]+", "-", tip).strip("-")
def get_clients(include_orphans=False):
"""Read all clients from /opt/Phobos/clients/*/metadata.json."""
clients = []
clients_path = Path(CLIENTS_DIR)
if not clients_path.exists():
clients_path = None
if clients_path:
for d in sorted(clients_path.iterdir()):
meta_file = d / "metadata.json"
if meta_file.exists():
try:
with open(meta_file) as f:
meta = json.load(f)
meta["_dir"] = str(d)
clients.append(meta)
except Exception:
pass
if include_orphans:
known = {c.get("public_key", ""): c for c in clients if c.get("public_key", "")}
allowed = get_wg_allowed_ips()
for pub, ip in allowed.items():
if pub in known:
continue
tip = (ip or "").split(",")[0].strip()
clients.append({
"client_id": _orphan_client_id(tip),
"client_name": _orphan_client_id(tip),
"tunnel_ip_v4": tip,
"public_key": pub,
"created_at": "",
"_orphan": True,
})
return clients
def purge_orphan_peer(public_key):
pub = (public_key or "").strip()
if not pub:
return False, "empty public key"
try:
subprocess.run(["wg", "set", "wg0", "peer", pub, "remove"], check=True, timeout=5)
except Exception as e:
return False, f"wg remove failed: {e}"
try:
cfg = Path(WG_CONFIG)
if cfg.exists():
text = cfg.read_text()
blocks = [b for b in re.split(r"\n\s*\n", text.strip()) if b.strip()]
kept = []
for block in blocks:
if pub in block and "[Peer]" in block:
continue
kept.append(block)
cfg.write_text("\n\n".join(kept).strip() + "\n")
except Exception:
pass
return True, f"orphan peer removed: {pub[:12]}..."
def get_active_sessions():
"""Combine WG peers with client metadata to build session list."""
peers = get_wg_peers()
clients = get_clients(include_orphans=True)
pub_to_client = {}
for c in clients:
pub_to_client[c.get("public_key", "")] = c
sessions = []
for pub_key, info in peers.items():
handshake = info.get("latest handshake", "")
if not handshake:
continue
client = pub_to_client.get(pub_key, {})
client_id = client.get("client_id", "unknown")
tunnel_ip = client.get("tunnel_ip_v4", "")
endpoint = info.get("endpoint", "")
real_ip = endpoint.split(":")[0] if endpoint else ""
rx = info.get("transfer", "")
rx_bytes = rx.split("received,")[0].strip() if "received," in rx else ""
tx_bytes = rx.split("received,")[1].strip().replace("sent", "").strip() if "received," in rx else ""
sessions.append({
"client_id": client_id,
"public_key": pub_key,
"tunnel_ip": tunnel_ip,
"real_ip": real_ip,
"endpoint": endpoint,
"handshake": handshake,
"rx": rx_bytes,
"tx": tx_bytes,
})
return sessions
def is_peer_online(handshake_str):
"""Check if peer had a handshake within last 3 minutes."""
s = (handshake_str or "").strip()
# never handshaked ("" / "(none)") or days old -> offline
if not re.search(r"\d", s) or "day" in s or "week" in s:
return False
try:
parts = s.split(",")
total_seconds = 0
for p in parts:
p = p.strip()
if "minute" in p:
total_seconds += int(re.search(r"(\d+)", p).group(1)) * 60
elif "second" in p:
total_seconds += int(re.search(r"(\d+)", p).group(1))
elif "hour" in p:
total_seconds += int(re.search(r"(\d+)", p).group(1)) * 3600
return total_seconds < 180
except Exception:
return False
def kick_peer(public_key):
"""Remove and re-add peer to force disconnect."""
try:
out = subprocess.check_output(["wg", "show", "wg0"], text=True, timeout=5)
allowed = ""
found = False
for line in out.split("\n"):
if line.strip().startswith("peer:") and public_key in line:
found = True
elif found and "allowed ips:" in line:
allowed = line.split("allowed ips:")[1].strip()
break
subprocess.run(["wg", "set", "wg0", "peer", public_key, "remove"], timeout=5)
if allowed:
subprocess.run(["wg", "set", "wg0", "peer", public_key, "allowed-ips", allowed], timeout=5)
return True
except Exception:
return False
def check_expiry():
"""Check subscription expiry, lock expired clients."""
s = load_settings() # Always reload fresh to avoid overwriting concurrent changes
subs = s.get("subscriptions", {})
today = datetime.now().date()
changed = False
for client_id, info in list(subs.items()):
if not info.get("expiry"):
continue
try:
exp_date = datetime.strptime(info["expiry"], "%Y-%m-%d").date()
except ValueError:
continue
days_left = (exp_date - today).days
if days_left <= 0 and not info.get("locked"):
info["locked"] = True
changed = True
kick_client_by_id(client_id)
tg_send(f"⛔ {client_id} — подписка истекла! Клиент заблокирован.")
elif days_left == 3 and not info.get("warn3"):
info["warn3"] = True
changed = True
tg_send(f"⚠️ {client_id} — подписка истекает через 3 дня ({info['expiry']})")
elif days_left == 1 and not info.get("warn1"):
info["warn1"] = True
changed = True
tg_send(f"⚠️ {client_id} — подписка истекает ЗАВТРА ({info['expiry']})")
if changed:
save_settings(s)
def kick_client_by_id(client_id):
"""Find client's public key and kick them."""
clients = get_clients()
for c in clients:
if c.get("client_id") == client_id:
kick_peer(c.get("public_key", ""))
return True
return False
prev_session_keys = None
prev_server_status = {}
_down_streak = {}
_last_fanout = 0
_last_peer_sync = 0
server_stats_cache = {}
server_handshakes_cache = {}
server_transfer_cache = {} # server ip -> {pub: [rx, tx]} (from secondary /api/health)
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)
_net_prev = {} # key -> (rx, tx, ts) for rate calc
BW_CAP_MBPS = float(os.environ.get("BW_CAP_MBPS", "1000")) # link cap ref for load fraction
_geo_cache = {}
def _ip_country(ip):
"""Flag + country code for a public IP (ip-api.com, cached). '' for private."""
ip = (ip or "").strip()
if not ip or ip.startswith(("10.", "192.168.", "127.")) or ip.startswith("172.16."):
return ""
if ip in _geo_cache:
return _geo_cache[ip]
res = ""
try:
import urllib.request as _u, json as _j
d = _j.loads(_u.urlopen(f"http://ip-api.com/json/{ip}?fields=countryCode", timeout=4).read())
cc = (d.get("countryCode") or "").upper()
if len(cc) == 2 and cc.isalpha():
flag = "".join(chr(0x1F1E6 + ord(c) - 65) for c in cc)
res = f"{flag} {cc}"
except Exception:
res = ""
_geo_cache[ip] = res
return res
def _calc_mbps(key, rx, tx, ts):
"""Throughput Mbps from consecutive NIC byte counters. First sample -> 0."""
prev = _net_prev.get(key)
_net_prev[key] = (rx, tx, ts)
if not prev:
return 0.0
prx, ptx, pts = prev
dt = ts - pts
if dt <= 0 or rx < prx or tx < ptx: # no time passed / counter reset
return 0.0
return round(((rx - prx) + (tx - ptx)) * 8 / (dt * 1e6), 1)
def _local_net_bytes():
try:
iface = subprocess.getoutput("ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i{tr("Возможно, ссылка устарела или раздел был переименован.","Link may be outdated or section was renamed.")}
{tr("Панель поймала ошибку. Обнови страницу. Если повторяется — проверь логи сервиса phobos-panel/phobos-dev-test.","Panel hit an error. Refresh page. If it repeats, check phobos-panel/phobos-dev-test logs.")}
{tr("На главную","Back to dashboard")}{tr("Лимит загрузки 64 MB. Уменьши файл или восстанавливай через shell.","Upload limit is 64 MB. Shrink file or restore via shell.")}
{tr("К настройкам","Back to settings")}")
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()
msg = ""
if request.method == "POST":
rip = request.headers.get("X-Forwarded-For", request.remote_addr or "?").split(",")[0].strip()
if not login_allowed(rip):
msg = f'{tr("Слишком много попыток. Подожди 15 минут.","Too many attempts. Wait 15 minutes.")}'
elif hmac.compare_digest(request.form.get("password", ""), s["admin_pass"]):
login_reset(rip)
session["auth"] = True
session.permanent = True
return redirect(url_for("dashboard"))
else:
login_failed(rip)
msg = f'{tr("Неверный пароль","Wrong password")}'
html = f"""
{tr("Вход в панель","Sign in")}
{msg}
"""
return render(html)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
def auth_required(f):
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("auth"):
return redirect(url_for("login"))
return f(*args, **kwargs)
return decorated
@app.route("/tags")
@auth_required
def tags_alias():
return redirect(url_for("labels_page"))
@app.route("/api/backup/download")
@auth_required
def backup_download():
"""Create a full server backup and stream it (contains private keys)."""
from flask import send_file
try:
r = subprocess.run(["bash", BACKUP_SCRIPT, "create"],
capture_output=True, text=True, timeout=60)
path = (r.stdout or "").strip().splitlines()[-1] if r.stdout.strip() else ""
if not path or not os.path.exists(path):
return ("backup failed: " + (r.stderr or "no output")[-300:], 500)
return send_file(path, as_attachment=True, download_name=os.path.basename(path),
mimetype="application/gzip")
except Exception as e:
return ("backup error: " + str(e), 500)
@app.route("/api/backup/restore", methods=["POST"])
@auth_required
def backup_restore():
"""Restore a server from an uploaded backup tarball."""
import tempfile
f = request.files.get("backup")
if not f:
return ("no file", 400)
tmp = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
try:
f.save(tmp.name)
r = subprocess.run(["bash", BACKUP_SCRIPT, "restore", tmp.name],
capture_output=True, text=True, timeout=120)
out = html_lib.escape((r.stdout + r.stderr)[-1200:])
return render(f'''{nav("settings")}''')
finally:
try:
os.unlink(tmp.name)
except Exception:
pass
# ── ttyd web-terminal for Keenetic routers (over WG tunnel) ─────────────────
# Router runs ttyd (web pty) bound to its tunnel IP, firewalled to the panel
# only. Panel reverse-proxies HTTP + bridges the websocket so the browser gets
# a full shell behind NAT without SSH. (awg-manager-style, but over our tunnel.)
def _ttyd_upstream(cid):
acc = load_settings().get("router_access", {}).get(cid, {})
ip = (acc.get("ttyd_ip") or acc.get("ssh_ip") or "").strip()
if not ip:
client = next((c for c in get_clients() if c.get("client_id") == cid), None)
ip = (client or {}).get("tunnel_ip_v4", "").split("/")[0]
port = str(acc.get("ttyd_port", "7681") or "7681")
token = acc.get("ttyd_token", "")
return ip, port, token
def _ttyd_auth_header(token):
import base64
return "Basic " + base64.b64encode(("phobos:" + token).encode()).decode()
@app.route("/ttyd//", defaults={"subpath": ""})
@app.route("/ttyd//", methods=["GET", "POST"])
@auth_required
def ttyd_proxy(cid, subpath):
import urllib.request, urllib.error
ip, port, token = _ttyd_upstream(cid)
if not ip:
return ("ttyd: tunnel IP unknown", 502)
url = f"http://{ip}:{port}/ttyd/{cid}/{subpath}"
data = request.get_data() if request.method == "POST" else None
req = urllib.request.Request(url, data=data, method=request.method,
headers={"Authorization": _ttyd_auth_header(token)})
try:
r = urllib.request.urlopen(req, timeout=12)
body = r.read()
ct = r.headers.get("Content-Type", "text/html")
return (body, r.status, {"Content-Type": ct})
except urllib.error.HTTPError as e:
return (e.read(), e.code, {"Content-Type": e.headers.get("Content-Type", "text/plain")})
except Exception as e:
return (f"ttyd proxy error: {e}", 502)
if sock:
@sock.route("/ttyd//ws")
def ttyd_ws(ws, cid):
import threading
if not session.get("auth"): # WS routes bypass @auth_required — gate here
return
try:
import websocket as _wsclient
except Exception:
ws.send("ttyd bridge: websocket-client not installed (phobos-update dev)\r\n")
return
ip, port, token = _ttyd_upstream(cid)
if not ip:
return
try:
up = _wsclient.create_connection(
f"ws://{ip}:{port}/ttyd/{cid}/ws",
header=["Authorization: " + _ttyd_auth_header(token)],
subprotocols=["tty"], timeout=12)
except Exception:
return
stop = {"v": False}
def up_to_browser():
try:
while not stop["v"]:
opcode, data = up.recv_data(True)
if data is None:
break
ws.send(data if isinstance(data, (bytes, bytearray)) else data)
except Exception:
pass
stop["v"] = True
t = threading.Thread(target=up_to_browser, daemon=True)
t.start()
try:
while not stop["v"]:
msg = ws.receive(timeout=30)
if msg is None:
continue
if isinstance(msg, (bytes, bytearray)):
up.send_binary(msg)
else:
up.send(msg)
except Exception:
pass
finally:
stop["v"] = True
try:
up.close()
except Exception:
pass
@app.route("/console/router/")
@auth_required
def ttyd_console(cid):
ip, port, token = _ttyd_upstream(cid)
ready = bool(ip and token)
note = "" if ready else tr(
"ttyd-терминал не настроен. Включи его кнопкой ниже (нужен запуск phobos-ttyd на роутере).",
"ttyd terminal not configured. Enable it below (run phobos-ttyd on the router).")
if ready:
body = f''
else:
body = (f'{note}'
f'')
return render(f'''{nav('clients')}
🖥️ {tr("Терминал","Terminal")} {_html_attr(cid)} {hlp("Полноценный shell роутера в браузере через ttyd. Работает за NAT по WG-туннелю (без SSH). ttyd слушает только панель.", "Full router shell in the browser via ttyd. Works behind NAT over the WG tunnel (no SSH). ttyd is reachable only by the panel.")}
{tr("Назад","Back")}
{body}
''')
@app.route("/console/router//enable", methods=["POST"])
@auth_required
def ttyd_enable(cid):
import secrets
s = load_settings()
ra = s.setdefault("router_access", {})
acc = ra.setdefault(cid, {})
token = acc.get("ttyd_token") or secrets.token_hex(12)
client = next((c for c in get_clients() if c.get("client_id") == cid), None)
tip = acc.get("ttyd_ip") or acc.get("ssh_ip") or (client or {}).get("tunnel_ip_v4", "").split("/")[0]
acc.update({"ttyd_token": token, "ttyd_ip": tip, "ttyd_port": acc.get("ttyd_port", "7681")})
save_settings(s)
srv_tip = server_tunnel_ip()
panel = f"http://{srv_tip}:{request.host.split(':')[-1] if ':' in request.host else '8443'}"
cmd = f"curl -fsSL {panel}/router/phobos-ttyd.sh | sh -s {cid} {token} {srv_tip}"
return render(f'''{nav('clients')}
⌨️ {tr("Включение терминала","Enable terminal")} — {_html_attr(cid)}
{tr("Выполни ОДНУ команду на роутере (SSH/консоль Entware) — поставит ttyd и откроет порт только для панели:","Run ONE command on the router (Entware SSH/console) — installs ttyd and opens the port only to the panel:")}
{tr("После запуска вернись на страницу терминала — появится shell в браузере.","After running it, return to the terminal page — the browser shell appears.")}
{tr("← к терминалу","← to terminal")}
''')
@app.route("/router/phobos-ttyd.sh")
def serve_ttyd_script():
"""Unauthenticated: routers fetch the ttyd setup script over the tunnel.
Script carries no secrets (token passed as arg by the operator)."""
for p in ("/opt/Phobos/repo/client/templates/phobos-ttyd.sh",
"/opt/Phobos/www/phobos-ttyd.sh"):
if os.path.exists(p):
return (open(p).read(), 200, {"Content-Type": "text/x-shellscript"})
return ("phobos-ttyd.sh not found", 404)
@app.route("/")
@auth_required
def dashboard():
return redirect(url_for("sessions_page"))
@app.route("/sessions")
@auth_required
def sessions_page():
s = load_settings()
labels = s.get("labels", {})
sessions = get_active_sessions()
# 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 = 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(sess.get("tunnel_ip", ""), "") or labels.get(real_ip, "")
disp = client_display_name(sess["client_id"], s)
disp_html = html_lib.escape(disp)
client_cell = disp_html if disp == sess["client_id"] else f'{disp_html}{sess["client_id"]}'
rows += f"""
{client_cell}
{sess['tunnel_ip']}
{real_ip}
{('' + label + '') if label else '—'}
{sess['handshake']}
{sess['rx']} / {sess['tx']}
{status}
{tr("Отключить","Kick")}{hlp("Принудительно разорвать текущую сессию клиента (сбросить WG-пир). Клиент переподключится автоматически.","Force-drop the client current session (reset the WG peer). The client reconnects automatically.")}
"""
if not rows:
rows = '{tr("Нет активных сессий","No active sessions")} '
html = f"""
{nav('sessions', online_count)}
{tr("Активные сессии","Active sessions")}
Клиент VPN IP Real IP Метка Handshake RX / TX Статус
{rows}
"""
return render(html)
@app.route("/kick/")
@auth_required
def kick(pub_key):
kick_peer(pub_key)
return redirect(url_for("sessions_page"))
def _html_attr(value):
return html_lib.escape(str(value or ""), quote=True)
def _terminal_button(url, title):
return (f'🖥️')
def _fmt_bytes(n):
for u in ("B", "KiB", "MiB", "GiB", "TiB"):
if n < 1024 or u == "TiB":
return f"{n:.2f} {u}" if u != "B" else f"{int(n)} B"
n /= 1024
def _traffic_cell(u):
if not u:
return "—"
return f"↓ {_fmt_bytes(u[1])} · ↑ {_fmt_bytes(u[0])}" # server tx = client download
@app.route("/api/traffic")
@auth_required
def api_traffic():
"""Monthly traffic per client: current month + history ({client: [rx, tx]}, server-side bytes)."""
d = _traffic_load()
return jsonify({"month": d.get("month"), "usage": d.get("usage", {}), "history": d.get("history", {})})
@app.route("/clients", methods=["GET", "POST"])
@auth_required
def clients_page():
s = load_settings()
subs = s.get("subscriptions", {})
msg = ""
if request.method == "POST":
action = request.form.get("action")
if action == "add":
name = request.form.get("name", "").strip()
if name and re.match(r"^[a-zA-Z0-9_-]+$", name):
ok, rep = create_client_cli(name)
if ok:
msg = f'{rep}'
else:
msg = f'{rep}'
else:
msg = 'Имя: буквы, цифры, _ и -'
elif action == "delete":
client_id = request.form.get("client_id", "").strip()
if client_id:
ok, rep = delete_client_cli(client_id)
if ok:
msg = f'{rep}'
else:
msg = f'{rep}'
elif action == "purge_orphan":
public_key = request.form.get("public_key", "").strip()
ok, rep = purge_orphan_peer(public_key)
msg = f'{rep}'
elif action == "toggle_warp_client":
client_id = request.form.get("client_id", "").strip()
desired = request.form.get("desired", "").strip() == "on"
if client_id:
ok, rep = warp_toggle_client(client_id, desired)
msg = f'{rep}'
elif action == "toggle_warp_client":
client_id = request.form.get("client_id", "").strip()
desired = request.form.get("desired", "").strip() == "on"
if client_id:
ok, rep = warp_toggle_client(client_id, desired)
msg = f'{rep}'
elif action == "rename_client":
client_id = request.form.get("client_id", "").strip()
new_name = request.form.get("display_name", "").strip()
if client_id:
aliases = s.setdefault("client_aliases", {})
if new_name:
if not re.match(r"^[\w. -]{1,64}$", new_name):
msg = f'{tr("Имя: буквы, цифры, пробел, _, -, точка.", "Name: letters, digits, space, _, -, dot.")}'
else:
aliases[client_id] = new_name
save_settings(s)
msg = f'{tr("Имя сохранено:", "Name saved:")} {html_lib.escape(new_name)}'
else:
aliases.pop(client_id, None)
save_settings(s)
msg = f'{tr("Псевдоним очищен.", "Alias cleared.")}'
elif action == "toggle_notify":
client_id = request.form.get("client_id", "").strip()
if client_id:
cn = s.setdefault("client_notify", {})
cn[client_id] = not cn.get(client_id, True)
save_settings(s)
st = "вкл" if cn[client_id] else "выкл"
msg = f'Уведомления {client_id}: {st}'
elif action == "set_limit":
client_id = request.form.get("client_id", "").strip()
raw = request.form.get("limit_mbit", "").strip()
if client_id and (raw == "" or raw.isdigit()):
lim = s.setdefault("speed_limits", {})
if raw in ("", "0"):
lim.pop(client_id, None)
else:
lim[client_id] = int(raw)
save_settings(s)
ok, rep = apply_speed_limits()
msg = f'{html_lib.escape(rep)}'
elif action == "set_expiry":
client_id = request.form.get("client_id", "").strip()
expiry = request.form.get("expiry", "").strip()
if client_id:
if client_id not in subs:
subs[client_id] = {}
subs[client_id]["expiry"] = expiry
subs[client_id].pop("locked", None)
subs[client_id].pop("warn3", None)
subs[client_id].pop("warn1", None)
save_settings(s)
msg = f'Срок для {client_id} обновлён'
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(include_orphans=True)
peers = get_wg_peers()
online_pubs = set()
for pub, info in peers.items():
if is_peer_online(info.get("latest handshake", "")):
online_pubs.add(pub)
all_srv = get_all_servers_ordered()
assignments = s.get("client_assignments", {})
assignments_notify = s.get("client_notify", {})
aliases = s.get("client_aliases", {})
warp_cfg = get_warp_settings(s)
warp_enabled_clients = set(str(x).strip() for x in s.get("warp_clients", []) if str(x).strip())
warp_state = warp_status()
# 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)
limits = s.get("speed_limits", {})
rows = ""
today = datetime.now().date()
for c in clients:
cid = c.get("client_id", "")
disp = str(aliases.get(cid, "") or "").strip() or cid
disp_input = _html_attr(aliases.get(cid, ""))
disp_html = html_lib.escape(disp)
pub = c.get("public_key", "")
ip = c.get("tunnel_ip_v4", "")
is_orphan = bool(c.get("_orphan"))
created = c.get("created_at", "")[:10]
is_online = pub in online_all
status = 'Online' if is_online else 'Offline'
if is_orphan:
status += ' orphan'
sub = subs.get(cid, {})
expiry = sub.get("expiry", "")
locked = sub.get("locked", False)
expiry_badge = ""
if expiry:
try:
exp_date = datetime.strptime(expiry, "%Y-%m-%d").date()
days = (exp_date - today).days
if locked:
expiry_badge = 'expired'
elif days <= 1:
expiry_badge = f'{days}d'
elif days <= 3:
expiry_badge = f'{days}d'
else:
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 + "?")
_notify = assignments_notify.get(cid, True)
notify_icon = "🔔" if _notify else "🔕"
notify_bg = "#4f46e5" if _notify else "#64748b"
notify_ttl = tr("Уведомления в Telegram включены — нажми, чтобы выключить", "Telegram notifications ON — click to turn off") if _notify else tr("Уведомления выключены — нажми, чтобы включить", "Notifications OFF — click to turn on")
term_btn = ""
if not is_orphan:
term_btn = _terminal_button(f"/terminal/router/{cid}", tr("SSH терминал роутера", "Router SSH terminal"))
rci_btn = (f'🛠️')
tty_btn = (f'⌨️')
term_btn = term_btn + " " + rci_btn + " " + tty_btn
warp_on = cid in warp_enabled_clients
warp_mode_ok = warp_cfg.get("mode") == "egress"
warp_btn_bg = "#0f766e" if warp_on else "#475569"
warp_btn_lbl = "WARP ON" if warp_on else "WARP OFF"
warp_btn_title = tr("Индивидуальный WARP для этого клиента", "Per-client WARP for this client")
if not warp_mode_ok:
warp_btn_bg = "#334155"
warp_btn_title = tr("Per-client WARP работает только в режиме egress", "Per-client WARP works only in egress mode")
elif warp_state.get("service") != "active":
warp_btn_title = tr("Сохранит выбор. Применится после включения WARP egress.", "Saves choice. Applies after WARP egress is enabled.")
server_cell = f"""
"""
expiry_cell = f"""
"""
actions_cell = f"""
📱
🍎
📡
{term_btn}
{h_del}
"""
if is_orphan:
server_cell = f'{tr("Сиротский peer в wg0", "Orphan peer in wg0")} {hlp("Панель видит WireGuard peer и handshake, но папка клиента и metadata отсутствуют. Такое бывает после ручной чистки или старого бага. Можно оставить как есть, либо очистить peer кнопкой Purge.", "Panel sees live WireGuard peer and handshake, but client folder and metadata are missing. This can happen after manual cleanup or old bug. You can keep it, or remove peer with Purge button.")}'
expiry_cell = '—'
actions_cell = f"""
{tr("Отключить", "Kick")}
"""
client_cell = html_lib.escape(disp)
if disp != cid:
client_cell += f'{cid}'
rename_box = f"""
"""
_tusage = _traffic_load().get("usage", {}) if "_tusage" not in dir() else _tusage
lim = str(limits.get(cid, "") or "")
rate = client_rates.get(pub)
speed = (f'↓{rate[0]:.1f} ↑{rate[1]:.1f} {tr("Мбит/с","Mbit/s")}' if rate else "")
limit_cell = "" if is_orphan else f"""
"""
rows += f"""
{client_cell} {ip} {status} {expiry_badge} {_traffic_cell(_tusage.get(cid))} {f'≤{_html_attr(lim)} Mbit' if lim and lim != "0" else ""} {speed}
{server_cell}{expiry_cell}{limit_cell}{rename_box}
{actions_cell}
"""
if not rows:
rows = f'{tr("Пока нет клиентов. Создай первого клиента выше.","No clients yet. Create first client above.")}
'
page_html = f"""
{nav('clients')}
{msg}
{tr("VPN-клиенты", "VPN Clients")}
⚡ {tr("Скорость на устройстве клиента (с включённым VPN)","Speed on the client device (VPN on)")}: http://{server_tunnel_ip()}:{panel_port()}/speedtest
{rows}
"""
return render(page_html)
@app.route("/labels", methods=["GET", "POST"])
@auth_required
def labels_page():
s = load_settings()
msg = ""
if request.method == "POST":
action = request.form.get("action")
if action == "add":
ip = request.form.get("ip", "").strip()
label = request.form.get("label", "").strip()
if ip and label:
s["labels"][ip] = label
save_settings(s)
msg = f'Метка добавлена: {ip} → {label}'
elif action == "delete":
ip = request.form.get("ip", "").strip()
s["labels"].pop(ip, None)
save_settings(s)
msg = f'Метка удалена'
labels = s.get("labels", {})
rows = ""
for ip, label in sorted(labels.items()):
rows += f"""
{ip} {label}
"""
html = f"""
{nav('labels')}
{msg}
{tr("Метки (по IP)","Labels (by IP)")} {hlp("Человекочитаемые имена для IP/туннелей — показываются в Сессиях вместо голого адреса.","Human-readable names for IPs/tunnels — shown in Sessions instead of the raw address.")}
Привяжите понятное имя (квартира, офис, дача) к внешнему IP адресу роутера. Метка отображается в таблице сессий и в Telegram уведомлениях вместо голого IP.
Real IP Метка
{rows}
"""
return render(html)
@app.route("/settings", methods=["GET", "POST"])
@auth_required
def settings_page():
s = load_settings()
msg = ""
if request.method == "POST" and request.form.get("action") in (
"update", "rollback", "check", "save_token",
"warp_save", "warp_enable", "warp_disable", "warp_status"
):
_act = request.form.get("action")
# subscriber token may be pasted in the version card -> persist for beta/dev
_tok = request.form.get("gh_token", "").strip()
if _tok:
save_gh_token(_tok)
warp_cfg = apply_warp_form(s)
save_settings(s)
if _act == "save_token":
_out = tr("Токен подписчика сохранён. Теперь можно переключаться на beta/dev.",
"Subscriber token saved. You can now switch to beta/dev.") if _tok else \
tr("Поле токена пустое.", "Token field is empty.")
elif _act == "warp_save":
_out = tr("WARP настройки сохранены.", "WARP settings saved.")
elif _act == "update":
_out = run_update(request.form.get("target", "").strip() or "main")
elif _act == "check":
_out = run_update("--check")
elif _act == "warp_enable":
if warp_cfg["mode"] == "relay":
_out = run_warp("enable", "relay", warp_cfg["profile_source"],
warp_cfg["relay_ports"], warp_cfg["relay_target"])
else:
_out = run_warp("enable", "egress", warp_cfg["profile_source"])
for _cid in [str(x).strip() for x in s.get("warp_clients", []) if str(x).strip()]:
_cl = next((c for c in get_clients() if c.get("client_id") == _cid), None)
_tip = ((_cl or {}).get("tunnel_ip_v4") or "").split("/")[0].strip()
if _tip:
run_warp("client-enable", _tip)
elif _act == "warp_disable":
_out = run_warp("disable")
elif _act == "warp_status":
_out = run_warp("status")
else:
_out = run_update("--rollback")
s = load_settings()
return render(f'''{nav("settings")}''')
if request.method == "POST" and request.form.get("action") in (
"domain_set", "domain_remove", "endpoints_save",
"guard_enable", "guard_disable", "guard_update"
):
_act = request.form.get("action")
if _act == "domain_set":
ok, _out = run_server_script("phobos-domain.sh", "set",
request.form.get("domain", "").strip(),
request.form.get("domain_email", "").strip(), timeout=300)
elif _act == "domain_remove":
ok, _out = run_server_script("phobos-domain.sh", "remove")
elif _act == "endpoints_save":
ok, _out = save_client_endpoints(request.form)
else:
ok, _out = run_server_script("phobos-guard.sh", _act.split("_", 1)[1], timeout=300)
cls = "alert-ok" if ok else "alert-error"
return render(f'''{nav("settings")}
{tr("Результат", "Result")}
{tr("Готово", "Done") if ok else tr("Ошибка", "Error")}
{html_lib.escape(_out)}
{tr("← к настройкам", "← back to settings")}''')
if request.method == "POST" and request.form.get("action") == "save_obf":
masking = request.form.get("obf_masking", "AUTO").strip().upper()
if masking not in ("AUTO", "STUN", "NONE"):
masking = "AUTO"
s["obf_params"] = {
"masking": masking,
"max_dummy": request.form.get("obf_max_dummy", "4").strip() or "4",
"idle": request.form.get("obf_idle", "300").strip() or "300",
}
save_settings(s)
pushed = 0
for c in get_clients():
try:
fanout_router_config(c.get("client_id", ""))
pushed += 1
except Exception:
pass
msg = f'{tr("Параметры обфускатора сохранены и отправлены на роутеры", "Obfuscator params saved and pushed to routers")} ({pushed})'
elif request.method == "POST" and request.form.get("action") == "save_srv_obf":
masking = request.form.get("srv_masking", "AUTO").strip().upper()
if masking not in ("AUTO", "STUN", "NONE"):
masking = "AUTO"
try:
dummy = str(min(255, max(0, int(request.form.get("srv_max_dummy", "50")))))
idle = str(min(3600, max(10, int(request.form.get("srv_idle", "300")))))
except ValueError:
dummy, idle = "50", "300"
ports = write_server_obf({"masking": masking, "max-dummy": dummy, "idle-timeout": idle})
msg = f'{tr("Серверный обфускатор перезапущен с новыми параметрами, порты", "Server obfuscator restarted with new params, ports")}: {", ".join(ports) or "—"}'
elif request.method == "POST":
new_pass = request.form.get("admin_pass", "").strip()
if new_pass:
s["admin_pass"] = new_pass
s["tg_bot_token"] = request.form.get("tg_bot_token", "").strip()
s["tg_chat_id"] = request.form.get("tg_chat_id", "").strip()
try:
s["monitor_interval"] = max(10, int(request.form.get("monitor_interval", 30)))
except ValueError:
pass
save_settings(s)
msg = 'Настройки сохранены'
warp_cfg = get_warp_settings(s)
custom_warp_profile = read_custom_warp_profile()
warp_license_key = read_warp_license_key()
warp = warp_status()
srv_obf = read_server_obf()
warp_service = warp.get("service", "unknown")
warp_state = tr("включён", "enabled") if warp.get("enabled") else tr("выключен", "disabled")
warp_subnet = _html_attr(warp.get("subnet", server_wg_network()))
warp_endpoint = _html_attr(warp.get("endpoint", ""))
warp_egress = _html_attr(warp.get("egress_ipv4", ""))
warp_error = _html_attr(warp.get("error", ""))
warp_mode = _html_attr(warp.get("mode", warp_cfg.get("mode", "egress")))
warp_profile_source = _html_attr(warp.get("profile_source", warp_cfg.get("profile_source", "free")))
warp_relay_ports = _html_attr(warp.get("relay_ports", warp_cfg.get("relay_ports", "4500")))
warp_relay_target = _html_attr(warp.get("relay_target", warp_cfg.get("relay_target", "")))
warp_client_count = len(warp.get("clients", [])) if isinstance(warp.get("clients", []), list) else len(s.get("warp_clients", []))
custom_profile_saved = bool(warp.get("custom_profile_saved")) or bool(custom_warp_profile.strip())
warp_license_saved = bool(warp.get("license_key_saved")) or bool(warp_license_key.strip()) or bool(warp_cfg.get("license_key", "").strip())
dom = script_status("phobos-domain.sh")
guard = script_status("phobos-guard.sh")
dom_q = tr("Убрать домен?", "Remove domain?")
dom_remove = (f''
if dom.get("domain") else "")
eps = s.get("endpoints", {})
ep_rows = "".join(
f''
f''
for sv in get_all_servers_ordered())
_cu = f"{panel_base_url()}/server/phobos-cascade.sh"
cascade_cmd = f"SELF_URL={_cu} bash <(curl -kfsSL {_cu}) enable {SERVER_IP} {_load_server_env().get('OBFUSCATOR_PORTS', '2083,5443,993')}"
extras_html = f"""
{tr("Домен панели","Panel domain")} {hlp("Панель по адресу https://ваш-домен/ с бесплатным сертификатом Let's Encrypt (nginx на 443). Домен (A-запись) должен указывать на IP этого сервера, порт 80 открыт. Старый адрес http://IP:порт продолжает работать — роутеры и второй сервер ничего не заметят.", "Panel at https://your-domain/ with a free Let's Encrypt certificate (nginx on 443). The domain A record must point to this server, port 80 open. The old http://IP:port keeps working — routers and secondaries notice nothing.")}
{f'{tr("Сейчас","Now")}: https://{_html_attr(dom.get("domain"))}/ · {tr("сертификат до","certificate until")} {_html_attr(dom.get("cert_expires") or "—")}
' if dom.get("domain") else f'{tr("Домен не задан.","No domain set.")}
'}
{dom_remove}
{tr("Адрес для клиентов и каскад","Client address & cascade")} {hlp("Куда подключаются клиенты для каждого сервера. Пусто = IP сервера. Можно вписать домен (A-запись на сервер) — сменится IP, поменяешь только DNS. Каскад: поставь на входной VPS (например, в РФ) ретранслятор командой ниже и впиши сюда IP/домен этого входа — клиенты пойдут вход → этот сервер. Роутеры получат новый адрес сами через pull (~15 с), телефонам нужно заново открыть ссылку/QR.", "Which host clients dial for each server. Empty = server IP. Put a domain (A record) to survive IP changes. Cascade: install the relay on an entry VPS with the command below and put that entry IP/domain here — clients go entry -> this server. Routers get it via pull (~15s); phones must reopen the link/QR.")}
{tr("Каскад — выполни на входном VPS (чистый Debian/Ubuntu, root):","Cascade — run on the entry VPS (clean Debian/Ubuntu, root):")}
{cascade_cmd}
{tr("Отключить: phobos-cascade disable · статус: phobos-cascade status. iOS/обычный WireGuard (порт 51820) через каскад не идёт.","Disable: phobos-cascade disable · status: phobos-cascade status. iOS/plain WireGuard (port 51820) does not go through the cascade.")}
{tr("Защита сервера (сторож)","Server protection (guard)")} {hlp("Блокирует подсети зондов и сканеров РКН до того, как они дойдут до сервера (nftables). Список IP — из проекта Loorrr293/Auto_IPtables, обновляется раз в сутки. Частные сети (LAN, туннель) никогда не блокируются.", "Drops RKN probe/scanner subnets before they reach the server (nftables). IP list from Loorrr293/Auto_IPtables, refreshed daily. Private networks (LAN, tunnel) are never blocked.")}
{tr("Статус","Status")}: {tr("включён","enabled") if guard.get("enabled") else tr("выключен","disabled")}
· {tr("подсетей","networks")}: {guard.get("networks", 0)} · {tr("отброшено пакетов","dropped packets")}: {guard.get("dropped_packets", 0)}
{tr("«Прикрывашка» (WARP на выход + Psiphon, автор miha75vu-bit/Flashkeen) — интерактивная, ставится по SSH: bash <(curl -fsSL https://raw.githubusercontent.com/miha75vu-bit/Flashkeen/main/wp). Она перестраивает выход wg0 — не включай одновременно со встроенным WARP egress ниже.","“Prikryvashka” (WARP egress + Psiphon by miha75vu-bit/Flashkeen) is interactive, run it over SSH: bash <(curl -fsSL https://raw.githubusercontent.com/miha75vu-bit/Flashkeen/main/wp). It rebuilds wg0 egress — do not combine with the built-in WARP egress below.")}
"""
html = f"""
{nav('settings')}
{msg}
{tr("Настройки панели","Panel settings")}
{extras_html}
{tr("Версия и обновления","Version & updates")} {hlp("Скачивает свежие файлы PCA-слоя (панель + скрипты) с GitHub и перезапускает панель. Ключи WireGuard, server.env и клиенты НЕ трогаются. Пусто или main = последняя версия; можно указать тег (например v1.1.0). Откат восстанавливает предыдущую версию из автоматического бэкапа.", "Fetches the latest PCA files (panel + scripts) from GitHub and restarts the panel. WireGuard keys, server.env and clients are NOT touched. Empty or main = latest; you can pass a tag (e.g. v1.1.0). Rollback restores the previous version from an automatic backup.")}
{tr("Установленная версия","Installed version")}: {panel_version()}
{tr("Каналы","Channels")}:
stable — {tr("стабильная","stable")} ·
beta — {tr("тест","testing")} ·
dev — {tr("разработка","development")}.
{tr("С 1.4.0 все каналы открыты и берутся с git.bobyrev.com (GitHub — запасной). Токен больше не нужен.","Since 1.4.0 all channels are open and served from git.bobyrev.com (GitHub as fallback). No token needed.")}
{tr("Бэкап и восстановление","Backup & restore")} {hlp("Полный бэкап состояния сервера: ключ WireGuard, server.env, все клиенты и ключи, токены, настройки панели (метки, назначения, доступы, токен подписчика). НЕ включает бинарники/пакеты. Восстановление на чистом VPS: сначала turnkey-установка, потом загрузи бэкап здесь — ключ сервера вернётся, клиенты переподключатся без пересоздания. ВНИМАНИЕ: файл содержит приватные ключи — храни в защищённом месте.", "Full server state backup: WireGuard key, server.env, all clients and keys, tokens, panel settings (labels, assignments, access, subscriber token). Excludes binaries/packages. Restore on a clean VPS: run the turnkey install first, then upload the backup here — the server key returns and clients reconnect without recreation. WARNING: the file contains private keys — store it securely.")}
⚠ {tr("Файл содержит приватные ключи. Храни в защищённом месте.","File contains private keys. Store it securely.")}
⬇ {tr("Скачать бэкап","Download backup")}
{tr("Обфускация (маскировка)","Obfuscation (masking)")} {hlp("Параметры wg-obfuscator. masking: AUTO (адаптивно), STUN (под STUN-трафик), NONE (без маскировки). max-dummy — добавочные мусорные байты. idle — таймаут простоя (сек). Сохранение отправляет параметры на ВСЕ роутеры по pull-каналу — применяются за ~15 сек без перевыдачи конфига. Серверный обфускатор остаётся AUTO и принимает любой клиентский режим.", "wg-obfuscator params. masking: AUTO (adaptive), STUN (mimic STUN), NONE (no masking). max-dummy — extra junk bytes. idle — idle timeout (s). Saving pushes the params to ALL routers over the pull channel — applied in ~15s without re-issuing configs. The server obfuscator stays AUTO and accepts any client mode.")}
{tr("Обфускатор сервера","Server obfuscator")} {hlp("Параметры wg-obfuscator на ЭТОМ сервере (все порты). masking: AUTO принимает любой режим клиента — менять, только если знаете зачем; STUN/NONE — клиенты должны работать в том же режиме. max-dummy — мусорные байты в ответах, idle — таймаут простоя (сек). Сохранение перезапускает обфускатор: клиенты переподключатся за пару секунд.", "wg-obfuscator params on THIS server (all ports). masking: AUTO accepts any client mode — change only if you know why; with STUN/NONE clients must use the same mode. max-dummy — junk bytes in replies, idle — idle timeout (s). Saving restarts the obfuscator: clients reconnect within seconds.")}
{tr("Информация о сервере","Server info")}
VPS IP {SERVER_IP}
{tr("WireGuard порт","WireGuard port")} 51820 (localhost)
{tr("Порты обфускатора","Obfuscator ports")} {_load_server_env().get('OBFUSCATOR_PORTS', '2083,5443,993')}
{tr("Адрес панели","Panel URL")} {panel_base_url()}
{tr("Порт панели","Panel port")} {panel_port()}
{tr("Протокол панели","Panel protocol")} {panel_scheme().upper()}
{tr("Клиенты Phobos","Phobos clients")} {CLIENTS_DIR}
WARP / WARP+ {hlp("Этот блок нужен для двух разных задач. Первая: отправить весь исходящий трафик клиентов Phobos через Cloudflare WARP. Вторая: открыть на сервере отдельные UDP relay-порты и перекинуть их на endpoint WARP/WARP+. Ниже можно выбрать режим, источник профиля и при желании вставить WARP+ key или полный custom WireGuard профиль.", "This block handles two different jobs. First: send all Phobos client egress through Cloudflare WARP. Second: open dedicated UDP relay ports on this server and forward them to WARP/WARP+ endpoint. Below you can choose mode, profile source, and optionally paste WARP+ key or full custom WireGuard profile.")}
{tr("Как это работает: выбираешь режим, заполняешь только нужные поля, жмёшь «Сохранить WARP настройки», потом «Enable selected mode». В каждый момент активен один режим: либо egress, либо relay. Кнопка Disable выключает текущий режим и убирает его правила.", "How it works: choose mode, fill only fields you need, press 'Save WARP settings', then 'Enable selected mode'. Only one mode is active at a time: either egress or relay. Disable turns current mode off and removes its rules.")}
{tr("Статус","Status")} {warp_state} ({_html_attr(warp_service)})
{tr("Режим","Mode")} {warp_mode} {hlp("egress включает интерфейс warp и меняет исходящий маршрут для подсети клиентов Phobos. relay не меняет интернет-выход клиентов, а только открывает UDP relay-порты на этом сервере.", "egress brings up warp interface and changes egress route for Phobos client subnet. relay does not change client internet egress; it only opens UDP relay ports on this server.")}
{tr("Источник профиля","Profile source")} {warp_profile_source} {hlp("free — профиль собирается через wgcf. Если сохранён WARP+ key, он применяется к wgcf-аккаунту. custom — используется твой готовый WireGuard профиль, который вставлен ниже.", "free — profile is built through wgcf. If WARP+ key is saved, it is applied to wgcf account. custom — uses your ready WireGuard profile pasted below.")}
{tr("Подсеть клиентов","Client subnet")} {warp_subnet}
Endpoint {warp_endpoint or '—'}
{tr("Relay ports","Relay ports")} {warp_relay_ports or '—'}
{tr("Relay target","Relay target")} {warp_relay_target or '—'}
{tr("Что именно включается","What gets enabled")} {tr("Только выбранный режим. Одновременно egress и relay не держатся.", "Only selected mode. egress and relay are not kept active together.")} {hlp("Если сейчас активен egress и ты включаешь relay, старый egress будет выключен и заменён relay. И наоборот.", "If egress is active and you enable relay, old egress is turned off and replaced by relay. Same in reverse.")}
{tr("По отдельным клиентам","Per-client control")} {tr("Да, через кнопки WARP ON/OFF в списке клиентов.", "Yes, via WARP ON/OFF buttons in client list.")} ({warp_client_count}) {hlp("В режиме egress WARP можно включать точечно по клиентам. Панель запоминает выбор и применяет правила по tunnel IP клиента. В relay режиме эти кнопки не работают.", "In egress mode WARP can be enabled per client. Panel remembers the choice and applies rules by client tunnel IP. These buttons do not work in relay mode.")}
{tr("WARP+ key","WARP+ key")} {tr("сохранён","saved") if warp_license_saved else tr("не задан","not set")}
{tr("Custom profile","Custom profile")} {tr("сохранён","saved") if custom_profile_saved else tr("не задан","not set")}
{tr("Egress IPv4","Egress IPv4")} {warp_egress or '—'}
{tr("Коротко: egress поднимает WARP на сервере, а дальше ты сам включаешь WARP точечно по клиентам кнопками WARP ON/OFF в разделе клиентов. relay нужен, когда нужен отдельный UDP вход на этом сервере с переадресацией в WARP/WARP+. Relay — это сценарий для отдельного WARP/WireGuard профиля, а не для штатного Phobos home.conf.", "Short version: egress brings WARP up on server, then you enable it per client with WARP ON/OFF buttons in client list. relay is for dedicated UDP entrypoint on this server forwarded into WARP/WARP+. Relay is for separate WARP/WireGuard profile, not for standard Phobos home.conf.")}
{f'{warp_error}
' if warp_error else ''}
{tr("Установка на роутер","Router install")}
{tr("Keenetic/Netcraze с Entware — выполнить по SSH на роутере:","Keenetic/Netcraze with Entware — run over SSH on the router:")}
{_render_install_commands()}
"""
return render(html)
TOKENS_FILE = f"{PHOBOS_DIR}/tokens/tokens.json"
def _render_install_commands():
"""Read tokens and render install commands for each client."""
try:
if os.path.exists(TOKENS_FILE):
with open(TOKENS_FILE) as f:
tokens = json.load(f)
else:
tokens = []
except Exception:
tokens = []
if not tokens:
return 'Нет активных токенов. Добавьте клиента.
'
port = panel_port()
lines = ""
for t in tokens:
client = t.get("client", "?")
token = t.get("token", "")
expires = t.get("expires", 0)
exp_str = datetime.fromtimestamp(expires).strftime("%Y-%m-%d %H:%M") if expires else "?"
cmd = install_command(token, port)
lines += f"""
{client}
(до {exp_str})
{cmd}
"""
uninstall_cmd = "/opt/etc/Phobos/phobos-uninstall.sh"
lines += f"""
{tr("Удалить с роутера", "Remove from router")}
({tr("общая команда для всех клиентов", "same command for all clients")})
{uninstall_cmd}
"""
return lines
SERVERS_FILE = f"{PANEL_DIR}/servers.json"
def load_servers():
if os.path.exists(SERVERS_FILE):
with open(SERVERS_FILE) as f:
return json.load(f)
return []
def save_servers(servers):
with open(SERVERS_FILE, "w") as f:
json.dump(servers, f, indent=2)
def panel_port():
try:
return open(os.path.join(PANEL_DIR, ".port")).read().strip() or "8443"
except Exception:
return "8443"
def panel_scheme():
scheme = (os.environ.get("PANEL_SCHEME") or "").strip().lower()
if scheme in ("http", "https"):
return scheme
try:
scheme = open(os.path.join(PANEL_DIR, ".scheme")).read().strip().lower()
if scheme in ("http", "https"):
return scheme
except Exception:
pass
return "http"
def panel_base_url(host=None):
return f"{panel_scheme()}://{host or SERVER_IP}:{panel_port()}"
def _server_api_key_candidates(server):
keys = []
for key in (server.get("api_key", ""), load_settings().get("server_api_key", "")):
if key and key not in keys:
keys.append(key)
return keys
def _remember_server_api_key(ip, api_key):
if not ip or not api_key:
return
servers = load_servers()
changed = False
for srv in servers:
if srv.get("ip") == ip and srv.get("api_key") != api_key:
srv["api_key"] = api_key
changed = True
if changed:
save_servers(servers)
def secondary_api_request(server, path, data=None, timeout=10):
import urllib.error
import urllib.request
headers = {"Content-Type": "application/json"}
body = json.dumps(data).encode() if data is not None else None
last_error = None
for key in _server_api_key_candidates(server):
headers["X-API-Key"] = key
try:
req = urllib.request.Request(
f"http://{server['ip']}:8444{path}",
data=body,
headers=headers,
method="POST" if body is not None else "GET",
)
resp = urllib.request.urlopen(req, timeout=timeout)
_remember_server_api_key(server.get("ip", ""), key)
server["api_key"] = key
return json.loads(resp.read())
except urllib.error.HTTPError as e:
last_error = e
if e.code in (401, 403):
continue
raise
if last_error:
raise last_error
raise RuntimeError("no API key for secondary server")
def install_command(token, port=None):
port = port or panel_port()
primary = f"{panel_scheme()}://{SERVER_IP}:{port}/init/{token}.sh"
urls = [primary, f"http://{SERVER_IP}/init/{token}.sh"]
return ("p=/tmp/phobos-init-$$.sh; ok=0; "
f"for u in {' '.join(urls)}; do "
"rm -f $p; "
"(wget -q -O $p $u || wget --no-check-certificate -q -O $p $u || curl -kfsSL -o $p $u || curl -fsSL -o $p $u) 2>/dev/null || continue; "
"head -c 2 $p | grep -q \"#!\" || continue; "
"sh $p && ok=1 && break; "
"done; rm -f $p; [ $ok -eq 1 ] || echo ERROR_install_script_download_failed")
@app.route("/init/")
def serve_init(name):
return send_from_directory(f"{PHOBOS_DIR}/www/init", name, mimetype="application/x-sh")
@app.route("/packages/")
def serve_package(name):
return send_from_directory(f"{PHOBOS_DIR}/www/packages", name, mimetype="application/octet-stream")
_SPEED_CHUNK = os.urandom(1 << 20)
_speed_busy = set()
@app.route("/speedtest")
def speedtest_page():
"""Public: a VPN client opens http://10.25.0.1:/speedtest on its device
to measure real device <-> server speed through the tunnel."""
return f"""
Phobos speed test
🛡️ Phobos — {tr("скорость до сервера", "speed to server")}
ping—
↓ {tr("Мбит/с", "Mbit/s")}—
↑ {tr("Мбит/с", "Mbit/s")}—
{tr("Открой эту страницу на устройстве с включённым VPN по адресу туннеля — так замеряется реальный канал до сервера.", "Open this page on a device with VPN on, via the tunnel address — this measures the real path to the server.")}
"""
@app.route("/speedtest/down")
def speedtest_down():
try:
mb = max(0, min(100, int(request.args.get("mb", "10"))))
except ValueError:
mb = 10
ip = request.remote_addr or "?"
if mb and ip in _speed_busy:
return ("busy", 429)
def gen():
_speed_busy.add(ip)
try:
for _ in range(mb):
yield _SPEED_CHUNK
finally:
_speed_busy.discard(ip)
return app.response_class(gen(), mimetype="application/octet-stream",
headers={"Cache-Control": "no-store", "Content-Length": str(mb << 20)})
@app.route("/speedtest/up", methods=["POST"])
def speedtest_up():
n = 0
stream = request.stream
while n < (100 << 20):
chunk = stream.read(1 << 16)
if not chunk:
break
n += len(chunk)
return {"bytes": n}
@app.route("/server/")
def serve_server_bootstrap(name):
# Public bootstrap files only. No secrets live in these scripts.
if name not in ("secondary-setup.sh", "api.py", "update.sh", "phobos-limit.sh", "phobos-speedtest.sh",
"phobos-cascade.sh"):
return ("not found", 404)
mimetype = "application/x-sh" if name.endswith(".sh") else "text/plain"
return send_from_directory(f"{PHOBOS_DIR}/server", name, mimetype=mimetype)
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:
return secondary_api_request(server, "/api/health", timeout=5)
except Exception as e:
return {"status": "error", "error": str(e)}
def sync_peer_to_server(server, public_key, allowed_ips, action="add"):
"""Add or remove peer on secondary server."""
try:
return secondary_api_request(server, f"/api/peers/{action}", {
"public_key": public_key, "allowed_ips": allowed_ips
}, timeout=10)
except Exception as e:
return {"status": "error", "msg": str(e)[:200]}
def sync_peer_to_all_servers(public_key, allowed_ips, action="add"):
"""Sync peer to all secondary servers."""
servers = load_servers()
for srv in servers:
if srv.get("enabled", True):
sync_peer_to_server(srv, public_key, allowed_ips, action)
def sync_missing_peers_to_secondaries(server=None):
"""Best-effort peer repair: every secondary must already know every client.
Router failover can only work if the target server has the client's WG peer.
"""
servers = [server] if server else [srv for srv in load_servers() if srv.get("enabled", True)]
clients = get_clients()
synced = 0
for srv in servers:
health = check_server_health(srv)
if health.get("status") not in ("ok", None):
continue
remote_keys = set(health.get("peer_keys", []) or [])
for c in clients:
pub = c.get("public_key", "")
tip = (c.get("tunnel_ip_v4") or "").split("/")[0]
if not pub or not tip or pub in remote_keys:
continue
res = sync_peer_to_server(srv, pub, f"{tip}/32", "add")
if res.get("status") == "ok":
synced += 1
remote_keys.add(pub)
return synced
@app.route("/download/apk")
@auth_required
def download_apk():
"""PhobosWG APK: local copy if the server has one, else the release on Gitea."""
local = "/opt/Phobos/www/app/PhobosWG.apk"
if os.path.exists(local):
from flask import send_file
return send_file(local, as_attachment=True, download_name="PhobosWG.apk",
mimetype="application/vnd.android.package-archive")
return redirect(f"{PCA_GIT}/releases/download/{APK_TAG}/PhobosWG.apk")
@app.route("/client//conf/")
@auth_required
def client_conf_download(cid, mode):
m = "ios" if mode == "ios" else "android"
if m == "android":
return ("Android/PhobosWG uses phobos:// QR/link import; .conf import is only valid for iOS/plain WireGuard.", 400)
conf, _link = build_phone_config(cid, m)
if conf is None:
return ("client not found", 404)
if m == "android":
conf = _pad_conf_with_none(conf)
return (conf, 200, {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": 'attachment; filename="%s-%s.conf"' % (cid, m),
})
@app.route("/client//phone/")
@auth_required
def client_phone(cid, mode):
if mode not in ("android", "ios"):
mode = "android"
conf, link = build_phone_config(cid, mode)
if conf is None:
return ("client not found", 404)
qr = qr_datauri(link if (mode == "android" and link) else conf)
if mode == "android":
title = tr("Android — PhobosWG (с обфускацией)", "Android — PhobosWG (obfuscated)")
apk_btn = ""
_apk_local = "/opt/Phobos/www/app/PhobosWG.apk"
if os.path.exists(_apk_local):
apk_btn = (f'📥 {tr("Скачать приложение PhobosWG (APK)","Download PhobosWG app (APK)")}
')
else:
_gh_token = ""
try:
_gh_token = open("/opt/phobos-panel/.gh_token").read().strip()
except Exception:
pass
if _gh_token:
apk_btn = (f'📥 {tr("Скачать PhobosWG (APK)","Download PhobosWG (APK)")}'
f'{tr("с GitHub","from GitHub")}
')
else:
apk_btn = (f'📥 {tr("Скачать PhobosWG (APK)","Download PhobosWG (APK)")}
')
client_btn = (f''
f'{tr("Поставь любое из двух приложений и открой ссылку ниже на телефоне — профиль импортируется сам.","Install either app and open the link below on the phone — the profile imports itself.")}
')
extra = (client_btn + apk_btn +
f'{tr("phobos:// ссылка — импорт в PCA Phobos VPN или PhobosWG:", "phobos:// link — import into PCA Phobos VPN or PhobosWG:")} {tr("открыть", "open")}
'
f'')
else:
title = tr("iPhone / iOS WireGuard (без обфускации)", "iPhone / iOS WireGuard (plain, no obfuscation)")
extra = (f'📥 {tr("WireGuard в App Store","WireGuard on the App Store")} ⬇ {tr("Скачать .conf","Download .conf")}
'
f'{tr("⚠ Обычный WireGuard без маскировки (Endpoint → :51820). На сервере должен быть открыт порт 51820 (ALLOW_PLAIN_WG=1).", "⚠ Plain WireGuard, no obfuscation (Endpoint → :51820). Port 51820 must be open on the server (ALLOW_PLAIN_WG=1).")}
')
html = f"""
{nav('clients')}
{cid} — {title}

{tr("Отсканируй QR в приложении или импортируй конфиг:", "Scan the QR in the app or import the config:")}
{extra}
"""
return render(html)
@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, {})
# packages ship server_api_key as pull_token (phobos-client.sh), so accept both
expected = {t for t in (acc.get("pull_token", ""), st.get("server_api_key", "")) if t}
if not token or token not in expected:
return ("forbidden", 403)
conf = generate_failover_conf_for_client(client_id)
return (conf, 200, {"Content-Type": "text/plain; charset=utf-8"})
@app.route("/client//router-config")
@auth_required
def client_router_config(cid):
if not any(c.get("client_id") == cid for c in get_clients()):
return ("client not found", 404)
token = _latest_token_for_client(cid)
cmd = install_command(token) if token else ""
title = tr("Установка на роутер", "Router install")
copy = tr("Копировать", "Copy")
copied = tr("Скопировано", "Copied")
body = (f"""
{tr("Одна команда для вставки в SSH роутера.", "One command to paste into router SSH.")}
{cmd}
""" if cmd else f'{tr("Нет активной команды установки. Создай/обнови клиента, чтобы выпустить install token.", "No active install command. Create/update the client to issue an install token.")}
')
html = f"""
{nav('clients')}
{cid} — {title}
{body}
"""
return render(html)
# ── Keenetic RCI console (KeenDNS, ndm-challenge auth) ──────────────────────
# Keenetic routers behind NAT don't accept SSH (neither WAN nor WG tunnel).
# Control them via RCI over KeenDNS HTTPS: ndmc CLI commands -> POST /rci/.
# Auth: sha256(challenge + md5(login:realm:password)) (ndm challenge).
def _rci_base(cid):
acc = load_settings().get("router_access", {}).get(cid, {})
url = (acc.get("web_url") or "").strip()
if not url:
return None, None, None
if not url.startswith("http"):
url = "https://" + url
return url.rstrip("/"), (acc.get("web_login") or "admin"), (acc.get("web_pass") or "")
def _rci_run(cid, command):
"""Run a ndmc CLI command on a Keenetic router via RCI/KeenDNS.
Returns (ok, text)."""
import urllib.request, urllib.error, ssl, json as _json, http.cookiejar, hashlib
base, login, pwd = _rci_base(cid)
if not base:
return False, "Не задан web-адрес роутера (Настройки → Доступ к роутерам → KeenDNS URL)."
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.HTTPSHandler(context=ctx),
urllib.request.HTTPCookieProcessor(cj))
try:
op.open(base + "/auth", timeout=12) # already authed (cookie) or 200
except urllib.error.HTTPError as e:
if e.code != 401:
return False, f"auth error: HTTP {e.code}"
realm = e.headers.get("X-NDM-Realm", ""); ch = e.headers.get("X-NDM-Challenge", "")
if not realm or not ch:
return False, "auth: no challenge headers"
md5 = hashlib.md5(f"{login}:{realm}:{pwd}".encode()).hexdigest()
sha = hashlib.sha256(f"{ch}{md5}".encode()).hexdigest()
body = _json.dumps({"login": login, "password": sha}).encode()
req = urllib.request.Request(base + "/auth", data=body,
headers={"Content-Type": "application/json"})
try:
op.open(req, timeout=12)
except Exception as ex:
return False, f"auth failed (проверь логин/пароль web-панели роутера): {ex}"
except Exception as e:
return False, f"connect failed: {e}"
try:
req = urllib.request.Request(base + "/rci/", data=_json.dumps({"parse": command}).encode(),
headers={"Content-Type": "application/json"})
r = op.open(req, timeout=25)
data = _json.loads(r.read())
return True, _json.dumps(data, ensure_ascii=False, indent=2)
except Exception as e:
return False, f"rci error: {e}"
@app.route("/api/router//rci", methods=["POST"])
@auth_required
def api_router_rci(cid):
command = (request.form.get("command", "") or "").strip()
if not command:
return ("no command", 400)
ok, text = _rci_run(cid, command)
return (text, 200 if ok else 502, {"Content-Type": "text/plain; charset=utf-8"})
@app.route("/rci/router/")
@auth_required
def rci_console(cid):
base, _login, _pwd = _rci_base(cid)
configured = bool(base)
note = "" if configured else tr(
"Web-адрес роутера не задан. Настройки → «Доступ к роутерам» → заполни KeenDNS URL + web-логин/пароль.",
"Router web URL not set. Settings → Router access → fill KeenDNS URL + web login/password.")
html_page = f"""
{nav('clients')}
🛠 RCI {_html_attr(cid)} {hlp("Консоль ndmc-команд Keenetic через KeenDNS (работает за NAT, SSH не нужен). Введи команду CLI (напр. show version, show interface Wireguard3, show ip hotspot) — роутер выполнит и вернёт JSON. Это НЕ интерактивный bash: каждая строка — отдельная команда RCI.", "Keenetic ndmc command console via KeenDNS (works behind NAT, no SSH). Type a CLI command (e.g. show version) — the router runs it and returns JSON. Not an interactive bash: each line is a separate RCI command.")}
{tr("Назад","Back")}
{f'{note}' if note else ''}
{tr("Быстрые","Quick")}:
show version ·
show interface Wireguard3 ·
show ip hotspot ·
show system ·
show running-config
"""
return render(html_page)
def _terminal_target(kind, target):
if kind == "server":
if target in ("main", SERVER_IP):
return {
"title": f"Main server {SERVER_IP}",
"mode": "local",
"cmd": ["/bin/bash", "-l"],
"env": os.environ.copy(),
}
srv = next((sv for sv in load_servers() if sv.get("ip") == target), None)
if not srv:
raise ValueError("server not found")
ssh_pass = srv.get("ssh_pass", "")
if not ssh_pass:
raise ValueError("server SSH password missing")
ssh_user = srv.get("ssh_user", "root") or "root"
ssh_port = str(srv.get("ssh_port", "22") or "22")
return {
"title": f"Server {target}",
"mode": "ssh",
"hosts": [target],
"username": ssh_user,
"password": ssh_pass,
"port": int(ssh_port),
}
if kind == "router":
clients = get_clients()
client = next((c for c in clients if c.get("client_id") == target), None)
if not client:
raise ValueError("client not found")
acc = load_settings().get("router_access", {}).get(target, {})
ssh_pass = acc.get("ssh_pass", "")
if not ssh_pass:
raise ValueError("router SSH password missing")
ssh_user = acc.get("ssh_user", "root") or "root"
ssh_port = str(acc.get("ssh_port", "22") or "22")
ssh_ip = acc.get("ssh_ip") or client.get("tunnel_ip_v4", "").split("/")[0]
ssh_static = acc.get("ssh_static", "")
hosts = [h for h in (ssh_static, ssh_ip) if h]
if not hosts:
raise ValueError("router SSH IP missing")
return {
"title": f"Router {target}",
"mode": "ssh",
"hosts": hosts,
"username": ssh_user,
"password": ssh_pass,
"port": int(ssh_port),
}
raise ValueError("bad terminal target")
@app.route("/terminal//")
@auth_required
def terminal_page(kind, target):
try:
spec = _terminal_target(kind, target)
error = "" if sock else "flask-sock is not installed; run phobos-update dev to install terminal dependencies"
except Exception as e:
spec = {"title": f"{kind} {target}"}
error = str(e)
title = _html_attr(spec.get("title", f"{kind} {target}"))
ws_path = f"/ws/terminal/{kind}/{target}"
js_error_line = ""
if error:
safe_error = _html_attr(error).replace("\\", "\\\\").replace('"', '\\"')
js_error_line = f'term.writeln("ERROR: {safe_error}"); return;'
html_page = f"""
{nav('servers' if kind == 'server' else 'clients')}
🖥️ {title}
{tr("Назад", "Back")}
{_html_attr(error) if error else tr("Подключение...", "Connecting...")}
"""
return render(html_page)
def _terminal_ws_loop(ws, kind, target):
try:
spec = _terminal_target(kind, target)
except Exception as e:
ws.send(f"ERROR: {e}\r\n")
return
if spec.get("mode") == "ssh":
return _terminal_ssh_ws_loop(ws, spec)
return _terminal_local_ws_loop(ws, spec)
def _terminal_local_ws_loop(ws, spec):
import pty, select, signal, struct, termios
master_fd, slave_fd = pty.openpty()
proc = subprocess.Popen(spec["cmd"], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
env=spec.get("env") or os.environ.copy(), close_fds=True,
preexec_fn=os.setsid)
os.close(slave_fd)
stop = threading.Event()
def set_winsize(cols, rows):
try:
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(cols), 0, 0))
except Exception:
pass
def reader():
try:
while not stop.is_set():
r, _, _ = select.select([master_fd], [], [], 0.2)
if master_fd in r:
data = os.read(master_fd, 4096)
if not data:
break
ws.send(data.decode(errors="replace"))
if proc.poll() is not None:
break
except Exception:
pass
finally:
stop.set()
threading.Thread(target=reader, daemon=True).start()
try:
while not stop.is_set():
raw = ws.receive(timeout=1)
if raw is None:
if proc.poll() is not None:
break
continue
try:
msg = json.loads(raw)
except Exception:
msg = {"type": "input", "data": raw}
if msg.get("type") == "resize":
set_winsize(msg.get("cols", 80), msg.get("rows", 24))
else:
os.write(master_fd, str(msg.get("data", raw)).encode())
finally:
stop.set()
try:
os.killpg(os.getpgid(proc.pid), signal.SIGHUP)
except Exception:
pass
try:
os.close(master_fd)
except Exception:
pass
def _terminal_ssh_ws_loop(ws, spec):
import asyncio
try:
import asyncssh
except Exception as e:
ws.send(f"ERROR: asyncssh is not installed: {e}\r\n")
return
def format_ssh_error(exc):
detail = str(exc).strip()
if detail:
return detail
if isinstance(exc, TimeoutError):
return "Connection timed out"
return exc.__class__.__name__ or "Connection failed"
async def run_ssh():
last_error = None
for host in spec.get("hosts", []):
try:
conn = await asyncssh.connect(
host=host,
port=spec.get("port", 22),
username=spec.get("username", "root"),
password=spec.get("password", ""),
known_hosts=None,
connect_timeout=10,
)
ws.send(f"Connected to {host}\r\n")
break
except Exception as e:
last_error = e
ws.send(f"ssh: connect to host {host} port {spec.get('port', 22)}: {format_ssh_error(e)}\r\n")
else:
ws.send(f"\r\nConnection error: {format_ssh_error(last_error)}\r\n")
return
async with conn:
process = await conn.create_process(term_type="xterm-256color", term_size=(120, 40))
stop = asyncio.Event()
async def read_output():
try:
while not stop.is_set():
data = await process.stdout.read(4096)
if not data:
break
ws.send(data)
except Exception:
pass
finally:
stop.set()
async def read_input():
try:
while not stop.is_set():
raw = await asyncio.to_thread(lambda: ws.receive(timeout=1))
if raw is None:
continue
try:
msg = json.loads(raw)
except Exception:
process.stdin.write(raw)
continue
if msg.get("type") == "resize":
process.change_terminal_size(int(msg.get("cols", 120)), int(msg.get("rows", 40)))
else:
process.stdin.write(str(msg.get("data", "")))
except Exception:
pass
finally:
stop.set()
tasks = [asyncio.create_task(read_output()), asyncio.create_task(read_input())]
await stop.wait()
for task in tasks:
task.cancel()
process.close()
asyncio.run(run_ssh())
if sock:
@sock.route("/ws/terminal//")
def terminal_ws(ws, kind, target):
if not session.get("auth"):
ws.send("ERROR: unauthorized\r\n")
return
_terminal_ws_loop(ws, kind, target)
@app.route("/api/servers/register", methods=["POST"])
def api_register_server():
s = load_settings()
api_key = request.headers.get("X-API-Key", "")
if api_key != s.get("server_api_key", ""):
return json.dumps({"error": "unauthorized"}), 401, {"Content-Type": "application/json"}
data = request.json
servers = load_servers()
ip = data.get("ip", "")
for srv in servers:
if srv["ip"] == ip:
srv.update(data)
srv["api_key"] = api_key
srv["last_seen"] = datetime.now().isoformat()
save_servers(servers)
# push existing clients to the (re)registered server right away
threading.Thread(target=lambda: (time.sleep(5), sync_missing_peers_to_secondaries()), daemon=True).start()
return json.dumps({"status": "updated"}), 200, {"Content-Type": "application/json"}
data["last_seen"] = datetime.now().isoformat()
data["enabled"] = True
data["api_key"] = api_key
servers.append(data)
save_servers(servers)
# push existing clients to the (re)registered server right away
threading.Thread(target=lambda: (time.sleep(5), sync_missing_peers_to_secondaries()), daemon=True).start()
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 = client_script_path()
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"{panel_base_url()}/init/{token}.sh" if token else ""
return json.dumps({
"ok": True, "client_id": cid, "install_url": install_url, "token": token,
"install_command": install_command(token) if token else "",
"pull_token": pull_token, "tunnel_ip": tip, "assigned_server": assigned,
}), 200, {"Content-Type": "application/json"}
@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():
s = load_settings()
servers = load_servers()
msg = ""
if request.method == "POST":
action = request.form.get("action")
if action == "add":
ip = request.form.get("ip", "").strip()
api_key = request.form.get("api_key", "").strip() or s.get("server_api_key", "")
ssh_user = request.form.get("ssh_user", "root").strip() or "root"
ssh_pass = request.form.get("ssh_pass", "").strip()
ssh_port = request.form.get("ssh_port", "22").strip() or "22"
if ip:
servers.append({
"ip": ip, "api_key": api_key, "enabled": True,
"last_seen": "", "wg_public_key": "", "obfuscator_key": "", "ports": "",
"ssh_user": ssh_user, "ssh_pass": ssh_pass, "ssh_port": ssh_port
})
save_servers(servers)
order = s.get("server_order", [])
if ip not in order:
order.append(ip)
s["server_order"] = order
save_settings(s)
synced = sync_missing_peers_to_secondaries(servers[-1])
msg = f'Сервер {ip} добавлен, синхронизировано {synced} клиентов'
elif action == "remove":
ip = request.form.get("ip", "").strip()
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((sv for sv in servers if sv["ip"] == ip), None)
if srv:
clients = get_clients()
synced = 0
for c in clients:
pub = c.get("public_key", "")
tip = c.get("tunnel_ip_v4", "")
if pub and tip:
res = sync_peer_to_server(srv, pub, f"{tip}/32", "add")
if res.get("status") == "ok":
synced += 1
msg = f'Синхронизировано {synced} клиентов на {ip}'
elif action == "fetch_info":
ip = request.form.get("ip", "").strip()
for srv in servers:
if srv["ip"] == ip:
try:
info = secondary_api_request(srv, "/api/info", timeout=5)
srv["wg_public_key"] = info.get("wg_public_key", "")
srv["obfuscator_key"] = info.get("obfuscator_key", "")
srv["ports"] = ",".join(info.get("ports", []))
save_servers(servers)
msg = f'Инфо получено от {ip}'
except Exception as e:
msg = f'Ошибка: {e}'
elif action == "set_api_key":
new_key = request.form.get("server_api_key", "").strip()
if new_key:
s["server_api_key"] = new_key
save_settings(s)
msg = 'API ключ обновлён'
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()
ssh_port = request.form.get(f"ssh_port_{cid}", "22").strip() or "22"
web_url = request.form.get(f"web_url_{cid}", "").strip()
web_login = request.form.get(f"web_login_{cid}", "").strip() or "admin"
web_pass = request.form.get(f"web_pass_{cid}", "").strip()
prev = ra.get(cid, {})
if ssh_ip or ssh_pass or web_url or prev.get("ttyd_token"):
ra[cid] = {"ssh_ip": ssh_ip, "ssh_static": ssh_static, "ssh_user": ssh_user,
"ssh_pass": ssh_pass, "ssh_port": ssh_port,
"web_url": web_url, "web_login": web_login, "web_pass": web_pass,
"ttyd_token": prev.get("ttyd_token", ""), "ttyd_ip": prev.get("ttyd_ip", ""),
"ttyd_port": prev.get("ttyd_port", "7681"),
"ssh_ok": prev.get("ssh_ok", False)}
elif cid in ra and not ssh_ip and not ssh_pass and not web_url:
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",
"-p", str(acc.get("ssh_port", "22") or "22"), "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
f"{acc.get('ssh_user','root')}@{ip}", "echo OK"],
capture_output=True, text=True, timeout=10
)
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()
sport = request.form.get(f"srv_ssh_port_{ip}", "22").strip() or "22"
if su:
srv["ssh_user"] = su
if sp:
srv["ssh_pass"] = sp
srv["ssh_port"] = sport
save_servers(servers)
msg = 'SSH доступ к серверам сохранён'
elif action == "speedtest":
ip = request.form.get("ip", "").strip()
res = run_speedtest(ip)
if res.get("error"):
msg = f'{tr("Замер не удался", "Speed test failed")}: {html_lib.escape(str(res["error"]))}'
else:
msg = f'{html_lib.escape(ip)}: ↓ {res.get("download_mbit")} / ↑ {res.get("upload_mbit")} {tr("Мбит/с", "Mbit/s")}, ping {res.get("ping_ms")} ms'
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", "")
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)
mbps_val = local.get("mbps", 0.0)
st_class = "badge-on"
st_text = "Primary"
else:
cpu_val = cached.get("cpu", "—")
mem_val = cached.get("mem", "—")
peers_val = cached.get("peers", "?")
mbps_val = cached.get("mbps", 0.0)
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"
term_target = "main" if is_primary else ip
term_btn = _terminal_button(f"/terminal/server/{term_target}", tr("SSH терминал сервера", "Server SSH terminal"))
_offline = (not is_primary) and (cached.get("status", "down") != "ok")
mbps_disp = "—" if _offline else f"{mbps_val} Mbps"
_st = load_settings().get("speedtests", {}).get(ip, {})
speed_line = (f'⚡ ↓ {_st.get("download_mbit")} / ↑ {_st.get("upload_mbit")} {tr("Мбит/с", "Mbit/s")} · '
f'{datetime.fromtimestamp(int(_st.get("ts", 0))).strftime("%d.%m %H:%M")}') if _st.get("ts") else ""
country = _ip_country(ip)
country_html = f'{country}' if country else ""
stats_cards += f"""
{ip}{country_html}
{term_btn}{st_text}
CPU: {cpu_val} RAM: {mem_val}
Peers: {peers_val} Assigned: {assigned} Ports: {ports}
↕ {mbps_disp}
{speed_line}
"""
# 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 = ""
term_target = "main" if is_primary else ip
term_btn = _terminal_button(f"/terminal/server/{term_target}", tr("SSH терминал сервера", "Server SSH terminal"))
if not is_primary:
acts = f"""
"""
rows += f"""
{prio}{ip}{ptag} {ports} {peers_val} {assigned}
{st_text}
"""
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_port = acc.get("ssh_port", "22")
web_url = acc.get("web_url", "")
web_login = acc.get("web_login", "")
web_pass = acc.get("web_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}
➕ {tr("Как добавить второй сервер", "How to add a second server")}
{tr("Сервер управления один — эта панель. Второй VPS ставится как «дополнительный» (secondary) одной командой.", "There is one management server — this panel. The second VPS is installed as a secondary with one command.")}
- {tr("Возьмите чистый VPS (Ubuntu/Debian). Если на нём уже стоял Phobos целиком — сначала удалите его (uninstall.sh).", "Take a clean VPS (Ubuntu/Debian). If a full Phobos was installed there, uninstall it first (uninstall.sh).")}
- {tr("Зайдите на него по SSH под root и выполните команду ниже.", "SSH to it as root and run the command below.")}
- {tr("Подсеть уже подставлена в команду (SERVER_WG_IPV4_NETWORK) — она такая же, как у этой панели, вопроса не будет.", "The subnet is already in the command (SERVER_WG_IPV4_NETWORK) — it is the same as on this panel, no prompt.")} {tr("Не меняйте её и не вводите другую!", "Do not change it or enter another one!")}
- {tr("Сервер сам появится в списке ниже, клиенты скопируются на него автоматически. Кнопка «Добавить сервер» не нужна.", "The server appears in the list below by itself and clients are copied to it automatically. The “Add Server” button is not needed.")}
- {tr("Назначить клиенту сервер: Клиенты → «Редактировать» → «Сервер». Наши приложения (роутер, iOS, Android, Windows 1.4.5+) переключатся сами; другие клиенты — только повторным импортом ссылки.", "Assign a server to a client: Clients → Edit → Server. Our apps (router, iOS, Android, Windows 1.4.5+) switch by themselves; other clients need the link re-imported.")}
SERVER_WG_IPV4_NETWORK={server_wg_network()} MAIN_PROTO={panel_scheme()} MAIN_SERVER={SERVER_IP} MAIN_PORT={panel_port()} MAIN_API_KEY={api_key} bash <(curl -kfsSL {panel_base_url()}/server/secondary-setup.sh)
⚠ {tr("Не ставьте второй сервер полным install.sh!", "Do not install the second server with the full install.sh!")}
{tr("Полный install.sh создаёт ОТДЕЛЬНУЮ панель со своими клиентами. Подсеть там придётся указывать свою (например 10.25.11.0/24, иначе у клиентов будут одинаковые IP и второй туннель на роутере не поднимется), а смена сервера у клиента между двумя панелями НЕ работает — это разные клиенты. Нужна смена и назначение серверов — ставьте только командой выше, подсеть по умолчанию (Enter).", "The full install.sh creates a SEPARATE panel with its own clients. You would have to enter your own subnet there (e.g. 10.25.11.0/24, otherwise clients get identical IPs and the second router tunnel will not come up), and switching a client's server between two panels does NOT work — they are different clients. If you need server assignment and switching, use only the command above with the default subnet (Enter).")}
{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}
{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.")}
{tr("Сервер","Server")} {tr("Порты","Ports")} Peers {tr("Назначено","Assigned")} {tr("Статус","Status")} {tr("Действия","Actions")}
{rows}
{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.")}
{tr("Сервер уже поставлен, но не появился в списке? Добавить вручную", "Server installed but not in the list? Add manually")}
{tr("Доступ к роутерам — SSH / RCI (опционально)", "Router access — SSH / RCI (optional)")} {hlp("НЕ обязательно для работы VPN (роутеры сами тянут конфиг по HTTP/HTTPS через pull). SSH-поля — для роутеров с прямым IP (мгновенный push). KeenDNS URL + web-логин/пароль — для RCI-консоли Keenetic (кнопка 🛠 RCI у клиента): управление ndmc-командами через KeenDNS, работает за NAT без SSH. Для Keenetic за NAT используй именно RCI (SSH к ним недоступен).", "NOT required for VPN (routers auto-pull config over HTTP/HTTPS). SSH fields — for direct-IP routers (instant push). KeenDNS URL + web login/password — for the Keenetic RCI console (🛠 RCI button): run ndmc commands via KeenDNS, works behind NAT without SSH. For NAT Keenetic use RCI (SSH is unreachable).")}
{"".join(f'' for c in clients)}
{tr("SSH-доступ к серверам","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.
{tr("API-ключ","API Key")}
"""
return render(html)
if __name__ == "__main__":
ssl_context = None
cert = os.environ.get("PANEL_TLS_CERT", "")
key = os.environ.get("PANEL_TLS_KEY", "")
if panel_scheme() == "https" and cert and key:
ssl_context = (cert, key)
app.run(host="0.0.0.0", port=int(panel_port()), debug=False, ssl_context=ssl_context)