diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/BETA.md b/BETA.md new file mode 100644 index 0000000..990cb1f --- /dev/null +++ b/BETA.md @@ -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 +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index d033214..a5cc791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,18 +19,23 @@ phobos-update beta # или: phobos-update dev --- -## stable +## 1.4.0 — единая версия (stable = beta = dev) -### v1.2.8 -- **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:** Android PhobosWG больше не получает `PresharedKey = none` в phobos payload, из-за чего импорт мог падать с «Неправильная длина ключа в 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:** поле имени клиента больше не даёт ошибку в консоли Chrome из-за HTML `pattern`. -- **fix:** fresh stable install теперь кладёт обязательный `lib-core.sh` рядом с `phobos-client.sh`, поэтому `/clients` не сыпется на `check_root/load_env/die/log_*: command not found`. -- **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`. Если бинарника нет в Ground-Zerro, installer и secondary installer берут `linux-x64` из ClusterM releases. +Три канала сведены в одну версию: всё из dev (WARP, ttyd, бэкап, маршрут Keenetic, параметры обфускатора) ++ фиксы beta (обновлятор через API, порт/схема панели) + новое: +- **feat:** светлая/тёмная тема, компактные карточки клиентов, мобильная версия без горизонтальной прокрутки. +- **feat:** лимит скорости на клиента (tc на wg0, применяется и на вторых серверах). +- **feat:** замер скорости сервера (реальный) и клиента (живой + `/speedtest` на устройстве через туннель). +- **feat:** домен панели (nginx + Let's Encrypt), адрес для клиентов по домену, каскад (`phobos-cascade.sh`). +- **feat:** сторож — блоклист сканеров РКН (`phobos-guard.sh`). +- **fix:** secondary-setup на чистой Ubuntu 24.04 (нет iptables → wg0 не поднимался), без tty, `SERVER_IP` для NAT; клиенты досылаются на новый сервер сразу. +- **fix:** очистка orphan-peer (не было WG_CONFIG), дубль карточки WARP и кнопки WARP, имена клиентов кириллицей. +- **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 - **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 | Установка/обновление могли пройти, но потом панель генерила кривую ссылку | `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.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.7 / 1.3.x | PhobosWG: «невозможно импортировать туннель» | `phobos://` дополняется `= none` для всех обязательных полей (`PresharedKey`) по спеке формата | | 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.6 / 1.3.x | Роутер aarch64: «бинарник не найден в архиве» | авто-загрузка aarch64/mipsel/armv7 из ClusterM releases | @@ -75,6 +77,17 @@ phobos-update beta # или: phobos-update dev ## 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); - кнопка скачивания приложения **PhobosWG (APK)** — авто-загрузка из GitHub Release приватного repo; - все критичные фиксы выше. @@ -83,6 +96,30 @@ phobos-update beta # или: phobos-update 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 - **баланс по трафику:** агенты отдают пропускную способность канала (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. diff --git a/README.md b/README.md index 9467550..a1833c0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ - 📡 **Работает за любым NAT** — серый 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** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера. -- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM), резервные серверы; роутеры переключаются автоматически. +- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM + пропускная способность канала), резервные серверы; роутеры переключаются автоматически. - **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP. - **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS. - **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR. - **Метки** — имя объекта по IP (видно в сессиях и 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-оповещения о подключении/отключении. - **Telegram-бот** — управление клиентами командами прямо из чата: @@ -40,14 +48,25 @@ - `/del <имя>` — удалить клиента - `/list` — список клиентов - **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь. - (Работает только для чата из настроек `tg_chat_id`.) + (Работает только для чата из настроек `tg_chat_id`.) Бот **необязателен**: хочешь — впиши токен от @BotFather и свой chat id в «Настройки», не хочешь — оставь пустым. --- ## Скриншоты -| Сессии | Клиенты | Серверы | -|--------|---------|---------| +| Клиенты (светлая) | Клиенты (тёмная) | +|---|---| +| ![Клиенты](docs/img/clients.png) | ![Клиенты тёмная](docs/img/clients-dark.png) | +| **Сессии** | **Серверы: замер скорости, второй сервер** | +| ![Сессии](docs/img/sessions.png) | ![Серверы](docs/img/servers.png) | + +| Телефон: панель | Телефон: тёмная | Android: VPN-клиент | Android: раздельный трафик | Android: админ-приложение | +|---|---|---|---|---| +| ![](docs/img/mobile-clients.png) | ![](docs/img/mobile-clients-dark.png) | ![](docs/img/android-vpn.png) | ![](docs/img/android-vpn-apps.png) | ![](docs/img/android-admin.png) | + +Настройки (домен, каскад, сторож, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png) + +--------|---------|---------| | ![Сессии](docs/img/sessions.png) | ![Клиенты](docs/img/clients.png) | ![Серверы](docs/img/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` | Порты обфускатора | | `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления | | `ALLOW_PLAIN_WG` | пусто | `=1` открыть порт 51820 для iOS WireGuard (без обфускации) | -| `CHANNEL` | `stable` | канал: `stable` (открытый) · `beta`/`dev` (под ключом `PHOBOS_KEY`) | +| `PANEL_TLS_CERT` / `PANEL_TLS_KEY` | пусто | Включить HTTPS для панели через свой сертификат | После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их. -> **Каналы:** -> - **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`. +Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md). ### Вторичный сервер (для failover / балансировки) ```bash -MAIN_SERVER= MAIN_API_KEY= \ +MAIN_PROTO=http MAIN_SERVER= MAIN_PORT=<порт_панели> MAIN_API_KEY= \ 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 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). Один конфиг на двух устройствах = конфликт ключей. **Приложения:** -- **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-клиента. --- @@ -127,7 +158,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main **Из консоли:** ```bash -phobos-update # обновить до последней версии +phobos-update # обновить до последней версии (stable) +phobos-update beta|dev # каналы для подписчиков (GH_TOKEN), сейчас = та же 1.4.0 phobos-update v1.1.0 # обновить до конкретной версии (git tag) phobos-update --rollback # откатить на предыдущую версию 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 policy `; 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. - -Самые частые быстрые проверки: - -```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 -``` +Логи: `journalctl -u phobos-panel -n 50` · сторож: `/opt/Phobos/server/watchdog.log` · роутер: `/opt/etc/Phobos/health.log`. --- diff --git a/VERSION b/VERSION index db6fb4a..88c5fb8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.8 +1.4.0 diff --git a/android-client/.gitignore b/android-client/.gitignore new file mode 100644 index 0000000..c088233 --- /dev/null +++ b/android-client/.gitignore @@ -0,0 +1,2 @@ +build/ +libs/x/ diff --git a/android-client/AndroidManifest.xml b/android-client/AndroidManifest.xml new file mode 100644 index 0000000..a960d1f --- /dev/null +++ b/android-client/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-client/README.md b/android-client/README.md new file mode 100644 index 0000000..4d47495 --- /dev/null +++ b/android-client/README.md @@ -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//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 +``` diff --git a/android-client/build.sh b/android-client/build.sh new file mode 100755 index 0000000..4122e98 --- /dev/null +++ b/android-client/build.sh @@ -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//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 diff --git a/android-client/jni/arm64-v8a/libwgobfuscator.so b/android-client/jni/arm64-v8a/libwgobfuscator.so new file mode 100755 index 0000000..f60c7c9 Binary files /dev/null and b/android-client/jni/arm64-v8a/libwgobfuscator.so differ diff --git a/android-client/jni/armeabi-v7a/libwgobfuscator.so b/android-client/jni/armeabi-v7a/libwgobfuscator.so new file mode 100755 index 0000000..d8fac17 Binary files /dev/null and b/android-client/jni/armeabi-v7a/libwgobfuscator.so differ diff --git a/android-client/libs/annotation-1.6.0.jar b/android-client/libs/annotation-1.6.0.jar new file mode 100644 index 0000000..579ad59 Binary files /dev/null and b/android-client/libs/annotation-1.6.0.jar differ diff --git a/android-client/libs/collection-1.2.0.jar b/android-client/libs/collection-1.2.0.jar new file mode 100644 index 0000000..845ec87 Binary files /dev/null and b/android-client/libs/collection-1.2.0.jar differ diff --git a/android-client/libs/tunnel-1.0.20230706.aar b/android-client/libs/tunnel-1.0.20230706.aar new file mode 100644 index 0000000..e103278 Binary files /dev/null and b/android-client/libs/tunnel-1.0.20230706.aar differ diff --git a/android-client/res/drawable/ic_launcher.xml b/android-client/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..e38799f --- /dev/null +++ b/android-client/res/drawable/ic_launcher.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/android-client/res/values-night/styles.xml b/android-client/res/values-night/styles.xml new file mode 100644 index 0000000..d9f2b4d --- /dev/null +++ b/android-client/res/values-night/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-client/res/values/styles.xml b/android-client/res/values/styles.xml new file mode 100644 index 0000000..c538ecf --- /dev/null +++ b/android-client/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-client/src/com/pca/phobos/vpn/MainActivity.java b/android-client/src/com/pca/phobos/vpn/MainActivity.java new file mode 100644 index 0000000..4fbe619 --- /dev/null +++ b/android-client/src/com/pca/phobos/vpn/MainActivity.java @@ -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://#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 section(String conf, String header) { + List 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 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 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 ris = pm.queryIntentActivities(main, 0); + List apps = new ArrayList<>(); + Set 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 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(); } +} diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/android/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..69f8ac6 --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..ae2e717 --- /dev/null +++ b/android/README.md @@ -0,0 +1,4 @@ +# PCA Phobos — админ-приложение (Android) + +Список ваших панелей Phobos, каждая открывается в приложении (WebView), тема по системе, +ссылки `phobos://` передаются в VPN-клиент. Сборка: `KS_PASS=… ./build.sh` → `build/PCA-Phobos.apk`. diff --git a/android/build.sh b/android/build.sh new file mode 100755 index 0000000..b9d3c70 --- /dev/null +++ b/android/build.sh @@ -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 diff --git a/android/res/drawable/ic_launcher.xml b/android/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..e38799f --- /dev/null +++ b/android/res/drawable/ic_launcher.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/android/res/values-night/styles.xml b/android/res/values-night/styles.xml new file mode 100644 index 0000000..d9f2b4d --- /dev/null +++ b/android/res/values-night/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/res/values/styles.xml b/android/res/values/styles.xml new file mode 100644 index 0000000..c538ecf --- /dev/null +++ b/android/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/src/com/pca/phobos/MainActivity.java b/android/src/com/pca/phobos/MainActivity.java new file mode 100644 index 0000000..553d307 --- /dev/null +++ b/android/src/com/pca/phobos/MainActivity.java @@ -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 "

Панель недоступна

" + url + "

" + + desc + "

Проверьте адрес и порт, интернет и что сервер включён. Кнопка ⟳ — повторить.

"; + } + + 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(); + } +} diff --git a/app.py b/app.py index 256e050..3b6151c 100644 --- a/app.py +++ b/app.py @@ -4,14 +4,28 @@ PCA Phobos — Web Panel for Phobos (Obfuscated WireGuard VPN) Management panel: clients, sessions, labels, subscriptions, Telegram alerts. """ -import json, os, subprocess, threading, time, secrets, hashlib, re, fcntl +import json, os, subprocess, threading, time, secrets, hashlib, re, fcntl, hmac, ipaddress +import html as html_lib from datetime import datetime, timedelta from pathlib import Path from flask import Flask, request, redirect, url_for, session, make_response, send_from_directory +try: + from flask_sock import Sock +except Exception: + Sock = None _settings_lock = threading.Lock() app = Flask(__name__) +# ttyd clients require the server to select the 'tty' subprotocol; simple_websocket +# only echoes it when the client requested it, so SSH-terminal WS (no subprotocol) +# is unaffected. +app.config["SOCK_SERVER_OPTIONS"] = {"subprotocols": ["tty"]} +sock = Sock(app) if Sock else None +app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=12) PHOBOS_DIR = "/opt/Phobos" CLIENTS_DIR = f"{PHOBOS_DIR}/clients" @@ -19,6 +33,10 @@ SERVER_ENV = f"{PHOBOS_DIR}/server/server.env" PANEL_DIR = "/opt/phobos-panel" SETTINGS_FILE = f"{PANEL_DIR}/settings.json" SECRET_FILE = f"{PANEL_DIR}/.secret_key" +WG_CONFIG = "/etc/wireguard/wg0.conf" +WARP_DIR = f"{PHOBOS_DIR}/server/warp" +WARP_CUSTOM_PROFILE_FILE = f"{WARP_DIR}/custom-profile.conf" +WARP_LICENSE_FILE = f"{WARP_DIR}/license.key" SERVER_IP = subprocess.getoutput("curl -s https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'").strip() os.makedirs(PANEL_DIR, exist_ok=True) @@ -36,8 +54,25 @@ DEFAULT_SETTINGS = { "tg_chat_id": "", "monitor_interval": 30, "labels": {}, - "subscriptions": {} + "subscriptions": {}, + "warp": { + "mode": "egress", + "profile_source": "free", + "relay_ports": "4500", + "relay_target": "", + "license_key": "", + }, + "warp_clients": [], + "client_aliases": {}, + "obf_params": { + "masking": "AUTO", + "max_dummy": "4", + "idle": "300", + }, } +LOGIN_ATTEMPTS = {} +LOGIN_WINDOW = 900 +LOGIN_MAX_ATTEMPTS = 10 def load_settings(): @@ -47,8 +82,14 @@ def load_settings(): s = json.load(f) for k, v in DEFAULT_SETTINGS.items(): s.setdefault(k, v) + if not s.get("server_api_key"): + s["server_api_key"] = secrets.token_hex(16) + with open(SETTINGS_FILE, "w") as f: + json.dump(s, f, indent=2, ensure_ascii=False) return s - return dict(DEFAULT_SETTINGS) + s = dict(DEFAULT_SETTINGS) + s["server_api_key"] = secrets.token_hex(16) + return s def save_settings(s): @@ -57,6 +98,165 @@ def save_settings(s): json.dump(s, f, indent=2, ensure_ascii=False) +def _load_server_env(): + """Load server.env as dict for subprocess env.""" + env = {} + if os.path.exists(SERVER_ENV): + with open(SERVER_ENV) as f: + for line in f: + line = line.strip() + if "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + env[k] = v + return env + + +def _server_wg_interface(): + env = _load_server_env() + iface = (env.get("SERVER_WG_IPV4_ADDRESS") or "").strip() + if iface: + return iface + try: + if os.path.exists("/etc/wireguard/wg0.conf"): + with open("/etc/wireguard/wg0.conf") as fh: + for raw in fh: + raw = raw.strip() + if raw.startswith("Address"): + value = raw.split("=", 1)[1].split(",", 1)[0].strip() + if value: + return value + except Exception: + pass + net = (env.get("SERVER_WG_IPV4_NETWORK") or "10.25.0.0/16").strip() + try: + network = ipaddress.ip_network(net, strict=False) + return str(ipaddress.ip_interface(f"{next(network.hosts())}/{network.prefixlen}")) + except Exception: + return "10.25.0.1/16" + + +def server_wg_network(): + env = _load_server_env() + raw = (env.get("SERVER_WG_IPV4_NETWORK") or "").strip() + if raw: + try: + return str(ipaddress.ip_network(raw, strict=False)) + except Exception: + pass + try: + return str(ipaddress.ip_interface(_server_wg_interface()).network) + except Exception: + return "10.25.0.0/16" + + +def server_tunnel_ip(): + try: + return str(ipaddress.ip_interface(_server_wg_interface()).ip) + except Exception: + return "10.25.0.1" + + +def client_display_name(cid, s=None): + cid = str(cid or "").strip() + aliases = (s or load_settings()).get("client_aliases", {}) or {} + alias = str(aliases.get(cid, "") or "").strip() + return alias or cid + + +def panel_service_name(): + for name in ("phobos-dev-test", "phobos-panel"): + try: + r = subprocess.run(["systemctl", "is-active", name], capture_output=True, text=True, timeout=5) + if (r.stdout or "").strip() in ("active", "inactive", "failed", "activating", "deactivating"): + return name + except Exception: + pass + return "phobos-panel" + + +def login_allowed(ip): + now = int(time.time()) + rec = [ts for ts in LOGIN_ATTEMPTS.get(ip, []) if now - ts < LOGIN_WINDOW] + LOGIN_ATTEMPTS[ip] = rec + return len(rec) < LOGIN_MAX_ATTEMPTS + + +def login_failed(ip): + now = int(time.time()) + rec = [ts for ts in LOGIN_ATTEMPTS.get(ip, []) if now - ts < LOGIN_WINDOW] + rec.append(now) + LOGIN_ATTEMPTS[ip] = rec + + +def login_reset(ip): + LOGIN_ATTEMPTS.pop(ip, None) + + +def _default_warp_settings(): + return dict(DEFAULT_SETTINGS["warp"]) + + +def get_warp_settings(s=None): + src = (s or load_settings()).get("warp", {}) + out = _default_warp_settings() + if isinstance(src, dict): + out.update({k: str(v) for k, v in src.items() if v is not None}) + out["mode"] = out.get("mode", "egress") if out.get("mode", "egress") in ("egress", "relay") else "egress" + out["profile_source"] = out.get("profile_source", "free") if out.get("profile_source", "free") in ("free", "custom") else "free" + out["relay_ports"] = out.get("relay_ports", "4500").strip() or "4500" + out["relay_target"] = out.get("relay_target", "").strip() + out["license_key"] = out.get("license_key", "").strip() + return out + + +def read_custom_warp_profile(): + try: + return Path(WARP_CUSTOM_PROFILE_FILE).read_text() + except Exception: + return "" + + +def save_custom_warp_profile(text): + text = (text or "").strip() + os.makedirs(WARP_DIR, exist_ok=True) + Path(WARP_CUSTOM_PROFILE_FILE).write_text(text + ("\n" if text else "")) + return True + + +def read_warp_license_key(): + try: + return Path(WARP_LICENSE_FILE).read_text().strip() + except Exception: + return "" + + +def save_warp_license_key(text): + text = (text or "").strip() + os.makedirs(WARP_DIR, exist_ok=True) + Path(WARP_LICENSE_FILE).write_text(text + ("\n" if text else "")) + return True + + +def delete_warp_license_key(): + try: + os.remove(WARP_LICENSE_FILE) + return True + except FileNotFoundError: + return False + except Exception: + return False + + +def delete_custom_warp_profile(): + try: + os.remove(WARP_CUSTOM_PROFILE_FILE) + return True + except FileNotFoundError: + return False + except Exception: + return False + + def tg_send(text): s = load_settings() token, chat = s.get("tg_bot_token", ""), s.get("tg_chat_id", "") @@ -76,22 +276,36 @@ def notify_on(cid): return load_settings().get("client_notify", {}).get(cid, True) +def warp_clients_enabled(): + vals = load_settings().get("warp_clients", []) + return {str(x).strip() for x in vals if str(x).strip()} + + +def client_warp_enabled(cid): + return cid in warp_clients_enabled() + + +def client_script_path(): + return f"{PHOBOS_DIR}/repo/server/scripts/phobos-client.sh" + + def create_client_cli(name): """Create a client (add + assign + fanout + install link). Returns (ok, html_msg). Used by web + Telegram bot.""" import re as _re name = (name or "").strip() + cid = _normalize_client_id(name) if not name or not _re.match(r"^[a-zA-Z0-9_-]+$", name): return False, "Имя: латиница, цифры, _ и - (без пробелов)." - if os.path.isdir(os.path.join(CLIENTS_DIR, name)): - return False, "Клиент %s уже существует." % name - script = "%s/repo/server/scripts/phobos-client.sh" % PHOBOS_DIR + if os.path.isdir(os.path.join(CLIENTS_DIR, cid)): + return False, "Клиент %s уже существует." % cid + script = client_script_path() try: subprocess.check_output([script, "add", name], text=True, timeout=45, stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()}) except subprocess.CalledProcessError as e: return False, "Ошибка создания: %s" % (e.output or "")[-300:] try: - auto_assign_server(name); fanout_router_config(name) + auto_assign_server(cid); fanout_router_config(cid) except Exception: pass cmd = "" @@ -103,7 +317,7 @@ def create_client_cli(name): cmd = ln.strip(); break except Exception: pass - msg = "\u2705 Клиент %s создан." % name + msg = "\u2705 Клиент %s создан." % cid if cmd: msg += "\n\nКоманда установки на роутер:\n%s" % cmd return True, msg @@ -113,7 +327,7 @@ def delete_client_cli(cid): cid = (cid or "").strip() if not cid or not os.path.isdir(os.path.join(CLIENTS_DIR, cid)): return False, "Клиент %s не найден." % cid - script = "%s/repo/server/scripts/phobos-client.sh" % PHOBOS_DIR + script = client_script_path() try: subprocess.check_output([script, "remove", cid], text=True, timeout=25, stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()}) @@ -335,29 +549,87 @@ def get_wg_peers(): return peers -def get_clients(): +def get_wg_allowed_ips(): + try: + out = subprocess.check_output(["wg", "show", "wg0", "allowed-ips"], text=True, timeout=5) + except Exception: + return {} + data = {} + for line in out.splitlines(): + parts = line.split() + if len(parts) >= 2: + data[parts[0].strip()] = parts[1].strip() + return data + + +def _orphan_client_id(tunnel_ip): + tip = (tunnel_ip or "").split("/")[0].strip() or "unknown" + return "orphan-" + re.sub(r"[^a-zA-Z0-9]+", "-", tip).strip("-") + + +def get_clients(include_orphans=False): """Read all clients from /opt/Phobos/clients/*/metadata.json.""" clients = [] clients_path = Path(CLIENTS_DIR) if not clients_path.exists(): - return clients - for d in sorted(clients_path.iterdir()): - meta_file = d / "metadata.json" - if meta_file.exists(): - try: - with open(meta_file) as f: - meta = json.load(f) - meta["_dir"] = str(d) - clients.append(meta) - except Exception: - pass + clients_path = None + if clients_path: + for d in sorted(clients_path.iterdir()): + meta_file = d / "metadata.json" + if meta_file.exists(): + try: + with open(meta_file) as f: + meta = json.load(f) + meta["_dir"] = str(d) + clients.append(meta) + except Exception: + pass + if include_orphans: + known = {c.get("public_key", ""): c for c in clients if c.get("public_key", "")} + allowed = get_wg_allowed_ips() + for pub, ip in allowed.items(): + if pub in known: + continue + tip = (ip or "").split(",")[0].strip() + clients.append({ + "client_id": _orphan_client_id(tip), + "client_name": _orphan_client_id(tip), + "tunnel_ip_v4": tip, + "public_key": pub, + "created_at": "", + "_orphan": True, + }) return clients +def purge_orphan_peer(public_key): + pub = (public_key or "").strip() + if not pub: + return False, "empty public key" + try: + subprocess.run(["wg", "set", "wg0", "peer", pub, "remove"], check=True, timeout=5) + except Exception as e: + return False, f"wg remove failed: {e}" + try: + cfg = Path(WG_CONFIG) + if cfg.exists(): + text = cfg.read_text() + blocks = [b for b in re.split(r"\n\s*\n", text.strip()) if b.strip()] + kept = [] + for block in blocks: + if pub in block and "[Peer]" in block: + continue + kept.append(block) + cfg.write_text("\n\n".join(kept).strip() + "\n") + except Exception: + pass + return True, f"orphan peer removed: {pub[:12]}..." + + def get_active_sessions(): """Combine WG peers with client metadata to build session list.""" peers = get_wg_peers() - clients = get_clients() + clients = get_clients(include_orphans=True) pub_to_client = {} for c in clients: @@ -395,8 +667,12 @@ def get_active_sessions(): def is_peer_online(handshake_str): """Check if peer had a handshake within last 3 minutes.""" + s = (handshake_str or "").strip() + # never handshaked ("" / "(none)") or days old -> offline + if not re.search(r"\d", s) or "day" in s or "week" in s: + return False try: - parts = handshake_str.split(",") + parts = s.split(",") total_seconds = 0 for p in parts: p = p.strip() @@ -494,24 +770,75 @@ def count_client_peers(): return sum(1 for pub in wg_peers if pub in client_pubs) +_net_prev = {} # key -> (rx, tx, ts) for rate calc +BW_CAP_MBPS = float(os.environ.get("BW_CAP_MBPS", "1000")) # link cap ref for load fraction + +_geo_cache = {} + + +def _ip_country(ip): + """Flag + country code for a public IP (ip-api.com, cached). '' for private.""" + ip = (ip or "").strip() + if not ip or ip.startswith(("10.", "192.168.", "127.")) or ip.startswith("172.16."): + return "" + if ip in _geo_cache: + return _geo_cache[ip] + res = "" + try: + import urllib.request as _u, json as _j + d = _j.loads(_u.urlopen(f"http://ip-api.com/json/{ip}?fields=countryCode", timeout=4).read()) + cc = (d.get("countryCode") or "").upper() + if len(cc) == 2 and cc.isalpha(): + flag = "".join(chr(0x1F1E6 + ord(c) - 65) for c in cc) + res = f"{flag} {cc}" + except Exception: + res = "" + _geo_cache[ip] = res + return res + + +def _calc_mbps(key, rx, tx, ts): + """Throughput Mbps from consecutive NIC byte counters. First sample -> 0.""" + prev = _net_prev.get(key) + _net_prev[key] = (rx, tx, ts) + if not prev: + return 0.0 + prx, ptx, pts = prev + dt = ts - pts + if dt <= 0 or rx < prx or tx < ptx: # no time passed / counter reset + return 0.0 + return round(((rx - prx) + (tx - ptx)) * 8 / (dt * 1e6), 1) + + +def _local_net_bytes(): + try: + iface = subprocess.getoutput("ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i (down_mbit, up_mbit) as seen from the client + + +def rate_sampler(): + """Real per-client speed from WireGuard byte counters, every 5 s.""" + prev, prev_t = {}, 0.0 + while True: + try: + out = subprocess.run(["wg", "show", "wg0", "transfer"], capture_output=True, text=True, timeout=5).stdout + now = time.time() + cur = {} + for ln in out.splitlines(): + p = ln.split() + if len(p) == 3: + cur[p[0]] = (int(p[1]), int(p[2])) + if prev_t: + dt = now - prev_t + rates = {} + for pub, (rx, tx) in cur.items(): + if pub in prev: + # server rx = client upload, server tx = client download + up = max(0, rx - prev[pub][0]) * 8 / dt / 1e6 + down = max(0, tx - prev[pub][1]) * 8 / dt / 1e6 + if up > 0.01 or down > 0.01: + rates[pub] = (down, up) + client_rates.clear() + client_rates.update(rates) + prev, prev_t = cur, now + except Exception: + pass + time.sleep(5) + + +def _swap_host(hostport, ip_to_host): + host, sep, port = hostport.rpartition(":") + return (ip_to_host(host) + sep + port) if sep else hostport + + +def run_warp(action, *extra): + try: + r = subprocess.run( + ["/opt/Phobos/server/phobos-warp.sh", action, *[str(x) for x in extra if x is not None]], + capture_output=True, text=True, timeout=180 + ) + return ((r.stdout or "") + (r.stderr or ""))[-1600:] + except Exception as e: + return "warp error: " + str(e) + + +def warp_toggle_client(cid, enable=True): + client = next((c for c in get_clients(include_orphans=True) if c.get("client_id") == cid), None) + if not client: + return False, tr("Клиент не найден.", "Client not found.") + tip = (client.get("tunnel_ip_v4") or "").split("/")[0].strip() + if not tip: + return False, tr("У клиента нет tunnel IP.", "Client has no tunnel IP.") + s = load_settings() + current = set(str(x).strip() for x in s.get("warp_clients", []) if str(x).strip()) + if enable: + current.add(cid) + else: + current.discard(cid) + s["warp_clients"] = sorted(current) + save_settings(s) + st = warp_status() + if st.get("mode") == "egress" and st.get("service") == "active": + out = run_warp("client-enable" if enable else "client-disable", tip) + if "error" in out.lower() or "required" in out.lower(): + return False, out + return True, out + return True, tr("Сохранено. Применится после включения WARP egress.", "Saved. Applies after WARP egress is enabled.") + + +def apply_warp_form(s): + cfg = get_warp_settings(s) + cfg["mode"] = request.form.get("warp_mode", cfg["mode"]).strip() or "egress" + if cfg["mode"] not in ("egress", "relay"): + cfg["mode"] = "egress" + cfg["profile_source"] = request.form.get("warp_profile_source", cfg["profile_source"]).strip() or "free" + if cfg["profile_source"] not in ("free", "custom"): + cfg["profile_source"] = "free" + cfg["relay_ports"] = request.form.get("warp_relay_ports", cfg["relay_ports"]).strip() or "4500" + cfg["relay_target"] = request.form.get("warp_relay_target", cfg["relay_target"]).strip() + cfg["license_key"] = request.form.get("warp_license_key", cfg.get("license_key", "")).strip() + s["warp"] = cfg + profile_text = request.form.get("warp_custom_profile", "") + if request.form.get("warp_clear_license") == "1": + cfg["license_key"] = "" + delete_warp_license_key() + elif cfg["license_key"]: + save_warp_license_key(cfg["license_key"]) + if request.form.get("warp_clear_custom") == "1": + delete_custom_warp_profile() + elif profile_text.strip(): + save_custom_warp_profile(profile_text) + return cfg def build_phone_config(cid, mode="android"): @@ -705,6 +1377,10 @@ def build_phone_config(cid, mode="android"): break except Exception: pass + try: + server_ip = client_endpoint(server_ip) + except Exception: + pass out = [] for ln in wg.split("\n"): if ln.strip().startswith("Endpoint"): @@ -715,6 +1391,10 @@ def build_phone_config(cid, mode="android"): inst = "" if os.path.exists(instpath): inst = open(instpath).read().strip() + inst = "\n".join( + ("target = " + _swap_host(ln.split("=", 1)[1].strip(), client_endpoint)) + if ln.strip().startswith("target") and "=" in ln else ln + for ln in inst.split("\n")) conf = wg + "\n\n" + inst + "\n" payload = _prepare_phoboswg_payload(conf) b64 = base64.urlsafe_b64encode(payload.encode()).decode() @@ -801,13 +1481,43 @@ def _normalize_wg_key_value(value): def _pad_conf_with_none(conf): - """Legacy helper kept for old endpoints that may call it.""" - return _prepare_phoboswg_payload(conf) + """Insert ' = none' for every missing mandatory PhobosWG field + (phobos:// spec 2.3). Only for phobos:// payload, NOT raw .conf.""" + mandatory = { + "[interface]": ["PrivateKey", "Address", "MTU", "DNS"], + "[peer]": ["PublicKey", "PresharedKey", "AllowedIPs", "PersistentKeepalive", "Endpoint"], + "[instance]": ["source-if", "source-lport", "target", "key", "masking", + "verbose", "idle-timeout", "max-dummy"], + } + sections = [] + cur = None + for ln in conf.split("\n"): + s = ln.strip() + if s.startswith("[") and s.endswith("]"): + cur = [s, []] + sections.append(cur) + elif cur is not None: + cur[1].append(ln) + out = [] + for header, lines in sections: + keys = {ln.split("=", 1)[0].strip().lower() for ln in lines if "=" in ln} + while lines and lines[-1].strip() == "": + lines.pop() + for req in mandatory.get(header.lower(), []): + if req.lower() not in keys: + lines.append("%s = none" % req) + out.append(header) + out.extend(lines) + out.append("") + return "\n".join(out).rstrip("\n") + "\n" def qr_datauri(data): try: import qrcode, io, base64 + # ECC L = fewer modules for the same data (phobos:// links are long). + # box_size/border chosen so native PNG ~matches the 300px display -> + # no CSS downscale blur, scannable by phone cameras. qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=4, border=3) qr.add_data(data) @@ -822,7 +1532,7 @@ def qr_datauri(data): def fanout_router_config(client_id): """Push this client's failover.conf to every secondary server's agent so - routers can PULL it through the tunnel (10.25.0.1:8444) — survives a public + routers can PULL it through the tunnel server IP (:8444) — survives a public panel-IP ban and follows the active server. Best-effort, non-blocking.""" try: conf = generate_failover_conf_for_client(client_id) @@ -836,7 +1546,8 @@ def fanout_router_config(client_id): if server_stats_cache.get(ip, {}).get("status") == "down": continue try: - secondary_api_request(srv, "/api/router-config-set", {"client_id": client_id, "conf": conf}, timeout=3) + secondary_api_request(srv, "/api/router-config-set", + {"client_id": client_id, "conf": conf}, timeout=3) except Exception: pass @@ -954,6 +1665,10 @@ def session_monitor(): if time.time() - _last_peer_sync >= 300: _last_peer_sync = time.time() sync_missing_peers_to_secondaries() + if load_settings().get("speed_limits"): + apply_speed_limits() + elif limits_need_reapply(): + apply_speed_limits(push=False) check_expiry() time.sleep(interval) @@ -967,25 +1682,62 @@ bot_thread = threading.Thread(target=telegram_bot, daemon=True) bot_thread.start() +def finish_legacy_update(): + """1.2.x/1.3.x updaters only know their own file list. After such an update + the new update.sh is already in place, so run it once more (detached from + the panel cgroup) to fetch the scripts the old list missed.""" + need = [f"{PHOBOS_DIR}/server/{n}" for n in + ("phobos-guard.sh", "phobos-cascade.sh", "phobos-domain.sh", "phobos-limit.sh", + "phobos-speedtest.sh", "phobos-warp.sh", "phobos-backup.sh")] + marker = os.path.join(PANEL_DIR, ".finish-update") + runner = f"{PHOBOS_DIR}/server/update.sh" + try: + recent = time.time() - os.path.getmtime(marker) < 6 * 3600 + except OSError: + recent = False + if all(os.path.exists(p) for p in need) or recent or not os.path.exists(runner): + return + try: + Path(marker).write_text(str(int(time.time()))) + ref = "main" + try: + ch = open(os.path.join(PANEL_DIR, ".channel")).read().strip() + ref = ch if ch in ("beta", "dev") or ch.endswith(("-dev", "-beta")) else "main" + except Exception: + pass + subprocess.Popen(["systemd-run", "--unit=phobos-finish-update", "--collect", runner, ref], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + pass + + +finish_legacy_update() +threading.Thread(target=rate_sampler, daemon=True).start() + + PAGE = """ Phobos VPN Panel + @@ -1026,9 +1812,9 @@ input:focus{outline:none;border-color:#4f46e5} CONTENT