3 Commits

4 changed files with 127 additions and 39 deletions

View File

@@ -169,7 +169,7 @@ phobos-update --list # список бэкапов
- 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate) - 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate)
- 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) - 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
- ✉️ **Telegram:** [@Iot_andrey](https://t.me/Iot_andrey) — вопросы, баги, идеи - ✉️ **Telegram:** [@PCAdministration](https://t.me/PCAdministration) — вопросы, баги, идеи
## На основе и благодарности ## На основе и благодарности

View File

@@ -1 +1 @@
1.2.0 1.2.3

142
app.py
View File

@@ -185,6 +185,43 @@ def _bot_status_text():
return "\n".join(lines) 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<code>%s</code>" % link, "parse_mode": "HTML"})
except Exception:
pass
def telegram_bot(): def telegram_bot():
offset = 0 offset = 0
import urllib.request import urllib.request
@@ -217,23 +254,40 @@ def telegram_bot():
text = (m.get("text") or "").strip() text = (m.get("text") or "").strip()
if not text: if not text:
continue continue
low = text.lower() # detect reply-keyboard button by its emoji prefix (NOT a command/arg)
if _bot_state.get(chat) == "add" and not text.startswith("/"): btn = None
_bot_state.pop(chat, None) if not text.startswith("/"):
_, rep = create_client_cli(text) if text.startswith("➕"):
tg_api("sendMessage", {"chat_id": chat, "text": rep, "parse_mode": "HTML", "reply_markup": MAIN_KB}) btn = "add"
continue elif text.startswith("\U0001F5D1"):
toks = text.split() btn = "del"
cmd = toks[0].split("@")[0].lower() elif text.startswith("\U0001F4CB"):
arg = toks[1] if len(toks) > 1 else "" btn = "list"
if text.startswith("➕") or cmd in ("/add", "/new", "/create"): 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: if arg:
_, rep = create_client_cli(arg) bot_send_new_client(chat, arg)
tg_api("sendMessage", {"chat_id": chat, "text": rep, "parse_mode": "HTML", "reply_markup": MAIN_KB})
else: else:
_bot_state[chat] = "add" _bot_state[chat] = "add"
tg_api("sendMessage", {"chat_id": chat, "text": "Введите имя нового клиента (латиница, цифры, _ и -):"}) tg_api("sendMessage", {"chat_id": chat, "text": "Введите имя нового клиента (латиница, цифры, _ и -):"})
elif text.startswith("\U0001F5D1") or cmd in ("/del", "/delete", "/remove", "/rm"): elif btn == "del" or cmd in ("/del", "/delete", "/remove", "/rm"):
if arg: if arg:
_, rep = delete_client_cli(arg) _, rep = delete_client_cli(arg)
tg_api("sendMessage", {"chat_id": chat, "text": rep, "parse_mode": "HTML", "reply_markup": MAIN_KB}) tg_api("sendMessage", {"chat_id": chat, "text": rep, "parse_mode": "HTML", "reply_markup": MAIN_KB})
@@ -243,14 +297,14 @@ def telegram_bot():
tg_api("sendMessage", {"chat_id": chat, "text": "Клиентов нет.", "reply_markup": MAIN_KB}) tg_api("sendMessage", {"chat_id": chat, "text": "Клиентов нет.", "reply_markup": MAIN_KB})
else: else:
kb = {"inline_keyboard": [[{"text": "\U0001F5D1 " + c.get("client_id", ""), "callback_data": "del:" + c.get("client_id", "")}] for c in cl]} 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}) tg_api("sendMessage", {"chat_id": chat, "text": "Кого удалить? Нажми на клиента:", "reply_markup": kb})
elif text.startswith("\U0001F4CB") or cmd in ("/list", "/ls"): elif btn == "list" or cmd in ("/list", "/ls"):
cl = [c.get("client_id", "") for c in get_clients()] 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 "Клиентов нет." 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}) tg_api("sendMessage", {"chat_id": chat, "text": rep, "reply_markup": MAIN_KB})
elif text.startswith("\U0001F4CA") or cmd in ("/status", "/stat"): 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}) tg_api("sendMessage", {"chat_id": chat, "text": _bot_status_text(), "parse_mode": "HTML", "reply_markup": MAIN_KB})
elif text.startswith("❓") or cmd in ("/help", "/start"): elif btn == "help" or cmd in ("/help", "/start"):
rep = ("<b>PCA Phobos — бот</b>\n\n" rep = ("<b>PCA Phobos — бот</b>\n\n"
"Кнопки внизу или команды:\n" "Кнопки внизу или команды:\n"
"➕ /add &lt;имя&gt; — создать клиента\n" "➕ /add &lt;имя&gt; — создать клиента\n"
@@ -425,6 +479,7 @@ def kick_client_by_id(client_id):
prev_session_keys = None prev_session_keys = None
prev_server_status = {} prev_server_status = {}
_down_streak = {}
_last_fanout = 0 _last_fanout = 0
server_stats_cache = {} server_stats_cache = {}
server_handshakes_cache = {} server_handshakes_cache = {}
@@ -449,12 +504,19 @@ def get_local_stats():
def get_remote_stats(server): def get_remote_stats(server):
import urllib.request
url = f"http://{server['ip']}:8444/api/health"
req = urllib.request.Request(url, headers={"X-API-Key": server.get("api_key", "")})
data = None
for _attempt in range(2): # retry once: smooths transient timeouts (1-worker agent)
try:
data = json.loads(urllib.request.urlopen(req, timeout=6).read())
break
except Exception:
data = None
try: try:
import urllib.request if data is None:
url = f"http://{server['ip']}:8444/api/health" raise Exception("no response")
req = urllib.request.Request(url, headers={"X-API-Key": server.get("api_key", "")})
resp = urllib.request.urlopen(req, timeout=3)
data = json.loads(resp.read())
# cache handshakes so page renders never block on remote HTTP # cache handshakes so page renders never block on remote HTTP
server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {} server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {}
# Filter peers: only count known client public keys # Filter peers: only count known client public keys
@@ -723,7 +785,7 @@ def push_config_to_router(client_id):
def session_monitor(): def session_monitor():
global prev_session_keys, prev_server_status, server_stats_cache, _last_fanout global prev_session_keys, prev_server_status, server_stats_cache, _last_fanout, _down_streak
while True: while True:
try: try:
s = load_settings() s = load_settings()
@@ -760,19 +822,24 @@ def session_monitor():
local_stats = get_local_stats() local_stats = get_local_stats()
server_stats_cache[SERVER_IP] = local_stats server_stats_cache[SERVER_IP] = local_stats
DOWN_CONFIRM = 3 # consecutive failed cycles before declaring a server DOWN
for srv in servers: for srv in servers:
ip = srv["ip"] ip = srv["ip"]
stats = get_remote_stats(srv) stats = get_remote_stats(srv)
server_stats_cache[ip] = stats raw = stats.get("status", "down")
streak = _down_streak.get(ip, 0)
streak = streak + 1 if raw == "down" else 0
_down_streak[ip] = streak
# update cache on OK, or only once a DOWN is confirmed (keep last-good on transient)
if raw == "ok" or streak >= DOWN_CONFIRM:
server_stats_cache[ip] = stats
eff = "down" if streak >= DOWN_CONFIRM else "ok"
was_up = prev_server_status.get(ip, "ok") was_up = prev_server_status.get(ip, "ok")
now_status = stats["status"] if was_up == "ok" and eff == "down":
if was_up == "ok" and now_status == "down":
tg_send(f"🔴 <b>Server {ip}</b> DOWN!") tg_send(f"🔴 <b>Server {ip}</b> DOWN!")
elif was_up == "down" and now_status == "ok": elif was_up == "down" and eff == "ok":
tg_send(f"🟢 <b>Server {ip}</b> back ONLINE") tg_send(f"🟢 <b>Server {ip}</b> back ONLINE")
prev_server_status[ip] = eff
prev_server_status[ip] = now_status
# periodic config fan-out to secondaries (covers CLI-created clients # periodic config fan-out to secondaries (covers CLI-created clients
# and config drift; gated ~5 min, skips down servers) # and config drift; gated ~5 min, skips down servers)
@@ -854,7 +921,7 @@ input:focus{outline:none;border-color:#4f46e5}
CONTENT CONTENT
<div class="footer"> <div class="footer">
<b style="color:#a5b4fc">PCA Phobos Panel</b> — панель управления &copy; <a href="https://github.com/andrey271192/PCA_Phobos" target="_blank" rel="noopener">andrey271192</a><br> <b style="color:#a5b4fc">PCA Phobos Panel</b> — панель управления &copy; <a href="https://github.com/andrey271192/PCA_Phobos" target="_blank" rel="noopener">andrey271192</a><br>
Поддержать проект: <a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener">💖 Boosty</a> · <a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener">💳 Ozon Bank</a> · <a href="https://t.me/c/2474115507/78722" target="_blank" rel="noopener">✉️ Telegram</a> Поддержать проект: <a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener">💖 Boosty</a> · <a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener">💳 Ozon Bank</a> · <a href="https://t.me/PCAdministration" target="_blank" rel="noopener">✉️ Telegram</a>
<div style="margin-top:8px;font-size:.88em;color:#475569;line-height:1.7;text-align:left;max-width:660px;margin:8px auto 0"> <div style="margin-top:8px;font-size:.88em;color:#475569;line-height:1.7;text-align:left;max-width:660px;margin:8px auto 0">
<b>Создано на основе открытых проектов:</b><br> <b>Создано на основе открытых проектов:</b><br>
&bull; <a href="https://github.com/Ground-Zerro/Phobos" target="_blank" rel="noopener">Phobos</a> — обфусцированный WireGuard VPN, автор <b>Ground_Zerro</b> &middot; <a href="https://boosty.to/ground_zerro" target="_blank" rel="noopener">поддержать</a><br> &bull; <a href="https://github.com/Ground-Zerro/Phobos" target="_blank" rel="noopener">Phobos</a> — обфусцированный WireGuard VPN, автор <b>Ground_Zerro</b> &middot; <a href="https://boosty.to/ground_zerro" target="_blank" rel="noopener">поддержать</a><br>
@@ -1317,9 +1384,12 @@ def labels_page():
def settings_page(): def settings_page():
s = load_settings() s = load_settings()
msg = "" msg = ""
if request.method == "POST" and request.form.get("action") in ("update", "rollback"): if request.method == "POST" and request.form.get("action") in ("update", "rollback", "check"):
if request.form.get("action") == "update": _act = request.form.get("action")
if _act == "update":
_out = run_update(request.form.get("target", "").strip() or "main") _out = run_update(request.form.get("target", "").strip() or "main")
elif _act == "check":
_out = run_update("--check")
else: else:
_out = run_update("--rollback") _out = run_update("--rollback")
s = load_settings() s = load_settings()
@@ -1360,9 +1430,13 @@ def settings_page():
<p>{tr("Установленная версия","Installed version")}: <b style="color:#a5b4fc">{panel_version()}</b></p> <p>{tr("Установленная версия","Installed version")}: <b style="color:#a5b4fc">{panel_version()}</b></p>
<form method="post" class="form-row"> <form method="post" class="form-row">
<input type="hidden" name="action" value="update"> <input type="hidden" name="action" value="update">
<input type="text" name="target" placeholder="main / v1.1.0" style="width:150px"> <input type="text" name="target" placeholder="main / beta / v1.2.3" style="width:150px">
<button class="btn btn-primary" type="submit" onclick="return confirm('{tr("Обновить панель и скрипты?","Update panel and scripts?")}')">{tr("Обновить","Update")}</button> <button class="btn btn-primary" type="submit" onclick="return confirm('{tr("Обновить панель и скрипты?","Update panel and scripts?")}')">{tr("Обновить","Update")}</button>
</form> </form>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="check">
<button class="btn btn-sm" style="background:#0ea5e9" type="submit">{tr("Проверить обновления","Check for updates")}</button>
</form>
<form method="post" style="margin-top:8px"> <form method="post" style="margin-top:8px">
<input type="hidden" name="action" value="rollback"> <input type="hidden" name="action" value="rollback">
<button class="btn btn-danger btn-sm" type="submit" onclick="return confirm('{tr("Откатить на предыдущую версию?","Roll back to the previous version?")}')">{tr("Откатить на предыдущую","Rollback")}</button> <button class="btn btn-danger btn-sm" type="submit" onclick="return confirm('{tr("Откатить на предыдущую версию?","Roll back to the previous version?")}')">{tr("Откатить на предыдущую","Rollback")}</button>

View File

@@ -60,10 +60,19 @@ apply_ref() {
done done
} }
ref_exists() { curl -fsSL -m12 "$RAW/$1/app.py" -o /dev/null 2>/dev/null; }
case "${1:-}" in case "${1:-}" in
--version|-v) --version|-v|--check)
echo "Установлено: $CUR" echo "Установлено: $CUR"
echo "Доступно: $(latest_version)" echo "Стабильная: $(latest_version)"
beta=$(curl -fsSL -m8 "$RAW/beta/VERSION" 2>/dev/null | tr -d ' \r\n')
[ -n "$beta" ] && echo "Бета (beta): $beta"
;;
--versions|--tags)
echo "Доступные версии (git tags):"
curl -fsSL -m10 "https://api.github.com/repos/$REPO/tags?per_page=100" 2>/dev/null \
| grep -oE '"name": *"[^"]+"' | sed 's/.*"name": *"\(.*\)"/ \1/' || echo " (нет данных)"
;; ;;
--list) --list)
echo "Бэкапы (для отката):" echo "Бэкапы (для отката):"
@@ -83,6 +92,11 @@ case "${1:-}" in
;; ;;
*) *)
ref="${1:-main}"; [ "$ref" = "latest" ] && ref="main" ref="${1:-main}"; [ "$ref" = "latest" ] && ref="main"
if ! ref_exists "$ref"; then
echo "Версия/ветка '$ref' не найдена на GitHub. Обновление отменено (текущая $CUR не тронута)."
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
exit 1
fi
target=$(curl -fsSL -m10 "$RAW/$ref/VERSION" 2>/dev/null | tr -d ' \r\n'); [ -z "$target" ] && target="$ref" target=$(curl -fsSL -m10 "$RAW/$ref/VERSION" 2>/dev/null | tr -d ' \r\n'); [ -z "$target" ] && target="$ref"
echo "Текущая: $CUR -> целевая: $target ($ref)" echo "Текущая: $CUR -> целевая: $target ($ref)"
b=$(do_backup); echo "Бэкап: $b" b=$(do_backup); echo "Бэкап: $b"