7 Commits

6 changed files with 482 additions and 143 deletions

257
README.md
View File

@@ -1,133 +1,165 @@
# PCA Phobos — Web Panel # PCA Phobos — VPN, который не отключается
Веб-панель управления для [Phobos](https://git.zerrolabs.org/Ground-Zerro/Phobos) (обфусцированный WireGuard VPN). **Веб-панель и turnkey-установщик для [Phobos](https://github.com/Ground-Zerro/Phobos)** — обфусцированного WireGuard VPN. Ставится на чистый VPS одной командой, управляет десятками роутеров и устройств с одной страницы, сам переключается между серверами при сбоях и восстанавливается после перезагрузок.
## Быстрый старт (turnkey — с чистого VPS, одной командой) <p align="center">
<img src="docs/img/sessions.png" alt="Панель — активные сессии" width="850">
</p>
Ставит ВСЁ со всеми зависимостями: wg-obfuscator, WireGuard, обфускатор-сервисы, ---
веб-панель, nginx, скрипты онбординга роутеров и сторож авто-восстановления.
Предустановленный Phobos НЕ требуется. ## Что это простыми словами
Обычный VPN падает, когда падает сервер или провайдер банит IP. **PCA Phobos — нет.**
- 🌐 **Маскированный WireGuard** — трафик не отличить от обычного, проходит мимо DPI и блокировок по сигнатуре.
- 🔁 **Авто-переключение между серверами** — сервер лёг или порт прикрыли → роутер сам прыгает на резервный за ~10–15 секунд, без твоего участия.
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
- 📱 **Телефоны** — Android (PhobosWG, с обфускацией) и iPhone (обычный WireGuard) через QR-код.
---
## Возможности
- **Активные сессии** — VPN IP, Real IP, метка, handshake, трафик RX/TX, Kick.
- **Клиенты VPN** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера.
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM), резервные серверы; роутеры переключаются автоматически.
- **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP.
- **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS.
- **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR.
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия.
- **Версии и обновления** — обновление и откат прямо из панели.
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
- **Telegram-бот** — управление клиентами командами прямо из чата:
- `/add <имя>` — создать клиента (бот пришлёт команду установки)
- `/del <имя>` — удалить клиента
- `/list` — список клиентов
- **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь.
(Работает только для чата из настроек `tg_chat_id`.)
---
## Скриншоты
| Сессии | Клиенты | Серверы |
|--------|---------|---------|
| ![Сессии](docs/img/sessions.png) | ![Клиенты](docs/img/clients.png) | ![Серверы](docs/img/servers.png) |
> Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`).
---
## Быстрый старт (с чистого VPS, одной командой)
Ставит **всё** со всеми зависимостями: wg-obfuscator, WireGuard, обфускатор-сервисы, веб-панель, nginx, скрипты онбординга роутеров, сторож авто-восстановления. Предустановленный Phobos **не требуется**.
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh) bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
``` ```
> Если репозиторий приватный — добавь `GH_TOKEN`: ### С параметрами
> ```bash
> GH_TOKEN=ghp_xxx bash <(curl -fsSL -H "Authorization: token ghp_xxx" \
> https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
> ```
### Вторичный сервер (для failover/балансировки) ```bash
PANEL_PASS=МойПароль TG_TOKEN=123:abc TG_CHAT=123456789 \
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
```
| Переменная | По умолчанию | Описание |
|-------------------|-----------------|----------|
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
| `PANEL_PORT` | случайный | Порт веб-панели |
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
| `ALLOW_PLAIN_WG` | пусто | `=1` открыть порт 51820 для iOS WireGuard (без обфускации) |
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
### Вторичный сервер (для failover / балансировки)
```bash ```bash
MAIN_SERVER=<ip_основного> MAIN_API_KEY=<API key из основного> \ MAIN_SERVER=<ip_основного> MAIN_API_KEY=<API key из основного> \
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh) bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)
``` ```
### С кастомными параметрами Затем в панели → **Серверы** → добавь его в список.
```bash
PANEL_PASS=AdminPass456 \
TG_TOKEN=1234567890:AABBCCDDaabbccdd \
TG_CHAT=123456789 \
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
```
| Переменная | По умолчанию | Описание |
|--------------|----------------|---------------------------------|
| `PANEL_PASS` | `OcAdmin2026!` | Пароль веб-панели (admin) |
| `TG_TOKEN` | пусто | Telegram bot token |
| `TG_CHAT` | пусто | Telegram chat ID для уведомлений|
| `PANEL_PORT` | `8443` | Порт веб-панели |
--- ---
## Возможности ## Клиенты для телефона
- **Активные сессии** — VPN IP, Real IP, handshake, трафик RX/TX, Kick В таблице **Клиенты** у каждого клиента есть кнопки:
- **Клиенты VPN** — добавить/удалить через Phobos, статус online/offline
- **Именование объектов** — привязать имя к Real IP (отображается в сессиях и Telegram) - 📱 **Android** — `phobos://`-ссылка + QR для приложения **PhobosWG** (с обфускацией). Скан QR → импорт.
- **Срок подписки** — дата окончания для каждого клиента: - 🍎 **iPhone / iOS** — обычный WireGuard-конфиг + QR для официального WireGuard (**без** обфускации). Требует открытого порта 51820 (`ALLOW_PLAIN_WG=1` при установке).
- Date picker в таблице клиентов
- Обратный отсчёт (18д, 3д⚠️, завтра⚠️, истёк⛔) > Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
- При истечении: автокик + Telegram уведомление
- Предупреждения за 3 дня и 1 день **Приложения:**
- **Telegram уведомления** — 🟢 подключение, 🔴 отключение, ⚠️ за 3 дня, ⛔ истёк - **Android (PhobosWG):** автор пока не публикует APK официально. Положи `.apk` на сервер в `/opt/Phobos/www/app/PhobosWG.apk` — панель сама покажет кнопку «Скачать приложение PhobosWG» на странице Android-клиента (раздаётся по `http://<сервер>/app/PhobosWG.apk`).
- **Настройки** — смена пароля панели, Telegram bot token + chat ID, интервал мониторинга - **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента.
- **Инфо о сервере** — порты, пути, команда установки на роутер
---
## Обновление и откат
Проект версионируется (см. файл `VERSION` и git-теги `vX.Y.Z`).
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**.
**Из консоли:**
```bash
phobos-update # обновить до последней версии
phobos-update v1.1.0 # обновить до конкретной версии (git tag)
phobos-update --rollback # откатить на предыдущую версию
phobos-update --version # показать установленную и доступную версию
phobos-update --list # список бэкапов
```
Обновляется только слой панели и скриптов. **Ключи WireGuard, `server.env` и клиенты не трогаются.** Перед каждым обновлением делается бэкап → откат восстанавливает его.
---
## Возможные проблемы и решения
| Симптом | Причина | Решение |
|---------|---------|---------|
| **Команда установки → 403 / не качается** | репозиторий приватный | Сделай репозиторий публичным, либо ставь с токеном: `GH_TOKEN=ghp_xxx bash <(curl -fsSL -H "Authorization: token ghp_xxx" .../install.sh)` |
| **Панель не открывается** | панель перезапускалась (обновление) / сервер недоступен | Подожди 5 сек, обнови страницу. Проверь: `systemctl status phobos-panel` |
| **Клиент Offline, хотя работает** | роутер на резервном сервере | Норма — статус считается по handshake на **любом** сервере; обнови страницу |
| **«SSH timeout» при тесте роутера** | роутер за NAT, входящий SSH закрыт | Это нормально — управление идёт по pull-каналу через туннель, SSH не нужен |
| **У роутера нет интернета на LAN-устройствах** | WG-интерфейс `private` / нет привязки устройства к политике | На роутере: `security-level public` + `ip hotspot host <mac> policy <PolicyName>`; health-скрипт чинит это сам в течение ~5 мин |
| **Роутер не поднялся после перезагрузки** | Entware/USB не запустил автозагрузку | Сторож (watchdog) поднимет за ~3 мин (нужны KeenDNS + web-логин роутера в `router_access`), либо перезагрузи роутер ещё раз |
| **iPhone не подключается по `phobos://`** | на iOS нет приложения-обфускатора | Используй 🍎-конфиг (обычный WireGuard) + открой 51820 (`ALLOW_PLAIN_WG=1`) |
| **Смена сервера применяется не сразу** | роутер тянет конфиг по cron | Применяется за ~12–15 сек, не мгновенно |
| **Забыл порт панели** | случайный порт | `cat /opt/phobos-panel/.port` |
| **Забыл API key** | | `grep server_api_key /opt/phobos-panel/settings.json` |
Логи: `journalctl -u phobos-panel -n 50` · сторож: `/opt/Phobos/server/watchdog.log` · роутер: `/opt/etc/Phobos/health.log`.
--- ---
## Архитектура ## Архитектура
``` ```
Keenetic Router → wg-obfuscator (client) → :51821 → wg-obfuscator (server) → :51820 WireGuard → Internet Телефон/Роутер → wg-obfuscator (клиент) ──обфускация──► сервер :2083/5443/993
│ │ де-обфускация
10.25.0.x WireGuard :51820
│ │ 10.25.0.x
Web Panel :8443 ┌──────────┴──────────┐
(Flask + Gunicorn) Веб-панель Failover на
``` (Flask+Gunicorn) резервные серверы
- Протокол: WireGuard + wg-obfuscator (обфускация от DPI)
- Аутентификация: ключевые пары (нет паролей — только WireGuard ключи)
- Подсеть VPN: `10.25.0.0/16`
- Мониторинг сессий: каждые 30 сек (настраивается)
---
## Управление
```bash
# Phobos (VPN)
phobos # Интерактивное меню
systemctl status wg-quick@wg0 # WireGuard
systemctl status wg-obfuscator # Обфускатор
wg show wg0 # Активные peers
# Веб-панель
systemctl status phobos-panel
systemctl restart phobos-panel
journalctl -u phobos-panel -f
```
---
## Структура файлов
```
/opt/Phobos/
├── clients/ # Клиенты VPN (ключи, конфиги)
│ └── {name}/
│ ├── metadata.json
│ ├── {name}.conf
│ └── wg-obfuscator.conf
├── server/
│ ├── server.env # Конфигурация сервера
│ └── wg-obfuscator.conf
└── repo/server/scripts/
└── phobos-client.sh # Управление клиентами
/opt/phobos-panel/
├── app.py # Flask веб-панель
├── settings.json # Настройки (пароль, Telegram, метки, сроки)
└── .secret_key # Ключ сессии
/etc/wireguard/
└── wg0.conf # WireGuard конфигурация
```
---
## Обновление панели
```bash
curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/app.py \
> /opt/phobos-panel/app.py
systemctl restart phobos-panel
``` ```
- Протокол: **WireGuard + wg-obfuscator** (обфускация от DPI).
- Управление роутерами — по **туннелю** (`10.25.0.1`), устойчиво к бану публичного IP.
- Состояние: JSON-файлы (без БД).
--- ---
@@ -137,23 +169,14 @@ systemctl restart phobos-panel
- 💖 **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) — вопросы, баги, идеи
## На основе и благодарности ## На основе и благодарности
PCA Phobos — это веб-панель и turnkey-инсталлятор поверх: PCA Phobos — это веб-панель и turnkey-инсталлятор поверх отличных открытых проектов:
- [**Phobos**](https://github.com/Ground-Zerro/Phobos) (Ground-Zerro) — обфусцированный WireGuard. Поддержать автора: [Boosty](https://boosty.to/ground_zerro) ❤️ - [**Phobos**](https://github.com/Ground-Zerro/Phobos) — обфусцированный WireGuard, автор **Ground_Zerro**. Поддержать: [Boosty](https://boosty.to/ground_zerro) ❤️
- [**WireGuard Easy**](https://github.com/wg-easy/wg-easy) (Emile Nijssen, AGPL-3.0) — веб-панель WireGuard, на которой основан Phobos. Поддержать автора: [GitHub Sponsors](https://github.com/sponsors/WeeJeWel) ❤️ - [**WireGuard Easy**](https://github.com/wg-easy/wg-easy) — веб-панель WireGuard (AGPL-3.0), автор **Emile Nijssen**. Поддержать: [GitHub Sponsors](https://github.com/sponsors/WeeJeWel) ❤️
- [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) (ClusterM) — обфускация WireGuard-трафика. Поддержать автора: [Boosty](https://boosty.to/cluster) ❤️ - [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) — обфускация WireGuard-трафика (GPL-3.0), автор **ClusterM**. Поддержать: [Boosty](https://boosty.to/cluster) ❤️
Спасибо авторам за отличные инструменты. Спасибо авторам за отличные инструменты.
## Клиенты для телефона
В таблице клиентов у каждого клиента есть кнопки:
- 📱 **Android** — `phobos://`-ссылка + QR для приложения **PhobosWG** (с обфускацией). Скан QR → импорт.
- 🍎 **iPhone / iOS** — обычный WireGuard-конфиг + QR для официального WireGuard (БЕЗ обфускации). Требует открытого порта 51820 на сервере (поставь `ALLOW_PLAIN_WG=1` при установке, либо удали правило `iptables ... 51820 ... DROP`).
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.

View File

@@ -1 +1 @@
1.0.0 1.2.3

334
app.py
View File

@@ -72,6 +72,249 @@ def tg_send(text):
pass pass
def notify_on(cid):
return load_settings().get("client_notify", {}).get(cid, True)
def create_client_cli(name):
"""Create a client (add + assign + fanout + install link). Returns (ok, html_msg). Used by web + Telegram bot."""
import re as _re
name = (name or "").strip()
if not name or not _re.match(r"^[a-zA-Z0-9_-]+$", name):
return False, "Имя: латиница, цифры, _ и - (без пробелов)."
if os.path.isdir(os.path.join(CLIENTS_DIR, name)):
return False, "Клиент %s уже существует." % name
script = "%s/repo/server/scripts/phobos-client.sh" % PHOBOS_DIR
try:
subprocess.check_output([script, "add", name], text=True, timeout=45,
stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()})
except subprocess.CalledProcessError as e:
return False, "Ошибка создания: %s" % (e.output or "")[-300:]
try:
auto_assign_server(name); fanout_router_config(name)
except Exception:
pass
cmd = ""
try:
out = subprocess.check_output([script, "link", name], text=True, timeout=25,
stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()})
for ln in out.splitlines():
if ln.strip().startswith("curl") or "/init/" in ln:
cmd = ln.strip(); break
except Exception:
pass
msg = "\u2705 Клиент <b>%s</b> создан." % name
if cmd:
msg += "\n\nКоманда установки на роутер:\n<code>%s</code>" % 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 = "%s/repo/server/scripts/phobos-client.sh" % PHOBOS_DIR
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 Клиент <b>%s</b> удалён." % 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 <b>Серверы</b>:"]
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 <b>Клиенты</b>:")
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<code>%s</code>" % 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 = ("<b>PCA Phobos — бот</b>\n\n"
"Кнопки внизу или команды:\n"
"➕ /add &lt;имя&gt; — создать клиента\n"
"\U0001F5D1 /del &lt;имя&gt; — удалить\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(): def get_wg_peers():
"""Parse `wg show wg0` to get active peers with transfer/handshake info.""" """Parse `wg show wg0` to get active peers with transfer/handshake info."""
try: try:
@@ -236,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 = {}
@@ -260,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
@@ -534,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()
@@ -554,13 +805,15 @@ def session_monitor():
client_id, real_ip = key client_id, real_ip = key
label = labels.get(real_ip, "") label = labels.get(real_ip, "")
name = f"{label} ({real_ip})" if label else real_ip name = f"{label} ({real_ip})" if label else real_ip
tg_send(f"🟢 <b>{client_id}</b> connected — {name}") if notify_on(client_id):
tg_send(f"🟢 <b>{client_id}</b> connected — {name}")
for key in prev_session_keys - current_keys: for key in prev_session_keys - current_keys:
client_id, real_ip = key client_id, real_ip = key
label = labels.get(real_ip, "") label = labels.get(real_ip, "")
name = f"{label} ({real_ip})" if label else real_ip name = f"{label} ({real_ip})" if label else real_ip
tg_send(f"🔴 <b>{client_id}</b> disconnected — {name}") if notify_on(client_id):
tg_send(f"🔴 <b>{client_id}</b> disconnected — {name}")
prev_session_keys = current_keys prev_session_keys = current_keys
@@ -569,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)
@@ -599,6 +857,8 @@ def session_monitor():
monitor_thread = threading.Thread(target=session_monitor, daemon=True) monitor_thread = threading.Thread(target=session_monitor, daemon=True)
monitor_thread.start() monitor_thread.start()
bot_thread = threading.Thread(target=telegram_bot, daemon=True)
bot_thread.start()
PAGE = """<!DOCTYPE html> PAGE = """<!DOCTYPE html>
@@ -661,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>
@@ -874,6 +1134,15 @@ def clients_page():
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
msg = f'<div class="alert alert-error">{e.output}</div>' msg = f'<div class="alert alert-error">{e.output}</div>'
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'<div class="alert alert-ok">Уведомления {client_id}: {st}</div>'
elif action == "set_expiry": elif action == "set_expiry":
client_id = request.form.get("client_id", "").strip() client_id = request.form.get("client_id", "").strip()
expiry = request.form.get("expiry", "").strip() expiry = request.form.get("expiry", "").strip()
@@ -938,6 +1207,7 @@ def clients_page():
all_srv = get_all_servers_ordered() all_srv = get_all_servers_ordered()
assignments = s.get("client_assignments", {}) assignments = s.get("client_assignments", {})
assignments_notify = s.get("client_notify", {})
# Online = fresh handshake on ANY server (truthful during failover/transition) # Online = fresh handshake on ANY server (truthful during failover/transition)
online_all = set(online_pubs) online_all = set(online_pubs)
for _sv in all_srv: for _sv in all_srv:
@@ -991,6 +1261,10 @@ def clients_page():
h_del = hlp("Безвозвратно удалить клиента, его ключи и конфигурацию. Отменить нельзя.", h_del = hlp("Безвозвратно удалить клиента, его ключи и конфигурацию. Отменить нельзя.",
"Permanently delete the client, its keys and config. Cannot be undone.") "Permanently delete the client, its keys and config. Cannot be undone.")
del_confirm = tr("Удалить " + cid + "?", "Delete " + cid + "?") 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")
rows += f"""<tr> rows += f"""<tr>
<td>{cid}</td> <td>{cid}</td>
<td>{ip}</td> <td>{ip}</td>
@@ -1015,6 +1289,11 @@ def clients_page():
</form> </form>
</td> </td>
<td style="display:flex;gap:4px;align-items:center"> <td style="display:flex;gap:4px;align-items:center">
<form method="post" title="{notify_ttl}">
<input type="hidden" name="action" value="toggle_notify">
<input type="hidden" name="client_id" value="{cid}">
<button class="btn btn-sm" style="background:{notify_bg}" type="submit">{notify_icon}</button>
</form>
<form method="post" onsubmit="return confirm('{del_confirm}')"> <form method="post" onsubmit="return confirm('{del_confirm}')">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<input type="hidden" name="client_id" value="{cid}"> <input type="hidden" name="client_id" value="{cid}">
@@ -1105,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()
@@ -1148,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>
@@ -1307,11 +1593,17 @@ def client_phone(cid, mode):
qr = qr_datauri(link if (mode == "android" and link) else conf) qr = qr_datauri(link if (mode == "android" and link) else conf)
if mode == "android": if mode == "android":
title = tr("Android — PhobosWG (с обфускацией)", "Android — PhobosWG (obfuscated)") title = tr("Android — PhobosWG (с обфускацией)", "Android — PhobosWG (obfuscated)")
extra = (f'<p>{tr("phobos:// ссылка — импорт в PhobosWG:", "phobos:// link — import into PhobosWG:")}</p>' apk_btn = ""
if os.path.exists("/opt/Phobos/www/app/PhobosWG.apk"):
apk_btn = (f'<p style="margin-top:6px"><a href="http://{SERVER_IP}/app/PhobosWG.apk" '
f'class="btn btn-primary" style="background:#16a34a">📥 {tr("Скачать приложение PhobosWG (APK)","Download PhobosWG app (APK)")}</a></p>')
extra = (apk_btn +
f'<p>{tr("phobos:// ссылка — импорт в PhobosWG:", "phobos:// link — import into PhobosWG:")}</p>'
f'<textarea readonly onclick="this.select()" style="width:100%;height:90px;font-size:.75em">{link}</textarea>') f'<textarea readonly onclick="this.select()" style="width:100%;height:90px;font-size:.75em">{link}</textarea>')
else: else:
title = tr("iPhone / iOS WireGuard (без обфускации)", "iPhone / iOS WireGuard (plain, no obfuscation)") title = tr("iPhone / iOS WireGuard (без обфускации)", "iPhone / iOS WireGuard (plain, no obfuscation)")
extra = (f'<p style="color:#fcd34d">{tr("⚠ Обычный WireGuard без маскировки (Endpoint → :51820). На сервере должен быть открыт порт 51820.", "⚠ Plain WireGuard, no obfuscation (Endpoint → :51820). Port 51820 must be open on the server.")}</p>') extra = (f'<p><a href="https://apps.apple.com/app/wireguard/id1441195209" target="_blank" rel="noopener" class="btn btn-primary" style="background:#0ea5e9">📥 {tr("WireGuard в App Store","WireGuard on the App Store")}</a></p>'
f'<p style="color:#fcd34d">{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).")}</p>')
html = f""" html = f"""
<div class="container"> <div class="container">
{nav('clients')} {nav('clients')}

9
docs/img/README.md Normal file
View File

@@ -0,0 +1,9 @@
# Скриншоты
Положи сюда 3 PNG (панель открыта — сделай скрины своих страниц):
- `sessions.png` — страница «Сессии»
- `clients.png` — страница «Клиенты»
- `servers.png` — страница «Серверы»
README в корне на них ссылается. Рекомендуемая ширина ~1000–1400px.

View File

@@ -221,11 +221,12 @@ server {
listen [::]:80 default_server; listen [::]:80 default_server;
location /init/ { alias /opt/Phobos/www/init/; default_type application/x-sh; } location /init/ { alias /opt/Phobos/www/init/; default_type application/x-sh; }
location /packages/ { alias /opt/Phobos/www/packages/; default_type application/octet-stream; } location /packages/ { alias /opt/Phobos/www/packages/; default_type application/octet-stream; }
location /app/ { alias /opt/Phobos/www/app/; default_type application/octet-stream; }
location / { return 404; } location / { return 404; }
} }
NGINX NGINX
ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos
chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages mkdir -p /opt/Phobos/www/app; chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages /opt/Phobos/www/app
nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed" nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed"
# ── 9. router watchdog (auto reboot-recovery) ── # ── 9. router watchdog (auto reboot-recovery) ──

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"