release: 1.4.0 — one version for stable/beta/dev
Light/dark theme and compact mobile layout, per-client speed limit, real speed tests, panel domain (Let's Encrypt), client address + cascade relay, RKN scanner guard, WARP, backup/restore, router web terminal, Android VPN client with split tunnelling and Android admin app. Updating from 1.2.x keeps keys, server.env and clients; tested 1.2.8 -> 1.4.0 on live VMs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
10
BETA.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Каналы PCA Phobos
|
||||||
|
|
||||||
|
С версии **1.4.0** все три канала (stable / beta / dev) — одна и та же версия.
|
||||||
|
Новые фичи по-прежнему сначала выкатываются в `dev`, затем в `beta`, затем в `main`
|
||||||
|
(`promote.sh`), но база одна — без расхождений.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
phobos-update # stable (открытый)
|
||||||
|
GH_TOKEN=… phobos-update beta|dev # для подписчиков Boosty
|
||||||
|
```
|
||||||
67
CHANGELOG.md
@@ -19,18 +19,23 @@ phobos-update beta # или: phobos-update dev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## stable
|
## 1.4.0 — единая версия (stable = beta = dev)
|
||||||
|
|
||||||
### v1.2.8
|
Три канала сведены в одну версию: всё из dev (WARP, ttyd, бэкап, маршрут Keenetic, параметры обфускатора)
|
||||||
- **fix:** install/update теперь запускают `phobos-self-check --fix`: автоматически чинят потерянный `=` в `SERVER_WG_PUBLIC_KEY`, сверяют ключ с `wg0`, проверяют router helper-файлы и shell syntax до выдачи клиентских ссылок.
|
+ фиксы beta (обновлятор через API, порт/схема панели) + новое:
|
||||||
- **fix:** Android/iOS phone configs теперь восстанавливают потерянный `=` в WireGuard `PrivateKey/PublicKey/PresharedKey`. Это закрывает ошибку PhobosWG «Неправильная длина ключа в PublicKey».
|
- **feat:** светлая/тёмная тема, компактные карточки клиентов, мобильная версия без горизонтальной прокрутки.
|
||||||
- **fix:** `lib-core.sh` больше не теряет финальный `=` при чтении `server.env`, поэтому новые клиентские `.conf` получают полный `PublicKey`.
|
- **feat:** лимит скорости на клиента (tc на wg0, применяется и на вторых серверах).
|
||||||
- **fix:** Android PhobosWG больше не получает `PresharedKey = none` в phobos payload, из-за чего импорт мог падать с «Неправильная длина ключа в PublicKey».
|
- **feat:** замер скорости сервера (реальный) и клиента (живой + `/speedtest` на устройстве через туннель).
|
||||||
- **fix:** клиентский router package теперь содержит обязательные `lib-client.sh`, `install-obfuscator.sh`, `install-wireguard.sh` и дополнительные helper-скрипты из PCA repo, поэтому `wget .../init/TOKEN.sh | sh` больше не падает с `can't open './lib-client.sh'`.
|
- **feat:** домен панели (nginx + Let's Encrypt), адрес для клиентов по домену, каскад (`phobos-cascade.sh`).
|
||||||
- **fix:** поле имени клиента больше не даёт ошибку в консоли Chrome из-за HTML `pattern`.
|
- **feat:** сторож — блоклист сканеров РКН (`phobos-guard.sh`).
|
||||||
- **fix:** fresh stable install теперь кладёт обязательный `lib-core.sh` рядом с `phobos-client.sh`, поэтому `/clients` не сыпется на `check_root/load_env/die/log_*: command not found`.
|
- **fix:** secondary-setup на чистой Ubuntu 24.04 (нет iptables → wg0 не поднимался), без tty, `SERVER_IP` для NAT; клиенты досылаются на новый сервер сразу.
|
||||||
- **fix:** шаг `[6/9] PCA patches` больше не падает с `curl: (23) Failure writing output to destination`, если папки `repo/server/scripts` или `repo/client/templates` не были созданы upstream sparse clone.
|
- **fix:** очистка orphan-peer (не было WG_CONFIG), дубль карточки WARP и кнопки WARP, имена клиентов кириллицей.
|
||||||
- **fix:** установка `wg-obfuscator` на x86_64 больше не падает с `ERROR: obfuscator binary for x86_64 missing`. Если бинарника нет в Ground-Zerro, installer и secondary installer берут `linux-x64` из ClusterM releases.
|
- **fix:** после обновления старым обновлятором 1.2.x/1.3.x панель сама докачивает новые скрипты.
|
||||||
|
- **fix:** APK без токена подписчика — из публичного релиза (как в 1.2.8).
|
||||||
|
|
||||||
|
Обновление: `phobos-update` (stable) — клиенты, ключи и `server.env` не трогаются. Проверено на школе: 1.2.8 → 1.4.0, старый клиент работает без переустановки.
|
||||||
|
|
||||||
|
## stable
|
||||||
|
|
||||||
### v1.2.7
|
### v1.2.7
|
||||||
- **fix:** импорт `phobos://` в PhobosWG. Конфиги дополняются `= none` для недостающих полей (`PresharedKey` и др.) перед base64 — иначе приложение писало «невозможно импортировать туннель».
|
- **fix:** импорт `phobos://` в PhobosWG. Конфиги дополняются `= none` для недостающих полей (`PresharedKey` и др.) перед base64 — иначе приложение писало «невозможно импортировать туннель».
|
||||||
@@ -58,12 +63,9 @@ phobos-update beta # или: phobos-update dev
|
|||||||
|
|
||||||
| Версия | Проблема | Решение |
|
| Версия | Проблема | Решение |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| 1.2.7 / 1.3.x | PhobosWG: «невозможно импортировать туннель» | `phobos://` дополняется `= none` для всех обязательных полей (`PresharedKey`) по спеке формата |
|
|
||||||
| 1.2.8 / 1.3.x | Stable не показывал кнопку скачивания APK без локального файла | кнопка **PhobosWG (APK)** всегда доступна на Android-странице; `/download/apk` ведет на public stable release |
|
|
||||||
| 1.2.8 / 1.3.x | Android PhobosWG: «Неправильная длина ключа в PublicKey» | Android/iOS payload восстанавливает потерянный `=` в WireGuard ключах и не добавляет `PresharedKey = none`; QR/link нужно открыть заново |
|
| 1.2.8 / 1.3.x | Android PhobosWG: «Неправильная длина ключа в PublicKey» | Android/iOS payload восстанавливает потерянный `=` в WireGuard ключах и не добавляет `PresharedKey = none`; QR/link нужно открыть заново |
|
||||||
| 1.2.8 / 1.3.x | Установка/обновление могли пройти, но потом панель генерила кривую ссылку | `phobos-self-check --fix` встроен в install/update и доступен как отдельная команда |
|
| 1.2.8 / 1.3.x | Установка/обновление могли пройти, но потом панель генерила кривую ссылку | `phobos-self-check --fix` встроен в install/update и доступен как отдельная команда |
|
||||||
| 1.2.8 / 1.3.x | Router init: `can't open './lib-client.sh'` | обязательные router helper-скрипты добавлены в PCA overlay и managed update |
|
| 1.2.7 / 1.3.x | PhobosWG: «невозможно импортировать туннель» | `phobos://` дополняется `= none` для всех обязательных полей (`PresharedKey`) по спеке формата |
|
||||||
| 1.2.8 / 1.3.x | Stable `/clients`: `lib-core.sh: No such file or directory`, `check_root/load_env: command not found` | `lib-core.sh` добавлен в public repo, installer и updater |
|
|
||||||
| 1.2.8 / 1.3.x | Install `[6/9] PCA patches`: `curl: (23) Failure writing output to destination` | `fetch()` создаёт папку назначения перед `curl -o` |
|
| 1.2.8 / 1.3.x | Install `[6/9] PCA patches`: `curl: (23) Failure writing output to destination` | `fetch()` создаёт папку назначения перед `curl -o` |
|
||||||
| 1.2.8 / 1.3.x | VPS x86_64: `ERROR: obfuscator binary for x86_64 missing` | авто-загрузка `linux-x64` из ClusterM releases + нормализация `amd64 -> x86_64` |
|
| 1.2.8 / 1.3.x | VPS x86_64: `ERROR: obfuscator binary for x86_64 missing` | авто-загрузка `linux-x64` из ClusterM releases + нормализация `amd64 -> x86_64` |
|
||||||
| 1.2.6 / 1.3.x | Роутер aarch64: «бинарник не найден в архиве» | авто-загрузка aarch64/mipsel/armv7 из ClusterM releases |
|
| 1.2.6 / 1.3.x | Роутер aarch64: «бинарник не найден в архиве» | авто-загрузка aarch64/mipsel/armv7 из ClusterM releases |
|
||||||
@@ -75,6 +77,17 @@ phobos-update beta # или: phobos-update dev
|
|||||||
## beta (приватный)
|
## beta (приватный)
|
||||||
|
|
||||||
### v1.3.0-beta
|
### v1.3.0-beta
|
||||||
|
- **fix:** install/update теперь запускают `phobos-self-check --fix`: автоматически чинят потерянный `=` в `SERVER_WG_PUBLIC_KEY`, сверяют ключ с `wg0`, проверяют router helper-файлы и shell syntax до выдачи клиентских ссылок.
|
||||||
|
- **fix:** Android/iOS phone configs теперь восстанавливают потерянный `=` в WireGuard `PrivateKey/PublicKey/PresharedKey`. Это закрывает ошибку PhobosWG «Неправильная длина ключа в PublicKey».
|
||||||
|
- **fix:** `lib-core.sh` больше не теряет финальный `=` при чтении `server.env`, поэтому новые клиентские `.conf` получают полный `PublicKey`.
|
||||||
|
- **fix:** клиентский router package теперь содержит обязательные `lib-client.sh`, `install-obfuscator.sh`, `install-wireguard.sh` и дополнительные helper-скрипты из PCA repo, поэтому `wget .../init/TOKEN.sh | sh` больше не падает с `can't open './lib-client.sh'`.
|
||||||
|
- **fix:** шаг `[6/9] PCA patches` больше не падает с `curl: (23) Failure writing output to destination`, если папки `repo/server/scripts` или `repo/client/templates` не были созданы upstream sparse clone.
|
||||||
|
- **fix:** установка `wg-obfuscator` на x86_64 больше не падает с `ERROR: obfuscator binary for x86_64 missing`. Installer и secondary installer берут `linux-x64` из ClusterM releases.
|
||||||
|
- **fix:** свежая beta-установка снова получает `lib-core.sh`, без которого client helper мог ломаться на чистой машине.
|
||||||
|
- **fix:** старые ссылки `/tags` теперь ведут в `/labels`, без 404.
|
||||||
|
- **fix:** поле имени клиента больше не даёт ошибку в консоли Chrome из-за HTML `pattern`. UI bench-test прошёл без console/page errors.
|
||||||
|
- **fix:** WG tunnel subnet перестала быть жёстко зашита в `10.25.0.0/16`. Installer, secondary deploy, client packaging и router pull теперь используют `SERVER_WG_IPV4_NETWORK`, так что `10.26`/`10.27` и другие отдельные WG-сети на разных VPS работают без ручных правок.
|
||||||
|
- **fix:** в клиентский пакет добавляется `panel_tunnel_ip`, а `phobos-pull` использует его как tunnel endpoint панели вместо жёсткого `10.25.0.1`.
|
||||||
- скачивание `.conf` для телефона (Android `phobos://` + QR, iOS обычный WireGuard + QR);
|
- скачивание `.conf` для телефона (Android `phobos://` + QR, iOS обычный WireGuard + QR);
|
||||||
- кнопка скачивания приложения **PhobosWG (APK)** — авто-загрузка из GitHub Release приватного repo;
|
- кнопка скачивания приложения **PhobosWG (APK)** — авто-загрузка из GitHub Release приватного repo;
|
||||||
- все критичные фиксы выше.
|
- все критичные фиксы выше.
|
||||||
@@ -83,6 +96,30 @@ phobos-update beta # или: phobos-update dev
|
|||||||
|
|
||||||
## dev (приватный, активная разработка)
|
## dev (приватный, активная разработка)
|
||||||
|
|
||||||
|
### v1.3.4-dev
|
||||||
|
- **fix:** install/update теперь запускают `phobos-self-check --fix`: автоматически чинят потерянный `=` в `SERVER_WG_PUBLIC_KEY`, сверяют ключ с `wg0`, проверяют router helper-файлы и shell syntax до выдачи клиентских ссылок.
|
||||||
|
- **fix:** Android/iOS phone configs теперь восстанавливают потерянный `=` в WireGuard `PrivateKey/PublicKey/PresharedKey`. Это закрывает ошибку PhobosWG «Неправильная длина ключа в PublicKey».
|
||||||
|
- **fix:** `lib-core.sh` и WARP helper больше не теряют финальный `=` при чтении `server.env`, поэтому новые клиентские `.conf` получают полный `PublicKey`.
|
||||||
|
- **fix:** клиентский router package теперь содержит обязательные `lib-client.sh`, `install-obfuscator.sh`, `install-wireguard.sh` и дополнительные helper-скрипты из PCA repo, поэтому `wget .../init/TOKEN.sh | sh` больше не падает с `can't open './lib-client.sh'`.
|
||||||
|
- **fix:** шаг `[6/9] PCA patches` больше не падает с `curl: (23) Failure writing output to destination`, если папки `repo/server/scripts` или `repo/client/templates` не были созданы upstream sparse clone.
|
||||||
|
- **fix:** установка `wg-obfuscator` на x86_64 больше не падает с `ERROR: obfuscator binary for x86_64 missing`. Installer и secondary installer берут `linux-x64` из ClusterM releases.
|
||||||
|
- **fix:** поле имени клиента больше не даёт ошибку в консоли Chrome из-за HTML `pattern`. UI bench-test прошёл без console/page errors.
|
||||||
|
- **fix:** WG tunnel subnet больше не зашита в `10.25.0.0/16`. `install.sh`, `secondary-setup.sh`, client packaging, router pull и secondary deploy теперь берут `SERVER_WG_IPV4_NETWORK`, поэтому один сервер можно держать на `10.25`, другой на `10.26`, `10.27` и дальше без ручного `nano`.
|
||||||
|
- **fix:** в клиентский пакет добавляется `panel_tunnel_ip`, а `phobos-pull`/`phobos-ttyd` используют его вместо жёсткого `10.25.0.1`. Это убирает поломку управления роутером после смены WG-подсети на сервере.
|
||||||
|
- **fix:** per-client WARP стал действительно точечным. Удалено старое правило на всю подсеть WG, правила теперь ставятся и снимаются только по `client tunnel IP /32`.
|
||||||
|
- **fix:** клиентские пакеты и install-команды снова собираются стабильно: создаётся `/opt/Phobos/bin`, шаблон `install-router.sh.template` обрабатывается мягко, пустой `OBFUSCATOR_KEY` больше не валит сборку.
|
||||||
|
- **fix:** `/clients` больше не падает при неполной выкладке dev-стенда. Добавлены обязательные `lib-core.sh` и client templates в install/update flow.
|
||||||
|
- **fix:** “живой peer есть, клиента нет” больше не выглядит как пропавший клиент. Сиротские peer теперь отображаются в `Сессии` и `Клиенты` как `orphan-*`, с кнопками `WARP`, `Kick`, `Purge`.
|
||||||
|
- **fix:** старые ссылки `/tags` теперь ведут в `/labels`.
|
||||||
|
- **security:** login hardening — `HttpOnly` + `SameSite=Lax` cookie, ограничение попыток входа, constant-time compare для пароля и API keys, upload limit `64 MB`.
|
||||||
|
- **ops:** `phobos-update` и `phobos-backup restore` теперь сами определяют активный panel service (`phobos-panel` или `phobos-dev-test`) и перезапускают правильный unit.
|
||||||
|
- **security/cleanup:** из secondary API убран `shell=True` там, где он был не нужен; чтение CPU/mem/network переведено на более безопасный путь.
|
||||||
|
|
||||||
### v1.3.1-dev
|
### v1.3.1-dev
|
||||||
- **баланс по трафику:** агенты отдают пропускную способность канала (rx/tx NIC), `server_load = 0.45·cpu + 0.30·mem + 0.25·bw`; карточки серверов показывают ↕ Mbps;
|
- **баланс по трафику:** агенты отдают пропускную способность канала (rx/tx NIC), `server_load = 0.45·cpu + 0.30·mem + 0.25·bw`; карточки серверов показывают ↕ Mbps;
|
||||||
|
- **WARP / WARP+:** в `/settings` добавлен отдельный блок с режимами `egress`, `relay`, `custom profile` и полем `WARP+ key`;
|
||||||
|
- **egress mode:** исходящий трафик клиентов можно завернуть через Cloudflare WARP на стороне сервера;
|
||||||
|
- **relay mode:** добавлен UDP relay по мотивам `warp-relay` для отдельных WARP/WireGuard профилей;
|
||||||
|
- **custom profile:** полный `warp.conf` можно вставить и сохранить прямо из веб-панели;
|
||||||
|
- **help UI:** у блока WARP появился `?` с человеческим объяснением режимов, ограничений и сценариев использования;
|
||||||
- все фиксы и фичи из beta.
|
- все фиксы и фичи из beta.
|
||||||
|
|||||||
125
README.md
@@ -17,7 +17,8 @@
|
|||||||
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
|
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
|
||||||
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
|
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
|
||||||
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
|
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
|
||||||
- 📱 **Телефоны** — Android (PhobosWG, с обфускацией) и iPhone (обычный WireGuard) через QR-код.
|
- 📱 **Телефоны** — своё приложение **PCA Phobos VPN** для Android (обфускация + раздельный трафик по приложениям), PhobosWG, iPhone (обычный WireGuard) — через QR/ссылку.
|
||||||
|
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,14 +26,21 @@
|
|||||||
|
|
||||||
- **Активные сессии** — VPN IP, Real IP, метка, handshake, трафик RX/TX, Kick.
|
- **Активные сессии** — VPN IP, Real IP, метка, handshake, трафик RX/TX, Kick.
|
||||||
- **Клиенты VPN** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера.
|
- **Клиенты VPN** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера.
|
||||||
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM), резервные серверы; роутеры переключаются автоматически.
|
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM + пропускная способность канала), резервные серверы; роутеры переключаются автоматически.
|
||||||
- **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP.
|
- **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP.
|
||||||
- **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS.
|
- **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS.
|
||||||
- **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR.
|
- **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR.
|
||||||
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
|
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
|
||||||
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
|
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
|
||||||
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
|
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
|
||||||
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия.
|
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия. **Светлая и тёмная тема** (кнопка ◐), компактная вёрстка — удобно с телефона.
|
||||||
|
- **Лимит скорости на клиента** — Мбит/с в карточке клиента; работает на всех серверах, сам восстанавливается после перезапуска WireGuard.
|
||||||
|
- **Замер скорости** — сервера (реальная скачка/отдача, кнопка ⚡ на «Серверах»), клиента (живая скорость в карточке + страница `http://10.25.0.1:<порт>/speedtest` на устройстве через VPN).
|
||||||
|
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
|
||||||
|
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
|
||||||
|
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
|
||||||
|
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
|
||||||
|
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
|
||||||
- **Версии и обновления** — обновление и откат прямо из панели.
|
- **Версии и обновления** — обновление и откат прямо из панели.
|
||||||
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
|
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
|
||||||
- **Telegram-бот** — управление клиентами командами прямо из чата:
|
- **Telegram-бот** — управление клиентами командами прямо из чата:
|
||||||
@@ -40,14 +48,25 @@
|
|||||||
- `/del <имя>` — удалить клиента
|
- `/del <имя>` — удалить клиента
|
||||||
- `/list` — список клиентов
|
- `/list` — список клиентов
|
||||||
- **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь.
|
- **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь.
|
||||||
(Работает только для чата из настроек `tg_chat_id`.)
|
(Работает только для чата из настроек `tg_chat_id`.) Бот **необязателен**: хочешь — впиши токен от @BotFather и свой chat id в «Настройки», не хочешь — оставь пустым.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Скриншоты
|
## Скриншоты
|
||||||
|
|
||||||
| Сессии | Клиенты | Серверы |
|
| Клиенты (светлая) | Клиенты (тёмная) |
|
||||||
|--------|---------|---------|
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| **Сессии** | **Серверы: замер скорости, второй сервер** |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Телефон: панель | Телефон: тёмная | Android: VPN-клиент | Android: раздельный трафик | Android: админ-приложение |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
|  |  |  |  |  |
|
||||||
|
|
||||||
|
Настройки (домен, каскад, сторож, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png)
|
||||||
|
|
||||||
|
--------|---------|---------|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
> Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`).
|
> Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`).
|
||||||
@@ -77,32 +96,44 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main
|
|||||||
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
|
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
|
||||||
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
|
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
|
||||||
| `ALLOW_PLAIN_WG` | пусто | `=1` открыть порт 51820 для iOS WireGuard (без обфускации) |
|
| `ALLOW_PLAIN_WG` | пусто | `=1` открыть порт 51820 для iOS WireGuard (без обфускации) |
|
||||||
| `CHANNEL` | `stable` | канал: `stable` (открытый) · `beta`/`dev` (под ключом `PHOBOS_KEY`) |
|
| `PANEL_TLS_CERT` / `PANEL_TLS_KEY` | пусто | Включить HTTPS для панели через свой сертификат |
|
||||||
|
|
||||||
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
||||||
|
|
||||||
> **Каналы:**
|
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
|
||||||
> - **stable** — стабильная (ветка `main`), ставится **без ключа**, по умолчанию.
|
|
||||||
> - **beta** — кандидат в релиз (тест), **под ключом** подписчика.
|
|
||||||
> - **dev** — активная разработка (нестабильно), **под ключом** подписчика.
|
|
||||||
>
|
|
||||||
> Ключ `PHOBOS_KEY` выдаётся по подписке [Boosty](https://boosty.to/andrey27). Закрытый канал:
|
|
||||||
> ```bash
|
|
||||||
> PHOBOS_KEY=ваш_ключ CHANNEL=dev bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
|
||||||
> ```
|
|
||||||
> Переключение после установки: `phobos-update stable` (без ключа) · `PHOBOS_KEY=... phobos-update beta|dev` · статус: `phobos-update --check`.
|
|
||||||
|
|
||||||
### Вторичный сервер (для failover / балансировки)
|
### Вторичный сервер (для failover / балансировки)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MAIN_SERVER=<ip_основного> MAIN_API_KEY=<API key из основного> \
|
MAIN_PROTO=http MAIN_SERVER=<ip_основного> MAIN_PORT=<порт_панели> 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
Затем в панели → **Серверы** → добавь его в список.
|
Сервер сам зарегистрируется в панели, клиенты досылаются на него сразу. Если сервер за NAT — добавь `SERVER_IP=<внешний_ip>`.
|
||||||
|
|
||||||
|
### Каскад (вход в РФ → выход за рубежом)
|
||||||
|
|
||||||
|
На входном VPS (чистый Debian/Ubuntu, Phobos не нужен):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/phobos-cascade.sh) enable <IP_выходного_сервера> 2083,5443,993
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем в панели выходного сервера → **Настройки → Адрес для клиентов и каскад** → впиши IP/домен входа. Роутеры переключатся сами (~15 с), телефонам — заново открыть ссылку/QR. Отключить: `phobos-cascade disable`.
|
||||||
|
|
||||||
|
### Сторонние «сторожа» по просьбам участников
|
||||||
|
|
||||||
|
- Защита от зондов РКН ([Loorrr293/Auto_IPtables](https://github.com/Loorrr293/Auto_IPtables)) — **встроена**: «Настройки → Защита сервера», включается кнопкой.
|
||||||
|
- «Прикрывашка» ([miha75vu-bit/Flashkeen](https://github.com/miha75vu-bit/Flashkeen)) — WARP на выход + Psiphon; интерактивная, ставится по SSH: `bash <(curl -fsSL https://raw.githubusercontent.com/miha75vu-bit/Flashkeen/main/wp)`. Перестраивает выход wg0 — не включай одновременно со встроенным WARP egress.
|
||||||
|
- ipidea-kimwolf-ban — репозиторий автора удалён (404), встроить нечего.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Приложения для Android
|
||||||
|
|
||||||
|
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), журнал, проверка скорости. [Скачать APK](https://github.com/andrey271192/PCA_Phobos/releases/latest/download/PCA-Phobos-VPN.apk) · исходники `android-client/`.
|
||||||
|
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://github.com/andrey271192/PCA_Phobos/releases/latest/download/PCA-Phobos.apk) · исходники `android/`.
|
||||||
|
|
||||||
## Клиенты для телефона
|
## Клиенты для телефона
|
||||||
|
|
||||||
В таблице **Клиенты** у каждого клиента есть кнопки:
|
В таблице **Клиенты** у каждого клиента есть кнопки:
|
||||||
@@ -113,7 +144,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main
|
|||||||
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
|
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
|
||||||
|
|
||||||
**Приложения:**
|
**Приложения:**
|
||||||
- **Android (PhobosWG):** на странице Android-клиента в панели есть кнопка **«Скачать приложение PhobosWG (APK)»** — приложение скачивается автоматически из public stable release (доступно всем каналам: stable/beta/dev). Установи APK, затем импортируй `phobos://`-ссылку или QR.
|
- **Android (PhobosWG):** кнопка «Скачать приложение PhobosWG (APK)» на странице Android-клиента. Можно положить свой `.apk` в `/opt/Phobos/www/app/PhobosWG.apk` — тогда панель раздаёт его.
|
||||||
- **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента.
|
- **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -127,7 +158,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main
|
|||||||
**Из консоли:**
|
**Из консоли:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
phobos-update # обновить до последней версии
|
phobos-update # обновить до последней версии (stable)
|
||||||
|
phobos-update beta|dev # каналы для подписчиков (GH_TOKEN), сейчас = та же 1.4.0
|
||||||
phobos-update v1.1.0 # обновить до конкретной версии (git tag)
|
phobos-update v1.1.0 # обновить до конкретной версии (git tag)
|
||||||
phobos-update --rollback # откатить на предыдущую версию
|
phobos-update --rollback # откатить на предыдущую версию
|
||||||
phobos-update --version # показать установленную и доступную версию
|
phobos-update --version # показать установленную и доступную версию
|
||||||
@@ -138,44 +170,25 @@ phobos-update --list # список бэкапов
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Полное удаление сервера
|
|
||||||
|
|
||||||
Одной командой (спросит подтверждение):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/uninstall.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Без подтверждения: `PURGE=1 bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/uninstall.sh)`
|
|
||||||
|
|
||||||
Удаляет: веб-панель, обфускатор-сервисы, WireGuard `wg0`, агент, nginx-сайт, watchdog-cron, iptables-правила, бинарники (`wg-obfuscator`, `phobos-update`) и каталоги `/opt/Phobos`, `/opt/phobos-panel`. Общие пакеты (wireguard, nginx, cron) НЕ трогает.
|
|
||||||
|
|
||||||
**Удаление с роутера** (на самом роутере): `/opt/etc/Phobos/phobos-uninstall.sh`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Возможные проблемы и решения
|
## Возможные проблемы и решения
|
||||||
|
|
||||||
Подробный разбор вынесен в отдельный файл:
|
| Симптом | Причина | Решение |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **Команда установки → 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` |
|
||||||
|
| **Домен: certbot failed** | A-запись не на этот сервер / закрыт 80 | `dig +short домен` = IP сервера; открой 80/tcp; повтори в «Настройках» |
|
||||||
|
| **Лимит скорости не действует** | wg0 перезапущен | панель сама восстановит за ≤1 мин; вручную: `phobos-limit.sh apply` |
|
||||||
|
| **Кнопки клиента «уезжают»** | старая версия | обнови до 1.4.0 — карточки клиентов компактные, телефоны тоже |
|
||||||
|
|
||||||
**[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** — установка сервера, роутеры, Android/iOS, secondary API, failover, Telegram, обновление, удаление, логи и что писать в issue.
|
Логи: `journalctl -u phobos-panel -n 50` · сторож: `/opt/Phobos/server/watchdog.log` · роутер: `/opt/etc/Phobos/health.log`.
|
||||||
|
|
||||||
Самые частые быстрые проверки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
systemctl status phobos-panel --no-pager
|
|
||||||
journalctl -u phobos-panel -n 80 --no-pager
|
|
||||||
cat /opt/phobos-panel/.port
|
|
||||||
```
|
|
||||||
|
|
||||||
На роутере:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/opt/etc/init.d/S49wg-obfuscator status
|
|
||||||
tail -n 80 /opt/etc/Phobos/health.log
|
|
||||||
grep '^SERVER_' /opt/etc/Phobos/failover.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
2
android-client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
build/
|
||||||
|
libs/x/
|
||||||
46
android-client/AndroidManifest.xml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.pca.phobos.vpn"
|
||||||
|
android:versionCode="140"
|
||||||
|
android:versionName="1.4.0">
|
||||||
|
|
||||||
|
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="PCA Phobos VPN"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:extractNativeLibs="true"
|
||||||
|
android:allowBackup="false">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- panel "phobos://" links open straight into the app -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="phobos" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- from com.wireguard.android:tunnel (AAR manifest, merged by hand: no Gradle) -->
|
||||||
|
<service
|
||||||
|
android:name="com.wireguard.android.backend.GoBackend$VpnService"
|
||||||
|
android:exported="false"
|
||||||
|
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.net.VpnService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
20
android-client/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# PCA Phobos VPN — Android-клиент
|
||||||
|
|
||||||
|
Своё приложение-клиент: импорт `phobos://` из панели, встроенный wg-obfuscator + WireGuard,
|
||||||
|
**раздельный трафик** по приложениям (весь / только выбранные / все кроме выбранных).
|
||||||
|
|
||||||
|
- WireGuard: `com.wireguard.android:tunnel` (Apache-2.0), `libs/`.
|
||||||
|
- Обфускатор: [ClusterM/wg-obfuscator](https://github.com/ClusterM/wg-obfuscator) v1.6 (GPL-3.0),
|
||||||
|
собран NDK под bionic: `jni/<abi>/libwgobfuscator.so`. Статический glibc-бинарник с роутеров
|
||||||
|
внутри приложения падает с SIGSYS (seccomp), поэтому только bionic-сборка.
|
||||||
|
- Само приложение всегда вне туннеля — иначе обфускатор не достучится до сервера.
|
||||||
|
|
||||||
|
Сборка без Gradle: `KS_PASS=… ./build.sh` → `build/PCA-Phobos-VPN.apk`
|
||||||
|
(ключ подписи — `~/.android/pca-phobos.jks`, пароль в ДОСТУПЫ.md; ключ не менять, иначе обновления не встанут).
|
||||||
|
|
||||||
|
Пересборка обфускатора:
|
||||||
|
```bash
|
||||||
|
NDK=~/Library/Android/sdk/ndk/29.0.14206865/toolchains/llvm/prebuilt/darwin-x86_64/bin
|
||||||
|
$NDK/aarch64-linux-android24-clang -O2 -std=gnu11 -o jni/arm64-v8a/libwgobfuscator.so wg-obfuscator/*.c
|
||||||
|
$NDK/armv7a-linux-androideabi24-clang -O2 -std=gnu11 -o jni/armeabi-v7a/libwgobfuscator.so wg-obfuscator/*.c
|
||||||
|
```
|
||||||
33
android-client/build.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build PCA Phobos VPN (client) without Gradle. Needs libs/: tunnel-*.aar (com.wireguard.android:tunnel),
|
||||||
|
# collection-*.jar, annotation-*.jar; jni/<abi>/libwgobfuscator.so (static ClusterM wg-obfuscator).
|
||||||
|
# KS_PASS=… ./build.sh -> build/PCA-Phobos-VPN.apk
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||||
|
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
|
||||||
|
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
|
||||||
|
KS="${KS:-$HOME/.android/pca-phobos.jks}"
|
||||||
|
KS_PASS="${KS_PASS:?set KS_PASS}"
|
||||||
|
AAR=$(ls libs/tunnel-*.aar | tail -1)
|
||||||
|
rm -rf build && mkdir -p build/res build/classes build/dex build/aar build/apk/lib
|
||||||
|
unzip -q "$AAR" -d build/aar
|
||||||
|
LIBS="build/aar/classes.jar:$(ls libs/*.jar | tr '\n' ':')"
|
||||||
|
"$BT/aapt2" compile --dir res -o build/res/
|
||||||
|
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml \
|
||||||
|
--java build/gen build/res/*.flat --min-sdk-version 24 --target-sdk-version 35
|
||||||
|
javac -source 11 -target 11 -nowarn -classpath "$JAR:$LIBS" -d build/classes \
|
||||||
|
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath\|^Note:" || true
|
||||||
|
"$BT/d8" --release --lib "$JAR" --min-api 24 --output build/dex \
|
||||||
|
$(find build/classes -name '*.class') build/aar/classes.jar libs/*.jar
|
||||||
|
cp build/base.apk build/unsigned.apk
|
||||||
|
(cd build/dex && zip -q ../unsigned.apk classes*.dex)
|
||||||
|
for abi in arm64-v8a armeabi-v7a; do
|
||||||
|
mkdir -p "build/apk/lib/$abi"
|
||||||
|
cp build/aar/jni/$abi/*.so "build/apk/lib/$abi/"
|
||||||
|
cp "jni/$abi/libwgobfuscator.so" "build/apk/lib/$abi/"
|
||||||
|
done
|
||||||
|
(cd build/apk && zip -qr ../unsigned.apk lib)
|
||||||
|
"$BT/zipalign" -f 4 build/unsigned.apk build/aligned.apk
|
||||||
|
"$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$KS_PASS" --out build/PCA-Phobos-VPN.apk build/aligned.apk
|
||||||
|
"$BT/apksigner" verify build/PCA-Phobos-VPN.apk && ls -la build/PCA-Phobos-VPN.apk
|
||||||
BIN
android-client/jni/arm64-v8a/libwgobfuscator.so
Executable file
BIN
android-client/jni/armeabi-v7a/libwgobfuscator.so
Executable file
BIN
android-client/libs/annotation-1.6.0.jar
Normal file
BIN
android-client/libs/collection-1.2.0.jar
Normal file
BIN
android-client/libs/tunnel-1.0.20230706.aar
Normal file
12
android-client/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#1e1b4b" android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path android:fillColor="#4f46e5"
|
||||||
|
android:pathData="M54,18 L82,28 L82,52 C82,70 70,84 54,90 C38,84 26,70 26,52 L26,28 Z" />
|
||||||
|
<path android:fillColor="#c7d2fe"
|
||||||
|
android:pathData="M54,30 L72,36.5 L72,52 C72,64 64,73.5 54,78 C44,73.5 36,64 36,52 L36,36.5 Z" />
|
||||||
|
<path android:fillColor="#1e1b4b"
|
||||||
|
android:pathData="M47,46 h14 v5 h-4.5 v16 h-5 v-16 h-4.5 z" />
|
||||||
|
</vector>
|
||||||
10
android-client/res/values-night/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:colorPrimary">#a5b4fc</item>
|
||||||
|
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||||
|
<item name="android:colorAccent">#818cf8</item>
|
||||||
|
<item name="android:windowBackground">#0f172a</item>
|
||||||
|
<item name="android:statusBarColor">#1e1b4b</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
10
android-client/res/values/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:colorPrimary">#4338ca</item>
|
||||||
|
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||||
|
<item name="android:colorAccent">#4f46e5</item>
|
||||||
|
<item name="android:windowBackground">#f1f5f9</item>
|
||||||
|
<item name="android:statusBarColor">#1e1b4b</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
453
android-client/src/com/pca/phobos/vpn/MainActivity.java
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
package com.pca.phobos.vpn;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.content.ClipboardManager;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.ApplicationInfo;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.content.pm.ResolveInfo;
|
||||||
|
import android.content.res.Configuration;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.graphics.drawable.GradientDrawable;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.net.VpnService;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Base64;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.RadioButton;
|
||||||
|
import android.widget.RadioGroup;
|
||||||
|
import android.widget.ScrollView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.wireguard.android.backend.GoBackend;
|
||||||
|
import com.wireguard.android.backend.Statistics;
|
||||||
|
import com.wireguard.android.backend.Tunnel;
|
||||||
|
import com.wireguard.config.Config;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCA Phobos VPN — client: imports a phobos:// link from the panel, runs the
|
||||||
|
* bundled wg-obfuscator + userspace WireGuard, split tunnelling by apps.
|
||||||
|
* The app itself is always outside the tunnel so the obfuscator reaches the server directly.
|
||||||
|
*/
|
||||||
|
public class MainActivity extends Activity {
|
||||||
|
static final int LPORT = 13255;
|
||||||
|
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
|
||||||
|
|
||||||
|
static GoBackend backend;
|
||||||
|
static Process obfuscator;
|
||||||
|
static final Tunnel TUNNEL = new Tunnel() {
|
||||||
|
public String getName() { return "phobos"; }
|
||||||
|
public void onStateChange(Tunnel.State s) { }
|
||||||
|
};
|
||||||
|
|
||||||
|
private final ExecutorService bg = Executors.newSingleThreadExecutor();
|
||||||
|
private final Handler ui = new Handler(Looper.getMainLooper());
|
||||||
|
private SharedPreferences prefs;
|
||||||
|
private boolean dark;
|
||||||
|
private TextView status, traffic;
|
||||||
|
private Button power;
|
||||||
|
private long lastRx, lastTx, lastT;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle b) {
|
||||||
|
super.onCreate(b);
|
||||||
|
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
|
||||||
|
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
|
||||||
|
if (backend == null) backend = new GoBackend(getApplicationContext());
|
||||||
|
handleIntent(getIntent());
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent i) {
|
||||||
|
super.onNewIntent(i);
|
||||||
|
handleIntent(i);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleIntent(Intent i) {
|
||||||
|
if (i != null && i.getData() != null && "phobos".equals(i.getData().getScheme())) {
|
||||||
|
importLink(i.getDataString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── colors / widgets ──────────────────────────────────────────────────
|
||||||
|
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
|
||||||
|
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
|
||||||
|
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
|
||||||
|
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
|
||||||
|
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
|
||||||
|
|
||||||
|
private GradientDrawable round(int color, int r) {
|
||||||
|
GradientDrawable g = new GradientDrawable();
|
||||||
|
g.setColor(color);
|
||||||
|
g.setCornerRadius(dp(r));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button button(String label, int color, View.OnClickListener l) {
|
||||||
|
Button b = new Button(this);
|
||||||
|
b.setText(label);
|
||||||
|
b.setAllCaps(false);
|
||||||
|
b.setTextColor(Color.WHITE);
|
||||||
|
b.setBackground(round(color, 10));
|
||||||
|
b.setOnClickListener(l);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextView tv(String s, int color, int size, boolean bold) {
|
||||||
|
TextView t = new TextView(this);
|
||||||
|
t.setText(s);
|
||||||
|
t.setTextColor(color);
|
||||||
|
t.setTextSize(size);
|
||||||
|
if (bold) t.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LinearLayout.LayoutParams mw(int top) {
|
||||||
|
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
|
lp.topMargin = dp(top);
|
||||||
|
return lp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── profile ───────────────────────────────────────────────────────────
|
||||||
|
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
|
||||||
|
private void importLink(String link) {
|
||||||
|
try {
|
||||||
|
String s = link.trim();
|
||||||
|
String name = "Phobos";
|
||||||
|
int hash = s.indexOf('#');
|
||||||
|
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
|
||||||
|
s = s.replaceFirst("^phobos://", "").trim();
|
||||||
|
String conf = new String(Base64.decode(s, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING), StandardCharsets.UTF_8);
|
||||||
|
if (!conf.contains("[Interface]") || !conf.contains("[instance]")) throw new IllegalArgumentException("not a Phobos config");
|
||||||
|
Config.parse(new BufferedReader(new StringReader(wgPart(conf, MODE_ALL, "")))); // validate
|
||||||
|
prefs.edit().putString("conf", conf).putString("name", name).apply();
|
||||||
|
toast("Профиль «" + name + "» импортирован");
|
||||||
|
} catch (Exception e) {
|
||||||
|
toast("Не удалось импортировать: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split payload into sections: header -> lines (drop "= none" placeholders). */
|
||||||
|
private static List<String> section(String conf, String header) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
boolean in = false;
|
||||||
|
for (String raw : conf.split("\n")) {
|
||||||
|
String ln = raw.trim();
|
||||||
|
if (ln.startsWith("[") && ln.endsWith("]")) { in = ln.equalsIgnoreCase(header); continue; }
|
||||||
|
if (!in || ln.isEmpty() || !ln.contains("=")) continue;
|
||||||
|
if (ln.substring(ln.indexOf('=') + 1).trim().equalsIgnoreCase("none")) continue;
|
||||||
|
out.add(ln);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String key(String line) { return line.substring(0, line.indexOf('=')).trim().toLowerCase(); }
|
||||||
|
private static String val(String line) { return line.substring(line.indexOf('=') + 1).trim(); }
|
||||||
|
|
||||||
|
String wgPart(String conf, String mode, String apps) {
|
||||||
|
StringBuilder sb = new StringBuilder("[Interface]\n");
|
||||||
|
for (String ln : section(conf, "[Interface]")) {
|
||||||
|
String k = key(ln);
|
||||||
|
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
|
||||||
|
sb.append(ln).append('\n');
|
||||||
|
}
|
||||||
|
List<String> sel = new ArrayList<>();
|
||||||
|
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
|
||||||
|
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
|
||||||
|
sel.remove(getPackageNameSafe());
|
||||||
|
sb.append("IncludedApplications = ").append(String.join(", ", sel)).append('\n');
|
||||||
|
} else {
|
||||||
|
Set<String> ex = new LinkedHashSet<>();
|
||||||
|
ex.add(getPackageNameSafe()); // obfuscator must bypass the tunnel
|
||||||
|
if (MODE_EXCEPT.equals(mode)) ex.addAll(sel);
|
||||||
|
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
|
||||||
|
}
|
||||||
|
sb.append("\n[Peer]\n");
|
||||||
|
for (String ln : section(conf, "[Peer]")) {
|
||||||
|
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
|
||||||
|
sb.append(ln).append('\n');
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
|
||||||
|
|
||||||
|
private String obfPart(String conf) {
|
||||||
|
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
|
||||||
|
for (String ln : section(conf, "[instance]")) {
|
||||||
|
String k = key(ln);
|
||||||
|
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
|
||||||
|
sb.append(ln).append('\n');
|
||||||
|
}
|
||||||
|
sb.append("verbose = INFO\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String target(String conf) {
|
||||||
|
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── connect / disconnect ──────────────────────────────────────────────
|
||||||
|
private boolean isUp() {
|
||||||
|
try { return backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toggle() {
|
||||||
|
if (isUp()) { bg.execute(this::down); return; }
|
||||||
|
if (prefs.getString("conf", "").isEmpty()) { toast("Сначала импортируйте ссылку phobos:// из панели"); return; }
|
||||||
|
Intent prep = VpnService.prepare(this);
|
||||||
|
if (prep != null) startActivityForResult(prep, 7);
|
||||||
|
else bg.execute(this::up);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int req, int res, Intent data) {
|
||||||
|
if (req == 7 && res == RESULT_OK) bg.execute(this::up);
|
||||||
|
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void up() {
|
||||||
|
try {
|
||||||
|
String conf = prefs.getString("conf", "");
|
||||||
|
File cfg = new File(getFilesDir(), "obf.conf");
|
||||||
|
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf).getBytes(StandardCharsets.UTF_8)); }
|
||||||
|
stopObfuscator();
|
||||||
|
String bin = getApplicationInfo().nativeLibraryDir + "/libwgobfuscator.so";
|
||||||
|
obfuscator = new ProcessBuilder(bin, "-c", cfg.getAbsolutePath())
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.redirectOutput(new File(getFilesDir(), "obf.log"))
|
||||||
|
.start();
|
||||||
|
Thread.sleep(600);
|
||||||
|
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
||||||
|
Config c = Config.parse(new BufferedReader(new StringReader(
|
||||||
|
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
|
||||||
|
backend.setState(TUNNEL, Tunnel.State.UP, c);
|
||||||
|
log("up ok, target " + target(conf) + ", mode " + prefs.getString("mode", MODE_ALL));
|
||||||
|
post("Подключено");
|
||||||
|
} catch (Throwable e) {
|
||||||
|
stopObfuscator();
|
||||||
|
log("up failed: " + e);
|
||||||
|
post("Ошибка: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void down() {
|
||||||
|
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
|
||||||
|
stopObfuscator();
|
||||||
|
post("Отключено");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void stopObfuscator() {
|
||||||
|
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
|
||||||
|
|
||||||
|
// ── UI ────────────────────────────────────────────────────────────────
|
||||||
|
private void render() {
|
||||||
|
LinearLayout root = new LinearLayout(this);
|
||||||
|
root.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
root.setBackgroundColor(bg());
|
||||||
|
|
||||||
|
LinearLayout h = new LinearLayout(this);
|
||||||
|
h.setBackgroundColor(0xFF1E1B4B);
|
||||||
|
h.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||||
|
h.addView(tv("🛡 PCA Phobos VPN", 0xFFC7D2FE, 18, true));
|
||||||
|
root.addView(h);
|
||||||
|
|
||||||
|
ScrollView sv = new ScrollView(this);
|
||||||
|
LinearLayout box = new LinearLayout(this);
|
||||||
|
box.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
box.setPadding(dp(16), dp(16), dp(16), dp(24));
|
||||||
|
sv.addView(box);
|
||||||
|
|
||||||
|
String conf = prefs.getString("conf", "");
|
||||||
|
LinearLayout prof = new LinearLayout(this);
|
||||||
|
prof.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
prof.setBackground(round(card(), 14));
|
||||||
|
prof.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||||
|
if (conf.isEmpty()) {
|
||||||
|
prof.addView(tv("Профиль не импортирован", text(), 16, true));
|
||||||
|
prof.addView(tv("В панели PCA Phobos откройте клиента → 📱 Android → нажмите ссылку phobos:// "
|
||||||
|
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
|
||||||
|
} else {
|
||||||
|
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
|
||||||
|
prof.addView(tv("Сервер: " + target(conf), muted(), 14, false));
|
||||||
|
}
|
||||||
|
box.addView(prof, mw(0));
|
||||||
|
|
||||||
|
power = new Button(this);
|
||||||
|
power.setAllCaps(false);
|
||||||
|
power.setTextColor(Color.WHITE);
|
||||||
|
power.setTextSize(20);
|
||||||
|
power.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
power.setOnClickListener(v -> toggle());
|
||||||
|
LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams(dp(180), dp(180));
|
||||||
|
plp.gravity = Gravity.CENTER_HORIZONTAL;
|
||||||
|
plp.topMargin = dp(24);
|
||||||
|
box.addView(power, plp);
|
||||||
|
|
||||||
|
status = tv("", text(), 16, true);
|
||||||
|
status.setGravity(Gravity.CENTER);
|
||||||
|
box.addView(status, mw(12));
|
||||||
|
traffic = tv("", muted(), 14, false);
|
||||||
|
traffic.setGravity(Gravity.CENTER);
|
||||||
|
box.addView(traffic, mw(2));
|
||||||
|
|
||||||
|
// split tunnelling
|
||||||
|
LinearLayout split = new LinearLayout(this);
|
||||||
|
split.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
split.setBackground(round(card(), 14));
|
||||||
|
split.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||||
|
split.addView(tv("Раздельный трафик", text(), 16, true));
|
||||||
|
RadioGroup rg = new RadioGroup(this);
|
||||||
|
String mode = prefs.getString("mode", MODE_ALL);
|
||||||
|
String[][] modes = {{MODE_ALL, "Весь трафик через VPN"}, {MODE_ONLY, "Только выбранные приложения"},
|
||||||
|
{MODE_EXCEPT, "Все, кроме выбранных приложений"}};
|
||||||
|
for (String[] m : modes) {
|
||||||
|
RadioButton r = new RadioButton(this);
|
||||||
|
r.setText(m[1]);
|
||||||
|
r.setTextColor(text());
|
||||||
|
r.setId(View.generateViewId());
|
||||||
|
r.setChecked(m[0].equals(mode));
|
||||||
|
r.setOnClickListener(v -> { prefs.edit().putString("mode", m[0]).apply(); applyIfUp(); refresh(); });
|
||||||
|
rg.addView(r);
|
||||||
|
}
|
||||||
|
split.addView(rg);
|
||||||
|
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
|
||||||
|
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
|
||||||
|
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
|
||||||
|
muted(), 12, false), mw(6));
|
||||||
|
box.addView(split, mw(20));
|
||||||
|
|
||||||
|
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
|
||||||
|
box.addView(button("Проверить скорость", 0xFF0EA5E9, v -> speedtest()), mw(8));
|
||||||
|
box.addView(button("Журнал", 0xFF475569, v -> showLog()), mw(8));
|
||||||
|
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||||
|
setContentView(root);
|
||||||
|
refresh();
|
||||||
|
ui.removeCallbacksAndMessages(null);
|
||||||
|
ui.postDelayed(new Runnable() { public void run() { refresh(); ui.postDelayed(this, 2000); } }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh() {
|
||||||
|
if (power == null) return;
|
||||||
|
boolean up = isUp();
|
||||||
|
power.setText(up ? "Отключить" : "Подключить");
|
||||||
|
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
|
||||||
|
status.setText(up ? "🟢 VPN включён" : "⚪ VPN выключен");
|
||||||
|
if (up) {
|
||||||
|
try {
|
||||||
|
Statistics st = backend.getStatistics(TUNNEL);
|
||||||
|
long rx = st.totalRx(), tx = st.totalTx(), now = System.currentTimeMillis();
|
||||||
|
String speed = "";
|
||||||
|
if (lastT > 0 && now > lastT) {
|
||||||
|
double s = (now - lastT) / 1000.0;
|
||||||
|
speed = String.format(" · ↓ %.1f / ↑ %.1f Мбит/с", (rx - lastRx) * 8 / s / 1e6, (tx - lastTx) * 8 / s / 1e6);
|
||||||
|
}
|
||||||
|
lastRx = rx; lastTx = tx; lastT = now;
|
||||||
|
traffic.setText(String.format("Получено %.1f МБ · Отправлено %.1f МБ", rx / 1048576.0, tx / 1048576.0) + speed);
|
||||||
|
} catch (Exception e) { traffic.setText(""); }
|
||||||
|
} else { traffic.setText(""); lastT = 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyIfUp() { if (isUp()) bg.execute(() -> { down(); up(); }); }
|
||||||
|
|
||||||
|
private void importDialog() {
|
||||||
|
final EditText e = new EditText(this);
|
||||||
|
e.setHint("phobos://…");
|
||||||
|
ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
|
||||||
|
if (cm != null && cm.getPrimaryClip() != null && cm.getPrimaryClip().getItemCount() > 0) {
|
||||||
|
CharSequence c = cm.getPrimaryClip().getItemAt(0).getText();
|
||||||
|
if (c != null && c.toString().trim().startsWith("phobos://")) e.setText(c);
|
||||||
|
}
|
||||||
|
new AlertDialog.Builder(this).setTitle("Ссылка из панели").setView(e)
|
||||||
|
.setPositiveButton("Импорт", (d, w) -> { importLink(e.getText().toString()); applyIfUp(); render(); })
|
||||||
|
.setNegativeButton("Отмена", null).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pickApps() {
|
||||||
|
PackageManager pm = getPackageManager();
|
||||||
|
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
||||||
|
List<ResolveInfo> ris = pm.queryIntentActivities(main, 0);
|
||||||
|
List<String[]> apps = new ArrayList<>();
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
for (ResolveInfo ri : ris) {
|
||||||
|
String p = ri.activityInfo.packageName;
|
||||||
|
if (p.equals(getPackageName()) || !seen.add(p)) continue;
|
||||||
|
apps.add(new String[]{String.valueOf(ri.loadLabel(pm)), p});
|
||||||
|
}
|
||||||
|
Collections.sort(apps, (a, b) -> a[0].compareToIgnoreCase(b[0]));
|
||||||
|
final Set<String> chosen = new LinkedHashSet<>(Arrays.asList(prefs.getString("apps", "").split(",")));
|
||||||
|
chosen.remove("");
|
||||||
|
String[] labels = new String[apps.size()];
|
||||||
|
boolean[] checked = new boolean[apps.size()];
|
||||||
|
for (int i = 0; i < apps.size(); i++) { labels[i] = apps.get(i)[0]; checked[i] = chosen.contains(apps.get(i)[1]); }
|
||||||
|
new AlertDialog.Builder(this).setTitle("Приложения")
|
||||||
|
.setMultiChoiceItems(labels, checked, (d, i, on) -> { if (on) chosen.add(apps.get(i)[1]); else chosen.remove(apps.get(i)[1]); })
|
||||||
|
.setPositiveButton("Готово", (d, w) -> { prefs.edit().putString("apps", String.join(",", chosen)).apply(); applyIfUp(); render(); })
|
||||||
|
.setNegativeButton("Отмена", null).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real speed through the tunnel: the browser is inside the VPN (unless excluded by the split). */
|
||||||
|
private void speedtest() {
|
||||||
|
if (!isUp()) { toast("Сначала подключитесь"); return; }
|
||||||
|
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://speed.cloudflare.com/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void log(String s) {
|
||||||
|
try (FileOutputStream o = new FileOutputStream(new File(getFilesDir(), "app.log"), true)) {
|
||||||
|
o.write((new java.util.Date() + " " + s + "\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String tail(File f, int max) {
|
||||||
|
try {
|
||||||
|
byte[] b = java.nio.file.Files.readAllBytes(f.toPath());
|
||||||
|
String t = new String(b, StandardCharsets.UTF_8);
|
||||||
|
return t.length() > max ? t.substring(t.length() - max) : t;
|
||||||
|
} catch (Exception e) { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLog() {
|
||||||
|
String t = "— приложение —\n" + tail(new File(getFilesDir(), "app.log"), 3000)
|
||||||
|
+ "\n— обфускатор —\n" + tail(new File(getFilesDir(), "obf.log"), 3000)
|
||||||
|
+ "\nобфускатор запущен: " + (obfuscator != null && obfuscator.isAlive());
|
||||||
|
TextView v = tv(t, text(), 12, false);
|
||||||
|
v.setTextIsSelectable(true);
|
||||||
|
v.setPadding(dp(16), dp(8), dp(16), dp(8));
|
||||||
|
ScrollView sv = new ScrollView(this);
|
||||||
|
sv.addView(v);
|
||||||
|
new AlertDialog.Builder(this).setTitle("Журнал").setView(sv).setPositiveButton("OK", null).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_LONG).show(); }
|
||||||
|
}
|
||||||
1
android/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
build/
|
||||||
27
android/AndroidManifest.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.pca.phobos"
|
||||||
|
android:versionCode="140"
|
||||||
|
android:versionName="1.4.0">
|
||||||
|
|
||||||
|
<uses-sdk android:minSdkVersion="23" android:targetSdkVersion="35" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="PCA Phobos"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:allowBackup="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||||
|
android:launchMode="singleTask">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
4
android/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# PCA Phobos — админ-приложение (Android)
|
||||||
|
|
||||||
|
Список ваших панелей Phobos, каждая открывается в приложении (WebView), тема по системе,
|
||||||
|
ссылки `phobos://` передаются в VPN-клиент. Сборка: `KS_PASS=… ./build.sh` → `build/PCA-Phobos.apk`.
|
||||||
26
android/build.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build PCA Phobos Android app without Gradle: aapt2 -> javac -> d8 -> zipalign -> apksigner.
|
||||||
|
# ANDROID_HOME=~/Library/Android/sdk KS=~/.android/pca-phobos.jks KS_PASS=… ./build.sh
|
||||||
|
# Output: build/PCA-Phobos.apk. The keystore is NOT in git (keep the same one, or updates won't install).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||||
|
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
|
||||||
|
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
|
||||||
|
KS="${KS:-$HOME/.android/pca-phobos.jks}"
|
||||||
|
KS_PASS="${KS_PASS:?set KS_PASS (keystore password)}"
|
||||||
|
rm -rf build && mkdir -p build/res build/classes build/dex
|
||||||
|
|
||||||
|
"$BT/aapt2" compile --dir res -o build/res/
|
||||||
|
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml \
|
||||||
|
--java build/gen build/res/*.flat --min-sdk-version 23 --target-sdk-version 35
|
||||||
|
javac -source 11 -target 11 -nowarn -classpath "$JAR" -d build/classes \
|
||||||
|
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath" || true
|
||||||
|
"$BT/d8" --release --lib "$JAR" --min-api 23 --output build/dex $(find build/classes -name '*.class')
|
||||||
|
cp build/base.apk build/unsigned.apk
|
||||||
|
(cd build/dex && zip -q ../unsigned.apk classes.dex)
|
||||||
|
"$BT/zipalign" -f 4 build/unsigned.apk build/aligned.apk
|
||||||
|
[ -f "$KS" ] || keytool -genkeypair -keystore "$KS" -storepass "$KS_PASS" -keypass "$KS_PASS" \
|
||||||
|
-alias pca -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=PCA Phobos, O=PCAdministration" >/dev/null
|
||||||
|
"$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$KS_PASS" --out build/PCA-Phobos.apk build/aligned.apk
|
||||||
|
"$BT/apksigner" verify build/PCA-Phobos.apk && ls -la build/PCA-Phobos.apk
|
||||||
12
android/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#1e1b4b" android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path android:fillColor="#4f46e5"
|
||||||
|
android:pathData="M54,18 L82,28 L82,52 C82,70 70,84 54,90 C38,84 26,70 26,52 L26,28 Z" />
|
||||||
|
<path android:fillColor="#c7d2fe"
|
||||||
|
android:pathData="M54,30 L72,36.5 L72,52 C72,64 64,73.5 54,78 C44,73.5 36,64 36,52 L36,36.5 Z" />
|
||||||
|
<path android:fillColor="#1e1b4b"
|
||||||
|
android:pathData="M47,46 h14 v5 h-4.5 v16 h-5 v-16 h-4.5 z" />
|
||||||
|
</vector>
|
||||||
10
android/res/values-night/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:colorPrimary">#a5b4fc</item>
|
||||||
|
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||||
|
<item name="android:colorAccent">#818cf8</item>
|
||||||
|
<item name="android:windowBackground">#0f172a</item>
|
||||||
|
<item name="android:statusBarColor">#1e1b4b</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
10
android/res/values/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:colorPrimary">#4338ca</item>
|
||||||
|
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||||
|
<item name="android:colorAccent">#4f46e5</item>
|
||||||
|
<item name="android:windowBackground">#f1f5f9</item>
|
||||||
|
<item name="android:statusBarColor">#1e1b4b</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
367
android/src/com/pca/phobos/MainActivity.java
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package com.pca.phobos;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.content.ActivityNotFoundException;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.res.Configuration;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.graphics.drawable.GradientDrawable;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.InputType;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.webkit.CookieManager;
|
||||||
|
import android.webkit.WebChromeClient;
|
||||||
|
import android.webkit.WebResourceRequest;
|
||||||
|
import android.webkit.WebSettings;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import android.webkit.WebViewClient;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.ScrollView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCA Phobos — admin app: a list of your Phobos panels, each opens in a WebView.
|
||||||
|
* phobos:// links are handed to PhobosWG, downloads go to the system browser.
|
||||||
|
*/
|
||||||
|
public class MainActivity extends Activity {
|
||||||
|
private SharedPreferences prefs;
|
||||||
|
private WebView web;
|
||||||
|
private ProgressBar progress;
|
||||||
|
private TextView title;
|
||||||
|
private boolean dark;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle b) {
|
||||||
|
super.onCreate(b);
|
||||||
|
prefs = getSharedPreferences("phobos", MODE_PRIVATE);
|
||||||
|
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)
|
||||||
|
== Configuration.UI_MODE_NIGHT_YES;
|
||||||
|
JSONArray list = panels();
|
||||||
|
String last = prefs.getString("last", "");
|
||||||
|
if (!last.isEmpty() && b == null) openPanel(last, nameFor(last));
|
||||||
|
else if (list.length() == 0) showEditor(null, -1);
|
||||||
|
else showList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── colors ────────────────────────────────────────────────────────────
|
||||||
|
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
|
||||||
|
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
|
||||||
|
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
|
||||||
|
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
|
||||||
|
private static final int ACCENT = 0xFF4F46E5;
|
||||||
|
private static final int HEADER = 0xFF1E1B4B;
|
||||||
|
|
||||||
|
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
|
||||||
|
|
||||||
|
private GradientDrawable round(int color, int radius) {
|
||||||
|
GradientDrawable g = new GradientDrawable();
|
||||||
|
g.setColor(color);
|
||||||
|
g.setCornerRadius(dp(radius));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button button(String label, int color, View.OnClickListener l) {
|
||||||
|
Button btn = new Button(this);
|
||||||
|
btn.setText(label);
|
||||||
|
btn.setAllCaps(false);
|
||||||
|
btn.setTextColor(Color.WHITE);
|
||||||
|
btn.setBackground(round(color, 10));
|
||||||
|
btn.setOnClickListener(l);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LinearLayout header(String text, View right) {
|
||||||
|
LinearLayout h = new LinearLayout(this);
|
||||||
|
h.setBackgroundColor(HEADER);
|
||||||
|
h.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
h.setPadding(dp(16), dp(12), dp(8), dp(12));
|
||||||
|
TextView t = new TextView(this);
|
||||||
|
t.setText("🛡 " + text);
|
||||||
|
t.setTextColor(0xFFC7D2FE);
|
||||||
|
t.setTextSize(18);
|
||||||
|
t.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
t.setSingleLine(true);
|
||||||
|
h.addView(t, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
|
||||||
|
if (right != null) h.addView(right);
|
||||||
|
title = t;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── storage ───────────────────────────────────────────────────────────
|
||||||
|
private JSONArray panels() {
|
||||||
|
try { return new JSONArray(prefs.getString("panels", "[]")); }
|
||||||
|
catch (Exception e) { return new JSONArray(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void savePanels(JSONArray a) { prefs.edit().putString("panels", a.toString()).apply(); }
|
||||||
|
|
||||||
|
private String nameFor(String url) {
|
||||||
|
JSONArray a = panels();
|
||||||
|
for (int i = 0; i < a.length(); i++) {
|
||||||
|
JSONObject o = a.optJSONObject(i);
|
||||||
|
if (o != null && url.equals(o.optString("url"))) return o.optString("name", url);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalize(String raw) {
|
||||||
|
String u = raw.trim();
|
||||||
|
if (u.isEmpty()) return "";
|
||||||
|
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "http://" + u;
|
||||||
|
while (u.endsWith("/")) u = u.substring(0, u.length() - 1);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── screens ───────────────────────────────────────────────────────────
|
||||||
|
private void showList() {
|
||||||
|
web = null;
|
||||||
|
prefs.edit().remove("last").apply();
|
||||||
|
LinearLayout root = new LinearLayout(this);
|
||||||
|
root.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
root.setBackgroundColor(bg());
|
||||||
|
root.addView(header("PCA Phobos", null));
|
||||||
|
|
||||||
|
ScrollView sv = new ScrollView(this);
|
||||||
|
LinearLayout box = new LinearLayout(this);
|
||||||
|
box.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
box.setPadding(dp(16), dp(16), dp(16), dp(16));
|
||||||
|
sv.addView(box);
|
||||||
|
|
||||||
|
TextView hint = new TextView(this);
|
||||||
|
hint.setText("Ваши серверы Phobos. Нажмите — откроется панель. Долгое нажатие — изменить.");
|
||||||
|
hint.setTextColor(muted());
|
||||||
|
hint.setPadding(0, 0, 0, dp(12));
|
||||||
|
box.addView(hint);
|
||||||
|
|
||||||
|
final JSONArray a = panels();
|
||||||
|
for (int i = 0; i < a.length(); i++) {
|
||||||
|
final int idx = i;
|
||||||
|
final JSONObject o = a.optJSONObject(i);
|
||||||
|
if (o == null) continue;
|
||||||
|
LinearLayout c = new LinearLayout(this);
|
||||||
|
c.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
c.setBackground(round(card(), 12));
|
||||||
|
c.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||||
|
TextView n = new TextView(this);
|
||||||
|
n.setText(o.optString("name"));
|
||||||
|
n.setTextColor(text());
|
||||||
|
n.setTextSize(17);
|
||||||
|
n.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
TextView u = new TextView(this);
|
||||||
|
u.setText(o.optString("url"));
|
||||||
|
u.setTextColor(muted());
|
||||||
|
c.addView(n);
|
||||||
|
c.addView(u);
|
||||||
|
c.setOnClickListener(v -> openPanel(o.optString("url"), o.optString("name")));
|
||||||
|
c.setOnLongClickListener(v -> { showEditor(o, idx); return true; });
|
||||||
|
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
|
lp.bottomMargin = dp(10);
|
||||||
|
box.addView(c, lp);
|
||||||
|
}
|
||||||
|
box.addView(button("+ Добавить сервер", ACCENT, v -> showEditor(null, -1)));
|
||||||
|
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||||
|
setContentView(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showEditor(final JSONObject existing, final int idx) {
|
||||||
|
web = null;
|
||||||
|
LinearLayout root = new LinearLayout(this);
|
||||||
|
root.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
root.setBackgroundColor(bg());
|
||||||
|
root.addView(header(existing == null ? "Новый сервер" : "Изменить сервер", null));
|
||||||
|
LinearLayout box = new LinearLayout(this);
|
||||||
|
box.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
box.setPadding(dp(16), dp(16), dp(16), dp(16));
|
||||||
|
|
||||||
|
final EditText name = field("Название (например: Амстердам)", existing == null ? "" : existing.optString("name"));
|
||||||
|
final EditText url = field("Адрес панели: http://IP:порт или https://домен", existing == null ? "" : existing.optString("url"));
|
||||||
|
url.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
|
||||||
|
box.addView(label("Название"));
|
||||||
|
box.addView(name);
|
||||||
|
box.addView(label("Адрес панели"));
|
||||||
|
box.addView(url);
|
||||||
|
TextView tip = new TextView(this);
|
||||||
|
tip.setText("Адрес и порт панели печатает установщик в конце установки (или: cat /opt/phobos-panel/.port). "
|
||||||
|
+ "Если задан домен в «Настройках» панели — используйте https://домен.");
|
||||||
|
tip.setTextColor(muted());
|
||||||
|
tip.setPadding(0, dp(4), 0, dp(16));
|
||||||
|
box.addView(tip);
|
||||||
|
|
||||||
|
box.addView(button("Сохранить и открыть", ACCENT, v -> {
|
||||||
|
String u = normalize(url.getText().toString());
|
||||||
|
if (u.isEmpty()) { toast("Введите адрес панели"); return; }
|
||||||
|
String n = name.getText().toString().trim();
|
||||||
|
if (n.isEmpty()) n = Uri.parse(u).getHost();
|
||||||
|
try {
|
||||||
|
JSONArray a = panels();
|
||||||
|
JSONObject o = new JSONObject().put("name", n).put("url", u);
|
||||||
|
if (idx >= 0) a.put(idx, o); else a.put(o);
|
||||||
|
savePanels(a);
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
openPanel(u, n);
|
||||||
|
}));
|
||||||
|
if (existing != null) {
|
||||||
|
Button del = button("Удалить из списка", 0xFFDC2626, v -> {
|
||||||
|
JSONArray a = panels(), out = new JSONArray();
|
||||||
|
for (int i = 0; i < a.length(); i++) if (i != idx) out.put(a.opt(i));
|
||||||
|
savePanels(out);
|
||||||
|
showList();
|
||||||
|
});
|
||||||
|
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
|
lp.topMargin = dp(10);
|
||||||
|
box.addView(del, lp);
|
||||||
|
}
|
||||||
|
if (panels().length() > 0) {
|
||||||
|
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
|
lp.topMargin = dp(10);
|
||||||
|
box.addView(button("Назад", 0xFF475569, v -> showList()), lp);
|
||||||
|
}
|
||||||
|
root.addView(box);
|
||||||
|
setContentView(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextView label(String s) {
|
||||||
|
TextView t = new TextView(this);
|
||||||
|
t.setText(s);
|
||||||
|
t.setTextColor(muted());
|
||||||
|
t.setPadding(0, dp(8), 0, dp(4));
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EditText field(String hint, String value) {
|
||||||
|
EditText e = new EditText(this);
|
||||||
|
e.setHint(hint);
|
||||||
|
e.setText(value);
|
||||||
|
e.setSingleLine(true);
|
||||||
|
e.setTextColor(text());
|
||||||
|
e.setHintTextColor(muted());
|
||||||
|
e.setBackground(round(card(), 10));
|
||||||
|
e.setPadding(dp(12), dp(12), dp(12), dp(12));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openPanel(final String url, String name) {
|
||||||
|
prefs.edit().putString("last", url).apply();
|
||||||
|
LinearLayout root = new LinearLayout(this);
|
||||||
|
root.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
root.setBackgroundColor(bg());
|
||||||
|
|
||||||
|
LinearLayout actions = new LinearLayout(this);
|
||||||
|
TextView reload = headerAction("⟳", v -> { if (web != null) web.reload(); });
|
||||||
|
TextView servers = headerAction("☰", v -> showList());
|
||||||
|
actions.addView(reload);
|
||||||
|
actions.addView(servers);
|
||||||
|
root.addView(header(name, actions));
|
||||||
|
|
||||||
|
progress = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal);
|
||||||
|
progress.setMax(100);
|
||||||
|
root.addView(progress, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3)));
|
||||||
|
|
||||||
|
web = new WebView(this);
|
||||||
|
WebSettings s = web.getSettings();
|
||||||
|
s.setJavaScriptEnabled(true);
|
||||||
|
s.setDomStorageEnabled(true);
|
||||||
|
s.setLoadWithOverviewMode(true);
|
||||||
|
s.setUseWideViewPort(true);
|
||||||
|
s.setBuiltInZoomControls(true);
|
||||||
|
s.setDisplayZoomControls(false);
|
||||||
|
s.setUserAgentString(s.getUserAgentString() + " PCAPhobosApp/1.4.0");
|
||||||
|
CookieManager.getInstance().setAcceptCookie(true);
|
||||||
|
web.setWebViewClient(new WebViewClient() {
|
||||||
|
@Override
|
||||||
|
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest req) {
|
||||||
|
Uri u = req.getUrl();
|
||||||
|
String scheme = u.getScheme() == null ? "" : u.getScheme();
|
||||||
|
if (scheme.equals("http") || scheme.equals("https")) {
|
||||||
|
Uri base = Uri.parse(url);
|
||||||
|
// other sites (GitHub, Boosty, Telegram…) -> system browser / apps
|
||||||
|
if (u.getHost() != null && !u.getHost().equals(base.getHost())) { external(u); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
external(u); // phobos:// -> PhobosWG, tg:// -> Telegram
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageFinished(WebView view, String u) {
|
||||||
|
progress.setVisibility(View.GONE);
|
||||||
|
CookieManager.getInstance().flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceivedError(WebView view, int code, String desc, String failing) {
|
||||||
|
if (failing != null && failing.startsWith(url)) {
|
||||||
|
view.loadDataWithBaseURL(null, errorPage(url, desc), "text/html", "utf-8", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
web.setWebChromeClient(new WebChromeClient() {
|
||||||
|
@Override
|
||||||
|
public void onProgressChanged(WebView view, int p) {
|
||||||
|
progress.setVisibility(p < 100 ? View.VISIBLE : View.GONE);
|
||||||
|
progress.setProgress(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
web.setDownloadListener((dlUrl, ua, cd, mime, len) -> external(Uri.parse(dlUrl)));
|
||||||
|
root.addView(web, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||||
|
setContentView(root);
|
||||||
|
web.loadUrl(url + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextView headerAction(String s, View.OnClickListener l) {
|
||||||
|
TextView t = new TextView(this);
|
||||||
|
t.setText(s);
|
||||||
|
t.setTextColor(0xFFC7D2FE);
|
||||||
|
t.setTextSize(22);
|
||||||
|
t.setPadding(dp(14), 0, dp(14), 0);
|
||||||
|
t.setOnClickListener(l);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String errorPage(String url, String desc) {
|
||||||
|
String bgc = dark ? "#0f172a" : "#f1f5f9", fg = dark ? "#e2e8f0" : "#0f172a";
|
||||||
|
return "<html><body style='font-family:sans-serif;background:" + bgc + ";color:" + fg
|
||||||
|
+ ";padding:24px;text-align:center'><h2>Панель недоступна</h2><p>" + url + "</p><p style='color:#64748b'>"
|
||||||
|
+ desc + "</p><p>Проверьте адрес и порт, интернет и что сервер включён. Кнопка ⟳ — повторить.</p></body></html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void external(Uri u) {
|
||||||
|
try {
|
||||||
|
startActivity(new Intent(Intent.ACTION_VIEW, u));
|
||||||
|
} catch (ActivityNotFoundException e) {
|
||||||
|
if ("phobos".equals(u.getScheme())) {
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("Нужно приложение PhobosWG")
|
||||||
|
.setMessage("Ссылки phobos:// открывает приложение PhobosWG. Скачайте его на странице Android-клиента в панели (кнопка «Скачать приложение PhobosWG»).")
|
||||||
|
.setPositiveButton("OK", null).show();
|
||||||
|
} else {
|
||||||
|
toast("Нет приложения для " + u.getScheme());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_SHORT).show(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
if (web != null && web.canGoBack()) { web.goBack(); return; }
|
||||||
|
if (web != null && panels().length() > 0) { showList(); return; }
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
}
|
||||||
107
docs/PORTS.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# PCA Phobos ports and firewall checklist
|
||||||
|
|
||||||
|
This page is the short truth table for normal Phobos work. Use it before
|
||||||
|
opening a ticket about "router does not switch", "secondary is online but sync
|
||||||
|
fails", or "panel is too exposed".
|
||||||
|
|
||||||
|
## Primary server
|
||||||
|
|
||||||
|
| Port | Protocol | Who connects | Required | Why |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `PANEL_PORT` | TCP | admin browser, secondary setup/register, router pull fallback | yes | Web panel and `/api/*`. Real value: `cat /opt/phobos-panel/.port`. |
|
||||||
|
| `80` | TCP | routers during install, Android APK download | recommended | nginx serves `/init/`, `/packages/`, `/app/`. |
|
||||||
|
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. Real value: `grep OBFUSCATOR_PORTS /opt/Phobos/server/server.env`. |
|
||||||
|
| `51820` | UDP | plain WireGuard clients only | no by default | Usually blocked to force obfuscation. Open only if installed with `ALLOW_PLAIN_WG=1` for iOS/plain WG. |
|
||||||
|
| `22` | TCP | admin only | optional | SSH maintenance. Do not expose broadly. |
|
||||||
|
|
||||||
|
`PANEL_PORT` is random on clean install unless passed as env:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /opt/phobos-panel/.port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secondary server
|
||||||
|
|
||||||
|
| Port | Protocol | Who connects | Required | Why |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `8444` | TCP | primary panel, routers via tunnel/public path | yes | Mini-API: health, peer sync, router config cache. |
|
||||||
|
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. |
|
||||||
|
| `51820` | UDP | nobody public | no | WireGuard listens locally behind obfuscator; public access is blocked. |
|
||||||
|
| `22` | TCP | admin during install | optional | SSH maintenance. |
|
||||||
|
|
||||||
|
If secondary is "Online" but `Info`, `Sync`, or client assignment fails, check
|
||||||
|
`8444/tcp` first and confirm the API key matches the main panel key.
|
||||||
|
|
||||||
|
## Router outbound access
|
||||||
|
|
||||||
|
Routers need outbound access to:
|
||||||
|
|
||||||
|
- every server UDP obfuscator port, default `2083,5443,993`;
|
||||||
|
- primary `PANEL_PORT/tcp` for initial install and public pull fallback;
|
||||||
|
- primary `80/tcp` if using the nginx install fallback;
|
||||||
|
- tunnel management paths after VPN is up:
|
||||||
|
- `http://10.25.0.1:8444` for secondary mini-API;
|
||||||
|
- `http(s)://10.25.0.1:<PANEL_PORT>` for primary panel API.
|
||||||
|
|
||||||
|
Current router packages write these files on the router:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/etc/Phobos/panel_url
|
||||||
|
/opt/etc/Phobos/panel_urls
|
||||||
|
/opt/etc/Phobos/panel_port
|
||||||
|
/opt/etc/Phobos/panel_scheme
|
||||||
|
```
|
||||||
|
|
||||||
|
If a router does not switch after changing its server in the panel, check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /opt/etc/Phobos/panel_urls
|
||||||
|
cat /opt/etc/Phobos/failover.conf
|
||||||
|
tail -n 80 /opt/etc/Phobos/health.log
|
||||||
|
/opt/etc/Phobos/phobos-pull.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If `panel_urls` contains an old IP or old panel port, create a fresh install
|
||||||
|
command in the panel and reinstall/update that router package.
|
||||||
|
|
||||||
|
## HTTPS panel
|
||||||
|
|
||||||
|
Native HTTPS is supported by the primary installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PANEL_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem \
|
||||||
|
PANEL_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem \
|
||||||
|
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the panel runs as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<server-ip>:<PANEL_PORT>
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated router and secondary commands use `curl -k`/`wget
|
||||||
|
--no-check-certificate` fallback so a self-signed certificate can work for
|
||||||
|
bootstrap. For humans in a browser, use a real certificate to avoid warnings.
|
||||||
|
|
||||||
|
## Can I IP-whitelist the panel port?
|
||||||
|
|
||||||
|
Yes, but with care. `PANEL_PORT` is not only the admin UI. It also carries
|
||||||
|
server registration and router pull API. If you whitelist it too tightly, these
|
||||||
|
features break:
|
||||||
|
|
||||||
|
- secondary auto-register;
|
||||||
|
- `Info` / `Sync` / client assignment to secondary;
|
||||||
|
- router pull fallback before tunnel is up;
|
||||||
|
- router config update when public path is the only available path.
|
||||||
|
|
||||||
|
Minimum allowlist for `PANEL_PORT`:
|
||||||
|
|
||||||
|
- your admin IPs;
|
||||||
|
- all secondary server public IPs;
|
||||||
|
- router public IPs, if they are static;
|
||||||
|
- or no router IP restriction until first install is complete and tunnel pull is proven.
|
||||||
|
|
||||||
|
For CGNAT/dynamic-router fleets, do not rely on a strict public-IP allowlist for
|
||||||
|
router management. Use strong panel password, API key secrecy, optional HTTPS,
|
||||||
|
and keep only the required ports open.
|
||||||
@@ -1,756 +0,0 @@
|
|||||||
# PCA Phobos: проблемы, диагностика и решения
|
|
||||||
|
|
||||||
Этот документ для живых ситуаций: установка не пошла, роутер не поднялся, Android не импортирует QR, secondary отвечает 401, Telegram молчит. Ниже симптомы, причины, проверки и безопасные решения.
|
|
||||||
|
|
||||||
Не публикуй в issue и чатах пароли, токены, приватные ключи WireGuard, `server_api_key`, `pull_token`, `GH_TOKEN`/`PHOBOS_KEY`, `PrivateKey`, содержимое `settings.json` целиком. Если нужен лог или конфиг, замени секреты на `***`.
|
|
||||||
|
|
||||||
## Быстрый чек-лист
|
|
||||||
|
|
||||||
На сервере:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
cat /opt/phobos-panel/.version
|
|
||||||
cat /opt/phobos-panel/.port
|
|
||||||
systemctl status phobos-panel --no-pager
|
|
||||||
journalctl -u phobos-panel -n 80 --no-pager
|
|
||||||
curl -s http://127.0.0.1:$(cat /opt/phobos-panel/.port)/api/obf-health
|
|
||||||
```
|
|
||||||
|
|
||||||
На роутере:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/opt/etc/init.d/S49wg-obfuscator status
|
|
||||||
tail -n 80 /opt/etc/Phobos/health.log
|
|
||||||
grep '^SERVER_' /opt/etc/Phobos/failover.conf
|
|
||||||
grep '^target' /opt/etc/Phobos/wg-obfuscator.conf
|
|
||||||
crontab -l | grep phobos
|
|
||||||
```
|
|
||||||
|
|
||||||
На secondary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status phobos-api --no-pager
|
|
||||||
journalctl -u phobos-api -n 80 --no-pager
|
|
||||||
curl -i http://SECONDARY_IP:8444/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## Установка сервера
|
|
||||||
|
|
||||||
### Команда установки пишет `403`, `404`, `Bad credentials`
|
|
||||||
|
|
||||||
Чаще всего ставится закрытый канал без ключа или ключ устарел.
|
|
||||||
|
|
||||||
Stable ставится без ключа:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Beta/dev требуют ключ:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PHOBOS_KEY=ваш_ключ CHANNEL=beta \
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка доступа к public stable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -I https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка закрытого канала:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -I -H "Authorization: token ваш_ключ" \
|
|
||||||
https://raw.githubusercontent.com/andrey271192/PCA_Phobos-dev/beta/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### `[2/9] wg-obfuscator`: `ERROR: obfuscator binary for x86_64 missing`
|
|
||||||
|
|
||||||
Это ошибка скачивания бинарника обфускатора под архитектуру VPS. Обычно сервер `x86_64`, а в скачанном наборе нет файла с ожидаемым именем.
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uname -m
|
|
||||||
ls -lah /opt/Phobos/bin 2>/dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
```
|
|
||||||
|
|
||||||
Если установка ещё не дошла до `phobos-update`, запусти свежий installer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
В актуальной версии installer берёт `linux-x64` из ClusterM releases и понимает `amd64 -> x86_64`.
|
|
||||||
|
|
||||||
### `[6/9] PCA patches`: `curl: (23) Failure writing output to destination`
|
|
||||||
|
|
||||||
Это не ошибка firewall и не порт WireGuard. `curl` не смог записать файл в путь назначения. Частая причина: папки `/opt/Phobos/repo/server/scripts` или `/opt/Phobos/repo/client/templates` ещё не были созданы.
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
```
|
|
||||||
|
|
||||||
Если сервер ставится с нуля и ещё нет `phobos-update`, повтори установку свежим installer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
В актуальной версии `fetch()` сам создаёт папку назначения перед `curl -o`.
|
|
||||||
|
|
||||||
### `/clients`: `lib-core.sh: No such file or directory`, `check_root: command not found`
|
|
||||||
|
|
||||||
Так выглядит неполная установка client helper. Файл `phobos-client.sh` уже есть, но рядом нет обязательного `lib-core.sh`. После этого скрипт теряет функции `check_root`, `load_env`, `ensure_dirs`, `die`, `log_info`, и появляются каскадные ошибки.
|
|
||||||
|
|
||||||
Быстрое лечение без переустановки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /opt/Phobos/repo/server/scripts
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/overlay/lib-core.sh \
|
|
||||||
-o /opt/Phobos/repo/server/scripts/lib-core.sh
|
|
||||||
chmod +x /opt/Phobos/repo/server/scripts/lib-core.sh
|
|
||||||
phobos-update stable
|
|
||||||
systemctl restart phobos-panel
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
test -f /opt/Phobos/repo/server/scripts/lib-core.sh && echo LIB_CORE_OK
|
|
||||||
/opt/Phobos/repo/server/scripts/phobos-client.sh list
|
|
||||||
```
|
|
||||||
|
|
||||||
### В Chrome консоли: `Pattern attribute value ... is not a valid regular expression`
|
|
||||||
|
|
||||||
Старый HTML pattern для имени клиента мог ругаться в новых версиях Chrome.
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
phobos-self-check --fix
|
|
||||||
systemctl restart phobos-panel
|
|
||||||
```
|
|
||||||
|
|
||||||
После обновления в `/opt/phobos-panel/app.py` должно быть:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep 'pattern=' /opt/phobos-panel/app.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидаемо:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pattern="[A-Za-z0-9_\\-]+"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Панель не открывается
|
|
||||||
|
|
||||||
Проверь реальный порт панели:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat /opt/phobos-panel/.port
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверь сервис:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status phobos-panel --no-pager
|
|
||||||
journalctl -u phobos-panel -n 120 --no-pager
|
|
||||||
ss -lntp | grep "$(cat /opt/phobos-panel/.port)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Если сервис упал:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl restart phobos-panel
|
|
||||||
journalctl -u phobos-panel -n 120 --no-pager
|
|
||||||
```
|
|
||||||
|
|
||||||
Если сервис живой, но браузер не открывает панель, проверь firewall/VPS-панель: порт из `/opt/phobos-panel/.port` должен быть открыт.
|
|
||||||
|
|
||||||
### Забыт порт, пароль или API key
|
|
||||||
|
|
||||||
Порт:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat /opt/phobos-panel/.port
|
|
||||||
```
|
|
||||||
|
|
||||||
Пароль:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep admin_pass /opt/phobos-panel/settings.json
|
|
||||||
```
|
|
||||||
|
|
||||||
API key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep server_api_key /opt/phobos-panel/settings.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Не отправляй эти значения в публичный issue.
|
|
||||||
|
|
||||||
### Обновление сломало панель
|
|
||||||
|
|
||||||
Откат:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update --rollback
|
|
||||||
```
|
|
||||||
|
|
||||||
Список бэкапов:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update --list
|
|
||||||
```
|
|
||||||
|
|
||||||
Обновление трогает панель и скрипты, но не должно удалять WireGuard-ключи, клиентов и `/opt/Phobos/server/server.env`.
|
|
||||||
|
|
||||||
## Установка на роутер
|
|
||||||
|
|
||||||
### Команда на роутере пишет `ERROR_install_script_download_failed`
|
|
||||||
|
|
||||||
Причины:
|
|
||||||
|
|
||||||
- install token старый или просрочен;
|
|
||||||
- роутер не достучался до порта панели;
|
|
||||||
- вместо shell скачался HTML;
|
|
||||||
- пакет клиента собран старой версией скриптов.
|
|
||||||
|
|
||||||
Проверка с роутера:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
u="http://SERVER_IP:PANEL_PORT/init/TOKEN.sh"
|
|
||||||
wget -q -O /tmp/phobos-init-test.sh "$u"
|
|
||||||
head -n 3 /tmp/phobos-init-test.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Хорошо:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
#!/bin/sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Плохо:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!DOCTYPE html>
|
|
||||||
```
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
/opt/Phobos/repo/server/scripts/phobos-client.sh package CLIENT_ID
|
|
||||||
/opt/Phobos/repo/server/scripts/phobos-client.sh link CLIENT_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
Потом вставь новую команду установки на роутере.
|
|
||||||
|
|
||||||
### Команда на роутере пишет `line 8: can't open './lib-client.sh'`
|
|
||||||
|
|
||||||
Причина: пакет клиента собран без обязательных helper-файлов (`lib-client.sh`, `install-obfuscator.sh`, `install-wireguard.sh`). Это происходило, когда installer брал часть templates из внешнего upstream repo, где эти файлы могли отсутствовать.
|
|
||||||
|
|
||||||
Решение на сервере:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
/opt/Phobos/repo/server/scripts/phobos-client.sh package CLIENT_ID
|
|
||||||
/opt/Phobos/repo/server/scripts/phobos-client.sh link CLIENT_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
Потом скопируй новую команду установки из панели и запусти её на роутере.
|
|
||||||
|
|
||||||
Проверка пакета:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tar tzf /opt/Phobos/packages/phobos-CLIENT_ID.tar.gz | grep -E 'lib-client.sh|install-obfuscator.sh|install-wireguard.sh'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пакет битый: `Bad package`, `not in gzip format`, HTML вместо tar.gz
|
|
||||||
|
|
||||||
Роутер скачал не пакет, а страницу ошибки.
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
file /tmp/phobos_install_*/package.tar.gz
|
|
||||||
tar tzf /tmp/phobos_install_*/package.tar.gz | head
|
|
||||||
```
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
|
|
||||||
- скопируй новую install-команду из панели;
|
|
||||||
- проверь, что в команде правильный порт панели;
|
|
||||||
- пересобери пакет клиента командой `package CLIENT_ID`;
|
|
||||||
- если VPS firewall закрывает порт панели, открой его.
|
|
||||||
|
|
||||||
### Установка прошла, но WireGuard интерфейс не появился
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ls -l /opt/etc/Phobos
|
|
||||||
/opt/etc/init.d/S49wg-obfuscator status
|
|
||||||
ndmc -c "show interface" | grep -i Phobos
|
|
||||||
tail -n 120 /opt/etc/Phobos/health.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Причины:
|
|
||||||
|
|
||||||
- нет свободного `Wireguard0..Wireguard9`;
|
|
||||||
- старый Phobos-интерфейс остался после ручных тестов;
|
|
||||||
- RCI API роутера вернул ошибку.
|
|
||||||
|
|
||||||
Безопасное решение:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/opt/etc/Phobos/phobos-uninstall.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Потом вставь свежую команду установки. Если удаляешь интерфейсы вручную, трогай только те, где description начинается с `Phobos-`.
|
|
||||||
|
|
||||||
### SSH test роутера в панели показывает timeout
|
|
||||||
|
|
||||||
Это часто нормально. Роутеры обычно за NAT, мобильным интернетом или KeenDNS. PCA Phobos не требует входящий SSH на роутер: основной способ управления - pull, роутер сам тянет конфиг.
|
|
||||||
|
|
||||||
Проверяй pull:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
crontab -l | grep phobos-pull
|
|
||||||
tail -n 80 /opt/etc/Phobos/health.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Если `phobos-pull.sh` есть в cron, timeout SSH не значит, что система сломана.
|
|
||||||
|
|
||||||
### После reboot у роутера сменился внешний IP
|
|
||||||
|
|
||||||
Это нормально для домашнего провайдера. Phobos должен подняться сам.
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/opt/etc/init.d/S49wg-obfuscator status
|
|
||||||
ndmc -c "show interface Wireguard3"
|
|
||||||
grep '^target' /opt/etc/Phobos/wg-obfuscator.conf
|
|
||||||
crontab -l | grep phobos
|
|
||||||
```
|
|
||||||
|
|
||||||
Если не поднялся:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/opt/etc/init.d/S49wg-obfuscator restart
|
|
||||||
sh /opt/etc/Phobos/phobos-health.sh
|
|
||||||
tail -n 120 /opt/etc/Phobos/health.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### После reboot Phobos не стартует
|
|
||||||
|
|
||||||
Частые причины:
|
|
||||||
|
|
||||||
- Entware не смонтировался;
|
|
||||||
- `/opt` поднялся поздно;
|
|
||||||
- cron не стартовал;
|
|
||||||
- init-скрипт потерял execute-bit.
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mount | grep /opt
|
|
||||||
ls -l /opt/etc/init.d/S49wg-obfuscator
|
|
||||||
crontab -l | grep phobos
|
|
||||||
```
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
chmod +x /opt/etc/init.d/S49wg-obfuscator
|
|
||||||
/opt/etc/init.d/S49wg-obfuscator restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Если `/opt` пустой или не смонтирован, это проблема Entware/USB/накопителя.
|
|
||||||
|
|
||||||
### На роутере VPN есть, но LAN-устройства не ходят через него
|
|
||||||
|
|
||||||
Для Keenetic/Netcraze важны:
|
|
||||||
|
|
||||||
- Phobos WireGuard interface должен быть `security-level public`;
|
|
||||||
- LAN-устройства должны быть привязаны к нужной policy.
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ndmc -c "show interface Wireguard3" | grep security-level
|
|
||||||
ndmc -c "show running-config" | grep -E 'ip hotspot host|policy'
|
|
||||||
```
|
|
||||||
|
|
||||||
Health-скрипт пытается чинить это сам. Запусти вручную:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sh /opt/etc/Phobos/phobos-health.sh
|
|
||||||
tail -n 120 /opt/etc/Phobos/health.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Удалить Phobos с роутера
|
|
||||||
|
|
||||||
На роутере:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/opt/etc/Phobos/phobos-uninstall.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт останавливает obfuscator, удаляет Phobos-интерфейс WireGuard, cron-строки и файлы `/opt/etc/Phobos`.
|
|
||||||
|
|
||||||
## Failover и pull-модель
|
|
||||||
|
|
||||||
### В логах `STALE`, `PORT HOP`, `FAILOVER`
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
|
|
||||||
```text
|
|
||||||
STALE: handshake=9999s
|
|
||||||
ACTION: restart obfuscator
|
|
||||||
PORT HOP -> port idx 1
|
|
||||||
FAILOVER -> server 2
|
|
||||||
```
|
|
||||||
|
|
||||||
Это не всегда ошибка. Так роутер лечит отсутствие handshake:
|
|
||||||
|
|
||||||
1. restart obfuscator;
|
|
||||||
2. другой порт на том же сервере;
|
|
||||||
3. другой сервер.
|
|
||||||
|
|
||||||
Если потом есть `OK: tunnel up`, failover работает штатно.
|
|
||||||
|
|
||||||
### Роутер не получает новый `failover.conf`
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
grep '^SERVER_' /opt/etc/Phobos/failover.conf
|
|
||||||
crontab -l | grep phobos-pull
|
|
||||||
sh /opt/etc/Phobos/phobos-pull.sh
|
|
||||||
tail -n 80 /opt/etc/Phobos/health.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Если публичный адрес панели недоступен, но туннель поднят, проверь tunnel pull:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ip -o addr show | grep '10.25.'
|
|
||||||
grep -- '--interface' /opt/etc/Phobos/phobos-pull.sh
|
|
||||||
PANEL_URLS="http://10.25.0.1:10514" POLL_PASSES=1 sh /opt/etc/Phobos/phobos-pull.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Если в `phobos-pull.sh` нет `--interface`, обнови сервер, пересобери пакет клиента и поставь свежую команду на роутер.
|
|
||||||
|
|
||||||
### `/tmp/phobos-pull.lock` висит долго
|
|
||||||
|
|
||||||
Lock нужен, чтобы два pull-процесса не писали конфиг одновременно.
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cat /tmp/phobos-pull.lock
|
|
||||||
ps | grep phobos-pull
|
|
||||||
```
|
|
||||||
|
|
||||||
Если процесса с этим PID нет:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rm -f /tmp/phobos-pull.lock
|
|
||||||
sh /opt/etc/Phobos/phobos-pull.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Secondary servers
|
|
||||||
|
|
||||||
### `Инфо`, `Синхр.` или назначение клиента дает 401
|
|
||||||
|
|
||||||
Причина: у secondary старый или другой API key.
|
|
||||||
|
|
||||||
Что делать:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
```
|
|
||||||
|
|
||||||
Потом в панели:
|
|
||||||
|
|
||||||
1. `Серверы`;
|
|
||||||
2. нажми `Инфо` на secondary;
|
|
||||||
3. если добавляешь secondary вручную, API key можно оставить пустым - панель использует главный `server_api_key`.
|
|
||||||
|
|
||||||
Если ставишь новый secondary, команда должна содержать реальный порт панели:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MAIN_SERVER=<main_ip> MAIN_PORT=<panel_port> MAIN_API_KEY=<api_key> \
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Порт панели:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat /opt/phobos-panel/.port
|
|
||||||
```
|
|
||||||
|
|
||||||
### Secondary не регистрируется
|
|
||||||
|
|
||||||
На secondary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status phobos-api --no-pager
|
|
||||||
systemctl status wg-quick@wg0 --no-pager
|
|
||||||
ss -lntp | grep 8444
|
|
||||||
journalctl -u phobos-api -n 100 --no-pager
|
|
||||||
```
|
|
||||||
|
|
||||||
С основного сервера:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -i http://SECONDARY_IP:8444/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Если `8444` закрыт, проверь firewall/VPS-панель и сервис `phobos-api`.
|
|
||||||
|
|
||||||
### Deploy Secondary использует не тот порт панели
|
|
||||||
|
|
||||||
Если панель не на `8443`, обязательно укажи `MAIN_PORT`.
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MAIN_SERVER=212.118.52.193 MAIN_PORT=10514 MAIN_API_KEY=*** \
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Android, iPhone и QR
|
|
||||||
|
|
||||||
### Android PhobosWG: `Невозможно импортировать туннель`
|
|
||||||
|
|
||||||
Проверь:
|
|
||||||
|
|
||||||
1. Импортируешь именно `phobos://` или QR с Android-страницы клиента.
|
|
||||||
2. Не импортируешь обычный `.conf` в PhobosWG.
|
|
||||||
3. Ссылка скопирована полностью, не обрезана Telegram/мессенджером.
|
|
||||||
4. Панель обновлена, QR открыт заново после обновления.
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
```
|
|
||||||
|
|
||||||
Потом заново открой Android-страницу клиента и сканируй QR прямо с панели. Если не помогло, создай нового клиента для телефона. Один клиент = одно устройство; роутер и телефон должны иметь разные клиенты.
|
|
||||||
|
|
||||||
### Android PhobosWG: `Неправильная длина ключа в PublicKey`
|
|
||||||
|
|
||||||
Причина: старый phobos payload мог добавлять `PresharedKey = none`, отдавать старый QR/ссылку после обновления панели или терять финальный `=` в WireGuard `PublicKey`. PhobosWG воспринимает это как битый ключ.
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
phobos-update stable
|
|
||||||
systemctl restart phobos-panel
|
|
||||||
```
|
|
||||||
|
|
||||||
Потом:
|
|
||||||
|
|
||||||
1. Открой клиента в панели заново.
|
|
||||||
2. Нажми Android/PhobosWG.
|
|
||||||
3. Сканируй новый QR или копируй новую `phobos://` ссылку.
|
|
||||||
4. Если клиент был создан старой версией и ошибка осталась, создай нового клиента именно для телефона.
|
|
||||||
|
|
||||||
В новых версиях `install.sh` и `phobos-update` запускают `phobos-self-check --fix` сами. Команда нужна только если хочешь повторно прогнать проверку на уже установленном сервере.
|
|
||||||
|
|
||||||
### Android импорт прошел, но интернета нет
|
|
||||||
|
|
||||||
В `[Peer]` должны быть маршруты:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
AllowedIPs = 0.0.0.0/0, ::/0
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверь DNS и MTU:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
DNS = 8.8.8.8, 2001:4860:4860::8888
|
|
||||||
MTU = 1420
|
|
||||||
```
|
|
||||||
|
|
||||||
Если мобильная сеть режет MTU, попробуй `1280`.
|
|
||||||
|
|
||||||
### iPhone не открывает `phobos://`
|
|
||||||
|
|
||||||
На iOS используется обычный WireGuard, не `phobos://`. В панели нажми кнопку iPhone/iOS. Для iOS сервер должен иметь открытый plain WireGuard:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ALLOW_PLAIN_WG=1 bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Если сервер уже установлен, проверь порт `51820/udp`.
|
|
||||||
|
|
||||||
### QR не сканируется
|
|
||||||
|
|
||||||
- Открой страницу клиента заново.
|
|
||||||
- Увеличь QR.
|
|
||||||
- Используй кнопку копирования ссылки.
|
|
||||||
- Не пересылай QR через мессенджер с сильным сжатием.
|
|
||||||
|
|
||||||
## Telegram
|
|
||||||
|
|
||||||
### Нет сообщений
|
|
||||||
|
|
||||||
В панели должны быть заполнены Telegram Token и Chat ID.
|
|
||||||
|
|
||||||
Проверка с сервера:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 - <<'PY'
|
|
||||||
import json, urllib.request
|
|
||||||
s=json.load(open('/opt/phobos-panel/settings.json'))
|
|
||||||
token=s.get('tg_bot_token','')
|
|
||||||
chat=s.get('tg_chat_id','')
|
|
||||||
print('token set:', bool(token), 'chat set:', bool(chat))
|
|
||||||
if token and chat:
|
|
||||||
data=json.dumps({'chat_id':chat,'text':'PCA Phobos test message'}).encode()
|
|
||||||
req=urllib.request.Request(f'https://api.telegram.org/bot{token}/sendMessage',
|
|
||||||
data=data, headers={'Content-Type':'application/json'})
|
|
||||||
print(urllib.request.urlopen(req, timeout=15).read().decode())
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
Не отправляй вывод с токеном в публичный issue.
|
|
||||||
|
|
||||||
### Сервер падал, но Telegram не прислал DOWN
|
|
||||||
|
|
||||||
Панель не объявляет сервер упавшим после одного таймаута, чтобы не спамить при коротких сетевых сбоях. Нужно несколько неудачных циклов подряд.
|
|
||||||
|
|
||||||
Проверь интервал:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep monitor_interval /opt/phobos-panel/settings.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверь secondary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -i http://SECONDARY_IP:8444/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Если UI стал Offline, но сообщения нет, проверь Bot API тестом выше.
|
|
||||||
|
|
||||||
## Скорость и стабильность
|
|
||||||
|
|
||||||
### Скорость низкая
|
|
||||||
|
|
||||||
Проверь, куда сейчас смотрит клиент:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
grep '^target' /opt/etc/Phobos/wg-obfuscator.conf
|
|
||||||
tail -n 120 /opt/etc/Phobos/health.log
|
|
||||||
```
|
|
||||||
|
|
||||||
На сервере:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wg show
|
|
||||||
curl -s http://127.0.0.1:$(cat /opt/phobos-panel/.port)/api/obf-health
|
|
||||||
```
|
|
||||||
|
|
||||||
Если в логах постоянный `PORT HOP` или `FAILOVER`, провайдер может плохо пропускать текущий порт/IP. Поменяй приоритет серверов или проверь другой порт.
|
|
||||||
|
|
||||||
### Ping большой
|
|
||||||
|
|
||||||
Частые причины:
|
|
||||||
|
|
||||||
- выбран дальний secondary;
|
|
||||||
- роутер ушел в failover;
|
|
||||||
- перегружен VPS;
|
|
||||||
- провайдер плохо маршрутизирует конкретный порт.
|
|
||||||
|
|
||||||
Смотри страницу `Серверы`: CPU/RAM, Online/Offline, назначение клиента.
|
|
||||||
|
|
||||||
## Безопасная переустановка
|
|
||||||
|
|
||||||
Только роутер:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/opt/etc/Phobos/phobos-uninstall.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Потом вставь свежую команду установки из панели. Клиент на сервере при этом не удаляется.
|
|
||||||
|
|
||||||
Полностью пересоздать клиента:
|
|
||||||
|
|
||||||
1. Удали клиента в панели.
|
|
||||||
2. Создай нового клиента.
|
|
||||||
3. Установи новый конфиг на устройство.
|
|
||||||
|
|
||||||
Так надо делать, если один и тот же клиент случайно использовался на двух устройствах.
|
|
||||||
|
|
||||||
Полностью удалить сервер:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/uninstall.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Без подтверждения:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PURGE=1 bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/uninstall.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что писать в issue
|
|
||||||
|
|
||||||
Приложи:
|
|
||||||
|
|
||||||
- версия: `cat /opt/phobos-panel/.version`;
|
|
||||||
- канал: stable/beta/dev;
|
|
||||||
- что именно ставишь: основной сервер, secondary, роутер, Android, iOS;
|
|
||||||
- текст ошибки;
|
|
||||||
- что уже пробовал;
|
|
||||||
- последние 80-120 строк логов.
|
|
||||||
|
|
||||||
Сервер:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u phobos-panel -n 120 --no-pager
|
|
||||||
systemctl status phobos-panel --no-pager
|
|
||||||
```
|
|
||||||
|
|
||||||
Роутер:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
tail -n 120 /opt/etc/Phobos/health.log
|
|
||||||
/opt/etc/init.d/S49wg-obfuscator status
|
|
||||||
```
|
|
||||||
|
|
||||||
Secondary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u phobos-api -n 120 --no-pager
|
|
||||||
systemctl status phobos-api --no-pager
|
|
||||||
```
|
|
||||||
|
|
||||||
Перед отправкой замени секреты:
|
|
||||||
|
|
||||||
```text
|
|
||||||
server_api_key = ***
|
|
||||||
PrivateKey = ***
|
|
||||||
PresharedKey = ***
|
|
||||||
pull_token = ***
|
|
||||||
GH_TOKEN = ***
|
|
||||||
PHOBOS_KEY = ***
|
|
||||||
```
|
|
||||||
160
docs/WARP_DEV_NOTES.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# WARP in `dev` — modes, checks, current limits
|
||||||
|
|
||||||
|
Date: `2026-06-06`
|
||||||
|
Channel: `dev`
|
||||||
|
Version: `v1.3.1-dev`
|
||||||
|
|
||||||
|
## What was added
|
||||||
|
|
||||||
|
The `dev` panel now has a dedicated `WARP / WARP+` block in `/settings`.
|
||||||
|
|
||||||
|
Supported modes:
|
||||||
|
|
||||||
|
- `egress` — route client outbound traffic through WARP on the server side;
|
||||||
|
- `relay` — expose a UDP relay for a separate WARP/WireGuard client profile;
|
||||||
|
- `custom profile` — save a full custom `warp.conf` from the web UI;
|
||||||
|
- `WARP+ key` — save a license key and rebuild the profile through `wgcf`.
|
||||||
|
|
||||||
|
The UI also has a `?` help block with plain-language explanations:
|
||||||
|
|
||||||
|
- what each mode does;
|
||||||
|
- when to use `free` vs `custom`;
|
||||||
|
- what `WARP+ key` changes;
|
||||||
|
- what `Enable` and `Disable` do;
|
||||||
|
- how per-client WARP works from the client list.
|
||||||
|
|
||||||
|
## How the modes work
|
||||||
|
|
||||||
|
### `egress`
|
||||||
|
|
||||||
|
Use this when you want Phobos clients to keep connecting the usual way, but send
|
||||||
|
their outgoing traffic through Cloudflare WARP after they land on the server.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- WireGuard clients still connect to the Phobos server;
|
||||||
|
- server-side policy routing sends only selected client tunnel IPs through `warp`;
|
||||||
|
- NAT is done with `POSTROUTING MASQUERADE`;
|
||||||
|
- client rows in `/clients` have `WARP ON/OFF`, which adds/removes `ip rule from <client-ip>/32`.
|
||||||
|
|
||||||
|
### `relay`
|
||||||
|
|
||||||
|
Use this when you need a separate relay endpoint for another WARP/WireGuard
|
||||||
|
client profile.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- this is **not** a drop-in replacement for the normal Phobos router config;
|
||||||
|
- the client must use a separate WARP/WireGuard profile aimed at the relay port;
|
||||||
|
- this mode is for relay-style scenarios, not for replacing the regular
|
||||||
|
`home.conf` path one-to-one.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- UDP relay rules are added with tagged iptables entries;
|
||||||
|
- incoming UDP on the selected port is DNAT'ed to the target WARP endpoint;
|
||||||
|
- outgoing packets are MASQUERADE'd back through the relay host.
|
||||||
|
|
||||||
|
### `custom profile` and `WARP+`
|
||||||
|
|
||||||
|
Two ways to use a non-default profile:
|
||||||
|
|
||||||
|
- paste a full custom `warp.conf`;
|
||||||
|
- save a `WARP+ key` and let `wgcf` rebuild the profile from it.
|
||||||
|
|
||||||
|
This keeps the UI simple:
|
||||||
|
|
||||||
|
- `free WARP` still works out of the box;
|
||||||
|
- `WARP+` is possible when the owner has a real key;
|
||||||
|
- no hidden magic is required outside the panel.
|
||||||
|
|
||||||
|
## Live checks completed
|
||||||
|
|
||||||
|
### Server-side checks
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
- `python3 -m py_compile app.py server/api.py`
|
||||||
|
- `bash -n install.sh update.sh server/phobos-warp.sh`
|
||||||
|
- live `status` checks on dev server `64.188.81.115`
|
||||||
|
|
||||||
|
Verified on the dev server:
|
||||||
|
|
||||||
|
- `egress` mode enables cleanly;
|
||||||
|
- current egress IPv4 is reported in status JSON;
|
||||||
|
- `custom profile` and `WARP+ key` save from the web UI;
|
||||||
|
- relay rules appear with separate tagged iptables entries.
|
||||||
|
|
||||||
|
### Real router check
|
||||||
|
|
||||||
|
Router used for testing:
|
||||||
|
|
||||||
|
- public SSH: `95.72.5.253`
|
||||||
|
- web: `https://homesmart.netcraze.pro/dashboard`
|
||||||
|
|
||||||
|
Confirmed:
|
||||||
|
|
||||||
|
- real router traffic was sent through the dev server;
|
||||||
|
- with `egress` enabled, a live `curl https://api.ipify.org` from the router
|
||||||
|
returned WARP egress IP `104.28.193.219`;
|
||||||
|
- this proves the real data path for `router -> Phobos -> WARP -> internet`.
|
||||||
|
|
||||||
|
### Relay check
|
||||||
|
|
||||||
|
Confirmed:
|
||||||
|
|
||||||
|
- relay mode enables from the server side;
|
||||||
|
- relay iptables rules are created;
|
||||||
|
- counters increase on live traffic;
|
||||||
|
- a separate WARP profile pointed at the relay port completes handshake.
|
||||||
|
|
||||||
|
Not fully closed yet:
|
||||||
|
|
||||||
|
- relay handshake and packet flow were confirmed;
|
||||||
|
- a full end-to-end "internet works through relay profile" check on the router
|
||||||
|
was not fully finished in a clean state before router access became limited.
|
||||||
|
|
||||||
|
## Router recovery note
|
||||||
|
|
||||||
|
During live tests, WAN SSH access to the router stopped responding, so full file
|
||||||
|
restore over shell was blocked.
|
||||||
|
|
||||||
|
What was still restored safely:
|
||||||
|
|
||||||
|
- temporary KeenDNS helper proxies created for testing were removed;
|
||||||
|
- temporary extra WireGuard peer entries were cleaned up;
|
||||||
|
- the router is currently left on a **working direct dev WireGuard peer** so it
|
||||||
|
stays reachable through the test tunnel rather than returning to a broken
|
||||||
|
down state.
|
||||||
|
|
||||||
|
What still needs one final shell pass later:
|
||||||
|
|
||||||
|
- restore `/opt/etc/Phobos/home.conf` from backup;
|
||||||
|
- restore `/opt/etc/Phobos/wg-obfuscator.conf` from backup;
|
||||||
|
- restore `/opt/etc/Phobos/failover.conf` from backup;
|
||||||
|
- restore minute cron jobs for `phobos-health.sh` and `phobos-pull.sh`.
|
||||||
|
|
||||||
|
Known backup path on the router:
|
||||||
|
|
||||||
|
- `/opt/etc/Phobos/test-backup-20260606-044834`
|
||||||
|
|
||||||
|
## Current honest status
|
||||||
|
|
||||||
|
Ready now:
|
||||||
|
|
||||||
|
- `egress` mode
|
||||||
|
- per-client `WARP ON/OFF`
|
||||||
|
- `custom profile`
|
||||||
|
- `WARP+ key`
|
||||||
|
- UI help text
|
||||||
|
|
||||||
|
Partially verified:
|
||||||
|
|
||||||
|
- `relay` mode
|
||||||
|
|
||||||
|
Extra audit fixes after live testing:
|
||||||
|
|
||||||
|
- orphan WireGuard peers now show up in both `Sessions` and `Clients` as `orphan-*` instead of disappearing into `unknown`;
|
||||||
|
- orphan rows can be `Kick`ed, `Purge`d, and also toggled through per-peer WARP;
|
||||||
|
- client package build no longer fails when `/opt/Phobos/bin` is missing or `OBFUSCATOR_KEY` is empty;
|
||||||
|
- update / restore scripts now restart the active panel service automatically (`phobos-panel` or `phobos-dev-test`).
|
||||||
BIN
docs/img/android-admin.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
docs/img/android-vpn-apps.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/img/android-vpn-import.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/img/android-vpn.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/img/clients-dark.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
docs/img/clients.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
docs/img/mobile-clients-dark.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
docs/img/mobile-clients.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/img/mobile-servers.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/img/servers-dark.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/img/servers.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
docs/img/sessions-dark.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/img/sessions.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/img/settings-dark.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
docs/img/settings.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
98
install.sh
@@ -13,6 +13,7 @@
|
|||||||
#
|
#
|
||||||
# Env (all optional):
|
# Env (all optional):
|
||||||
# PANEL_PORT random 10000-59999 web panel port
|
# PANEL_PORT random 10000-59999 web panel port
|
||||||
|
# PANEL_TLS_CERT / PANEL_TLS_KEY enable native HTTPS for panel
|
||||||
# PANEL_PASS OcAdmin2026! panel admin password
|
# PANEL_PASS OcAdmin2026! panel admin password
|
||||||
# API_KEY random shared key (agents + router pull token)
|
# API_KEY random shared key (agents + router pull token)
|
||||||
# OBF_PORTS 2083,5443,993 obfuscator listen ports
|
# OBF_PORTS 2083,5443,993 obfuscator listen ports
|
||||||
@@ -22,15 +23,21 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
PANEL_PASS="${PANEL_PASS:-OcAdmin2026!}"
|
PANEL_PASS="${PANEL_PASS:-OcAdmin2026!}"
|
||||||
|
PANEL_TLS_CERT="${PANEL_TLS_CERT:-}"
|
||||||
|
PANEL_TLS_KEY="${PANEL_TLS_KEY:-}"
|
||||||
|
PANEL_SCHEME="${PANEL_SCHEME:-http}"
|
||||||
TG_TOKEN="${TG_TOKEN:-}"
|
TG_TOKEN="${TG_TOKEN:-}"
|
||||||
TG_CHAT="${TG_CHAT:-}"
|
TG_CHAT="${TG_CHAT:-}"
|
||||||
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
||||||
|
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||||
# Каналы: stable (открытый, публичный repo) · beta (тест) · dev (разработка).
|
# Каналы: stable (открытый, публичный repo) · beta (тест) · dev (разработка).
|
||||||
# beta и dev живут в ПРИВАТНОМ repo PCA_Phobos-dev — доступ по токену подписчика
|
# beta и dev живут в ПРИВАТНОМ repo PCA_Phobos-dev — доступ по токену подписчика
|
||||||
# (GH_TOKEN, read-only, выдаётся по подписке Boosty). stable — без токена.
|
# (GH_TOKEN, read-only, выдаётся по подписке Boosty). stable — без токена.
|
||||||
GH_TOKEN="${GH_TOKEN:-}"
|
GH_TOKEN="${GH_TOKEN:-}"
|
||||||
PUB_REPO="andrey271192/PCA_Phobos"
|
PUB_REPO="andrey271192/PCA_Phobos"
|
||||||
DEV_REPO="andrey271192/PCA_Phobos-dev"
|
DEV_REPO="andrey271192/PCA_Phobos-dev"
|
||||||
|
PHOBOSWG_APK_TAG="v1.3.0-beta"
|
||||||
|
PHOBOSWG_APK_SHA256="5399de7c576d56e491a01ab9b52d53c08d30a34d1156c1cb0f4520bf34821e02"
|
||||||
CHANNEL="${CHANNEL:-stable}"
|
CHANNEL="${CHANNEL:-stable}"
|
||||||
case "$CHANNEL" in
|
case "$CHANNEL" in
|
||||||
beta) REPO="$DEV_REPO"; PCA_BRANCH="${PCA_BRANCH:-beta}";;
|
beta) REPO="$DEV_REPO"; PCA_BRANCH="${PCA_BRANCH:-beta}";;
|
||||||
@@ -50,13 +57,31 @@ fi
|
|||||||
PHOBOS_DIR="/opt/Phobos"
|
PHOBOS_DIR="/opt/Phobos"
|
||||||
PANEL_DIR="/opt/phobos-panel"
|
PANEL_DIR="/opt/phobos-panel"
|
||||||
RAW="https://raw.githubusercontent.com/${REPO}/${PCA_BRANCH}"
|
RAW="https://raw.githubusercontent.com/${REPO}/${PCA_BRANCH}"
|
||||||
|
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
[ "$EUID" -eq 0 ] || { echo "Run as root"; exit 1; }
|
[ "$EUID" -eq 0 ] || { echo "Run as root"; exit 1; }
|
||||||
|
|
||||||
if [ -z "$PANEL_PORT" ]; then
|
if [ -z "$PANEL_PORT" ]; then
|
||||||
PANEL_PORT=$(shuf -i 10000-59999 -n 1 2>/dev/null || awk 'BEGIN{srand(); print int(10000+rand()*50000)}')
|
PANEL_PORT=$(shuf -i 10000-59999 -n 1 2>/dev/null || awk 'BEGIN{srand(); print int(10000+rand()*50000)}')
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$PANEL_TLS_CERT" ] || [ -n "$PANEL_TLS_KEY" ]; then
|
||||||
|
[ -f "$PANEL_TLS_CERT" ] || { echo "ERROR: PANEL_TLS_CERT not found: $PANEL_TLS_CERT"; exit 1; }
|
||||||
|
[ -f "$PANEL_TLS_KEY" ] || { echo "ERROR: PANEL_TLS_KEY not found: $PANEL_TLS_KEY"; exit 1; }
|
||||||
|
PANEL_SCHEME="https"
|
||||||
|
fi
|
||||||
|
case "$PANEL_SCHEME" in http|https) ;; *) echo "ERROR: PANEL_SCHEME must be http or https"; exit 1;; esac
|
||||||
API_KEY="${API_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24)}"
|
API_KEY="${API_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24)}"
|
||||||
|
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
net = ipaddress.ip_network(sys.argv[1], strict=False)
|
||||||
|
print(next(net.hosts()))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
WG_PREFIXLEN="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
print(ipaddress.ip_network(sys.argv[1], strict=False).prefixlen)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
SERVER_IP=$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}')
|
SERVER_IP=$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}')
|
||||||
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
||||||
@@ -73,8 +98,18 @@ echo "============================================"
|
|||||||
echo "[1/9] dependencies..."
|
echo "[1/9] dependencies..."
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq wireguard wireguard-tools iptables jq curl git \
|
apt-get install -y -qq wireguard wireguard-tools iptables jq curl git ca-certificates \
|
||||||
python3 python3-flask gunicorn nginx cron python3-qrcode >/dev/null
|
python3 python3-flask python3-pip gunicorn nginx cron python3-qrcode sshpass >/dev/null
|
||||||
|
apt-get install -y -qq python3-flask-sock >/dev/null 2>&1 || true
|
||||||
|
apt-get install -y -qq python3-asyncssh >/dev/null 2>&1 || true
|
||||||
|
apt-get install -y -qq python3-websocket >/dev/null 2>&1 || true
|
||||||
|
python3 - <<'PY' >/dev/null 2>&1 || python3 -m pip install flask-sock asyncssh websocket-client >/dev/null 2>&1 || python3 -m pip install --break-system-packages flask-sock asyncssh websocket-client >/dev/null
|
||||||
|
import flask_sock, asyncssh, websocket
|
||||||
|
PY
|
||||||
|
mkdir -p "$PANEL_DIR/static"
|
||||||
|
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css -o "$PANEL_DIR/static/xterm.min.css" 2>/dev/null || true
|
||||||
|
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js -o "$PANEL_DIR/static/xterm.min.js" 2>/dev/null || true
|
||||||
|
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js -o "$PANEL_DIR/static/xterm-addon-fit.min.js" 2>/dev/null || true
|
||||||
systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true
|
systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true
|
||||||
|
|
||||||
# ── 2. wg-obfuscator binary (Ground-Zerro) ──
|
# ── 2. wg-obfuscator binary (Ground-Zerro) ──
|
||||||
@@ -126,7 +161,7 @@ if [ ! -f /etc/wireguard/wg0.conf ]; then
|
|||||||
WG_PRIV=$(wg genkey); WG_PUB=$(echo "$WG_PRIV" | wg pubkey)
|
WG_PRIV=$(wg genkey); WG_PUB=$(echo "$WG_PRIV" | wg pubkey)
|
||||||
cat > /etc/wireguard/wg0.conf <<WG
|
cat > /etc/wireguard/wg0.conf <<WG
|
||||||
[Interface]
|
[Interface]
|
||||||
Address = 10.25.0.1/16
|
Address = ${WG_SERVER_IPV4}/${WG_PREFIXLEN}
|
||||||
ListenPort = 51820
|
ListenPort = 51820
|
||||||
PrivateKey = $WG_PRIV
|
PrivateKey = $WG_PRIV
|
||||||
PostUp = iptables -I FORWARD 1 -i wg0 -j ACCEPT; iptables -I FORWARD 1 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
|
PostUp = iptables -I FORWARD 1 -i wg0 -j ACCEPT; iptables -I FORWARD 1 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
|
||||||
@@ -186,6 +221,8 @@ cat > "$PHOBOS_DIR/server/server.env" <<EOF
|
|||||||
SERVER_WG_PRIVATE_KEY=$WG_PRIV
|
SERVER_WG_PRIVATE_KEY=$WG_PRIV
|
||||||
SERVER_WG_PUBLIC_KEY=$WG_PUB
|
SERVER_WG_PUBLIC_KEY=$WG_PUB
|
||||||
SERVER_PUBLIC_IP_V4=$SERVER_IP
|
SERVER_PUBLIC_IP_V4=$SERVER_IP
|
||||||
|
SERVER_WG_IPV4_NETWORK=$SERVER_WG_IPV4_NETWORK
|
||||||
|
SERVER_WG_IPV4_ADDRESS=${WG_SERVER_IPV4}/${WG_PREFIXLEN}
|
||||||
OBFUSCATOR_KEY=$OBF_KEY
|
OBFUSCATOR_KEY=$OBF_KEY
|
||||||
OBFUSCATOR_PORTS=$OBF_PORTS
|
OBFUSCATOR_PORTS=$OBF_PORTS
|
||||||
CLIENT_WG_PORT=51820
|
CLIENT_WG_PORT=51820
|
||||||
@@ -196,10 +233,13 @@ EOF
|
|||||||
echo "[6/9] PCA patches (tunnel-pull, self-heal, watchdog, 403 fix)..."
|
echo "[6/9] PCA patches (tunnel-pull, self-heal, watchdog, 403 fix)..."
|
||||||
fetch() {
|
fetch() {
|
||||||
mkdir -p "$(dirname "$2")"
|
mkdir -p "$(dirname "$2")"
|
||||||
|
# ponytail: файл из архива рядом с install.sh — не ходим в сеть.
|
||||||
|
if [ -f "$SRC/$1" ]; then cp -f "$SRC/$1" "$2" && return 0; fi
|
||||||
|
_url="$RAW/$1?cb=$(date +%s)"
|
||||||
if [ -n "$GH_TOKEN" ]; then
|
if [ -n "$GH_TOKEN" ]; then
|
||||||
curl -fsSL -m20 -H "Authorization: token $GH_TOKEN" "$RAW/$1" -o "$2" && return 0
|
curl -fsSL -m20 -H "Authorization: token $GH_TOKEN" "$_url" -o "$2" && return 0
|
||||||
else
|
else
|
||||||
curl -fsSL -m20 "$RAW/$1" -o "$2" && return 0
|
curl -fsSL -m20 "$_url" -o "$2" && return 0
|
||||||
fi
|
fi
|
||||||
echo " WARN: fetch $1 failed"; return 1
|
echo " WARN: fetch $1 failed"; return 1
|
||||||
}
|
}
|
||||||
@@ -212,13 +252,23 @@ fetch overlay/install-wireguard.sh "$PHOBOS_DIR/repo/client/template
|
|||||||
fetch overlay/router-configure-wireguard.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh"
|
fetch overlay/router-configure-wireguard.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh"
|
||||||
fetch overlay/router-configure-wireguard-openwrt.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh"
|
fetch overlay/router-configure-wireguard-openwrt.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh"
|
||||||
fetch overlay/phobos-pull.sh "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh"
|
fetch overlay/phobos-pull.sh "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh"
|
||||||
|
fetch overlay/phobos-ttyd.sh "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" && cp "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" "$PHOBOS_DIR/www/phobos-ttyd.sh" 2>/dev/null || true
|
||||||
fetch overlay/phobos-uninstall.sh "$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh"
|
fetch overlay/phobos-uninstall.sh "$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh"
|
||||||
fetch overlay/3xui.sh "$PHOBOS_DIR/repo/client/templates/3xui.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/3xui.sh"
|
fetch overlay/3xui.sh "$PHOBOS_DIR/repo/client/templates/3xui.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/3xui.sh"
|
||||||
|
fetch server/phobos-warp.sh "$PHOBOS_DIR/server/phobos-warp.sh" && chmod +x "$PHOBOS_DIR/server/phobos-warp.sh" && ln -sf "$PHOBOS_DIR/server/phobos-warp.sh" /usr/local/bin/phobos-warp
|
||||||
|
fetch server/phobos-guard.sh "$PHOBOS_DIR/server/phobos-guard.sh" && chmod +x "$PHOBOS_DIR/server/phobos-guard.sh" && ln -sf "$PHOBOS_DIR/server/phobos-guard.sh" /usr/local/bin/phobos-guard
|
||||||
|
fetch server/phobos-cascade.sh "$PHOBOS_DIR/server/phobos-cascade.sh" && chmod +x "$PHOBOS_DIR/server/phobos-cascade.sh" && ln -sf "$PHOBOS_DIR/server/phobos-cascade.sh" /usr/local/bin/phobos-cascade
|
||||||
|
fetch server/phobos-domain.sh "$PHOBOS_DIR/server/phobos-domain.sh" && chmod +x "$PHOBOS_DIR/server/phobos-domain.sh" && ln -sf "$PHOBOS_DIR/server/phobos-domain.sh" /usr/local/bin/phobos-domain
|
||||||
|
fetch server/phobos-limit.sh "$PHOBOS_DIR/server/phobos-limit.sh" && chmod +x "$PHOBOS_DIR/server/phobos-limit.sh"
|
||||||
|
fetch server/phobos-speedtest.sh "$PHOBOS_DIR/server/phobos-speedtest.sh" && chmod +x "$PHOBOS_DIR/server/phobos-speedtest.sh"
|
||||||
fetch server/phobos-health.sh "$PHOBOS_DIR/server/phobos-health.sh" && chmod +x "$PHOBOS_DIR/server/phobos-health.sh"
|
fetch server/phobos-health.sh "$PHOBOS_DIR/server/phobos-health.sh" && chmod +x "$PHOBOS_DIR/server/phobos-health.sh"
|
||||||
fetch server/phobos-self-check.sh "$PHOBOS_DIR/server/phobos-self-check.sh" && chmod +x "$PHOBOS_DIR/server/phobos-self-check.sh" && ln -sf "$PHOBOS_DIR/server/phobos-self-check.sh" /usr/local/bin/phobos-self-check
|
fetch server/phobos-self-check.sh "$PHOBOS_DIR/server/phobos-self-check.sh" && chmod +x "$PHOBOS_DIR/server/phobos-self-check.sh" && ln -sf "$PHOBOS_DIR/server/phobos-self-check.sh" /usr/local/bin/phobos-self-check
|
||||||
fetch server/phobos-pull.sh "$PHOBOS_DIR/server/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/server/phobos-pull.sh"
|
fetch server/phobos-pull.sh "$PHOBOS_DIR/server/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/server/phobos-pull.sh"
|
||||||
fetch server/phobos-router-watchdog.py "$PHOBOS_DIR/server/phobos-router-watchdog.py"
|
fetch server/phobos-router-watchdog.py "$PHOBOS_DIR/server/phobos-router-watchdog.py"
|
||||||
|
fetch server/api.py "$PHOBOS_DIR/server/api.py"
|
||||||
|
fetch server/secondary-setup.sh "$PHOBOS_DIR/server/secondary-setup.sh" && chmod +x "$PHOBOS_DIR/server/secondary-setup.sh"
|
||||||
fetch update.sh "$PHOBOS_DIR/server/update.sh" && chmod +x "$PHOBOS_DIR/server/update.sh" && ln -sf "$PHOBOS_DIR/server/update.sh" /usr/local/bin/phobos-update
|
fetch update.sh "$PHOBOS_DIR/server/update.sh" && chmod +x "$PHOBOS_DIR/server/update.sh" && ln -sf "$PHOBOS_DIR/server/update.sh" /usr/local/bin/phobos-update
|
||||||
|
fetch server/phobos-backup.sh "$PHOBOS_DIR/server/phobos-backup.sh" && chmod +x "$PHOBOS_DIR/server/phobos-backup.sh" && ln -sf "$PHOBOS_DIR/server/phobos-backup.sh" /usr/local/bin/phobos-backup
|
||||||
mkdir -p "$PANEL_DIR"; fetch VERSION "$PANEL_DIR/.version" 2>/dev/null || true
|
mkdir -p "$PANEL_DIR"; fetch VERSION "$PANEL_DIR/.version" 2>/dev/null || true
|
||||||
[ -f "$PHOBOS_DIR/tokens/tokens.json" ] || echo '[]' > "$PHOBOS_DIR/tokens/tokens.json"
|
[ -f "$PHOBOS_DIR/tokens/tokens.json" ] || echo '[]' > "$PHOBOS_DIR/tokens/tokens.json"
|
||||||
"$PHOBOS_DIR/server/phobos-self-check.sh" --fix
|
"$PHOBOS_DIR/server/phobos-self-check.sh" --fix
|
||||||
@@ -243,6 +293,11 @@ if [ ! -f "$PANEL_DIR/settings.json" ]; then
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
echo "$PANEL_PORT" > "$PANEL_DIR/.port"
|
echo "$PANEL_PORT" > "$PANEL_DIR/.port"
|
||||||
|
echo "$PANEL_SCHEME" > "$PANEL_DIR/.scheme"
|
||||||
|
TLS_ARGS=""
|
||||||
|
if [ "$PANEL_SCHEME" = "https" ]; then
|
||||||
|
TLS_ARGS="--certfile $PANEL_TLS_CERT --keyfile $PANEL_TLS_KEY"
|
||||||
|
fi
|
||||||
cat > /etc/systemd/system/phobos-panel.service <<EOF
|
cat > /etc/systemd/system/phobos-panel.service <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Phobos VPN Web Panel
|
Description=Phobos VPN Web Panel
|
||||||
@@ -251,10 +306,11 @@ Wants=wg-quick@wg0.service
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=$PANEL_DIR
|
WorkingDirectory=$PANEL_DIR
|
||||||
ExecStart=/usr/bin/gunicorn -w 1 -b 0.0.0.0:$PANEL_PORT app:app
|
ExecStart=/usr/bin/gunicorn -w 1 --threads 20 $TLS_ARGS -b 0.0.0.0:$PANEL_PORT app:app
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=PANEL_SCHEME=$PANEL_SCHEME
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
@@ -288,6 +344,34 @@ if [ -f "$PHOBOS_DIR/server/phobos-router-watchdog.py" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
|
# ── PhobosWG APK (beta/dev only: fetch from private release asset) ──
|
||||||
|
if [ "$REPO" = "$DEV_REPO" ] && [ -n "$GH_TOKEN" ]; then
|
||||||
|
mkdir -p "$PHOBOS_DIR/www/app"
|
||||||
|
_apk="$PHOBOS_DIR/www/app/PhobosWG.apk"
|
||||||
|
_cur_sha="$(sha256sum "$_apk" 2>/dev/null | awk '{print $1}')"
|
||||||
|
if [ "$_cur_sha" != "$PHOBOSWG_APK_SHA256" ]; then
|
||||||
|
echo "[APK] Refreshing PhobosWG.apk from release $PHOBOSWG_APK_TAG..."
|
||||||
|
_apk_api_url=$(curl -fsSL -m15 -H "Authorization: token $GH_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$DEV_REPO/releases/tags/$PHOBOSWG_APK_TAG" 2>/dev/null \
|
||||||
|
| grep -B8 '"name": "PhobosWG.apk"' | grep '"url"' | head -1 | cut -d'"' -f4)
|
||||||
|
if [ -n "$_apk_api_url" ] && curl -fsSL -m120 -H "Authorization: token $GH_TOKEN" \
|
||||||
|
-H "Accept: application/octet-stream" "$_apk_api_url" -o "$_apk"; then
|
||||||
|
_new_sha="$(sha256sum "$_apk" 2>/dev/null | awk '{print $1}')"
|
||||||
|
if [ "$_new_sha" = "$PHOBOSWG_APK_SHA256" ]; then
|
||||||
|
echo "[APK] Downloaded PhobosWG.apk: $(ls -lh "$_apk" | awk '{print $5}')"
|
||||||
|
else
|
||||||
|
rm -f "$_apk"
|
||||||
|
echo "[APK] SHA mismatch after download (non-fatal)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[APK] Download failed (non-fatal)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[APK] PhobosWG.apk current"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# persist channel + subscriber token so phobos-update can reach private channels
|
# persist channel + subscriber token so phobos-update can reach private channels
|
||||||
mkdir -p "$PANEL_DIR"
|
mkdir -p "$PANEL_DIR"
|
||||||
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
|
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
|
||||||
@@ -298,7 +382,7 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " Installation complete"
|
echo " Installation complete"
|
||||||
echo " Panel : http://$SERVER_IP:$PANEL_PORT"
|
echo " Panel : $PANEL_SCHEME://$SERVER_IP:$PANEL_PORT"
|
||||||
echo " Login : admin"
|
echo " Login : admin"
|
||||||
echo " Pass : $PANEL_PASS"
|
echo " Pass : $PANEL_PASS"
|
||||||
echo " API key (agents+pull): $API_KEY"
|
echo " API key (agents+pull): $API_KEY"
|
||||||
|
|||||||
@@ -443,6 +443,11 @@ deploy_health_monitor() {
|
|||||||
chmod +x "$PHOBOS_DIR/phobos-pull.sh"
|
chmod +x "$PHOBOS_DIR/phobos-pull.sh"
|
||||||
echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id"
|
echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id"
|
||||||
[ -f "pull_token" ] && cp "pull_token" "$PHOBOS_DIR/pull_token"
|
[ -f "pull_token" ] && cp "pull_token" "$PHOBOS_DIR/pull_token"
|
||||||
|
[ -f "panel_tunnel_ip" ] && cp "panel_tunnel_ip" "$PHOBOS_DIR/panel_tunnel_ip"
|
||||||
|
[ -f "panel_port" ] && cp "panel_port" "$PHOBOS_DIR/panel_port"
|
||||||
|
[ -f "panel_scheme" ] && cp "panel_scheme" "$PHOBOS_DIR/panel_scheme"
|
||||||
|
[ -f "panel_url" ] && cp "panel_url" "$PHOBOS_DIR/panel_url"
|
||||||
|
[ -f "panel_urls" ] && cp "panel_urls" "$PHOBOS_DIR/panel_urls"
|
||||||
log " Pull-агент установлен (client_id={{CLIENT_NAME}})"
|
log " Pull-агент установлен (client_id={{CLIENT_NAME}})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ action_add() {
|
|||||||
for d in "$CLIENTS_DIR"/*; do
|
for d in "$CLIENTS_DIR"/*; do
|
||||||
if [[ -d "$d" ]] && [[ -f "$d/metadata.json" ]]; then
|
if [[ -d "$d" ]] && [[ -f "$d/metadata.json" ]]; then
|
||||||
local ip=$(jq -r '.tunnel_ip_v4 // empty' "$d/metadata.json" 2>/dev/null | cut -d/ -f1)
|
local ip=$(jq -r '.tunnel_ip_v4 // empty' "$d/metadata.json" 2>/dev/null | cut -d/ -f1)
|
||||||
[[ -n "$ip" ]] && used_ips="${used_ips}${ip}"$'\n'
|
[[ -n "$ip" ]] && used_ips="${used_ips}${ip}\n"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
client_ip_v4="$(USED_IPS="$used_ips" python3 - "$server_wg_network" <<'PY'
|
client_ip_v4="$(USED_IPS="$(printf "%b" "$used_ips")" python3 - "$server_wg_network" <<'PY'
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -123,7 +123,7 @@ EOF
|
|||||||
[instance]
|
[instance]
|
||||||
source-if = 127.0.0.1
|
source-if = 127.0.0.1
|
||||||
source-lport = ${CLIENT_WG_PORT:-13255}
|
source-lport = ${CLIENT_WG_PORT:-13255}
|
||||||
target = $SERVER_PUBLIC_IP_V4:${OBFUSCATOR_PORT:-51821}
|
target = ${CLIENT_ENDPOINT:-$SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORT:-51821}
|
||||||
key = ${OBFUSCATOR_KEY:-KEY}
|
key = ${OBFUSCATOR_KEY:-KEY}
|
||||||
masking = ${OBFUSCATOR_MASKING:-AUTO}
|
masking = ${OBFUSCATOR_MASKING:-AUTO}
|
||||||
verbose = INFO
|
verbose = INFO
|
||||||
@@ -213,6 +213,7 @@ action_package() {
|
|||||||
local tmp=$(mktemp -d)
|
local tmp=$(mktemp -d)
|
||||||
local pkg_root="$tmp/phobos-$id"
|
local pkg_root="$tmp/phobos-$id"
|
||||||
|
|
||||||
|
mkdir -p "$PHOBOS_DIR/bin"
|
||||||
mkdir -p "$pkg_root/bin"
|
mkdir -p "$pkg_root/bin"
|
||||||
|
|
||||||
cp "$dir/${id}.conf" "$pkg_root/${id}.conf"
|
cp "$dir/${id}.conf" "$pkg_root/${id}.conf"
|
||||||
@@ -239,7 +240,7 @@ action_package() {
|
|||||||
|
|
||||||
if [[ -d "$tpl_dir" ]]; then
|
if [[ -d "$tpl_dir" ]]; then
|
||||||
for required in install-router.sh.template lib-client.sh install-obfuscator.sh install-wireguard.sh; do
|
for required in install-router.sh.template lib-client.sh install-obfuscator.sh install-wireguard.sh; do
|
||||||
[[ -f "$tpl_dir/$required" ]] || die "Обязательный шаблон $required не найден в $tpl_dir. Выполни phobos-update stable и пересоздай пакет."
|
[[ -f "$tpl_dir/$required" ]] || die "Обязательный шаблон $required не найден в $tpl_dir. Выполни phobos-update и пересоздай пакет."
|
||||||
done
|
done
|
||||||
cp "$tpl_dir/install-router.sh.template" "$pkg_root/install-router.sh"
|
cp "$tpl_dir/install-router.sh.template" "$pkg_root/install-router.sh"
|
||||||
sed -i "s|{{CLIENT_NAME}}|${id}|g" "$pkg_root/install-router.sh"
|
sed -i "s|{{CLIENT_NAME}}|${id}|g" "$pkg_root/install-router.sh"
|
||||||
@@ -273,10 +274,42 @@ action_package() {
|
|||||||
[[ -n "$PULL_TOKEN" ]] && echo "$PULL_TOKEN" > "$pkg_root/pull_token"
|
[[ -n "$PULL_TOKEN" ]] && echo "$PULL_TOKEN" > "$pkg_root/pull_token"
|
||||||
# Generate failover.conf with current server data
|
# Generate failover.conf with current server data
|
||||||
source "$PHOBOS_DIR/server/server.env"
|
source "$PHOBOS_DIR/server/server.env"
|
||||||
|
local panel_port="${HTTP_PORT:-}"
|
||||||
|
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
|
||||||
|
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
panel_port="${panel_port:-8443}"
|
||||||
|
local panel_scheme="${PANEL_SCHEME:-}"
|
||||||
|
if [[ -z "$panel_scheme" && -f /opt/phobos-panel/.scheme ]]; then
|
||||||
|
panel_scheme=$(cat /opt/phobos-panel/.scheme 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
case "$panel_scheme" in http|https) ;; *) panel_scheme="http";; esac
|
||||||
|
local panel_tunnel_ip=""
|
||||||
|
panel_tunnel_ip="$(python3 - "${SERVER_WG_IPV4_ADDRESS:-}" "${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
iface = (sys.argv[1] or "").strip()
|
||||||
|
network = (sys.argv[2] or "10.25.0.0/16").strip()
|
||||||
|
if iface:
|
||||||
|
try:
|
||||||
|
print(ipaddress.ip_interface(iface).ip)
|
||||||
|
raise SystemExit(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
net = ipaddress.ip_network(network, strict=False)
|
||||||
|
print(next(net.hosts()))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
[[ -n "$panel_tunnel_ip" ]] && echo "$panel_tunnel_ip" > "$pkg_root/panel_tunnel_ip"
|
||||||
|
echo "$panel_port" > "$pkg_root/panel_port"
|
||||||
|
echo "$panel_scheme" > "$pkg_root/panel_scheme"
|
||||||
|
echo "${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}" > "$pkg_root/panel_url"
|
||||||
|
printf 'http://%s:8444 %s://%s:%s %s://%s:%s\n' \
|
||||||
|
"$panel_tunnel_ip" "$panel_scheme" "$panel_tunnel_ip" "$panel_port" \
|
||||||
|
"$panel_scheme" "$SERVER_PUBLIC_IP_V4" "$panel_port" > "$pkg_root/panel_urls"
|
||||||
cat > "$pkg_root/failover.conf" <<FAILEOF
|
cat > "$pkg_root/failover.conf" <<FAILEOF
|
||||||
# Phobos Failover Configuration
|
# Phobos Failover Configuration
|
||||||
SERVER_1=${SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORTS:-51821,51822,51823}
|
SERVER_1=${CLIENT_ENDPOINT:-$SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORTS:-51821,51822,51823}
|
||||||
KEY_1=${OBFUSCATOR_KEY}
|
KEY_1=${OBFUSCATOR_KEY:-}
|
||||||
FAILEOF
|
FAILEOF
|
||||||
|
|
||||||
find "$pkg_root" -type f ! -path "*/bin/*" -exec sed -i 's/\r$//' {} \;
|
find "$pkg_root" -type f ! -path "*/bin/*" -exec sed -i 's/\r$//' {} \;
|
||||||
@@ -355,8 +388,13 @@ action_link() {
|
|||||||
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
|
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
|
||||||
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
|
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
|
||||||
fi
|
fi
|
||||||
panel_port="${panel_port:-80}"
|
panel_port="${panel_port:-8443}"
|
||||||
local primary_url="http://${SERVER_PUBLIC_IP_V4}:${panel_port}/packages/$token/phobos-$id.tar.gz"
|
local panel_scheme="${PANEL_SCHEME:-}"
|
||||||
|
if [[ -z "$panel_scheme" && -f /opt/phobos-panel/.scheme ]]; then
|
||||||
|
panel_scheme=$(cat /opt/phobos-panel/.scheme 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
case "$panel_scheme" in http|https) ;; *) panel_scheme="http";; esac
|
||||||
|
local primary_url="${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}/packages/$token/phobos-$id.tar.gz"
|
||||||
local fallback_url="http://${SERVER_PUBLIC_IP_V4}:80/packages/$token/phobos-$id.tar.gz"
|
local fallback_url="http://${SERVER_PUBLIC_IP_V4}:80/packages/$token/phobos-$id.tar.gz"
|
||||||
|
|
||||||
cat > "$WWW_DIR/init/$token.sh" <<EOF
|
cat > "$WWW_DIR/init/$token.sh" <<EOF
|
||||||
@@ -366,12 +404,12 @@ dir="/tmp/phobos_install_\$\$"
|
|||||||
mkdir -p "\$dir"
|
mkdir -p "\$dir"
|
||||||
echo "Downloading..."
|
echo "Downloading..."
|
||||||
ok=0
|
ok=0
|
||||||
for url in \$urls; do
|
for url in \$urls; do
|
||||||
rm -f "\$dir/package.tar.gz"
|
rm -f "\$dir/package.tar.gz"
|
||||||
if command -v curl >/dev/null; then
|
if command -v curl >/dev/null; then
|
||||||
curl -L -s -o "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
|
curl -k -L -s -o "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
|
||||||
else
|
else
|
||||||
wget -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
|
wget --no-check-certificate -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || wget -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
|
||||||
fi
|
fi
|
||||||
if grep -qi '<html' "\$dir/package.tar.gz" 2>/dev/null; then
|
if grep -qi '<html' "\$dir/package.tar.gz" 2>/dev/null; then
|
||||||
echo "Bad response from \$url (HTML/proxy page), trying next..."
|
echo "Bad response from \$url (HTML/proxy page), trying next..."
|
||||||
@@ -399,9 +437,9 @@ EOF
|
|||||||
chmod 755 "$PACKAGES_DIR" 2>/dev/null
|
chmod 755 "$PACKAGES_DIR" 2>/dev/null
|
||||||
chmod 644 "$PACKAGES_DIR/phobos-$id.tar.gz" 2>/dev/null
|
chmod 644 "$PACKAGES_DIR/phobos-$id.tar.gz" 2>/dev/null
|
||||||
|
|
||||||
local init_primary="http://${SERVER_PUBLIC_IP_V4}:${panel_port}/init/$token.sh"
|
local init_primary="${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}/init/$token.sh"
|
||||||
local init_fallback="http://${SERVER_PUBLIC_IP_V4}:80/init/$token.sh"
|
local init_fallback="http://${SERVER_PUBLIC_IP_V4}:80/init/$token.sh"
|
||||||
local cmd="tmp=/tmp/phobos-init-\$\$.sh; ok=0; for u in $init_primary $init_fallback; do rm -f \$tmp; (wget -q -O \$tmp \$u || curl -fsSL -o \$tmp \$u) 2>/dev/null || continue; grep -qi '<html' \$tmp && continue; head -1 \$tmp | grep -q '^#!' || continue; sh \$tmp; ok=1; break; done; rm -f \$tmp; [ \$ok -eq 1 ] || echo 'ERROR: install script download failed (got HTML/proxy page or timeout)'"
|
local cmd="p=/tmp/phobos-init-\$\$.sh; ok=0; for u in $init_primary $init_fallback; 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"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|||||||
@@ -24,14 +24,18 @@ PHOBOS_DIR="/opt/etc/Phobos"
|
|||||||
CONF="$PHOBOS_DIR/failover.conf"
|
CONF="$PHOBOS_DIR/failover.conf"
|
||||||
HEALTH="$PHOBOS_DIR/phobos-health.sh"
|
HEALTH="$PHOBOS_DIR/phobos-health.sh"
|
||||||
LOG="$PHOBOS_DIR/health.log"
|
LOG="$PHOBOS_DIR/health.log"
|
||||||
# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 is the wg0 of whatever
|
# Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
|
||||||
# server the tunnel currently terminates on, so management rides the same
|
# whatever server the tunnel currently terminates on, so management rides the
|
||||||
# obfuscated channel as data — survives a public-IP ban and follows failover.
|
# same obfuscated channel as data — survives a public-IP ban and follows
|
||||||
# - 10.25.0.1:8444 -> secondary server agent (phobos-api)
|
# failover.
|
||||||
# - 10.25.0.1:10514 -> primary server panel
|
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
|
||||||
|
# - panel_tunnel_ip:<panel_port> -> primary server panel
|
||||||
# - public panel IP -> bootstrap / if tunnel is down
|
# - public panel IP -> bootstrap / if tunnel is down
|
||||||
PANEL="${PANEL:-http://212.118.52.193:10514}"
|
PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$PHOBOS_DIR/panel_tunnel_ip" 2>/dev/null)}"
|
||||||
PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}"
|
[ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
|
||||||
|
PANEL="${PANEL:-$(cat "$PHOBOS_DIR/panel_url" 2>/dev/null)}"
|
||||||
|
PANEL_URLS="${PANEL_URLS:-$(cat "$PHOBOS_DIR/panel_urls" 2>/dev/null)}"
|
||||||
|
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
|
||||||
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
|
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
|
||||||
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
|
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
|
||||||
TMP="/tmp/failover.conf.pull"
|
TMP="/tmp/failover.conf.pull"
|
||||||
@@ -50,21 +54,24 @@ trap 'rm -f "$LOCK"' EXIT
|
|||||||
[ -z "$TOKEN" ] && exit 0
|
[ -z "$TOKEN" ] && exit 0
|
||||||
|
|
||||||
detect_tunnel_iface() {
|
detect_tunnel_iface() {
|
||||||
ip -o addr show 2>/dev/null | awk '$4 ~ /^10\.25\./ {print $2; exit}'
|
ip route get "$PANEL_TUNNEL_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch_config() {
|
fetch_config() {
|
||||||
base="$1"
|
base="$1"
|
||||||
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
|
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
|
||||||
case "$base" in
|
case "$base" in
|
||||||
http://10.25.0.1:*|https://10.25.0.1:*)
|
"http://${PANEL_TUNNEL_IP}:"*|"https://${PANEL_TUNNEL_IP}:"*)
|
||||||
iface=$(detect_tunnel_iface)
|
iface=$(detect_tunnel_iface)
|
||||||
if [ -n "$iface" ] && command -v curl >/dev/null; then
|
if [ -n "$iface" ] && command -v curl >/dev/null; then
|
||||||
curl --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
curl -k --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
curl -s -m 8 -o "$TMP" "$url" 2>/dev/null
|
if command -v curl >/dev/null; then
|
||||||
|
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
|
fi
|
||||||
|
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
||||||
@@ -95,6 +102,40 @@ do_pull() {
|
|||||||
if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then
|
if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then
|
||||||
sh "$HEALTH" apply-server 1
|
sh "$HEALTH" apply-server 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
apply_obf_params
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply obfuscator params (masking/max-dummy/idle) delivered in failover.conf.
|
||||||
|
apply_obf_params() {
|
||||||
|
M=$(grep "^OBF_MASKING=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
|
[ -z "$M" ] && return 0
|
||||||
|
D=$(grep "^OBF_MAX_DUMMY=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
|
I=$(grep "^OBF_IDLE=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
|
changed=0
|
||||||
|
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; do
|
||||||
|
[ -f "$OBF" ] || continue
|
||||||
|
cur=$(grep "^masking = " "$OBF" 2>/dev/null | sed 's/masking = //')
|
||||||
|
[ "$cur" != "$M" ] && { sed -i "s/^masking = .*/masking = $M/" "$OBF"; changed=1; }
|
||||||
|
if [ -n "$D" ]; then
|
||||||
|
cur=$(grep "^max-dummy = " "$OBF" 2>/dev/null | sed 's/max-dummy = //')
|
||||||
|
[ "$cur" != "$D" ] && { sed -i "s/^max-dummy = .*/max-dummy = $D/" "$OBF"; changed=1; }
|
||||||
|
fi
|
||||||
|
if [ -n "$I" ]; then
|
||||||
|
cur=$(grep "^idle-timeout = " "$OBF" 2>/dev/null | sed 's/idle-timeout = //')
|
||||||
|
[ "$cur" != "$I" ] && { sed -i "s/^idle-timeout = .*/idle-timeout = $I/" "$OBF"; changed=1; }
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$changed" = 0 ] && return 0
|
||||||
|
log "PULL: obf params applied (masking=$M dummy=${D:-} idle=${I:-}), restart obfuscator"
|
||||||
|
if ls /opt/etc/init.d/S*obfuscator* >/dev/null 2>&1; then
|
||||||
|
for sc in /opt/etc/init.d/S*obfuscator*; do [ -x "$sc" ] && "$sc" restart >/dev/null 2>&1; done
|
||||||
|
elif command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl restart 'wg-obfuscator*' >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
killall wg-obfuscator 2>/dev/null; sleep 1
|
||||||
|
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; do [ -f "$OBF" ] && wg-obfuscator --config "$OBF" >/dev/null 2>&1 & done
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Inner loop: cron fires this every 60s, but we poll ~4x per minute so a
|
# Inner loop: cron fires this every 60s, but we poll ~4x per minute so a
|
||||||
|
|||||||
67
overlay/phobos-ttyd.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ============================================================
|
||||||
|
# PCA Phobos — ttyd web-terminal на роутере Keenetic
|
||||||
|
#
|
||||||
|
# phobos-ttyd.sh <client_id> <token> [server_tunnel_ip]
|
||||||
|
#
|
||||||
|
# Ставит ttyd (web pty), открывает порт на WG-интерфейсе ТОЛЬКО для панели
|
||||||
|
# (server tunnel IP), создаёт init-сервис (переживает перезагрузку).
|
||||||
|
# Панель проксирует терминал к роутеру через WG-туннель (за NAT, без SSH).
|
||||||
|
# ============================================================
|
||||||
|
CID="${1:?usage: phobos-ttyd.sh <client_id> <token> [server_ip]}"
|
||||||
|
TOKEN="${2:?token required}"
|
||||||
|
SRV_IP="${3:-$(cat /opt/etc/Phobos/panel_tunnel_ip 2>/dev/null)}"
|
||||||
|
[ -n "$SRV_IP" ] || SRV_IP="10.25.0.1"
|
||||||
|
PORT="${TTYD_PORT:-7681}"
|
||||||
|
SHELL_BIN="$( [ -x /opt/bin/bash ] && echo /opt/bin/bash || echo sh )"
|
||||||
|
|
||||||
|
# WG tunnel netdev (best effort: interface holding RFC1918 or explicit panel route)
|
||||||
|
WGDEV=$(ip route get "$SRV_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
||||||
|
[ -z "$WGDEV" ] && WGDEV=nwg3
|
||||||
|
|
||||||
|
opkg update >/dev/null 2>&1
|
||||||
|
opkg install ttyd >/dev/null 2>&1
|
||||||
|
opkg install iptables >/dev/null 2>&1
|
||||||
|
TTYD_BIN="$(command -v ttyd || echo /opt/bin/ttyd)"
|
||||||
|
[ -x "$TTYD_BIN" ] || { echo "ERROR: ttyd not installed"; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p /opt/etc/Phobos
|
||||||
|
echo "$TOKEN" > /opt/etc/Phobos/ttyd_token
|
||||||
|
chmod 600 /opt/etc/Phobos/ttyd_token
|
||||||
|
|
||||||
|
# init.d service: opens firewall for panel + runs ttyd (base-path + basic auth)
|
||||||
|
cat > /opt/etc/init.d/S51phobos-ttyd <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
PORT=$PORT
|
||||||
|
WGDEV=$WGDEV
|
||||||
|
SRV_IP=$SRV_IP
|
||||||
|
CID=$CID
|
||||||
|
TTYD=$TTYD_BIN
|
||||||
|
SH=$SHELL_BIN
|
||||||
|
TOKEN=\$(cat /opt/etc/Phobos/ttyd_token 2>/dev/null)
|
||||||
|
start() {
|
||||||
|
# firewall: allow ONLY the panel (server tunnel IP) to reach ttyd over WG
|
||||||
|
iptables -D INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT 2>/dev/null
|
||||||
|
iptables -I INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT
|
||||||
|
pkill -f "ttyd -p \$PORT" 2>/dev/null; sleep 1
|
||||||
|
# busybox has no setsid/nohup; plain background detaches fine on Entware.
|
||||||
|
# no -i flag => ttyd binds all interfaces (0.0.0.0).
|
||||||
|
"\$TTYD" -p "\$PORT" -b "/ttyd/\$CID" -c "phobos:\$TOKEN" --writable "\$SH" >/tmp/phobos-ttyd.log 2>&1 &
|
||||||
|
sleep 1
|
||||||
|
echo "ttyd started on \$PORT (base /ttyd/\$CID, panel-only via \$SRV_IP)"
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
pkill -f "ttyd -p \$PORT" 2>/dev/null
|
||||||
|
iptables -D INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT 2>/dev/null
|
||||||
|
}
|
||||||
|
case "\$1" in
|
||||||
|
start) start ;;
|
||||||
|
stop) stop ;;
|
||||||
|
restart) stop; sleep 1; start ;;
|
||||||
|
*) echo "usage: \$0 {start|stop|restart}" ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x /opt/etc/init.d/S51phobos-ttyd
|
||||||
|
/opt/etc/init.d/S51phobos-ttyd restart
|
||||||
|
|
||||||
|
echo "OK: ttyd на роутере, base-path /ttyd/$CID, порт $PORT (только для $SRV_IP по $WGDEV)."
|
||||||
@@ -60,7 +60,7 @@ Options:
|
|||||||
Example:
|
Example:
|
||||||
$0 --client-name Pegacomp \\
|
$0 --client-name Pegacomp \\
|
||||||
--client-private-key "ABCD..." \\
|
--client-private-key "ABCD..." \\
|
||||||
--client-ip 10.25.0.4 \\
|
--client-ip 10.26.0.4 \\
|
||||||
--client-ipv6 fd00:10:25::4 \\
|
--client-ipv6 fd00:10:25::4 \\
|
||||||
--server-public-key "EFGH..." \\
|
--server-public-key "EFGH..." \\
|
||||||
--endpoint-port 13255 \\
|
--endpoint-port 13255 \\
|
||||||
@@ -195,6 +195,7 @@ configure_wireguard_interface() {
|
|||||||
local description="Phobos-${CLIENT_NAME}"
|
local description="Phobos-${CLIENT_NAME}"
|
||||||
local client_ip_addr=$(echo "${CLIENT_IP}" | cut -d'/' -f1)
|
local client_ip_addr=$(echo "${CLIENT_IP}" | cut -d'/' -f1)
|
||||||
local client_ipv6_block="${CLIENT_IPV6}"
|
local client_ipv6_block="${CLIENT_IPV6}"
|
||||||
|
local route_target=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0/16
|
||||||
|
|
||||||
log "Настройка интерфейса ${interface_name}..."
|
log "Настройка интерфейса ${interface_name}..."
|
||||||
|
|
||||||
@@ -275,6 +276,31 @@ EOF
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Настраиваем статический маршрут в созданный интерфейс отдельным вызовом RCI
|
||||||
|
local route_target_net=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0
|
||||||
|
log "Настройка маршрута для подсети туннеля (${route_target_net}/16) -> ${interface_name}..."
|
||||||
|
local route_json=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"network": "${route_target_net}",
|
||||||
|
"mask": "255.255.0.0",
|
||||||
|
"interface": "${interface_name}",
|
||||||
|
"auto": true
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
local route_result=$(echo "${route_json}" | curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @- \
|
||||||
|
"http://localhost:79/rci/ip/route/" 2>/dev/null)
|
||||||
|
|
||||||
|
if echo "${route_result}" | jq -e '.status[] | select(.status == "error")' >/dev/null 2>&1; then
|
||||||
|
local error_msg=$(echo "${route_result}" | jq -r '.status[] | select(.status == "error") | .message // "Unknown error"' 2>/dev/null)
|
||||||
|
error "RCI API отклонил настройку маршрута: ${error_msg}"
|
||||||
|
log "JSON запрос:"
|
||||||
|
log "${route_json}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
log "Интерфейс ${interface_name} создан ✓"
|
log "Интерфейс ${interface_name} создан ✓"
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Wants=wg-quick@wg0.service
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/opt/phobos-panel
|
WorkingDirectory=/opt/phobos-panel
|
||||||
ExecStart=/usr/bin/gunicorn -w 1 -b 0.0.0.0:8443 app:app
|
ExecStart=/usr/bin/gunicorn -w 1 --threads 20 -b 0.0.0.0:8443 app:app
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|||||||
143
server/api.py
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Phobos Secondary Server API — peer management + health."""
|
"""Phobos Secondary Server API — peer management + health."""
|
||||||
import json, os, subprocess
|
import json, os, subprocess, time, hmac
|
||||||
|
from pathlib import Path
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -17,7 +18,50 @@ def load_env():
|
|||||||
def check_api_key():
|
def check_api_key():
|
||||||
env = load_env()
|
env = load_env()
|
||||||
key = request.headers.get("X-API-Key", "")
|
key = request.headers.get("X-API-Key", "")
|
||||||
return key == env.get("MAIN_API_KEY", "")
|
return hmac.compare_digest(key, env.get("MAIN_API_KEY", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def default_iface():
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["ip", "route", "get", "8.8.8.8"], text=True, timeout=4)
|
||||||
|
parts = out.split()
|
||||||
|
for i, part in enumerate(parts[:-1]):
|
||||||
|
if part == "dev":
|
||||||
|
return parts[i + 1].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_percent():
|
||||||
|
try:
|
||||||
|
with open("/proc/stat") as fh:
|
||||||
|
a = fh.readline().split()[1:]
|
||||||
|
time.sleep(0.1)
|
||||||
|
with open("/proc/stat") as fh:
|
||||||
|
b = fh.readline().split()[1:]
|
||||||
|
a = [int(x) for x in a]
|
||||||
|
b = [int(x) for x in b]
|
||||||
|
idle = b[3] - a[3]
|
||||||
|
total = sum(b) - sum(a)
|
||||||
|
if total <= 0:
|
||||||
|
return "?"
|
||||||
|
return f"{round(100 - (idle * 100.0 / total), 1)}%"
|
||||||
|
except Exception:
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
def net_bytes():
|
||||||
|
try:
|
||||||
|
iface = default_iface()
|
||||||
|
with open("/proc/net/dev") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.split(":")[0].strip() == iface:
|
||||||
|
p = line.split(":")[1].split()
|
||||||
|
return {"iface": iface, "rx": int(p[0]), "tx": int(p[8]), "ts": int(time.time())}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/health")
|
@app.route("/api/health")
|
||||||
def health():
|
def health():
|
||||||
@@ -39,18 +83,45 @@ def health():
|
|||||||
peers = 0
|
peers = 0
|
||||||
peer_keys = []
|
peer_keys = []
|
||||||
handshakes = {}
|
handshakes = {}
|
||||||
|
cpu = cpu_percent()
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output("top -bn1 | grep Cpu", shell=True, text=True, timeout=5)
|
parts = Path("/proc/meminfo").read_text().splitlines()
|
||||||
idle = float([x for x in out.split(",") if "id" in x][0].split()[0])
|
vals = {}
|
||||||
cpu = f"{round(100 - idle, 1)}%"
|
for line in parts:
|
||||||
except Exception:
|
k, v = line.split(":", 1)
|
||||||
cpu = "?"
|
vals[k] = int(v.strip().split()[0])
|
||||||
try:
|
total = vals.get("MemTotal", 0) // 1024
|
||||||
mem = subprocess.check_output("free -m", shell=True, text=True).split("\n")[1].split()
|
avail = vals.get("MemAvailable", 0) // 1024
|
||||||
mem_str = f"{mem[2]}/{mem[1]}MB"
|
used = max(total - avail, 0)
|
||||||
|
mem_str = f"{used}/{total}MB"
|
||||||
except Exception:
|
except Exception:
|
||||||
mem_str = "?"
|
mem_str = "?"
|
||||||
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str})
|
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/obf-health")
|
||||||
|
def obf_health():
|
||||||
|
"""Return 200 only when all local wg-obfuscator-* services are active."""
|
||||||
|
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:
|
||||||
|
env = load_env()
|
||||||
|
ports = [p.strip() for p in env.get("OBFUSCATOR_PORTS", "2083,5443,993").split(",") if p.strip()]
|
||||||
|
names = [f"wg-obfuscator-{p}.service" for p in ports]
|
||||||
|
bad = []
|
||||||
|
for name in names:
|
||||||
|
status = subprocess.getoutput("systemctl is-active " + name).strip()
|
||||||
|
if status != "active":
|
||||||
|
bad.append(name + "=" + status)
|
||||||
|
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("/api/peers", methods=["GET"])
|
@app.route("/api/peers", methods=["GET"])
|
||||||
def list_peers():
|
def list_peers():
|
||||||
@@ -67,11 +138,54 @@ def list_peers():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
LIMIT_SCRIPT = "/opt/Phobos/server/phobos-limit.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_script(path):
|
||||||
|
"""Fetch a helper script from the main panel if this agent predates it."""
|
||||||
|
if os.path.exists(path):
|
||||||
|
return
|
||||||
|
env = load_env()
|
||||||
|
url = "%s://%s:%s/server/%s" % (env.get("MAIN_PROTO", "http"), env.get("MAIN_SERVER", ""),
|
||||||
|
env.get("MAIN_PORT", ""), os.path.basename(path))
|
||||||
|
subprocess.run(["curl", "-kfsSL", "-m", "20", url, "-o", path], timeout=30)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.chmod(path, 0o755)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/speedtest", methods=["POST"])
|
||||||
|
def speedtest():
|
||||||
|
if not check_api_key():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
path = "/opt/Phobos/server/phobos-speedtest.sh"
|
||||||
|
ensure_script(path)
|
||||||
|
try:
|
||||||
|
r = subprocess.run([path], capture_output=True, text=True, timeout=80)
|
||||||
|
return jsonify(json.loads(r.stdout.strip().splitlines()[-1]))
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/limits", methods=["POST"])
|
||||||
|
def set_limits():
|
||||||
|
"""Per-client speed limits pushed by the main panel ("<tunnel_ip> <mbit>" lines)."""
|
||||||
|
if not check_api_key():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
text = (request.get_json(force=True, silent=True) or {}).get("limits", "")
|
||||||
|
Path("/opt/Phobos/server/limits.conf").write_text(text)
|
||||||
|
ensure_script(LIMIT_SCRIPT)
|
||||||
|
try:
|
||||||
|
r = subprocess.run([LIMIT_SCRIPT, "apply"], capture_output=True, text=True, timeout=30)
|
||||||
|
return jsonify({"status": "ok" if r.returncode == 0 else "error", "out": (r.stdout + r.stderr)[-500:]})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@app.route("/api/peers/add", methods=["POST"])
|
@app.route("/api/peers/add", methods=["POST"])
|
||||||
def add_peer():
|
def add_peer():
|
||||||
if not check_api_key():
|
if not check_api_key():
|
||||||
return jsonify({"error": "unauthorized"}), 401
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
data = request.json
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
pub_key = data.get("public_key", "")
|
pub_key = data.get("public_key", "")
|
||||||
allowed_ips = data.get("allowed_ips", "")
|
allowed_ips = data.get("allowed_ips", "")
|
||||||
if not pub_key or not allowed_ips:
|
if not pub_key or not allowed_ips:
|
||||||
@@ -87,7 +201,8 @@ def add_peer():
|
|||||||
def remove_peer():
|
def remove_peer():
|
||||||
if not check_api_key():
|
if not check_api_key():
|
||||||
return jsonify({"error": "unauthorized"}), 401
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
pub_key = request.json.get("public_key", "")
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
pub_key = data.get("public_key", "")
|
||||||
if not pub_key:
|
if not pub_key:
|
||||||
return jsonify({"error": "missing public_key"}), 400
|
return jsonify({"error": "missing public_key"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -116,7 +231,7 @@ ROUTER_CONFIGS_DIR = "/opt/Phobos/server/router-configs"
|
|||||||
@app.route("/api/router-config/<client_id>")
|
@app.route("/api/router-config/<client_id>")
|
||||||
def router_config(client_id):
|
def router_config(client_id):
|
||||||
"""Serve a client's failover.conf so routers can PULL via the tunnel
|
"""Serve a client's failover.conf so routers can PULL via the tunnel
|
||||||
(http://10.25.0.1:8444/...). Token via ?token= or X-API-Key."""
|
(http://<server-tunnel-ip>:8444/...). Token via ?token= or X-API-Key."""
|
||||||
token = request.args.get("token", "") or request.headers.get("X-API-Key", "")
|
token = request.args.get("token", "") or request.headers.get("X-API-Key", "")
|
||||||
if token != load_env().get("MAIN_API_KEY", ""):
|
if token != load_env().get("MAIN_API_KEY", ""):
|
||||||
return ("forbidden", 403)
|
return ("forbidden", 403)
|
||||||
|
|||||||
93
server/phobos-backup.sh
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# PCA Phobos — полный бэкап / восстановление сервера
|
||||||
|
#
|
||||||
|
# phobos-backup create # создать бэкап -> /opt/Phobos/backups/*.tar.gz
|
||||||
|
# phobos-backup restore <file> # восстановить из бэкапа
|
||||||
|
# phobos-backup list # список бэкапов
|
||||||
|
#
|
||||||
|
# В бэкап входит ВСЁ состояние (НЕ бинарники/пакеты):
|
||||||
|
# WireGuard ключ сервера (wg0.conf), server.env (OBF/WG ключи),
|
||||||
|
# обфускатор-конфиги, все клиенты+ключи, токены, settings.json панели
|
||||||
|
# (router_access, метки, назначения, токен подписчика, версия/канал/порт).
|
||||||
|
#
|
||||||
|
# ⚠ Бэкап содержит ПРИВАТНЫЕ КЛЮЧИ — храни в защищённом месте.
|
||||||
|
#
|
||||||
|
# Disaster recovery на чистом VPS:
|
||||||
|
# 1) поставить turnkey: bash <(curl -fsSL .../install.sh) (ставит бинарники/сервисы)
|
||||||
|
# 2) залить бэкап и: phobos-backup restore phobos-backup-*.tar.gz
|
||||||
|
# Ключ сервера восстановится — клиенты переподключатся без пересоздания.
|
||||||
|
# ============================================================
|
||||||
|
set -u
|
||||||
|
PHOBOS_DIR=/opt/Phobos
|
||||||
|
PANEL_DIR=/opt/phobos-panel
|
||||||
|
BACKUP_DIR="$PHOBOS_DIR/backups"
|
||||||
|
|
||||||
|
panel_service_name() {
|
||||||
|
for name in phobos-dev-test phobos-panel; do
|
||||||
|
systemctl status "$name" >/dev/null 2>&1 && { echo "$name"; return; }
|
||||||
|
done
|
||||||
|
echo phobos-panel
|
||||||
|
}
|
||||||
|
|
||||||
|
PATHS=(
|
||||||
|
/etc/wireguard/wg0.conf
|
||||||
|
"$PHOBOS_DIR/server/server.env"
|
||||||
|
"$PHOBOS_DIR/clients"
|
||||||
|
"$PHOBOS_DIR/tokens"
|
||||||
|
"$PHOBOS_DIR/server/warp"
|
||||||
|
"$PANEL_DIR/settings.json"
|
||||||
|
"$PANEL_DIR/servers.json"
|
||||||
|
"$PANEL_DIR/.version"
|
||||||
|
"$PANEL_DIR/.channel"
|
||||||
|
"$PANEL_DIR/.port"
|
||||||
|
"$PANEL_DIR/.scheme"
|
||||||
|
"$PANEL_DIR/.gh_token"
|
||||||
|
)
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
create)
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
OUT="$BACKUP_DIR/phobos-backup-$(hostname -s 2>/dev/null || echo srv)-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||||
|
# -P: absolute paths (restore lands in original locations). obfuscator confs by glob.
|
||||||
|
EXIST=()
|
||||||
|
for p in "${PATHS[@]}"; do [ -e "$p" ] && EXIST+=("$p"); done
|
||||||
|
for f in "$PHOBOS_DIR"/server/wg-obfuscator-*.conf; do [ -e "$f" ] && EXIST+=("$f"); done
|
||||||
|
tar -czf "$OUT" -P --ignore-failed-read "${EXIST[@]}" 2>/dev/null
|
||||||
|
chmod 600 "$OUT"
|
||||||
|
echo "$OUT"
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
FILE="${2:-}"
|
||||||
|
[ -f "$FILE" ] || { echo "Файл не найден: $FILE"; exit 1; }
|
||||||
|
# sanity: must be a phobos backup (contains server.env or wg0.conf)
|
||||||
|
if ! tar -tzf "$FILE" 2>/dev/null | grep -qE 'server\.env|wg0\.conf'; then
|
||||||
|
echo "Не похоже на бэкап PCA Phobos (нет server.env/wg0.conf). Отмена."; exit 1
|
||||||
|
fi
|
||||||
|
# safety snapshot before overwrite
|
||||||
|
PRE="$BACKUP_DIR/pre-restore-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
PEX=()
|
||||||
|
for p in "${PATHS[@]}"; do [ -e "$p" ] && PEX+=("$p"); done
|
||||||
|
for f in "$PHOBOS_DIR"/server/wg-obfuscator-*.conf; do [ -e "$f" ] && PEX+=("$f"); done
|
||||||
|
[ ${#PEX[@]} -gt 0 ] && tar -czf "$PRE" -P --ignore-failed-read "${PEX[@]}" 2>/dev/null && echo "Снимок до восстановления: $PRE"
|
||||||
|
tar -xzf "$FILE" -P
|
||||||
|
chmod 600 /etc/wireguard/wg0.conf 2>/dev/null || true
|
||||||
|
systemctl restart wg-quick@wg0 2>/dev/null || true
|
||||||
|
for s in $(systemctl list-units --all --plain --no-legend 'wg-obfuscator-*' 2>/dev/null | awk '{print $1}'); do
|
||||||
|
systemctl restart "$s" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
systemctl restart "$(panel_service_name)" 2>/dev/null || true
|
||||||
|
echo "Восстановлено из $FILE. Сервисы перезапущены."
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
echo "Бэкапы в $BACKUP_DIR:"
|
||||||
|
ls -lht "$BACKUP_DIR"/phobos-backup-*.tar.gz 2>/dev/null | awk '{print " "$9" ("$5")"}' || echo " нет"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "phobos-backup — бэкап/восстановление сервера PCA Phobos"
|
||||||
|
echo " create создать бэкап"
|
||||||
|
echo " restore <file> восстановить"
|
||||||
|
echo " list список бэкапов"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
93
server/phobos-cascade.sh
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PCA Phobos — cascade relay. Run on an ENTRY VPS (e.g. in Russia, "white" IP):
|
||||||
|
# clients connect to this server, UDP obfuscator ports are forwarded to the EXIT
|
||||||
|
# Phobos server abroad. The entry box needs no Phobos, no keys, no panel.
|
||||||
|
# bash <(curl -fsSL .../server/phobos-cascade.sh) enable <exit_host> [ports]
|
||||||
|
# phobos-cascade.sh disable | status | apply
|
||||||
|
# Then in the exit server panel: Settings -> "Адрес для клиентов" -> entry IP/domain.
|
||||||
|
set -uo pipefail
|
||||||
|
STATE=/etc/phobos-cascade.env
|
||||||
|
BIN=/usr/local/sbin/phobos-cascade
|
||||||
|
SELF_URL="${SELF_URL:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/phobos-cascade.sh}"
|
||||||
|
TAG="phobos-cascade"
|
||||||
|
|
||||||
|
clear_rules() {
|
||||||
|
local t
|
||||||
|
for t in nat filter; do
|
||||||
|
iptables -t $t -S 2>/dev/null | grep -- "--comment $TAG" | sed 's/^-A /-D /' | \
|
||||||
|
while read -r rule; do eval iptables -t $t $rule 2>/dev/null; done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
apply() {
|
||||||
|
[ -f "$STATE" ] || { echo "cascade: not configured"; return 0; }
|
||||||
|
. "$STATE"
|
||||||
|
local ip p
|
||||||
|
ip=$(getent ahostsv4 "$EXIT_HOST" | awk 'NR==1{print $1}')
|
||||||
|
[ -n "$ip" ] || { echo "cascade: cannot resolve $EXIT_HOST" >&2; return 1; }
|
||||||
|
sysctl -qw net.ipv4.ip_forward=1
|
||||||
|
clear_rules
|
||||||
|
for p in ${PORTS//,/ }; do
|
||||||
|
iptables -t nat -A PREROUTING -p udp --dport "$p" -m comment --comment $TAG -j DNAT --to-destination "$ip:$p"
|
||||||
|
iptables -t nat -A POSTROUTING -p udp -d "$ip" --dport "$p" -m comment --comment $TAG -j MASQUERADE
|
||||||
|
iptables -I FORWARD -p udp -d "$ip" --dport "$p" -m comment --comment $TAG -j ACCEPT
|
||||||
|
iptables -I FORWARD -p udp -s "$ip" --sport "$p" -m comment --comment $TAG -j ACCEPT
|
||||||
|
done
|
||||||
|
echo "cascade: udp ${PORTS} -> ${EXIT_HOST} (${ip})"
|
||||||
|
}
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
local host="${1:-}" ports="${2:-2083,5443,993}"
|
||||||
|
[ -n "$host" ] || { echo "usage: $0 enable <exit_host> [ports]" >&2; exit 2; }
|
||||||
|
[[ "$ports" =~ ^[0-9]+(,[0-9]+)*$ ]] || { echo "bad ports: $ports" >&2; exit 2; }
|
||||||
|
command -v iptables >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq iptables >/dev/null
|
||||||
|
printf 'EXIT_HOST=%s\nPORTS=%s\n' "$host" "$ports" > "$STATE"
|
||||||
|
# bash <(curl ...) gives a pipe as $0 -> fetch a real copy for the systemd unit
|
||||||
|
if [ "$(readlink -f "$0")" != "$BIN" ]; then
|
||||||
|
if [ -f "$0" ]; then cp "$0" "$BIN"; else curl -fsSL -m30 "$SELF_URL" -o "$BIN" || { echo "cannot save $BIN" >&2; exit 1; }; fi
|
||||||
|
chmod +x "$BIN"
|
||||||
|
fi
|
||||||
|
cat > /etc/systemd/system/phobos-cascade.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=PCA Phobos cascade relay
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=$BIN apply
|
||||||
|
ExecStop=$BIN stop
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable phobos-cascade.service >/dev/null 2>&1
|
||||||
|
systemctl restart phobos-cascade.service
|
||||||
|
apply >/dev/null
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
systemctl disable --now phobos-cascade.service >/dev/null 2>&1 || true
|
||||||
|
clear_rules
|
||||||
|
rm -f /etc/systemd/system/phobos-cascade.service "$STATE"
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo "cascade: disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
local host="" ports="" rules
|
||||||
|
[ -f "$STATE" ] && . "$STATE" && host="$EXIT_HOST" && ports="$PORTS"
|
||||||
|
rules=$(iptables -t nat -S PREROUTING 2>/dev/null | grep -c -- "--comment $TAG")
|
||||||
|
printf '{"enabled": %s, "exit_host": "%s", "ports": "%s", "rules": %s}\n' \
|
||||||
|
"$([ "$rules" -gt 0 ] && echo true || echo false)" "$host" "$ports" "$rules"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
enable) enable "${2:-}" "${3:-}" ;;
|
||||||
|
apply) apply ;;
|
||||||
|
stop) clear_rules ;;
|
||||||
|
disable) disable ;;
|
||||||
|
status) status ;;
|
||||||
|
*) echo "usage: $0 enable <exit_host> [ports]|disable|status"; exit 2 ;;
|
||||||
|
esac
|
||||||
105
server/phobos-domain.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PCA Phobos — panel domain: https://<domain>/ via nginx + Let's Encrypt.
|
||||||
|
# The panel keeps listening on its old http port, so routers/secondaries that
|
||||||
|
# already know http://IP:PORT keep working; the domain is an extra entrance.
|
||||||
|
# phobos-domain.sh set <domain> [email] (SELF_SIGNED=1 for LAN tests)
|
||||||
|
# phobos-domain.sh remove | status
|
||||||
|
set -uo pipefail
|
||||||
|
PANEL_DIR=/opt/phobos-panel
|
||||||
|
SITE=/etc/nginx/sites-available/phobos-domain
|
||||||
|
WEBROOT=/var/www/phobos-acme
|
||||||
|
SS_DIR=/etc/phobos-domain
|
||||||
|
|
||||||
|
panel_port() { cat "$PANEL_DIR/.port" 2>/dev/null || echo 8443; }
|
||||||
|
panel_scheme() { cat "$PANEL_DIR/.scheme" 2>/dev/null || echo http; }
|
||||||
|
|
||||||
|
write_site() { # $1=domain $2=cert $3=key ($2 empty -> http only, for ACME)
|
||||||
|
local d="$1" up
|
||||||
|
up="$(panel_scheme)://127.0.0.1:$(panel_port)"
|
||||||
|
{
|
||||||
|
echo "server {"
|
||||||
|
echo " listen 80;"
|
||||||
|
echo " listen [::]:80;"
|
||||||
|
echo " server_name $d;"
|
||||||
|
echo " location /.well-known/acme-challenge/ { root $WEBROOT; }"
|
||||||
|
[ -n "$2" ] && echo " location / { return 301 https://\$host\$request_uri; }"
|
||||||
|
echo "}"
|
||||||
|
if [ -n "$2" ]; then
|
||||||
|
cat <<EOF
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name $d;
|
||||||
|
ssl_certificate $2;
|
||||||
|
ssl_certificate_key $3;
|
||||||
|
client_max_body_size 64m;
|
||||||
|
location / {
|
||||||
|
proxy_pass $up;
|
||||||
|
proxy_ssl_verify off;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Forwarded-For \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
} > "$SITE"
|
||||||
|
ln -sf "$SITE" /etc/nginx/sites-enabled/phobos-domain
|
||||||
|
# nginx < 1.25 has no "http2 on;" -> fall back to the old listen flag
|
||||||
|
nginx -t >/dev/null 2>&1 || sed -i -e '/http2 on;/d' -e 's/listen 443 ssl;/listen 443 ssl http2;/' -e 's/listen \[::\]:443 ssl;/listen [::]:443 ssl http2;/' "$SITE"
|
||||||
|
nginx -t >/dev/null 2>&1 || { nginx -t; return 1; }
|
||||||
|
systemctl reload nginx
|
||||||
|
}
|
||||||
|
|
||||||
|
set_domain() {
|
||||||
|
local d="${1:-}" email="${2:-}" cert key
|
||||||
|
[[ "$d" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$ ]] || { echo "bad domain: $d" >&2; exit 2; }
|
||||||
|
command -v nginx >/dev/null || { echo "nginx not installed" >&2; exit 1; }
|
||||||
|
mkdir -p "$WEBROOT"
|
||||||
|
write_site "$d" "" "" || exit 1
|
||||||
|
if [ "${SELF_SIGNED:-0}" = 1 ]; then
|
||||||
|
mkdir -p "$SS_DIR"
|
||||||
|
openssl req -x509 -nodes -newkey rsa:2048 -days 825 -subj "/CN=$d" \
|
||||||
|
-keyout "$SS_DIR/$d.key" -out "$SS_DIR/$d.crt" >/dev/null 2>&1
|
||||||
|
cert="$SS_DIR/$d.crt"; key="$SS_DIR/$d.key"
|
||||||
|
else
|
||||||
|
command -v certbot >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq certbot >/dev/null
|
||||||
|
local mail_args=(--register-unsafely-without-email)
|
||||||
|
[ -n "$email" ] && mail_args=(-m "$email")
|
||||||
|
certbot certonly --webroot -w "$WEBROOT" -d "$d" --agree-tos -n "${mail_args[@]}" \
|
||||||
|
--deploy-hook "systemctl reload nginx" || {
|
||||||
|
echo "certbot failed: check that $d points to this server and port 80 is open" >&2
|
||||||
|
rm -f /etc/nginx/sites-enabled/phobos-domain; systemctl reload nginx; exit 1; }
|
||||||
|
cert="/etc/letsencrypt/live/$d/fullchain.pem"; key="/etc/letsencrypt/live/$d/privkey.pem"
|
||||||
|
fi
|
||||||
|
write_site "$d" "$cert" "$key" || exit 1
|
||||||
|
echo "$d" > "$PANEL_DIR/.domain"
|
||||||
|
echo "domain: https://$d/ -> panel $(panel_scheme)://127.0.0.1:$(panel_port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
rm -f /etc/nginx/sites-enabled/phobos-domain "$SITE" "$PANEL_DIR/.domain"
|
||||||
|
nginx -t >/dev/null 2>&1 && systemctl reload nginx
|
||||||
|
echo "domain: removed (certificate kept in /etc/letsencrypt)"
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
local d exp=""
|
||||||
|
d=$(cat "$PANEL_DIR/.domain" 2>/dev/null)
|
||||||
|
if [ -n "$d" ]; then
|
||||||
|
exp=$(echo | timeout 5 openssl s_client -connect 127.0.0.1:443 -servername "$d" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||||
|
fi
|
||||||
|
printf '{"domain": "%s", "cert_expires": "%s"}\n' "$d" "$exp"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
set) set_domain "${2:-}" "${3:-}" ;;
|
||||||
|
remove) remove ;;
|
||||||
|
status) status ;;
|
||||||
|
*) echo "usage: $0 set <domain> [email]|remove|status"; exit 2 ;;
|
||||||
|
esac
|
||||||
113
server/phobos-guard.sh
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PCA Phobos — guard: drop RKN probes / scanner subnets before they reach the server.
|
||||||
|
# Idea and IP list: github.com/Loorrr293/Auto_IPtables (list: Loorrr293/blocklist).
|
||||||
|
# We only download the IP list (data), never third-party code.
|
||||||
|
# phobos-guard.sh enable [list_url] install nft table + daily timer
|
||||||
|
# phobos-guard.sh update refresh the list now
|
||||||
|
# phobos-guard.sh disable remove table + timer
|
||||||
|
# phobos-guard.sh status JSON
|
||||||
|
set -uo pipefail
|
||||||
|
STATE=/etc/phobos-guard.env
|
||||||
|
DEFAULT_URL="https://raw.githubusercontent.com/Loorrr293/blocklist/main/blocklist.txt"
|
||||||
|
TABLE="inet phobos_guard"
|
||||||
|
SELF="$(readlink -f "$0")"
|
||||||
|
|
||||||
|
list_url() { [ -f "$STATE" ] && . "$STATE"; echo "${GUARD_URL:-$DEFAULT_URL}"; }
|
||||||
|
|
||||||
|
ensure_nft() {
|
||||||
|
command -v nft >/dev/null 2>&1 && return 0
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nftables >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
update_list() {
|
||||||
|
local url tmp nf
|
||||||
|
url="$(list_url)"
|
||||||
|
tmp="$(mktemp)"; nf="$(mktemp)"
|
||||||
|
curl -fsSL -m60 "$url" -o "$tmp" || { rm -f "$tmp" "$nf"; echo "download failed: $url" >&2; return 1; }
|
||||||
|
nft add table $TABLE 2>/dev/null || true
|
||||||
|
nft add set $TABLE v4 '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null || true
|
||||||
|
nft add set $TABLE v6 '{ type ipv6_addr; flags interval; auto-merge; }' 2>/dev/null || true
|
||||||
|
nft add chain $TABLE input '{ type filter hook input priority raw; policy accept; }' 2>/dev/null || true
|
||||||
|
nft list chain $TABLE input | grep -q '@v4' || nft add rule $TABLE input ip saddr @v4 counter drop
|
||||||
|
nft list chain $TABLE input | grep -q '@v6' || nft add rule $TABLE input ip6 saddr @v6 counter drop
|
||||||
|
# collapse + skip private/loopback so a bad list can never cut LAN/tunnel/admin
|
||||||
|
python3 - "$tmp" > "$nf" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
v4, v6 = [], []
|
||||||
|
for line in open(sys.argv[1], encoding="utf-8", errors="ignore"):
|
||||||
|
for tok in line.split("#", 1)[0].split():
|
||||||
|
try:
|
||||||
|
n = ipaddress.ip_network(tok, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if n.is_private or n.is_loopback or n.is_link_local or n.prefixlen == 0:
|
||||||
|
continue
|
||||||
|
(v6 if n.version == 6 else v4).append(n)
|
||||||
|
print("flush set inet phobos_guard v4")
|
||||||
|
print("flush set inet phobos_guard v6")
|
||||||
|
for name, nets in (("v4", v4), ("v6", v6)):
|
||||||
|
nets = list(ipaddress.collapse_addresses(nets))
|
||||||
|
for i in range(0, len(nets), 2000):
|
||||||
|
print("add element inet phobos_guard %s { %s }" % (name, ", ".join(map(str, nets[i:i + 2000]))))
|
||||||
|
PY
|
||||||
|
local rc=0
|
||||||
|
nft -f "$nf" || rc=1
|
||||||
|
rm -f "$tmp" "$nf"
|
||||||
|
[ "$rc" = 0 ] && echo "guard: list loaded from $url"
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
ensure_nft || { echo "nftables install failed" >&2; exit 1; }
|
||||||
|
[ -n "${1:-}" ] && echo "GUARD_URL=$1" > "$STATE" || touch "$STATE"
|
||||||
|
cat > /etc/systemd/system/phobos-guard.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=PCA Phobos guard (RKN scanner blocklist)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=$SELF update
|
||||||
|
EOF
|
||||||
|
cat > /etc/systemd/system/phobos-guard.timer <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Refresh PCA Phobos guard list daily
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
OnCalendar=daily
|
||||||
|
RandomizedDelaySec=1h
|
||||||
|
Persistent=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now phobos-guard.timer >/dev/null 2>&1
|
||||||
|
update_list
|
||||||
|
}
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
systemctl disable --now phobos-guard.timer >/dev/null 2>&1 || true
|
||||||
|
rm -f /etc/systemd/system/phobos-guard.service /etc/systemd/system/phobos-guard.timer "$STATE"
|
||||||
|
systemctl daemon-reload
|
||||||
|
nft delete table $TABLE 2>/dev/null || true
|
||||||
|
echo "guard: disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
local on=false n4=0 drops=0
|
||||||
|
if nft list table $TABLE >/dev/null 2>&1; then
|
||||||
|
on=true
|
||||||
|
n4=$(nft list set $TABLE v4 2>/dev/null | grep -o '[0-9.]*/[0-9]*\|[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*' | wc -l)
|
||||||
|
drops=$(nft list chain $TABLE input 2>/dev/null | grep -o 'packets [0-9]*' | awk '{s+=$2} END {print s+0}')
|
||||||
|
fi
|
||||||
|
printf '{"enabled": %s, "networks": %s, "dropped_packets": %s, "timer": "%s", "url": "%s"}\n' \
|
||||||
|
"$on" "$n4" "$drops" "$(systemctl is-active phobos-guard.timer 2>/dev/null)" "$(list_url)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
enable) enable "${2:-}" ;;
|
||||||
|
update) update_list ;;
|
||||||
|
disable) disable ;;
|
||||||
|
status) status ;;
|
||||||
|
*) echo "usage: $0 enable [url]|update|disable|status"; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -25,7 +25,10 @@ PRIMARY_CHECK_INTERVAL=300
|
|||||||
# wg-obfuscator-* services are active — the bare panel port stays up even
|
# wg-obfuscator-* services are active — the bare panel port stays up even
|
||||||
# when the tunnel path is dead, so we must NOT switch back on panel liveness
|
# when the tunnel path is dead, so we must NOT switch back on panel liveness
|
||||||
# alone (that caused premature switchback to a dead primary).
|
# alone (that caused premature switchback to a dead primary).
|
||||||
PRIMARY_PROBE_PORT=10514
|
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$PHOBOS_DIR/panel_scheme" 2>/dev/null)}"
|
||||||
|
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
|
||||||
|
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$PHOBOS_DIR/panel_port" 2>/dev/null)}"
|
||||||
|
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
|
||||||
PRIMARY_PROBE_PATH="/api/obf-health"
|
PRIMARY_PROBE_PATH="/api/obf-health"
|
||||||
# Connectivity check targets (logged for context only)
|
# Connectivity check targets (logged for context only)
|
||||||
CHECK_HOST_1="8.8.8.8"
|
CHECK_HOST_1="8.8.8.8"
|
||||||
@@ -274,13 +277,22 @@ check_primary() {
|
|||||||
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
|
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
|
||||||
# obfuscator path is actually up. Anything else (000 no-response, 503
|
# obfuscator path is actually up. Anything else (000 no-response, 503
|
||||||
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
|
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
|
||||||
code=$(curl -s -m 4 -o /dev/null -w '%{http_code}' "http://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" 2>/dev/null)
|
ok_url=""
|
||||||
if [ "$code" = "200" ]; then
|
last_code=""
|
||||||
log "PRIMARY ($host obf-health http=200) alive, switching back"
|
for url in "${PRIMARY_PROBE_SCHEME}://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" "http://${host}:8444${PRIMARY_PROBE_PATH}"; do
|
||||||
|
code=$(curl -k -s -m 4 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
|
||||||
|
last_code="$code"
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
ok_url="$url"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$ok_url" ]; then
|
||||||
|
log "PRIMARY ($ok_url http=200) alive, switching back"
|
||||||
switch_endpoint 1 0
|
switch_endpoint 1 0
|
||||||
echo "0" > "$STATE/fail_count"
|
echo "0" > "$STATE/fail_count"
|
||||||
else
|
else
|
||||||
log "PRIMARY still down (obf-health http=${code:-none}), staying on server $CURRENT_SERVER"
|
log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
50
server/phobos-limit.sh
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PCA Phobos — per-client speed limit on wg0 (tc).
|
||||||
|
# limits file: one line per client "<tunnel_ip> <mbit>" (download = upload = mbit)
|
||||||
|
# phobos-limit.sh apply [file] rebuild tc from file (default below)
|
||||||
|
# phobos-limit.sh clear | status
|
||||||
|
set -uo pipefail
|
||||||
|
DEV="${WG_DEV:-wg0}"
|
||||||
|
FILE="${2:-/opt/Phobos/server/limits.conf}"
|
||||||
|
|
||||||
|
clear_tc() {
|
||||||
|
tc qdisc del dev "$DEV" root 2>/dev/null
|
||||||
|
tc qdisc del dev "$DEV" ingress 2>/dev/null
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
apply() {
|
||||||
|
ip link show "$DEV" >/dev/null 2>&1 || { echo "limit: $DEV is down"; return 0; }
|
||||||
|
clear_tc
|
||||||
|
[ -s "$FILE" ] || { echo "limit: no limits"; return 0; }
|
||||||
|
tc qdisc add dev "$DEV" root handle 1: htb default 9999 r2q 1000
|
||||||
|
tc class add dev "$DEV" parent 1: classid 1:9999 htb rate 10gbit 2>/dev/null
|
||||||
|
tc qdisc add dev "$DEV" handle ffff: ingress
|
||||||
|
local n=10 ip mbit count=0
|
||||||
|
while read -r ip mbit _; do
|
||||||
|
[[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ && "$mbit" =~ ^[0-9]+$ && "$mbit" -gt 0 ]] || continue
|
||||||
|
# download (server -> client): shaped, packets queue instead of dropping
|
||||||
|
tc class add dev "$DEV" parent 1: classid 1:$n htb rate "${mbit}mbit" ceil "${mbit}mbit" burst 64k
|
||||||
|
tc qdisc add dev "$DEV" parent 1:$n fq_codel 2>/dev/null
|
||||||
|
tc filter add dev "$DEV" parent 1: protocol ip prio 1 u32 match ip dst "$ip/32" flowid 1:$n
|
||||||
|
# upload (client -> server): policed on ingress
|
||||||
|
tc filter add dev "$DEV" parent ffff: protocol ip prio 1 u32 match ip src "$ip/32" \
|
||||||
|
police rate "${mbit}mbit" burst $(( mbit * 64 + 256 ))k drop flowid :1
|
||||||
|
n=$((n + 1)); count=$((count + 1))
|
||||||
|
done < "$FILE"
|
||||||
|
echo "limit: $count client(s) limited on $DEV"
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
local cnt
|
||||||
|
cnt=$(tc class show dev "$DEV" 2>/dev/null | grep -c 'htb 1:[1-9][0-9]* ' )
|
||||||
|
cnt=$((cnt > 0 ? cnt - 1 : 0))
|
||||||
|
printf '{"device": "%s", "limited": %s, "file": "%s"}\n' "$DEV" "$cnt" "$FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
apply) apply ;;
|
||||||
|
clear) clear_tc; echo "limit: cleared" ;;
|
||||||
|
status) status ;;
|
||||||
|
*) echo "usage: $0 apply [file]|clear|status"; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -24,14 +24,18 @@ PHOBOS_DIR="/opt/etc/Phobos"
|
|||||||
CONF="$PHOBOS_DIR/failover.conf"
|
CONF="$PHOBOS_DIR/failover.conf"
|
||||||
HEALTH="$PHOBOS_DIR/phobos-health.sh"
|
HEALTH="$PHOBOS_DIR/phobos-health.sh"
|
||||||
LOG="$PHOBOS_DIR/health.log"
|
LOG="$PHOBOS_DIR/health.log"
|
||||||
# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 is the wg0 of whatever
|
# Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
|
||||||
# server the tunnel currently terminates on, so management rides the same
|
# whatever server the tunnel currently terminates on, so management rides the
|
||||||
# obfuscated channel as data — survives a public-IP ban and follows failover.
|
# same obfuscated channel as data — survives a public-IP ban and follows
|
||||||
# - 10.25.0.1:8444 -> secondary server agent (phobos-api)
|
# failover.
|
||||||
# - 10.25.0.1:10514 -> primary server panel
|
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
|
||||||
|
# - panel_tunnel_ip:<panel_port> -> primary server panel
|
||||||
# - public panel IP -> bootstrap / if tunnel is down
|
# - public panel IP -> bootstrap / if tunnel is down
|
||||||
PANEL="${PANEL:-http://212.118.52.193:10514}"
|
PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$PHOBOS_DIR/panel_tunnel_ip" 2>/dev/null)}"
|
||||||
PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}"
|
[ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
|
||||||
|
PANEL="${PANEL:-$(cat "$PHOBOS_DIR/panel_url" 2>/dev/null)}"
|
||||||
|
PANEL_URLS="${PANEL_URLS:-$(cat "$PHOBOS_DIR/panel_urls" 2>/dev/null)}"
|
||||||
|
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
|
||||||
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
|
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
|
||||||
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
|
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
|
||||||
TMP="/tmp/failover.conf.pull"
|
TMP="/tmp/failover.conf.pull"
|
||||||
@@ -50,21 +54,24 @@ trap 'rm -f "$LOCK"' EXIT
|
|||||||
[ -z "$TOKEN" ] && exit 0
|
[ -z "$TOKEN" ] && exit 0
|
||||||
|
|
||||||
detect_tunnel_iface() {
|
detect_tunnel_iface() {
|
||||||
ip -o addr show 2>/dev/null | awk '$4 ~ /^10\.25\./ {print $2; exit}'
|
ip route get "$PANEL_TUNNEL_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch_config() {
|
fetch_config() {
|
||||||
base="$1"
|
base="$1"
|
||||||
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
|
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
|
||||||
case "$base" in
|
case "$base" in
|
||||||
http://10.25.0.1:*|https://10.25.0.1:*)
|
"http://${PANEL_TUNNEL_IP}:"*|"https://${PANEL_TUNNEL_IP}:"*)
|
||||||
iface=$(detect_tunnel_iface)
|
iface=$(detect_tunnel_iface)
|
||||||
if [ -n "$iface" ] && command -v curl >/dev/null; then
|
if [ -n "$iface" ] && command -v curl >/dev/null; then
|
||||||
curl --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
curl -k --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
curl -s -m 8 -o "$TMP" "$url" 2>/dev/null
|
if command -v curl >/dev/null; then
|
||||||
|
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
|
fi
|
||||||
|
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
||||||
|
|||||||
0
server/phobos-self-check.sh
Normal file → Executable file
28
server/phobos-speedtest.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PCA Phobos — real server internet speed: download/upload actual bytes, print JSON.
|
||||||
|
# Cloudflare speed endpoints first, Hetzner/OVH files as download fallback.
|
||||||
|
# phobos-speedtest.sh [seconds]
|
||||||
|
set -uo pipefail
|
||||||
|
T="${1:-8}"
|
||||||
|
CF="https://speed.cloudflare.com"
|
||||||
|
|
||||||
|
down_mbit() { # $1=url -> Mbit/s
|
||||||
|
curl -s -o /dev/null -m "$T" -w '%{speed_download}' "$1" 2>/dev/null | awk '{printf "%.1f", $1*8/1e6}'
|
||||||
|
}
|
||||||
|
|
||||||
|
ping_ms() {
|
||||||
|
curl -s -o /dev/null -m 5 -w '%{time_connect}' "$CF/__down?bytes=0" 2>/dev/null | awk '{printf "%.0f", $1*1000}'
|
||||||
|
}
|
||||||
|
|
||||||
|
dl=$(down_mbit "$CF/__down?bytes=500000000")
|
||||||
|
src="cloudflare"
|
||||||
|
if [ -z "$dl" ] || awk "BEGIN{exit !($dl < 1)}"; then
|
||||||
|
for u in https://speed.hetzner.de/1GB.bin https://proof.ovh.net/files/1Gb.dat; do
|
||||||
|
dl=$(down_mbit "$u"); src="$u"
|
||||||
|
awk "BEGIN{exit !($dl >= 1)}" && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
ul=$(head -c 200000000 /dev/zero | curl -s -o /dev/null -m "$T" -w '%{speed_upload}' \
|
||||||
|
-X POST --data-binary @- "$CF/__up" 2>/dev/null | awk '{printf "%.1f", $1*8/1e6}')
|
||||||
|
printf '{"download_mbit": %s, "upload_mbit": %s, "ping_ms": %s, "source": "%s", "ts": %s}\n' \
|
||||||
|
"${dl:-0}" "${ul:-0}" "$(ping_ms || echo 0)" "$src" "$(date +%s)"
|
||||||
519
server/phobos-warp.sh
Executable file
@@ -0,0 +1,519 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVER_ENV="/opt/Phobos/server/server.env"
|
||||||
|
WARP_DIR="/opt/Phobos/server/warp"
|
||||||
|
WGCF_BIN="/usr/local/bin/wgcf"
|
||||||
|
WARP_IF="warp"
|
||||||
|
WARP_CONF="/etc/wireguard/${WARP_IF}.conf"
|
||||||
|
WARP_TABLE="51820"
|
||||||
|
WARP_RULE_PRIO="11930"
|
||||||
|
STATE_FILE="${WARP_DIR}/state.env"
|
||||||
|
FREE_PROFILE="${WARP_DIR}/wgcf-profile.conf"
|
||||||
|
CUSTOM_PROFILE="${WARP_DIR}/custom-profile.conf"
|
||||||
|
LICENSE_FILE="${WARP_DIR}/license.key"
|
||||||
|
RELAY_TAG_PREFIX="PHOBOS_WARP_RELAY"
|
||||||
|
|
||||||
|
load_env() {
|
||||||
|
if [ -f "$SERVER_ENV" ]; then
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
case "$line" in
|
||||||
|
''|\#*) continue ;;
|
||||||
|
esac
|
||||||
|
k="${line%%=*}"
|
||||||
|
v="${line#*=}"
|
||||||
|
case "$k" in
|
||||||
|
''|\#*) continue ;;
|
||||||
|
esac
|
||||||
|
export "$k=$v"
|
||||||
|
done < "$SERVER_ENV"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_state() {
|
||||||
|
STATE_MODE=""
|
||||||
|
STATE_PROFILE_SOURCE=""
|
||||||
|
STATE_RELAY_PORTS=""
|
||||||
|
STATE_RELAY_TARGET=""
|
||||||
|
STATE_RELAY_TARGET_IP=""
|
||||||
|
STATE_CLIENTS=""
|
||||||
|
if [ -f "$STATE_FILE" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$STATE_FILE"
|
||||||
|
STATE_MODE="${MODE:-}"
|
||||||
|
STATE_PROFILE_SOURCE="${PROFILE_SOURCE:-}"
|
||||||
|
STATE_RELAY_PORTS="${RELAY_PORTS:-}"
|
||||||
|
STATE_RELAY_TARGET="${RELAY_TARGET:-}"
|
||||||
|
STATE_RELAY_TARGET_IP="${RELAY_TARGET_IP:-}"
|
||||||
|
STATE_CLIENTS="${CLIENTS:-}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
save_state() {
|
||||||
|
mkdir -p "$WARP_DIR"
|
||||||
|
cat > "$STATE_FILE" <<EOF
|
||||||
|
MODE=${1:-}
|
||||||
|
PROFILE_SOURCE=${2:-}
|
||||||
|
RELAY_PORTS=${3:-}
|
||||||
|
RELAY_TARGET=${4:-}
|
||||||
|
RELAY_TARGET_IP=${5:-}
|
||||||
|
CLIENTS=${6:-}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
server_public_ip() {
|
||||||
|
if [ -n "${SERVER_PUBLIC_IP_V4:-}" ]; then
|
||||||
|
printf '%s\n' "$SERVER_PUBLIC_IP_V4"
|
||||||
|
else
|
||||||
|
curl -4 -fsS --max-time 6 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wg_subnet() {
|
||||||
|
if [ -n "${SERVER_WG_IPV4_NETWORK:-}" ]; then
|
||||||
|
printf '%s\n' "$SERVER_WG_IPV4_NETWORK"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ -f /etc/wireguard/wg0.conf ]; then
|
||||||
|
python3 - <<'PY' /etc/wireguard/wg0.conf
|
||||||
|
import ipaddress, re, sys
|
||||||
|
for raw in open(sys.argv[1], encoding="utf-8"):
|
||||||
|
m = re.match(r"^Address\s*=\s*(.+)$", raw.strip())
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
value = m.group(1).split(",", 1)[0].strip()
|
||||||
|
print(ipaddress.ip_interface(value).network)
|
||||||
|
break
|
||||||
|
PY
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
printf '10.25.0.0/16\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
arch_name() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) echo "amd64" ;;
|
||||||
|
aarch64|arm64) echo "arm64" ;;
|
||||||
|
armv7l) echo "armv7" ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_wgcf() {
|
||||||
|
if [ -x "$WGCF_BIN" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local arch version url
|
||||||
|
arch="$(arch_name)"
|
||||||
|
[ -n "$arch" ] || { echo "unsupported architecture: $(uname -m)"; exit 1; }
|
||||||
|
version="2.2.26"
|
||||||
|
url="https://github.com/ViRb3/wgcf/releases/download/v${version}/wgcf_${version}_linux_${arch}"
|
||||||
|
curl -fsSL "$url" -o "$WGCF_BIN"
|
||||||
|
chmod +x "$WGCF_BIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_free_profile() {
|
||||||
|
local license_key=""
|
||||||
|
mkdir -p "$WARP_DIR"
|
||||||
|
cd "$WARP_DIR"
|
||||||
|
ensure_wgcf
|
||||||
|
if [ ! -f "${WARP_DIR}/wgcf-account.toml" ]; then
|
||||||
|
"$WGCF_BIN" register --accept-tos >/tmp/phobos-warp-register.log 2>&1
|
||||||
|
fi
|
||||||
|
if [ -s "$LICENSE_FILE" ]; then
|
||||||
|
license_key="$(tr -d '\r\n' < "$LICENSE_FILE")"
|
||||||
|
if [ -n "$license_key" ]; then
|
||||||
|
"$WGCF_BIN" update --license "$license_key" >/tmp/phobos-warp-license.log 2>&1 || {
|
||||||
|
echo "failed to apply WARP+ license key" >&2
|
||||||
|
cat /tmp/phobos-warp-license.log >&2 || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
rm -f "$FREE_PROFILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ ! -f "$FREE_PROFILE" ] || [ ! -s "$FREE_PROFILE" ]; then
|
||||||
|
"$WGCF_BIN" generate >/tmp/phobos-warp-generate.log 2>&1 || {
|
||||||
|
echo "failed to generate wgcf profile" >&2
|
||||||
|
cat /tmp/phobos-warp-generate.log >&2 || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_custom_profile() {
|
||||||
|
[ -s "$CUSTOM_PROFILE" ] || { echo "custom profile not found: $CUSTOM_PROFILE" >&2; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
profile_path_for() {
|
||||||
|
case "${1:-free}" in
|
||||||
|
custom) ensure_custom_profile; printf '%s\n' "$CUSTOM_PROFILE" ;;
|
||||||
|
*) ensure_free_profile; printf '%s\n' "$FREE_PROFILE" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
profile_endpoint() {
|
||||||
|
awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$1" | tr -d ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_host_ipv4() {
|
||||||
|
getent ahostsv4 "$1" | awk 'NR==1{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
build_warp_conf() {
|
||||||
|
local profile_path subnet
|
||||||
|
profile_path="$1"
|
||||||
|
subnet="$(wg_subnet)"
|
||||||
|
python3 - "$profile_path" "$WARP_CONF" "$subnet" "$WARP_TABLE" "$WARP_RULE_PRIO" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
profile_path = Path(sys.argv[1])
|
||||||
|
out_path = Path(sys.argv[2])
|
||||||
|
subnet = sys.argv[3]
|
||||||
|
table = sys.argv[4]
|
||||||
|
prio = sys.argv[5]
|
||||||
|
|
||||||
|
section = None
|
||||||
|
data = {"Interface": {}, "Peer": {}}
|
||||||
|
for raw in profile_path.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.startswith("[") and line.endswith("]"):
|
||||||
|
section = line.strip("[]")
|
||||||
|
continue
|
||||||
|
if "=" not in line or section not in data:
|
||||||
|
continue
|
||||||
|
k, v = [x.strip() for x in line.split("=", 1)]
|
||||||
|
data[section][k] = v
|
||||||
|
|
||||||
|
iface = data["Interface"]
|
||||||
|
peer = data["Peer"]
|
||||||
|
|
||||||
|
post_up = (
|
||||||
|
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
|
||||||
|
f"ip route replace {subnet} dev wg0 table {table}; "
|
||||||
|
f"ip route replace default dev %i table {table}; "
|
||||||
|
"iptables -C FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || iptables -I FORWARD 1 -i wg0 -o %i -j ACCEPT; "
|
||||||
|
"iptables -C FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || "
|
||||||
|
"iptables -I FORWARD 1 -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; "
|
||||||
|
f"iptables -t nat -C POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || "
|
||||||
|
f"iptables -t nat -A POSTROUTING -s {subnet} -o %i -j MASQUERADE"
|
||||||
|
)
|
||||||
|
post_down = (
|
||||||
|
f"ip route del {subnet} dev wg0 table {table} 2>/dev/null || true; "
|
||||||
|
f"ip route del default dev %i table {table} 2>/dev/null || true; "
|
||||||
|
"iptables -D FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || true; "
|
||||||
|
"iptables -D FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true; "
|
||||||
|
f"iptables -t nat -D POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"[Interface]",
|
||||||
|
f"PrivateKey = {iface.get('PrivateKey', '')}",
|
||||||
|
f"Address = {iface.get('Address', '')}",
|
||||||
|
"MTU = 1280",
|
||||||
|
"Table = off",
|
||||||
|
f"PostUp = {post_up}",
|
||||||
|
f"PostDown = {post_down}",
|
||||||
|
"",
|
||||||
|
"[Peer]",
|
||||||
|
f"PublicKey = {peer.get('PublicKey', '')}",
|
||||||
|
f"AllowedIPs = {peer.get('AllowedIPs', '0.0.0.0/0, ::/0')}",
|
||||||
|
f"Endpoint = {peer.get('Endpoint', '')}",
|
||||||
|
f"PersistentKeepalive = {peer.get('PersistentKeepalive', '25')}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
out_path.write_text("\n".join(lines))
|
||||||
|
PY
|
||||||
|
chmod 600 "$WARP_CONF"
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_egress() {
|
||||||
|
systemctl disable --now "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
||||||
|
ip route del "$(wg_subnet)" dev wg0 table "$WARP_TABLE" 2>/dev/null || true
|
||||||
|
ip route del default dev "$WARP_IF" table "$WARP_TABLE" 2>/dev/null || true
|
||||||
|
iptables -D FORWARD -i wg0 -o "$WARP_IF" -j ACCEPT 2>/dev/null || true
|
||||||
|
iptables -D FORWARD -i "$WARP_IF" -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
||||||
|
iptables -t nat -D POSTROUTING -s "$(wg_subnet)" -o "$WARP_IF" -j MASQUERADE 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_legacy_subnet_rule() {
|
||||||
|
local subnet
|
||||||
|
subnet="$(wg_subnet)"
|
||||||
|
ip rule del from "$subnet" table "$WARP_TABLE" priority "$WARP_RULE_PRIO" 2>/dev/null || true
|
||||||
|
ip rule del from "$subnet" lookup "$WARP_TABLE" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
client_rule_prio() {
|
||||||
|
python3 - "$1" "$WARP_RULE_PRIO" <<'PY'
|
||||||
|
import sys
|
||||||
|
ip = sys.argv[1].split('/')[0].strip()
|
||||||
|
base = int(sys.argv[2])
|
||||||
|
parts = [int(x) for x in ip.split('.')]
|
||||||
|
value = ((parts[2] << 8) + parts[3]) % 5000
|
||||||
|
print(base + 1 + value)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_client_ip() {
|
||||||
|
printf '%s\n' "${1%%/*}" | tr -d ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
state_clients_csv_add() {
|
||||||
|
local ip csv
|
||||||
|
ip="$(normalize_client_ip "$1")"
|
||||||
|
csv="${2:-}"
|
||||||
|
python3 - "$ip" "$csv" <<'PY'
|
||||||
|
import sys
|
||||||
|
ip = sys.argv[1]
|
||||||
|
items = [x for x in sys.argv[2].split(',') if x]
|
||||||
|
if ip not in items:
|
||||||
|
items.append(ip)
|
||||||
|
print(",".join(items))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
state_clients_csv_del() {
|
||||||
|
local ip csv
|
||||||
|
ip="$(normalize_client_ip "$1")"
|
||||||
|
csv="${2:-}"
|
||||||
|
python3 - "$ip" "$csv" <<'PY'
|
||||||
|
import sys
|
||||||
|
ip = sys.argv[1]
|
||||||
|
items = [x for x in sys.argv[2].split(',') if x and x != ip]
|
||||||
|
print(",".join(items))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_client_rules() {
|
||||||
|
load_state
|
||||||
|
local ip prio
|
||||||
|
IFS=',' read -r -a __clients <<< "${STATE_CLIENTS:-}"
|
||||||
|
for ip in "${__clients[@]}"; do
|
||||||
|
[ -n "$ip" ] || continue
|
||||||
|
prio="$(client_rule_prio "$ip")"
|
||||||
|
ip rule del from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
reapply_client_rules() {
|
||||||
|
local csv="${1:-}" ip prio
|
||||||
|
IFS=',' read -r -a __clients <<< "$csv"
|
||||||
|
for ip in "${__clients[@]}"; do
|
||||||
|
[ -n "$ip" ] || continue
|
||||||
|
prio="$(client_rule_prio "$ip")"
|
||||||
|
ip rule add from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
relay_ports_array() {
|
||||||
|
local raw="${1:-4500}"
|
||||||
|
raw="${raw// /}"
|
||||||
|
IFS=',' read -r -a RELAY_PORTS_ARRAY <<< "$raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
relay_rule_tag() {
|
||||||
|
printf '%s:%s:%s:%s\n' "$RELAY_TAG_PREFIX" "$1" "$2" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_relay() {
|
||||||
|
load_state
|
||||||
|
local lines line cmd
|
||||||
|
lines="$(iptables-save -t nat 2>/dev/null | grep -F "$RELAY_TAG_PREFIX" || true)"
|
||||||
|
if [ -n "$lines" ]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
case "$line" in
|
||||||
|
-A\ *) cmd="iptables -t nat ${line/-A /-D }"; eval "$cmd" >/dev/null 2>&1 || true ;;
|
||||||
|
esac
|
||||||
|
done <<EOF
|
||||||
|
$lines
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
lines="$(iptables-save 2>/dev/null | grep -F "$RELAY_TAG_PREFIX" || true)"
|
||||||
|
if [ -n "$lines" ]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
case "$line" in
|
||||||
|
-A\ FORWARD\ *) cmd="iptables ${line/-A /-D }"; eval "$cmd" >/dev/null 2>&1 || true ;;
|
||||||
|
esac
|
||||||
|
done <<EOF
|
||||||
|
$lines
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_all() {
|
||||||
|
clear_legacy_subnet_rule
|
||||||
|
clear_client_rules
|
||||||
|
clear_egress
|
||||||
|
clear_relay
|
||||||
|
}
|
||||||
|
|
||||||
|
relay_enabled() {
|
||||||
|
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_egress() {
|
||||||
|
local profile_source profile_path existing_clients
|
||||||
|
load_env
|
||||||
|
load_state
|
||||||
|
existing_clients="${STATE_CLIENTS:-}"
|
||||||
|
profile_source="${1:-free}"
|
||||||
|
disable_all
|
||||||
|
profile_path="$(profile_path_for "$profile_source")"
|
||||||
|
build_warp_conf "$profile_path"
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||||
|
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
||||||
|
systemctl restart "wg-quick@${WARP_IF}"
|
||||||
|
clear_legacy_subnet_rule
|
||||||
|
reapply_client_rules "$existing_clients"
|
||||||
|
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
|
||||||
|
status_json
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_relay() {
|
||||||
|
local profile_source ports target profile_path endpoint host port_num target_ip server_ip port tag
|
||||||
|
load_env
|
||||||
|
profile_source="${1:-free}"
|
||||||
|
ports="${2:-4500}"
|
||||||
|
target="${3:-}"
|
||||||
|
disable_all
|
||||||
|
profile_path="$(profile_path_for "$profile_source")"
|
||||||
|
endpoint="${target:-$(profile_endpoint "$profile_path")}"
|
||||||
|
[ -n "$endpoint" ] || { echo "relay target endpoint is empty" >&2; exit 1; }
|
||||||
|
host="${endpoint%:*}"
|
||||||
|
port_num="${endpoint##*:}"
|
||||||
|
target_ip="$(resolve_host_ipv4 "$host")"
|
||||||
|
[ -n "$target_ip" ] || { echo "failed to resolve endpoint host: $host" >&2; exit 1; }
|
||||||
|
server_ip="$(server_public_ip)"
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||||
|
relay_ports_array "$ports"
|
||||||
|
for port in "${RELAY_PORTS_ARRAY[@]}"; do
|
||||||
|
[ -n "$port" ] || continue
|
||||||
|
tag="$(relay_rule_tag "$port" "$target_ip" "$port_num")"
|
||||||
|
iptables -t nat -C PREROUTING -d "$server_ip" -p udp --dport "$port" -m comment --comment "$tag" -j DNAT --to-destination "$target_ip:$port_num" 2>/dev/null \
|
||||||
|
|| iptables -t nat -A PREROUTING -d "$server_ip" -p udp --dport "$port" -m comment --comment "$tag" -j DNAT --to-destination "$target_ip:$port_num"
|
||||||
|
iptables -t nat -C POSTROUTING -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j MASQUERADE 2>/dev/null \
|
||||||
|
|| iptables -t nat -A POSTROUTING -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j MASQUERADE
|
||||||
|
iptables -C FORWARD -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j ACCEPT 2>/dev/null \
|
||||||
|
|| iptables -A FORWARD -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j ACCEPT
|
||||||
|
iptables -C FORWARD -p udp -s "$target_ip" --sport "$port_num" -m comment --comment "$tag" -j ACCEPT 2>/dev/null \
|
||||||
|
|| iptables -A FORWARD -p udp -s "$target_ip" --sport "$port_num" -m comment --comment "$tag" -j ACCEPT
|
||||||
|
done
|
||||||
|
save_state "relay" "$profile_source" "$ports" "$endpoint" "$target_ip" ""
|
||||||
|
status_json
|
||||||
|
}
|
||||||
|
|
||||||
|
client_enable() {
|
||||||
|
local ip prio updated
|
||||||
|
ip="$(normalize_client_ip "${1:-}")"
|
||||||
|
[ -n "$ip" ] || { echo "client ip required" >&2; exit 1; }
|
||||||
|
load_state
|
||||||
|
[ "${STATE_MODE:-}" = "egress" ] || { echo "WARP egress mode is not active" >&2; exit 1; }
|
||||||
|
systemctl is-active --quiet "wg-quick@${WARP_IF}" || { echo "WARP interface is not active" >&2; exit 1; }
|
||||||
|
prio="$(client_rule_prio "$ip")"
|
||||||
|
ip rule add from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
|
||||||
|
updated="$(state_clients_csv_add "$ip" "${STATE_CLIENTS:-}")"
|
||||||
|
save_state "${STATE_MODE:-egress}" "${STATE_PROFILE_SOURCE:-free}" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$updated"
|
||||||
|
status_json
|
||||||
|
}
|
||||||
|
|
||||||
|
client_disable() {
|
||||||
|
local ip prio updated
|
||||||
|
ip="$(normalize_client_ip "${1:-}")"
|
||||||
|
[ -n "$ip" ] || { echo "client ip required" >&2; exit 1; }
|
||||||
|
load_state
|
||||||
|
prio="$(client_rule_prio "$ip")"
|
||||||
|
ip rule del from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
|
||||||
|
updated="$(state_clients_csv_del "$ip" "${STATE_CLIENTS:-}")"
|
||||||
|
save_state "${STATE_MODE:-}" "${STATE_PROFILE_SOURCE:-}" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$updated"
|
||||||
|
status_json
|
||||||
|
}
|
||||||
|
|
||||||
|
status_json() {
|
||||||
|
load_env
|
||||||
|
load_state
|
||||||
|
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on
|
||||||
|
subnet="$(wg_subnet)"
|
||||||
|
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
|
||||||
|
mode="${STATE_MODE:-}"
|
||||||
|
profile_source="${STATE_PROFILE_SOURCE:-}"
|
||||||
|
configured=false
|
||||||
|
endpoint=""
|
||||||
|
pubkey=""
|
||||||
|
egress=""
|
||||||
|
relay_on=false
|
||||||
|
|
||||||
|
if [ "$service" = "active" ]; then
|
||||||
|
mode="${mode:-egress}"
|
||||||
|
configured=true
|
||||||
|
endpoint="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" 2>/dev/null || true)"
|
||||||
|
pubkey="$(wg show "${WARP_IF}" public-key 2>/dev/null || true)"
|
||||||
|
egress="$(curl -4 --interface "${WARP_IF}" -fsS --max-time 6 https://api.ipify.org 2>/dev/null || true)"
|
||||||
|
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if relay_enabled; then
|
||||||
|
relay_on=true
|
||||||
|
[ -n "$mode" ] || mode="relay"
|
||||||
|
configured=true
|
||||||
|
[ -n "$endpoint" ] || endpoint="${STATE_RELAY_TARGET:-}"
|
||||||
|
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
||||||
|
fi
|
||||||
|
|
||||||
|
enabled=false
|
||||||
|
if [ "$service" = "active" ] || [ "$relay_on" = true ]; then
|
||||||
|
enabled=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$enabled" "$configured" "$service" "$subnet" "$endpoint" "$pubkey" "$egress" "$mode" "$profile_source" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$( [ -s "$CUSTOM_PROFILE" ] && echo true || echo false )" "$relay_on" "$( [ -s "$LICENSE_FILE" ] && echo true || echo false )" "${STATE_CLIENTS:-}" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
enabled, configured, service, subnet, endpoint, pubkey, egress, mode, profile_source, relay_ports, relay_target, relay_target_ip, custom_saved, relay_on, license_saved, clients_csv = sys.argv[1:]
|
||||||
|
if relay_on == "true" and service != "active":
|
||||||
|
service = "relay"
|
||||||
|
print(json.dumps({
|
||||||
|
"enabled": enabled == "true",
|
||||||
|
"configured": configured == "true",
|
||||||
|
"service": service or "inactive",
|
||||||
|
"mode": mode or "off",
|
||||||
|
"profile_source": profile_source or "free",
|
||||||
|
"subnet": subnet,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"public_key": pubkey,
|
||||||
|
"egress_ipv4": egress,
|
||||||
|
"relay_enabled": relay_on == "true",
|
||||||
|
"relay_ports": relay_ports,
|
||||||
|
"relay_target": relay_target,
|
||||||
|
"relay_target_ip": relay_target_ip,
|
||||||
|
"clients": [x for x in clients_csv.split(",") if x],
|
||||||
|
"custom_profile_saved": custom_saved == "true",
|
||||||
|
"license_key_saved": license_saved == "true",
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_warp() {
|
||||||
|
disable_all
|
||||||
|
save_state "" "" "" "" "" ""
|
||||||
|
status_json
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
enable|up)
|
||||||
|
case "${2:-egress}" in
|
||||||
|
relay) enable_relay "${3:-free}" "${4:-4500}" "${5:-}" ;;
|
||||||
|
*) enable_egress "${3:-free}" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
client-enable) client_enable "${2:-}" ;;
|
||||||
|
client-disable) client_disable "${2:-}" ;;
|
||||||
|
disable|down) disable_warp ;;
|
||||||
|
status|check|--json) status_json ;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 {status|enable [egress|relay] [free|custom] [ports] [endpoint]|client-enable <ip>|client-disable <ip>|disable}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
# Deploys WG + obfuscator + mini-API (no web panel)
|
# Deploys WG + obfuscator + mini-API (no web panel)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# MAIN_SERVER=212.118.52.193 MAIN_PORT=10514 MAIN_API_KEY=secret123 \
|
# MAIN_PROTO=http MAIN_SERVER=203.0.113.10 MAIN_PORT=10514 MAIN_API_KEY=secret123 \
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)
|
# bash <(curl -fsSL http://203.0.113.10:10514/server/secondary-setup.sh)
|
||||||
#
|
#
|
||||||
# Uninstall:
|
# Uninstall:
|
||||||
# bash /opt/Phobos/server/phobos-secondary-uninstall.sh
|
# bash /opt/Phobos/server/phobos-secondary-uninstall.sh
|
||||||
@@ -15,13 +15,27 @@ set -e
|
|||||||
|
|
||||||
MAIN_SERVER="${MAIN_SERVER:?Set MAIN_SERVER=ip_of_main_server}"
|
MAIN_SERVER="${MAIN_SERVER:?Set MAIN_SERVER=ip_of_main_server}"
|
||||||
MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
|
MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
|
||||||
|
MAIN_PROTO="${MAIN_PROTO:-http}"
|
||||||
MAIN_PORT="${MAIN_PORT:-8443}"
|
MAIN_PORT="${MAIN_PORT:-8443}"
|
||||||
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
||||||
|
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||||
PHOBOS_DIR="/opt/Phobos"
|
PHOBOS_DIR="/opt/Phobos"
|
||||||
|
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
net = ipaddress.ip_network(sys.argv[1], strict=False)
|
||||||
|
print(next(net.hosts()))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
WG_PREFIXLEN="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
print(ipaddress.ip_network(sys.argv[1], strict=False).prefixlen)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
SERVER_IP=$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}')
|
SERVER_IP="${SERVER_IP:-$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}')}"
|
||||||
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
||||||
IFACE="${IFACE:-eth0}"
|
IFACE="${IFACE:-eth0}"
|
||||||
|
case "$MAIN_PROTO" in http|https) ;; *) echo "ERROR: MAIN_PROTO must be http or https"; exit 1;; esac
|
||||||
|
|
||||||
# ── Pre-flight safety checks ──
|
# ── Pre-flight safety checks ──
|
||||||
echo ""
|
echo ""
|
||||||
@@ -31,6 +45,7 @@ echo "╠═══════════════════════
|
|||||||
echo "║ Server IP : $SERVER_IP"
|
echo "║ Server IP : $SERVER_IP"
|
||||||
echo "║ Interface : $IFACE"
|
echo "║ Interface : $IFACE"
|
||||||
echo "║ Main server : $MAIN_SERVER"
|
echo "║ Main server : $MAIN_SERVER"
|
||||||
|
echo "║ Main proto : $MAIN_PROTO"
|
||||||
echo "║ Main port : $MAIN_PORT"
|
echo "║ Main port : $MAIN_PORT"
|
||||||
echo "║ OBF ports : $OBF_PORTS"
|
echo "║ OBF ports : $OBF_PORTS"
|
||||||
echo "╚══════════════════════════════════════════════════════╝"
|
echo "╚══════════════════════════════════════════════════════╝"
|
||||||
@@ -106,7 +121,7 @@ if [ "$WARNINGS" -gt 0 ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Found $WARNINGS warning(s). Existing configs will be preserved where possible."
|
echo "Found $WARNINGS warning(s). Existing configs will be preserved where possible."
|
||||||
echo "Press Enter to continue or Ctrl+C to abort..."
|
echo "Press Enter to continue or Ctrl+C to abort..."
|
||||||
read -r < /dev/tty 2>/dev/null || true
|
[ -t 0 ] && read -r < /dev/tty 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -128,7 +143,7 @@ fi
|
|||||||
# ── 1. Dependencies ──
|
# ── 1. Dependencies ──
|
||||||
echo "[1/7] Installing dependencies..."
|
echo "[1/7] Installing dependencies..."
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard jq curl python3 python3-flask gunicorn 2>/dev/null
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard wireguard-tools iptables jq curl git ca-certificates python3 python3-flask gunicorn 2>/dev/null
|
||||||
|
|
||||||
# ── 2. Phobos binaries ──
|
# ── 2. Phobos binaries ──
|
||||||
echo "[2/7] Getting Phobos binaries..."
|
echo "[2/7] Getting Phobos binaries..."
|
||||||
@@ -193,7 +208,7 @@ else
|
|||||||
|
|
||||||
cat > /etc/wireguard/wg0.conf << WGEOF
|
cat > /etc/wireguard/wg0.conf << WGEOF
|
||||||
[Interface]
|
[Interface]
|
||||||
Address = 10.25.0.1/16
|
Address = ${WG_SERVER_IPV4}/${WG_PREFIXLEN}
|
||||||
ListenPort = 51820
|
ListenPort = 51820
|
||||||
PrivateKey = $WG_PRIV
|
PrivateKey = $WG_PRIV
|
||||||
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
|
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
|
||||||
@@ -259,9 +274,12 @@ cat > "$PHOBOS_DIR/server/server.env" << EOF
|
|||||||
SERVER_WG_PRIVATE_KEY=$WG_PRIV
|
SERVER_WG_PRIVATE_KEY=$WG_PRIV
|
||||||
SERVER_WG_PUBLIC_KEY=$WG_PUB
|
SERVER_WG_PUBLIC_KEY=$WG_PUB
|
||||||
SERVER_PUBLIC_IP_V4=$SERVER_IP
|
SERVER_PUBLIC_IP_V4=$SERVER_IP
|
||||||
|
SERVER_WG_IPV4_NETWORK=$SERVER_WG_IPV4_NETWORK
|
||||||
|
SERVER_WG_IPV4_ADDRESS=${WG_SERVER_IPV4}/${WG_PREFIXLEN}
|
||||||
OBFUSCATOR_KEY=$OBF_KEY
|
OBFUSCATOR_KEY=$OBF_KEY
|
||||||
OBFUSCATOR_PORTS=$OBF_PORTS
|
OBFUSCATOR_PORTS=$OBF_PORTS
|
||||||
MAIN_SERVER=$MAIN_SERVER
|
MAIN_SERVER=$MAIN_SERVER
|
||||||
|
MAIN_PROTO=$MAIN_PROTO
|
||||||
MAIN_API_KEY=$MAIN_API_KEY
|
MAIN_API_KEY=$MAIN_API_KEY
|
||||||
MAIN_PORT=$MAIN_PORT
|
MAIN_PORT=$MAIN_PORT
|
||||||
ROLE=secondary
|
ROLE=secondary
|
||||||
@@ -269,13 +287,17 @@ EOF
|
|||||||
|
|
||||||
# ── 6. Mini-API ──
|
# ── 6. Mini-API ──
|
||||||
echo "[6/7] Setting up mini-API..."
|
echo "[6/7] Setting up mini-API..."
|
||||||
# Mini-API (fetched from PCA — includes /api/router-config for tunnel pull)
|
# Mini-API (fetched from the main panel first, then PCA GitHub fallback)
|
||||||
PCA_BRANCH="${PCA_BRANCH:-main}"
|
PCA_BRANCH="${PCA_BRANCH:-main}"
|
||||||
RAW="https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}"
|
PCA_REPO="${PCA_REPO:-andrey271192/PCA_Phobos}"
|
||||||
if [ -n "${GH_TOKEN:-}" ]; then
|
RAW="${PCA_RAW_BASE:-https://raw.githubusercontent.com/${PCA_REPO}/${PCA_BRANCH}}"
|
||||||
curl -fsSL -H "Authorization: token $GH_TOKEN" "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/server/api.py"
|
||||||
else
|
if ! curl -kfsSL "$PANEL_BOOTSTRAP" -o "$PHOBOS_DIR/server/api.py"; then
|
||||||
curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
if [ -n "${GH_TOKEN:-}" ]; then
|
||||||
|
curl -fsSL -H "Authorization: token $GH_TOKEN" "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
||||||
|
else
|
||||||
|
curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
[ -s "$PHOBOS_DIR/server/api.py" ] || { echo "ERROR: api.py fetch failed"; exit 1; }
|
[ -s "$PHOBOS_DIR/server/api.py" ] || { echo "ERROR: api.py fetch failed"; exit 1; }
|
||||||
|
|
||||||
@@ -322,7 +344,7 @@ echo ""
|
|||||||
echo "Backups (if any) in $PHOBOS_DIR/backup/ will be KEPT."
|
echo "Backups (if any) in $PHOBOS_DIR/backup/ will be KEPT."
|
||||||
echo ""
|
echo ""
|
||||||
echo "Press Enter to continue or Ctrl+C to abort..."
|
echo "Press Enter to continue or Ctrl+C to abort..."
|
||||||
read -r < /dev/tty 2>/dev/null || true
|
[ -t 0 ] && read -r < /dev/tty 2>/dev/null || true
|
||||||
|
|
||||||
echo "Stopping services..."
|
echo "Stopping services..."
|
||||||
# Stop obfuscator instances
|
# Stop obfuscator instances
|
||||||
@@ -398,7 +420,7 @@ REG_DATA=$(cat << EOF
|
|||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
curl -s -X POST "http://${MAIN_SERVER}:${MAIN_PORT}/api/servers/register" \
|
curl -ks -X POST "${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/api/servers/register" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-API-Key: ${MAIN_API_KEY}" \
|
-H "X-API-Key: ${MAIN_API_KEY}" \
|
||||||
-d "$REG_DATA" || echo " (Registration will be done manually via panel)"
|
-d "$REG_DATA" || echo " (Registration will be done manually via panel)"
|
||||||
|
|||||||
162
update.sh
@@ -25,12 +25,27 @@ PHOBOS_DIR=/opt/Phobos
|
|||||||
PANEL_DIR=/opt/phobos-panel
|
PANEL_DIR=/opt/phobos-panel
|
||||||
PUB_REPO=andrey271192/PCA_Phobos
|
PUB_REPO=andrey271192/PCA_Phobos
|
||||||
DEV_REPO=andrey271192/PCA_Phobos-dev
|
DEV_REPO=andrey271192/PCA_Phobos-dev
|
||||||
|
PHOBOSWG_APK_TAG="v1.3.0-beta"
|
||||||
|
PHOBOSWG_APK_SHA256="5399de7c576d56e491a01ab9b52d53c08d30a34d1156c1cb0f4520bf34821e02"
|
||||||
BACKUPS="$PHOBOS_DIR/updates"
|
BACKUPS="$PHOBOS_DIR/updates"
|
||||||
VERFILE="$PANEL_DIR/.version"
|
VERFILE="$PANEL_DIR/.version"
|
||||||
CUR=$(cat "$VERFILE" 2>/dev/null || echo "unknown")
|
CUR=$(cat "$VERFILE" 2>/dev/null || echo "unknown")
|
||||||
# Токен подписчика для приватных каналов: env > сохранённый файл
|
# Токен подписчика для приватных каналов: env > сохранённый файл
|
||||||
GH_TOKEN="${GH_TOKEN:-$(cat "$PANEL_DIR/.gh_token" 2>/dev/null || true)}"
|
GH_TOKEN="${GH_TOKEN:-$(cat "$PANEL_DIR/.gh_token" 2>/dev/null || true)}"
|
||||||
|
|
||||||
|
panel_service_name() {
|
||||||
|
for name in phobos-dev-test phobos-panel; do
|
||||||
|
systemctl status "$name" >/dev/null 2>&1 && { echo "$name"; return; }
|
||||||
|
done
|
||||||
|
echo phobos-panel
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_panel_service() {
|
||||||
|
local svc
|
||||||
|
svc="$(panel_service_name)"
|
||||||
|
systemctl restart "$svc" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
# repo for a ref/channel: beta/dev/*-beta/*-dev -> private, остальное -> public
|
# repo for a ref/channel: beta/dev/*-beta/*-dev -> private, остальное -> public
|
||||||
repo_for() { case "$1" in beta|dev|*-beta|*-dev) echo "$DEV_REPO";; *) echo "$PUB_REPO";; esac; }
|
repo_for() { case "$1" in beta|dev|*-beta|*-dev) echo "$DEV_REPO";; *) echo "$PUB_REPO";; esac; }
|
||||||
is_private() { case "$1" in beta|dev|*-beta|*-dev) return 0;; *) return 1;; esac; }
|
is_private() { case "$1" in beta|dev|*-beta|*-dev) return 0;; *) return 1;; esac; }
|
||||||
@@ -44,6 +59,60 @@ gh_curl() { # $1=ref, rest=curl args
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetch_repo_file() { # $1=ref $2=repo_path $3=output
|
||||||
|
local ref="$1" rp="$2" out="$3" repo
|
||||||
|
repo=$(repo_for "$ref")
|
||||||
|
if is_private "$ref" && [ -n "$GH_TOKEN" ]; then
|
||||||
|
curl -fsSL -m25 -H "Authorization: token $GH_TOKEN" \
|
||||||
|
-H "Accept: application/vnd.github.raw" \
|
||||||
|
"https://api.github.com/repos/$repo/contents/$rp?ref=$ref" -o "$out"
|
||||||
|
else
|
||||||
|
gh_curl "$ref" -fsSL -m25 \
|
||||||
|
"https://raw.githubusercontent.com/$repo/$ref/$rp?cb=$(date +%s)" -o "$out"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_repo_text() { # $1=ref $2=repo_path
|
||||||
|
local tmp rc
|
||||||
|
tmp=$(mktemp)
|
||||||
|
fetch_repo_file "$1" "$2" "$tmp"; rc=$?
|
||||||
|
if [ "$rc" = 0 ]; then cat "$tmp"; fi
|
||||||
|
rm -f "$tmp"
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_phoboswg_apk() { # $1=ref/channel
|
||||||
|
local ref="${1:-}"
|
||||||
|
is_private "$ref" || return 0
|
||||||
|
[ -n "$GH_TOKEN" ] || return 0
|
||||||
|
local apk="$PHOBOS_DIR/www/app/PhobosWG.apk"
|
||||||
|
mkdir -p "$PHOBOS_DIR/www/app"
|
||||||
|
local cur_sha
|
||||||
|
cur_sha="$(sha256sum "$apk" 2>/dev/null | awk '{print $1}')"
|
||||||
|
if [ "$cur_sha" = "$PHOBOSWG_APK_SHA256" ]; then
|
||||||
|
echo "[APK] PhobosWG.apk current"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "[APK] Refreshing PhobosWG.apk from release $PHOBOSWG_APK_TAG..."
|
||||||
|
local apk_api_url
|
||||||
|
apk_api_url="$(curl -fsSL -m15 -H "Authorization: token $GH_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$DEV_REPO/releases/tags/$PHOBOSWG_APK_TAG" 2>/dev/null \
|
||||||
|
| grep -B8 '"name": "PhobosWG.apk"' | grep '"url"' | head -1 | cut -d'"' -f4)"
|
||||||
|
if [ -n "$apk_api_url" ] && curl -fsSL -m120 -H "Authorization: token $GH_TOKEN" \
|
||||||
|
-H "Accept: application/octet-stream" "$apk_api_url" -o "$apk"; then
|
||||||
|
local new_sha
|
||||||
|
new_sha="$(sha256sum "$apk" 2>/dev/null | awk '{print $1}')"
|
||||||
|
if [ "$new_sha" = "$PHOBOSWG_APK_SHA256" ]; then
|
||||||
|
echo "[APK] Downloaded PhobosWG.apk: $(ls -lh "$apk" | awk '{print $5}')"
|
||||||
|
else
|
||||||
|
rm -f "$apk"
|
||||||
|
echo "[APK] SHA mismatch after download (non-fatal)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[APK] Download failed (non-fatal)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
managed_files() {
|
managed_files() {
|
||||||
cat <<LIST
|
cat <<LIST
|
||||||
$PANEL_DIR/app.py|app.py
|
$PANEL_DIR/app.py|app.py
|
||||||
@@ -56,12 +125,22 @@ $PHOBOS_DIR/repo/client/templates/install-wireguard.sh|overlay/install-wireguard
|
|||||||
$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh|overlay/router-configure-wireguard.sh
|
$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh|overlay/router-configure-wireguard.sh
|
||||||
$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh|overlay/router-configure-wireguard-openwrt.sh
|
$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh|overlay/router-configure-wireguard-openwrt.sh
|
||||||
$PHOBOS_DIR/repo/client/templates/phobos-pull.sh|overlay/phobos-pull.sh
|
$PHOBOS_DIR/repo/client/templates/phobos-pull.sh|overlay/phobos-pull.sh
|
||||||
|
$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh|overlay/phobos-ttyd.sh
|
||||||
$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh|overlay/phobos-uninstall.sh
|
$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh|overlay/phobos-uninstall.sh
|
||||||
$PHOBOS_DIR/repo/client/templates/3xui.sh|overlay/3xui.sh
|
$PHOBOS_DIR/repo/client/templates/3xui.sh|overlay/3xui.sh
|
||||||
|
$PHOBOS_DIR/server/phobos-warp.sh|server/phobos-warp.sh
|
||||||
$PHOBOS_DIR/server/phobos-health.sh|server/phobos-health.sh
|
$PHOBOS_DIR/server/phobos-health.sh|server/phobos-health.sh
|
||||||
$PHOBOS_DIR/server/phobos-self-check.sh|server/phobos-self-check.sh
|
$PHOBOS_DIR/server/phobos-self-check.sh|server/phobos-self-check.sh
|
||||||
$PHOBOS_DIR/server/phobos-pull.sh|server/phobos-pull.sh
|
$PHOBOS_DIR/server/phobos-pull.sh|server/phobos-pull.sh
|
||||||
$PHOBOS_DIR/server/phobos-router-watchdog.py|server/phobos-router-watchdog.py
|
$PHOBOS_DIR/server/phobos-router-watchdog.py|server/phobos-router-watchdog.py
|
||||||
|
$PHOBOS_DIR/server/api.py|server/api.py
|
||||||
|
$PHOBOS_DIR/server/secondary-setup.sh|server/secondary-setup.sh
|
||||||
|
$PHOBOS_DIR/server/phobos-backup.sh|server/phobos-backup.sh
|
||||||
|
$PHOBOS_DIR/server/phobos-guard.sh|server/phobos-guard.sh
|
||||||
|
$PHOBOS_DIR/server/phobos-cascade.sh|server/phobos-cascade.sh
|
||||||
|
$PHOBOS_DIR/server/phobos-domain.sh|server/phobos-domain.sh
|
||||||
|
$PHOBOS_DIR/server/phobos-limit.sh|server/phobos-limit.sh
|
||||||
|
$PHOBOS_DIR/server/phobos-speedtest.sh|server/phobos-speedtest.sh
|
||||||
$PHOBOS_DIR/server/update.sh|update.sh
|
$PHOBOS_DIR/server/update.sh|update.sh
|
||||||
LIST
|
LIST
|
||||||
}
|
}
|
||||||
@@ -73,13 +152,33 @@ run_self_check() {
|
|||||||
else
|
else
|
||||||
echo " WARN: phobos-self-check.sh не найден"
|
echo " WARN: phobos-self-check.sh не найден"
|
||||||
fi
|
fi
|
||||||
|
[ -x "$PHOBOS_DIR/server/phobos-backup.sh" ] && ln -sf "$PHOBOS_DIR/server/phobos-backup.sh" /usr/local/bin/phobos-backup 2>/dev/null || true
|
||||||
|
[ -x "$PHOBOS_DIR/server/phobos-warp.sh" ] && ln -sf "$PHOBOS_DIR/server/phobos-warp.sh" /usr/local/bin/phobos-warp 2>/dev/null || true
|
||||||
|
for _n in phobos-guard phobos-cascade phobos-domain; do [ -x "$PHOBOS_DIR/server/$_n.sh" ] && ln -sf "$PHOBOS_DIR/server/$_n.sh" /usr/local/bin/$_n 2>/dev/null || true; done
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_router_packages() {
|
||||||
|
local script="$PHOBOS_DIR/repo/server/scripts/phobos-client.sh"
|
||||||
|
[ -x "$script" ] || return 0
|
||||||
|
[ -d "$PHOBOS_DIR/clients" ] || return 0
|
||||||
|
local rebuilt=0 failed=0 id
|
||||||
|
for dir in "$PHOBOS_DIR/clients"/*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
id=$(basename "$dir")
|
||||||
|
if "$script" package "$id" >/dev/null 2>&1; then
|
||||||
|
rebuilt=$((rebuilt + 1))
|
||||||
|
else
|
||||||
|
failed=$((failed + 1))
|
||||||
|
echo " WARN: router package rebuild failed for $id"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$rebuilt" = 0 ] && [ "$failed" = 0 ] || echo "Router packages rebuilt: $rebuilt, failed: $failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
# fetch VERSION of a channel (public repos / or private if token present)
|
# fetch VERSION of a channel (public repos / or private if token present)
|
||||||
chan_version() { # $1 = ref(channel)
|
chan_version() { # $1 = ref(channel)
|
||||||
local repo; repo=$(repo_for "$1"); local repo_ref="$1"
|
|
||||||
if is_private "$1" && [ -z "$GH_TOKEN" ]; then echo ""; return; fi
|
if is_private "$1" && [ -z "$GH_TOKEN" ]; then echo ""; return; fi
|
||||||
gh_curl "$1" -fsSL -m8 "https://raw.githubusercontent.com/$repo/$1/VERSION" 2>/dev/null | tr -d ' \r\n'
|
fetch_repo_text "$1" VERSION 2>/dev/null | tr -d ' \r\n'
|
||||||
}
|
}
|
||||||
latest_version() { curl -fsSL -m10 "https://raw.githubusercontent.com/$PUB_REPO/main/VERSION" 2>/dev/null | tr -d ' \r\n'; }
|
latest_version() { curl -fsSL -m10 "https://raw.githubusercontent.com/$PUB_REPO/main/VERSION" 2>/dev/null | tr -d ' \r\n'; }
|
||||||
|
|
||||||
@@ -94,12 +193,10 @@ do_backup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_ref() {
|
apply_ref() {
|
||||||
local ref="$1"; local repo; repo=$(repo_for "$ref")
|
local ref="$1"
|
||||||
local repo_ref="$ref"
|
|
||||||
local RAW="https://raw.githubusercontent.com/$repo/$ref"
|
|
||||||
managed_files | while IFS='|' read -r lp rp; do
|
managed_files | while IFS='|' read -r lp rp; do
|
||||||
local tmp; tmp=$(mktemp)
|
local tmp; tmp=$(mktemp)
|
||||||
if gh_curl "$ref" -fsSL -m25 "$RAW/$rp" -o "$tmp" && [ -s "$tmp" ]; then
|
if fetch_repo_file "$ref" "$rp" "$tmp" && [ -s "$tmp" ]; then
|
||||||
mkdir -p "$(dirname "$lp")"; mv "$tmp" "$lp"
|
mkdir -p "$(dirname "$lp")"; mv "$tmp" "$lp"
|
||||||
case "$lp" in *.sh|*.py) chmod +x "$lp" 2>/dev/null || true;; esac
|
case "$lp" in *.sh|*.py) chmod +x "$lp" 2>/dev/null || true;; esac
|
||||||
else
|
else
|
||||||
@@ -108,9 +205,39 @@ apply_ref() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_runtime_deps() {
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sshpass python3-pip >/dev/null 2>&1 || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-flask-sock >/dev/null 2>&1 || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-asyncssh >/dev/null 2>&1 || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-websocket >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
python3 - <<'PY' >/dev/null 2>&1 || python3 -m pip install flask-sock asyncssh websocket-client >/dev/null 2>&1 || python3 -m pip install --break-system-packages flask-sock asyncssh websocket-client >/dev/null 2>&1 || true
|
||||||
|
import flask_sock, asyncssh, websocket
|
||||||
|
PY
|
||||||
|
mkdir -p "$PANEL_DIR/static"
|
||||||
|
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css -o "$PANEL_DIR/static/xterm.min.css" 2>/dev/null || true
|
||||||
|
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js -o "$PANEL_DIR/static/xterm.min.js" 2>/dev/null || true
|
||||||
|
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js -o "$PANEL_DIR/static/xterm-addon-fit.min.js" 2>/dev/null || true
|
||||||
|
if [ -f /etc/systemd/system/phobos-panel.service ]; then
|
||||||
|
if grep -q 'ExecStart=/usr/bin/gunicorn -w 1 -b ' /etc/systemd/system/phobos-panel.service; then
|
||||||
|
sed -i 's#ExecStart=/usr/bin/gunicorn -w 1 -b #ExecStart=/usr/bin/gunicorn -w 1 --threads 20 -b #' /etc/systemd/system/phobos-panel.service
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if grep -q '0.0.0.0:8443 app:app' /etc/systemd/system/phobos-panel.service && [ -s "$PANEL_DIR/.port" ]; then
|
||||||
|
port=$(cat "$PANEL_DIR/.port" 2>/dev/null || echo 8443)
|
||||||
|
sed -i "s#0.0.0.0:8443 app:app#0.0.0.0:${port} app:app#" /etc/systemd/system/phobos-panel.service
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
ref_exists() {
|
ref_exists() {
|
||||||
local repo; repo=$(repo_for "$1"); local repo_ref="$1"
|
local tmp rc
|
||||||
gh_curl "$1" -fsSL -m12 "https://raw.githubusercontent.com/$repo/$1/app.py" -o /dev/null 2>/dev/null
|
tmp=$(mktemp)
|
||||||
|
fetch_repo_file "$1" app.py "$tmp" >/dev/null 2>&1; rc=$?
|
||||||
|
rm -f "$tmp"
|
||||||
|
return "$rc"
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
@@ -150,7 +277,8 @@ case "${1:-}" in
|
|||||||
done
|
done
|
||||||
echo "$fromver" > "$VERFILE"
|
echo "$fromver" > "$VERFILE"
|
||||||
run_self_check
|
run_self_check
|
||||||
systemctl restart phobos-panel 2>/dev/null || true
|
restart_panel_service
|
||||||
|
refresh_router_packages
|
||||||
echo "Откат выполнен -> $fromver"
|
echo "Откат выполнен -> $fromver"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -174,13 +302,25 @@ case "${1:-}" in
|
|||||||
( umask 077; printf %s "$GH_TOKEN" > "$PANEL_DIR/.gh_token" )
|
( umask 077; printf %s "$GH_TOKEN" > "$PANEL_DIR/.gh_token" )
|
||||||
fi
|
fi
|
||||||
repo_ref="$ref"
|
repo_ref="$ref"
|
||||||
target=$(gh_curl "$ref" -fsSL -m10 "https://raw.githubusercontent.com/$(repo_for "$ref")/$ref/VERSION" 2>/dev/null | tr -d ' \r\n'); [ -z "$target" ] && target="$ref"
|
# run the TARGET version's updater: its file list knows the new files
|
||||||
|
if [ -z "${PHOBOS_UPDATE_REEXEC:-}" ]; then
|
||||||
|
_new=$(mktemp)
|
||||||
|
if fetch_repo_file "$ref" update.sh "$_new" && head -1 "$_new" | grep -q '^#!' && ! cmp -s "$_new" "$0"; then
|
||||||
|
chmod +x "$_new"
|
||||||
|
PHOBOS_UPDATE_REEXEC=1 GH_TOKEN="$GH_TOKEN" exec bash "$_new" "$@"
|
||||||
|
fi
|
||||||
|
rm -f "$_new"
|
||||||
|
fi
|
||||||
|
target=$(fetch_repo_text "$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"
|
||||||
apply_ref "$ref"
|
apply_ref "$ref"
|
||||||
|
ensure_runtime_deps
|
||||||
|
refresh_phoboswg_apk "$ref"
|
||||||
run_self_check
|
run_self_check
|
||||||
|
refresh_router_packages
|
||||||
echo "$target" > "$VERFILE"
|
echo "$target" > "$VERFILE"
|
||||||
systemctl restart phobos-panel 2>/dev/null || true
|
restart_panel_service
|
||||||
echo "Обновлено до $target. Откат: phobos-update --rollback"
|
echo "Обновлено до $target. Откат: phobos-update --rollback"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||