Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f3eced228 | ||
|
|
22537b3aad | ||
|
|
f0f4f334a5 | ||
|
|
ad839e4cad | ||
|
|
28986677eb | ||
|
|
af4c269438 | ||
|
|
528d799edc | ||
|
|
31978f22f0 | ||
|
|
c5e9e45141 | ||
|
|
51ec84db6c | ||
|
|
d030344b00 | ||
|
|
8dc9fda65b | ||
|
|
8bfc84f315 | ||
|
|
381e97a564 | ||
|
|
eefe3fc2af | ||
|
|
15011c2756 | ||
|
|
29790aaf4d | ||
|
|
884df15f42 | ||
|
|
eb2b2768cd | ||
|
|
147ef3a42d | ||
|
|
dde6403cea | ||
|
|
d795bc6f70 | ||
|
|
c139debd8d | ||
|
|
01632034b0 | ||
|
|
071c1dc7f1 |
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
windows/build/
|
||||||
|
|||||||
3
APPS
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Android (PCA Phobos VPN)=1.4.6
|
||||||
|
iPhone (TestFlight)=1.4.5
|
||||||
|
Windows=1.4.5-beta
|
||||||
15
BETA.md
@@ -1,10 +1,11 @@
|
|||||||
# Каналы PCA Phobos
|
# Каналы PCA Phobos
|
||||||
|
|
||||||
С версии **1.4.0** все три канала (stable / beta / dev) — одна и та же версия.
|
С **1.5.0** все три канала — одна и та же панель и одни и те же серверные функции.
|
||||||
Новые фичи по-прежнему сначала выкатываются в `dev`, затем в `beta`, затем в `main`
|
|
||||||
(`promote.sh`), но база одна — без расхождений.
|
|
||||||
|
|
||||||
```bash
|
| Канал | Для кого | Отличие |
|
||||||
phobos-update # stable (открытый)
|
|---|---|---|
|
||||||
GH_TOKEN=… phobos-update beta|dev # для подписчиков Boosty
|
| **stable** (`main`, открытый) | все | без приложений Apple: в окне «Подключение» нет вкладок iOS и macOS |
|
||||||
```
|
| **beta** | подписчики Boosty (токен) | + приложения PCA Phobos VPN для iPhone (TestFlight) и macOS (.pkg) |
|
||||||
|
| **dev** | подписчики, тест нового | то же, что beta, новые функции появляются здесь первыми |
|
||||||
|
|
||||||
|
Переключение: `phobos-update stable|beta|dev` или «Настройки → Версия и обновления». Токен подписчика: там же.
|
||||||
|
|||||||
48
CHANGELOG.md
@@ -1,3 +1,18 @@
|
|||||||
|
## 1.5.0 — 2026-11-11
|
||||||
|
- **Одна версия во всех каналах.** stable получил всё из beta: новый дизайн (боковое меню, «Сессии» с графиками, окна «Правка» / «Подключение» / «Роутер»), пресеты обфускатора, firewall по клиенту, трафик за месяц, DNS для клиентов, автопереход роутеров по всем портам и адресам + TCP, второй IP сервера (`phobos-altip`), несколько учёток на роутере, IPv6 для телефонов, вкладки Windows и HR, баннер «Доступна новая версия».
|
||||||
|
- **Приложения Apple — только beta/dev.** В beta/dev окно «Подключение» показывает вкладки **iOS** и новую **macOS** (установщик PCA Phobos для macOS 1.4.3 скачивается из закрытого релиза по токену подписчика). В stable этих вкладок нет, у iPhone — только обычный WireGuard; исходники iOS/macOS/Windows в открытый репозиторий не входят.
|
||||||
|
- Меню: при новой версии под «Настройками» — зелёный пункт «Обновить до N». На телефоне верхнее меню больше не раздувается до 200 px.
|
||||||
|
- Скачивание из git.bobyrev.com (Windows, macOS, проверка версий) — свой User-Agent: Cloudflare отвечал 403 на «Python-urllib».
|
||||||
|
- Ссылка на Android-приложение — релиз v1.4.10 (PCA Phobos VPN 1.4.6).
|
||||||
|
- Документация: источник — GitHub adleibzon-rgb (git.bobyrev.com — запасной), разделы «Пароль панели», «Удаление», «Подключение устройств», свежие скриншоты; PORTS.md — TCP-резерв и второй IP.
|
||||||
|
|
||||||
|
## 1.4.11 — 2026-11-11
|
||||||
|
- Панель сама раз в час проверяет свой канал (stable/beta/dev): если вышла новая версия — сверху зелёная полоса «Доступна новая версия» и кнопка «Обновить». В окне перед обновлением — что нового, как сделать бэкап и как откатиться.
|
||||||
|
|
||||||
|
## 1.4.9 — 2026-11-03
|
||||||
|
- Команда secondary сама подставляет подсеть главной; install.sh в сводке показывает реальные ключ API и пароль.
|
||||||
|
- Панель «Серверы»: сверху блок «Как добавить второй сервер» (по-русски, команда secondary, предупреждение про независимые панели и подсети); ручное добавление свёрнуто. install.sh: предупреждение перед вопросом о подсети; подсеть `10.25.11.0/16` → `10.25.11.0/24` вместо молчаливого `10.25.0.0/16`.
|
||||||
|
|
||||||
# Changelog — PCA Phobos
|
# Changelog — PCA Phobos
|
||||||
|
|
||||||
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
|
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
|
||||||
@@ -5,6 +20,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.4.10 — 2026-11-03
|
||||||
|
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
|
||||||
|
|
||||||
## Как обновиться после фиксов
|
## Как обновиться после фиксов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -19,6 +37,36 @@ phobos-update beta # или: phobos-update dev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.4.8 — сторож роутера не трогает чужие туннели
|
||||||
|
- Исправлено: если сторож не находил интерфейс Phobos (удалён или переименован), он брал Wireguard3 по умолчанию и прописывал туда пир Phobos — ломался посторонний WireGuard-туннель. Теперь он просто пропускает проверку.
|
||||||
|
- Роутерам: обновить скрипты из панели или переустановить клиента; серверу — `phobos-update stable`.
|
||||||
|
|
||||||
|
## 1.4.7 — роутеры Keenetic сами переключаются между серверами
|
||||||
|
- Исправлено: после переключения на другой сервер туннель на роутере переставал работать — пир прописывался на порт обфускатора 13255, а он мог слушать другой. Порт теперь берётся из `wg-obfuscator.conf`.
|
||||||
|
- Исправлено: проверка и обновление конфига по расписанию на Keenetic не запускались — задачи писались в `/opt/etc/crontab`, который демон не читает. Теперь busybox `crond` и crontab пользователя root.
|
||||||
|
- Исправлено: `phobos-pull` навсегда зависал на `wget`, если туннель не работает (таймаут 8 с).
|
||||||
|
- Роутерам: переустановить клиента или обновить скрипты из панели; серверу — `phobos-update stable`.
|
||||||
|
|
||||||
|
## 1.4.6 — роутеры снова получают смену адреса
|
||||||
|
- Исправлено: роутер получал 403 при pull конфига, если клиент создан через API/бота (`/api/client/ensure`) — смена адреса/сервера до него не доходила. Теперь принимается и общий ключ из пакета, и личный токен клиента. Роутеры переустанавливать не нужно — достаточно обновить панель.
|
||||||
|
|
||||||
|
## 1.4.5 — подсеть при установке, beta/dev по токену
|
||||||
|
|
||||||
|
- **feat:** beta и dev теперь берутся из приватного репозитория на Gitea (GitHub заблокирован): `phobos-update beta|dev` с токеном подписчика — в панели «Настройки → Версия и обновления → токен подписчика» или `GH_TOKEN=… phobos-update beta`. Без токена updater сообщает, что канал закрыт, и ничего не трогает.
|
||||||
|
- **fix:** при переустановке на сервер с готовым `wg0.conf` подсеть берётся из него.
|
||||||
|
|
||||||
|
- **feat:** подсеть VPN-клиентов задаётся при установке: `install.sh` и `secondary-setup.sh` спрашивают её в терминале (Enter = `10.25.0.0/16`) или берут `SERVER_WG_IPV4_NETWORK=10.25.1.0/24`. Неверная или публичная сеть — установка останавливается с ошибкой.
|
||||||
|
|
||||||
|
## 1.4.4 — канал «Облако» (через KeenDNS своего Keenetic)
|
||||||
|
- **Канал «Облако»** в Android PCA Phobos VPN 1.4.4 и iPhone (TestFlight 1.0 (6)): телефон → облако KeenDNS → ваш Keenetic → сервер. Помогает, когда сам сервер заблокирован, а сайты Keenetic открываются; белый IP роутеру не нужен.
|
||||||
|
- **wsgw** — шлюз WebSocket → UDP для Keenetic (Entware: aarch64, mipsel, mips) и Linux. Инструкция с картинками — [docs/cloud.md](docs/cloud.md).
|
||||||
|
- Панель: ссылки на приложения ведут на v1.4.4. Остальной сервер не менялся.
|
||||||
|
|
||||||
|
## 1.4.2 — настройка обфускатора сервера
|
||||||
|
|
||||||
|
- **feat:** в панели «Настройки» → «Обфускатор сервера»: masking (AUTO/STUN/NONE), max-dummy, idle — записываются во все порты сервера, обфускатор перезапускается (клиенты переподключаются за секунды).
|
||||||
|
- **fix (Android PCA Phobos VPN 1.4.3):** конфиги других сборок Phobos с лишними ключами (`obfuscate-bytes` и т. п.) больше не роняют обфускатор («обфускатор завершился, код 1»): передаются только ключи, известные wg-obfuscator v1.6.
|
||||||
|
|
||||||
## 1.4.1 — резервный TCP-канал, «РФ напрямую»
|
## 1.4.1 — резервный TCP-канал, «РФ напрямую»
|
||||||
|
|
||||||
- **feat:** резервный канал TCP: если провайдер режет UDP, клиент сам переходит на TCP (порт 443, иначе 8443/2096/4433). Сервер: служба `phobos-tcp` (`udptcp`), ставится и включается автоматически при установке и обновлении. Проверено реальной блокировкой UDP: переключение за ~15 с, без блокировки остаётся UDP.
|
- **feat:** резервный канал TCP: если провайдер режет UDP, клиент сам переходит на TCP (порт 443, иначе 8443/2096/4433). Сервер: служба `phobos-tcp` (`udptcp`), ставится и включается автоматически при установке и обновлении. Проверено реальной блокировкой UDP: переключение за ~15 с, без блокировки остаётся UDP.
|
||||||
|
|||||||
122
README.md
@@ -17,7 +17,7 @@
|
|||||||
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
|
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
|
||||||
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
|
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
|
||||||
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
|
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
|
||||||
- 📱 **Телефоны** — своё приложение **PCA Phobos VPN** для Android (обфускация + раздельный трафик по приложениям), PhobosWG, iPhone (обычный WireGuard) — через QR/ссылку.
|
- 📱 **Телефоны и ПК** — свои приложения **PCA Phobos VPN**: Android (APK) и Windows (по токену подписчика). Плюс PhobosWG и обычный WireGuard — через QR/ссылку. Приложения для iPhone и macOS — в каналах beta/dev.
|
||||||
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
|
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
- **Мульти-сервер + 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 / Windows / Router / HR (+ обычный WireGuard для iPhone).
|
||||||
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
|
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
|
||||||
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
|
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
|
||||||
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
|
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
|
||||||
@@ -39,9 +39,17 @@
|
|||||||
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
|
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
|
||||||
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
|
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
|
||||||
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
|
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
|
||||||
|
- **Канал «Облако»** — если сервер заблокирован целиком: телефон идёт через облако KeenDNS своего Keenetic. [Инструкция](docs/cloud.md).
|
||||||
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
|
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
|
||||||
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
|
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
|
||||||
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
|
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
|
||||||
|
- **Новый дизайн (с 1.5.0 во всех каналах)** — боковое меню, «Сессии» с графиками CPU/RAM/скорости, кольцо трафика по клиентам; клиенты — таблица и окна «Правка», «Подключение» (Android / iOS / Windows / Router / **HR** для HydraRoute), «Роутер» (SSH / RCI / TTYD).
|
||||||
|
- **Пресеты обфускатора** — свой набор портов и ключ на группу клиентов; **firewall по клиенту** (🛡 — только разрешённые адреса).
|
||||||
|
- **Трафик за месяц** по каждому клиенту (сумма по всем серверам), история по месяцам.
|
||||||
|
- **DNS для клиентов** — задаётся в «Настройках».
|
||||||
|
- **Автопереход роутера (1.5.0)** — при сбое сторож перебирает все порты и все адреса сервера (второй IP, домены), затем TCP; на основной возвращается сам, если там снова есть рукопожатие.
|
||||||
|
- **Второй IP сервера** (`phobos-altip`) — если провайдер клиента режет основной адрес, роутер уходит на второй.
|
||||||
|
- **Несколько учёток на одном роутере**, **IPv6 для телефонов**, **ссылка для HydraRoute** (строго по формату автора Phobos).
|
||||||
- **Версии и обновления** — обновление и откат прямо из панели.
|
- **Версии и обновления** — обновление и откат прямо из панели.
|
||||||
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
|
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
|
||||||
- **Telegram-бот** — управление клиентами командами прямо из чата:
|
- **Telegram-бот** — управление клиентами командами прямо из чата:
|
||||||
@@ -55,8 +63,20 @@
|
|||||||
|
|
||||||
## Скриншоты
|
## Скриншоты
|
||||||
|
|
||||||
| Клиенты (светлая) | Клиенты (тёмная) |
|
| Сессии (светлая) | Сессии (тёмная) |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| **Клиенты** | **Серверы: второй сервер, домен для клиентов** |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Телефон: клиенты | Телефон: тёмная | Телефон: серверы |
|
||||||
|
|---|---|---|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
Настройки (пароль, домен, каскад, DNS, сторож, обновления, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png).
|
||||||
|
Android-приложения: [VPN-клиент](docs/img/android-vpn.png) · [раздельный трафик](docs/img/android-vpn-apps.png) · [админ](docs/img/android-admin.png).
|
||||||
|
|
||||||
|
---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
| **Сессии** | **Серверы: замер скорости, второй сервер** |
|
| **Сессии** | **Серверы: замер скорости, второй сервер** |
|
||||||
|  |  |
|
|  |  |
|
||||||
@@ -67,37 +87,35 @@
|
|||||||
|
|
||||||
Настройки (домен, каскад, сторож, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png)
|
Настройки (домен, каскад, сторож, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png)
|
||||||
|
|
||||||
--------|---------|---------|
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
> Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Источник с 23.10.2026:** `git.bobyrev.com/PCA/PCA_Phobos` (GitHub-аккаунт временно недоступен). Серверы на 1.2.x обновляются одной командой:
|
> **Откуда ставится (с 11.11.2026):** основной источник — GitHub [`adleibzon-rgb/PCA_Phobos`](https://github.com/adleibzon-rgb/PCA_Phobos), запасной — зеркало `git.bobyrev.com/PCA/PCA_Phobos` (обновлятор сам переходит на него, если GitHub недоступен). Старый аккаунт `andrey271192` заблокирован — ссылки на него не работают.
|
||||||
|
> Серверы на старых версиях (1.2.x–1.4.x) обновляются одной командой:
|
||||||
> ```bash
|
> ```bash
|
||||||
> bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/update.sh)
|
> bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/update.sh)
|
||||||
> ```
|
> ```
|
||||||
|
> Если GitHub с сервера не открывается — то же с зеркала: `bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/update.sh)`
|
||||||
|
|
||||||
## Быстрый старт (с чистого VPS, одной командой)
|
## Быстрый старт (с чистого VPS, одной командой)
|
||||||
|
|
||||||
Ставит **всё** со всеми зависимостями: wg-obfuscator, WireGuard, обфускатор-сервисы, веб-панель, nginx, скрипты онбординга роутеров, сторож авто-восстановления. Предустановленный Phobos **не требуется**.
|
Ставит **всё** со всеми зависимостями: wg-obfuscator, WireGuard, обфускатор-сервисы, веб-панель, nginx, скрипты онбординга роутеров, сторож авто-восстановления. Предустановленный Phobos **не требуется**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
### С параметрами
|
### С параметрами
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PANEL_PASS=МойПароль TG_TOKEN=123:abc TG_CHAT=123456789 \
|
PANEL_PASS=МойПароль TG_TOKEN=123:abc TG_CHAT=123456789 \
|
||||||
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Переменная | По умолчанию | Описание |
|
| Переменная | По умолчанию | Описание |
|
||||||
|-------------------|-----------------|----------|
|
|-------------------|-----------------|----------|
|
||||||
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
|
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
|
||||||
| `PANEL_PORT` | случайный | Порт веб-панели |
|
| `PANEL_PORT` | случайный | Порт веб-панели |
|
||||||
|
| `SERVER_WG_IPV4_NETWORK` | `10.25.0.0/16` | Подсеть VPN-клиентов (например `10.25.1.0/24`). Без переменной установщик спросит в терминале. Сервер получает первый адрес (`10.25.1.1`). Задаётся только при установке |
|
||||||
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
|
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
|
||||||
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
|
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
|
||||||
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
|
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
|
||||||
@@ -107,12 +125,13 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install
|
|||||||
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
||||||
|
|
||||||
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
|
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
|
||||||
|
DNS клиентов и свой AdGuard Home: [docs/DNS.md](docs/DNS.md). WARP — проверка и проблемы: [docs/WARP.md](docs/WARP.md).
|
||||||
|
|
||||||
### Вторичный сервер (для failover / балансировки)
|
### Вторичный сервер (для failover / балансировки)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MAIN_PROTO=http MAIN_SERVER=<ip_основного> MAIN_PORT=<порт_панели> MAIN_API_KEY=<API key из основного> \
|
MAIN_PROTO=http MAIN_SERVER=<ip_основного> MAIN_PORT=<порт_панели> MAIN_API_KEY=<API key из основного> \
|
||||||
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/secondary-setup.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/server/secondary-setup.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер сам зарегистрируется в панели, клиенты досылаются на него сразу. Если сервер за NAT — добавь `SERVER_IP=<внешний_ip>`.
|
Сервер сам зарегистрируется в панели, клиенты досылаются на него сразу. Если сервер за NAT — добавь `SERVER_IP=<внешний_ip>`.
|
||||||
@@ -122,7 +141,7 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/
|
|||||||
На входном VPS (чистый Debian/Ubuntu, Phobos не нужен):
|
На входном VPS (чистый Debian/Ubuntu, Phobos не нужен):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/phobos-cascade.sh) enable <IP_выходного_сервера> 2083,5443,993
|
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/server/phobos-cascade.sh) enable <IP_выходного_сервера> 2083,5443,993
|
||||||
```
|
```
|
||||||
|
|
||||||
Затем в панели выходного сервера → **Настройки → Адрес для клиентов и каскад** → впиши IP/домен входа. Роутеры переключатся сами (~15 с), телефонам — заново открыть ссылку/QR. Отключить: `phobos-cascade disable`.
|
Затем в панели выходного сервера → **Настройки → Адрес для клиентов и каскад** → впиши IP/домен входа. Роутеры переключатся сами (~15 с), телефонам — заново открыть ссылку/QR. Отключить: `phobos-cascade disable`.
|
||||||
@@ -137,38 +156,39 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/
|
|||||||
|
|
||||||
## Приложения для Android
|
## Приложения для Android
|
||||||
|
|
||||||
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.1/PCA-Phobos-VPN.apk) · исходники `android-client/`.
|
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.10/PCA-Phobos-VPN.apk) · исходники `android-client/`.
|
||||||
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.1/PCA-Phobos.apk) · исходники `android/`.
|
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.4/PCA-Phobos.apk) · исходники `android/`.
|
||||||
|
|
||||||
## Клиенты для телефона
|
## Подключение устройств
|
||||||
|
|
||||||
В таблице **Клиенты** у каждого клиента есть кнопки:
|
Клиенты → у клиента окно **«Подключение»** с вкладками:
|
||||||
|
|
||||||
- 📱 **Android** — `phobos://`-ссылка + QR для приложения **PhobosWG** (с обфускацией). Скан QR → импорт.
|
- **Android** — `phobos://`-ссылка + QR для **PCA Phobos VPN** или **PhobosWG** (с обфускацией), файл `.conf`.
|
||||||
- 🍎 **iPhone / iOS** — обычный WireGuard-конфиг + QR для официального WireGuard (**без** обфускации). Требует открытого порта 51820 (`ALLOW_PLAIN_WG=1` при установке).
|
- **Windows** — ссылка `phobos://` и кнопка скачивания **PCA Phobos для Windows** (архив из закрытого релиза, нужен токен подписчика в «Настройках»).
|
||||||
|
- **Router** — команда установки на Keenetic/OpenWrt (Entware) и команда удаления.
|
||||||
|
- **HR** — ссылка для HydraRoute Neo строго по формату автора Phobos.
|
||||||
|
- 🍎 **iPhone** в stable — только обычный WireGuard (без обфускации, нужен порт 51820, `ALLOW_PLAIN_WG=1`). Приложения **PCA Phobos VPN для iPhone и macOS** (с обфускацией, автопереход) — в каналах **beta/dev** для подписчиков Boosty.
|
||||||
|
|
||||||
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
|
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
|
||||||
|
|
||||||
**Приложения:**
|
|
||||||
- **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-клиента.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Обновление и откат
|
## Обновление и откат
|
||||||
|
|
||||||
Проект версионируется (см. файл `VERSION` и git-теги `vX.Y.Z`).
|
Проект версионируется (см. файл `VERSION` и git-теги `vX.Y.Z`).
|
||||||
|
|
||||||
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**.
|
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**. С 1.4.11 панель сама раз в час проверяет свой канал: вышла новая версия — сверху зелёная полоса «Доступна новая версия N» с кнопкой **Обновить** (в окне — что нового, бэкап, откат). Там же видно, если вышли новые версии приложений. В меню под «Настройками» в это время горит зелёный пункт **«Обновить до N»**.
|
||||||
|
|
||||||
|
> Панели до 1.4.11 этой полосы не показывают — один раз обнови вручную (`phobos-update`), дальше она появляется сама.
|
||||||
|
|
||||||
**Из консоли:**
|
**Из консоли:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
phobos-update # обновить до последней версии (stable)
|
phobos-update # обновить до последней версии (stable)
|
||||||
phobos-update beta|dev # каналы для подписчиков (GH_TOKEN), сейчас = та же 1.4.0
|
phobos-update beta|dev # каналы для подписчиков Boosty (токен сохраняется в /opt/phobos-panel/.gh_token)
|
||||||
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 --check # установленная версия и версии всех каналов
|
||||||
phobos-update --list # список бэкапов
|
phobos-update --list # список бэкапов
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -176,11 +196,53 @@ phobos-update --list # список бэкапов
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Пароль панели — сменить или вспомнить
|
||||||
|
|
||||||
|
- **Сменить из панели:** «Настройки» → поле **«Пароль панели»** → новый пароль → «Сохранить». Пустое поле — пароль не меняется. Логин всегда `admin`.
|
||||||
|
- **Вспомнить:** `grep admin_pass /opt/phobos-panel/settings.json`
|
||||||
|
- **Сменить без входа (из консоли сервера):**
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json; p="/opt/phobos-panel/settings.json"; s=json.load(open(p))
|
||||||
|
s["admin_pass"]="НовыйПароль"; json.dump(s, open(p,"w"), ensure_ascii=False, indent=2)
|
||||||
|
EOF
|
||||||
|
systemctl restart phobos-panel
|
||||||
|
```
|
||||||
|
- Пароль по умолчанию (если не задавал `PANEL_PASS` при установке): `OcAdmin2026!` — смени сразу после установки.
|
||||||
|
- Порт панели: `cat /opt/phobos-panel/.port` · API key: `grep server_api_key /opt/phobos-panel/settings.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Удаление
|
||||||
|
|
||||||
|
**Сервер целиком** (панель, обфускатор, WireGuard `wg0`, nginx-сайт, сторож, правила iptables, каталоги Phobos):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/uninstall.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Спросит подтверждение — ответь `yes` (или `y` / `да`; до 1.4.6 работал только полный `yes`). Без вопросов: `PURGE=1 bash <(curl -fsSL …/uninstall.sh)`. Перед удалением сохрани бэкап из панели («Настройки» → «Бэкап»), если сервер понадобится снова. Клиенты на других серверах при удалении одного сервера продолжат работать (если есть резервный).
|
||||||
|
|
||||||
|
**Один клиент** — в панели «Клиенты» → 🗑 у клиента: ключ отзывается на всех серверах, роутер/телефон больше не подключится.
|
||||||
|
|
||||||
|
**С роутера Keenetic** (по SSH в Entware) — команда есть и в панели, внизу окна установки роутера («Удалить с роутера»):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/etc/Phobos/phobos-uninstall.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт останавливает обфускатор, удаляет его конфиги и сам удаляет интерфейс WireGuard Phobos на роутере.
|
||||||
|
|
||||||
|
**Телефон / Windows:** удалить туннель в приложении и само приложение; в Windows — `uninstall.cmd` из папки программы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Возможные проблемы и решения
|
## Возможные проблемы и решения
|
||||||
|
|
||||||
| Симптом | Причина | Решение |
|
| Симптом | Причина | Решение |
|
||||||
|---------|---------|---------|
|
|---------|---------|---------|
|
||||||
| **Команда установки → 403 / не качается** | репозиторий приватный | Сделай репозиторий публичным, либо ставь с токеном: `GH_TOKEN=ghp_xxx bash <(curl -fsSL -H "Authorization: token ghp_xxx" .../install.sh)` |
|
| **Команда установки → 403 / 404 / не качается** | старая ссылка (`andrey271192` заблокирован) или GitHub недоступен с сервера | Ссылки только `github.com/adleibzon-rgb/…`; если GitHub не открывается — ставь с зеркала `git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh` |
|
||||||
|
| **Удаление: «Отменено»** | старая версия uninstall.sh понимала только `yes` | Набери `yes` полностью или `PURGE=1` |
|
||||||
| **Панель не открывается** | панель перезапускалась (обновление) / сервер недоступен | Подожди 5 сек, обнови страницу. Проверь: `systemctl status phobos-panel` |
|
| **Панель не открывается** | панель перезапускалась (обновление) / сервер недоступен | Подожди 5 сек, обнови страницу. Проверь: `systemctl status phobos-panel` |
|
||||||
| **Клиент Offline, хотя работает** | роутер на резервном сервере | Норма — статус считается по handshake на **любом** сервере; обнови страницу |
|
| **Клиент Offline, хотя работает** | роутер на резервном сервере | Норма — статус считается по handshake на **любом** сервере; обнови страницу |
|
||||||
| **«SSH timeout» при тесте роутера** | роутер за NAT, входящий SSH закрыт | Это нормально — управление идёт по pull-каналу через туннель, SSH не нужен |
|
| **«SSH timeout» при тесте роутера** | роутер за NAT, входящий SSH закрыт | Это нормально — управление идёт по pull-каналу через туннель, SSH не нужен |
|
||||||
@@ -189,6 +251,7 @@ phobos-update --list # список бэкапов
|
|||||||
| **iPhone не подключается по `phobos://`** | на iOS нет приложения-обфускатора | Используй 🍎-конфиг (обычный WireGuard) + открой 51820 (`ALLOW_PLAIN_WG=1`) |
|
| **iPhone не подключается по `phobos://`** | на iOS нет приложения-обфускатора | Используй 🍎-конфиг (обычный WireGuard) + открой 51820 (`ALLOW_PLAIN_WG=1`) |
|
||||||
| **Смена сервера применяется не сразу** | роутер тянет конфиг по cron | Применяется за ~12–15 сек, не мгновенно |
|
| **Смена сервера применяется не сразу** | роутер тянет конфиг по cron | Применяется за ~12–15 сек, не мгновенно |
|
||||||
| **Забыл порт панели** | случайный порт | `cat /opt/phobos-panel/.port` |
|
| **Забыл порт панели** | случайный порт | `cat /opt/phobos-panel/.port` |
|
||||||
|
| **Забыл пароль панели** | | см. раздел [«Пароль панели»](#пароль-панели--сменить-или-вспомнить) |
|
||||||
| **Забыл API key** | | `grep server_api_key /opt/phobos-panel/settings.json` |
|
| **Забыл API key** | | `grep server_api_key /opt/phobos-panel/settings.json` |
|
||||||
| **Домен: certbot failed** | A-запись не на этот сервер / закрыт 80 | `dig +short домен` = IP сервера; открой 80/tcp; повтори в «Настройках» |
|
| **Домен: certbot failed** | A-запись не на этот сервер / закрыт 80 | `dig +short домен` = IP сервера; открой 80/tcp; повтори в «Настройках» |
|
||||||
| **Лимит скорости не действует** | wg0 перезапущен | панель сама восстановит за ≤1 мин; вручную: `phobos-limit.sh apply` |
|
| **Лимит скорости не действует** | wg0 перезапущен | панель сама восстановит за ≤1 мин; вручную: `phobos-limit.sh apply` |
|
||||||
@@ -233,3 +296,6 @@ PCA Phobos — это веб-панель и turnkey-инсталлятор по
|
|||||||
- [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) — обфускация WireGuard-трафика (GPL-3.0), автор **ClusterM**. Поддержать: [Boosty](https://boosty.to/cluster) ❤️
|
- [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) — обфускация WireGuard-трафика (GPL-3.0), автор **ClusterM**. Поддержать: [Boosty](https://boosty.to/cluster) ❤️
|
||||||
|
|
||||||
Спасибо авторам за отличные инструменты.
|
Спасибо авторам за отличные инструменты.
|
||||||
|
|
||||||
|
## Смена сервера у клиента
|
||||||
|
См. [docs/SERVER-SWITCH.md](docs/SERVER-SWITCH.md): какие приложения переключаются сами (роутер, iOS, Android, Windows) и что делать в остальных.
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.pca.phobos.vpn"
|
package="com.pca.phobos.vpn"
|
||||||
android:versionCode="142"
|
android:versionCode="147"
|
||||||
android:versionName="1.4.2">
|
android:versionName="1.4.6">
|
||||||
|
|
||||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:label="PCA Phobos VPN"
|
android:label="PCA Phobos VPN"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
@@ -33,6 +35,18 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- quick settings tile "PCA VPN" -->
|
||||||
|
<service
|
||||||
|
android:name=".PhobosTile"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="PCA VPN"
|
||||||
|
android:icon="@drawable/ic_vpn"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<!-- from com.wireguard.android:tunnel (AAR manifest, merged by hand: no Gradle) -->
|
<!-- from com.wireguard.android:tunnel (AAR manifest, merged by hand: no Gradle) -->
|
||||||
<service
|
<service
|
||||||
android:name="com.wireguard.android.backend.GoBackend$VpnService"
|
android:name="com.wireguard.android.backend.GoBackend$VpnService"
|
||||||
|
|||||||
2
android-client/assets/ru_nets.txt
Normal file
8
android-client/res/drawable/ic_vpn.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- monochrome shield for the status bar and the quick settings tile -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,2 L20,5 L20,11 C20,16 16.6,20.2 12,22 C7.4,20.2 4,16 4,11 L4,5 Z M10.5,8 h3 v8 h-3 z" />
|
||||||
|
</vector>
|
||||||
@@ -56,12 +56,23 @@ import java.util.concurrent.Executors;
|
|||||||
*/
|
*/
|
||||||
public class MainActivity extends Activity {
|
public class MainActivity extends Activity {
|
||||||
static final int LPORT = 13255;
|
static final int LPORT = 13255;
|
||||||
|
static final String ACTION_TOGGLE = "com.pca.phobos.vpn.TOGGLE";
|
||||||
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
|
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
|
||||||
|
|
||||||
static GoBackend backend;
|
static GoBackend backend;
|
||||||
static Process obfuscator, relay;
|
static Process obfuscator, relay;
|
||||||
static final int TCP_LPORT = 13256;
|
static final int TCP_LPORT = 13256;
|
||||||
static volatile String transport = ""; // "UDP" | "TCP" while up
|
static volatile String transport = ""; // "UDP" | "TCP" | "Облако" while up
|
||||||
|
static WsRelay wsRelay;
|
||||||
|
static V6Relay v6Relay;
|
||||||
|
static final int V6_LPORT = 13257;
|
||||||
|
static final Object LOCK = new Object();
|
||||||
|
/** Panel subscription (# pca-sub): servers in the panel's order, IPv6 entries last; failover walks them round-robin. */
|
||||||
|
static org.json.JSONArray sub = new org.json.JSONArray();
|
||||||
|
static volatile int subIndex;
|
||||||
|
static volatile String curConf = ""; // profile pointed at the server in use
|
||||||
|
static volatile long watchGen; // bumped on connect/disconnect: stops the old watcher
|
||||||
|
static final long SILENCE_MS = 40000; // no server reply this long -> next server
|
||||||
static final Tunnel TUNNEL = new Tunnel() {
|
static final Tunnel TUNNEL = new Tunnel() {
|
||||||
public String getName() { return "phobos"; }
|
public String getName() { return "phobos"; }
|
||||||
public void onStateChange(Tunnel.State s) { }
|
public void onStateChange(Tunnel.State s) { }
|
||||||
@@ -71,7 +82,7 @@ public class MainActivity extends Activity {
|
|||||||
private final Handler ui = new Handler(Looper.getMainLooper());
|
private final Handler ui = new Handler(Looper.getMainLooper());
|
||||||
private SharedPreferences prefs;
|
private SharedPreferences prefs;
|
||||||
private boolean dark;
|
private boolean dark;
|
||||||
private TextView status, traffic;
|
private TextView status, traffic, server;
|
||||||
private Button power;
|
private Button power;
|
||||||
private long lastRx, lastTx, lastT;
|
private long lastRx, lastTx, lastT;
|
||||||
|
|
||||||
@@ -81,6 +92,9 @@ public class MainActivity extends Activity {
|
|||||||
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
|
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
|
||||||
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
|
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
|
||||||
if (backend == null) backend = new GoBackend(getApplicationContext());
|
if (backend == null) backend = new GoBackend(getApplicationContext());
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 33 && checkSelfPermission("android.permission.POST_NOTIFICATIONS") != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(new String[]{"android.permission.POST_NOTIFICATIONS"}, 8);
|
||||||
|
}
|
||||||
handleIntent(getIntent());
|
handleIntent(getIntent());
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -93,6 +107,14 @@ public class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleIntent(Intent i) {
|
private void handleIntent(Intent i) {
|
||||||
|
if (i != null && ACTION_TOGGLE.equals(i.getAction())) {
|
||||||
|
setIntent(new Intent());
|
||||||
|
// tile: toggle and get out of the way, unless Android must ask for the VPN permission first
|
||||||
|
if (!isUp() && !prefs.getString("conf", "").isEmpty() && VpnService.prepare(this) == null) { bg.execute(this::up); finish(); return; }
|
||||||
|
if (isUp()) { bg.execute(this::down); finish(); return; }
|
||||||
|
toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (i != null && i.getData() != null && "phobos".equals(i.getData().getScheme())) {
|
if (i != null && i.getData() != null && "phobos".equals(i.getData().getScheme())) {
|
||||||
importLink(i.getDataString());
|
importLink(i.getDataString());
|
||||||
}
|
}
|
||||||
@@ -139,16 +161,21 @@ public class MainActivity extends Activity {
|
|||||||
|
|
||||||
// ── profile ───────────────────────────────────────────────────────────
|
// ── profile ───────────────────────────────────────────────────────────
|
||||||
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
|
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
|
||||||
private void importLink(String link) {
|
private void importLink(String link) { importText(link, "Phobos"); }
|
||||||
|
|
||||||
|
/** A phobos:// link, or the text of a client .conf ([Interface] + [Peer] + [instance]). */
|
||||||
|
private void importText(String text, String name) {
|
||||||
try {
|
try {
|
||||||
String s = link.trim();
|
String s = text.replace("\uFEFF", "").trim(), conf = s;
|
||||||
String name = "Phobos";
|
if (s.startsWith("phobos://")) {
|
||||||
int hash = s.indexOf('#');
|
int hash = s.indexOf('#');
|
||||||
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
|
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
|
||||||
s = s.replaceFirst("^phobos://", "").trim();
|
s = s.replaceFirst("^phobos://", "").trim();
|
||||||
String conf = new String(Base64.decode(s, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING), StandardCharsets.UTF_8);
|
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
|
if (!conf.contains("[Interface]") || !conf.contains("[instance]"))
|
||||||
|
throw new IllegalArgumentException("нужен конфиг Phobos: секции [Interface], [Peer] и [instance]");
|
||||||
|
Config.parse(new BufferedReader(new StringReader(wgPart(conf, MODE_ALL, "", false)))); // validate
|
||||||
prefs.edit().putString("conf", conf).putString("name", name).apply();
|
prefs.edit().putString("conf", conf).putString("name", name).apply();
|
||||||
toast("Профиль «" + name + "» импортирован");
|
toast("Профиль «" + name + "» импортирован");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -173,13 +200,15 @@ public class MainActivity extends Activity {
|
|||||||
private static String key(String line) { return line.substring(0, line.indexOf('=')).trim().toLowerCase(); }
|
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(); }
|
private static String val(String line) { return line.substring(line.indexOf('=') + 1).trim(); }
|
||||||
|
|
||||||
String wgPart(String conf, String mode, String apps) {
|
String wgPart(String conf, String mode, String apps, boolean ruRoutes) {
|
||||||
StringBuilder sb = new StringBuilder("[Interface]\n");
|
StringBuilder sb = new StringBuilder("[Interface]\n");
|
||||||
for (String ln : section(conf, "[Interface]")) {
|
for (String ln : section(conf, "[Interface]")) {
|
||||||
String k = key(ln);
|
String k = key(ln);
|
||||||
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
|
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
|
||||||
|
if (k.equals("dns") && !myDns().isEmpty()) continue;
|
||||||
sb.append(ln).append('\n');
|
sb.append(ln).append('\n');
|
||||||
}
|
}
|
||||||
|
if (!myDns().isEmpty()) sb.append("DNS = ").append(myDns()).append('\n');
|
||||||
List<String> sel = new ArrayList<>();
|
List<String> sel = new ArrayList<>();
|
||||||
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
|
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
|
||||||
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
|
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
|
||||||
@@ -192,7 +221,7 @@ public class MainActivity extends Activity {
|
|||||||
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
|
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
|
||||||
}
|
}
|
||||||
sb.append("\n[Peer]\n");
|
sb.append("\n[Peer]\n");
|
||||||
String ruBypass = prefs != null && prefs.getBoolean("ru_direct", true) ? ruBypassAllowedIps() : null;
|
String ruBypass = ruRoutes && prefs.getBoolean("ru_direct", true) ? ruBypassAllowedIps() : null;
|
||||||
for (String ln : section(conf, "[Peer]")) {
|
for (String ln : section(conf, "[Peer]")) {
|
||||||
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
|
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
|
||||||
if (key(ln).equals("allowedips") && ruBypass != null) ln = "AllowedIPs = " + ruBypass;
|
if (key(ln).equals("allowedips") && ruBypass != null) ln = "AllowedIPs = " + ruBypass;
|
||||||
@@ -201,28 +230,40 @@ public class MainActivity extends Activity {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String ruCache;
|
/** Internet minus Russian networks (assets/ru_nets.txt) and the user's own sites; resolves domains, so not on the UI thread. */
|
||||||
|
|
||||||
/** Internet minus Russian networks (assets/ru_bypass.txt, generated from ipdeny). */
|
|
||||||
private String ruBypassAllowedIps() {
|
private String ruBypassAllowedIps() {
|
||||||
if (ruCache != null) return ruCache;
|
List<String> ru = new ArrayList<>();
|
||||||
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(
|
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(
|
||||||
getAssets().open("ru_bypass.txt"), StandardCharsets.UTF_8))) {
|
getAssets().open("ru_nets.txt"), StandardCharsets.UTF_8))) {
|
||||||
for (String ln; (ln = r.readLine()) != null; ) {
|
for (String ln; (ln = r.readLine()) != null; ) {
|
||||||
if (!ln.startsWith("#") && ln.contains("/")) { ruCache = ln.trim(); break; }
|
if (!ln.startsWith("#") && ln.contains("/")) for (String c : ln.split(",")) ru.add(c.trim());
|
||||||
}
|
}
|
||||||
} catch (Exception e) { log("ru_bypass: " + e); }
|
} catch (Exception e) { log("ru_nets: " + e); }
|
||||||
return ruCache;
|
return RuNets.allowed(ru, lines(prefs.getString("my_direct", "")), lines(prefs.getString("my_vpn", "")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<String> lines(String s) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (String l : s.split("[\\n,\\s]+")) if (!l.trim().isEmpty()) out.add(l.trim());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User's own DNS servers ("1.1.1.1, 8.8.8.8"); empty = the profile's. */
|
||||||
|
private String myDns() { return prefs.getString("dns", ""); }
|
||||||
|
|
||||||
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
|
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
|
||||||
|
|
||||||
private String obfPart(String conf, boolean viaTcp) {
|
private static final java.util.Set<String> OBF_KEYS = new java.util.HashSet<>(java.util.Arrays.asList(
|
||||||
|
"target", "key", "masking", "idle-timeout", "max-dummy", "static-bindings", "max-clients", "target-if", "allow-clean", "in-timeout", "resolve-interval"));
|
||||||
|
|
||||||
|
private String obfPart(String conf, String targetOverride) {
|
||||||
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
|
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
|
||||||
for (String ln : section(conf, "[instance]")) {
|
for (String ln : section(conf, "[instance]")) {
|
||||||
String k = key(ln);
|
String k = key(ln);
|
||||||
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
|
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
|
||||||
if (viaTcp && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
|
// configs from other Phobos builds carry keys our wg-obfuscator v1.6 rejects (obfuscate-bytes…)
|
||||||
|
if (!OBF_KEYS.contains(k)) continue;
|
||||||
|
if (targetOverride != null && k.equals("target")) ln = "target = " + targetOverride;
|
||||||
sb.append(ln).append('\n');
|
sb.append(ln).append('\n');
|
||||||
}
|
}
|
||||||
sb.append("verbose = INFO\n");
|
sb.append("verbose = INFO\n");
|
||||||
@@ -238,14 +279,99 @@ public class MainActivity extends Activity {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** wss:// of the user's Keenetic (KeenDNS cloud subdomain -> wsgw): set in the app, or "# pca-ws = wss://…" in the link. */
|
||||||
|
private String wsUrl(String conf) {
|
||||||
|
String u = prefs.getString("ws_url", "");
|
||||||
|
if (!u.isEmpty()) return u;
|
||||||
|
for (String ln : conf.split("\n")) {
|
||||||
|
String t = ln.trim();
|
||||||
|
if (t.startsWith("#") && t.contains("pca-ws") && t.contains("=")) return val(t);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "# pca-sub = http://panel/api/phone-sub/<client>?token=…" */
|
||||||
|
private static String subUrl(String conf) {
|
||||||
|
for (String ln : conf.split("\n")) {
|
||||||
|
String t = ln.trim();
|
||||||
|
if (t.startsWith("#") && t.contains("pca-sub") && t.contains("=")) return val(t);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same profile pointed at another subscription server (address, obfuscator key, server WG key, TCP port). */
|
||||||
|
static String applying(String conf, org.json.JSONObject s) {
|
||||||
|
String host = s.optString("host"), sec = "";
|
||||||
|
if (host.contains(":")) host = "[" + host + "]";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String raw : conf.split("\n", -1)) {
|
||||||
|
String ln = raw.trim();
|
||||||
|
if (ln.startsWith("[") && ln.endsWith("]")) sec = ln.toLowerCase();
|
||||||
|
else if (ln.startsWith("#")) { if (ln.contains("pca-tcp")) continue; }
|
||||||
|
else if (ln.contains("=")) {
|
||||||
|
String k = key(ln);
|
||||||
|
if (sec.equals("[instance]") && k.equals("target")) { sb.append("target = ").append(host).append(':').append(s.optInt("port")).append('\n'); continue; }
|
||||||
|
if (sec.equals("[instance]") && k.equals("key")) { sb.append("key = ").append(s.optString("key")).append('\n'); continue; }
|
||||||
|
if (sec.equals("[peer]") && k.equals("publickey")) { sb.append("PublicKey = ").append(s.optString("wg_pub")).append('\n'); continue; }
|
||||||
|
}
|
||||||
|
sb.append(raw).append('\n');
|
||||||
|
}
|
||||||
|
if (s.optInt("tcp") != 0) sb.append("# pca-tcp = ").append(s.optInt("tcp")).append('\n');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the server list from the panel (the app is outside the tunnel); cached when the panel is unreachable. */
|
||||||
|
private void refreshSub(String conf) {
|
||||||
|
String u = subUrl(conf);
|
||||||
|
if (u.isEmpty()) { sub = new org.json.JSONArray(); return; }
|
||||||
|
try {
|
||||||
|
java.net.HttpURLConnection c = (java.net.HttpURLConnection) new java.net.URL(u).openConnection();
|
||||||
|
c.setConnectTimeout(6000); c.setReadTimeout(6000);
|
||||||
|
try (java.io.InputStream in = c.getInputStream()) {
|
||||||
|
String body = new String(readAll(in), StandardCharsets.UTF_8);
|
||||||
|
org.json.JSONArray a = new org.json.JSONObject(body).getJSONArray("servers");
|
||||||
|
if (a.length() > 0) prefs.edit().putString("sub", a.toString()).apply();
|
||||||
|
}
|
||||||
|
} catch (Exception e) { log("sub: " + e); }
|
||||||
|
try { sub = new org.json.JSONArray(prefs.getString("sub", "[]")); } catch (Exception e) { sub = new org.json.JSONArray(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readAll(java.io.InputStream in) throws java.io.IOException {
|
||||||
|
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
for (int n; (n = in.read(buf)) > 0; ) o.write(buf, 0, n);
|
||||||
|
return o.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
private String target(String conf) {
|
private String target(String conf) {
|
||||||
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
||||||
return "?";
|
return "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── connect / disconnect ──────────────────────────────────────────────
|
// ── connect / disconnect ──────────────────────────────────────────────
|
||||||
private boolean isUp() {
|
private boolean isUp() { return tunnelUp(); }
|
||||||
try { return backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
|
|
||||||
|
static boolean tunnelUp() {
|
||||||
|
try { return backend != null && backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ongoing notification = shield in the status bar while the VPN is on (GoBackend's service has none). */
|
||||||
|
private void notifyState(boolean up) {
|
||||||
|
android.app.NotificationManager nm = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null) return;
|
||||||
|
if (!up) { nm.cancel(1); PhobosTile.refresh(this); return; }
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 26) {
|
||||||
|
nm.createNotificationChannel(new android.app.NotificationChannel("vpn", "VPN", android.app.NotificationManager.IMPORTANCE_LOW));
|
||||||
|
}
|
||||||
|
android.app.PendingIntent open = android.app.PendingIntent.getActivity(this, 0,
|
||||||
|
new Intent(this, MainActivity.class), android.app.PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
android.app.Notification.Builder b = android.os.Build.VERSION.SDK_INT >= 26
|
||||||
|
? new android.app.Notification.Builder(this, "vpn") : new android.app.Notification.Builder(this);
|
||||||
|
b.setSmallIcon(R.drawable.ic_vpn).setContentTitle("VPN")
|
||||||
|
.setContentText("Сервер: " + target(curConf.isEmpty() ? prefs.getString("conf", "") : curConf))
|
||||||
|
.setOngoing(true).setContentIntent(open);
|
||||||
|
try { nm.notify(1, b.build()); } catch (SecurityException ignored) { }
|
||||||
|
PhobosTile.refresh(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void toggle() {
|
private void toggle() {
|
||||||
@@ -258,74 +384,147 @@ public class MainActivity extends Activity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onActivityResult(int req, int res, Intent data) {
|
protected void onActivityResult(int req, int res, Intent data) {
|
||||||
|
if (req == 9) {
|
||||||
|
if (res != RESULT_OK || data == null || data.getData() == null) return;
|
||||||
|
String name = "Phobos";
|
||||||
|
try (android.database.Cursor c = getContentResolver().query(data.getData(), null, null, null, null)) {
|
||||||
|
int i = c == null ? -1 : c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME);
|
||||||
|
if (i >= 0 && c.moveToFirst()) name = c.getString(i).replaceFirst("\\.[^.]*$", "");
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
try (java.io.InputStream in = getContentResolver().openInputStream(data.getData())) {
|
||||||
|
importText(new String(readAll(in), StandardCharsets.UTF_8), name);
|
||||||
|
} catch (Exception e) { toast("Не удалось прочитать файл: " + e.getMessage()); }
|
||||||
|
applyIfUp();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req == 7 && res == RESULT_OK) bg.execute(this::up);
|
if (req == 7 && res == RESULT_OK) bg.execute(this::up);
|
||||||
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start (or restart) obfuscator; via TCP it dials the local udptcp relay. */
|
/** Start (or restart) obfuscator; via TCP / cloud it dials the local relay on TCP_LPORT. */
|
||||||
private void startTransport(String conf, boolean viaTcp) throws Exception {
|
private void startTransport(String conf, String via) throws Exception {
|
||||||
stopObfuscator();
|
stopObfuscator();
|
||||||
String lib = getApplicationInfo().nativeLibraryDir;
|
String lib = getApplicationInfo().nativeLibraryDir;
|
||||||
|
boolean viaTcp = "tcp".equals(via), viaWs = "cloud".equals(via);
|
||||||
|
if (viaWs) {
|
||||||
|
if (wsUrl(conf).isEmpty()) throw new IllegalStateException("не задан адрес роутера (wss://…) для канала «Облако»");
|
||||||
|
wsRelay = new WsRelay(TCP_LPORT, wsUrl(conf), this::log);
|
||||||
|
wsRelay.start();
|
||||||
|
}
|
||||||
|
String target = target(conf), host = target.replaceAll(":\\d+$", "").replaceAll("[\\[\\]]", "");
|
||||||
|
String override = viaTcp || viaWs ? "127.0.0.1:" + TCP_LPORT : null;
|
||||||
|
if (!viaTcp && !viaWs && host.contains(":")) {
|
||||||
|
v6Relay = new V6Relay(V6_LPORT, host, Integer.parseInt(target.replaceAll("^.*:", "")));
|
||||||
|
v6Relay.start();
|
||||||
|
override = "127.0.0.1:" + V6_LPORT;
|
||||||
|
}
|
||||||
if (viaTcp) {
|
if (viaTcp) {
|
||||||
String host = target(conf).replaceAll(":\\d+$", "");
|
|
||||||
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
|
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
|
||||||
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
|
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
|
||||||
}
|
}
|
||||||
File cfg = new File(getFilesDir(), "obf.conf");
|
File cfg = new File(getFilesDir(), "obf.conf");
|
||||||
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp).getBytes(StandardCharsets.UTF_8)); }
|
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, override).getBytes(StandardCharsets.UTF_8)); }
|
||||||
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
|
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
|
||||||
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
|
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
|
||||||
Thread.sleep(600);
|
Thread.sleep(600);
|
||||||
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
||||||
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
|
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
|
||||||
transport = viaTcp ? "TCP" : "UDP";
|
transport = viaWs ? "Облако" : viaTcp ? "TCP" : "UDP";
|
||||||
log("transport " + transport + " -> " + target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : ""));
|
log("transport " + transport + " -> " + (viaWs ? wsUrl(conf) : target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : "")));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void up() {
|
private void up() {
|
||||||
|
synchronized (LOCK) {
|
||||||
try {
|
try {
|
||||||
String conf = prefs.getString("conf", "");
|
String base = prefs.getString("conf", "");
|
||||||
String mode = prefs.getString("transport", "auto");
|
refreshSub(base);
|
||||||
boolean tcpAvail = !tcpPort(conf).isEmpty();
|
subIndex = 0;
|
||||||
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
|
connect(sub.length() > 0 ? applying(base, sub.getJSONObject(0)) : base);
|
||||||
startTransport(conf, "tcp".equals(mode));
|
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", servers " + Math.max(1, sub.length()));
|
||||||
Config c = Config.parse(new BufferedReader(new StringReader(
|
notifyState(true);
|
||||||
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
|
|
||||||
backend.setState(TUNNEL, Tunnel.State.UP, c);
|
|
||||||
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", transport " + mode);
|
|
||||||
post("Подключено (" + transport + ")");
|
post("Подключено (" + transport + ")");
|
||||||
if ("auto".equals(mode) && tcpAvail) watchUdp(conf);
|
if (sub.length() > 1 && !"cloud".equals(prefs.getString("transport", "auto"))) watchServer(base);
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
stopObfuscator();
|
stopObfuscator();
|
||||||
log("up failed: " + e);
|
log("up failed: " + e);
|
||||||
post("Ошибка: " + e.getMessage());
|
post("Ошибка: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Obfuscator (+ relay) and WireGuard for one server; called again in place on failover. */
|
||||||
|
private void connect(String conf) throws Exception {
|
||||||
|
String mode = prefs.getString("transport", "auto");
|
||||||
|
boolean tcpAvail = !tcpPort(conf).isEmpty();
|
||||||
|
if ("tcp".equals(mode) && !tcpAvail && sub.length() == 0) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
|
||||||
|
startTransport(conf, "tcp".equals(mode) && !tcpAvail ? "udp" : "auto".equals(mode) ? "udp" : mode);
|
||||||
|
Config c = Config.parse(new BufferedReader(new StringReader(
|
||||||
|
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", ""), true))));
|
||||||
|
backend.setState(TUNNEL, Tunnel.State.UP, c);
|
||||||
|
curConf = conf;
|
||||||
|
if ("auto".equals(mode) && tcpAvail) watchUdp(conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server answered nothing for SILENCE_MS -> next subscription server. Runs while the VPN service keeps the process alive. */
|
||||||
|
private void watchServer(String base) {
|
||||||
|
final long gen = ++watchGen;
|
||||||
|
new Thread(() -> {
|
||||||
|
long lastRx = -1, since = System.currentTimeMillis();
|
||||||
|
while (gen == watchGen) {
|
||||||
|
try { Thread.sleep(5000); } catch (InterruptedException e) { return; }
|
||||||
|
if (gen != watchGen || !isUp()) return;
|
||||||
|
long rx;
|
||||||
|
try { rx = backend.getStatistics(TUNNEL).totalRx(); } catch (Exception e) { continue; }
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (rx != lastRx) { lastRx = rx; since = now; continue; }
|
||||||
|
if (now - since < SILENCE_MS) continue;
|
||||||
|
since = now;
|
||||||
|
synchronized (LOCK) {
|
||||||
|
if (gen != watchGen) return;
|
||||||
|
try {
|
||||||
|
subIndex = (subIndex + 1) % sub.length();
|
||||||
|
org.json.JSONObject s = sub.getJSONObject(subIndex);
|
||||||
|
log("server silent -> " + s.optString("host") + ":" + s.optInt("port"));
|
||||||
|
connect(applying(base, s));
|
||||||
|
notifyState(true);
|
||||||
|
post("Сервер не отвечал — перешёл на " + target(curConf));
|
||||||
|
} catch (Throwable e) { log("failover: " + e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "phobos-watch").start();
|
||||||
|
}
|
||||||
|
|
||||||
/** Auto: no bytes back over UDP within 15 s -> UDP is cut, move the obfuscator to TCP. */
|
/** Auto: no bytes back over UDP within 15 s -> UDP is cut, move the obfuscator to TCP. */
|
||||||
private void watchUdp(String conf) {
|
private void watchUdp(String conf) {
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
|
long rx0 = backend.getStatistics(TUNNEL).totalRx();
|
||||||
Thread.sleep(15000);
|
Thread.sleep(15000);
|
||||||
if (!isUp() || !"UDP".equals(transport)) return;
|
if (!isUp() || !"UDP".equals(transport) || !conf.equals(curConf)) return;
|
||||||
if (backend.getStatistics(TUNNEL).totalRx() > 0) return;
|
if (backend.getStatistics(TUNNEL).totalRx() > rx0) return;
|
||||||
log("UDP: no answer in 15 s -> switching to TCP");
|
log("UDP: no answer in 15 s -> switching to TCP");
|
||||||
startTransport(conf, true);
|
startTransport(conf, "tcp");
|
||||||
post("UDP не отвечает — переключился на TCP");
|
post("UDP не отвечает — переключился на TCP");
|
||||||
} catch (Throwable e) { log("watchUdp: " + e); }
|
} catch (Throwable e) { log("watchUdp: " + e); }
|
||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void down() {
|
private void down() {
|
||||||
|
watchGen++;
|
||||||
|
curConf = "";
|
||||||
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
|
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
|
||||||
stopObfuscator();
|
stopObfuscator();
|
||||||
transport = "";
|
transport = "";
|
||||||
|
notifyState(false);
|
||||||
post("Отключено");
|
post("Отключено");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void stopObfuscator() {
|
private static void stopObfuscator() {
|
||||||
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
|
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
|
||||||
if (relay != null) { relay.destroy(); relay = null; }
|
if (relay != null) { relay.destroy(); relay = null; }
|
||||||
|
if (wsRelay != null) { wsRelay.stop(); wsRelay = null; }
|
||||||
|
if (v6Relay != null) { v6Relay.stop(); v6Relay = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
|
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
|
||||||
@@ -359,7 +558,8 @@ public class MainActivity extends Activity {
|
|||||||
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
|
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
|
||||||
} else {
|
} else {
|
||||||
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
|
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
|
||||||
prof.addView(tv("Сервер: " + target(conf), muted(), 14, false));
|
server = tv("", muted(), 14, false);
|
||||||
|
prof.addView(server);
|
||||||
}
|
}
|
||||||
box.addView(prof, mw(0));
|
box.addView(prof, mw(0));
|
||||||
|
|
||||||
@@ -411,23 +611,31 @@ public class MainActivity extends Activity {
|
|||||||
RadioGroup tg = new RadioGroup(this);
|
RadioGroup tg = new RadioGroup(this);
|
||||||
tg.setOrientation(RadioGroup.HORIZONTAL);
|
tg.setOrientation(RadioGroup.HORIZONTAL);
|
||||||
String tmode = prefs.getString("transport", "auto");
|
String tmode = prefs.getString("transport", "auto");
|
||||||
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}}) {
|
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}, {"cloud", "Облако"}}) {
|
||||||
RadioButton r = new RadioButton(this);
|
RadioButton r = new RadioButton(this);
|
||||||
r.setText(m[1]);
|
r.setText(m[1]);
|
||||||
r.setTextColor(text());
|
r.setTextColor(text());
|
||||||
r.setId(View.generateViewId());
|
r.setId(View.generateViewId());
|
||||||
r.setChecked(m[0].equals(tmode));
|
r.setChecked(m[0].equals(tmode));
|
||||||
r.setOnClickListener(v -> { prefs.edit().putString("transport", m[0]).apply(); applyIfUp(); });
|
r.setOnClickListener(v -> {
|
||||||
|
prefs.edit().putString("transport", m[0]).apply();
|
||||||
|
if ("cloud".equals(m[0])) wsDialog(); else applyIfUp();
|
||||||
|
});
|
||||||
tg.addView(r);
|
tg.addView(r);
|
||||||
}
|
}
|
||||||
split.addView(tg);
|
split.addView(tg);
|
||||||
|
int ns = lines(prefs.getString("my_direct", "")).size() + lines(prefs.getString("my_vpn", "")).size();
|
||||||
|
split.addView(button("Свои сайты (" + ns + ")", 0xFF475569, v -> sitesDialog()), mw(4));
|
||||||
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
|
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
|
||||||
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
|
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
|
||||||
|
split.addView(button("Свой DNS" + (myDns().isEmpty() ? "" : " (" + myDns() + ")"), 0xFF475569, v -> dnsDialog()), mw(8));
|
||||||
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
|
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
|
||||||
muted(), 12, false), mw(6));
|
muted(), 12, false), mw(6));
|
||||||
box.addView(split, mw(20));
|
box.addView(split, mw(20));
|
||||||
|
|
||||||
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
|
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
|
||||||
|
box.addView(button("Импорт из файла .conf", 0xFF4F46E5, v -> startActivityForResult(
|
||||||
|
new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"), 9)), mw(8));
|
||||||
box.addView(button("Проверить скорость", 0xFF0EA5E9, v -> speedtest()), mw(8));
|
box.addView(button("Проверить скорость", 0xFF0EA5E9, v -> speedtest()), mw(8));
|
||||||
box.addView(button("Журнал", 0xFF475569, v -> showLog()), mw(8));
|
box.addView(button("Журнал", 0xFF475569, v -> showLog()), mw(8));
|
||||||
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||||
@@ -441,6 +649,8 @@ public class MainActivity extends Activity {
|
|||||||
if (power == null) return;
|
if (power == null) return;
|
||||||
boolean up = isUp();
|
boolean up = isUp();
|
||||||
power.setText(up ? "Отключить" : "Подключить");
|
power.setText(up ? "Отключить" : "Подключить");
|
||||||
|
String conf = prefs.getString("conf", "");
|
||||||
|
if (server != null && !conf.isEmpty()) server.setText("Сервер: " + target(up && !curConf.isEmpty() ? curConf : conf));
|
||||||
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
|
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
|
||||||
status.setText(up ? "🟢 VPN включён" + (transport.isEmpty() ? "" : " · " + transport) : "⚪ VPN выключен");
|
status.setText(up ? "🟢 VPN включён" + (transport.isEmpty() ? "" : " · " + transport) : "⚪ VPN выключен");
|
||||||
if (up) {
|
if (up) {
|
||||||
@@ -473,6 +683,68 @@ public class MainActivity extends Activity {
|
|||||||
.setNegativeButton("Отмена", null).show();
|
.setNegativeButton("Отмена", null).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void dnsDialog() {
|
||||||
|
final EditText e = new EditText(this);
|
||||||
|
e.setHint("1.1.1.1, 8.8.8.8");
|
||||||
|
e.setText(myDns());
|
||||||
|
new AlertDialog.Builder(this).setTitle("Свой DNS").setView(e)
|
||||||
|
.setMessage("Адреса DNS-серверов через запятую. Пусто — DNS из профиля.")
|
||||||
|
.setPositiveButton("Сохранить", (d, w) -> {
|
||||||
|
String v = String.join(", ", lines(e.getText().toString()));
|
||||||
|
if (!v.isEmpty() && !v.matches("[0-9a-fA-F:., ]+")) { toast("DNS — только IP-адреса, например 1.1.1.1"); return; }
|
||||||
|
prefs.edit().putString("dns", v).apply();
|
||||||
|
applyIfUp();
|
||||||
|
render();
|
||||||
|
})
|
||||||
|
.setNegativeButton("Отмена", null).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channel "Облако": address of the user's Keenetic web app (KeenDNS cloud) with wsgw behind it. */
|
||||||
|
private void wsDialog() {
|
||||||
|
final EditText e = new EditText(this);
|
||||||
|
e.setHint("wss://phobos.имя.keenetic.pro/");
|
||||||
|
e.setText(wsUrl(prefs.getString("conf", "")));
|
||||||
|
new AlertDialog.Builder(this).setTitle("Адрес роутера (KeenDNS)").setView(e)
|
||||||
|
.setMessage("Канал через облако Keenetic: телефон → облако KeenDNS → ваш роутер → сервер.")
|
||||||
|
.setPositiveButton("Сохранить", (d, w) -> {
|
||||||
|
String u = e.getText().toString().trim();
|
||||||
|
if (!u.isEmpty() && !u.startsWith("wss://")) u = "wss://" + u.replaceFirst("^\\w+://", "");
|
||||||
|
prefs.edit().putString("ws_url", u).apply();
|
||||||
|
applyIfUp();
|
||||||
|
})
|
||||||
|
.setNegativeButton("Отмена", null).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User's own exceptions to "Russian sites directly": domains, IPs or networks, one per line. */
|
||||||
|
private void sitesDialog() {
|
||||||
|
LinearLayout l = new LinearLayout(this);
|
||||||
|
l.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
l.setPadding(dp(16), dp(8), dp(16), 0);
|
||||||
|
l.addView(tv("Напрямую, без VPN (по одному на строку):", text(), 14, true));
|
||||||
|
final EditText direct = new EditText(this);
|
||||||
|
direct.setHint("mysite.ru\n1.2.3.4\n5.6.0.0/16");
|
||||||
|
direct.setText(prefs.getString("my_direct", ""));
|
||||||
|
direct.setMinLines(3);
|
||||||
|
l.addView(direct);
|
||||||
|
l.addView(tv("Всегда через VPN, даже если российский:", text(), 14, true), mw(8));
|
||||||
|
final EditText vpn = new EditText(this);
|
||||||
|
vpn.setHint("rutracker.org");
|
||||||
|
vpn.setText(prefs.getString("my_vpn", ""));
|
||||||
|
vpn.setMinLines(3);
|
||||||
|
l.addView(vpn);
|
||||||
|
l.addView(tv("Работает, когда включено «Российские сайты — напрямую». Сайт берётся по его текущим адресам при подключении.", muted(), 12, false), mw(6));
|
||||||
|
ScrollView sv = new ScrollView(this);
|
||||||
|
sv.addView(l);
|
||||||
|
new AlertDialog.Builder(this).setTitle("Свои сайты").setView(sv)
|
||||||
|
.setPositiveButton("Сохранить", (d, w) -> {
|
||||||
|
prefs.edit().putString("my_direct", direct.getText().toString().trim())
|
||||||
|
.putString("my_vpn", vpn.getText().toString().trim()).apply();
|
||||||
|
applyIfUp();
|
||||||
|
render();
|
||||||
|
})
|
||||||
|
.setNegativeButton("Отмена", null).show();
|
||||||
|
}
|
||||||
|
|
||||||
private void pickApps() {
|
private void pickApps() {
|
||||||
PackageManager pm = getPackageManager();
|
PackageManager pm = getPackageManager();
|
||||||
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
||||||
|
|||||||
36
android-client/src/com/pca/phobos/vpn/PhobosTile.java
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package com.pca.phobos.vpn;
|
||||||
|
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.service.quicksettings.Tile;
|
||||||
|
import android.service.quicksettings.TileService;
|
||||||
|
|
||||||
|
/** Quick settings tile: on/off. The connect logic lives in MainActivity, which handles ACTION_TOGGLE and closes itself. */
|
||||||
|
public class PhobosTile extends TileService {
|
||||||
|
@Override
|
||||||
|
public void onStartListening() { update(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick() {
|
||||||
|
Intent i = new Intent(this, MainActivity.class).setAction(MainActivity.ACTION_TOGGLE)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
|
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE));
|
||||||
|
} else {
|
||||||
|
startActivityAndCollapse(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void update() {
|
||||||
|
Tile t = getQsTile();
|
||||||
|
if (t == null) return;
|
||||||
|
t.setState(MainActivity.tunnelUp() ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
||||||
|
t.updateTile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by MainActivity after connect/disconnect so an open shade shows the new state. */
|
||||||
|
static void refresh(android.content.Context c) {
|
||||||
|
requestListeningState(c, new android.content.ComponentName(c, PhobosTile.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
101
android-client/src/com/pca/phobos/vpn/RuNets.java
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package com.pca.phobos.vpn;
|
||||||
|
|
||||||
|
import java.net.Inet4Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AllowedIPs for "Russian sites directly": the whole IPv4 internet minus the direct set,
|
||||||
|
* where direct = Russian networks + the user's "direct" entries - the user's "via VPN" entries.
|
||||||
|
* Entries: IP, CIDR or domain (resolved to its current IPv4 addresses).
|
||||||
|
*/
|
||||||
|
final class RuNets {
|
||||||
|
private RuNets() { }
|
||||||
|
|
||||||
|
static String allowed(List<String> ruCidrs, List<String> direct, List<String> viaVpn) {
|
||||||
|
List<long[]> d = new ArrayList<>();
|
||||||
|
for (String c : ruCidrs) add(d, c);
|
||||||
|
for (String e : direct) for (String c : resolve(e)) add(d, c);
|
||||||
|
List<long[]> v = new ArrayList<>();
|
||||||
|
for (String e : viaVpn) for (String c : resolve(e)) add(v, c);
|
||||||
|
List<long[]> directSet = subtract(merge(d), merge(v));
|
||||||
|
// complement over 0.0.0.0 - 255.255.255.255
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
long next = 0;
|
||||||
|
for (long[] r : directSet) {
|
||||||
|
if (r[0] > next) cidrs(next, r[0] - 1, out);
|
||||||
|
next = Math.max(next, r[1] + 1);
|
||||||
|
}
|
||||||
|
if (next <= 0xFFFFFFFFL) cidrs(next, 0xFFFFFFFFL, out);
|
||||||
|
return String.join(", ", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1.2.3.4", "1.2.3.0/24" or "site.ru" -> CIDRs (empty if it does not resolve). */
|
||||||
|
static List<String> resolve(String entry) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
String e = entry.trim().toLowerCase().replaceFirst("^\\w+://", "").replaceFirst("/$", "");
|
||||||
|
if (e.isEmpty() || e.startsWith("#")) return out;
|
||||||
|
if (e.matches("[0-9.]+(/\\d+)?")) { out.add(e.contains("/") ? e : e + "/32"); return out; }
|
||||||
|
e = e.replaceFirst("/.*$", "");
|
||||||
|
try {
|
||||||
|
for (InetAddress a : InetAddress.getAllByName(e)) if (a instanceof Inet4Address) out.add(a.getHostAddress() + "/32");
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void add(List<long[]> list, String cidr) {
|
||||||
|
try {
|
||||||
|
String[] p = cidr.trim().split("/");
|
||||||
|
String[] o = p[0].split("\\.");
|
||||||
|
if (o.length != 4) return;
|
||||||
|
long ip = 0;
|
||||||
|
for (String x : o) ip = (ip << 8) | Integer.parseInt(x);
|
||||||
|
int bits = p.length > 1 ? Integer.parseInt(p[1]) : 32;
|
||||||
|
long size = 1L << (32 - bits);
|
||||||
|
long start = ip & ~(size - 1) & 0xFFFFFFFFL;
|
||||||
|
list.add(new long[]{start, start + size - 1});
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<long[]> merge(List<long[]> in) {
|
||||||
|
Collections.sort(in, (a, b) -> Long.compare(a[0], b[0]));
|
||||||
|
List<long[]> out = new ArrayList<>();
|
||||||
|
for (long[] r : in) {
|
||||||
|
if (!out.isEmpty() && r[0] <= out.get(out.size() - 1)[1] + 1) {
|
||||||
|
long[] last = out.get(out.size() - 1);
|
||||||
|
last[1] = Math.max(last[1], r[1]);
|
||||||
|
} else out.add(new long[]{r[0], r[1]});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<long[]> subtract(List<long[]> a, List<long[]> b) {
|
||||||
|
List<long[]> out = new ArrayList<>();
|
||||||
|
for (long[] r : a) {
|
||||||
|
long s = r[0], e = r[1];
|
||||||
|
for (long[] x : b) {
|
||||||
|
if (x[1] < s || x[0] > e) continue;
|
||||||
|
if (x[0] > s) out.add(new long[]{s, x[0] - 1});
|
||||||
|
s = x[1] + 1;
|
||||||
|
if (s > e) break;
|
||||||
|
}
|
||||||
|
if (s <= e) out.add(new long[]{s, e});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void cidrs(long start, long end, List<String> out) {
|
||||||
|
while (start <= end) {
|
||||||
|
int bits = 32;
|
||||||
|
while (bits > 0) {
|
||||||
|
long size = 1L << (33 - bits);
|
||||||
|
if ((start & (size - 1)) != 0 || start + size - 1 > end) break;
|
||||||
|
bits--;
|
||||||
|
}
|
||||||
|
out.add(((start >> 24) & 255) + "." + ((start >> 16) & 255) + "." + ((start >> 8) & 255) + "." + (start & 255) + "/" + bits);
|
||||||
|
start += 1L << (32 - bits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
android-client/src/com/pca/phobos/vpn/V6Relay.java
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package com.pca.phobos.vpn;
|
||||||
|
|
||||||
|
import java.net.DatagramPacket;
|
||||||
|
import java.net.DatagramSocket;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.SocketAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 127.0.0.1:lport <-> [ipv6]:port over UDP: our wg-obfuscator is IPv4-only, the server side
|
||||||
|
* listens on IPv6 through phobos-v6relay. One upstream socket, like the server's relay.
|
||||||
|
*/
|
||||||
|
final class V6Relay {
|
||||||
|
private final DatagramSocket local, up;
|
||||||
|
private volatile SocketAddress peer;
|
||||||
|
private volatile boolean running = true;
|
||||||
|
|
||||||
|
V6Relay(int lport, String host, int port) throws Exception {
|
||||||
|
local = new DatagramSocket(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), lport));
|
||||||
|
up = new DatagramSocket();
|
||||||
|
up.connect(new InetSocketAddress(InetAddress.getByName(host), port));
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
new Thread(() -> pump(local, true), "v6relay-out").start();
|
||||||
|
new Thread(() -> pump(up, false), "v6relay-in").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pump(DatagramSocket from, boolean out) {
|
||||||
|
byte[] buf = new byte[65535];
|
||||||
|
while (running) {
|
||||||
|
try {
|
||||||
|
DatagramPacket p = new DatagramPacket(buf, buf.length);
|
||||||
|
from.receive(p);
|
||||||
|
if (out) { peer = p.getSocketAddress(); up.send(new DatagramPacket(buf, p.getLength())); }
|
||||||
|
else if (peer != null) local.send(new DatagramPacket(buf, p.getLength(), peer));
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (!running) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
running = false;
|
||||||
|
local.close();
|
||||||
|
up.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
157
android-client/src/com/pca/phobos/vpn/WsRelay.java
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package com.pca.phobos.vpn;
|
||||||
|
|
||||||
|
import java.io.DataInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.DatagramPacket;
|
||||||
|
import java.net.DatagramSocket;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.SocketAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
import javax.net.ssl.SSLSocketFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport "Keenetic cloud": local UDP (obfuscator) <-> wss://<keendns-subdomain>/ -> wsgw on the router -> server UDP.
|
||||||
|
* One binary WS message = one obfuscated packet. Reconnects on drop; the tunnel itself is not restarted.
|
||||||
|
*/
|
||||||
|
final class WsRelay {
|
||||||
|
interface Log { void log(String s); }
|
||||||
|
|
||||||
|
private final int lport;
|
||||||
|
private final URI uri;
|
||||||
|
private final Log log;
|
||||||
|
private final SecureRandom rnd = new SecureRandom();
|
||||||
|
private volatile boolean stop;
|
||||||
|
private volatile SSLSocket ws;
|
||||||
|
private volatile OutputStream out;
|
||||||
|
private volatile SocketAddress peer;
|
||||||
|
private DatagramSocket udp;
|
||||||
|
|
||||||
|
WsRelay(int lport, String url, Log log) {
|
||||||
|
this.lport = lport;
|
||||||
|
this.uri = URI.create(url.trim());
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() throws IOException {
|
||||||
|
udp = new DatagramSocket(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), lport));
|
||||||
|
new Thread(this::upLoop, "ws-up").start();
|
||||||
|
new Thread(this::downLoop, "ws-down").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
stop = true;
|
||||||
|
closeWs();
|
||||||
|
if (udp != null) udp.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void closeWs() {
|
||||||
|
try { if (ws != null) ws.close(); } catch (IOException ignored) { }
|
||||||
|
ws = null;
|
||||||
|
out = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized OutputStream connect() throws IOException {
|
||||||
|
if (out != null) return out;
|
||||||
|
String host = uri.getHost();
|
||||||
|
int port = uri.getPort() > 0 ? uri.getPort() : 443;
|
||||||
|
SSLSocket s = (SSLSocket) SSLSocketFactory.getDefault().createSocket(host, port);
|
||||||
|
s.setTcpNoDelay(true);
|
||||||
|
s.setKeepAlive(true);
|
||||||
|
s.startHandshake();
|
||||||
|
byte[] k = new byte[16];
|
||||||
|
rnd.nextBytes(k);
|
||||||
|
String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "/" : uri.getRawPath();
|
||||||
|
String req = "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||||
|
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " + android.util.Base64.encodeToString(k, android.util.Base64.NO_WRAP) + "\r\n\r\n";
|
||||||
|
OutputStream o = s.getOutputStream();
|
||||||
|
o.write(req.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
o.flush();
|
||||||
|
InputStream in = s.getInputStream();
|
||||||
|
StringBuilder head = new StringBuilder();
|
||||||
|
while (!head.toString().endsWith("\r\n\r\n")) {
|
||||||
|
int c = in.read();
|
||||||
|
if (c < 0 || head.length() > 8192) throw new IOException("ws: no handshake");
|
||||||
|
head.append((char) c);
|
||||||
|
}
|
||||||
|
if (!head.toString().startsWith("HTTP/1.1 101")) {
|
||||||
|
s.close();
|
||||||
|
throw new IOException("ws: " + head.toString().split("\r\n")[0]);
|
||||||
|
}
|
||||||
|
ws = s;
|
||||||
|
out = o;
|
||||||
|
log.log("ws: connected " + uri);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** obfuscator -> server */
|
||||||
|
private void upLoop() {
|
||||||
|
byte[] buf = new byte[65535];
|
||||||
|
DatagramPacket p = new DatagramPacket(buf, buf.length);
|
||||||
|
while (!stop) {
|
||||||
|
try {
|
||||||
|
udp.receive(p);
|
||||||
|
peer = p.getSocketAddress();
|
||||||
|
OutputStream o = connect();
|
||||||
|
writeFrame(o, buf, p.getLength());
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (stop) return;
|
||||||
|
log.log("ws up: " + e.getMessage());
|
||||||
|
closeWs();
|
||||||
|
sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** server -> obfuscator */
|
||||||
|
private void downLoop() {
|
||||||
|
while (!stop) {
|
||||||
|
SSLSocket s = ws;
|
||||||
|
if (s == null) { sleep(200); continue; }
|
||||||
|
try {
|
||||||
|
DataInputStream in = new DataInputStream(s.getInputStream());
|
||||||
|
while (!stop) {
|
||||||
|
int b0 = in.readUnsignedByte(), b1 = in.readUnsignedByte();
|
||||||
|
long n = b1 & 0x7f;
|
||||||
|
if (n == 126) n = in.readUnsignedShort();
|
||||||
|
else if (n == 127) n = in.readLong();
|
||||||
|
if (n > 65535) throw new IOException("ws: frame too big");
|
||||||
|
byte[] p = new byte[(int) n];
|
||||||
|
in.readFully(p);
|
||||||
|
int op = b0 & 0x0f;
|
||||||
|
if (op == 8) throw new IOException("ws: closed by server");
|
||||||
|
if ((op == 2 || op == 0) && peer != null) udp.send(new DatagramPacket(p, p.length, peer));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (stop) return;
|
||||||
|
if (s == ws) { log.log("ws down: " + e.getMessage()); closeWs(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client frames are masked (RFC 6455). */
|
||||||
|
private synchronized void writeFrame(OutputStream o, byte[] p, int n) throws IOException {
|
||||||
|
byte[] f = new byte[n + 14];
|
||||||
|
int i = 0;
|
||||||
|
f[i++] = (byte) 0x82;
|
||||||
|
if (n < 126) f[i++] = (byte) (0x80 | n);
|
||||||
|
else { f[i++] = (byte) (0x80 | 126); f[i++] = (byte) (n >> 8); f[i++] = (byte) n; }
|
||||||
|
byte[] m = new byte[4];
|
||||||
|
rnd.nextBytes(m);
|
||||||
|
System.arraycopy(m, 0, f, i, 4);
|
||||||
|
i += 4;
|
||||||
|
for (int j = 0; j < n; j++) f[i + j] = (byte) (p[j] ^ m[j & 3]);
|
||||||
|
o.write(f, 0, i + n);
|
||||||
|
o.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sleep(long ms) {
|
||||||
|
try { Thread.sleep(ms); } catch (InterruptedException ignored) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,49 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Phobos Health Monitor — Auto-heal + Failover for routers
|
# Phobos Health Monitor v2 — Keenetic Edition
|
||||||
# Runs via cron every 60 seconds on Keenetic/Entware
|
# Uses ndmc/RCI instead of wg-tools. Connectivity-based failover.
|
||||||
#
|
# Runs via cron every 60 seconds
|
||||||
# Install: crontab -e → */1 * * * * /opt/etc/Phobos/phobos-health.sh
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
PHOBOS_DIR="/opt/etc/Phobos"
|
PHOBOS_DIR="/opt/etc/Phobos"
|
||||||
CONF="$PHOBOS_DIR/failover.conf"
|
# 02.11.2026: несколько учёток на одном роутере. Файлы учётки (failover.conf, state,
|
||||||
STATE="$PHOBOS_DIR/state"
|
# client_id, panel_*) — в PHOBOS_ACC (доп. учётки: $PHOBOS_DIR/acc/<клиент>),
|
||||||
LOG="$PHOBOS_DIR/health.log"
|
# главная — прямо в $PHOBOS_DIR. Конфиги обфускаторов общие, свой ищем по *.link.
|
||||||
WG_CONF="$PHOBOS_DIR/wg0.conf"
|
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
|
||||||
|
CONF="$ACC_DIR/failover.conf"
|
||||||
|
STATE="$ACC_DIR/state"
|
||||||
|
LOG="$ACC_DIR/health.log"
|
||||||
|
CID=$(cat "$ACC_DIR/client_id" 2>/dev/null)
|
||||||
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
||||||
LOCKFILE="/tmp/phobos-health.lock"
|
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
|
||||||
|
[ -n "$CID" ] && [ "$(cat "$l" 2>/dev/null)" = "$CID" ] && OBF_CONF="${l%.link}.conf" && break
|
||||||
|
done
|
||||||
|
LOCKFILE="/tmp/phobos-health${PHOBOS_ACC:+-$CID}.lock"
|
||||||
|
WG_IF="Wireguard3"
|
||||||
|
|
||||||
MAX_LOG_LINES=200
|
MAX_LOG_LINES=200
|
||||||
HANDSHAKE_WARN=180
|
# Thresholds (seconds)
|
||||||
HANDSHAKE_RESTART_OBF=300
|
HANDSHAKE_WARN=150
|
||||||
HANDSHAKE_RESTART_WG=600
|
HANDSHAKE_PORT_HOP=300
|
||||||
HANDSHAKE_FAILOVER=900
|
HANDSHAKE_SERVER_SWITCH=600
|
||||||
|
PRIMARY_CHECK_INTERVAL=300
|
||||||
|
# Primary-alive probe: servers block ICMP and busybox `nc -z` is unreliable,
|
||||||
|
# so reachability is tested with curl against the panel's obfuscator-health
|
||||||
|
# endpoint on the primary VPS. It returns http 200 ONLY when all
|
||||||
|
# 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
|
||||||
|
# alone (that caused premature switchback to a dead primary).
|
||||||
|
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$ACC_DIR/panel_scheme" 2>/dev/null)}"
|
||||||
|
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
|
||||||
|
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$ACC_DIR/panel_port" 2>/dev/null)}"
|
||||||
|
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
|
||||||
|
PRIMARY_PROBE_PATH="/api/obf-health"
|
||||||
|
# Connectivity check targets (logged for context only)
|
||||||
|
CHECK_HOST_1="8.8.8.8"
|
||||||
|
CHECK_HOST_2="1.1.1.1"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
|
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
|
||||||
# Trim log
|
|
||||||
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
|
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
|
||||||
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
||||||
fi
|
fi
|
||||||
@@ -38,33 +59,59 @@ fi
|
|||||||
echo $$ > "$LOCKFILE"
|
echo $$ > "$LOCKFILE"
|
||||||
trap 'rm -f "$LOCKFILE"' EXIT
|
trap 'rm -f "$LOCKFILE"' EXIT
|
||||||
|
|
||||||
# Create state dir
|
|
||||||
mkdir -p "$STATE"
|
mkdir -p "$STATE"
|
||||||
|
|
||||||
|
# ── Detect WG interface ──
|
||||||
|
detect_wg_interface() {
|
||||||
|
# точное совпадение "Phobos-<клиент>"; доп. учётка не должна схватить чужой интерфейс
|
||||||
|
for i in 0 1 2 3 4 5 6 7 8 9; do
|
||||||
|
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
|
||||||
|
# HydraRoute Neo names the interface by the bare client id (09.33)
|
||||||
|
if [ -n "$CID" ] && echo "$desc" | grep -qE "(Phobos-|: )${CID}\$"; then
|
||||||
|
WG_IF="Wireguard${i}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -n "$PHOBOS_ACC" ] && return 1
|
||||||
|
for i in 0 1 2 3 4 5 6 7 8 9; do
|
||||||
|
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
|
||||||
|
if echo "$desc" | grep -qi phobos; then
|
||||||
|
WG_IF="Wireguard${i}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Restart own obfuscator (init script whose ARGS name our config) ──
|
||||||
|
restart_obf() {
|
||||||
|
sc=$(grep -l "/$(basename "$OBF_CONF")\"\$" /opt/etc/init.d/S*obfuscator* 2>/dev/null | head -1)
|
||||||
|
[ -z "$sc" ] && [ -f /opt/etc/init.d/S49wg-obfuscator ] && sc=/opt/etc/init.d/S49wg-obfuscator
|
||||||
|
if [ -n "$sc" ]; then
|
||||||
|
"$sc" restart >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
killall wg-obfuscator 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ── Read failover config ──
|
# ── Read failover config ──
|
||||||
if [ ! -f "$CONF" ]; then
|
if [ ! -f "$CONF" ]; then
|
||||||
log "ERROR: no failover.conf"
|
log "ERROR: no failover.conf"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Parse servers: SERVER_1=ip:port1,port2,port3
|
|
||||||
# Parse current state
|
|
||||||
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
|
|
||||||
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
|
|
||||||
RESTART_COUNT=$(cat "$STATE/restart_count" 2>/dev/null || echo "0")
|
|
||||||
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
|
||||||
|
|
||||||
# Load server list
|
|
||||||
SERVER_COUNT=0
|
SERVER_COUNT=0
|
||||||
idx=1
|
idx=1
|
||||||
while true; do
|
while true; do
|
||||||
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
if [ -z "$val" ]; then
|
[ -z "$val" ] && break
|
||||||
break
|
|
||||||
fi
|
|
||||||
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
|
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
|
||||||
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
|
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
|
||||||
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||||
|
eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||||
|
eval "SERVER_${idx}_TCP=$(grep "^TCP_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||||
SERVER_COUNT=$idx
|
SERVER_COUNT=$idx
|
||||||
idx=$((idx + 1))
|
idx=$((idx + 1))
|
||||||
done
|
done
|
||||||
@@ -74,22 +121,57 @@ if [ "$SERVER_COUNT" -eq 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Get current handshake age ──
|
# Read state
|
||||||
get_handshake_age() {
|
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
|
||||||
hs=$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}')
|
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
|
||||||
if [ -z "$hs" ] || [ "$hs" = "0" ]; then
|
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
|
||||||
echo "9999"
|
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
||||||
return
|
TCP_MODE=$(cat "$STATE/tcp_mode" 2>/dev/null || echo "0") # 0 = UDP, N = TCP via server N
|
||||||
fi
|
CYCLES=$(cat "$STATE/udp_cycles" 2>/dev/null || echo "0") # full UDP rounds without success
|
||||||
now=$(date +%s)
|
UDPTCP_BIN=/opt/bin/udptcp
|
||||||
age=$((now - hs))
|
UDPTCP_LPORT=13256
|
||||||
echo "$age"
|
|
||||||
|
# ── Detect Phobos WG interface ──
|
||||||
|
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим (так сторож
|
||||||
|
# удалённой доп. учётки вписал пир Phobos в посторонний туннель).
|
||||||
|
detect_wg_interface || {
|
||||||
|
log "ERROR: own WG interface not found, skip"
|
||||||
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Get port by index from comma-separated list ──
|
# ── Panel tunnel IP must go through our tunnel (HydraRoute-made interface has no
|
||||||
|
# such route → pull went to WAN and hung, 09.33) ──
|
||||||
|
PT_IP=$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)
|
||||||
|
if [ -n "$PT_IP" ] && ip route get "$PT_IP" 2>/dev/null | grep -q "dev ppp\|dev eth\|dev wan"; then
|
||||||
|
ndmc -c "ip route $PT_IP 255.255.255.255 $WG_IF auto" >/dev/null 2>&1 && log "ROUTE: $PT_IP via $WG_IF"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Get handshake age via ndmc ──
|
||||||
|
# A peer switch can leave the removed peer's stale handshake listed in ndmc
|
||||||
|
# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum
|
||||||
|
# positive, sane) handshake across all peers — that is the live tunnel.
|
||||||
|
get_handshake_age() {
|
||||||
|
hs=$(ndmc -c "show interface $WG_IF" 2>/dev/null \
|
||||||
|
| grep "last-handshake" \
|
||||||
|
| awk '{v=$2} v>0 && v<86400 {print v}' \
|
||||||
|
| sort -n | head -1)
|
||||||
|
if [ -z "$hs" ]; then
|
||||||
|
echo "9999"
|
||||||
|
else
|
||||||
|
echo "$hs"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check real connectivity ──
|
||||||
|
check_connectivity() {
|
||||||
|
ping -c 1 -W 3 "$CHECK_HOST_1" >/dev/null 2>&1 && return 0
|
||||||
|
ping -c 1 -W 3 "$CHECK_HOST_2" >/dev/null 2>&1 && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Get port by index ──
|
||||||
get_port() {
|
get_port() {
|
||||||
server_idx=$1
|
server_idx=$1; port_idx=$2
|
||||||
port_idx=$2
|
|
||||||
eval "ports=\$SERVER_${server_idx}_PORTS"
|
eval "ports=\$SERVER_${server_idx}_PORTS"
|
||||||
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
|
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
|
||||||
}
|
}
|
||||||
@@ -100,6 +182,82 @@ get_port_count() {
|
|||||||
echo "$ports" | tr ',' '\n' | wc -l
|
echo "$ports" | tr ',' '\n' | wc -l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Keys currently on the interface (includes the interface's OWN key) ──
|
||||||
|
# WG public keys are 43 base64 chars + '='. The interface's own public-key is
|
||||||
|
# also matched here — callers must only act on KNOWN server WGKEYs so the
|
||||||
|
# local key is never touched.
|
||||||
|
list_iface_keys() {
|
||||||
|
ndmc -c "show interface $WG_IF" 2>/dev/null \
|
||||||
|
| grep -oE '[A-Za-z0-9+/]{43}=' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
# Count how many KNOWN server WGKEYs are currently attached as peers.
|
||||||
|
count_server_peers() {
|
||||||
|
present=$(list_iface_keys)
|
||||||
|
n=0
|
||||||
|
seen=" "
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
|
eval "wk=\$SERVER_${i}_WGKEY"
|
||||||
|
# one server may have several addresses = same key in several entries
|
||||||
|
case "$seen" in *" $wk "*) i=$((i + 1)); continue ;; esac
|
||||||
|
seen="$seen$wk "
|
||||||
|
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
|
||||||
|
n=$((n + 1))
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
echo "$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Switch WG peer so EXACTLY ONE server peer (new_key) remains ──
|
||||||
|
# Failover adds a new peer; the RCI "remove" is unreliable on Keenetic and
|
||||||
|
# leaves dead peers behind. Multiple peers each with allow-ips 0.0.0.0/0 make
|
||||||
|
# egress routing ambiguous. We hard-purge every OTHER known server key via the
|
||||||
|
# ndmc CLI (reliable) — never the interface's own key — then ensure new_key.
|
||||||
|
switch_wg_peer() {
|
||||||
|
new_key=$1
|
||||||
|
|
||||||
|
if [ -z "$new_key" ]; then
|
||||||
|
log "WARN: no WGKEY for target server, skip peer switch"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
present=$(list_iface_keys)
|
||||||
|
|
||||||
|
# Purge any OTHER known server key that is attached
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
|
eval "wk=\$SERVER_${i}_WGKEY"
|
||||||
|
if [ -n "$wk" ] && [ "$wk" != "$new_key" ] && echo "$present" | grep -q "^${wk}$"; then
|
||||||
|
log "WG PEER purge: $wk"
|
||||||
|
ndmc -c "interface $WG_IF no wireguard peer $wk" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Add the target peer if it is not already present
|
||||||
|
if ! echo "$present" | grep -q "^${new_key}$"; then
|
||||||
|
log "WG PEER add: $new_key"
|
||||||
|
# 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
|
||||||
|
# берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
|
||||||
|
# Через ndmc по шагам: JSON-RCI с allow-ips "::"/0 молча не ставил allow-ips.
|
||||||
|
lport=$(grep 'source-lport' "$OBF_CONF" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
|
||||||
|
lport=${lport:-13255}
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key endpoint 127.0.0.1:$lport" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
|
||||||
|
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
|
||||||
|
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
|
||||||
|
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Persist config
|
||||||
|
ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# ── Switch to specific server:port ──
|
# ── Switch to specific server:port ──
|
||||||
switch_endpoint() {
|
switch_endpoint() {
|
||||||
server_idx=$1
|
server_idx=$1
|
||||||
@@ -107,6 +265,7 @@ switch_endpoint() {
|
|||||||
|
|
||||||
eval "host=\$SERVER_${server_idx}_HOST"
|
eval "host=\$SERVER_${server_idx}_HOST"
|
||||||
eval "obf_key=\$SERVER_${server_idx}_KEY"
|
eval "obf_key=\$SERVER_${server_idx}_KEY"
|
||||||
|
eval "wg_key=\$SERVER_${server_idx}_WGKEY"
|
||||||
port=$(get_port "$server_idx" "$port_idx")
|
port=$(get_port "$server_idx" "$port_idx")
|
||||||
|
|
||||||
if [ -z "$host" ] || [ -z "$port" ]; then
|
if [ -z "$host" ] || [ -z "$port" ]; then
|
||||||
@@ -115,8 +274,12 @@ switch_endpoint() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "SWITCH → server $server_idx ($host:$port)"
|
log "SWITCH → server $server_idx ($host:$port)"
|
||||||
|
stop_tcp
|
||||||
|
|
||||||
# Update obfuscator config target
|
# 1. Switch WG peer key if different server
|
||||||
|
switch_wg_peer "$wg_key"
|
||||||
|
|
||||||
|
# 2. Update obfuscator config
|
||||||
if [ -f "$OBF_CONF" ]; then
|
if [ -f "$OBF_CONF" ]; then
|
||||||
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
|
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
|
||||||
if [ -n "$obf_key" ]; then
|
if [ -n "$obf_key" ]; then
|
||||||
@@ -124,25 +287,59 @@ switch_endpoint() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Restart obfuscator
|
# 3. Restart obfuscator
|
||||||
killall wg-obfuscator 2>/dev/null
|
restart_obf
|
||||||
sleep 1
|
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
|
||||||
|
|
||||||
# Save state
|
# 4. Save state (fail_count is managed by the caller, NOT reset here —
|
||||||
|
# resetting on a port-hop would prevent escalation to server failover)
|
||||||
echo "$server_idx" > "$STATE/current_server"
|
echo "$server_idx" > "$STATE/current_server"
|
||||||
echo "$port_idx" > "$STATE/current_port_idx"
|
echo "$port_idx" > "$STATE/current_port_idx"
|
||||||
echo "0" > "$STATE/restart_count"
|
}
|
||||||
|
|
||||||
|
# ── TCP channel (09.33/1.5.0): obfuscated UDP inside TCP via local udptcp ──
|
||||||
|
stop_tcp() {
|
||||||
|
[ "$TCP_MODE" = "0" ] && ! pidof udptcp >/dev/null 2>&1 && return 0
|
||||||
|
killall udptcp 2>/dev/null
|
||||||
|
TCP_MODE=0
|
||||||
|
echo 0 > "$STATE/tcp_mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
# start_tcp N — tunnel through server N's TCP port; 1 = started
|
||||||
|
start_tcp() {
|
||||||
|
n=$1
|
||||||
|
eval "host=\$SERVER_${n}_HOST"
|
||||||
|
eval "tport=\$SERVER_${n}_TCP"
|
||||||
|
eval "obf_key=\$SERVER_${n}_KEY"
|
||||||
|
eval "wg_key=\$SERVER_${n}_WGKEY"
|
||||||
|
[ -n "$tport" ] && [ -x "$UDPTCP_BIN" ] || return 1
|
||||||
|
killall udptcp 2>/dev/null
|
||||||
|
"$UDPTCP_BIN" -c "127.0.0.1:${UDPTCP_LPORT}" "${host}:${tport}" >/dev/null 2>&1 &
|
||||||
|
switch_wg_peer "$wg_key"
|
||||||
|
sed -i "s|^target = .*|target = 127.0.0.1:${UDPTCP_LPORT}|" "$OBF_CONF"
|
||||||
|
[ -n "$obf_key" ] && sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
|
||||||
|
restart_obf
|
||||||
|
TCP_MODE=$n
|
||||||
|
echo "$n" > "$STATE/tcp_mode"
|
||||||
|
echo "$n" > "$STATE/current_server"
|
||||||
|
log "TCP MODE → server $n (${host}:${tport} over TCP)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# try_tcp_after N — first server after N (no wrap) that has a TCP port; 1 = none
|
||||||
|
try_tcp_after() {
|
||||||
|
i=$(( $1 + 1 ))
|
||||||
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
|
start_tcp "$i" && return 0
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Try next port on current server ──
|
# ── Try next port on current server ──
|
||||||
try_next_port() {
|
try_next_port() {
|
||||||
port_count=$(get_port_count "$CURRENT_SERVER")
|
port_count=$(get_port_count "$CURRENT_SERVER")
|
||||||
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
|
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
|
||||||
|
[ "$next_idx" -eq 0 ] && return 1
|
||||||
if [ "$next_idx" -eq 0 ]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
|
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
|
||||||
switch_endpoint "$CURRENT_SERVER" "$next_idx"
|
switch_endpoint "$CURRENT_SERVER" "$next_idx"
|
||||||
@@ -155,93 +352,233 @@ try_next_server() {
|
|||||||
if [ "$next" -gt "$SERVER_COUNT" ]; then
|
if [ "$next" -gt "$SERVER_COUNT" ]; then
|
||||||
next=1
|
next=1
|
||||||
fi
|
fi
|
||||||
|
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
|
||||||
if [ "$next" -eq "$CURRENT_SERVER" ]; then
|
|
||||||
log "WARN: only one server, can't failover"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "FAILOVER → server $next"
|
log "FAILOVER → server $next"
|
||||||
switch_endpoint "$next" "0"
|
switch_endpoint "$next" "0"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Check if primary is back (every 5 min when on secondary) ──
|
# ── Check if primary is back ──
|
||||||
check_primary() {
|
check_primary() {
|
||||||
if [ "$CURRENT_SERVER" -eq 1 ]; then
|
[ "$CURRENT_SERVER" -eq 1 ] && [ "$TCP_MODE" = "0" ] && return
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
now=$(date +%s)
|
now=$(date +%s)
|
||||||
elapsed=$((now - PRIMARY_CHECK_TS))
|
elapsed=$((now - PRIMARY_CHECK_TS))
|
||||||
if [ "$elapsed" -lt 300 ]; then
|
[ "$elapsed" -lt "$PRIMARY_CHECK_INTERVAL" ] && return
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo "$now" > "$STATE/primary_check_ts"
|
echo "$now" > "$STATE/primary_check_ts"
|
||||||
|
|
||||||
eval "host=\$SERVER_1_HOST"
|
eval "host=\$SERVER_1_HOST"
|
||||||
port=$(get_port 1 0)
|
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
|
||||||
|
# obfuscator path is actually up. Anything else (000 no-response, 503
|
||||||
# Quick UDP probe — send a byte, see if port responds
|
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
|
||||||
if ping -c 1 -W 2 "$host" >/dev/null 2>&1; then
|
ok_url=""
|
||||||
log "PRIMARY alive, switching back"
|
last_code=""
|
||||||
|
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
|
||||||
|
# HTTP alive does not mean UDP passes (провайдер режет UDP к IP, 09.33):
|
||||||
|
# пробуем, а если рукопожатия не будет — trial_check вернёт как было.
|
||||||
|
log "PRIMARY ($ok_url http=200) alive, TRIAL switch back"
|
||||||
|
echo "$CURRENT_SERVER $CURRENT_PORT_IDX $TCP_MODE $(date +%s)" > "$STATE/trial"
|
||||||
switch_endpoint 1 0
|
switch_endpoint 1 0
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
else
|
||||||
|
log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Main logic ──
|
# ── LAN routing self-heal ──────────────────────
|
||||||
AGE=$(get_handshake_age)
|
# A router REBOOT silently drops the per-device `ip hotspot host <mac> policy`
|
||||||
|
# binding and can reset the WG interface security-level, so LAN clients leak to
|
||||||
|
# WAN (no VPN). This:
|
||||||
|
# 1) ensures WG_IF security-level = public (needed for masquerade),
|
||||||
|
# 2) LEARNS current host->policy bindings into state/lan-hosts (append-only),
|
||||||
|
# 3) RE-APPLIES any learned binding that is currently missing.
|
||||||
|
# Append-only learning means a reboot (which clears live bindings) never erases
|
||||||
|
# the record, so the next run restores them. ndmc show running-config is heavy,
|
||||||
|
# so the caller gates this to run only every few minutes.
|
||||||
|
heal_lan_routing() {
|
||||||
|
LANHOSTS="$STATE/lan-hosts"
|
||||||
|
rc=$(ndmc -c "show running-config" 2>/dev/null)
|
||||||
|
[ -z "$rc" ] && return 0
|
||||||
|
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
# 1) WG interface must be public
|
||||||
# All good, reset counters
|
sl=$(echo "$rc" | awk -v ifc="interface $WG_IF" '$0~ifc{f=1} f&&/security-level/{print $2; exit}')
|
||||||
if [ "$RESTART_COUNT" -gt 0 ]; then
|
if [ -n "$sl" ] && [ "$sl" != "public" ]; then
|
||||||
log "OK: connection restored (age=${AGE}s)"
|
ndmc -c "interface $WG_IF security-level public" >/dev/null 2>&1
|
||||||
echo "0" > "$STATE/restart_count"
|
ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
log "HEAL: $WG_IF security-level -> public"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 2) learn live bindings (append-only) into lan-hosts
|
||||||
|
touch "$LANHOSTS"
|
||||||
|
echo "$rc" | grep -oE "host [0-9a-f:]+ policy [A-Za-z0-9_]+" | while read -r _h mac _p pol; do
|
||||||
|
grep -q "^$mac " "$LANHOSTS" 2>/dev/null || echo "$mac $pol" >> "$LANHOSTS"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3) re-apply any learned binding that is missing live
|
||||||
|
changed=0
|
||||||
|
while read -r mac pol; do
|
||||||
|
[ -z "$mac" ] && continue
|
||||||
|
case "$mac" in \#*) continue;; esac
|
||||||
|
# политику удалили — не воскрешаем привязку к ней
|
||||||
|
echo "$rc" | grep -qx "ip policy $pol" || continue
|
||||||
|
if ! echo "$rc" | grep -q "host $mac policy $pol"; then
|
||||||
|
ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1
|
||||||
|
log "HEAL: re-bound host $mac -> $pol"
|
||||||
|
changed=1
|
||||||
|
fi
|
||||||
|
done < "$LANHOSTS"
|
||||||
|
[ "$changed" = 1 ] && ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Explicit apply hook (used by phobos-pull.sh after a config change).
|
||||||
|
# `phobos-health.sh apply-server N` re-points the tunnel to SERVER_N
|
||||||
|
# immediately, skipping the failure-escalation logic. Reuses
|
||||||
|
# switch_endpoint so the WG-peer/obfuscator/state changes stay identical
|
||||||
|
# to a normal failover. Resets fail_count so the new server starts clean.
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
if [ "$1" = "apply-server" ]; then
|
||||||
|
idx="${2:-1}"
|
||||||
|
eval "ahost=\$SERVER_${idx}_HOST"
|
||||||
|
if [ -z "$ahost" ]; then
|
||||||
|
log "APPLY: server $idx not in conf, ignore"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "APPLY: pull requested server $idx ($ahost)"
|
||||||
|
switch_endpoint "$idx" "0"
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
echo "$idx" > "$STATE/current_server"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Pre-check: fix desync (obfuscator targeting wrong server)
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
if [ -f "$OBF_CONF" ]; then
|
||||||
|
cur_target=$(grep "^target = " "$OBF_CONF" 2>/dev/null | sed 's/target = //')
|
||||||
|
eval "expected_host=\$SERVER_${CURRENT_SERVER}_HOST"
|
||||||
|
if [ -n "$cur_target" ] && [ -n "$expected_host" ]; then
|
||||||
|
echo "$cur_target" | grep -q "$expected_host" || {
|
||||||
|
log "DESYNC: obf=$cur_target state=server${CURRENT_SERVER}($expected_host). Resync."
|
||||||
|
switch_endpoint "$CURRENT_SERVER" "$CURRENT_PORT_IDX"
|
||||||
|
sleep 5
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── LAN routing self-heal (gated ~5 min; a reboot drops host->policy bindings) ──
|
||||||
|
HEAL_TS=$(cat "$STATE/heal_ts" 2>/dev/null || echo 0)
|
||||||
|
now_heal=$(date +%s)
|
||||||
|
if [ $((now_heal - HEAL_TS)) -ge 300 ]; then
|
||||||
|
echo "$now_heal" > "$STATE/heal_ts"
|
||||||
|
heal_lan_routing
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Peer hygiene: keep exactly ONE WG peer = current server's key ──
|
||||||
|
# Failovers can leave dead/duplicate peers; multiple 0.0.0.0/0 peers cause
|
||||||
|
# ambiguous egress routing. Self-heal here every run (cheap when already clean).
|
||||||
|
eval "cur_wgkey=\$SERVER_${CURRENT_SERVER}_WGKEY"
|
||||||
|
if [ -n "$cur_wgkey" ]; then
|
||||||
|
peer_n=$(count_server_peers)
|
||||||
|
if [ "$peer_n" -gt 1 ]; then
|
||||||
|
log "HYGIENE: $peer_n server peers present, purging to server${CURRENT_SERVER}"
|
||||||
|
switch_wg_peer "$cur_wgkey"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Main logic
|
||||||
|
# Tunnel health = WireGuard handshake age (authoritative: a fresh
|
||||||
|
# handshake only happens through the full obfuscator→server→WG path).
|
||||||
|
# WAN ping is logged for context ONLY — it does NOT gate decisions,
|
||||||
|
# because the router's default route is not the tunnel, so WAN can be
|
||||||
|
# up while the tunnel is dead (and a WAN blip must not cause failover).
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
AGE=$(get_handshake_age)
|
||||||
|
WAN=$(check_connectivity && echo "yes" || echo "no")
|
||||||
|
|
||||||
|
# ── Trial of the primary: fresh handshake within ~2 min or go back ──
|
||||||
|
if [ -f "$STATE/trial" ]; then
|
||||||
|
read t_srv t_port t_tcp t_ts < "$STATE/trial"
|
||||||
|
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
||||||
|
log "TRIAL ok: primary works"
|
||||||
|
rm -f "$STATE/trial"; echo 0 > "$STATE/udp_cycles"
|
||||||
|
elif [ $(( $(date +%s) - ${t_ts:-0} )) -ge 110 ]; then
|
||||||
|
rm -f "$STATE/trial"
|
||||||
|
log "TRIAL failed: back to server $t_srv"
|
||||||
|
if [ "${t_tcp:-0}" != "0" ]; then start_tcp "$t_tcp" || switch_endpoint "$t_srv" "$t_port"
|
||||||
|
else switch_endpoint "$t_srv" "$t_port"; fi
|
||||||
|
echo 0 > "$STATE/fail_count"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
exit 0 # give the trial time
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tunnel healthy = fresh handshake
|
||||||
|
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
||||||
|
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||||
|
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
fi
|
||||||
|
echo 0 > "$STATE/udp_cycles"
|
||||||
check_primary
|
check_primary
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "STALE handshake: ${AGE}s (server=$CURRENT_SERVER port_idx=$CURRENT_PORT_IDX restarts=$RESTART_COUNT)"
|
# Stale handshake = tunnel down → escalate.
|
||||||
|
# fail_count is MONOTONIC: it climbs across stages and is only reset by the
|
||||||
|
# OK branch (real recovery) or after a successful failover to a NEW server
|
||||||
|
# (so the new server gets a fresh restart→port-hop→failover cycle).
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
echo "$FAIL_COUNT" > "$STATE/fail_count"
|
||||||
|
log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PORT_IDX fails=$FAIL_COUNT"
|
||||||
|
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_RESTART_OBF" ]; then
|
# Stage 1 (fail 1): Restart obfuscator
|
||||||
# Restart obfuscator only
|
if [ "$FAIL_COUNT" -eq 1 ]; then
|
||||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
log "ACTION: restart obfuscator"
|
||||||
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
restart_obf
|
||||||
log "ACTION: restart obfuscator (attempt $RESTART_COUNT)"
|
|
||||||
killall wg-obfuscator 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_RESTART_WG" ]; then
|
# TCP mode went stale → back to the UDP round (next server)
|
||||||
# Restart WireGuard + obfuscator
|
if [ "$TCP_MODE" != "0" ] && [ "$FAIL_COUNT" -ge 2 ]; then
|
||||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
log "TCP stale on server $TCP_MODE"
|
||||||
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
if try_tcp_after "$TCP_MODE"; then echo 0 > "$STATE/fail_count"; exit 0; fi
|
||||||
log "ACTION: restart WG + obfuscator (attempt $RESTART_COUNT)"
|
stop_tcp
|
||||||
wg-quick down wg0 2>/dev/null
|
switch_endpoint 1 0; echo 0 > "$STATE/fail_count"; exit 0
|
||||||
killall wg-obfuscator 2>/dev/null
|
|
||||||
sleep 2
|
|
||||||
wg-quick up wg0 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_FAILOVER" ]; then
|
# Stage 2 (fail 2+): hop through ALL ports of the SAME server (was: only one)
|
||||||
# Try next port
|
if [ "$FAIL_COUNT" -ge 2 ]; then
|
||||||
log "ACTION: try port hop"
|
if try_next_port; then
|
||||||
if ! try_next_port; then
|
|
||||||
log "ACTION: all ports exhausted, try next server"
|
|
||||||
try_next_server
|
|
||||||
fi
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
log "ports of server $CURRENT_SERVER exhausted, escalate to failover"
|
||||||
# Full failover
|
fi
|
||||||
log "ACTION: failover (handshake ${AGE}s)"
|
|
||||||
if ! try_next_server; then
|
# Whole UDP round failed (all servers × ports) → TCP, if any server offers it
|
||||||
# Only one server, cycle ports
|
if [ "$CURRENT_SERVER" -eq "$SERVER_COUNT" ]; then
|
||||||
try_next_port
|
CYCLES=$((CYCLES + 1)); echo "$CYCLES" > "$STATE/udp_cycles"
|
||||||
|
if try_tcp_after 0; then echo 0 > "$STATE/fail_count"; exit 0; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server
|
||||||
|
# gets its own restart→port-hop→failover cycle next ticks.
|
||||||
|
log "ACTION: server failover (fail $FAIL_COUNT)"
|
||||||
|
if try_next_server; then
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
else
|
||||||
|
# Only one server — cycle back to port 0 and restart escalation
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
switch_endpoint "$CURRENT_SERVER" "0"
|
||||||
fi
|
fi
|
||||||
|
|||||||
51
docs/DNS.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# DNS для клиентов и свой AdGuard Home
|
||||||
|
|
||||||
|
## Какие DNS получают клиенты
|
||||||
|
|
||||||
|
По умолчанию в конфиге клиента (секция `[Interface]`) стоит строка
|
||||||
|
|
||||||
|
```
|
||||||
|
DNS = 1.1.1.1, 8.8.8.8
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть Cloudflare и Google.
|
||||||
|
|
||||||
|
## Где посмотреть и поменять
|
||||||
|
|
||||||
|
**В панели:** «Настройки» → «DNS для клиентов». Впишите адреса через запятую. Если поле пустое, используются 1.1.1.1 и 8.8.8.8.
|
||||||
|
|
||||||
|
- Новые клиенты получают новый DNS сразу.
|
||||||
|
- Уже выданным клиентам достаточно заново открыть ссылку или QR, пересоздавать клиента не нужно.
|
||||||
|
- Значение хранится в `/opt/Phobos/server/server.env` в строке `CLIENT_DNS=...`, без пробелов.
|
||||||
|
|
||||||
|
**Вручную, только для одного клиента:** исправьте строку `DNS =` в его `.conf` и заново импортируйте конфиг.
|
||||||
|
|
||||||
|
**Роутеры Keenetic** раздают устройствам в домашней сети свой собственный DNS, строка из конфига на них не влияет.
|
||||||
|
|
||||||
|
**Firewall клиента (🛡).** Если клиенту задан список разрешённых адресов, всё остальное блокируется, и запросы к DNS тоже. Добавьте в разрешённые адреса DNS-серверов, например `1.1.1.1:53/udp, 8.8.8.8:53/udp`.
|
||||||
|
|
||||||
|
## Свой AdGuard Home на том же VPS
|
||||||
|
|
||||||
|
> `127.0.0.1` в конфиг клиента прописывать **нельзя**. Строку `DNS =` выполняет устройство клиента, и `127.0.0.1` для него — это оно само, а не сервер.
|
||||||
|
|
||||||
|
Указывать нужно адрес сервера **внутри туннеля**. Это первый адрес подсети WG, его видно в строке `Address` файла `/etc/wireguard/wg0.conf` на сервере, например `10.25.11.1`. Закрытый наружу 53 порт ничему не мешает: запросы идут внутри туннеля через интерфейс `wg0`.
|
||||||
|
|
||||||
|
1. **AGH должен слушать адрес wg0.** В `AdGuardHome.yaml`, в разделе `dns.bind_hosts`, должен быть `10.25.11.1` или `0.0.0.0`. Если там только `127.0.0.1`, добавьте адрес wg0 и перезапустите AGH.
|
||||||
|
2. **Откройте 53 порт только из туннеля** (нужно, если входящие соединения на сервере закрыты):
|
||||||
|
```bash
|
||||||
|
iptables -I INPUT -i wg0 -p udp --dport 53 -j ACCEPT
|
||||||
|
iptables -I INPUT -i wg0 -p tcp --dport 53 -j ACCEPT
|
||||||
|
```
|
||||||
|
Чтобы правило пережило перезагрузку, сохраните его, например через `netfilter-persistent save`.
|
||||||
|
3. **Пропишите этот адрес клиентам:** панель → «Настройки» → «DNS для клиентов» → `10.25.11.1`.
|
||||||
|
4. Если у клиента включён firewall 🛡, добавьте в разрешённые `10.25.11.1:53/udp`.
|
||||||
|
|
||||||
|
Проверка с устройства клиента при включённом VPN:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nslookup ya.ru 10.25.11.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Запрос должен появиться в журнале запросов AGH.
|
||||||
|
|
||||||
|
**Если серверов несколько.** Клиент может переключиться на другой сервер, а адрес DNS в конфиге один для всех. Поэтому либо ставьте AGH на каждый сервер так, чтобы он слушал адрес wg0 этого сервера, либо оставьте общедоступные DNS.
|
||||||
@@ -11,6 +11,8 @@ fails", or "panel is too exposed".
|
|||||||
| `PANEL_PORT` | TCP | admin browser, secondary setup/register, router pull fallback | yes | Web panel and `/api/*`. Real value: `cat /opt/phobos-panel/.port`. |
|
| `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/`. |
|
| `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`. |
|
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. Real value: `grep OBFUSCATOR_PORTS /opt/Phobos/server/server.env`. |
|
||||||
|
| `443` (else `8443`/`2096`/`4433`) | TCP | PCA Phobos VPN apps, Windows, routers (1.5.0+) when UDP is blocked | recommended | TCP fallback `phobos-tcp` (udptcp) since 1.4.1; port chosen at install/update, shown in `phobos-update` output (`TCP fallback: port N`). |
|
||||||
|
| second public IP: obfuscator ports + TCP | UDP/TCP | routers whose ISP blocks the main IP | optional | `phobos-altip` (1.5.0) DNATs the second IP to the main one; list it in panel → «Адрес для клиентов» → spare addresses. |
|
||||||
| `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. |
|
| `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. |
|
| `22` | TCP | admin only | optional | SSH maintenance. Do not expose broadly. |
|
||||||
|
|
||||||
|
|||||||
47
docs/SERVER-SWITCH.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Смена сервера у клиента: что и как работает
|
||||||
|
|
||||||
|
Клиент (учётка) в панели привязан к серверу. Если в панели открыть «Редактировать» и выбрать другой
|
||||||
|
сервер, адрес меняется у клиента **автоматически только в наших приложениях**. Сторонние клиенты
|
||||||
|
(PhobosWG и др.) ничего не знают о панели — им нужно заново импортировать ссылку `phobos://`.
|
||||||
|
|
||||||
|
## Кто подхватывает смену сервера
|
||||||
|
|
||||||
|
| Клиент | Как узнаёт о смене | Задержка |
|
||||||
|
|---|---|---|
|
||||||
|
| Роутер (Keenetic/OpenWrt) | pull-агент `phobos-pull.sh` раз в минуту забирает `failover.conf`; `phobos-health.sh` видит рассинхрон (DESYNC) и переключает (SWITCH) | до ~1 мин |
|
||||||
|
| iOS (build 7+), Android 1.4.5+ | подписка `# pca-sub` в ссылке: приложение читает `/api/phone-sub/<клиент>?token=…` | при запуске и по таймеру |
|
||||||
|
| Windows 1.4.5+ | помощник `sub` читает `# pca-sub`, раз в минуту; при другом host/ключе переписывает `profile.conf` и переподключается | до ~1 мин |
|
||||||
|
| Сторонние (PhobosWG и др.) | никак | переимпорт ссылки вручную |
|
||||||
|
|
||||||
|
## Как это устроено
|
||||||
|
1. Панель → Клиенты → «Редактировать» → «Сервер» (поле `client_assignments` в `settings.json`).
|
||||||
|
2. Панель добавляет WG-пир клиента на выбранный сервер и рассылает конфиг (`fanout_router_config`).
|
||||||
|
3. «Переход на другие серверы»: «Да, при блокировке» — выбранный сервер первый, остальные запасные;
|
||||||
|
«Только этот сервер» (`client_pinned`) — в списке один выбранный сервер.
|
||||||
|
4. Список отдаётся роутеру (`failover.conf`) и телефонам/Windows (`/api/phone-sub`); первый в списке — назначенный.
|
||||||
|
|
||||||
|
## Что нужно, чтобы это работало
|
||||||
|
- **Проверено 03.11.2026** (главная панель на ВМ + secondary на VPS): регистрация (`registered`), пир клиента появляется на secondary, «Сервер» меняет порядок в `/api/phone-sub` (назначенный первый), «Только этот сервер» оставляет один. Команда secondary в панели сама подставляет подсеть главной.
|
||||||
|
- **Один сервер управления.** Остальные серверы ставятся как secondary командой из панели
|
||||||
|
(раздел «Серверы»): общий список клиентов и адресов. Отдельная панель на втором VPS (полный
|
||||||
|
`install.sh`) — это другая панель со своими клиентами, смена сервера между ними не работает.
|
||||||
|
- Если всё же ставите независимые серверы — у каждого своя подсеть (`10.25.0.0/16`, `10.25.11.0/24`, …),
|
||||||
|
иначе клиенты получат одинаковые IP и второй туннель на роутере не поднимется.
|
||||||
|
Подсеть вида `10.25.11.0/16` установщик превращает в `10.25.11.0/24`.
|
||||||
|
- В ссылке клиента должна быть строка `# pca-sub = …`. Старые ссылки без неё импортировать заново один раз.
|
||||||
|
- Несколько учёток на одном роутере (по стране на учётку): у каждой «Только этот сервер», в beta/dev
|
||||||
|
они ставятся в `/opt/etc/Phobos/acc/<учётка>/`. Не пробовать на канале stable (v1.4.7): там затрёт главную.
|
||||||
|
|
||||||
|
## Как проверить за минуту
|
||||||
|
```bash
|
||||||
|
# что панель отдаёт клиенту (первый сервер = назначенный)
|
||||||
|
curl -s "http://ПАНЕЛЬ:ПОРТ/api/phone-sub/КЛИЕНТ?token=ТОКЕН"
|
||||||
|
# роутер: новый сервер и журнал переключения
|
||||||
|
cat /opt/etc/Phobos/failover.conf | grep ^SERVER_1; tail /opt/etc/Phobos/health.log
|
||||||
|
# Windows: журнал и профиль
|
||||||
|
type C:\ProgramData\PCAPhobos\pcaphobos.log # «panel moved us to … -> reconnect»
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
- Windows-помощник сравнивает host, ключ обфускатора и WG-ключ; смена только порта не вызывает переподключения.
|
||||||
|
- Пока клиент оффлайн, он узнает о смене при следующем запуске/подключении.
|
||||||
29
docs/WARP.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# WARP: проверка и типичные проблемы
|
||||||
|
|
||||||
|
WARP включается в панели: «Настройки» → WARP. Режим egress выпускает исходящий трафик клиентов в интернет через Cloudflare: всех клиентов или только отмеченных.
|
||||||
|
|
||||||
|
## Как понять, что WARP реально работает
|
||||||
|
|
||||||
|
В карточке WARP есть строка **Handshake**:
|
||||||
|
|
||||||
|
- **«N с назад»** — связь с Cloudflare есть.
|
||||||
|
- **«нет — UDP до Cloudflare не проходит»** — интерфейс поднят, но Cloudflare не отвечает. Трафик клиентов через WARP идти не будет.
|
||||||
|
|
||||||
|
Проверка с сервера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wg show warp latest-handshakes
|
||||||
|
curl --interface warp -m 6 https://www.cloudflare.com/cdn-cgi/trace | grep warp= # ожидается warp=on
|
||||||
|
phobos-warp status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что делает панель сама
|
||||||
|
|
||||||
|
- **Ищет рабочий адрес Cloudflare.** При включении перебирает адреса и порты (2408 / 500 / 1701 / 4500) и записывает в конфиг первый, с которым появился handshake. Если не подошёл ни один, это занимает около минуты.
|
||||||
|
- **Исправляет размер пакетов.** У WARP MTU 1280, у клиентов 1420. Без поправки крупные TCP-пакеты терялись, и интернет у клиентов работал «через раз». Теперь сервер автоматически уменьшает размер TCP-пакетов под WARP (правило MSS-clamp).
|
||||||
|
|
||||||
|
## «У клиентов нет интернета», IP Cloudflare показывается через раз
|
||||||
|
|
||||||
|
1. Обновите панель до версии с этими исправлениями («Версия и обновления»), затем выключите и снова включите WARP.
|
||||||
|
2. Посмотрите строку Handshake. Если там «нет», значит UDP до Cloudflare с этого VPS режется. Обычно так бывает на VPS в РФ, где может не проходить даже регистрация WARP. В этом случае держите WARP выключенным или используйте его на сервере за рубежом.
|
||||||
|
3. Если включён firewall клиента (🛡), проверьте, что в разрешённых есть DNS-серверы. См. [DNS.md](DNS.md).
|
||||||
139
docs/cloud.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Канал «Облако» — VPN через облако своего Keenetic (PCA Phobos 1.4.4)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Что это и зачем
|
||||||
|
Обычно телефон ходит к серверу Phobos напрямую по UDP (или по резервному TCP). Если оператор или сеть
|
||||||
|
режет сам сервер, а сайты Keenetic открываются, можно пойти **через свой домашний Keenetic**:
|
||||||
|
|
||||||
|
1. телефон открывает защищённый WebSocket `wss://phobos.<имя>.<домен>/` — для оператора это обычный HTTPS к облаку Keenetic;
|
||||||
|
2. облако KeenDNS (серверы Keenetic в РФ) по имени пересылает соединение в ваш роутер — белый IP роутеру не нужен;
|
||||||
|
3. на роутере шлюз **wsgw** разворачивает WebSocket обратно в UDP и шлёт на обфускатор сервера Phobos;
|
||||||
|
4. дальше всё как обычно: WireGuard → интернет с IP сервера.
|
||||||
|
|
||||||
|
Это **дополнение** к UDP/TCP, не замена. Шифрование то же (WireGuard), облако Keenetic видит только зашифрованный поток.
|
||||||
|
|
||||||
|
**Адрес вида `wss://phobos.vasya.keenetic.pro/`** = `wss://` (WebSocket через HTTPS, порт 443) +
|
||||||
|
`phobos` (поддомен веб-приложения на роутере) + `vasya.keenetic.pro` (KeenDNS-имя роутера).
|
||||||
|
|
||||||
|
Скорость в нашем тесте (Keenetic → сервер в Польше): ↓11 / ↑15 Мбит/с, задержка ~80 мс, потерь 0 %.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Часть 1. Роутер Keenetic (один раз)
|
||||||
|
|
||||||
|
**Что нужно:** Keenetic с KeenDNS-именем (Интернет → Доменное имя), установленный Entware (OPKG) и SSH.
|
||||||
|
Команды ниже — в SSH роутера (`ssh root@192.168.1.1 -p 222` или как у вас настроено).
|
||||||
|
|
||||||
|
### Шаг 1. Веб-интерфейс на порт 80
|
||||||
|
Роутер выпускает сертификат Let's Encrypt для поддомена, только если его веб-интерфейс на порту 80.
|
||||||
|
```sh
|
||||||
|
ndmc -c 'ip http port 80'
|
||||||
|
```
|
||||||
|
Если раньше был другой порт (81, 8080…) — поправьте закладки и скрипты, которые туда ходили.
|
||||||
|
Снаружи порт 80 пробрасывать не нужно.
|
||||||
|
|
||||||
|
### Шаг 2. Шлюз wsgw
|
||||||
|
Узнать архитектуру:
|
||||||
|
```sh
|
||||||
|
uname -m # aarch64 → wsgw-aarch64; mips → wsgw-mipsel (или wsgw-mips, если не запустится)
|
||||||
|
```
|
||||||
|
Скачать прямо на роутер (замените `aarch64` на свою архитектуру):
|
||||||
|
```sh
|
||||||
|
curl -fsSL -o /opt/sbin/wsgw https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/wsgw/bin/wsgw-aarch64
|
||||||
|
curl -fsSL -o /opt/etc/init.d/S80wsgw https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/wsgw/router/S80wsgw
|
||||||
|
chmod +x /opt/sbin/wsgw /opt/etc/init.d/S80wsgw
|
||||||
|
```
|
||||||
|
Настройка — файл `/opt/etc/wsgw.conf`:
|
||||||
|
```sh
|
||||||
|
cat > /opt/etc/wsgw.conf <<'EOF'
|
||||||
|
WSGW_LISTEN=192.168.1.1:18090
|
||||||
|
WSGW_TARGET=147.45.126.83:2083
|
||||||
|
EOF
|
||||||
|
/opt/etc/init.d/S80wsgw start
|
||||||
|
```
|
||||||
|
- `WSGW_LISTEN` — LAN-адрес **вашего** роутера (Главная → Домашняя сеть) и порт 18090.
|
||||||
|
- `WSGW_TARGET` — адрес обфускатора сервера: строка `target = …` из ссылки клиента
|
||||||
|
(в панели клиента → 📱 → «показать конфиг»). Для сервера Польша — `147.45.126.83:2083`.
|
||||||
|
|
||||||
|
Проверка: `netstat -ltn | grep 18090` — должна быть строка LISTEN.
|
||||||
|
|
||||||
|
### Шаг 3. Поддомен и облачный режим KeenDNS
|
||||||
|
Подставьте своё имя: для `vasya.keenetic.pro` — `<имя>` = `vasya`, `<домен>` = `keenetic.pro`.
|
||||||
|
```sh
|
||||||
|
ndmc -c 'ip http proxy phobos'
|
||||||
|
ndmc -c 'ip http proxy phobos upstream http 192.168.1.1 18090'
|
||||||
|
ndmc -c 'ip http proxy phobos domain ndns'
|
||||||
|
ndmc -c 'ip http proxy phobos security-level public'
|
||||||
|
ndmc -c 'ndns book-name <имя> <домен> cloud'
|
||||||
|
ndmc -c 'ip http ssl acme get phobos.<имя>.<домен>'
|
||||||
|
ndmc -c 'system configuration save'
|
||||||
|
```
|
||||||
|
То же в веб-интерфейсе: **Сетевые правила → Переадресация → Веб-приложения** (или «Доменное имя → Веб-приложения»):
|
||||||
|
имя `phobos`, адрес `192.168.1.1`, порт `18090`, доступ **без авторизации**; режим доменного имени — **через облако**.
|
||||||
|
|
||||||
|
Сертификат выпускается за 10–60 секунд. Проверить:
|
||||||
|
```sh
|
||||||
|
ndmc -c 'ip http ssl acme list' | grep domain # в списке должен быть phobos.<имя>.<домен>
|
||||||
|
```
|
||||||
|
Если его нет, а `ndmc -c 'show acme' | grep get-pending` показывает `yes` — **перезагрузите роутер** и повторите `acme get`.
|
||||||
|
|
||||||
|
### Шаг 4. Проверка с любого компьютера
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://phobos.<имя>.<домен>/
|
||||||
|
```
|
||||||
|
`426` — всё работает (шлюз ответил «нужен WebSocket»). Ошибка TLS (`internal error`) — см. «Если не работает».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Часть 2. Телефон
|
||||||
|
|
||||||
|
### Android (PCA Phobos VPN 1.4.4+)
|
||||||
|
1. Импортируйте ссылку `phobos://` как обычно.
|
||||||
|
2. «Канал до сервера» → **Облако**.
|
||||||
|
3. В окне «Адрес роутера (KeenDNS)» введите `wss://phobos.<имя>.<домен>/` → **Сохранить**.
|
||||||
|
4. **Подключить**. Статус: «🟢 VPN включён · Облако».
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### iPhone (PCA Phobos, TestFlight 1.0 (6) и новее)
|
||||||
|
1. Импортируйте ссылку `phobos://`.
|
||||||
|
2. «Канал до сервера» → сегмент **Облако**.
|
||||||
|
3. В появившемся поле — `wss://phobos.<имя>.<домен>/`.
|
||||||
|
4. **Подключить** → «VPN включён · Облако».
|
||||||
|
|
||||||
|
### Проверка
|
||||||
|
Откройте speed.cloudflare.com: «Your IP address» должен быть IP сервера Phobos (Польша — 147.45.126.83).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Адрес можно не вводить руками: если в ссылке клиента есть строка `# pca-ws = wss://…`, приложение возьмёт её само
|
||||||
|
(пока строку добавляет только администратор вручную).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Если не работает
|
||||||
|
|
||||||
|
| Что видно | Причина | Что делать |
|
||||||
|
|---|---|---|
|
||||||
|
| `acme`: «available only when HTTP port is set to 80» | веб-интерфейс не на 80 | Шаг 1 |
|
||||||
|
| сертификата нет, `get-pending: yes` | задание ACME зависло | перезагрузить роутер, повторить `acme get` |
|
||||||
|
| curl: TLS `internal error` | у роутера нет сертификата **или** WAN:443 проброшен на другой компьютер (облако шлёт поддомены на WAN:443) | проверить `acme list`; убрать проброс 443 или настроить на том компьютере прокси поддомена на `роутер:18090` (например, Caddy: `phobos.<имя>.<домен> { reverse_proxy 192.168.1.1:18090 }`) |
|
||||||
|
| curl: `403` | поддомен не `public` / облачный режим выключен | `security-level public`, `ndns … cloud` |
|
||||||
|
| curl: `502` | шлюз не запущен | `/opt/etc/init.d/S80wsgw start`, проверить `wsgw.conf` |
|
||||||
|
| VPN «включён · Облако», но сайты не открываются | неверный `WSGW_TARGET` или профиль от другого сервера | `target` в ссылке клиента = `WSGW_TARGET` |
|
||||||
|
|
||||||
|
Журнал приложения (Android → «Журнал»): строки `ws: connected …` / `ws up: …` / `ws down: …`.
|
||||||
|
|
||||||
|
## Как выключить
|
||||||
|
```sh
|
||||||
|
/opt/etc/init.d/S80wsgw stop
|
||||||
|
ndmc -c 'no ip http proxy phobos'
|
||||||
|
ndmc -c 'ndns book-name <имя> <домен> direct'
|
||||||
|
ndmc -c 'system configuration save'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Для разработчика
|
||||||
|
Код: `wsgw/` (шлюз, Go), `android-client/src/com/pca/phobos/vpn/WsRelay.java`,
|
||||||
|
`ios/Tunnel/WsLink.swift` + режим 2 в `pcarelay.c`. Протокол: одно бинарное WS-сообщение = один обфусцированный UDP-пакет.
|
||||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 158 KiB |
BIN
docs/img/cloud/android-1-glavnyj.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/img/cloud/android-2-adres.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/img/cloud/android-3-skorost.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/img/cloud/skhema.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
37
docs/img/cloud/skhema.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430" font-family="Helvetica, Arial, sans-serif">
|
||||||
|
<rect width="1200" height="430" fill="#ffffff"/>
|
||||||
|
<text x="600" y="38" text-anchor="middle" font-size="24" font-weight="bold" fill="#0f172a">Канал «Облако»: телефон → облако KeenDNS → свой Keenetic → сервер Phobos</text>
|
||||||
|
<defs><marker id="a" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#475569"/></marker></defs>
|
||||||
|
<g font-size="17">
|
||||||
|
<rect x="20" y="90" width="200" height="130" rx="14" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||||
|
<text x="120" y="125" text-anchor="middle" font-weight="bold" fill="#1e1b4b">Телефон</text>
|
||||||
|
<text x="120" y="152" text-anchor="middle" fill="#334155">PCA Phobos VPN</text>
|
||||||
|
<text x="120" y="176" text-anchor="middle" fill="#334155">канал «Облако»</text>
|
||||||
|
<text x="120" y="200" text-anchor="middle" fill="#334155">WireGuard+обфускатор</text>
|
||||||
|
<rect x="320" y="90" width="210" height="130" rx="14" fill="#f0f9ff" stroke="#0ea5e9" stroke-width="2"/>
|
||||||
|
<text x="425" y="125" text-anchor="middle" font-weight="bold" fill="#0c4a6e">Облако KeenDNS</text>
|
||||||
|
<text x="425" y="152" text-anchor="middle" fill="#334155">серверы Keenetic в РФ</text>
|
||||||
|
<text x="425" y="176" text-anchor="middle" fill="#334155">пересылает HTTPS</text>
|
||||||
|
<text x="425" y="200" text-anchor="middle" fill="#334155">по имени (SNI)</text>
|
||||||
|
<rect x="630" y="90" width="250" height="130" rx="14" fill="#f0fdf4" stroke="#16a34a" stroke-width="2"/>
|
||||||
|
<text x="755" y="125" text-anchor="middle" font-weight="bold" fill="#14532d">Ваш Keenetic</text>
|
||||||
|
<text x="755" y="152" text-anchor="middle" fill="#334155">поддомен phobos.* (TLS)</text>
|
||||||
|
<text x="755" y="176" text-anchor="middle" fill="#334155">→ шлюз wsgw :18090</text>
|
||||||
|
<text x="755" y="200" text-anchor="middle" fill="#334155">WebSocket → UDP</text>
|
||||||
|
<rect x="980" y="90" width="200" height="130" rx="14" fill="#fff7ed" stroke="#ea580c" stroke-width="2"/>
|
||||||
|
<text x="1080" y="125" text-anchor="middle" font-weight="bold" fill="#7c2d12">Сервер Phobos</text>
|
||||||
|
<text x="1080" y="152" text-anchor="middle" fill="#334155">обфускатор UDP</text>
|
||||||
|
<text x="1080" y="176" text-anchor="middle" fill="#334155">→ WireGuard</text>
|
||||||
|
<text x="1080" y="200" text-anchor="middle" fill="#334155">→ интернет</text>
|
||||||
|
</g>
|
||||||
|
<g stroke="#475569" stroke-width="3" marker-end="url(#a)"><line x1="222" y1="155" x2="316" y2="155"/><line x1="532" y1="155" x2="626" y2="155"/><line x1="882" y1="155" x2="976" y2="155"/></g>
|
||||||
|
<g font-size="15" fill="#475569" text-anchor="middle">
|
||||||
|
<text x="270" y="140">wss:// 443</text><text x="580" y="140">туннель</text><text x="580" y="178">Keenetic</text><text x="930" y="140">UDP</text></g>
|
||||||
|
<rect x="20" y="260" width="1160" height="150" rx="14" fill="#f8fafc" stroke="#cbd5e1"/>
|
||||||
|
<g font-size="17" fill="#0f172a">
|
||||||
|
<text x="44" y="295" font-weight="bold">Когда нужно</text>
|
||||||
|
<text x="44" y="322">• у оператора/в сети заблокирован сервер Phobos (UDP и TCP), а сайты Keenetic открываются;</text>
|
||||||
|
<text x="44" y="348">• у роутера нет белого IP — облако KeenDNS достаёт его всё равно.</text>
|
||||||
|
<text x="44" y="380" font-weight="bold">Проверено 31.10.2026: </text><text x="250" y="380">планшет → облако → домашний Keenetic → Польша, ↓11 / ↑15 Мбит/с, потерь 0 %.</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 168 KiB |
BIN
docs/img/mobile-servers-dark.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 589 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 608 KiB |
53
install.sh
@@ -19,6 +19,7 @@
|
|||||||
# OBF_PORTS 2083,5443,993 obfuscator listen ports
|
# OBF_PORTS 2083,5443,993 obfuscator listen ports
|
||||||
# TG_TOKEN / TG_CHAT Telegram alerts
|
# TG_TOKEN / TG_CHAT Telegram alerts
|
||||||
# PCA_BRANCH main branch to pull PCA files from
|
# PCA_BRANCH main branch to pull PCA files from
|
||||||
|
# SKIP_NGINX 0 1 = do not touch an existing nginx
|
||||||
# ============================================================
|
# ============================================================
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -29,11 +30,46 @@ 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}"
|
||||||
|
# Уже есть wg0.conf (переустановка) — подсеть берём из него, иначе server.env разойдётся с интерфейсом
|
||||||
|
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -f /etc/wireguard/wg0.conf ]; then
|
||||||
|
SERVER_WG_IPV4_NETWORK="$(python3 - <<'PY' 2>/dev/null
|
||||||
|
import ipaddress, re
|
||||||
|
for ln in open("/etc/wireguard/wg0.conf"):
|
||||||
|
m = re.match(r"\s*Address\s*=\s*([^,\s]+)", ln)
|
||||||
|
if m:
|
||||||
|
print(ipaddress.ip_interface(m.group(1)).network); break
|
||||||
|
PY
|
||||||
|
)" || SERVER_WG_IPV4_NETWORK=""
|
||||||
|
fi
|
||||||
|
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
||||||
|
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
|
||||||
|
printf '\n!!! Это ВТОРОЙ сервер к уже работающей панели? Тогда прервите (Ctrl+C) и ставьте командой из панели\n «Серверы -> Как добавить второй сервер» (secondary-setup.sh), подсеть там по умолчанию.\n Своя подсеть нужна только для НЕЗАВИСИМОЙ панели (например 10.25.11.0/24, не пересекается с первой);\n смена сервера у клиентов между независимыми панелями не работает.\n\n' > /dev/tty
|
||||||
|
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
|
||||||
|
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
||||||
|
fi
|
||||||
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||||
|
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
|
||||||
|
except ValueError:
|
||||||
|
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
|
||||||
|
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
|
||||||
|
# Считаем, что нужна подсеть этого адреса, и берём /24.
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
|
||||||
|
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
|
||||||
|
except ValueError:
|
||||||
|
sys.exit("ERROR: bad subnet " + sys.argv[1])
|
||||||
|
if net.version != 4 or not net.is_private or net.prefixlen > 29:
|
||||||
|
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
|
||||||
|
print(net)
|
||||||
|
PY
|
||||||
|
)" || exit 1
|
||||||
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
|
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
|
||||||
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
|
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
|
||||||
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
||||||
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
|
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
|
||||||
CHANNEL="${CHANNEL:-stable}"
|
CHANNEL="${CHANNEL:-stable}"
|
||||||
case "$CHANNEL" in
|
case "$CHANNEL" in
|
||||||
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
|
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
|
||||||
@@ -69,7 +105,8 @@ print(ipaddress.ip_network(sys.argv[1], strict=False).prefixlen)
|
|||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
|
|
||||||
SERVER_IP=$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}')
|
# ponytail: SERVER_IP можно задать явно — для серверов с несколькими IP
|
||||||
|
SERVER_IP="${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)
|
||||||
IFACE="${IFACE:-eth0}"
|
IFACE="${IFACE:-eth0}"
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
@@ -305,6 +342,8 @@ systemctl daemon-reload; systemctl enable phobos-panel -q; systemctl restart pho
|
|||||||
|
|
||||||
# ── 8. nginx (serve /init + /packages over plain HTTP for routers) ──
|
# ── 8. nginx (serve /init + /packages over plain HTTP for routers) ──
|
||||||
echo "[8/9] nginx..."
|
echo "[8/9] nginx..."
|
||||||
|
# ponytail: SKIP_NGINX=1 — на сервере уже есть свой nginx; /init и /packages отдаёт панель
|
||||||
|
if [ "${SKIP_NGINX:-0}" = "1" ]; then echo " skipped (SKIP_NGINX=1)"; else
|
||||||
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
||||||
cat > /etc/nginx/sites-available/phobos <<'NGINX'
|
cat > /etc/nginx/sites-available/phobos <<'NGINX'
|
||||||
server {
|
server {
|
||||||
@@ -317,8 +356,9 @@ server {
|
|||||||
}
|
}
|
||||||
NGINX
|
NGINX
|
||||||
ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos
|
ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos
|
||||||
mkdir -p /opt/Phobos/www/app; chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages /opt/Phobos/www/app
|
|
||||||
nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed"
|
nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed"
|
||||||
|
fi
|
||||||
|
mkdir -p /opt/Phobos/www/app; chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages /opt/Phobos/www/app
|
||||||
|
|
||||||
# ── 9. router watchdog (auto reboot-recovery) ──
|
# ── 9. router watchdog (auto reboot-recovery) ──
|
||||||
echo "[9/9] router watchdog..."
|
echo "[9/9] router watchdog..."
|
||||||
@@ -336,6 +376,13 @@ sleep 2
|
|||||||
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
|
||||||
|
|
||||||
|
# при переустановке settings.json остаётся старый — в сводке показываем то, что реально в панели
|
||||||
|
_S="$PANEL_DIR/settings.json"
|
||||||
|
if [ -f "$_S" ]; then
|
||||||
|
API_KEY="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("server_api_key") or sys.argv[2])' "$_S" "$API_KEY" 2>/dev/null || echo "$API_KEY")"
|
||||||
|
PANEL_PASS="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("admin_pass") or sys.argv[2])' "$_S" "$PANEL_PASS" 2>/dev/null || echo "$PANEL_PASS")"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " Installation complete"
|
echo " Installation complete"
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ resolve_install_names() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f "$binary_path" ] && [ -f "$PHOBOS_DIR/${CLIENT_NAME}.conf" ]; then
|
if [ -f "$binary_path" ] && [ -f "$PHOBOS_DIR/${CLIENT_NAME}.conf" ]; then
|
||||||
|
# 02.11.2026: переустановка — берём те же имена, что у этой учётки (по *.link),
|
||||||
|
# иначе доп. учётка затёрла бы обфускатор главной
|
||||||
|
local l n sc
|
||||||
|
for l in "$PHOBOS_DIR"/wg-obfuscator?*.link; do
|
||||||
|
[ "$(cat "$l" 2>/dev/null)" = "${CLIENT_NAME}" ] || continue
|
||||||
|
n=$(basename "$l" .link); n=${n#wg-obfuscator}
|
||||||
|
sc=$(grep -l "/wg-obfuscator${n}.conf\"\$" /opt/etc/init.d/S*wg-obfuscator 2>/dev/null | head -1)
|
||||||
|
OBF_BINARY_NAME="wg-obfuscator${n}"
|
||||||
|
OBF_CONF_NAME="wg-obfuscator${n}.conf"
|
||||||
|
OBF_INIT_NAME=$(basename "${sc:-S50wg-obfuscator}")
|
||||||
|
OBF_SERVICE_NAME="phobos-obfuscator${n}"
|
||||||
|
OBF_WG_IFACE="phobos${n}"
|
||||||
|
return
|
||||||
|
done
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,16 @@ show_final_info() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Убирает из crontab строки health/pull только этой учётки ($1 = "PHOBOS_ACC=<dir> "
|
||||||
|
# для доп. учётки, "PHOBOS_ACC=" для главной — у неё строки без PHOBOS_ACC).
|
||||||
|
drop_own_cron() {
|
||||||
|
if [ "$1" = "PHOBOS_ACC=" ]; then
|
||||||
|
awk '!/phobos-(health|pull)/ || /PHOBOS_ACC=/'
|
||||||
|
else
|
||||||
|
grep -v -F "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
deploy_health_monitor() {
|
deploy_health_monitor() {
|
||||||
log "Развертывание монитора здоровья Phobos..."
|
log "Развертывание монитора здоровья Phobos..."
|
||||||
|
|
||||||
@@ -428,12 +438,19 @@ deploy_health_monitor() {
|
|||||||
cp "phobos-health.sh" "$PHOBOS_DIR/phobos-health.sh"
|
cp "phobos-health.sh" "$PHOBOS_DIR/phobos-health.sh"
|
||||||
chmod +x "$PHOBOS_DIR/phobos-health.sh"
|
chmod +x "$PHOBOS_DIR/phobos-health.sh"
|
||||||
|
|
||||||
if [ -f "failover.conf" ]; then
|
# 02.11.2026: вторая и следующие учётки на одном роутере — свой каталог
|
||||||
cp "failover.conf" "$PHOBOS_DIR/failover.conf"
|
# (failover.conf, state, client_id, panel_*), свои строки cron с PHOBOS_ACC.
|
||||||
chmod 600 "$PHOBOS_DIR/failover.conf"
|
local A="$PHOBOS_DIR" envp="" mine="PHOBOS_ACC="
|
||||||
|
local main_cid=$(cat "$A/client_id" 2>/dev/null)
|
||||||
|
if [ -n "$main_cid" ] && [ "$main_cid" != "{{CLIENT_NAME}}" ]; then
|
||||||
|
A="$PHOBOS_DIR/acc/{{CLIENT_NAME}}"; envp="PHOBOS_ACC=$A "; mine="PHOBOS_ACC=$A "
|
||||||
|
log " Дополнительная учётка: $A (главная — $main_cid)"
|
||||||
|
fi
|
||||||
|
mkdir -p "$A/state"
|
||||||
|
if [ -f "failover.conf" ]; then
|
||||||
|
cp "failover.conf" "$A/failover.conf"
|
||||||
|
chmod 600 "$A/failover.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$PHOBOS_DIR/state"
|
|
||||||
|
|
||||||
# --- Phobos pull agent: panel -> router config sync (NAT-friendly) ---
|
# --- Phobos pull agent: panel -> router config sync (NAT-friendly) ---
|
||||||
# Routers behind NAT cannot be reached by the panel over SSH, so they PULL
|
# Routers behind NAT cannot be reached by the panel over SSH, so they PULL
|
||||||
@@ -441,54 +458,48 @@ deploy_health_monitor() {
|
|||||||
if [ -f "phobos-pull.sh" ]; then
|
if [ -f "phobos-pull.sh" ]; then
|
||||||
cp "phobos-pull.sh" "$PHOBOS_DIR/phobos-pull.sh"
|
cp "phobos-pull.sh" "$PHOBOS_DIR/phobos-pull.sh"
|
||||||
chmod +x "$PHOBOS_DIR/phobos-pull.sh"
|
chmod +x "$PHOBOS_DIR/phobos-pull.sh"
|
||||||
echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id"
|
echo "{{CLIENT_NAME}}" > "$A/client_id"
|
||||||
[ -f "pull_token" ] && cp "pull_token" "$PHOBOS_DIR/pull_token"
|
[ -f "pull_token" ] && cp "pull_token" "$A/pull_token"
|
||||||
[ -f "panel_tunnel_ip" ] && cp "panel_tunnel_ip" "$PHOBOS_DIR/panel_tunnel_ip"
|
[ -f "panel_tunnel_ip" ] && cp "panel_tunnel_ip" "$A/panel_tunnel_ip"
|
||||||
[ -f "panel_port" ] && cp "panel_port" "$PHOBOS_DIR/panel_port"
|
[ -f "panel_port" ] && cp "panel_port" "$A/panel_port"
|
||||||
[ -f "panel_scheme" ] && cp "panel_scheme" "$PHOBOS_DIR/panel_scheme"
|
[ -f "panel_scheme" ] && cp "panel_scheme" "$A/panel_scheme"
|
||||||
[ -f "panel_url" ] && cp "panel_url" "$PHOBOS_DIR/panel_url"
|
[ -f "panel_url" ] && cp "panel_url" "$A/panel_url"
|
||||||
[ -f "panel_urls" ] && cp "panel_urls" "$PHOBOS_DIR/panel_urls"
|
[ -f "panel_urls" ] && cp "panel_urls" "$A/panel_urls"
|
||||||
log " Pull-агент установлен (client_id={{CLIENT_NAME}})"
|
log " Pull-агент установлен (client_id={{CLIENT_NAME}})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local cron_line="*/1 * * * * $PHOBOS_DIR/phobos-health.sh"
|
local cron_line="*/1 * * * * ${envp}$PHOBOS_DIR/phobos-health.sh"
|
||||||
local pull_line="*/1 * * * * $PHOBOS_DIR/phobos-pull.sh"
|
local pull_line="*/1 * * * * ${envp}$PHOBOS_DIR/phobos-pull.sh"
|
||||||
[ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line=""
|
[ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line=""
|
||||||
|
|
||||||
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
|
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
|
||||||
# Install cron if not present (Entware)
|
# Демон — busybox crond (есть в Entware как /opt/sbin/crond). Entware-пакет cron
|
||||||
if ! command -v crontab >/dev/null 2>&1 && command -v opkg >/dev/null 2>&1; then
|
# (vixie, PROCS=cron) на Keenetic задачи не выполнял — заменяем (01.11.2026)
|
||||||
log " Установка cron через opkg..."
|
local I=/opt/etc/init.d/S10cron
|
||||||
opkg update >/dev/null 2>&1
|
if [ ! -x $I ] || grep -q "^PROCS=cron$" $I; then
|
||||||
opkg install cron >/dev/null 2>&1
|
[ -x $I ] && $I stop </dev/null >/dev/null 2>&1
|
||||||
/opt/etc/init.d/S10cron start >/dev/null 2>&1 || true
|
printf '#!/bin/sh\n\nENABLED=yes\nPROCS=crond\nARGS="-c /opt/var/spool/cron/crontabs -b"\nPREARGS=""\nDESC=$PROCS\nPATH=/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n. /opt/etc/init.d/rc.func\n' > $I
|
||||||
fi
|
chmod +x $I
|
||||||
local cron_file="/opt/etc/crontab"
|
|
||||||
# Create crontab file if missing
|
|
||||||
if [ ! -f "$cron_file" ]; then
|
|
||||||
mkdir -p /opt/etc
|
|
||||||
touch "$cron_file"
|
|
||||||
fi
|
|
||||||
if ! grep -q "phobos-health" "$cron_file" 2>/dev/null; then
|
|
||||||
echo "$cron_line" >> "$cron_file"
|
|
||||||
log " Cron (health) добавлен в $cron_file"
|
|
||||||
fi
|
|
||||||
if [ -n "$pull_line" ] && ! grep -q "phobos-pull" "$cron_file" 2>/dev/null; then
|
|
||||||
echo "$pull_line" >> "$cron_file"
|
|
||||||
log " Cron (pull) добавлен в $cron_file"
|
|
||||||
fi
|
fi
|
||||||
|
[ -x /opt/sbin/crond ] || command -v crond >/dev/null 2>&1 || log " ВНИМАНИЕ: crond не найден — health/pull по расписанию работать не будут (opkg install cron или busybox с crond)"
|
||||||
|
mkdir -p /opt/var/spool/cron/crontabs
|
||||||
|
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
|
||||||
|
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
|
||||||
|
[ -z "$envp" ] && sed -i "/phobos-health\|phobos-pull/d" /opt/etc/crontab 2>/dev/null || true
|
||||||
|
(crontab -l 2>/dev/null | drop_own_cron "$mine"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
||||||
|
log " Cron добавлен через crontab (health + pull)"
|
||||||
/opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true
|
/opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true
|
||||||
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
||||||
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
(crontab -l 2>/dev/null | drop_own_cron "$mine"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
||||||
log " Cron добавлен через crontab (health + pull)"
|
log " Cron добавлен через crontab (health + pull)"
|
||||||
elif [ "$ROUTER_PLATFORM" = "linux" ]; then
|
elif [ "$ROUTER_PLATFORM" = "linux" ]; then
|
||||||
{ echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line"; } > /etc/cron.d/phobos-health
|
{ echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line"; } > /etc/cron.d/phobos-health${envp:+-{{CLIENT_NAME}}}
|
||||||
chmod 644 /etc/cron.d/phobos-health
|
chmod 644 /etc/cron.d/phobos-health*
|
||||||
log " Cron добавлен в /etc/cron.d/phobos-health (health + pull)"
|
log " Cron добавлен в /etc/cron.d/phobos-health (health + pull)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log " Монитор здоровья: $PHOBOS_DIR/phobos-health.sh"
|
log " Монитор здоровья: $PHOBOS_DIR/phobos-health.sh"
|
||||||
log " Failover конфиг: $PHOBOS_DIR/failover.conf"
|
log " Failover конфиг: $A/failover.conf"
|
||||||
log " Проверка каждую минуту через cron"
|
log " Проверка каждую минуту через cron"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ PY
|
|||||||
[Interface]
|
[Interface]
|
||||||
PrivateKey = $priv_key
|
PrivateKey = $priv_key
|
||||||
Address = $addr_str
|
Address = $addr_str
|
||||||
DNS = 1.1.1.1, 8.8.8.8
|
DNS = ${CLIENT_DNS:-1.1.1.1, 8.8.8.8}
|
||||||
MTU = 1420
|
MTU = 1420
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
@@ -220,7 +220,7 @@ action_package() {
|
|||||||
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
|
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
|
||||||
|
|
||||||
# Fetch missing router-arch binaries from ClusterM on-demand
|
# Fetch missing router-arch binaries from ClusterM on-demand
|
||||||
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
|
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') || _tag="" # ponytail: GitHub API rate limit/недоступность не должны ронять сборку пакета — берём уже скачанные бинарники
|
||||||
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
|
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
|
||||||
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
|
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
|
||||||
for _arch in aarch64 mipsel mips armv7 x86_64; do
|
for _arch in aarch64 mipsel mips armv7 x86_64; do
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
export PATH=/opt/bin:/opt/sbin:/opt/usr/bin:$PATH # cron без /opt/bin -> busybox wget висел вечно (09.29)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Phobos Config Pull — NAT-friendly management channel.
|
# Phobos Config Pull — NAT-friendly management channel.
|
||||||
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
|
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
|
||||||
@@ -21,9 +22,11 @@
|
|||||||
# PANEL env or default below -> panel base URL
|
# PANEL env or default below -> panel base URL
|
||||||
# ============================================================
|
# ============================================================
|
||||||
PHOBOS_DIR="/opt/etc/Phobos"
|
PHOBOS_DIR="/opt/etc/Phobos"
|
||||||
CONF="$PHOBOS_DIR/failover.conf"
|
# 02.11.2026: доп. учётки — PHOBOS_ACC=$PHOBOS_DIR/acc/<клиент> (см. phobos-health.sh)
|
||||||
|
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
|
||||||
|
CONF="$ACC_DIR/failover.conf"
|
||||||
HEALTH="$PHOBOS_DIR/phobos-health.sh"
|
HEALTH="$PHOBOS_DIR/phobos-health.sh"
|
||||||
LOG="$PHOBOS_DIR/health.log"
|
LOG="$ACC_DIR/health.log"
|
||||||
# Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
|
# Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
|
||||||
# whatever server the tunnel currently terminates on, so management rides the
|
# whatever server the tunnel currently terminates on, so management rides the
|
||||||
# same obfuscated channel as data — survives a public-IP ban and follows
|
# same obfuscated channel as data — survives a public-IP ban and follows
|
||||||
@@ -31,15 +34,18 @@ LOG="$PHOBOS_DIR/health.log"
|
|||||||
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
|
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
|
||||||
# - panel_tunnel_ip:<panel_port> -> primary server panel
|
# - 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_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$PHOBOS_DIR/panel_tunnel_ip" 2>/dev/null)}"
|
PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)}"
|
||||||
[ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
|
[ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
|
||||||
PANEL="${PANEL:-$(cat "$PHOBOS_DIR/panel_url" 2>/dev/null)}"
|
PANEL="${PANEL:-$(cat "$ACC_DIR/panel_url" 2>/dev/null)}"
|
||||||
PANEL_URLS="${PANEL_URLS:-$(cat "$PHOBOS_DIR/panel_urls" 2>/dev/null)}"
|
PANEL_URLS="${PANEL_URLS:-$(cat "$ACC_DIR/panel_urls" 2>/dev/null)}"
|
||||||
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
|
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
|
||||||
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
|
# + public URLs the panel announced in failover.conf (second IP etc., 09.33)
|
||||||
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
|
_extra=$(grep "^PANEL_URLS_EXTRA=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
TMP="/tmp/failover.conf.pull"
|
for _u in $_extra; do case " $PANEL_URLS " in *" $_u "*) ;; *) PANEL_URLS="$PANEL_URLS $_u" ;; esac; done
|
||||||
LOCK="/tmp/phobos-pull.lock"
|
CLIENT_ID=$(cat "$ACC_DIR/client_id" 2>/dev/null || echo "home")
|
||||||
|
TOKEN=$(cat "$ACC_DIR/pull_token" 2>/dev/null)
|
||||||
|
TMP="/tmp/failover.conf.pull${PHOBOS_ACC:+-$CLIENT_ID}"
|
||||||
|
LOCK="/tmp/phobos-pull${PHOBOS_ACC:+-$CLIENT_ID}.lock"
|
||||||
|
|
||||||
log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; }
|
log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; }
|
||||||
|
|
||||||
@@ -71,11 +77,24 @@ fetch_config() {
|
|||||||
if command -v curl >/dev/null; then
|
if command -v curl >/dev/null; then
|
||||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
fi
|
fi
|
||||||
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# TCP channel for routers (1.5.0): the guard needs /opt/bin/udptcp when failover.conf has TCP_N.
|
||||||
|
ensure_udptcp() {
|
||||||
|
[ -x /opt/bin/udptcp ] && return 0
|
||||||
|
grep -q "^TCP_[0-9]*=" "$CONF" 2>/dev/null || return 0
|
||||||
|
case "$(uname -m)" in aarch64) a=aarch64 ;; mips*) a=mipsel ;; *) return 0 ;; esac
|
||||||
|
command -v curl >/dev/null || return 0
|
||||||
|
{ curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/udptcp/bin/udptcp-$a" 2>/dev/null \
|
||||||
|
|| curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/udptcp/bin/udptcp-$a" 2>/dev/null; } \
|
||||||
|
&& [ -s /opt/bin/udptcp.tmp ] && chmod 755 /opt/bin/udptcp.tmp && mv /opt/bin/udptcp.tmp /opt/bin/udptcp \
|
||||||
|
&& log "PULL: udptcp installed ($a)" || rm -f /opt/bin/udptcp.tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
||||||
do_pull() {
|
do_pull() {
|
||||||
|
ensure_udptcp
|
||||||
# Try each source (tunnel first); accept first that returns a valid conf.
|
# Try each source (tunnel first); accept first that returns a valid conf.
|
||||||
got=0
|
got=0
|
||||||
for base in $PANEL_URLS; do
|
for base in $PANEL_URLS; do
|
||||||
@@ -113,7 +132,13 @@ apply_obf_params() {
|
|||||||
D=$(grep "^OBF_MAX_DUMMY=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
D=$(grep "^OBF_MAX_DUMMY=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
I=$(grep "^OBF_IDLE=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
I=$(grep "^OBF_IDLE=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
changed=0
|
changed=0
|
||||||
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; do
|
# только свой обфускатор: у других учёток свои failover.conf
|
||||||
|
own=""
|
||||||
|
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
|
||||||
|
[ "$(cat "$l" 2>/dev/null)" = "$CLIENT_ID" ] && own="${l%.link}.conf"
|
||||||
|
done
|
||||||
|
[ -z "$own" ] && [ -z "$PHOBOS_ACC" ] && own="$PHOBOS_DIR/wg-obfuscator*.conf"
|
||||||
|
for OBF in $own; do
|
||||||
[ -f "$OBF" ] || continue
|
[ -f "$OBF" ] || continue
|
||||||
cur=$(grep "^masking = " "$OBF" 2>/dev/null | sed 's/masking = //')
|
cur=$(grep "^masking = " "$OBF" 2>/dev/null | sed 's/masking = //')
|
||||||
[ "$cur" != "$M" ] && { sed -i "s/^masking = .*/masking = $M/" "$OBF"; changed=1; }
|
[ "$cur" != "$M" ] && { sed -i "s/^masking = .*/masking = $M/" "$OBF"; changed=1; }
|
||||||
@@ -129,7 +154,9 @@ apply_obf_params() {
|
|||||||
[ "$changed" = 0 ] && return 0
|
[ "$changed" = 0 ] && return 0
|
||||||
log "PULL: obf params applied (masking=$M dummy=${D:-} idle=${I:-}), restart obfuscator"
|
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
|
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
|
for sc in /opt/etc/init.d/S*obfuscator*; do
|
||||||
|
for OBF in $own; do grep -q "/$(basename "$OBF")\"\$" "$sc" && [ -x "$sc" ] && "$sc" restart >/dev/null 2>&1; done
|
||||||
|
done
|
||||||
elif command -v systemctl >/dev/null 2>&1; then
|
elif command -v systemctl >/dev/null 2>&1; then
|
||||||
systemctl restart 'wg-obfuscator*' >/dev/null 2>&1 || true
|
systemctl restart 'wg-obfuscator*' >/dev/null 2>&1 || true
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -278,11 +278,21 @@ EOF
|
|||||||
|
|
||||||
# Настраиваем статический маршрут в созданный интерфейс отдельным вызовом RCI
|
# Настраиваем статический маршрут в созданный интерфейс отдельным вызовом RCI
|
||||||
local route_target_net=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0
|
local route_target_net=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0
|
||||||
log "Настройка маршрута для подсети туннеля (${route_target_net}/16) -> ${interface_name}..."
|
local route_mask="255.255.0.0"
|
||||||
|
# 02.11.2026: доп. учётка (на роутере уже стоит другая): та же /16 через второй
|
||||||
|
# интерфейс конфликтует с главной, а /32 до панели увёл бы pull главной в чужой
|
||||||
|
# туннель — маршрут не ставим, pull доп. учётки ходит по panel_urls.
|
||||||
|
local main_cid=$(cat /opt/etc/Phobos/client_id 2>/dev/null)
|
||||||
|
if [ -n "$main_cid" ] && [ "$main_cid" != "${CLIENT_NAME}" ]; then
|
||||||
|
log "Доп. учётка (главная — ${main_cid}): маршрут подсети туннеля не нужен"
|
||||||
|
log "Интерфейс ${interface_name} создан ✓"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "Настройка маршрута для туннеля (${route_target_net}/${route_mask}) -> ${interface_name}..."
|
||||||
local route_json=$(cat <<EOF
|
local route_json=$(cat <<EOF
|
||||||
{
|
{
|
||||||
"network": "${route_target_net}",
|
"network": "${route_target_net}",
|
||||||
"mask": "255.255.0.0",
|
"mask": "${route_mask}",
|
||||||
"interface": "${interface_name}",
|
"interface": "${interface_name}",
|
||||||
"auto": true
|
"auto": true
|
||||||
}
|
}
|
||||||
|
|||||||
10
promote.sh
@@ -3,9 +3,9 @@
|
|||||||
# PCA Phobos — promote / sync каналов (maintainer-инструмент)
|
# PCA Phobos — promote / sync каналов (maintainer-инструмент)
|
||||||
#
|
#
|
||||||
# Каналы и репозитории:
|
# Каналы и репозитории:
|
||||||
# stable -> публичный andrey271192/PCA_Phobos ветка main
|
# stable -> публичный adleibzon-rgb/PCA_Phobos ветка main
|
||||||
# beta -> приватный andrey271192/PCA_Phobos-dev ветка beta
|
# beta -> приватный adleibzon-rgb/PCA_Phobos-dev ветка beta
|
||||||
# dev -> приватный andrey271192/PCA_Phobos-dev ветка dev
|
# dev -> приватный adleibzon-rgb/PCA_Phobos-dev ветка dev
|
||||||
#
|
#
|
||||||
# Поток фич — строго в одну сторону: dev -> beta -> stable
|
# Поток фич — строго в одну сторону: dev -> beta -> stable
|
||||||
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
|
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
set -e
|
set -e
|
||||||
GH_TOKEN="${GH_TOKEN:-}"
|
GH_TOKEN="${GH_TOKEN:-}"
|
||||||
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
|
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
|
||||||
PUB="https://andrey271192:${GH_TOKEN}@github.com/andrey271192/PCA_Phobos.git"
|
PUB="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos.git"
|
||||||
PRIV="https://andrey271192:${GH_TOKEN}@github.com/andrey271192/PCA_Phobos-dev.git"
|
PRIV="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos-dev.git"
|
||||||
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
|
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
|
||||||
|
|
||||||
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
|
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
|
||||||
|
|||||||
@@ -69,11 +69,16 @@ def health():
|
|||||||
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
||||||
peer_keys = []
|
peer_keys = []
|
||||||
handshakes = {}
|
handshakes = {}
|
||||||
|
transfer = {}
|
||||||
for line in out.strip().split("\n")[1:]:
|
for line in out.strip().split("\n")[1:]:
|
||||||
parts = line.split("\t")
|
parts = line.split("\t")
|
||||||
if len(parts) >= 4:
|
if len(parts) >= 4:
|
||||||
pub = parts[0]
|
pub = parts[0]
|
||||||
peer_keys.append(pub)
|
peer_keys.append(pub)
|
||||||
|
try:
|
||||||
|
transfer[pub] = [int(parts[5]), int(parts[6])]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
handshakes[pub] = int(parts[4])
|
handshakes[pub] = int(parts[4])
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -83,6 +88,7 @@ def health():
|
|||||||
peers = 0
|
peers = 0
|
||||||
peer_keys = []
|
peer_keys = []
|
||||||
handshakes = {}
|
handshakes = {}
|
||||||
|
transfer = {}
|
||||||
cpu = cpu_percent()
|
cpu = cpu_percent()
|
||||||
try:
|
try:
|
||||||
parts = Path("/proc/meminfo").read_text().splitlines()
|
parts = Path("/proc/meminfo").read_text().splitlines()
|
||||||
@@ -96,7 +102,7 @@ def health():
|
|||||||
mem_str = f"{used}/{total}MB"
|
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, "net": net_bytes()})
|
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "transfer": transfer, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/obf-health")
|
@app.route("/api/obf-health")
|
||||||
@@ -167,6 +173,48 @@ def speedtest():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- Google geo (09.30): how Google sees the server's addresses ---------------
|
||||||
|
GOOGLE_CLOSED = {"Russia", "Belarus", "China", "Iran", "Hong Kong", "North Korea", "Cuba", "Syria"}
|
||||||
|
|
||||||
|
|
||||||
|
def _google_country(*args):
|
||||||
|
"""«Country version» at the bottom of policies.google.com/terms = the country Google assigns to the address."""
|
||||||
|
import re as _re
|
||||||
|
import subprocess as _sp
|
||||||
|
try:
|
||||||
|
html = _sp.run(["curl", *args, "-s", "-m", "15", "https://policies.google.com/terms?hl=en"],
|
||||||
|
capture_output=True, text=True, timeout=25).stdout
|
||||||
|
m = _re.search(r"Country version:\s*</a>\s*([A-Za-z][A-Za-z .'-]*?)\s*<", html)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def google_geo():
|
||||||
|
"""v4/v6 = default route (what clients exit with); ips = every public IPv4 on the server."""
|
||||||
|
import re as _re
|
||||||
|
import subprocess as _sp
|
||||||
|
import time as _t
|
||||||
|
out = {"v4": _google_country("-4"), "v6": _google_country("-6"), "ips": {}, "ts": int(_t.time())}
|
||||||
|
try:
|
||||||
|
for line in _sp.run(["ip", "-4", "-o", "addr", "show", "scope", "global"], capture_output=True,
|
||||||
|
text=True, timeout=10).stdout.splitlines():
|
||||||
|
ip = line.split()[3].split("/")[0]
|
||||||
|
if not _re.match(r"^(10|127|172\.(1[6-9]|2\d|3[01])|192\.168|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7]))\.", ip):
|
||||||
|
out["ips"][ip] = _google_country("-4", "--interface", ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/google-geo")
|
||||||
|
def api_google_geo():
|
||||||
|
if not check_api_key():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
return jsonify(google_geo())
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/limits", methods=["POST"])
|
@app.route("/api/limits", methods=["POST"])
|
||||||
def set_limits():
|
def set_limits():
|
||||||
"""Per-client speed limits pushed by the main panel ("<tunnel_ip> <mbit>" lines)."""
|
"""Per-client speed limits pushed by the main panel ("<tunnel_ip> <mbit>" lines)."""
|
||||||
|
|||||||
43
server/phobos-altip.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# PCA Phobos — второй/запасной IP сервера (09.33).
|
||||||
|
# wg-obfuscator и udptcp слушают 0.0.0.0, но отвечают с основного IP, а провайдер
|
||||||
|
# клиента может резать именно основной. DNAT «второй IP:порт → основной IP:порт»
|
||||||
|
# заставляет ответ уходить с того адреса, на который пришёл запрос.
|
||||||
|
# phobos-altip apply — правила для всех доп. IPv4 сервера (служба делает это при старте)
|
||||||
|
# phobos-altip status — что настроено
|
||||||
|
# phobos-altip enable — поставить службу phobos-altip
|
||||||
|
ENV=/opt/Phobos/server/server.env
|
||||||
|
[ -f "$ENV" ] || ENV=/opt/Phobos/server.env
|
||||||
|
get() { grep -E "^$1=" "$ENV" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"; }
|
||||||
|
MAIN=$(get SERVER_PUBLIC_IP_V4)
|
||||||
|
PORTS=$(get OBFUSCATOR_PORTS); [ -n "$PORTS" ] || PORTS=2083,5443,993
|
||||||
|
TCP=$(get TCP_PORT)
|
||||||
|
alts() { ip -4 -o addr show scope global | awk '{split($4,a,"/"); print a[1]}' | grep -vxF "$MAIN" | grep -v '^10\.\|^172\.\(1[6-9]\|2[0-9]\|3[01]\)\.\|^192\.168\.'; }
|
||||||
|
rule() { # $1 proto $2 alt $3 port
|
||||||
|
iptables -t nat -C PREROUTING -d "$2" -p "$1" --dport "$3" -j DNAT --to-destination "$MAIN:$3" 2>/dev/null \
|
||||||
|
|| iptables -t nat -I PREROUTING -d "$2" -p "$1" --dport "$3" -j DNAT --to-destination "$MAIN:$3"
|
||||||
|
}
|
||||||
|
case "$1" in
|
||||||
|
apply)
|
||||||
|
[ -n "$MAIN" ] || { echo "SERVER_PUBLIC_IP_V4 not set"; exit 1; }
|
||||||
|
for a in $(alts); do
|
||||||
|
for p in $(echo "$PORTS" | tr ',' ' '); do rule udp "$a" "$p"; done
|
||||||
|
[ -n "$TCP" ] && rule tcp "$a" "$TCP"
|
||||||
|
echo "alt $a -> $MAIN (udp $PORTS${TCP:+, tcp $TCP})"
|
||||||
|
done ;;
|
||||||
|
status) echo "main: $MAIN"; echo "alt: $(alts | tr '\n' ' ')"; iptables -t nat -S PREROUTING | grep -- "--to-destination $MAIN:" ;;
|
||||||
|
enable)
|
||||||
|
cat > /etc/systemd/system/phobos-altip.service <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=PCA Phobos: second IP answers from itself (DNAT)
|
||||||
|
After=network-online.target
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/bin/sh $(readlink -f "$0") apply
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl daemon-reload && systemctl enable --now phobos-altip ;;
|
||||||
|
*) echo "usage: $0 apply|status|enable"; exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -6,11 +6,19 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
PHOBOS_DIR="/opt/etc/Phobos"
|
PHOBOS_DIR="/opt/etc/Phobos"
|
||||||
CONF="$PHOBOS_DIR/failover.conf"
|
# 02.11.2026: несколько учёток на одном роутере. Файлы учётки (failover.conf, state,
|
||||||
STATE="$PHOBOS_DIR/state"
|
# client_id, panel_*) — в PHOBOS_ACC (доп. учётки: $PHOBOS_DIR/acc/<клиент>),
|
||||||
LOG="$PHOBOS_DIR/health.log"
|
# главная — прямо в $PHOBOS_DIR. Конфиги обфускаторов общие, свой ищем по *.link.
|
||||||
|
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
|
||||||
|
CONF="$ACC_DIR/failover.conf"
|
||||||
|
STATE="$ACC_DIR/state"
|
||||||
|
LOG="$ACC_DIR/health.log"
|
||||||
|
CID=$(cat "$ACC_DIR/client_id" 2>/dev/null)
|
||||||
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
||||||
LOCKFILE="/tmp/phobos-health.lock"
|
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
|
||||||
|
[ -n "$CID" ] && [ "$(cat "$l" 2>/dev/null)" = "$CID" ] && OBF_CONF="${l%.link}.conf" && break
|
||||||
|
done
|
||||||
|
LOCKFILE="/tmp/phobos-health${PHOBOS_ACC:+-$CID}.lock"
|
||||||
WG_IF="Wireguard3"
|
WG_IF="Wireguard3"
|
||||||
|
|
||||||
MAX_LOG_LINES=200
|
MAX_LOG_LINES=200
|
||||||
@@ -25,9 +33,9 @@ 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_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$PHOBOS_DIR/panel_scheme" 2>/dev/null)}"
|
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$ACC_DIR/panel_scheme" 2>/dev/null)}"
|
||||||
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
|
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:-$(cat "$ACC_DIR/panel_port" 2>/dev/null)}"
|
||||||
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
|
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)
|
||||||
@@ -55,6 +63,16 @@ mkdir -p "$STATE"
|
|||||||
|
|
||||||
# ── Detect WG interface ──
|
# ── Detect WG interface ──
|
||||||
detect_wg_interface() {
|
detect_wg_interface() {
|
||||||
|
# точное совпадение "Phobos-<клиент>"; доп. учётка не должна схватить чужой интерфейс
|
||||||
|
for i in 0 1 2 3 4 5 6 7 8 9; do
|
||||||
|
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
|
||||||
|
# HydraRoute Neo names the interface by the bare client id (09.33)
|
||||||
|
if [ -n "$CID" ] && echo "$desc" | grep -qE "(Phobos-|: )${CID}\$"; then
|
||||||
|
WG_IF="Wireguard${i}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -n "$PHOBOS_ACC" ] && return 1
|
||||||
for i in 0 1 2 3 4 5 6 7 8 9; do
|
for i in 0 1 2 3 4 5 6 7 8 9; do
|
||||||
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
|
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
|
||||||
if echo "$desc" | grep -qi phobos; then
|
if echo "$desc" | grep -qi phobos; then
|
||||||
@@ -65,6 +83,19 @@ detect_wg_interface() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Restart own obfuscator (init script whose ARGS name our config) ──
|
||||||
|
restart_obf() {
|
||||||
|
sc=$(grep -l "/$(basename "$OBF_CONF")\"\$" /opt/etc/init.d/S*obfuscator* 2>/dev/null | head -1)
|
||||||
|
[ -z "$sc" ] && [ -f /opt/etc/init.d/S49wg-obfuscator ] && sc=/opt/etc/init.d/S49wg-obfuscator
|
||||||
|
if [ -n "$sc" ]; then
|
||||||
|
"$sc" restart >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
killall wg-obfuscator 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ── Read failover config ──
|
# ── Read failover config ──
|
||||||
if [ ! -f "$CONF" ]; then
|
if [ ! -f "$CONF" ]; then
|
||||||
log "ERROR: no failover.conf"
|
log "ERROR: no failover.conf"
|
||||||
@@ -80,6 +111,7 @@ while true; do
|
|||||||
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
|
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
|
||||||
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||||
eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||||
|
eval "SERVER_${idx}_TCP=$(grep "^TCP_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||||
SERVER_COUNT=$idx
|
SERVER_COUNT=$idx
|
||||||
idx=$((idx + 1))
|
idx=$((idx + 1))
|
||||||
done
|
done
|
||||||
@@ -94,12 +126,26 @@ CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
|
|||||||
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
|
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
|
||||||
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
|
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
|
||||||
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
||||||
|
TCP_MODE=$(cat "$STATE/tcp_mode" 2>/dev/null || echo "0") # 0 = UDP, N = TCP via server N
|
||||||
|
CYCLES=$(cat "$STATE/udp_cycles" 2>/dev/null || echo "0") # full UDP rounds without success
|
||||||
|
UDPTCP_BIN=/opt/bin/udptcp
|
||||||
|
UDPTCP_LPORT=13256
|
||||||
|
|
||||||
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
# ── Detect Phobos WG interface ──
|
||||||
|
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим (так сторож
|
||||||
|
# удалённой доп. учётки вписал пир Phobos в посторонний туннель).
|
||||||
detect_wg_interface || {
|
detect_wg_interface || {
|
||||||
log "WARN: detect failed, using default $WG_IF"
|
log "ERROR: own WG interface not found, skip"
|
||||||
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Panel tunnel IP must go through our tunnel (HydraRoute-made interface has no
|
||||||
|
# such route → pull went to WAN and hung, 09.33) ──
|
||||||
|
PT_IP=$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)
|
||||||
|
if [ -n "$PT_IP" ] && ip route get "$PT_IP" 2>/dev/null | grep -q "dev ppp\|dev eth\|dev wan"; then
|
||||||
|
ndmc -c "ip route $PT_IP 255.255.255.255 $WG_IF auto" >/dev/null 2>&1 && log "ROUTE: $PT_IP via $WG_IF"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Get handshake age via ndmc ──
|
# ── Get handshake age via ndmc ──
|
||||||
# A peer switch can leave the removed peer's stale handshake listed in ndmc
|
# A peer switch can leave the removed peer's stale handshake listed in ndmc
|
||||||
# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum
|
# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum
|
||||||
@@ -149,9 +195,13 @@ list_iface_keys() {
|
|||||||
count_server_peers() {
|
count_server_peers() {
|
||||||
present=$(list_iface_keys)
|
present=$(list_iface_keys)
|
||||||
n=0
|
n=0
|
||||||
|
seen=" "
|
||||||
i=1
|
i=1
|
||||||
while [ "$i" -le "$SERVER_COUNT" ]; do
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
eval "wk=\$SERVER_${i}_WGKEY"
|
eval "wk=\$SERVER_${i}_WGKEY"
|
||||||
|
# one server may have several addresses = same key in several entries
|
||||||
|
case "$seen" in *" $wk "*) i=$((i + 1)); continue ;; esac
|
||||||
|
seen="$seen$wk "
|
||||||
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
|
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
|
||||||
n=$((n + 1))
|
n=$((n + 1))
|
||||||
fi
|
fi
|
||||||
@@ -189,9 +239,18 @@ switch_wg_peer() {
|
|||||||
# Add the target peer if it is not already present
|
# Add the target peer if it is not already present
|
||||||
if ! echo "$present" | grep -q "^${new_key}$"; then
|
if ! echo "$present" | grep -q "^${new_key}$"; then
|
||||||
log "WG PEER add: $new_key"
|
log "WG PEER add: $new_key"
|
||||||
curl -s -X POST "http://localhost:79/rci/" \
|
# 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
|
||||||
-H "Content-Type: application/json" \
|
# берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
|
||||||
-d "{\"interface\":{\"${WG_IF}\":{\"wireguard\":{\"peer\":{\"key\":\"${new_key}\",\"comment\":\"Phobos VPS Server\",\"endpoint\":{\"address\":\"127.0.0.1:13255\"},\"keepalive-interval\":{\"interval\":25},\"allow-ips\":[{\"address\":\"0.0.0.0\",\"mask\":\"0.0.0.0\"},{\"address\":\"::\",\"mask\":\"0\"}]}}}}}" >/dev/null 2>&1
|
# Через ndmc по шагам: JSON-RCI с allow-ips "::"/0 молча не ставил allow-ips.
|
||||||
|
lport=$(grep 'source-lport' "$OBF_CONF" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
|
||||||
|
lport=${lport:-13255}
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key endpoint 127.0.0.1:$lport" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
|
||||||
|
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
|
||||||
|
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
|
||||||
|
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Persist config
|
# Persist config
|
||||||
@@ -215,6 +274,7 @@ switch_endpoint() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "SWITCH → server $server_idx ($host:$port)"
|
log "SWITCH → server $server_idx ($host:$port)"
|
||||||
|
stop_tcp
|
||||||
|
|
||||||
# 1. Switch WG peer key if different server
|
# 1. Switch WG peer key if different server
|
||||||
switch_wg_peer "$wg_key"
|
switch_wg_peer "$wg_key"
|
||||||
@@ -228,13 +288,7 @@ switch_endpoint() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Restart obfuscator
|
# 3. Restart obfuscator
|
||||||
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
restart_obf
|
||||||
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
|
|
||||||
else
|
|
||||||
killall wg-obfuscator 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. Save state (fail_count is managed by the caller, NOT reset here —
|
# 4. Save state (fail_count is managed by the caller, NOT reset here —
|
||||||
# resetting on a port-hop would prevent escalation to server failover)
|
# resetting on a port-hop would prevent escalation to server failover)
|
||||||
@@ -242,6 +296,45 @@ switch_endpoint() {
|
|||||||
echo "$port_idx" > "$STATE/current_port_idx"
|
echo "$port_idx" > "$STATE/current_port_idx"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── TCP channel (09.33/1.5.0): obfuscated UDP inside TCP via local udptcp ──
|
||||||
|
stop_tcp() {
|
||||||
|
[ "$TCP_MODE" = "0" ] && ! pidof udptcp >/dev/null 2>&1 && return 0
|
||||||
|
killall udptcp 2>/dev/null
|
||||||
|
TCP_MODE=0
|
||||||
|
echo 0 > "$STATE/tcp_mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
# start_tcp N — tunnel through server N's TCP port; 1 = started
|
||||||
|
start_tcp() {
|
||||||
|
n=$1
|
||||||
|
eval "host=\$SERVER_${n}_HOST"
|
||||||
|
eval "tport=\$SERVER_${n}_TCP"
|
||||||
|
eval "obf_key=\$SERVER_${n}_KEY"
|
||||||
|
eval "wg_key=\$SERVER_${n}_WGKEY"
|
||||||
|
[ -n "$tport" ] && [ -x "$UDPTCP_BIN" ] || return 1
|
||||||
|
killall udptcp 2>/dev/null
|
||||||
|
"$UDPTCP_BIN" -c "127.0.0.1:${UDPTCP_LPORT}" "${host}:${tport}" >/dev/null 2>&1 &
|
||||||
|
switch_wg_peer "$wg_key"
|
||||||
|
sed -i "s|^target = .*|target = 127.0.0.1:${UDPTCP_LPORT}|" "$OBF_CONF"
|
||||||
|
[ -n "$obf_key" ] && sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
|
||||||
|
restart_obf
|
||||||
|
TCP_MODE=$n
|
||||||
|
echo "$n" > "$STATE/tcp_mode"
|
||||||
|
echo "$n" > "$STATE/current_server"
|
||||||
|
log "TCP MODE → server $n (${host}:${tport} over TCP)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# try_tcp_after N — first server after N (no wrap) that has a TCP port; 1 = none
|
||||||
|
try_tcp_after() {
|
||||||
|
i=$(( $1 + 1 ))
|
||||||
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
|
start_tcp "$i" && return 0
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# ── Try next port on current server ──
|
# ── Try next port on current server ──
|
||||||
try_next_port() {
|
try_next_port() {
|
||||||
port_count=$(get_port_count "$CURRENT_SERVER")
|
port_count=$(get_port_count "$CURRENT_SERVER")
|
||||||
@@ -256,7 +349,9 @@ try_next_port() {
|
|||||||
# ── Try next server ──
|
# ── Try next server ──
|
||||||
try_next_server() {
|
try_next_server() {
|
||||||
next=$((CURRENT_SERVER + 1))
|
next=$((CURRENT_SERVER + 1))
|
||||||
[ "$next" -gt "$SERVER_COUNT" ] && next=1
|
if [ "$next" -gt "$SERVER_COUNT" ]; then
|
||||||
|
next=1
|
||||||
|
fi
|
||||||
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
|
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
|
||||||
|
|
||||||
log "FAILOVER → server $next"
|
log "FAILOVER → server $next"
|
||||||
@@ -266,7 +361,7 @@ try_next_server() {
|
|||||||
|
|
||||||
# ── Check if primary is back ──
|
# ── Check if primary is back ──
|
||||||
check_primary() {
|
check_primary() {
|
||||||
[ "$CURRENT_SERVER" -eq 1 ] && return
|
[ "$CURRENT_SERVER" -eq 1 ] && [ "$TCP_MODE" = "0" ] && return
|
||||||
|
|
||||||
now=$(date +%s)
|
now=$(date +%s)
|
||||||
elapsed=$((now - PRIMARY_CHECK_TS))
|
elapsed=$((now - PRIMARY_CHECK_TS))
|
||||||
@@ -288,7 +383,10 @@ check_primary() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -n "$ok_url" ]; then
|
if [ -n "$ok_url" ]; then
|
||||||
log "PRIMARY ($ok_url http=200) alive, switching back"
|
# HTTP alive does not mean UDP passes (провайдер режет UDP к IP, 09.33):
|
||||||
|
# пробуем, а если рукопожатия не будет — trial_check вернёт как было.
|
||||||
|
log "PRIMARY ($ok_url http=200) alive, TRIAL switch back"
|
||||||
|
echo "$CURRENT_SERVER $CURRENT_PORT_IDX $TCP_MODE $(date +%s)" > "$STATE/trial"
|
||||||
switch_endpoint 1 0
|
switch_endpoint 1 0
|
||||||
echo "0" > "$STATE/fail_count"
|
echo "0" > "$STATE/fail_count"
|
||||||
else
|
else
|
||||||
@@ -330,6 +428,8 @@ heal_lan_routing() {
|
|||||||
while read -r mac pol; do
|
while read -r mac pol; do
|
||||||
[ -z "$mac" ] && continue
|
[ -z "$mac" ] && continue
|
||||||
case "$mac" in \#*) continue;; esac
|
case "$mac" in \#*) continue;; esac
|
||||||
|
# политику удалили — не воскрешаем привязку к ней
|
||||||
|
echo "$rc" | grep -qx "ip policy $pol" || continue
|
||||||
if ! echo "$rc" | grep -q "host $mac policy $pol"; then
|
if ! echo "$rc" | grep -q "host $mac policy $pol"; then
|
||||||
ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1
|
ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1
|
||||||
log "HEAL: re-bound host $mac -> $pol"
|
log "HEAL: re-bound host $mac -> $pol"
|
||||||
@@ -406,12 +506,31 @@ fi
|
|||||||
AGE=$(get_handshake_age)
|
AGE=$(get_handshake_age)
|
||||||
WAN=$(check_connectivity && echo "yes" || echo "no")
|
WAN=$(check_connectivity && echo "yes" || echo "no")
|
||||||
|
|
||||||
|
# ── Trial of the primary: fresh handshake within ~2 min or go back ──
|
||||||
|
if [ -f "$STATE/trial" ]; then
|
||||||
|
read t_srv t_port t_tcp t_ts < "$STATE/trial"
|
||||||
|
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
||||||
|
log "TRIAL ok: primary works"
|
||||||
|
rm -f "$STATE/trial"; echo 0 > "$STATE/udp_cycles"
|
||||||
|
elif [ $(( $(date +%s) - ${t_ts:-0} )) -ge 110 ]; then
|
||||||
|
rm -f "$STATE/trial"
|
||||||
|
log "TRIAL failed: back to server $t_srv"
|
||||||
|
if [ "${t_tcp:-0}" != "0" ]; then start_tcp "$t_tcp" || switch_endpoint "$t_srv" "$t_port"
|
||||||
|
else switch_endpoint "$t_srv" "$t_port"; fi
|
||||||
|
echo 0 > "$STATE/fail_count"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
exit 0 # give the trial time
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Tunnel healthy = fresh handshake
|
# Tunnel healthy = fresh handshake
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
||||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||||
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
|
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
|
||||||
echo "0" > "$STATE/fail_count"
|
echo "0" > "$STATE/fail_count"
|
||||||
fi
|
fi
|
||||||
|
echo 0 > "$STATE/udp_cycles"
|
||||||
check_primary
|
check_primary
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -427,23 +546,30 @@ log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PO
|
|||||||
# Stage 1 (fail 1): Restart obfuscator
|
# Stage 1 (fail 1): Restart obfuscator
|
||||||
if [ "$FAIL_COUNT" -eq 1 ]; then
|
if [ "$FAIL_COUNT" -eq 1 ]; then
|
||||||
log "ACTION: restart obfuscator"
|
log "ACTION: restart obfuscator"
|
||||||
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
restart_obf
|
||||||
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
|
|
||||||
else
|
|
||||||
killall wg-obfuscator 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
|
||||||
fi
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server
|
# TCP mode went stale → back to the UDP round (next server)
|
||||||
if [ "$FAIL_COUNT" -eq 2 ]; then
|
if [ "$TCP_MODE" != "0" ] && [ "$FAIL_COUNT" -ge 2 ]; then
|
||||||
|
log "TCP stale on server $TCP_MODE"
|
||||||
|
if try_tcp_after "$TCP_MODE"; then echo 0 > "$STATE/fail_count"; exit 0; fi
|
||||||
|
stop_tcp
|
||||||
|
switch_endpoint 1 0; echo 0 > "$STATE/fail_count"; exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stage 2 (fail 2+): hop through ALL ports of the SAME server (was: only one)
|
||||||
|
if [ "$FAIL_COUNT" -ge 2 ]; then
|
||||||
if try_next_port; then
|
if try_next_port; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# only one port → fall through to server failover
|
log "ports of server $CURRENT_SERVER exhausted, escalate to failover"
|
||||||
log "single port, escalate to failover"
|
fi
|
||||||
|
|
||||||
|
# Whole UDP round failed (all servers × ports) → TCP, if any server offers it
|
||||||
|
if [ "$CURRENT_SERVER" -eq "$SERVER_COUNT" ]; then
|
||||||
|
CYCLES=$((CYCLES + 1)); echo "$CYCLES" > "$STATE/udp_cycles"
|
||||||
|
if try_tcp_after 0; then echo 0 > "$STATE/fail_count"; exit 0; fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server
|
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
export PATH=/opt/bin:/opt/sbin:/opt/usr/bin:$PATH # cron без /opt/bin -> busybox wget висел вечно (09.29)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Phobos Config Pull — NAT-friendly management channel.
|
# Phobos Config Pull — NAT-friendly management channel.
|
||||||
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
|
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
|
||||||
@@ -21,9 +22,11 @@
|
|||||||
# PANEL env or default below -> panel base URL
|
# PANEL env or default below -> panel base URL
|
||||||
# ============================================================
|
# ============================================================
|
||||||
PHOBOS_DIR="/opt/etc/Phobos"
|
PHOBOS_DIR="/opt/etc/Phobos"
|
||||||
CONF="$PHOBOS_DIR/failover.conf"
|
# 02.11.2026: доп. учётки — PHOBOS_ACC=$PHOBOS_DIR/acc/<клиент> (см. phobos-health.sh)
|
||||||
|
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
|
||||||
|
CONF="$ACC_DIR/failover.conf"
|
||||||
HEALTH="$PHOBOS_DIR/phobos-health.sh"
|
HEALTH="$PHOBOS_DIR/phobos-health.sh"
|
||||||
LOG="$PHOBOS_DIR/health.log"
|
LOG="$ACC_DIR/health.log"
|
||||||
# Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
|
# Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
|
||||||
# whatever server the tunnel currently terminates on, so management rides the
|
# whatever server the tunnel currently terminates on, so management rides the
|
||||||
# same obfuscated channel as data — survives a public-IP ban and follows
|
# same obfuscated channel as data — survives a public-IP ban and follows
|
||||||
@@ -31,15 +34,18 @@ LOG="$PHOBOS_DIR/health.log"
|
|||||||
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
|
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
|
||||||
# - panel_tunnel_ip:<panel_port> -> primary server panel
|
# - 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_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$PHOBOS_DIR/panel_tunnel_ip" 2>/dev/null)}"
|
PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)}"
|
||||||
[ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
|
[ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
|
||||||
PANEL="${PANEL:-$(cat "$PHOBOS_DIR/panel_url" 2>/dev/null)}"
|
PANEL="${PANEL:-$(cat "$ACC_DIR/panel_url" 2>/dev/null)}"
|
||||||
PANEL_URLS="${PANEL_URLS:-$(cat "$PHOBOS_DIR/panel_urls" 2>/dev/null)}"
|
PANEL_URLS="${PANEL_URLS:-$(cat "$ACC_DIR/panel_urls" 2>/dev/null)}"
|
||||||
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
|
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
|
||||||
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
|
# + public URLs the panel announced in failover.conf (second IP etc., 09.33)
|
||||||
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
|
_extra=$(grep "^PANEL_URLS_EXTRA=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
TMP="/tmp/failover.conf.pull"
|
for _u in $_extra; do case " $PANEL_URLS " in *" $_u "*) ;; *) PANEL_URLS="$PANEL_URLS $_u" ;; esac; done
|
||||||
LOCK="/tmp/phobos-pull.lock"
|
CLIENT_ID=$(cat "$ACC_DIR/client_id" 2>/dev/null || echo "home")
|
||||||
|
TOKEN=$(cat "$ACC_DIR/pull_token" 2>/dev/null)
|
||||||
|
TMP="/tmp/failover.conf.pull${PHOBOS_ACC:+-$CLIENT_ID}"
|
||||||
|
LOCK="/tmp/phobos-pull${PHOBOS_ACC:+-$CLIENT_ID}.lock"
|
||||||
|
|
||||||
log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; }
|
log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; }
|
||||||
|
|
||||||
@@ -71,11 +77,24 @@ fetch_config() {
|
|||||||
if command -v curl >/dev/null; then
|
if command -v curl >/dev/null; then
|
||||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
fi
|
fi
|
||||||
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# TCP channel for routers (1.5.0): the guard needs /opt/bin/udptcp when failover.conf has TCP_N.
|
||||||
|
ensure_udptcp() {
|
||||||
|
[ -x /opt/bin/udptcp ] && return 0
|
||||||
|
grep -q "^TCP_[0-9]*=" "$CONF" 2>/dev/null || return 0
|
||||||
|
case "$(uname -m)" in aarch64) a=aarch64 ;; mips*) a=mipsel ;; *) return 0 ;; esac
|
||||||
|
command -v curl >/dev/null || return 0
|
||||||
|
{ curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/udptcp/bin/udptcp-$a" 2>/dev/null \
|
||||||
|
|| curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/udptcp/bin/udptcp-$a" 2>/dev/null; } \
|
||||||
|
&& [ -s /opt/bin/udptcp.tmp ] && chmod 755 /opt/bin/udptcp.tmp && mv /opt/bin/udptcp.tmp /opt/bin/udptcp \
|
||||||
|
&& log "PULL: udptcp installed ($a)" || rm -f /opt/bin/udptcp.tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
||||||
do_pull() {
|
do_pull() {
|
||||||
|
ensure_udptcp
|
||||||
# Try each source (tunnel first); accept first that returns a valid conf.
|
# Try each source (tunnel first); accept first that returns a valid conf.
|
||||||
got=0
|
got=0
|
||||||
for base in $PANEL_URLS; do
|
for base in $PANEL_URLS; do
|
||||||
@@ -102,6 +121,48 @@ 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
|
||||||
|
# только свой обфускатор: у других учёток свои failover.conf
|
||||||
|
own=""
|
||||||
|
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
|
||||||
|
[ "$(cat "$l" 2>/dev/null)" = "$CLIENT_ID" ] && own="${l%.link}.conf"
|
||||||
|
done
|
||||||
|
[ -z "$own" ] && [ -z "$PHOBOS_ACC" ] && own="$PHOBOS_DIR/wg-obfuscator*.conf"
|
||||||
|
for OBF in $own; 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
|
||||||
|
for OBF in $own; do grep -q "/$(basename "$OBF")\"\$" "$sc" && [ -x "$sc" ] && "$sc" restart >/dev/null 2>&1; done
|
||||||
|
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
|
||||||
|
|||||||
54
server/phobos-v6relay.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""IPv6 -> local IPv4 UDP relay for the obfuscator (it speaks IPv4 only).
|
||||||
|
|
||||||
|
Usage: phobos-v6relay.py LISTEN_PORT [TARGET_PORT] [TARGET_HOST]
|
||||||
|
One upstream socket per IPv6 client, so the obfuscator sees a stable
|
||||||
|
client address for the whole session. Idle clients are dropped after 300 s.
|
||||||
|
"""
|
||||||
|
import selectors
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
IDLE = 300
|
||||||
|
|
||||||
|
listen_port = int(sys.argv[1])
|
||||||
|
target = (sys.argv[3] if len(sys.argv) > 3 else "127.0.0.1",
|
||||||
|
int(sys.argv[2]) if len(sys.argv) > 2 else listen_port)
|
||||||
|
|
||||||
|
down = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
|
||||||
|
down.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||||
|
down.bind(("::", listen_port))
|
||||||
|
|
||||||
|
sel = selectors.DefaultSelector()
|
||||||
|
sel.register(down, selectors.EVENT_READ, None)
|
||||||
|
clients = {} # v6 addr -> [upstream socket, last seen]
|
||||||
|
|
||||||
|
while True:
|
||||||
|
for key, _ in sel.select(timeout=30):
|
||||||
|
if key.data is None:
|
||||||
|
data, addr = down.recvfrom(65535)
|
||||||
|
c = clients.get(addr)
|
||||||
|
if c is None:
|
||||||
|
up = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
up.connect(target)
|
||||||
|
sel.register(up, selectors.EVENT_READ, addr)
|
||||||
|
c = clients[addr] = [up, 0]
|
||||||
|
c[1] = time.monotonic()
|
||||||
|
try:
|
||||||
|
c[0].send(data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
addr = key.data
|
||||||
|
try:
|
||||||
|
down.sendto(key.fileobj.recv(65535), addr)
|
||||||
|
clients[addr][1] = time.monotonic()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
now = time.monotonic()
|
||||||
|
for addr, (up, seen) in list(clients.items()):
|
||||||
|
if now - seen > IDLE:
|
||||||
|
sel.unregister(up)
|
||||||
|
up.close()
|
||||||
|
del clients[addr]
|
||||||
@@ -194,6 +194,8 @@ post_up = (
|
|||||||
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
|
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
|
||||||
f"ip route replace {subnet} dev wg0 table {table}; "
|
f"ip route replace {subnet} dev wg0 table {table}; "
|
||||||
f"ip route replace default dev %i table {table}; "
|
f"ip route replace default dev %i table {table}; "
|
||||||
|
"iptables -t mangle -C FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || "
|
||||||
|
"iptables -t mangle -A FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu; "
|
||||||
"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 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 -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; "
|
"iptables -I FORWARD 1 -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; "
|
||||||
@@ -203,6 +205,7 @@ post_up = (
|
|||||||
post_down = (
|
post_down = (
|
||||||
f"ip route del {subnet} dev wg0 table {table} 2>/dev/null || true; "
|
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; "
|
f"ip route del default dev %i table {table} 2>/dev/null || true; "
|
||||||
|
"iptables -t mangle -D FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true; "
|
||||||
"iptables -D FORWARD -i wg0 -o %i -j ACCEPT 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; "
|
"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"
|
f"iptables -t nat -D POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || true"
|
||||||
@@ -234,6 +237,7 @@ clear_egress() {
|
|||||||
systemctl disable --now "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
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 "$(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
|
ip route del default dev "$WARP_IF" table "$WARP_TABLE" 2>/dev/null || true
|
||||||
|
iptables -t mangle -D FORWARD -o "$WARP_IF" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||||
iptables -D FORWARD -i wg0 -o "$WARP_IF" -j ACCEPT 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 -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
|
iptables -t nat -D POSTROUTING -s "$(wg_subnet)" -o "$WARP_IF" -j MASQUERADE 2>/dev/null || true
|
||||||
@@ -356,6 +360,35 @@ relay_enabled() {
|
|||||||
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
|
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warp_handshake_age() {
|
||||||
|
local ts
|
||||||
|
ts="$(wg show "$WARP_IF" latest-handshakes 2>/dev/null | awk 'NR==1{print $2}')"
|
||||||
|
[ -n "$ts" ] && [ "$ts" != "0" ] && echo $(( $(date +%s) - ts )) || echo -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cloudflare UDP to engage.cloudflareclient.com:2408 is often cut on the way:
|
||||||
|
# try the profile endpoint first, then known WARP IP:port pairs, keep the first with a handshake.
|
||||||
|
pick_warp_endpoint() {
|
||||||
|
local pub ep cur i age
|
||||||
|
pub="$(wg show "$WARP_IF" peers 2>/dev/null | head -1)"
|
||||||
|
[ -n "$pub" ] || return 0
|
||||||
|
cur="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" | tr -d ' ')"
|
||||||
|
for ep in "$cur" 162.159.192.1:2408 162.159.193.1:2408 162.159.192.1:500 162.159.192.1:1701 162.159.192.1:4500 162.159.195.1:2408 188.114.97.1:2408; do
|
||||||
|
[ -n "$ep" ] || continue
|
||||||
|
wg set "$WARP_IF" peer "$pub" endpoint "$ep" 2>/dev/null || continue
|
||||||
|
for i in 1 2 3 4 5 6; do
|
||||||
|
ping -c1 -W1 -I "$WARP_IF" 1.1.1.1 >/dev/null 2>&1 || true
|
||||||
|
age="$(warp_handshake_age)"
|
||||||
|
if [ "$age" -ge 0 ] && [ "$age" -lt 30 ]; then
|
||||||
|
[ "$ep" = "$cur" ] || sed -i "s|^Endpoint *=.*|Endpoint = ${ep}|" "$WARP_CONF"
|
||||||
|
echo "WARP endpoint: $ep" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo "WARP: no handshake on any endpoint (UDP to Cloudflare blocked?)" >&2
|
||||||
|
}
|
||||||
|
|
||||||
enable_egress() {
|
enable_egress() {
|
||||||
local profile_source profile_path existing_clients
|
local profile_source profile_path existing_clients
|
||||||
load_env
|
load_env
|
||||||
@@ -368,6 +401,7 @@ enable_egress() {
|
|||||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||||
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
||||||
systemctl restart "wg-quick@${WARP_IF}"
|
systemctl restart "wg-quick@${WARP_IF}"
|
||||||
|
pick_warp_endpoint
|
||||||
clear_legacy_subnet_rule
|
clear_legacy_subnet_rule
|
||||||
reapply_client_rules "$existing_clients"
|
reapply_client_rules "$existing_clients"
|
||||||
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
|
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
|
||||||
@@ -436,7 +470,7 @@ client_disable() {
|
|||||||
status_json() {
|
status_json() {
|
||||||
load_env
|
load_env
|
||||||
load_state
|
load_state
|
||||||
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on
|
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on handshake=-1
|
||||||
subnet="$(wg_subnet)"
|
subnet="$(wg_subnet)"
|
||||||
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
|
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
|
||||||
mode="${STATE_MODE:-}"
|
mode="${STATE_MODE:-}"
|
||||||
@@ -453,6 +487,7 @@ status_json() {
|
|||||||
endpoint="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" 2>/dev/null || 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)"
|
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)"
|
egress="$(curl -4 --interface "${WARP_IF}" -fsS --max-time 6 https://api.ipify.org 2>/dev/null || true)"
|
||||||
|
handshake="$(warp_handshake_age)"
|
||||||
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -469,9 +504,9 @@ status_json() {
|
|||||||
enabled=true
|
enabled=true
|
||||||
fi
|
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'
|
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:-}" "$handshake" <<'PY'
|
||||||
import json, sys
|
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:]
|
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, handshake = sys.argv[1:]
|
||||||
if relay_on == "true" and service != "active":
|
if relay_on == "true" and service != "active":
|
||||||
service = "relay"
|
service = "relay"
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
@@ -484,6 +519,7 @@ print(json.dumps({
|
|||||||
"endpoint": endpoint,
|
"endpoint": endpoint,
|
||||||
"public_key": pubkey,
|
"public_key": pubkey,
|
||||||
"egress_ipv4": egress,
|
"egress_ipv4": egress,
|
||||||
|
"handshake_age": int(handshake),
|
||||||
"relay_enabled": relay_on == "true",
|
"relay_enabled": relay_on == "true",
|
||||||
"relay_ports": relay_ports,
|
"relay_ports": relay_ports,
|
||||||
"relay_target": relay_target,
|
"relay_target": relay_target,
|
||||||
|
|||||||
@@ -18,7 +18,30 @@ MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
|
|||||||
MAIN_PROTO="${MAIN_PROTO:-http}"
|
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}"
|
||||||
|
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
||||||
|
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
|
||||||
|
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
|
||||||
|
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
||||||
|
fi
|
||||||
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||||
|
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
|
||||||
|
except ValueError:
|
||||||
|
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
|
||||||
|
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
|
||||||
|
# Считаем, что нужна подсеть этого адреса, и берём /24.
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
|
||||||
|
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
|
||||||
|
except ValueError:
|
||||||
|
sys.exit("ERROR: bad subnet " + sys.argv[1])
|
||||||
|
if net.version != 4 or not net.is_private or net.prefixlen > 29:
|
||||||
|
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
|
||||||
|
print(net)
|
||||||
|
PY
|
||||||
|
)" || exit 1
|
||||||
PHOBOS_DIR="/opt/Phobos"
|
PHOBOS_DIR="/opt/Phobos"
|
||||||
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
import ipaddress, sys
|
import ipaddress, sys
|
||||||
@@ -293,7 +316,7 @@ RAW="${PCA_RAW_BASE:-https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/${PCA_BRA
|
|||||||
PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/server/api.py"
|
PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/server/api.py"
|
||||||
curl -kfsSL "$PANEL_BOOTSTRAP" -o "$PHOBOS_DIR/server/api.py" \
|
curl -kfsSL "$PANEL_BOOTSTRAP" -o "$PHOBOS_DIR/server/api.py" \
|
||||||
|| curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" \
|
|| curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" \
|
||||||
|| curl -fsSL "https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
|| curl -fsSL "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/${PCA_BRANCH}/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
||||||
[ -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; }
|
||||||
|
|
||||||
cat > /etc/systemd/system/phobos-api.service << EOF
|
cat > /etc/systemd/system/phobos-api.service << EOF
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# PCA Phobos — полное удаление сервера (primary / panel node)
|
# PCA Phobos — полное удаление сервера (primary / panel node)
|
||||||
#
|
#
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/uninstall.sh)
|
# bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/uninstall.sh)
|
||||||
#
|
#
|
||||||
# Останавливает и удаляет: панель, обфускатор-сервисы, WireGuard wg0,
|
# Останавливает и удаляет: панель, обфускатор-сервисы, WireGuard wg0,
|
||||||
# nginx-сайт, watchdog-cron, бинарники, iptables-правила, каталоги Phobos.
|
# nginx-сайт, watchdog-cron, бинарники, iptables-правила, каталоги Phobos.
|
||||||
@@ -21,8 +21,11 @@ echo " watchdog, бинарники, $PHOBOS_DIR, $PANEL_DIR"
|
|||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
if [ "${PURGE:-0}" != "1" ]; then
|
if [ "${PURGE:-0}" != "1" ]; then
|
||||||
printf "Точно удалить ВСЁ? [yes/NO]: "
|
printf "Точно удалить ВСЁ? [yes/NO]: "
|
||||||
read ans
|
read -r ans </dev/tty 2>/dev/null || read -r ans
|
||||||
[ "$ans" = "yes" ] || { echo "Отменено."; exit 0; }
|
# tolerate CR from Windows/phone terminals, spaces, case, "да"
|
||||||
|
# no tr: in C locale it mangles Cyrillic bytes
|
||||||
|
ans=${ans//$'\r'/}; ans=${ans//[[:space:]]/}
|
||||||
|
case "$ans" in yes|Yes|YES|y|Y|да|Да|ДА|д|Д) ;; *) echo "Отменено (нужно ввести yes)."; exit 0 ;; esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
28
update.sh
@@ -19,7 +19,7 @@ set -e
|
|||||||
PHOBOS_DIR=/opt/Phobos
|
PHOBOS_DIR=/opt/Phobos
|
||||||
PANEL_DIR=/opt/phobos-panel
|
PANEL_DIR=/opt/phobos-panel
|
||||||
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
||||||
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
|
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
|
||||||
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")
|
||||||
@@ -42,11 +42,20 @@ restart_panel_service() {
|
|||||||
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
|
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
|
||||||
is_private() { return 1; }
|
is_private() { return 1; }
|
||||||
|
|
||||||
|
PCA_GIT_DEV="${PCA_GIT_DEV:-https://git.bobyrev.com/PCA/PCA_Phobos-dev}"
|
||||||
|
|
||||||
fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallback
|
fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallback
|
||||||
local ref="$1" rp="$2" out="$3" kind=branch
|
local ref="$1" rp="$2" out="$3" kind=branch
|
||||||
case "$ref" in v[0-9]*) kind=tag;; esac
|
case "$ref" in v[0-9]*) kind=tag;; esac
|
||||||
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
# beta/dev live in the private Gitea repo PCA_Phobos-dev: read-only Gitea token in $PANEL_DIR/.gh_token or GH_TOKEN
|
||||||
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out"
|
case "$ref" in beta|dev)
|
||||||
|
if [ -n "$GH_TOKEN" ]; then
|
||||||
|
curl -fsSL -m25 -H "Authorization: token $GH_TOKEN" "$PCA_GIT_DEV/raw/branch/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
||||||
|
fi;;
|
||||||
|
esac
|
||||||
|
# GitHub first (Gitea sits on one home IP and is cut for some providers, 09.33), Gitea fallback
|
||||||
|
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
||||||
|
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out"
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch_repo_text() { # $1=ref $2=repo_path
|
fetch_repo_text() { # $1=ref $2=repo_path
|
||||||
@@ -61,6 +70,7 @@ fetch_repo_text() { # $1=ref $2=repo_path
|
|||||||
managed_files() {
|
managed_files() {
|
||||||
cat <<LIST
|
cat <<LIST
|
||||||
$PANEL_DIR/app.py|app.py
|
$PANEL_DIR/app.py|app.py
|
||||||
|
$PANEL_DIR/APPS|APPS
|
||||||
$PHOBOS_DIR/repo/server/scripts/lib-core.sh|overlay/lib-core.sh
|
$PHOBOS_DIR/repo/server/scripts/lib-core.sh|overlay/lib-core.sh
|
||||||
$PHOBOS_DIR/repo/server/scripts/phobos-client.sh|overlay/phobos-client.sh
|
$PHOBOS_DIR/repo/server/scripts/phobos-client.sh|overlay/phobos-client.sh
|
||||||
$PHOBOS_DIR/repo/client/templates/install-router.sh.template|overlay/install-router.sh.template
|
$PHOBOS_DIR/repo/client/templates/install-router.sh.template|overlay/install-router.sh.template
|
||||||
@@ -87,6 +97,7 @@ $PHOBOS_DIR/server/phobos-domain.sh|server/phobos-domain.sh
|
|||||||
$PHOBOS_DIR/server/phobos-limit.sh|server/phobos-limit.sh
|
$PHOBOS_DIR/server/phobos-limit.sh|server/phobos-limit.sh
|
||||||
$PHOBOS_DIR/server/phobos-speedtest.sh|server/phobos-speedtest.sh
|
$PHOBOS_DIR/server/phobos-speedtest.sh|server/phobos-speedtest.sh
|
||||||
$PHOBOS_DIR/server/phobos-tcp.sh|server/phobos-tcp.sh
|
$PHOBOS_DIR/server/phobos-tcp.sh|server/phobos-tcp.sh
|
||||||
|
$PHOBOS_DIR/server/phobos-altip.sh|server/phobos-altip.sh
|
||||||
$PHOBOS_DIR/server/update.sh|update.sh
|
$PHOBOS_DIR/server/update.sh|update.sh
|
||||||
LIST
|
LIST
|
||||||
}
|
}
|
||||||
@@ -103,6 +114,8 @@ ensure_tcp_fallback() { # $1=ref ; primary only (secondaries have no panel)
|
|||||||
[ -x "$PHOBOS_DIR/server/phobos-tcp.sh" ] && ln -sf "$PHOBOS_DIR/server/phobos-tcp.sh" /usr/local/bin/phobos-tcp
|
[ -x "$PHOBOS_DIR/server/phobos-tcp.sh" ] && ln -sf "$PHOBOS_DIR/server/phobos-tcp.sh" /usr/local/bin/phobos-tcp
|
||||||
[ -x "$PHOBOS_DIR/bin/udptcp" ] && "$PHOBOS_DIR/server/phobos-tcp.sh" enable >/dev/null 2>&1 \
|
[ -x "$PHOBOS_DIR/bin/udptcp" ] && "$PHOBOS_DIR/server/phobos-tcp.sh" enable >/dev/null 2>&1 \
|
||||||
&& echo "TCP fallback: port $(grep '^TCP_PORT=' "$PHOBOS_DIR/server/server.env" | cut -d= -f2)" || true
|
&& echo "TCP fallback: port $(grep '^TCP_PORT=' "$PHOBOS_DIR/server/server.env" | cut -d= -f2)" || true
|
||||||
|
# second IP answers from itself (09.33) — no-op on single-IP servers
|
||||||
|
[ -f "$PHOBOS_DIR/server/phobos-altip.sh" ] && sh "$PHOBOS_DIR/server/phobos-altip.sh" enable >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
run_self_check() {
|
run_self_check() {
|
||||||
@@ -241,6 +254,15 @@ case "${1:-}" in
|
|||||||
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
|
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
case "$ref" in beta|dev)
|
||||||
|
if [ -z "$GH_TOKEN" ]; then
|
||||||
|
echo "Каналы beta и dev — по токену подписчика (read-only токен Gitea)."
|
||||||
|
echo "Вставь токен в панели: Настройки → Версия и обновления → «токен подписчика», или:"
|
||||||
|
echo " GH_TOKEN=ваш_токен phobos-update $ref"
|
||||||
|
echo "Стабильная (без токена): phobos-update stable"
|
||||||
|
exit 1
|
||||||
|
fi;;
|
||||||
|
esac
|
||||||
if ! ref_exists "$ref"; then
|
if ! ref_exists "$ref"; then
|
||||||
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
|
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
|
||||||
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
|
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
|
||||||
|
|||||||
21
wsgw/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# wsgw — шлюз WebSocket → UDP (транспорт «Облако» / Keenetic-облако)
|
||||||
|
|
||||||
|
Телефон без доступа к серверу → облако KeenDNS (HTTPS по SNI) → свой Keenetic → `wsgw` → UDP обфускатора сервера.
|
||||||
|
Одно бинарное WS-сообщение = один обфусцированный пакет; на каждое WS-соединение — свой UDP-сокет к `-t`.
|
||||||
|
|
||||||
|
Роутер (Entware):
|
||||||
|
```sh
|
||||||
|
cp bin/wsgw-aarch64 /opt/sbin/wsgw # mipsel/mips — по uname -m
|
||||||
|
cp router/S80wsgw /opt/etc/init.d/ && chmod +x /opt/sbin/wsgw /opt/etc/init.d/S80wsgw
|
||||||
|
printf 'WSGW_LISTEN=192.168.1.1:18090\nWSGW_TARGET=<сервер>:<порт обфускатора>\n' > /opt/etc/wsgw.conf
|
||||||
|
/opt/etc/init.d/S80wsgw start
|
||||||
|
ndmc -c 'ip http proxy phobos'; ndmc -c 'ip http proxy phobos upstream http 192.168.1.1 18090'
|
||||||
|
ndmc -c 'ip http proxy phobos domain ndns'; ndmc -c 'ip http proxy phobos security-level public'
|
||||||
|
ndmc -c 'ndns book-name <имя> <домен> cloud'; ndmc -c 'ip http ssl acme get phobos.<имя>.<домен>'
|
||||||
|
ndmc -c 'system configuration save'
|
||||||
|
```
|
||||||
|
Требования: `ip http port 80` (иначе ACME не выпустит сертификат), WAN:443 не проброшен на другой хост
|
||||||
|
(облако шлёт TLS поддоменов на WAN:443). Если `acme get` висит в `get-pending` — перезагрузка роутера и команда ещё раз.
|
||||||
|
|
||||||
|
Клиент Android 1.4.4: канал «Облако», адрес `wss://phobos.<имя>.<домен>/` (или `# pca-ws = wss://…` в ссылке).
|
||||||
|
Сборка: `./build.sh` (Go, статические linux arm64/mipsle/mips/amd64).
|
||||||
BIN
wsgw/bin/wsgw-aarch64
Executable file
BIN
wsgw/bin/wsgw-mips
Executable file
BIN
wsgw/bin/wsgw-mipsel
Executable file
BIN
wsgw/bin/wsgw-x86_64
Executable file
7
wsgw/build.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Static builds for Keenetic (Entware) and Linux servers.
|
||||||
|
cd "$(dirname "$0")"; mkdir -p bin
|
||||||
|
for a in arm64:aarch64 mipsle:mipsel mips:mips amd64:x86_64; do
|
||||||
|
GOOS=linux GOARCH=${a%%:*} GOMIPS=softfloat CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/wsgw-${a##*:} . || exit 1
|
||||||
|
done
|
||||||
|
ls -l bin
|
||||||
3
wsgw/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module wsgw
|
||||||
|
|
||||||
|
go 1.22
|
||||||
147
wsgw/main.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// wsgw: WebSocket -> UDP gateway for the PCA Phobos "Keenetic cloud" transport.
|
||||||
|
// Keenetic `ip http proxy` (KeenDNS cloud, TLS on the router) forwards wss://<sub>/ here as plain ws.
|
||||||
|
// One binary WS message = one obfuscated UDP packet; each WS connection gets its own UDP socket to -t.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"flag"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listen := flag.String("l", "0.0.0.0:18090", "listen addr (http)")
|
||||||
|
target := flag.String("t", "", "UDP target host:port (server obfuscator)")
|
||||||
|
flag.Parse()
|
||||||
|
if *target == "" {
|
||||||
|
log.Fatal("-t host:port required")
|
||||||
|
}
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
||||||
|
http.Error(w, "ok", http.StatusUpgradeRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, rw, err := h.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := sha1.Sum([]byte(r.Header.Get("Sec-WebSocket-Key") + wsGUID))
|
||||||
|
rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " +
|
||||||
|
base64.StdEncoding.EncodeToString(sum[:]) + "\r\n\r\n")
|
||||||
|
rw.Flush()
|
||||||
|
go serve(conn, rw.Reader, *target)
|
||||||
|
})
|
||||||
|
log.Printf("wsgw %s -> udp %s", *listen, *target)
|
||||||
|
log.Fatal(http.ListenAndServe(*listen, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(c net.Conn, br *bufio.Reader, target string) {
|
||||||
|
defer c.Close()
|
||||||
|
u, err := net.Dial("udp", target)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("udp %s: %v", target, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer u.Close()
|
||||||
|
go func() { // udp -> ws
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
u.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
||||||
|
n, err := u.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
c.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if writeFrame(c, 2, buf[:n]) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for { // ws -> udp
|
||||||
|
op, p, err := readFrame(br)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch op {
|
||||||
|
case 2, 0:
|
||||||
|
u.Write(p)
|
||||||
|
case 8:
|
||||||
|
return
|
||||||
|
case 9:
|
||||||
|
writeFrame(c, 10, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrame reads one client frame (masked). Fragmented messages are not used by our clients.
|
||||||
|
func readFrame(r *bufio.Reader) (byte, []byte, error) {
|
||||||
|
var h [2]byte
|
||||||
|
if _, err := io.ReadFull(r, h[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
n := uint64(h[1] & 0x7f)
|
||||||
|
switch n {
|
||||||
|
case 126:
|
||||||
|
var e [2]byte
|
||||||
|
if _, err := io.ReadFull(r, e[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
n = uint64(binary.BigEndian.Uint16(e[:]))
|
||||||
|
case 127:
|
||||||
|
var e [8]byte
|
||||||
|
if _, err := io.ReadFull(r, e[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
n = binary.BigEndian.Uint64(e[:])
|
||||||
|
}
|
||||||
|
if n > 65535 {
|
||||||
|
return 0, nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
var mask [4]byte
|
||||||
|
masked := h[1]&0x80 != 0
|
||||||
|
if masked {
|
||||||
|
if _, err := io.ReadFull(r, mask[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(r, p); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
if masked {
|
||||||
|
for i := range p {
|
||||||
|
p[i] ^= mask[i&3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h[0] & 0x0f, p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFrame(w io.Writer, op byte, p []byte) error {
|
||||||
|
hdr := []byte{0x80 | op, 0}
|
||||||
|
switch n := len(p); {
|
||||||
|
case n < 126:
|
||||||
|
hdr[1] = byte(n)
|
||||||
|
case n < 65536:
|
||||||
|
hdr[1] = 126
|
||||||
|
hdr = binary.BigEndian.AppendUint16(hdr, uint16(n))
|
||||||
|
default:
|
||||||
|
hdr[1] = 127
|
||||||
|
hdr = binary.BigEndian.AppendUint64(hdr, uint64(n))
|
||||||
|
}
|
||||||
|
_, err := w.Write(append(hdr, p...))
|
||||||
|
return err
|
||||||
|
}
|
||||||
10
wsgw/router/S80wsgw
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# PCA Phobos: WebSocket -> UDP gateway (transport "Keenetic cloud"). Config: /opt/etc/wsgw.conf
|
||||||
|
[ -f /opt/etc/wsgw.conf ] && . /opt/etc/wsgw.conf
|
||||||
|
ENABLED=yes
|
||||||
|
PROCS=wsgw
|
||||||
|
ARGS="-l ${WSGW_LISTEN:-127.0.0.1:18090} -t $WSGW_TARGET"
|
||||||
|
PREARGS=""
|
||||||
|
DESC=$PROCS
|
||||||
|
PATH=/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
. /opt/etc/init.d/rc.func
|
||||||