Compare commits
68 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 | ||
|
|
dff6af2c8e | ||
|
|
071d3695e6 | ||
|
|
5354656bb1 | ||
|
|
5a01a6eed2 | ||
|
|
7eac63343d | ||
|
|
093301eb51 | ||
|
|
1a0460b927 | ||
|
|
95806be3ed | ||
|
|
a686ea967e | ||
|
|
8e491b8f29 | ||
|
|
e215cedbb3 | ||
|
|
eac33b3cf6 | ||
|
|
3831292330 | ||
|
|
13644f6991 | ||
|
|
07fc4657bf | ||
|
|
e97b106adc | ||
|
|
390501ad34 | ||
|
|
e430d1a70c | ||
|
|
fde7b9b897 | ||
|
|
c1a151903a | ||
|
|
38ff3ae3dd | ||
|
|
4c1c82afec | ||
|
|
0f98c074c8 | ||
|
|
286142bd7a | ||
|
|
cf4b5f79b5 | ||
|
|
5f97c6c6d7 | ||
|
|
14bb6ae589 | ||
|
|
51712310f6 | ||
|
|
6ec1782d8b | ||
|
|
e61ec09b18 | ||
|
|
ed3071a971 | ||
|
|
db4cab7a38 | ||
|
|
9f402be886 | ||
|
|
8971565e6e | ||
|
|
b6525bc0ae | ||
|
|
d072c8227f | ||
|
|
7b941dc532 | ||
|
|
8c1654ecaa | ||
|
|
dc83d90a35 | ||
|
|
e0ea54e83a | ||
|
|
628973374e | ||
|
|
041a533457 | ||
|
|
48ae0c2fd8 |
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.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
|
||||
11
BETA.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Каналы PCA Phobos
|
||||
|
||||
С **1.5.0** все три канала — одна и та же панель и одни и те же серверные функции.
|
||||
|
||||
| Канал | Для кого | Отличие |
|
||||
|---|---|---|
|
||||
| **stable** (`main`, открытый) | все | без приложений Apple: в окне «Подключение» нет вкладок iOS и macOS |
|
||||
| **beta** | подписчики Boosty (токен) | + приложения PCA Phobos VPN для iPhone (TestFlight) и macOS (.pkg) |
|
||||
| **dev** | подписчики, тест нового | то же, что beta, новые функции появляются здесь первыми |
|
||||
|
||||
Переключение: `phobos-update stable|beta|dev` или «Настройки → Версия и обновления». Токен подписчика: там же.
|
||||
180
CHANGELOG.md
Normal file
@@ -0,0 +1,180 @@
|
||||
## 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
|
||||
|
||||
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
|
||||
Обновление: `phobos-update stable` · `GH_TOKEN=… phobos-update beta|dev` · из панели → «Версия и обновления».
|
||||
|
||||
---
|
||||
|
||||
## 1.4.10 — 2026-11-03
|
||||
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
|
||||
|
||||
## Как обновиться после фиксов
|
||||
|
||||
```bash
|
||||
# stable (открытый)
|
||||
phobos-update stable
|
||||
|
||||
# beta/dev (токен уже сохранён в /opt/phobos-panel/.gh_token)
|
||||
phobos-update beta # или: phobos-update dev
|
||||
```
|
||||
Затем **пересоздать клиентов** в панели (старые конфиги могли содержать неверный порт обфускатора).
|
||||
Обновляются: панель, скрипты онбординга, агент, updater. Ключи WireGuard, `server.env`, клиенты — НЕ трогаются.
|
||||
|
||||
---
|
||||
|
||||
## 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-канал, «РФ напрямую»
|
||||
|
||||
- **feat:** резервный канал TCP: если провайдер режет UDP, клиент сам переходит на TCP (порт 443, иначе 8443/2096/4433). Сервер: служба `phobos-tcp` (`udptcp`), ставится и включается автоматически при установке и обновлении. Проверено реальной блокировкой UDP: переключение за ~15 с, без блокировки остаётся UDP.
|
||||
- **feat (Android 1.4.2):** «Российские сайты — напрямую, без VPN» (по умолчанию), выбор канала Авто/UDP/TCP, журнал TCP-канала.
|
||||
- **fix:** установка и обновления с git.bobyrev.com (GitHub — запасной), каскад раздаёт панель.
|
||||
- Ограничения: TCP-резерв пока только на основном сервере (не на вторых) и только в приложении PCA Phobos VPN (роутеры и PhobosWG — по-прежнему UDP с перебором портов).
|
||||
|
||||
## 1.4.0 — единая версия (stable = beta = dev)
|
||||
|
||||
Три канала сведены в одну версию: всё из dev (WARP, ttyd, бэкап, маршрут Keenetic, параметры обфускатора)
|
||||
+ фиксы beta (обновлятор через API, порт/схема панели) + новое:
|
||||
- **feat:** светлая/тёмная тема, компактные карточки клиентов, мобильная версия без горизонтальной прокрутки.
|
||||
- **feat:** лимит скорости на клиента (tc на wg0, применяется и на вторых серверах).
|
||||
- **feat:** замер скорости сервера (реальный) и клиента (живой + `/speedtest` на устройстве через туннель).
|
||||
- **feat:** домен панели (nginx + Let's Encrypt), адрес для клиентов по домену, каскад (`phobos-cascade.sh`).
|
||||
- **feat:** сторож — блоклист сканеров РКН (`phobos-guard.sh`).
|
||||
- **fix:** secondary-setup на чистой Ubuntu 24.04 (нет iptables → wg0 не поднимался), без tty, `SERVER_IP` для NAT; клиенты досылаются на новый сервер сразу.
|
||||
- **fix:** очистка orphan-peer (не было WG_CONFIG), дубль карточки WARP и кнопки WARP, имена клиентов кириллицей.
|
||||
- **fix:** после обновления старым обновлятором 1.2.x/1.3.x панель сама докачивает новые скрипты.
|
||||
- **fix:** APK без токена подписчика — из публичного релиза (как в 1.2.8).
|
||||
|
||||
Обновление: `phobos-update` (stable) — клиенты, ключи и `server.env` не трогаются. Проверено на школе: 1.2.8 → 1.4.0, старый клиент работает без переустановки.
|
||||
|
||||
## stable
|
||||
|
||||
### v1.2.7
|
||||
- **fix:** импорт `phobos://` в PhobosWG. Конфиги дополняются `= none` для недостающих полей (`PresharedKey` и др.) перед base64 — иначе приложение писало «невозможно импортировать туннель».
|
||||
|
||||
### v1.2.6
|
||||
- **fix:** бинарники wg-obfuscator для роутеров. Keenetic = aarch64, а Ground-Zerro раздаёт только x86_64. Теперь install.sh и сборка пакета клиента авто-качают aarch64/mipsel/armv7 из ClusterM/wg-obfuscator releases.
|
||||
|
||||
### v1.2.5
|
||||
- **feat:** поле токена подписчика + кнопки каналов (stable/beta/dev) прямо в карточке «Версия и обновления». Подписчик вставляет токен в панель и переключается на beta/dev без консоли.
|
||||
|
||||
### v1.2.4
|
||||
- **feat:** установка по умолчанию стабильная; beta/dev — опционально (`CHANNEL=`).
|
||||
|
||||
### v1.2.3
|
||||
- **fix:** дебаунс DOWN-алертов (3 цикла + повтор) — убирает ложный флап серверов.
|
||||
- **fix:** `phobos-update` отменяет обновление на несуществующую версию (текущая не трогается).
|
||||
- **feat:** `--check` / `--versions`, кнопка «Проверить обновления».
|
||||
|
||||
### v1.2.2
|
||||
- контакт во всех местах → группа `@PCAdministration`.
|
||||
|
||||
---
|
||||
|
||||
## Критичные фиксы (общие, во всех каналах)
|
||||
|
||||
| Версия | Проблема | Решение |
|
||||
|--------|----------|---------|
|
||||
| 1.2.8 / 1.3.x | Android PhobosWG: «Неправильная длина ключа в PublicKey» | Android/iOS payload восстанавливает потерянный `=` в WireGuard ключах и не добавляет `PresharedKey = none`; QR/link нужно открыть заново |
|
||||
| 1.2.8 / 1.3.x | Установка/обновление могли пройти, но потом панель генерила кривую ссылку | `phobos-self-check --fix` встроен в install/update и доступен как отдельная команда |
|
||||
| 1.2.7 / 1.3.x | PhobosWG: «невозможно импортировать туннель» | `phobos://` дополняется `= none` для всех обязательных полей (`PresharedKey`) по спеке формата |
|
||||
| 1.2.8 / 1.3.x | Install `[6/9] PCA patches`: `curl: (23) Failure writing output to destination` | `fetch()` создаёт папку назначения перед `curl -o` |
|
||||
| 1.2.8 / 1.3.x | VPS x86_64: `ERROR: obfuscator binary for x86_64 missing` | авто-загрузка `linux-x64` из ClusterM releases + нормализация `amd64 -> x86_64` |
|
||||
| 1.2.6 / 1.3.x | Роутер aarch64: «бинарник не найден в архиве» | авто-загрузка aarch64/mipsel/armv7 из ClusterM releases |
|
||||
| (внутр.) | Клиент не подключается, «НЕТ СОЕДИНЕНИЯ», handshake не встаёт | клиентский обфускатор целился в порт 51821 (никто не слушал); теперь берёт первый из `OBFUSCATOR_PORTS` (2083) |
|
||||
| (внутр.) | агент secondary не обновлялся через `phobos-update` | `server/api.py` добавлен в managed_files |
|
||||
|
||||
---
|
||||
|
||||
## beta (приватный)
|
||||
|
||||
### v1.3.0-beta
|
||||
- **fix:** install/update теперь запускают `phobos-self-check --fix`: автоматически чинят потерянный `=` в `SERVER_WG_PUBLIC_KEY`, сверяют ключ с `wg0`, проверяют router helper-файлы и shell syntax до выдачи клиентских ссылок.
|
||||
- **fix:** Android/iOS phone configs теперь восстанавливают потерянный `=` в WireGuard `PrivateKey/PublicKey/PresharedKey`. Это закрывает ошибку PhobosWG «Неправильная длина ключа в PublicKey».
|
||||
- **fix:** `lib-core.sh` больше не теряет финальный `=` при чтении `server.env`, поэтому новые клиентские `.conf` получают полный `PublicKey`.
|
||||
- **fix:** клиентский router package теперь содержит обязательные `lib-client.sh`, `install-obfuscator.sh`, `install-wireguard.sh` и дополнительные helper-скрипты из PCA repo, поэтому `wget .../init/TOKEN.sh | sh` больше не падает с `can't open './lib-client.sh'`.
|
||||
- **fix:** шаг `[6/9] PCA patches` больше не падает с `curl: (23) Failure writing output to destination`, если папки `repo/server/scripts` или `repo/client/templates` не были созданы upstream sparse clone.
|
||||
- **fix:** установка `wg-obfuscator` на x86_64 больше не падает с `ERROR: obfuscator binary for x86_64 missing`. Installer и secondary installer берут `linux-x64` из ClusterM releases.
|
||||
- **fix:** свежая beta-установка снова получает `lib-core.sh`, без которого client helper мог ломаться на чистой машине.
|
||||
- **fix:** старые ссылки `/tags` теперь ведут в `/labels`, без 404.
|
||||
- **fix:** поле имени клиента больше не даёт ошибку в консоли Chrome из-за HTML `pattern`. UI bench-test прошёл без console/page errors.
|
||||
- **fix:** WG tunnel subnet перестала быть жёстко зашита в `10.25.0.0/16`. Installer, secondary deploy, client packaging и router pull теперь используют `SERVER_WG_IPV4_NETWORK`, так что `10.26`/`10.27` и другие отдельные WG-сети на разных VPS работают без ручных правок.
|
||||
- **fix:** в клиентский пакет добавляется `panel_tunnel_ip`, а `phobos-pull` использует его как tunnel endpoint панели вместо жёсткого `10.25.0.1`.
|
||||
- скачивание `.conf` для телефона (Android `phobos://` + QR, iOS обычный WireGuard + QR);
|
||||
- кнопка скачивания приложения **PhobosWG (APK)** — авто-загрузка из GitHub Release приватного repo;
|
||||
- все критичные фиксы выше.
|
||||
|
||||
---
|
||||
|
||||
## dev (приватный, активная разработка)
|
||||
|
||||
### v1.3.4-dev
|
||||
- **fix:** install/update теперь запускают `phobos-self-check --fix`: автоматически чинят потерянный `=` в `SERVER_WG_PUBLIC_KEY`, сверяют ключ с `wg0`, проверяют router helper-файлы и shell syntax до выдачи клиентских ссылок.
|
||||
- **fix:** Android/iOS phone configs теперь восстанавливают потерянный `=` в WireGuard `PrivateKey/PublicKey/PresharedKey`. Это закрывает ошибку PhobosWG «Неправильная длина ключа в PublicKey».
|
||||
- **fix:** `lib-core.sh` и WARP helper больше не теряют финальный `=` при чтении `server.env`, поэтому новые клиентские `.conf` получают полный `PublicKey`.
|
||||
- **fix:** клиентский router package теперь содержит обязательные `lib-client.sh`, `install-obfuscator.sh`, `install-wireguard.sh` и дополнительные helper-скрипты из PCA repo, поэтому `wget .../init/TOKEN.sh | sh` больше не падает с `can't open './lib-client.sh'`.
|
||||
- **fix:** шаг `[6/9] PCA patches` больше не падает с `curl: (23) Failure writing output to destination`, если папки `repo/server/scripts` или `repo/client/templates` не были созданы upstream sparse clone.
|
||||
- **fix:** установка `wg-obfuscator` на x86_64 больше не падает с `ERROR: obfuscator binary for x86_64 missing`. Installer и secondary installer берут `linux-x64` из ClusterM releases.
|
||||
- **fix:** поле имени клиента больше не даёт ошибку в консоли Chrome из-за HTML `pattern`. UI bench-test прошёл без console/page errors.
|
||||
- **fix:** WG tunnel subnet больше не зашита в `10.25.0.0/16`. `install.sh`, `secondary-setup.sh`, client packaging, router pull и secondary deploy теперь берут `SERVER_WG_IPV4_NETWORK`, поэтому один сервер можно держать на `10.25`, другой на `10.26`, `10.27` и дальше без ручного `nano`.
|
||||
- **fix:** в клиентский пакет добавляется `panel_tunnel_ip`, а `phobos-pull`/`phobos-ttyd` используют его вместо жёсткого `10.25.0.1`. Это убирает поломку управления роутером после смены WG-подсети на сервере.
|
||||
- **fix:** per-client WARP стал действительно точечным. Удалено старое правило на всю подсеть WG, правила теперь ставятся и снимаются только по `client tunnel IP /32`.
|
||||
- **fix:** клиентские пакеты и install-команды снова собираются стабильно: создаётся `/opt/Phobos/bin`, шаблон `install-router.sh.template` обрабатывается мягко, пустой `OBFUSCATOR_KEY` больше не валит сборку.
|
||||
- **fix:** `/clients` больше не падает при неполной выкладке dev-стенда. Добавлены обязательные `lib-core.sh` и client templates в install/update flow.
|
||||
- **fix:** “живой peer есть, клиента нет” больше не выглядит как пропавший клиент. Сиротские peer теперь отображаются в `Сессии` и `Клиенты` как `orphan-*`, с кнопками `WARP`, `Kick`, `Purge`.
|
||||
- **fix:** старые ссылки `/tags` теперь ведут в `/labels`.
|
||||
- **security:** login hardening — `HttpOnly` + `SameSite=Lax` cookie, ограничение попыток входа, constant-time compare для пароля и API keys, upload limit `64 MB`.
|
||||
- **ops:** `phobos-update` и `phobos-backup restore` теперь сами определяют активный panel service (`phobos-panel` или `phobos-dev-test`) и перезапускают правильный unit.
|
||||
- **security/cleanup:** из secondary API убран `shell=True` там, где он был не нужен; чтение CPU/mem/network переведено на более безопасный путь.
|
||||
|
||||
### v1.3.1-dev
|
||||
- **баланс по трафику:** агенты отдают пропускную способность канала (rx/tx NIC), `server_load = 0.45·cpu + 0.30·mem + 0.25·bw`; карточки серверов показывают ↕ Mbps;
|
||||
- **WARP / WARP+:** в `/settings` добавлен отдельный блок с режимами `egress`, `relay`, `custom profile` и полем `WARP+ key`;
|
||||
- **egress mode:** исходящий трафик клиентов можно завернуть через Cloudflare WARP на стороне сервера;
|
||||
- **relay mode:** добавлен UDP relay по мотивам `warp-relay` для отдельных WARP/WireGuard профилей;
|
||||
- **custom profile:** полный `warp.conf` можно вставить и сохранить прямо из веб-панели;
|
||||
- **help UI:** у блока WARP появился `?` с человеческим объяснением режимов, ограничений и сценариев использования;
|
||||
- все фиксы и фичи из beta.
|
||||
174
README.md
@@ -17,7 +17,8 @@
|
||||
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
|
||||
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
|
||||
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
|
||||
- 📱 **Телефоны** — Android (PhobosWG, с обфускацией) и iPhone (обычный WireGuard) через QR-код.
|
||||
- 📱 **Телефоны и ПК** — свои приложения **PCA Phobos VPN**: Android (APK) и Windows (по токену подписчика). Плюс PhobosWG и обычный WireGuard — через QR/ссылку. Приложения для iPhone и macOS — в каналах beta/dev.
|
||||
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
|
||||
|
||||
---
|
||||
|
||||
@@ -25,99 +26,169 @@
|
||||
|
||||
- **Активные сессии** — VPN IP, Real IP, метка, handshake, трафик RX/TX, Kick.
|
||||
- **Клиенты VPN** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера.
|
||||
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM), резервные серверы; роутеры переключаются автоматически.
|
||||
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM + пропускная способность канала), резервные серверы; роутеры переключаются автоматически.
|
||||
- **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP.
|
||||
- **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS.
|
||||
- **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR.
|
||||
- **Окно «Подключение»** — Android / Windows / Router / HR (+ обычный WireGuard для iPhone).
|
||||
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
|
||||
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
|
||||
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
|
||||
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия.
|
||||
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия. **Светлая и тёмная тема** (кнопка ◐), компактная вёрстка — удобно с телефона.
|
||||
- **Лимит скорости на клиента** — Мбит/с в карточке клиента; работает на всех серверах, сам восстанавливается после перезапуска WireGuard.
|
||||
- **Замер скорости** — сервера (реальная скачка/отдача, кнопка ⚡ на «Серверах»), клиента (живая скорость в карточке + страница `http://10.25.0.1:<порт>/speedtest` на устройстве через VPN).
|
||||
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
|
||||
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
|
||||
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
|
||||
- **Канал «Облако»** — если сервер заблокирован целиком: телефон идёт через облако KeenDNS своего Keenetic. [Инструкция](docs/cloud.md).
|
||||
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
|
||||
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
|
||||
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (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-оповещения о подключении/отключении.
|
||||
- **Telegram-бот** — управление клиентами командами прямо из чата:
|
||||
- `/add <имя>` — создать клиента (бот пришлёт команду установки)
|
||||
- `/del <имя>` — удалить клиента
|
||||
- `/list` — список клиентов
|
||||
(Работает только для чата из настроек `tg_chat_id`.)
|
||||
- **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь.
|
||||
(Работает только для чата из настроек `tg_chat_id`.) Бот **необязателен**: хочешь — впиши токен от @BotFather и свой chat id в «Настройки», не хочешь — оставь пустым.
|
||||
|
||||
---
|
||||
|
||||
## Скриншоты
|
||||
|
||||
| Сессии | Клиенты | Серверы |
|
||||
|--------|---------|---------|
|
||||
|  |  |  |
|
||||
| Сессии (светлая) | Сессии (тёмная) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Клиенты** | **Серверы: второй сервер, домен для клиентов** |
|
||||
|  |  |
|
||||
|
||||
> Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`).
|
||||
| Телефон: клиенты | Телефон: тёмная | Телефон: серверы |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|
||||
Настройки (пароль, домен, каскад, 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).
|
||||
|
||||
---|---|
|
||||
|  |  |
|
||||
| **Сессии** | **Серверы: замер скорости, второй сервер** |
|
||||
|  |  |
|
||||
|
||||
| Телефон: панель | Телефон: тёмная | Android: VPN-клиент | Android: раздельный трафик | Android: админ-приложение |
|
||||
|---|---|---|---|---|
|
||||
|  |  |  |  |  |
|
||||
|
||||
Настройки (домен, каскад, сторож, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png)
|
||||
|
||||
---
|
||||
|
||||
> **Откуда ставится (с 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 <(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, одной командой)
|
||||
|
||||
Ставит **всё** со всеми зависимостями: wg-obfuscator, WireGuard, обфускатор-сервисы, веб-панель, nginx, скрипты онбординга роутеров, сторож авто-восстановления. Предустановленный Phobos **не требуется**.
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/install.sh)
|
||||
```
|
||||
|
||||
### С параметрами
|
||||
|
||||
```bash
|
||||
PANEL_PASS=МойПароль TG_TOKEN=123:abc TG_CHAT=123456789 \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/install.sh)
|
||||
```
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|-------------------|-----------------|----------|
|
||||
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
|
||||
| `PANEL_PORT` | случайный | Порт веб-панели |
|
||||
| `SERVER_WG_IPV4_NETWORK` | `10.25.0.0/16` | Подсеть VPN-клиентов (например `10.25.1.0/24`). Без переменной установщик спросит в терминале. Сервер получает первый адрес (`10.25.1.1`). Задаётся только при установке |
|
||||
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
|
||||
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
|
||||
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
|
||||
| `ALLOW_PLAIN_WG` | пусто | `=1` открыть порт 51820 для iOS WireGuard (без обфускации) |
|
||||
| `PANEL_TLS_CERT` / `PANEL_TLS_KEY` | пусто | Включить HTTPS для панели через свой сертификат |
|
||||
|
||||
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
||||
|
||||
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
|
||||
DNS клиентов и свой AdGuard Home: [docs/DNS.md](docs/DNS.md). WARP — проверка и проблемы: [docs/WARP.md](docs/WARP.md).
|
||||
|
||||
### Вторичный сервер (для failover / балансировки)
|
||||
|
||||
```bash
|
||||
MAIN_SERVER=<ip_основного> MAIN_API_KEY=<API key из основного> \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)
|
||||
MAIN_PROTO=http MAIN_SERVER=<ip_основного> MAIN_PORT=<порт_панели> MAIN_API_KEY=<API key из основного> \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/server/secondary-setup.sh)
|
||||
```
|
||||
|
||||
Затем в панели → **Серверы** → добавь его в список.
|
||||
Сервер сам зарегистрируется в панели, клиенты досылаются на него сразу. Если сервер за NAT — добавь `SERVER_IP=<внешний_ip>`.
|
||||
|
||||
### Каскад (вход в РФ → выход за рубежом)
|
||||
|
||||
На входном VPS (чистый Debian/Ubuntu, Phobos не нужен):
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
### Сторонние «сторожа» по просьбам участников
|
||||
|
||||
- Защита от зондов РКН ([Loorrr293/Auto_IPtables](https://github.com/Loorrr293/Auto_IPtables)) — **встроена**: «Настройки → Защита сервера», включается кнопкой.
|
||||
- «Прикрывашка» ([miha75vu-bit/Flashkeen](https://github.com/miha75vu-bit/Flashkeen)) — WARP на выход + Psiphon; интерактивная, ставится по SSH: `bash <(curl -fsSL https://raw.githubusercontent.com/miha75vu-bit/Flashkeen/main/wp)`. Перестраивает выход wg0 — не включай одновременно со встроенным WARP egress.
|
||||
- ipidea-kimwolf-ban — репозиторий автора удалён (404), встроить нечего.
|
||||
|
||||
---
|
||||
|
||||
## Клиенты для телефона
|
||||
## Приложения для Android
|
||||
|
||||
В таблице **Клиенты** у каждого клиента есть кнопки:
|
||||
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/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.4/PCA-Phobos.apk) · исходники `android/`.
|
||||
|
||||
- 📱 **Android** — `phobos://`-ссылка + QR для приложения **PhobosWG** (с обфускацией). Скан QR → импорт.
|
||||
- 🍎 **iPhone / iOS** — обычный WireGuard-конфиг + QR для официального WireGuard (**без** обфускации). Требует открытого порта 51820 (`ALLOW_PLAIN_WG=1` при установке).
|
||||
## Подключение устройств
|
||||
|
||||
Клиенты → у клиента окно **«Подключение»** с вкладками:
|
||||
|
||||
- **Android** — `phobos://`-ссылка + QR для **PCA Phobos VPN** или **PhobosWG** (с обфускацией), файл `.conf`.
|
||||
- **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). Один конфиг на двух устройствах = конфликт ключей.
|
||||
|
||||
**Приложения:**
|
||||
- **Android (PhobosWG):** автор пока не публикует APK официально. Положи `.apk` на сервер в `/opt/Phobos/www/app/PhobosWG.apk` — панель сама покажет кнопку «Скачать приложение PhobosWG» на странице Android-клиента (раздаётся по `http://<сервер>/app/PhobosWG.apk`).
|
||||
- **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента.
|
||||
|
||||
---
|
||||
|
||||
## Обновление и откат
|
||||
|
||||
Проект версионируется (см. файл `VERSION` и git-теги `vX.Y.Z`).
|
||||
|
||||
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**.
|
||||
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**. С 1.4.11 панель сама раз в час проверяет свой канал: вышла новая версия — сверху зелёная полоса «Доступна новая версия N» с кнопкой **Обновить** (в окне — что нового, бэкап, откат). Там же видно, если вышли новые версии приложений. В меню под «Настройками» в это время горит зелёный пункт **«Обновить до N»**.
|
||||
|
||||
> Панели до 1.4.11 этой полосы не показывают — один раз обнови вручную (`phobos-update`), дальше она появляется сама.
|
||||
|
||||
**Из консоли:**
|
||||
|
||||
```bash
|
||||
phobos-update # обновить до последней версии
|
||||
phobos-update # обновить до последней версии (stable)
|
||||
phobos-update beta|dev # каналы для подписчиков Boosty (токен сохраняется в /opt/phobos-panel/.gh_token)
|
||||
phobos-update v1.1.0 # обновить до конкретной версии (git tag)
|
||||
phobos-update --rollback # откатить на предыдущую версию
|
||||
phobos-update --version # показать установленную и доступную версию
|
||||
phobos-update --check # установленная версия и версии всех каналов
|
||||
phobos-update --list # список бэкапов
|
||||
```
|
||||
|
||||
@@ -125,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` |
|
||||
| **Клиент Offline, хотя работает** | роутер на резервном сервере | Норма — статус считается по handshake на **любом** сервере; обнови страницу |
|
||||
| **«SSH timeout» при тесте роутера** | роутер за NAT, входящий SSH закрыт | Это нормально — управление идёт по pull-каналу через туннель, SSH не нужен |
|
||||
@@ -138,7 +251,11 @@ phobos-update --list # список бэкапов
|
||||
| **iPhone не подключается по `phobos://`** | на iOS нет приложения-обфускатора | Используй 🍎-конфиг (обычный WireGuard) + открой 51820 (`ALLOW_PLAIN_WG=1`) |
|
||||
| **Смена сервера применяется не сразу** | роутер тянет конфиг по cron | Применяется за ~12–15 сек, не мгновенно |
|
||||
| **Забыл порт панели** | случайный порт | `cat /opt/phobos-panel/.port` |
|
||||
| **Забыл пароль панели** | | см. раздел [«Пароль панели»](#пароль-панели--сменить-или-вспомнить) |
|
||||
| **Забыл API key** | | `grep server_api_key /opt/phobos-panel/settings.json` |
|
||||
| **Домен: certbot failed** | A-запись не на этот сервер / закрыт 80 | `dig +short домен` = IP сервера; открой 80/tcp; повтори в «Настройках» |
|
||||
| **Лимит скорости не действует** | wg0 перезапущен | панель сама восстановит за ≤1 мин; вручную: `phobos-limit.sh apply` |
|
||||
| **Кнопки клиента «уезжают»** | старая версия | обнови до 1.4.0 — карточки клиентов компактные, телефоны тоже |
|
||||
|
||||
Логи: `journalctl -u phobos-panel -n 50` · сторож: `/opt/Phobos/server/watchdog.log` · роутер: `/opt/etc/Phobos/health.log`.
|
||||
|
||||
@@ -168,7 +285,7 @@ phobos-update --list # список бэкапов
|
||||
|
||||
- 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate)
|
||||
- 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
|
||||
- ✉️ **Telegram:** [@Iot_andrey](https://t.me/Iot_andrey) — вопросы, баги, идеи
|
||||
- ✉️ **Telegram:** [@PCAdministration](https://t.me/PCAdministration) — вопросы, баги, идеи
|
||||
|
||||
## На основе и благодарности
|
||||
|
||||
@@ -179,3 +296,6 @@ PCA Phobos — это веб-панель и turnkey-инсталлятор по
|
||||
- [**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) и что делать в остальных.
|
||||
|
||||
2
android-client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
build/
|
||||
libs/x/
|
||||
60
android-client/AndroidManifest.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.pca.phobos.vpn"
|
||||
android:versionCode="147"
|
||||
android:versionName="1.4.6">
|
||||
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="PCA Phobos VPN"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:theme="@style/AppTheme"
|
||||
android:extractNativeLibs="true"
|
||||
android:allowBackup="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- panel "phobos://" links open straight into the app -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="phobos" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- 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) -->
|
||||
<service
|
||||
android:name="com.wireguard.android.backend.GoBackend$VpnService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
20
android-client/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# PCA Phobos VPN — Android-клиент
|
||||
|
||||
Своё приложение-клиент: импорт `phobos://` из панели, встроенный wg-obfuscator + WireGuard,
|
||||
**раздельный трафик** по приложениям (весь / только выбранные / все кроме выбранных).
|
||||
|
||||
- WireGuard: `com.wireguard.android:tunnel` (Apache-2.0), `libs/`.
|
||||
- Обфускатор: [ClusterM/wg-obfuscator](https://github.com/ClusterM/wg-obfuscator) v1.6 (GPL-3.0),
|
||||
собран NDK под bionic: `jni/<abi>/libwgobfuscator.so`. Статический glibc-бинарник с роутеров
|
||||
внутри приложения падает с SIGSYS (seccomp), поэтому только bionic-сборка.
|
||||
- Само приложение всегда вне туннеля — иначе обфускатор не достучится до сервера.
|
||||
|
||||
Сборка без Gradle: `KS_PASS=… ./build.sh` → `build/PCA-Phobos-VPN.apk`
|
||||
(ключ подписи — `~/.android/pca-phobos.jks`, пароль в ДОСТУПЫ.md; ключ не менять, иначе обновления не встанут).
|
||||
|
||||
Пересборка обфускатора:
|
||||
```bash
|
||||
NDK=~/Library/Android/sdk/ndk/29.0.14206865/toolchains/llvm/prebuilt/darwin-x86_64/bin
|
||||
$NDK/aarch64-linux-android24-clang -O2 -std=gnu11 -o jni/arm64-v8a/libwgobfuscator.so wg-obfuscator/*.c
|
||||
$NDK/armv7a-linux-androideabi24-clang -O2 -std=gnu11 -o jni/armeabi-v7a/libwgobfuscator.so wg-obfuscator/*.c
|
||||
```
|
||||
2
android-client/assets/ru_nets.txt
Normal file
34
android-client/build.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build PCA Phobos VPN (client) without Gradle. Needs libs/: tunnel-*.aar (com.wireguard.android:tunnel),
|
||||
# collection-*.jar, annotation-*.jar; jni/<abi>/libwgobfuscator.so (static ClusterM wg-obfuscator).
|
||||
# KS_PASS=… ./build.sh -> build/PCA-Phobos-VPN.apk
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
|
||||
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
|
||||
KS="${KS:-$HOME/.android/pca-phobos.jks}"
|
||||
KS_PASS="${KS_PASS:?set KS_PASS}"
|
||||
AAR=$(ls libs/tunnel-*.aar | tail -1)
|
||||
rm -rf build && mkdir -p build/res build/classes build/dex build/aar build/apk/lib
|
||||
unzip -q "$AAR" -d build/aar
|
||||
LIBS="build/aar/classes.jar:$(ls libs/*.jar | tr '\n' ':')"
|
||||
"$BT/aapt2" compile --dir res -o build/res/
|
||||
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml -A assets \
|
||||
--java build/gen build/res/*.flat --min-sdk-version 24 --target-sdk-version 35
|
||||
javac -source 11 -target 11 -nowarn -classpath "$JAR:$LIBS" -d build/classes \
|
||||
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath\|^Note:\|^warning: \[options\]\|^1 warning" || true
|
||||
[ -f build/classes/com/pca/phobos/vpn/MainActivity.class ] || { echo "javac failed"; exit 1; }
|
||||
"$BT/d8" --release --lib "$JAR" --min-api 24 --output build/dex \
|
||||
$(find build/classes -name '*.class') build/aar/classes.jar libs/*.jar
|
||||
cp build/base.apk build/unsigned.apk
|
||||
(cd build/dex && zip -q ../unsigned.apk classes*.dex)
|
||||
for abi in arm64-v8a armeabi-v7a; do
|
||||
mkdir -p "build/apk/lib/$abi"
|
||||
cp build/aar/jni/$abi/*.so "build/apk/lib/$abi/"
|
||||
cp "jni/$abi/libwgobfuscator.so" "jni/$abi/libudptcp.so" "build/apk/lib/$abi/"
|
||||
done
|
||||
(cd build/apk && zip -qr ../unsigned.apk lib)
|
||||
"$BT/zipalign" -f 4 build/unsigned.apk build/aligned.apk
|
||||
"$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$KS_PASS" --out build/PCA-Phobos-VPN.apk build/aligned.apk
|
||||
"$BT/apksigner" verify build/PCA-Phobos-VPN.apk && ls -la build/PCA-Phobos-VPN.apk
|
||||
BIN
android-client/jni/arm64-v8a/libudptcp.so
Executable file
BIN
android-client/jni/arm64-v8a/libwgobfuscator.so
Executable file
BIN
android-client/jni/armeabi-v7a/libudptcp.so
Executable file
BIN
android-client/jni/armeabi-v7a/libwgobfuscator.so
Executable file
BIN
android-client/libs/annotation-1.6.0.jar
Normal file
BIN
android-client/libs/collection-1.2.0.jar
Normal file
BIN
android-client/libs/tunnel-1.0.20230706.aar
Normal file
12
android-client/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#1e1b4b" android:pathData="M0,0h108v108h-108z" />
|
||||
<path android:fillColor="#4f46e5"
|
||||
android:pathData="M54,18 L82,28 L82,52 C82,70 70,84 54,90 C38,84 26,70 26,52 L26,28 Z" />
|
||||
<path android:fillColor="#c7d2fe"
|
||||
android:pathData="M54,30 L72,36.5 L72,52 C72,64 64,73.5 54,78 C44,73.5 36,64 36,52 L36,36.5 Z" />
|
||||
<path android:fillColor="#1e1b4b"
|
||||
android:pathData="M47,46 h14 v5 h-4.5 v16 h-5 v-16 h-4.5 z" />
|
||||
</vector>
|
||||
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>
|
||||
10
android-client/res/values-night/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:colorPrimary">#a5b4fc</item>
|
||||
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||
<item name="android:colorAccent">#818cf8</item>
|
||||
<item name="android:windowBackground">#0f172a</item>
|
||||
<item name="android:statusBarColor">#1e1b4b</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
android-client/res/values/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:colorPrimary">#4338ca</item>
|
||||
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||
<item name="android:colorAccent">#4f46e5</item>
|
||||
<item name="android:windowBackground">#f1f5f9</item>
|
||||
<item name="android:statusBarColor">#1e1b4b</item>
|
||||
</style>
|
||||
</resources>
|
||||
809
android-client/src/com/pca/phobos/vpn/MainActivity.java
Normal file
@@ -0,0 +1,809 @@
|
||||
package com.pca.phobos.vpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.net.Uri;
|
||||
import android.net.VpnService;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.wireguard.android.backend.GoBackend;
|
||||
import com.wireguard.android.backend.Statistics;
|
||||
import com.wireguard.android.backend.Tunnel;
|
||||
import com.wireguard.config.Config;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* PCA Phobos VPN — client: imports a phobos:// link from the panel, runs the
|
||||
* bundled wg-obfuscator + userspace WireGuard, split tunnelling by apps.
|
||||
* The app itself is always outside the tunnel so the obfuscator reaches the server directly.
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
static final int LPORT = 13255;
|
||||
static final String ACTION_TOGGLE = "com.pca.phobos.vpn.TOGGLE";
|
||||
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
|
||||
|
||||
static GoBackend backend;
|
||||
static Process obfuscator, relay;
|
||||
static final int TCP_LPORT = 13256;
|
||||
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() {
|
||||
public String getName() { return "phobos"; }
|
||||
public void onStateChange(Tunnel.State s) { }
|
||||
};
|
||||
|
||||
private final ExecutorService bg = Executors.newSingleThreadExecutor();
|
||||
private final Handler ui = new Handler(Looper.getMainLooper());
|
||||
private SharedPreferences prefs;
|
||||
private boolean dark;
|
||||
private TextView status, traffic, server;
|
||||
private Button power;
|
||||
private long lastRx, lastTx, lastT;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
|
||||
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
|
||||
if (backend == null) backend = new GoBackend(getApplicationContext());
|
||||
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());
|
||||
render();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent i) {
|
||||
super.onNewIntent(i);
|
||||
handleIntent(i);
|
||||
render();
|
||||
}
|
||||
|
||||
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())) {
|
||||
importLink(i.getDataString());
|
||||
}
|
||||
}
|
||||
|
||||
// ── colors / widgets ──────────────────────────────────────────────────
|
||||
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
|
||||
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
|
||||
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
|
||||
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
|
||||
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
|
||||
|
||||
private GradientDrawable round(int color, int r) {
|
||||
GradientDrawable g = new GradientDrawable();
|
||||
g.setColor(color);
|
||||
g.setCornerRadius(dp(r));
|
||||
return g;
|
||||
}
|
||||
|
||||
private Button button(String label, int color, View.OnClickListener l) {
|
||||
Button b = new Button(this);
|
||||
b.setText(label);
|
||||
b.setAllCaps(false);
|
||||
b.setTextColor(Color.WHITE);
|
||||
b.setBackground(round(color, 10));
|
||||
b.setOnClickListener(l);
|
||||
return b;
|
||||
}
|
||||
|
||||
private TextView tv(String s, int color, int size, boolean bold) {
|
||||
TextView t = new TextView(this);
|
||||
t.setText(s);
|
||||
t.setTextColor(color);
|
||||
t.setTextSize(size);
|
||||
if (bold) t.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
return t;
|
||||
}
|
||||
|
||||
private LinearLayout.LayoutParams mw(int top) {
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp.topMargin = dp(top);
|
||||
return lp;
|
||||
}
|
||||
|
||||
// ── profile ───────────────────────────────────────────────────────────
|
||||
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
|
||||
private void importLink(String link) { importText(link, "Phobos"); }
|
||||
|
||||
/** A phobos:// link, or the text of a client .conf ([Interface] + [Peer] + [instance]). */
|
||||
private void importText(String text, String name) {
|
||||
try {
|
||||
String s = text.replace("\uFEFF", "").trim(), conf = s;
|
||||
if (s.startsWith("phobos://")) {
|
||||
int hash = s.indexOf('#');
|
||||
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
|
||||
s = s.replaceFirst("^phobos://", "").trim();
|
||||
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("нужен конфиг 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();
|
||||
toast("Профиль «" + name + "» импортирован");
|
||||
} catch (Exception e) {
|
||||
toast("Не удалось импортировать: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Split payload into sections: header -> lines (drop "= none" placeholders). */
|
||||
private static List<String> section(String conf, String header) {
|
||||
List<String> out = new ArrayList<>();
|
||||
boolean in = false;
|
||||
for (String raw : conf.split("\n")) {
|
||||
String ln = raw.trim();
|
||||
if (ln.startsWith("[") && ln.endsWith("]")) { in = ln.equalsIgnoreCase(header); continue; }
|
||||
if (!in || ln.isEmpty() || !ln.contains("=")) continue;
|
||||
if (ln.substring(ln.indexOf('=') + 1).trim().equalsIgnoreCase("none")) continue;
|
||||
out.add(ln);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String key(String line) { return line.substring(0, line.indexOf('=')).trim().toLowerCase(); }
|
||||
private static String val(String line) { return line.substring(line.indexOf('=') + 1).trim(); }
|
||||
|
||||
String wgPart(String conf, String mode, String apps, boolean ruRoutes) {
|
||||
StringBuilder sb = new StringBuilder("[Interface]\n");
|
||||
for (String ln : section(conf, "[Interface]")) {
|
||||
String k = key(ln);
|
||||
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
|
||||
if (k.equals("dns") && !myDns().isEmpty()) continue;
|
||||
sb.append(ln).append('\n');
|
||||
}
|
||||
if (!myDns().isEmpty()) sb.append("DNS = ").append(myDns()).append('\n');
|
||||
List<String> sel = new ArrayList<>();
|
||||
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
|
||||
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
|
||||
sel.remove(getPackageNameSafe());
|
||||
sb.append("IncludedApplications = ").append(String.join(", ", sel)).append('\n');
|
||||
} else {
|
||||
Set<String> ex = new LinkedHashSet<>();
|
||||
ex.add(getPackageNameSafe()); // obfuscator must bypass the tunnel
|
||||
if (MODE_EXCEPT.equals(mode)) ex.addAll(sel);
|
||||
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
|
||||
}
|
||||
sb.append("\n[Peer]\n");
|
||||
String ruBypass = ruRoutes && prefs.getBoolean("ru_direct", true) ? ruBypassAllowedIps() : null;
|
||||
for (String ln : section(conf, "[Peer]")) {
|
||||
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
|
||||
if (key(ln).equals("allowedips") && ruBypass != null) ln = "AllowedIPs = " + ruBypass;
|
||||
sb.append(ln).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Internet minus Russian networks (assets/ru_nets.txt) and the user's own sites; resolves domains, so not on the UI thread. */
|
||||
private String ruBypassAllowedIps() {
|
||||
List<String> ru = new ArrayList<>();
|
||||
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(
|
||||
getAssets().open("ru_nets.txt"), StandardCharsets.UTF_8))) {
|
||||
for (String ln; (ln = r.readLine()) != null; ) {
|
||||
if (!ln.startsWith("#") && ln.contains("/")) for (String c : ln.split(",")) ru.add(c.trim());
|
||||
}
|
||||
} catch (Exception e) { log("ru_nets: " + e); }
|
||||
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 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");
|
||||
for (String ln : section(conf, "[instance]")) {
|
||||
String k = key(ln);
|
||||
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
|
||||
// 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("verbose = INFO\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** "# pca-tcp = 443" written by the panel when the server has the TCP fallback. */
|
||||
private static String tcpPort(String conf) {
|
||||
for (String ln : conf.split("\n")) {
|
||||
String t = ln.trim();
|
||||
if (t.startsWith("#") && t.contains("pca-tcp") && t.contains("=")) return val(t);
|
||||
}
|
||||
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) {
|
||||
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
||||
return "?";
|
||||
}
|
||||
|
||||
// ── connect / disconnect ──────────────────────────────────────────────
|
||||
private boolean isUp() { return tunnelUp(); }
|
||||
|
||||
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() {
|
||||
if (isUp()) { bg.execute(this::down); return; }
|
||||
if (prefs.getString("conf", "").isEmpty()) { toast("Сначала импортируйте ссылку phobos:// из панели"); return; }
|
||||
Intent prep = VpnService.prepare(this);
|
||||
if (prep != null) startActivityForResult(prep, 7);
|
||||
else bg.execute(this::up);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int req, int res, Intent data) {
|
||||
if (req == 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);
|
||||
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
||||
}
|
||||
|
||||
/** Start (or restart) obfuscator; via TCP / cloud it dials the local relay on TCP_LPORT. */
|
||||
private void startTransport(String conf, String via) throws Exception {
|
||||
stopObfuscator();
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
File cfg = new File(getFilesDir(), "obf.conf");
|
||||
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, override).getBytes(StandardCharsets.UTF_8)); }
|
||||
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
|
||||
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
|
||||
Thread.sleep(600);
|
||||
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
||||
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
|
||||
transport = viaWs ? "Облако" : viaTcp ? "TCP" : "UDP";
|
||||
log("transport " + transport + " -> " + (viaWs ? wsUrl(conf) : target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : "")));
|
||||
}
|
||||
|
||||
private void up() {
|
||||
synchronized (LOCK) {
|
||||
try {
|
||||
String base = prefs.getString("conf", "");
|
||||
refreshSub(base);
|
||||
subIndex = 0;
|
||||
connect(sub.length() > 0 ? applying(base, sub.getJSONObject(0)) : base);
|
||||
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", servers " + Math.max(1, sub.length()));
|
||||
notifyState(true);
|
||||
post("Подключено (" + transport + ")");
|
||||
if (sub.length() > 1 && !"cloud".equals(prefs.getString("transport", "auto"))) watchServer(base);
|
||||
} catch (Throwable e) {
|
||||
stopObfuscator();
|
||||
log("up failed: " + e);
|
||||
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. */
|
||||
private void watchUdp(String conf) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
long rx0 = backend.getStatistics(TUNNEL).totalRx();
|
||||
Thread.sleep(15000);
|
||||
if (!isUp() || !"UDP".equals(transport) || !conf.equals(curConf)) return;
|
||||
if (backend.getStatistics(TUNNEL).totalRx() > rx0) return;
|
||||
log("UDP: no answer in 15 s -> switching to TCP");
|
||||
startTransport(conf, "tcp");
|
||||
post("UDP не отвечает — переключился на TCP");
|
||||
} catch (Throwable e) { log("watchUdp: " + e); }
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void down() {
|
||||
watchGen++;
|
||||
curConf = "";
|
||||
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
|
||||
stopObfuscator();
|
||||
transport = "";
|
||||
notifyState(false);
|
||||
post("Отключено");
|
||||
}
|
||||
|
||||
private static void stopObfuscator() {
|
||||
if (obfuscator != null) { obfuscator.destroy(); obfuscator = 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(); }); }
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────────────
|
||||
private void render() {
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(bg());
|
||||
|
||||
LinearLayout h = new LinearLayout(this);
|
||||
h.setBackgroundColor(0xFF1E1B4B);
|
||||
h.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||
h.addView(tv("🛡 PCA Phobos VPN", 0xFFC7D2FE, 18, true));
|
||||
root.addView(h);
|
||||
|
||||
ScrollView sv = new ScrollView(this);
|
||||
LinearLayout box = new LinearLayout(this);
|
||||
box.setOrientation(LinearLayout.VERTICAL);
|
||||
box.setPadding(dp(16), dp(16), dp(16), dp(24));
|
||||
sv.addView(box);
|
||||
|
||||
String conf = prefs.getString("conf", "");
|
||||
LinearLayout prof = new LinearLayout(this);
|
||||
prof.setOrientation(LinearLayout.VERTICAL);
|
||||
prof.setBackground(round(card(), 14));
|
||||
prof.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||
if (conf.isEmpty()) {
|
||||
prof.addView(tv("Профиль не импортирован", text(), 16, true));
|
||||
prof.addView(tv("В панели PCA Phobos откройте клиента → 📱 Android → нажмите ссылку phobos:// "
|
||||
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
|
||||
} else {
|
||||
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
|
||||
server = tv("", muted(), 14, false);
|
||||
prof.addView(server);
|
||||
}
|
||||
box.addView(prof, mw(0));
|
||||
|
||||
power = new Button(this);
|
||||
power.setAllCaps(false);
|
||||
power.setTextColor(Color.WHITE);
|
||||
power.setTextSize(20);
|
||||
power.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
power.setOnClickListener(v -> toggle());
|
||||
LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams(dp(180), dp(180));
|
||||
plp.gravity = Gravity.CENTER_HORIZONTAL;
|
||||
plp.topMargin = dp(24);
|
||||
box.addView(power, plp);
|
||||
|
||||
status = tv("", text(), 16, true);
|
||||
status.setGravity(Gravity.CENTER);
|
||||
box.addView(status, mw(12));
|
||||
traffic = tv("", muted(), 14, false);
|
||||
traffic.setGravity(Gravity.CENTER);
|
||||
box.addView(traffic, mw(2));
|
||||
|
||||
// split tunnelling
|
||||
LinearLayout split = new LinearLayout(this);
|
||||
split.setOrientation(LinearLayout.VERTICAL);
|
||||
split.setBackground(round(card(), 14));
|
||||
split.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||
split.addView(tv("Раздельный трафик", text(), 16, true));
|
||||
RadioGroup rg = new RadioGroup(this);
|
||||
String mode = prefs.getString("mode", MODE_ALL);
|
||||
String[][] modes = {{MODE_ALL, "Весь трафик через VPN"}, {MODE_ONLY, "Только выбранные приложения"},
|
||||
{MODE_EXCEPT, "Все, кроме выбранных приложений"}};
|
||||
for (String[] m : modes) {
|
||||
RadioButton r = new RadioButton(this);
|
||||
r.setText(m[1]);
|
||||
r.setTextColor(text());
|
||||
r.setId(View.generateViewId());
|
||||
r.setChecked(m[0].equals(mode));
|
||||
r.setOnClickListener(v -> { prefs.edit().putString("mode", m[0]).apply(); applyIfUp(); refresh(); });
|
||||
rg.addView(r);
|
||||
}
|
||||
split.addView(rg);
|
||||
android.widget.CheckBox ru = new android.widget.CheckBox(this);
|
||||
ru.setText("Российские сайты — напрямую, без VPN");
|
||||
ru.setTextColor(text());
|
||||
ru.setChecked(prefs.getBoolean("ru_direct", true));
|
||||
ru.setOnCheckedChangeListener((b, on) -> { prefs.edit().putBoolean("ru_direct", on).apply(); applyIfUp(); });
|
||||
split.addView(ru, mw(4));
|
||||
split.addView(tv("Канал до сервера", text(), 14, true), mw(8));
|
||||
RadioGroup tg = new RadioGroup(this);
|
||||
tg.setOrientation(RadioGroup.HORIZONTAL);
|
||||
String tmode = prefs.getString("transport", "auto");
|
||||
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}, {"cloud", "Облако"}}) {
|
||||
RadioButton r = new RadioButton(this);
|
||||
r.setText(m[1]);
|
||||
r.setTextColor(text());
|
||||
r.setId(View.generateViewId());
|
||||
r.setChecked(m[0].equals(tmode));
|
||||
r.setOnClickListener(v -> {
|
||||
prefs.edit().putString("transport", m[0]).apply();
|
||||
if ("cloud".equals(m[0])) wsDialog(); else applyIfUp();
|
||||
});
|
||||
tg.addView(r);
|
||||
}
|
||||
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;
|
||||
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
|
||||
split.addView(button("Свой DNS" + (myDns().isEmpty() ? "" : " (" + myDns() + ")"), 0xFF475569, v -> dnsDialog()), mw(8));
|
||||
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
|
||||
muted(), 12, false), mw(6));
|
||||
box.addView(split, mw(20));
|
||||
|
||||
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
|
||||
box.addView(button("Импорт из файла .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("Журнал", 0xFF475569, v -> showLog()), mw(8));
|
||||
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||
setContentView(root);
|
||||
refresh();
|
||||
ui.removeCallbacksAndMessages(null);
|
||||
ui.postDelayed(new Runnable() { public void run() { refresh(); ui.postDelayed(this, 2000); } }, 2000);
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
if (power == null) return;
|
||||
boolean up = isUp();
|
||||
power.setText(up ? "Отключить" : "Подключить");
|
||||
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));
|
||||
status.setText(up ? "🟢 VPN включён" + (transport.isEmpty() ? "" : " · " + transport) : "⚪ VPN выключен");
|
||||
if (up) {
|
||||
try {
|
||||
Statistics st = backend.getStatistics(TUNNEL);
|
||||
long rx = st.totalRx(), tx = st.totalTx(), now = System.currentTimeMillis();
|
||||
String speed = "";
|
||||
if (lastT > 0 && now > lastT) {
|
||||
double s = (now - lastT) / 1000.0;
|
||||
speed = String.format(" · ↓ %.1f / ↑ %.1f Мбит/с", (rx - lastRx) * 8 / s / 1e6, (tx - lastTx) * 8 / s / 1e6);
|
||||
}
|
||||
lastRx = rx; lastTx = tx; lastT = now;
|
||||
traffic.setText(String.format("Получено %.1f МБ · Отправлено %.1f МБ", rx / 1048576.0, tx / 1048576.0) + speed);
|
||||
} catch (Exception e) { traffic.setText(""); }
|
||||
} else { traffic.setText(""); lastT = 0; }
|
||||
}
|
||||
|
||||
private void applyIfUp() { if (isUp()) bg.execute(() -> { down(); up(); }); }
|
||||
|
||||
private void importDialog() {
|
||||
final EditText e = new EditText(this);
|
||||
e.setHint("phobos://…");
|
||||
ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
|
||||
if (cm != null && cm.getPrimaryClip() != null && cm.getPrimaryClip().getItemCount() > 0) {
|
||||
CharSequence c = cm.getPrimaryClip().getItemAt(0).getText();
|
||||
if (c != null && c.toString().trim().startsWith("phobos://")) e.setText(c);
|
||||
}
|
||||
new AlertDialog.Builder(this).setTitle("Ссылка из панели").setView(e)
|
||||
.setPositiveButton("Импорт", (d, w) -> { importLink(e.getText().toString()); applyIfUp(); render(); })
|
||||
.setNegativeButton("Отмена", null).show();
|
||||
}
|
||||
|
||||
private void 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() {
|
||||
PackageManager pm = getPackageManager();
|
||||
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
||||
List<ResolveInfo> ris = pm.queryIntentActivities(main, 0);
|
||||
List<String[]> apps = new ArrayList<>();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (ResolveInfo ri : ris) {
|
||||
String p = ri.activityInfo.packageName;
|
||||
if (p.equals(getPackageName()) || !seen.add(p)) continue;
|
||||
apps.add(new String[]{String.valueOf(ri.loadLabel(pm)), p});
|
||||
}
|
||||
Collections.sort(apps, (a, b) -> a[0].compareToIgnoreCase(b[0]));
|
||||
final Set<String> chosen = new LinkedHashSet<>(Arrays.asList(prefs.getString("apps", "").split(",")));
|
||||
chosen.remove("");
|
||||
String[] labels = new String[apps.size()];
|
||||
boolean[] checked = new boolean[apps.size()];
|
||||
for (int i = 0; i < apps.size(); i++) { labels[i] = apps.get(i)[0]; checked[i] = chosen.contains(apps.get(i)[1]); }
|
||||
new AlertDialog.Builder(this).setTitle("Приложения")
|
||||
.setMultiChoiceItems(labels, checked, (d, i, on) -> { if (on) chosen.add(apps.get(i)[1]); else chosen.remove(apps.get(i)[1]); })
|
||||
.setPositiveButton("Готово", (d, w) -> { prefs.edit().putString("apps", String.join(",", chosen)).apply(); applyIfUp(); render(); })
|
||||
.setNegativeButton("Отмена", null).show();
|
||||
}
|
||||
|
||||
/** Real speed through the tunnel: the browser is inside the VPN (unless excluded by the split). */
|
||||
private void speedtest() {
|
||||
if (!isUp()) { toast("Сначала подключитесь"); return; }
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://speed.cloudflare.com/")));
|
||||
}
|
||||
|
||||
private void log(String s) {
|
||||
try (FileOutputStream o = new FileOutputStream(new File(getFilesDir(), "app.log"), true)) {
|
||||
o.write((new java.util.Date() + " " + s + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
|
||||
private static String tail(File f, int max) {
|
||||
try {
|
||||
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.FileInputStream in = new java.io.FileInputStream(f)) {
|
||||
byte[] buf = new byte[8192];
|
||||
for (int n; (n = in.read(buf)) > 0; ) o.write(buf, 0, n);
|
||||
}
|
||||
String t = new String(o.toByteArray(), StandardCharsets.UTF_8);
|
||||
return t.length() > max ? t.substring(t.length() - max) : t;
|
||||
} catch (Exception e) { return ""; }
|
||||
}
|
||||
|
||||
private void showLog() {
|
||||
String t = "— приложение —\n" + tail(new File(getFilesDir(), "app.log"), 3000)
|
||||
+ "\n— обфускатор —\n" + tail(new File(getFilesDir(), "obf.log"), 3000)
|
||||
+ "\n— TCP-канал —\n" + tail(new File(getFilesDir(), "tcp.log"), 1500)
|
||||
+ "\nобфускатор запущен: " + (obfuscator != null && obfuscator.isAlive());
|
||||
TextView v = tv(t, text(), 12, false);
|
||||
v.setTextIsSelectable(true);
|
||||
v.setPadding(dp(16), dp(8), dp(16), dp(8));
|
||||
ScrollView sv = new ScrollView(this);
|
||||
sv.addView(v);
|
||||
new AlertDialog.Builder(this).setTitle("Журнал").setView(sv).setPositiveButton("OK", null).show();
|
||||
}
|
||||
|
||||
private void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_LONG).show(); }
|
||||
}
|
||||
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
android/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build/
|
||||
27
android/AndroidManifest.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.pca.phobos"
|
||||
android:versionCode="140"
|
||||
android:versionName="1.4.0">
|
||||
|
||||
<uses-sdk android:minSdkVersion="23" android:targetSdkVersion="35" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="PCA Phobos"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:allowBackup="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
4
android/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# PCA Phobos — админ-приложение (Android)
|
||||
|
||||
Список ваших панелей Phobos, каждая открывается в приложении (WebView), тема по системе,
|
||||
ссылки `phobos://` передаются в VPN-клиент. Сборка: `KS_PASS=… ./build.sh` → `build/PCA-Phobos.apk`.
|
||||
26
android/build.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build PCA Phobos Android app without Gradle: aapt2 -> javac -> d8 -> zipalign -> apksigner.
|
||||
# ANDROID_HOME=~/Library/Android/sdk KS=~/.android/pca-phobos.jks KS_PASS=… ./build.sh
|
||||
# Output: build/PCA-Phobos.apk. The keystore is NOT in git (keep the same one, or updates won't install).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
|
||||
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
|
||||
KS="${KS:-$HOME/.android/pca-phobos.jks}"
|
||||
KS_PASS="${KS_PASS:?set KS_PASS (keystore password)}"
|
||||
rm -rf build && mkdir -p build/res build/classes build/dex
|
||||
|
||||
"$BT/aapt2" compile --dir res -o build/res/
|
||||
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml \
|
||||
--java build/gen build/res/*.flat --min-sdk-version 23 --target-sdk-version 35
|
||||
javac -source 11 -target 11 -nowarn -classpath "$JAR" -d build/classes \
|
||||
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath" || true
|
||||
"$BT/d8" --release --lib "$JAR" --min-api 23 --output build/dex $(find build/classes -name '*.class')
|
||||
cp build/base.apk build/unsigned.apk
|
||||
(cd build/dex && zip -q ../unsigned.apk classes.dex)
|
||||
"$BT/zipalign" -f 4 build/unsigned.apk build/aligned.apk
|
||||
[ -f "$KS" ] || keytool -genkeypair -keystore "$KS" -storepass "$KS_PASS" -keypass "$KS_PASS" \
|
||||
-alias pca -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=PCA Phobos, O=PCAdministration" >/dev/null
|
||||
"$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$KS_PASS" --out build/PCA-Phobos.apk build/aligned.apk
|
||||
"$BT/apksigner" verify build/PCA-Phobos.apk && ls -la build/PCA-Phobos.apk
|
||||
12
android/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#1e1b4b" android:pathData="M0,0h108v108h-108z" />
|
||||
<path android:fillColor="#4f46e5"
|
||||
android:pathData="M54,18 L82,28 L82,52 C82,70 70,84 54,90 C38,84 26,70 26,52 L26,28 Z" />
|
||||
<path android:fillColor="#c7d2fe"
|
||||
android:pathData="M54,30 L72,36.5 L72,52 C72,64 64,73.5 54,78 C44,73.5 36,64 36,52 L36,36.5 Z" />
|
||||
<path android:fillColor="#1e1b4b"
|
||||
android:pathData="M47,46 h14 v5 h-4.5 v16 h-5 v-16 h-4.5 z" />
|
||||
</vector>
|
||||
10
android/res/values-night/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:colorPrimary">#a5b4fc</item>
|
||||
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||
<item name="android:colorAccent">#818cf8</item>
|
||||
<item name="android:windowBackground">#0f172a</item>
|
||||
<item name="android:statusBarColor">#1e1b4b</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
android/res/values/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:colorPrimary">#4338ca</item>
|
||||
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||
<item name="android:colorAccent">#4f46e5</item>
|
||||
<item name="android:windowBackground">#f1f5f9</item>
|
||||
<item name="android:statusBarColor">#1e1b4b</item>
|
||||
</style>
|
||||
</resources>
|
||||
367
android/src/com/pca/phobos/MainActivity.java
Normal file
@@ -0,0 +1,367 @@
|
||||
package com.pca.phobos;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* PCA Phobos — admin app: a list of your Phobos panels, each opens in a WebView.
|
||||
* phobos:// links are handed to PhobosWG, downloads go to the system browser.
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
private SharedPreferences prefs;
|
||||
private WebView web;
|
||||
private ProgressBar progress;
|
||||
private TextView title;
|
||||
private boolean dark;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
prefs = getSharedPreferences("phobos", MODE_PRIVATE);
|
||||
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)
|
||||
== Configuration.UI_MODE_NIGHT_YES;
|
||||
JSONArray list = panels();
|
||||
String last = prefs.getString("last", "");
|
||||
if (!last.isEmpty() && b == null) openPanel(last, nameFor(last));
|
||||
else if (list.length() == 0) showEditor(null, -1);
|
||||
else showList();
|
||||
}
|
||||
|
||||
// ── colors ────────────────────────────────────────────────────────────
|
||||
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
|
||||
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
|
||||
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
|
||||
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
|
||||
private static final int ACCENT = 0xFF4F46E5;
|
||||
private static final int HEADER = 0xFF1E1B4B;
|
||||
|
||||
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
|
||||
|
||||
private GradientDrawable round(int color, int radius) {
|
||||
GradientDrawable g = new GradientDrawable();
|
||||
g.setColor(color);
|
||||
g.setCornerRadius(dp(radius));
|
||||
return g;
|
||||
}
|
||||
|
||||
private Button button(String label, int color, View.OnClickListener l) {
|
||||
Button btn = new Button(this);
|
||||
btn.setText(label);
|
||||
btn.setAllCaps(false);
|
||||
btn.setTextColor(Color.WHITE);
|
||||
btn.setBackground(round(color, 10));
|
||||
btn.setOnClickListener(l);
|
||||
return btn;
|
||||
}
|
||||
|
||||
private LinearLayout header(String text, View right) {
|
||||
LinearLayout h = new LinearLayout(this);
|
||||
h.setBackgroundColor(HEADER);
|
||||
h.setGravity(Gravity.CENTER_VERTICAL);
|
||||
h.setPadding(dp(16), dp(12), dp(8), dp(12));
|
||||
TextView t = new TextView(this);
|
||||
t.setText("🛡 " + text);
|
||||
t.setTextColor(0xFFC7D2FE);
|
||||
t.setTextSize(18);
|
||||
t.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
t.setSingleLine(true);
|
||||
h.addView(t, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
|
||||
if (right != null) h.addView(right);
|
||||
title = t;
|
||||
return h;
|
||||
}
|
||||
|
||||
// ── storage ───────────────────────────────────────────────────────────
|
||||
private JSONArray panels() {
|
||||
try { return new JSONArray(prefs.getString("panels", "[]")); }
|
||||
catch (Exception e) { return new JSONArray(); }
|
||||
}
|
||||
|
||||
private void savePanels(JSONArray a) { prefs.edit().putString("panels", a.toString()).apply(); }
|
||||
|
||||
private String nameFor(String url) {
|
||||
JSONArray a = panels();
|
||||
for (int i = 0; i < a.length(); i++) {
|
||||
JSONObject o = a.optJSONObject(i);
|
||||
if (o != null && url.equals(o.optString("url"))) return o.optString("name", url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
static String normalize(String raw) {
|
||||
String u = raw.trim();
|
||||
if (u.isEmpty()) return "";
|
||||
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "http://" + u;
|
||||
while (u.endsWith("/")) u = u.substring(0, u.length() - 1);
|
||||
return u;
|
||||
}
|
||||
|
||||
// ── screens ───────────────────────────────────────────────────────────
|
||||
private void showList() {
|
||||
web = null;
|
||||
prefs.edit().remove("last").apply();
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(bg());
|
||||
root.addView(header("PCA Phobos", null));
|
||||
|
||||
ScrollView sv = new ScrollView(this);
|
||||
LinearLayout box = new LinearLayout(this);
|
||||
box.setOrientation(LinearLayout.VERTICAL);
|
||||
box.setPadding(dp(16), dp(16), dp(16), dp(16));
|
||||
sv.addView(box);
|
||||
|
||||
TextView hint = new TextView(this);
|
||||
hint.setText("Ваши серверы Phobos. Нажмите — откроется панель. Долгое нажатие — изменить.");
|
||||
hint.setTextColor(muted());
|
||||
hint.setPadding(0, 0, 0, dp(12));
|
||||
box.addView(hint);
|
||||
|
||||
final JSONArray a = panels();
|
||||
for (int i = 0; i < a.length(); i++) {
|
||||
final int idx = i;
|
||||
final JSONObject o = a.optJSONObject(i);
|
||||
if (o == null) continue;
|
||||
LinearLayout c = new LinearLayout(this);
|
||||
c.setOrientation(LinearLayout.VERTICAL);
|
||||
c.setBackground(round(card(), 12));
|
||||
c.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||
TextView n = new TextView(this);
|
||||
n.setText(o.optString("name"));
|
||||
n.setTextColor(text());
|
||||
n.setTextSize(17);
|
||||
n.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
TextView u = new TextView(this);
|
||||
u.setText(o.optString("url"));
|
||||
u.setTextColor(muted());
|
||||
c.addView(n);
|
||||
c.addView(u);
|
||||
c.setOnClickListener(v -> openPanel(o.optString("url"), o.optString("name")));
|
||||
c.setOnLongClickListener(v -> { showEditor(o, idx); return true; });
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp.bottomMargin = dp(10);
|
||||
box.addView(c, lp);
|
||||
}
|
||||
box.addView(button("+ Добавить сервер", ACCENT, v -> showEditor(null, -1)));
|
||||
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||
setContentView(root);
|
||||
}
|
||||
|
||||
private void showEditor(final JSONObject existing, final int idx) {
|
||||
web = null;
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(bg());
|
||||
root.addView(header(existing == null ? "Новый сервер" : "Изменить сервер", null));
|
||||
LinearLayout box = new LinearLayout(this);
|
||||
box.setOrientation(LinearLayout.VERTICAL);
|
||||
box.setPadding(dp(16), dp(16), dp(16), dp(16));
|
||||
|
||||
final EditText name = field("Название (например: Амстердам)", existing == null ? "" : existing.optString("name"));
|
||||
final EditText url = field("Адрес панели: http://IP:порт или https://домен", existing == null ? "" : existing.optString("url"));
|
||||
url.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
|
||||
box.addView(label("Название"));
|
||||
box.addView(name);
|
||||
box.addView(label("Адрес панели"));
|
||||
box.addView(url);
|
||||
TextView tip = new TextView(this);
|
||||
tip.setText("Адрес и порт панели печатает установщик в конце установки (или: cat /opt/phobos-panel/.port). "
|
||||
+ "Если задан домен в «Настройках» панели — используйте https://домен.");
|
||||
tip.setTextColor(muted());
|
||||
tip.setPadding(0, dp(4), 0, dp(16));
|
||||
box.addView(tip);
|
||||
|
||||
box.addView(button("Сохранить и открыть", ACCENT, v -> {
|
||||
String u = normalize(url.getText().toString());
|
||||
if (u.isEmpty()) { toast("Введите адрес панели"); return; }
|
||||
String n = name.getText().toString().trim();
|
||||
if (n.isEmpty()) n = Uri.parse(u).getHost();
|
||||
try {
|
||||
JSONArray a = panels();
|
||||
JSONObject o = new JSONObject().put("name", n).put("url", u);
|
||||
if (idx >= 0) a.put(idx, o); else a.put(o);
|
||||
savePanels(a);
|
||||
} catch (Exception ignored) { }
|
||||
openPanel(u, n);
|
||||
}));
|
||||
if (existing != null) {
|
||||
Button del = button("Удалить из списка", 0xFFDC2626, v -> {
|
||||
JSONArray a = panels(), out = new JSONArray();
|
||||
for (int i = 0; i < a.length(); i++) if (i != idx) out.put(a.opt(i));
|
||||
savePanels(out);
|
||||
showList();
|
||||
});
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp.topMargin = dp(10);
|
||||
box.addView(del, lp);
|
||||
}
|
||||
if (panels().length() > 0) {
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp.topMargin = dp(10);
|
||||
box.addView(button("Назад", 0xFF475569, v -> showList()), lp);
|
||||
}
|
||||
root.addView(box);
|
||||
setContentView(root);
|
||||
}
|
||||
|
||||
private TextView label(String s) {
|
||||
TextView t = new TextView(this);
|
||||
t.setText(s);
|
||||
t.setTextColor(muted());
|
||||
t.setPadding(0, dp(8), 0, dp(4));
|
||||
return t;
|
||||
}
|
||||
|
||||
private EditText field(String hint, String value) {
|
||||
EditText e = new EditText(this);
|
||||
e.setHint(hint);
|
||||
e.setText(value);
|
||||
e.setSingleLine(true);
|
||||
e.setTextColor(text());
|
||||
e.setHintTextColor(muted());
|
||||
e.setBackground(round(card(), 10));
|
||||
e.setPadding(dp(12), dp(12), dp(12), dp(12));
|
||||
return e;
|
||||
}
|
||||
|
||||
private void openPanel(final String url, String name) {
|
||||
prefs.edit().putString("last", url).apply();
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(bg());
|
||||
|
||||
LinearLayout actions = new LinearLayout(this);
|
||||
TextView reload = headerAction("⟳", v -> { if (web != null) web.reload(); });
|
||||
TextView servers = headerAction("☰", v -> showList());
|
||||
actions.addView(reload);
|
||||
actions.addView(servers);
|
||||
root.addView(header(name, actions));
|
||||
|
||||
progress = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal);
|
||||
progress.setMax(100);
|
||||
root.addView(progress, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3)));
|
||||
|
||||
web = new WebView(this);
|
||||
WebSettings s = web.getSettings();
|
||||
s.setJavaScriptEnabled(true);
|
||||
s.setDomStorageEnabled(true);
|
||||
s.setLoadWithOverviewMode(true);
|
||||
s.setUseWideViewPort(true);
|
||||
s.setBuiltInZoomControls(true);
|
||||
s.setDisplayZoomControls(false);
|
||||
s.setUserAgentString(s.getUserAgentString() + " PCAPhobosApp/1.4.0");
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
web.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest req) {
|
||||
Uri u = req.getUrl();
|
||||
String scheme = u.getScheme() == null ? "" : u.getScheme();
|
||||
if (scheme.equals("http") || scheme.equals("https")) {
|
||||
Uri base = Uri.parse(url);
|
||||
// other sites (GitHub, Boosty, Telegram…) -> system browser / apps
|
||||
if (u.getHost() != null && !u.getHost().equals(base.getHost())) { external(u); return true; }
|
||||
return false;
|
||||
}
|
||||
external(u); // phobos:// -> PhobosWG, tg:// -> Telegram
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String u) {
|
||||
progress.setVisibility(View.GONE);
|
||||
CookieManager.getInstance().flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, int code, String desc, String failing) {
|
||||
if (failing != null && failing.startsWith(url)) {
|
||||
view.loadDataWithBaseURL(null, errorPage(url, desc), "text/html", "utf-8", null);
|
||||
}
|
||||
}
|
||||
});
|
||||
web.setWebChromeClient(new WebChromeClient() {
|
||||
@Override
|
||||
public void onProgressChanged(WebView view, int p) {
|
||||
progress.setVisibility(p < 100 ? View.VISIBLE : View.GONE);
|
||||
progress.setProgress(p);
|
||||
}
|
||||
});
|
||||
web.setDownloadListener((dlUrl, ua, cd, mime, len) -> external(Uri.parse(dlUrl)));
|
||||
root.addView(web, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||
setContentView(root);
|
||||
web.loadUrl(url + "/");
|
||||
}
|
||||
|
||||
private TextView headerAction(String s, View.OnClickListener l) {
|
||||
TextView t = new TextView(this);
|
||||
t.setText(s);
|
||||
t.setTextColor(0xFFC7D2FE);
|
||||
t.setTextSize(22);
|
||||
t.setPadding(dp(14), 0, dp(14), 0);
|
||||
t.setOnClickListener(l);
|
||||
return t;
|
||||
}
|
||||
|
||||
private String errorPage(String url, String desc) {
|
||||
String bgc = dark ? "#0f172a" : "#f1f5f9", fg = dark ? "#e2e8f0" : "#0f172a";
|
||||
return "<html><body style='font-family:sans-serif;background:" + bgc + ";color:" + fg
|
||||
+ ";padding:24px;text-align:center'><h2>Панель недоступна</h2><p>" + url + "</p><p style='color:#64748b'>"
|
||||
+ desc + "</p><p>Проверьте адрес и порт, интернет и что сервер включён. Кнопка ⟳ — повторить.</p></body></html>";
|
||||
}
|
||||
|
||||
private void external(Uri u) {
|
||||
try {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, u));
|
||||
} catch (ActivityNotFoundException e) {
|
||||
if ("phobos".equals(u.getScheme())) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Нужно приложение PhobosWG")
|
||||
.setMessage("Ссылки phobos:// открывает приложение PhobosWG. Скачайте его на странице Android-клиента в панели (кнопка «Скачать приложение PhobosWG»).")
|
||||
.setPositiveButton("OK", null).show();
|
||||
} else {
|
||||
toast("Нет приложения для " + u.getScheme());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_SHORT).show(); }
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (web != null && web.canGoBack()) { web.goBack(); return; }
|
||||
if (web != null && panels().length() > 0) { showList(); return; }
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,49 @@
|
||||
#!/bin/sh
|
||||
# ============================================================
|
||||
# Phobos Health Monitor — Auto-heal + Failover for routers
|
||||
# Runs via cron every 60 seconds on Keenetic/Entware
|
||||
#
|
||||
# Install: crontab -e → */1 * * * * /opt/etc/Phobos/phobos-health.sh
|
||||
# Phobos Health Monitor v2 — Keenetic Edition
|
||||
# Uses ndmc/RCI instead of wg-tools. Connectivity-based failover.
|
||||
# Runs via cron every 60 seconds
|
||||
# ============================================================
|
||||
|
||||
PHOBOS_DIR="/opt/etc/Phobos"
|
||||
CONF="$PHOBOS_DIR/failover.conf"
|
||||
STATE="$PHOBOS_DIR/state"
|
||||
LOG="$PHOBOS_DIR/health.log"
|
||||
WG_CONF="$PHOBOS_DIR/wg0.conf"
|
||||
# 02.11.2026: несколько учёток на одном роутере. Файлы учётки (failover.conf, state,
|
||||
# client_id, panel_*) — в PHOBOS_ACC (доп. учётки: $PHOBOS_DIR/acc/<клиент>),
|
||||
# главная — прямо в $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"
|
||||
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
|
||||
HANDSHAKE_WARN=180
|
||||
HANDSHAKE_RESTART_OBF=300
|
||||
HANDSHAKE_RESTART_WG=600
|
||||
HANDSHAKE_FAILOVER=900
|
||||
# Thresholds (seconds)
|
||||
HANDSHAKE_WARN=150
|
||||
HANDSHAKE_PORT_HOP=300
|
||||
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() {
|
||||
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
|
||||
# Trim log
|
||||
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
|
||||
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
||||
fi
|
||||
@@ -38,33 +59,59 @@ fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
trap 'rm -f "$LOCKFILE"' EXIT
|
||||
|
||||
# Create state dir
|
||||
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 ──
|
||||
if [ ! -f "$CONF" ]; then
|
||||
log "ERROR: no failover.conf"
|
||||
exit 1
|
||||
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
|
||||
idx=1
|
||||
while true; do
|
||||
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||
if [ -z "$val" ]; then
|
||||
break
|
||||
fi
|
||||
[ -z "$val" ] && break
|
||||
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
|
||||
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}_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
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
@@ -74,22 +121,57 @@ if [ "$SERVER_COUNT" -eq 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Get current handshake age ──
|
||||
get_handshake_age() {
|
||||
hs=$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}')
|
||||
if [ -z "$hs" ] || [ "$hs" = "0" ]; then
|
||||
echo "9999"
|
||||
return
|
||||
fi
|
||||
now=$(date +%s)
|
||||
age=$((now - hs))
|
||||
echo "$age"
|
||||
# Read 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")
|
||||
FAIL_COUNT=$(cat "$STATE/fail_count" 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 ──
|
||||
# Нет своего интерфейса — выходим: запасной 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() {
|
||||
server_idx=$1
|
||||
port_idx=$2
|
||||
server_idx=$1; port_idx=$2
|
||||
eval "ports=\$SERVER_${server_idx}_PORTS"
|
||||
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
|
||||
}
|
||||
@@ -100,6 +182,82 @@ get_port_count() {
|
||||
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_endpoint() {
|
||||
server_idx=$1
|
||||
@@ -107,6 +265,7 @@ switch_endpoint() {
|
||||
|
||||
eval "host=\$SERVER_${server_idx}_HOST"
|
||||
eval "obf_key=\$SERVER_${server_idx}_KEY"
|
||||
eval "wg_key=\$SERVER_${server_idx}_WGKEY"
|
||||
port=$(get_port "$server_idx" "$port_idx")
|
||||
|
||||
if [ -z "$host" ] || [ -z "$port" ]; then
|
||||
@@ -115,8 +274,12 @@ switch_endpoint() {
|
||||
fi
|
||||
|
||||
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
|
||||
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
|
||||
if [ -n "$obf_key" ]; then
|
||||
@@ -124,25 +287,59 @@ switch_endpoint() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restart obfuscator
|
||||
killall wg-obfuscator 2>/dev/null
|
||||
sleep 1
|
||||
wg-obfuscator --config "$OBF_CONF" &
|
||||
# 3. Restart obfuscator
|
||||
restart_obf
|
||||
|
||||
# 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 "$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() {
|
||||
port_count=$(get_port_count "$CURRENT_SERVER")
|
||||
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
|
||||
|
||||
if [ "$next_idx" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
[ "$next_idx" -eq 0 ] && return 1
|
||||
|
||||
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
|
||||
switch_endpoint "$CURRENT_SERVER" "$next_idx"
|
||||
@@ -155,93 +352,233 @@ try_next_server() {
|
||||
if [ "$next" -gt "$SERVER_COUNT" ]; then
|
||||
next=1
|
||||
fi
|
||||
|
||||
if [ "$next" -eq "$CURRENT_SERVER" ]; then
|
||||
log "WARN: only one server, can't failover"
|
||||
return 1
|
||||
fi
|
||||
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
|
||||
|
||||
log "FAILOVER → server $next"
|
||||
switch_endpoint "$next" "0"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Check if primary is back (every 5 min when on secondary) ──
|
||||
# ── Check if primary is back ──
|
||||
check_primary() {
|
||||
if [ "$CURRENT_SERVER" -eq 1 ]; then
|
||||
return
|
||||
fi
|
||||
[ "$CURRENT_SERVER" -eq 1 ] && [ "$TCP_MODE" = "0" ] && return
|
||||
|
||||
now=$(date +%s)
|
||||
elapsed=$((now - PRIMARY_CHECK_TS))
|
||||
if [ "$elapsed" -lt 300 ]; then
|
||||
return
|
||||
fi
|
||||
[ "$elapsed" -lt "$PRIMARY_CHECK_INTERVAL" ] && return
|
||||
echo "$now" > "$STATE/primary_check_ts"
|
||||
|
||||
eval "host=\$SERVER_1_HOST"
|
||||
port=$(get_port 1 0)
|
||||
|
||||
# Quick UDP probe — send a byte, see if port responds
|
||||
if ping -c 1 -W 2 "$host" >/dev/null 2>&1; then
|
||||
log "PRIMARY alive, switching back"
|
||||
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
|
||||
# obfuscator path is actually up. Anything else (000 no-response, 503
|
||||
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
|
||||
ok_url=""
|
||||
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
|
||||
echo "0" > "$STATE/fail_count"
|
||||
else
|
||||
log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main logic ──
|
||||
AGE=$(get_handshake_age)
|
||||
# ── LAN routing self-heal ──────────────────────
|
||||
# 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
|
||||
# All good, reset counters
|
||||
if [ "$RESTART_COUNT" -gt 0 ]; then
|
||||
log "OK: connection restored (age=${AGE}s)"
|
||||
echo "0" > "$STATE/restart_count"
|
||||
# 1) WG interface must be public
|
||||
sl=$(echo "$rc" | awk -v ifc="interface $WG_IF" '$0~ifc{f=1} f&&/security-level/{print $2; exit}')
|
||||
if [ -n "$sl" ] && [ "$sl" != "public" ]; then
|
||||
ndmc -c "interface $WG_IF security-level public" >/dev/null 2>&1
|
||||
ndmc -c "system configuration save" >/dev/null 2>&1
|
||||
log "HEAL: $WG_IF security-level -> public"
|
||||
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
|
||||
exit 0
|
||||
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
|
||||
# Restart obfuscator only
|
||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
||||
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
||||
log "ACTION: restart obfuscator (attempt $RESTART_COUNT)"
|
||||
killall wg-obfuscator 2>/dev/null
|
||||
sleep 1
|
||||
wg-obfuscator --config "$OBF_CONF" &
|
||||
# Stage 1 (fail 1): Restart obfuscator
|
||||
if [ "$FAIL_COUNT" -eq 1 ]; then
|
||||
log "ACTION: restart obfuscator"
|
||||
restart_obf
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$AGE" -lt "$HANDSHAKE_RESTART_WG" ]; then
|
||||
# Restart WireGuard + obfuscator
|
||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
||||
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
||||
log "ACTION: restart WG + obfuscator (attempt $RESTART_COUNT)"
|
||||
wg-quick down wg0 2>/dev/null
|
||||
killall wg-obfuscator 2>/dev/null
|
||||
sleep 2
|
||||
wg-quick up wg0 2>/dev/null
|
||||
sleep 1
|
||||
wg-obfuscator --config "$OBF_CONF" &
|
||||
exit 0
|
||||
# TCP mode went stale → back to the UDP round (next server)
|
||||
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
|
||||
|
||||
if [ "$AGE" -lt "$HANDSHAKE_FAILOVER" ]; then
|
||||
# Try next port
|
||||
log "ACTION: try port hop"
|
||||
if ! try_next_port; then
|
||||
log "ACTION: all ports exhausted, try next server"
|
||||
try_next_server
|
||||
# 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
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
log "ports of server $CURRENT_SERVER exhausted, escalate to failover"
|
||||
fi
|
||||
|
||||
# Full failover
|
||||
log "ACTION: failover (handshake ${AGE}s)"
|
||||
if ! try_next_server; then
|
||||
# Only one server, cycle ports
|
||||
try_next_port
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
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.
|
||||
109
docs/PORTS.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# PCA Phobos ports and firewall checklist
|
||||
|
||||
This page is the short truth table for normal Phobos work. Use it before
|
||||
opening a ticket about "router does not switch", "secondary is online but sync
|
||||
fails", or "panel is too exposed".
|
||||
|
||||
## Primary server
|
||||
|
||||
| Port | Protocol | Who connects | Required | Why |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `PANEL_PORT` | TCP | admin browser, secondary setup/register, router pull fallback | yes | Web panel and `/api/*`. Real value: `cat /opt/phobos-panel/.port`. |
|
||||
| `80` | TCP | routers during install, Android APK download | recommended | nginx serves `/init/`, `/packages/`, `/app/`. |
|
||||
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. Real value: `grep OBFUSCATOR_PORTS /opt/Phobos/server/server.env`. |
|
||||
| `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. |
|
||||
| `22` | TCP | admin only | optional | SSH maintenance. Do not expose broadly. |
|
||||
|
||||
`PANEL_PORT` is random on clean install unless passed as env:
|
||||
|
||||
```bash
|
||||
cat /opt/phobos-panel/.port
|
||||
```
|
||||
|
||||
## Secondary server
|
||||
|
||||
| Port | Protocol | Who connects | Required | Why |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `8444` | TCP | primary panel, routers via tunnel/public path | yes | Mini-API: health, peer sync, router config cache. |
|
||||
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. |
|
||||
| `51820` | UDP | nobody public | no | WireGuard listens locally behind obfuscator; public access is blocked. |
|
||||
| `22` | TCP | admin during install | optional | SSH maintenance. |
|
||||
|
||||
If secondary is "Online" but `Info`, `Sync`, or client assignment fails, check
|
||||
`8444/tcp` first and confirm the API key matches the main panel key.
|
||||
|
||||
## Router outbound access
|
||||
|
||||
Routers need outbound access to:
|
||||
|
||||
- every server UDP obfuscator port, default `2083,5443,993`;
|
||||
- primary `PANEL_PORT/tcp` for initial install and public pull fallback;
|
||||
- primary `80/tcp` if using the nginx install fallback;
|
||||
- tunnel management paths after VPN is up:
|
||||
- `http://10.25.0.1:8444` for secondary mini-API;
|
||||
- `http(s)://10.25.0.1:<PANEL_PORT>` for primary panel API.
|
||||
|
||||
Current router packages write these files on the router:
|
||||
|
||||
```bash
|
||||
/opt/etc/Phobos/panel_url
|
||||
/opt/etc/Phobos/panel_urls
|
||||
/opt/etc/Phobos/panel_port
|
||||
/opt/etc/Phobos/panel_scheme
|
||||
```
|
||||
|
||||
If a router does not switch after changing its server in the panel, check:
|
||||
|
||||
```bash
|
||||
cat /opt/etc/Phobos/panel_urls
|
||||
cat /opt/etc/Phobos/failover.conf
|
||||
tail -n 80 /opt/etc/Phobos/health.log
|
||||
/opt/etc/Phobos/phobos-pull.sh
|
||||
```
|
||||
|
||||
If `panel_urls` contains an old IP or old panel port, create a fresh install
|
||||
command in the panel and reinstall/update that router package.
|
||||
|
||||
## HTTPS panel
|
||||
|
||||
Native HTTPS is supported by the primary installer:
|
||||
|
||||
```bash
|
||||
PANEL_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem \
|
||||
PANEL_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem \
|
||||
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
Then the panel runs as:
|
||||
|
||||
```text
|
||||
https://<server-ip>:<PANEL_PORT>
|
||||
```
|
||||
|
||||
The generated router and secondary commands use `curl -k`/`wget
|
||||
--no-check-certificate` fallback so a self-signed certificate can work for
|
||||
bootstrap. For humans in a browser, use a real certificate to avoid warnings.
|
||||
|
||||
## Can I IP-whitelist the panel port?
|
||||
|
||||
Yes, but with care. `PANEL_PORT` is not only the admin UI. It also carries
|
||||
server registration and router pull API. If you whitelist it too tightly, these
|
||||
features break:
|
||||
|
||||
- secondary auto-register;
|
||||
- `Info` / `Sync` / client assignment to secondary;
|
||||
- router pull fallback before tunnel is up;
|
||||
- router config update when public path is the only available path.
|
||||
|
||||
Minimum allowlist for `PANEL_PORT`:
|
||||
|
||||
- your admin IPs;
|
||||
- all secondary server public IPs;
|
||||
- router public IPs, if they are static;
|
||||
- or no router IP restriction until first install is complete and tunnel pull is proven.
|
||||
|
||||
For CGNAT/dynamic-router fleets, do not rely on a strict public-IP allowlist for
|
||||
router management. Use strong panel password, API key secrecy, optional HTTPS,
|
||||
and keep only the required ports open.
|
||||
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).
|
||||
160
docs/WARP_DEV_NOTES.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# WARP in `dev` — modes, checks, current limits
|
||||
|
||||
Date: `2026-06-06`
|
||||
Channel: `dev`
|
||||
Version: `v1.3.1-dev`
|
||||
|
||||
## What was added
|
||||
|
||||
The `dev` panel now has a dedicated `WARP / WARP+` block in `/settings`.
|
||||
|
||||
Supported modes:
|
||||
|
||||
- `egress` — route client outbound traffic through WARP on the server side;
|
||||
- `relay` — expose a UDP relay for a separate WARP/WireGuard client profile;
|
||||
- `custom profile` — save a full custom `warp.conf` from the web UI;
|
||||
- `WARP+ key` — save a license key and rebuild the profile through `wgcf`.
|
||||
|
||||
The UI also has a `?` help block with plain-language explanations:
|
||||
|
||||
- what each mode does;
|
||||
- when to use `free` vs `custom`;
|
||||
- what `WARP+ key` changes;
|
||||
- what `Enable` and `Disable` do;
|
||||
- how per-client WARP works from the client list.
|
||||
|
||||
## How the modes work
|
||||
|
||||
### `egress`
|
||||
|
||||
Use this when you want Phobos clients to keep connecting the usual way, but send
|
||||
their outgoing traffic through Cloudflare WARP after they land on the server.
|
||||
|
||||
Implementation:
|
||||
|
||||
- WireGuard clients still connect to the Phobos server;
|
||||
- server-side policy routing sends only selected client tunnel IPs through `warp`;
|
||||
- NAT is done with `POSTROUTING MASQUERADE`;
|
||||
- client rows in `/clients` have `WARP ON/OFF`, which adds/removes `ip rule from <client-ip>/32`.
|
||||
|
||||
### `relay`
|
||||
|
||||
Use this when you need a separate relay endpoint for another WARP/WireGuard
|
||||
client profile.
|
||||
|
||||
Important:
|
||||
|
||||
- this is **not** a drop-in replacement for the normal Phobos router config;
|
||||
- the client must use a separate WARP/WireGuard profile aimed at the relay port;
|
||||
- this mode is for relay-style scenarios, not for replacing the regular
|
||||
`home.conf` path one-to-one.
|
||||
|
||||
Implementation:
|
||||
|
||||
- UDP relay rules are added with tagged iptables entries;
|
||||
- incoming UDP on the selected port is DNAT'ed to the target WARP endpoint;
|
||||
- outgoing packets are MASQUERADE'd back through the relay host.
|
||||
|
||||
### `custom profile` and `WARP+`
|
||||
|
||||
Two ways to use a non-default profile:
|
||||
|
||||
- paste a full custom `warp.conf`;
|
||||
- save a `WARP+ key` and let `wgcf` rebuild the profile from it.
|
||||
|
||||
This keeps the UI simple:
|
||||
|
||||
- `free WARP` still works out of the box;
|
||||
- `WARP+` is possible when the owner has a real key;
|
||||
- no hidden magic is required outside the panel.
|
||||
|
||||
## Live checks completed
|
||||
|
||||
### Server-side checks
|
||||
|
||||
Passed:
|
||||
|
||||
- `python3 -m py_compile app.py server/api.py`
|
||||
- `bash -n install.sh update.sh server/phobos-warp.sh`
|
||||
- live `status` checks on dev server `64.188.81.115`
|
||||
|
||||
Verified on the dev server:
|
||||
|
||||
- `egress` mode enables cleanly;
|
||||
- current egress IPv4 is reported in status JSON;
|
||||
- `custom profile` and `WARP+ key` save from the web UI;
|
||||
- relay rules appear with separate tagged iptables entries.
|
||||
|
||||
### Real router check
|
||||
|
||||
Router used for testing:
|
||||
|
||||
- public SSH: `95.72.5.253`
|
||||
- web: `https://homesmart.netcraze.pro/dashboard`
|
||||
|
||||
Confirmed:
|
||||
|
||||
- real router traffic was sent through the dev server;
|
||||
- with `egress` enabled, a live `curl https://api.ipify.org` from the router
|
||||
returned WARP egress IP `104.28.193.219`;
|
||||
- this proves the real data path for `router -> Phobos -> WARP -> internet`.
|
||||
|
||||
### Relay check
|
||||
|
||||
Confirmed:
|
||||
|
||||
- relay mode enables from the server side;
|
||||
- relay iptables rules are created;
|
||||
- counters increase on live traffic;
|
||||
- a separate WARP profile pointed at the relay port completes handshake.
|
||||
|
||||
Not fully closed yet:
|
||||
|
||||
- relay handshake and packet flow were confirmed;
|
||||
- a full end-to-end "internet works through relay profile" check on the router
|
||||
was not fully finished in a clean state before router access became limited.
|
||||
|
||||
## Router recovery note
|
||||
|
||||
During live tests, WAN SSH access to the router stopped responding, so full file
|
||||
restore over shell was blocked.
|
||||
|
||||
What was still restored safely:
|
||||
|
||||
- temporary KeenDNS helper proxies created for testing were removed;
|
||||
- temporary extra WireGuard peer entries were cleaned up;
|
||||
- the router is currently left on a **working direct dev WireGuard peer** so it
|
||||
stays reachable through the test tunnel rather than returning to a broken
|
||||
down state.
|
||||
|
||||
What still needs one final shell pass later:
|
||||
|
||||
- restore `/opt/etc/Phobos/home.conf` from backup;
|
||||
- restore `/opt/etc/Phobos/wg-obfuscator.conf` from backup;
|
||||
- restore `/opt/etc/Phobos/failover.conf` from backup;
|
||||
- restore minute cron jobs for `phobos-health.sh` and `phobos-pull.sh`.
|
||||
|
||||
Known backup path on the router:
|
||||
|
||||
- `/opt/etc/Phobos/test-backup-20260606-044834`
|
||||
|
||||
## Current honest status
|
||||
|
||||
Ready now:
|
||||
|
||||
- `egress` mode
|
||||
- per-client `WARP ON/OFF`
|
||||
- `custom profile`
|
||||
- `WARP+ key`
|
||||
- UI help text
|
||||
|
||||
Partially verified:
|
||||
|
||||
- `relay` mode
|
||||
|
||||
Extra audit fixes after live testing:
|
||||
|
||||
- orphan WireGuard peers now show up in both `Sessions` and `Clients` as `orphan-*` instead of disappearing into `unknown`;
|
||||
- orphan rows can be `Kick`ed, `Purge`d, and also toggled through per-peer WARP;
|
||||
- client package build no longer fails when `/opt/Phobos/bin` is missing or `OBFUSCATOR_KEY` is empty;
|
||||
- update / restore scripts now restart the active panel service automatically (`phobos-panel` or `phobos-dev-test`).
|
||||
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-пакет.
|
||||
BIN
docs/img/android-admin.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
docs/img/android-vpn-apps.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/img/android-vpn-import.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/img/android-vpn.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/img/clients-dark.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
docs/img/clients.png
Normal file
|
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 |
BIN
docs/img/mobile-clients-dark.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
docs/img/mobile-clients.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/img/mobile-servers-dark.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
docs/img/mobile-servers.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/img/servers-dark.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
docs/img/servers.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
docs/img/sessions-dark.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/img/sessions.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
docs/img/settings-dark.png
Normal file
|
After Width: | Height: | Size: 589 KiB |
BIN
docs/img/settings.png
Normal file
|
After Width: | Height: | Size: 608 KiB |
179
install.sh
@@ -3,7 +3,7 @@
|
||||
# PCA Phobos — TURNKEY installer (primary / panel node)
|
||||
#
|
||||
# One command, all dependencies, from a clean VPS:
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
||||
# bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
|
||||
#
|
||||
# Installs, in order:
|
||||
# deps -> wg-obfuscator (Ground-Zerro) -> WireGuard wg0 ->
|
||||
@@ -13,32 +13,100 @@
|
||||
#
|
||||
# Env (all optional):
|
||||
# PANEL_PORT random 10000-59999 web panel port
|
||||
# PANEL_TLS_CERT / PANEL_TLS_KEY enable native HTTPS for panel
|
||||
# PANEL_PASS OcAdmin2026! panel admin password
|
||||
# API_KEY random shared key (agents + router pull token)
|
||||
# OBF_PORTS 2083,5443,993 obfuscator listen ports
|
||||
# TG_TOKEN / TG_CHAT Telegram alerts
|
||||
# PCA_BRANCH main branch to pull PCA files from
|
||||
# SKIP_NGINX 0 1 = do not touch an existing nginx
|
||||
# ============================================================
|
||||
set -e
|
||||
|
||||
PANEL_PASS="${PANEL_PASS:-OcAdmin2026!}"
|
||||
PANEL_TLS_CERT="${PANEL_TLS_CERT:-}"
|
||||
PANEL_TLS_KEY="${PANEL_TLS_KEY:-}"
|
||||
PANEL_SCHEME="${PANEL_SCHEME:-http}"
|
||||
TG_TOKEN="${TG_TOKEN:-}"
|
||||
TG_CHAT="${TG_CHAT:-}"
|
||||
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
||||
PCA_BRANCH="${PCA_BRANCH:-main}"
|
||||
GH_TOKEN="${GH_TOKEN:-}" # set for private-repo installs; empty for public
|
||||
# Уже есть 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="$(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 — запасной.
|
||||
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
|
||||
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
||||
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
|
||||
CHANNEL="${CHANNEL:-stable}"
|
||||
case "$CHANNEL" in
|
||||
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
|
||||
*) PCA_BRANCH="${PCA_BRANCH:-$CHANNEL}";;
|
||||
esac
|
||||
case "$PCA_BRANCH" in v[0-9]*) _kind=tag;; *) _kind=branch;; esac
|
||||
PHOBOS_DIR="/opt/Phobos"
|
||||
PANEL_DIR="/opt/phobos-panel"
|
||||
RAW="https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}"
|
||||
RAW="$PCA_GIT/raw/$_kind/$PCA_BRANCH"
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
[ "$EUID" -eq 0 ] || { echo "Run as root"; exit 1; }
|
||||
|
||||
if [ -z "$PANEL_PORT" ]; then
|
||||
PANEL_PORT=$(shuf -i 10000-59999 -n 1 2>/dev/null || awk 'BEGIN{srand(); print int(10000+rand()*50000)}')
|
||||
fi
|
||||
if [ -n "$PANEL_TLS_CERT" ] || [ -n "$PANEL_TLS_KEY" ]; then
|
||||
[ -f "$PANEL_TLS_CERT" ] || { echo "ERROR: PANEL_TLS_CERT not found: $PANEL_TLS_CERT"; exit 1; }
|
||||
[ -f "$PANEL_TLS_KEY" ] || { echo "ERROR: PANEL_TLS_KEY not found: $PANEL_TLS_KEY"; exit 1; }
|
||||
PANEL_SCHEME="https"
|
||||
fi
|
||||
case "$PANEL_SCHEME" in http|https) ;; *) echo "ERROR: PANEL_SCHEME must be http or https"; exit 1;; esac
|
||||
API_KEY="${API_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24)}"
|
||||
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||
import ipaddress, sys
|
||||
net = ipaddress.ip_network(sys.argv[1], strict=False)
|
||||
print(next(net.hosts()))
|
||||
PY
|
||||
)"
|
||||
WG_PREFIXLEN="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||
import ipaddress, sys
|
||||
print(ipaddress.ip_network(sys.argv[1], strict=False).prefixlen)
|
||||
PY
|
||||
)"
|
||||
|
||||
SERVER_IP=$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}')
|
||||
# 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="${IFACE:-eth0}"
|
||||
ARCH=$(uname -m)
|
||||
@@ -53,24 +121,52 @@ echo "============================================"
|
||||
echo "[1/9] dependencies..."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq wireguard wireguard-tools iptables jq curl git \
|
||||
python3 python3-flask gunicorn nginx cron python3-qrcode >/dev/null
|
||||
apt-get install -y -qq wireguard wireguard-tools iptables jq curl git ca-certificates \
|
||||
python3 python3-flask python3-pip gunicorn nginx cron python3-qrcode sshpass >/dev/null
|
||||
apt-get install -y -qq python3-flask-sock >/dev/null 2>&1 || true
|
||||
apt-get install -y -qq python3-asyncssh >/dev/null 2>&1 || true
|
||||
apt-get install -y -qq python3-websocket >/dev/null 2>&1 || true
|
||||
python3 - <<'PY' >/dev/null 2>&1 || python3 -m pip install flask-sock asyncssh websocket-client >/dev/null 2>&1 || python3 -m pip install --break-system-packages flask-sock asyncssh websocket-client >/dev/null
|
||||
import flask_sock, asyncssh, websocket
|
||||
PY
|
||||
mkdir -p "$PANEL_DIR/static"
|
||||
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css -o "$PANEL_DIR/static/xterm.min.css" 2>/dev/null || true
|
||||
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js -o "$PANEL_DIR/static/xterm.min.js" 2>/dev/null || true
|
||||
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js -o "$PANEL_DIR/static/xterm-addon-fit.min.js" 2>/dev/null || true
|
||||
systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true
|
||||
|
||||
# ── 2. wg-obfuscator binary (Ground-Zerro) ──
|
||||
echo "[2/9] wg-obfuscator..."
|
||||
mkdir -p "$PHOBOS_DIR"/{server,clients,bin,tokens,www/init,www/packages,packages}
|
||||
OBF_ARCH="$ARCH"
|
||||
[ "$OBF_ARCH" = "amd64" ] && OBF_ARCH="x86_64"
|
||||
[ "$OBF_ARCH" = "arm64" ] && OBF_ARCH="aarch64"
|
||||
if [ ! -x /usr/local/bin/wg-obfuscator ]; then
|
||||
R=/tmp/phobos-obf; rm -rf "$R"; mkdir -p "$R"; cd "$R"
|
||||
git init -q; git remote add origin https://github.com/Ground-Zerro/Phobos.git
|
||||
git config core.sparseCheckout true; echo "wg-obfuscator" > .git/info/sparse-checkout
|
||||
git pull origin main -q
|
||||
cp -f "wg-obfuscator/bin/wg-obfuscator-${ARCH}" "$PHOBOS_DIR/bin/" 2>/dev/null || true
|
||||
cp -f "wg-obfuscator/bin/"wg-obfuscator-* "$PHOBOS_DIR/bin/" 2>/dev/null || true
|
||||
chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-* 2>/dev/null || true
|
||||
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${ARCH}" /usr/local/bin/wg-obfuscator
|
||||
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${OBF_ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
|
||||
cd /; rm -rf "$R"
|
||||
fi
|
||||
_OBF_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/')
|
||||
_CB="https://github.com/ClusterM/wg-obfuscator/releases/download/${_OBF_TAG}"
|
||||
for _am in "x86_64:linux-x64" "aarch64:linux-arm64" "mipsel:linux-mipsel-mips32" "mips:linux-mips-mips32" "armv7:linux-armv7-hf"; do
|
||||
_da="${_am%%:*}"; _ss="${_am##*:}"
|
||||
if [ ! -f "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" ] && [ -n "$_OBF_TAG" ]; then
|
||||
_t=$(mktemp -d)
|
||||
curl -fsSL -m30 "${_CB}/wg-obfuscator-${_OBF_TAG}-${_ss}.tar.gz" -o "$_t/o.tgz" 2>/dev/null &&
|
||||
tar -xzf "$_t/o.tgz" -C "$_t" 2>/dev/null &&
|
||||
_b=$(find "$_t" -name "wg-obfuscator" -type f | head -1) &&
|
||||
[ -n "$_b" ] && cp "$_b" "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" && chmod +x "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" && echo " +wg-obfuscator-${_da} (ClusterM ${_OBF_TAG})"
|
||||
rm -rf "$_t"
|
||||
fi
|
||||
done
|
||||
[ -x /usr/local/bin/wg-obfuscator ] || ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${OBF_ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
|
||||
[ -x /usr/local/bin/wg-obfuscator ] || { echo "ERROR: obfuscator binary for $ARCH missing"; exit 1; }
|
||||
echo " bins: $(ls $PHOBOS_DIR/bin/ | tr '\n' ' ')"
|
||||
|
||||
# ── 3. Phobos repo (onboarding scripts) ──
|
||||
echo "[3/9] Phobos repo (onboarding scripts)..."
|
||||
@@ -88,7 +184,7 @@ if [ ! -f /etc/wireguard/wg0.conf ]; then
|
||||
WG_PRIV=$(wg genkey); WG_PUB=$(echo "$WG_PRIV" | wg pubkey)
|
||||
cat > /etc/wireguard/wg0.conf <<WG
|
||||
[Interface]
|
||||
Address = 10.25.0.1/16
|
||||
Address = ${WG_SERVER_IPV4}/${WG_PREFIXLEN}
|
||||
ListenPort = 51820
|
||||
PrivateKey = $WG_PRIV
|
||||
PostUp = iptables -I FORWARD 1 -i wg0 -j ACCEPT; iptables -I FORWARD 1 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
|
||||
@@ -148,6 +244,8 @@ cat > "$PHOBOS_DIR/server/server.env" <<EOF
|
||||
SERVER_WG_PRIVATE_KEY=$WG_PRIV
|
||||
SERVER_WG_PUBLIC_KEY=$WG_PUB
|
||||
SERVER_PUBLIC_IP_V4=$SERVER_IP
|
||||
SERVER_WG_IPV4_NETWORK=$SERVER_WG_IPV4_NETWORK
|
||||
SERVER_WG_IPV4_ADDRESS=${WG_SERVER_IPV4}/${WG_PREFIXLEN}
|
||||
OBFUSCATOR_KEY=$OBF_KEY
|
||||
OBFUSCATOR_PORTS=$OBF_PORTS
|
||||
CLIENT_WG_PORT=51820
|
||||
@@ -157,23 +255,44 @@ EOF
|
||||
# ── 6. PCA patches over onboarding scripts + server-side helpers ──
|
||||
echo "[6/9] PCA patches (tunnel-pull, self-heal, watchdog, 403 fix)..."
|
||||
fetch() {
|
||||
if [ -n "$GH_TOKEN" ]; then
|
||||
curl -fsSL -m20 -H "Authorization: token $GH_TOKEN" "$RAW/$1" -o "$2" && return 0
|
||||
else
|
||||
curl -fsSL -m20 "$RAW/$1" -o "$2" && return 0
|
||||
fi
|
||||
mkdir -p "$(dirname "$2")"
|
||||
# ponytail: файл из архива рядом с install.sh — не ходим в сеть.
|
||||
if [ -f "$SRC/$1" ]; then cp -f "$SRC/$1" "$2" && return 0; fi
|
||||
curl -fsSL -m20 "$RAW/$1?cb=$(date +%s)" -o "$2" && return 0
|
||||
curl -fsSL -m20 "$GH_RAW/$PCA_BRANCH/$1?cb=$(date +%s)" -o "$2" && return 0
|
||||
echo " WARN: fetch $1 failed"; return 1
|
||||
}
|
||||
fetch overlay/lib-core.sh "$PHOBOS_DIR/repo/server/scripts/lib-core.sh" && chmod +x "$PHOBOS_DIR/repo/server/scripts/lib-core.sh"
|
||||
fetch overlay/phobos-client.sh "$PHOBOS_DIR/repo/server/scripts/phobos-client.sh" && chmod +x "$PHOBOS_DIR/repo/server/scripts/phobos-client.sh"
|
||||
fetch overlay/install-router.sh.template "$PHOBOS_DIR/repo/client/templates/install-router.sh.template"
|
||||
fetch overlay/lib-client.sh "$PHOBOS_DIR/repo/client/templates/lib-client.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/lib-client.sh"
|
||||
fetch overlay/install-obfuscator.sh "$PHOBOS_DIR/repo/client/templates/install-obfuscator.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/install-obfuscator.sh"
|
||||
fetch overlay/install-wireguard.sh "$PHOBOS_DIR/repo/client/templates/install-wireguard.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/install-wireguard.sh"
|
||||
fetch overlay/router-configure-wireguard.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh"
|
||||
fetch overlay/router-configure-wireguard-openwrt.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh"
|
||||
fetch overlay/phobos-pull.sh "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh"
|
||||
fetch overlay/phobos-ttyd.sh "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" && cp "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" "$PHOBOS_DIR/www/phobos-ttyd.sh" 2>/dev/null || true
|
||||
fetch overlay/phobos-uninstall.sh "$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh"
|
||||
fetch overlay/3xui.sh "$PHOBOS_DIR/repo/client/templates/3xui.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/3xui.sh"
|
||||
fetch server/phobos-warp.sh "$PHOBOS_DIR/server/phobos-warp.sh" && chmod +x "$PHOBOS_DIR/server/phobos-warp.sh" && ln -sf "$PHOBOS_DIR/server/phobos-warp.sh" /usr/local/bin/phobos-warp
|
||||
fetch server/phobos-guard.sh "$PHOBOS_DIR/server/phobos-guard.sh" && chmod +x "$PHOBOS_DIR/server/phobos-guard.sh" && ln -sf "$PHOBOS_DIR/server/phobos-guard.sh" /usr/local/bin/phobos-guard
|
||||
fetch server/phobos-cascade.sh "$PHOBOS_DIR/server/phobos-cascade.sh" && chmod +x "$PHOBOS_DIR/server/phobos-cascade.sh" && ln -sf "$PHOBOS_DIR/server/phobos-cascade.sh" /usr/local/bin/phobos-cascade
|
||||
fetch server/phobos-domain.sh "$PHOBOS_DIR/server/phobos-domain.sh" && chmod +x "$PHOBOS_DIR/server/phobos-domain.sh" && ln -sf "$PHOBOS_DIR/server/phobos-domain.sh" /usr/local/bin/phobos-domain
|
||||
fetch server/phobos-limit.sh "$PHOBOS_DIR/server/phobos-limit.sh" && chmod +x "$PHOBOS_DIR/server/phobos-limit.sh"
|
||||
fetch server/phobos-speedtest.sh "$PHOBOS_DIR/server/phobos-speedtest.sh" && chmod +x "$PHOBOS_DIR/server/phobos-speedtest.sh"
|
||||
fetch server/phobos-tcp.sh "$PHOBOS_DIR/server/phobos-tcp.sh" && chmod +x "$PHOBOS_DIR/server/phobos-tcp.sh" && ln -sf "$PHOBOS_DIR/server/phobos-tcp.sh" /usr/local/bin/phobos-tcp
|
||||
fetch "udptcp/bin/udptcp-$OBF_ARCH" "$PHOBOS_DIR/bin/udptcp" && chmod +x "$PHOBOS_DIR/bin/udptcp"
|
||||
fetch server/phobos-health.sh "$PHOBOS_DIR/server/phobos-health.sh" && chmod +x "$PHOBOS_DIR/server/phobos-health.sh"
|
||||
fetch server/phobos-self-check.sh "$PHOBOS_DIR/server/phobos-self-check.sh" && chmod +x "$PHOBOS_DIR/server/phobos-self-check.sh" && ln -sf "$PHOBOS_DIR/server/phobos-self-check.sh" /usr/local/bin/phobos-self-check
|
||||
fetch server/phobos-pull.sh "$PHOBOS_DIR/server/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/server/phobos-pull.sh"
|
||||
fetch server/phobos-router-watchdog.py "$PHOBOS_DIR/server/phobos-router-watchdog.py"
|
||||
fetch server/api.py "$PHOBOS_DIR/server/api.py"
|
||||
fetch server/secondary-setup.sh "$PHOBOS_DIR/server/secondary-setup.sh" && chmod +x "$PHOBOS_DIR/server/secondary-setup.sh"
|
||||
fetch update.sh "$PHOBOS_DIR/server/update.sh" && chmod +x "$PHOBOS_DIR/server/update.sh" && ln -sf "$PHOBOS_DIR/server/update.sh" /usr/local/bin/phobos-update
|
||||
fetch server/phobos-backup.sh "$PHOBOS_DIR/server/phobos-backup.sh" && chmod +x "$PHOBOS_DIR/server/phobos-backup.sh" && ln -sf "$PHOBOS_DIR/server/phobos-backup.sh" /usr/local/bin/phobos-backup
|
||||
mkdir -p "$PANEL_DIR"; fetch VERSION "$PANEL_DIR/.version" 2>/dev/null || true
|
||||
[ -f "$PHOBOS_DIR/tokens/tokens.json" ] || echo '[]' > "$PHOBOS_DIR/tokens/tokens.json"
|
||||
"$PHOBOS_DIR/server/phobos-self-check.sh" --fix
|
||||
|
||||
# ── 7. web panel ──
|
||||
echo "[7/9] web panel..."
|
||||
@@ -195,6 +314,11 @@ if [ ! -f "$PANEL_DIR/settings.json" ]; then
|
||||
EOF
|
||||
fi
|
||||
echo "$PANEL_PORT" > "$PANEL_DIR/.port"
|
||||
echo "$PANEL_SCHEME" > "$PANEL_DIR/.scheme"
|
||||
TLS_ARGS=""
|
||||
if [ "$PANEL_SCHEME" = "https" ]; then
|
||||
TLS_ARGS="--certfile $PANEL_TLS_CERT --keyfile $PANEL_TLS_KEY"
|
||||
fi
|
||||
cat > /etc/systemd/system/phobos-panel.service <<EOF
|
||||
[Unit]
|
||||
Description=Phobos VPN Web Panel
|
||||
@@ -203,17 +327,23 @@ Wants=wg-quick@wg0.service
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$PANEL_DIR
|
||||
ExecStart=/usr/bin/gunicorn -w 1 -b 0.0.0.0:$PANEL_PORT app:app
|
||||
ExecStart=/usr/bin/gunicorn -w 1 --threads 20 $TLS_ARGS -b 0.0.0.0:$PANEL_PORT app:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PANEL_SCHEME=$PANEL_SCHEME
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload; systemctl enable phobos-panel -q; systemctl restart phobos-panel
|
||||
|
||||
# TCP fallback for clients whose UDP is cut (port 443/8443/... picked automatically)
|
||||
"$PHOBOS_DIR/server/phobos-tcp.sh" enable || echo " WARN: TCP fallback not enabled"
|
||||
|
||||
# ── 8. nginx (serve /init + /packages over plain HTTP for routers) ──
|
||||
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
|
||||
cat > /etc/nginx/sites-available/phobos <<'NGINX'
|
||||
server {
|
||||
@@ -226,8 +356,9 @@ server {
|
||||
}
|
||||
NGINX
|
||||
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"
|
||||
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) ──
|
||||
echo "[9/9] router watchdog..."
|
||||
@@ -240,10 +371,22 @@ if [ -f "$PHOBOS_DIR/server/phobos-router-watchdog.py" ]; then
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
|
||||
# persist channel so phobos-update follows it
|
||||
mkdir -p "$PANEL_DIR"
|
||||
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 " Installation complete"
|
||||
echo " Panel : http://$SERVER_IP:$PANEL_PORT"
|
||||
echo " Panel : $PANEL_SCHEME://$SERVER_IP:$PANEL_PORT"
|
||||
echo " Login : admin"
|
||||
echo " Pass : $PANEL_PASS"
|
||||
echo " API key (agents+pull): $API_KEY"
|
||||
|
||||
456
overlay/3xui.sh
Executable file
@@ -0,0 +1,456 @@
|
||||
#!/bin/bash
|
||||
|
||||
GREEN='\033[92m'
|
||||
YELLOW='\033[93m'
|
||||
RED='\033[91m'
|
||||
BLUE='\033[94m'
|
||||
CYAN='\033[96m'
|
||||
RESET='\033[0m'
|
||||
BOLD='\033[1m'
|
||||
|
||||
print_status() {
|
||||
local message="$1"
|
||||
local status="${2:-info}"
|
||||
local prefix=""
|
||||
case "$status" in
|
||||
success) prefix="${GREEN}[OK]" ;;
|
||||
error) prefix="${RED}[ERR]" ;;
|
||||
warning) prefix="${YELLOW}[WARN]" ;;
|
||||
info) prefix="${BLUE}[INFO]" ;;
|
||||
debug) prefix="${CYAN}[DBG]" ;;
|
||||
esac
|
||||
echo -e "${prefix} ${message}${RESET}"
|
||||
}
|
||||
|
||||
check_dependencies() {
|
||||
local missing=()
|
||||
for cmd in jq sqlite3; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
missing+=("$cmd")
|
||||
fi
|
||||
done
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
print_status "Installing missing dependencies: ${missing[*]}" "info"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq "${missing[@]}" >/dev/null 2>&1
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y -q "${missing[@]/sqlite3/sqlite}" >/dev/null 2>&1
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y -q "${missing[@]/sqlite3/sqlite}" >/dev/null 2>&1
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --quiet "${missing[@]/sqlite3/sqlite}" >/dev/null 2>&1
|
||||
fi
|
||||
for cmd in "${missing[@]}"; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
print_status "Failed to install $cmd" "error"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
print_status "Dependencies installed successfully" "success"
|
||||
fi
|
||||
}
|
||||
|
||||
parse_wireguard_conf() {
|
||||
local config_path="$1"
|
||||
local section=""
|
||||
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
print_status "File $config_path not found" "error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INTERFACE_PRIVATEKEY=""
|
||||
INTERFACE_ADDRESS=""
|
||||
INTERFACE_MTU="1420"
|
||||
PEER_PUBLICKEY=""
|
||||
PEER_ENDPOINT=""
|
||||
PEER_ALLOWEDIPS=""
|
||||
PEER_PRESHAREDKEY=""
|
||||
PEER_KEEPALIVE="0"
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
|
||||
if [[ "$line" == "["*"]" ]]; then
|
||||
section=$(echo "$line" | tr -d '[]' | tr '[:upper:]' '[:lower:]')
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" == *"="* ]]; then
|
||||
local key=$(echo "$line" | cut -d'=' -f1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
local value=$(echo "$line" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
if [[ "$section" == "interface" ]]; then
|
||||
case "$key" in
|
||||
PrivateKey) INTERFACE_PRIVATEKEY="$value" ;;
|
||||
Address) INTERFACE_ADDRESS="$value" ;;
|
||||
MTU) INTERFACE_MTU="$value" ;;
|
||||
esac
|
||||
elif [[ "$section" == "peer" ]]; then
|
||||
case "$key" in
|
||||
PublicKey) PEER_PUBLICKEY="$value" ;;
|
||||
Endpoint) PEER_ENDPOINT="$value" ;;
|
||||
AllowedIPs) PEER_ALLOWEDIPS="$value" ;;
|
||||
PresharedKey|PreSharedKey) PEER_PRESHAREDKEY="$value" ;;
|
||||
PersistentKeepalive) PEER_KEEPALIVE="$value" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
done < "$config_path"
|
||||
}
|
||||
|
||||
extract_endpoint() {
|
||||
local endpoint="$1"
|
||||
if [[ "$endpoint" == "["*"]:"* ]]; then
|
||||
SERVER_ADDRESS=$(echo "$endpoint" | sed 's/^\[\([^]]*\)\]:.*/\1/')
|
||||
SERVER_PORT=$(echo "$endpoint" | sed 's/.*\]:\([0-9]*\).*/\1/')
|
||||
else
|
||||
SERVER_ADDRESS=$(echo "$endpoint" | rev | cut -d':' -f2- | rev)
|
||||
SERVER_PORT=$(echo "$endpoint" | rev | cut -d':' -f1 | rev)
|
||||
fi
|
||||
[[ -z "$SERVER_PORT" ]] && SERVER_PORT="51820"
|
||||
}
|
||||
|
||||
build_local_addresses() {
|
||||
local addresses="$1"
|
||||
local result="[]"
|
||||
|
||||
IFS=',' read -ra ADDR_ARRAY <<< "$addresses"
|
||||
for addr in "${ADDR_ARRAY[@]}"; do
|
||||
addr=$(echo "$addr" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
[[ -z "$addr" ]] && continue
|
||||
|
||||
local ip_part="$addr"
|
||||
if [[ "$addr" == *"/"* ]]; then
|
||||
ip_part=$(echo "$addr" | cut -d'/' -f1)
|
||||
fi
|
||||
|
||||
if [[ "$ip_part" == *":"* ]]; then
|
||||
result=$(echo "$result" | jq --arg ip "${ip_part}/128" '. + [$ip]')
|
||||
else
|
||||
result=$(echo "$result" | jq --arg ip "${ip_part}/32" '. + [$ip]')
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
build_allowed_ips() {
|
||||
local allowed="$1"
|
||||
local result="[]"
|
||||
|
||||
if [[ -z "$allowed" ]]; then
|
||||
echo '["0.0.0.0/0", "::/0"]'
|
||||
return
|
||||
fi
|
||||
|
||||
IFS=',' read -ra IP_ARRAY <<< "$allowed"
|
||||
for ip in "${IP_ARRAY[@]}"; do
|
||||
ip=$(echo "$ip" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
[[ -z "$ip" ]] && continue
|
||||
result=$(echo "$result" | jq --arg ip "$ip" '. + [$ip]')
|
||||
done
|
||||
|
||||
if [[ "$result" == "[]" ]]; then
|
||||
echo '["0.0.0.0/0", "::/0"]'
|
||||
else
|
||||
echo "$result"
|
||||
fi
|
||||
}
|
||||
|
||||
convert_to_xray_outbound() {
|
||||
local tag="${1:-Phobos}"
|
||||
|
||||
extract_endpoint "$PEER_ENDPOINT"
|
||||
|
||||
local local_addresses=$(build_local_addresses "$INTERFACE_ADDRESS")
|
||||
local allowed_ips=$(build_allowed_ips "$PEER_ALLOWEDIPS")
|
||||
|
||||
local peer_config
|
||||
peer_config=$(jq -n \
|
||||
--arg pubkey "$PEER_PUBLICKEY" \
|
||||
--argjson allowed "$allowed_ips" \
|
||||
--arg endpoint "${SERVER_ADDRESS}:${SERVER_PORT}" \
|
||||
'{
|
||||
"publicKey": $pubkey,
|
||||
"allowedIPs": $allowed,
|
||||
"endpoint": $endpoint
|
||||
}')
|
||||
|
||||
if [[ -n "$PEER_KEEPALIVE" && "$PEER_KEEPALIVE" != "0" ]]; then
|
||||
peer_config=$(echo "$peer_config" | jq --argjson ka "$PEER_KEEPALIVE" '. + {"keepAlive": $ka}')
|
||||
fi
|
||||
|
||||
if [[ -n "$PEER_PRESHAREDKEY" ]]; then
|
||||
peer_config=$(echo "$peer_config" | jq --arg psk "$PEER_PRESHAREDKEY" '. + {"preSharedKey": $psk}')
|
||||
fi
|
||||
|
||||
local mtu="${INTERFACE_MTU:-1420}"
|
||||
|
||||
OUTBOUND_JSON=$(jq -n \
|
||||
--arg tag "$tag" \
|
||||
--argjson mtu "$mtu" \
|
||||
--arg secret "$INTERFACE_PRIVATEKEY" \
|
||||
--argjson addresses "$local_addresses" \
|
||||
--argjson peer "$peer_config" \
|
||||
'{
|
||||
"protocol": "wireguard",
|
||||
"settings": {
|
||||
"mtu": $mtu,
|
||||
"secretKey": $secret,
|
||||
"address": $addresses,
|
||||
"workers": 2,
|
||||
"peers": [$peer],
|
||||
"noKernelTun": true
|
||||
},
|
||||
"tag": $tag
|
||||
}')
|
||||
}
|
||||
|
||||
get_default_xray_config() {
|
||||
cat <<'XRAY_CONFIG'
|
||||
{
|
||||
"api": {
|
||||
"services": ["HandlerService", "StatsService", "LoggerService"],
|
||||
"tag": "api"
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "127.0.0.1",
|
||||
"port": 62789,
|
||||
"protocol": "dokodemo-door",
|
||||
"settings": {"address": "127.0.0.1"},
|
||||
"tag": "api"
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{"protocol": "freedom", "settings": {}, "tag": "direct"},
|
||||
{"protocol": "blackhole", "settings": {}, "tag": "block"}
|
||||
],
|
||||
"policy": {
|
||||
"levels": {"0": {"statsUserDownlink": true, "statsUserUplink": true}},
|
||||
"system": {
|
||||
"statsInboundDownlink": true,
|
||||
"statsInboundUplink": true,
|
||||
"statsOutboundDownlink": true,
|
||||
"statsOutboundUplink": true
|
||||
}
|
||||
},
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": [
|
||||
{"inboundTag": ["api"], "outboundTag": "api", "type": "field"},
|
||||
{"ip": ["geoip:private"], "outboundTag": "block", "type": "field"}
|
||||
]
|
||||
},
|
||||
"stats": {}
|
||||
}
|
||||
XRAY_CONFIG
|
||||
}
|
||||
|
||||
find_database() {
|
||||
local paths=(
|
||||
"/etc/x-ui/x-ui.db"
|
||||
"/usr/local/x-ui/x-ui.db"
|
||||
"/opt/x-ui/x-ui.db"
|
||||
"./x-ui.db"
|
||||
)
|
||||
|
||||
for path in "${paths[@]}"; do
|
||||
if [[ -f "$path" ]]; then
|
||||
print_status "Database found: $path" "success" >&2
|
||||
echo "$path"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
diagnose_database() {
|
||||
local db_path="$1"
|
||||
|
||||
local tables=$(sqlite3 "$db_path" "SELECT name FROM sqlite_master WHERE type='table';" 2>/dev/null)
|
||||
print_status "Found tables: $tables" "debug" >&2
|
||||
|
||||
if echo "$tables" | grep -q "settings"; then
|
||||
local keys=$(sqlite3 "$db_path" "SELECT key FROM settings;" 2>/dev/null)
|
||||
print_status "Keys in settings: $keys" "debug" >&2
|
||||
|
||||
for key in xrayTemplateConfig xrayConfig xray_config config; do
|
||||
local result=$(sqlite3 "$db_path" "SELECT value FROM settings WHERE key = '$key';" 2>/dev/null)
|
||||
if [[ -n "$result" ]]; then
|
||||
print_status "Found config key: $key" "success" >&2
|
||||
echo "$key"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
write_setting_to_db() {
|
||||
local db_path="$1"
|
||||
local config_key="$2"
|
||||
local config_value="$3"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
printf '%s' "$config_value" > "$tmp"
|
||||
sqlite3 "$db_path" "INSERT OR REPLACE INTO settings (key, value) VALUES ('$config_key', readfile('$tmp'));"
|
||||
local ret=$?
|
||||
rm -f "$tmp"
|
||||
return $ret
|
||||
}
|
||||
|
||||
import_via_database() {
|
||||
local db_path="$1"
|
||||
|
||||
print_status "Importing to database..." "info"
|
||||
|
||||
if [[ ! -f "$db_path" ]]; then
|
||||
print_status "Database not found: $db_path" "error"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local config_key=$(diagnose_database "$db_path")
|
||||
|
||||
if [[ -z "$config_key" ]]; then
|
||||
print_status "Xray config not found in database, creating new..." "warning"
|
||||
|
||||
local base_config=$(get_default_xray_config)
|
||||
local new_config=$(echo "$base_config" | jq --argjson ob "$OUTBOUND_JSON" '.outbounds = [$ob] + .outbounds')
|
||||
|
||||
if write_setting_to_db "$db_path" "xrayTemplateConfig" "$new_config"; then
|
||||
print_status "Base Xray config created with outbound" "success"
|
||||
return 0
|
||||
else
|
||||
print_status "Failed to create config" "error"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local current_config=$(sqlite3 "$db_path" "SELECT value FROM settings WHERE key = '$config_key';" 2>/dev/null)
|
||||
|
||||
if [[ -z "$current_config" ]]; then
|
||||
print_status "Xray config not found" "error"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! echo "$current_config" | jq . >/dev/null 2>&1; then
|
||||
print_status "Invalid JSON in config" "error"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tag=$(echo "$OUTBOUND_JSON" | jq -r '.tag')
|
||||
local existing_tag=$(echo "$current_config" | jq -r --arg t "$tag" '.outbounds[]? | select(.tag == $t) | .tag' 2>/dev/null)
|
||||
|
||||
if [[ -n "$existing_tag" ]]; then
|
||||
print_status "Outbound '$tag' exists, replacing..." "warning"
|
||||
current_config=$(echo "$current_config" | jq --arg t "$tag" '.outbounds = [.outbounds[] | select(.tag != $t)]')
|
||||
fi
|
||||
|
||||
local new_config=$(echo "$current_config" | jq --argjson ob "$OUTBOUND_JSON" '.outbounds = [$ob] + .outbounds')
|
||||
|
||||
if write_setting_to_db "$db_path" "$config_key" "$new_config"; then
|
||||
print_status "Outbound added to database" "success"
|
||||
return 0
|
||||
else
|
||||
print_status "Failed to update database" "error"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
restart_xui_service() {
|
||||
print_status "Restarting x-ui service..." "info"
|
||||
|
||||
if systemctl restart x-ui 2>/dev/null; then
|
||||
sleep 1
|
||||
if systemctl is-active --quiet x-ui; then
|
||||
print_status "x-ui service restarted and active" "success"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
for cmd in "service x-ui restart" "/etc/init.d/x-ui restart" "x-ui restart"; do
|
||||
if $cmd 2>/dev/null; then
|
||||
print_status "Service restarted via: $cmd" "success"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
print_status "Failed to restart service automatically" "error"
|
||||
print_status "Restart manually: systemctl restart x-ui" "info"
|
||||
return 1
|
||||
}
|
||||
|
||||
main() {
|
||||
echo -e "\n${BOLD}=== WireGuard to 3x-ui Auto-Import Tool ===${RESET}\n"
|
||||
|
||||
check_dependencies
|
||||
|
||||
local config_path="$1"
|
||||
if [[ -z "$config_path" ]]; then
|
||||
echo -n "Path to wireguard.conf: "
|
||||
read config_path
|
||||
fi
|
||||
|
||||
print_status "Parsing $config_path..." "info"
|
||||
parse_wireguard_conf "$config_path"
|
||||
|
||||
print_status "Converting to Xray format..." "info"
|
||||
convert_to_xray_outbound "Phobos"
|
||||
|
||||
echo -e "\n${BOLD}Outbound configuration:${RESET}"
|
||||
echo "$OUTBOUND_JSON" | jq .
|
||||
echo
|
||||
|
||||
local db_path="/etc/x-ui/x-ui.db"
|
||||
if [[ ! -f "$db_path" ]]; then
|
||||
print_status "Database not found at: $db_path" "warning"
|
||||
db_path=$(find_database)
|
||||
if [[ -z "$db_path" ]]; then
|
||||
print_status "Database not found in standard locations" "error"
|
||||
print_status "Make sure 3x-ui is installed" "info"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl stop x-ui 2>/dev/null
|
||||
if ! import_via_database "$db_path"; then
|
||||
systemctl start x-ui 2>/dev/null
|
||||
print_status "Database import failed" "error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local output_path="${config_path%.*}_3xui_outbound.json"
|
||||
echo "$OUTBOUND_JSON" | jq . > "$output_path" 2>/dev/null
|
||||
if [[ $? -eq 0 ]]; then
|
||||
print_status "JSON saved to: $output_path" "success"
|
||||
fi
|
||||
|
||||
echo
|
||||
local restart_ok=0
|
||||
restart_xui_service && restart_ok=1
|
||||
|
||||
echo
|
||||
print_status "Import completed successfully!" "success"
|
||||
echo
|
||||
print_status "${BOLD}Info:${RESET}" "info"
|
||||
print_status " Outbound 'Phobos' added to config" "info"
|
||||
if [[ $restart_ok -eq 1 ]]; then
|
||||
print_status " x-ui service restarted" "info"
|
||||
else
|
||||
print_status " x-ui service needs manual restart" "warning"
|
||||
fi
|
||||
print_status " JSON config saved to $output_path" "info"
|
||||
echo
|
||||
print_status "${BOLD}Verification:${RESET}" "info"
|
||||
print_status " Open 3x-ui web panel" "info"
|
||||
print_status " Go to Outbounds section" "info"
|
||||
print_status " Verify 'Phobos' outbound is present" "info"
|
||||
echo
|
||||
}
|
||||
|
||||
main "$@"
|
||||
262
overlay/install-obfuscator.sh
Executable file
@@ -0,0 +1,262 @@
|
||||
OBF_BINARY_NAME="wg-obfuscator"
|
||||
OBF_CONF_NAME="wg-obfuscator.conf"
|
||||
OBF_INIT_NAME="S49wg-obfuscator"
|
||||
OBF_SERVICE_NAME="phobos-obfuscator"
|
||||
OBF_WG_IFACE="phobos"
|
||||
|
||||
resolve_install_names() {
|
||||
local binary_dir
|
||||
local binary_path
|
||||
|
||||
if [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
||||
binary_dir="/usr/bin"
|
||||
elif [ "$ROUTER_PLATFORM" = "linux" ]; then
|
||||
binary_dir="/usr/local/bin"
|
||||
else
|
||||
binary_dir="/opt/bin"
|
||||
fi
|
||||
|
||||
binary_path="${binary_dir}/wg-obfuscator"
|
||||
|
||||
if [ ! -f "$binary_path" ] && [ ! -f "$PHOBOS_DIR/wg-obfuscator.conf" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
local idx=1
|
||||
while [ -f "${binary_dir}/wg-obfuscator${idx}" ]; do
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
local init_num=50
|
||||
while [ -f "/opt/etc/init.d/S${init_num}wg-obfuscator" ]; do
|
||||
init_num=$((init_num + 1))
|
||||
done
|
||||
|
||||
OBF_BINARY_NAME="wg-obfuscator${idx}"
|
||||
OBF_CONF_NAME="wg-obfuscator${idx}.conf"
|
||||
OBF_INIT_NAME="S${init_num}wg-obfuscator"
|
||||
OBF_SERVICE_NAME="phobos-obfuscator${idx}"
|
||||
OBF_WG_IFACE="phobos${idx}"
|
||||
}
|
||||
|
||||
install_obfuscator() {
|
||||
local arch=$1
|
||||
|
||||
log "Установка wg-obfuscator для архитектуры $arch..."
|
||||
|
||||
if [ "$OBF_BINARY_NAME" = "wg-obfuscator" ]; then
|
||||
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||
log "Остановка текущего процесса wg-obfuscator..."
|
||||
/opt/etc/init.d/S49wg-obfuscator stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -f /etc/init.d/phobos-obfuscator ]; then
|
||||
log "Остановка текущего процесса wg-obfuscator..."
|
||||
/etc/init.d/phobos-obfuscator stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
local binary_name="wg-obfuscator-$arch"
|
||||
|
||||
if [ ! -f "bin/$binary_name" ]; then
|
||||
log "Ошибка: бинарник $binary_name не найден в архиве"
|
||||
log "Доступные бинарники:"
|
||||
ls -1 bin/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local target_path="/opt/bin/${OBF_BINARY_NAME}"
|
||||
if [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
||||
target_path="/usr/bin/${OBF_BINARY_NAME}"
|
||||
fi
|
||||
|
||||
if [ -f "$target_path" ]; then
|
||||
rm "$target_path"
|
||||
fi
|
||||
|
||||
if [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
||||
mkdir -p /usr/bin
|
||||
else
|
||||
mkdir -p /opt/bin
|
||||
fi
|
||||
|
||||
cp "bin/$binary_name" "$target_path"
|
||||
chmod +x "$target_path"
|
||||
|
||||
log "Бинарник wg-obfuscator установлен в $target_path"
|
||||
}
|
||||
|
||||
create_init_script() {
|
||||
log "Создание init-скрипта для obfuscator..."
|
||||
|
||||
cat > /opt/etc/init.d/${OBF_INIT_NAME} <<EOF
|
||||
#!/bin/sh
|
||||
|
||||
ENABLED=yes
|
||||
PROCS=${OBF_BINARY_NAME}
|
||||
ARGS="--config $PHOBOS_DIR/${OBF_CONF_NAME}"
|
||||
PREARGS=""
|
||||
DESC=\$PROCS
|
||||
PATH=/opt/sbin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
. /opt/etc/init.d/rc.func
|
||||
EOF
|
||||
|
||||
chmod +x /opt/etc/init.d/${OBF_INIT_NAME}
|
||||
|
||||
log "Init-скрипт создан: /opt/etc/init.d/${OBF_INIT_NAME}"
|
||||
}
|
||||
|
||||
start_obfuscator() {
|
||||
log "Запуск wg-obfuscator..."
|
||||
|
||||
if [ "$ROUTER_PLATFORM" = "keenetic" ] || [ -f /opt/etc/init.d/${OBF_INIT_NAME} ]; then
|
||||
/opt/etc/init.d/${OBF_INIT_NAME} start
|
||||
elif [ "$ROUTER_PLATFORM" = "openwrt" ] && [ -f /etc/init.d/${OBF_SERVICE_NAME} ]; then
|
||||
/etc/init.d/${OBF_SERVICE_NAME} start
|
||||
/etc/init.d/${OBF_SERVICE_NAME} enable
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
|
||||
local process_found=0
|
||||
|
||||
if command -v pidof >/dev/null 2>&1 && pidof ${OBF_BINARY_NAME} >/dev/null 2>&1; then
|
||||
process_found=1
|
||||
elif command -v pgrep >/dev/null 2>&1 && pgrep -f wg-obfuscator >/dev/null 2>&1; then
|
||||
process_found=1
|
||||
elif ps w 2>/dev/null | grep -v grep | grep -q "wg-obfuscator\|/opt/bin/wg-obfuscator\|/usr/bin/wg-obfuscator"; then
|
||||
process_found=1
|
||||
elif ps 2>/dev/null | grep -v grep | grep -q "wg-obfuscator"; then
|
||||
process_found=1
|
||||
fi
|
||||
|
||||
if [ $process_found -eq 0 ]; then
|
||||
if [ "$ROUTER_PLATFORM" = "keenetic" ] && [ -f /opt/etc/init.d/${OBF_INIT_NAME} ]; then
|
||||
local status_output=$(/opt/etc/init.d/${OBF_INIT_NAME} status 2>&1 || echo "")
|
||||
if echo "$status_output" | grep -q "alive"; then
|
||||
process_found=1
|
||||
fi
|
||||
elif [ "$ROUTER_PLATFORM" = "openwrt" ] && [ -f /etc/init.d/${OBF_SERVICE_NAME} ]; then
|
||||
if /etc/init.d/${OBF_SERVICE_NAME} status >/dev/null 2>&1; then
|
||||
process_found=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $process_found -eq 1 ]; then
|
||||
log "✓ wg-obfuscator успешно запущен"
|
||||
else
|
||||
log "✗ wg-obfuscator не запущен. Проверьте логи."
|
||||
fi
|
||||
}
|
||||
|
||||
create_procd_init_script() {
|
||||
log "Создание procd init-скрипта для obfuscator..."
|
||||
|
||||
cat > /etc/init.d/${OBF_SERVICE_NAME} <<EOF
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=49
|
||||
STOP=51
|
||||
|
||||
USE_PROCD=1
|
||||
|
||||
PROG=/usr/bin/${OBF_BINARY_NAME}
|
||||
CONFIG_FILE=/etc/Phobos/${OBF_CONF_NAME}
|
||||
|
||||
start_service() {
|
||||
if [ ! -f "\$PROG" ]; then
|
||||
echo "Error: wg-obfuscator not found at \$PROG"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "\$CONFIG_FILE" ]; then
|
||||
echo "Error: config not found at \$CONFIG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
procd_open_instance
|
||||
procd_set_param command \$PROG --config \$CONFIG_FILE
|
||||
procd_set_param respawn
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
EOF
|
||||
|
||||
chmod +x /etc/init.d/${OBF_SERVICE_NAME}
|
||||
|
||||
log "Procd init-скрипт создан: /etc/init.d/${OBF_SERVICE_NAME}"
|
||||
}
|
||||
|
||||
create_systemd_obfuscator_service() {
|
||||
log "Создание systemd service для obfuscator..."
|
||||
|
||||
cat > /etc/systemd/system/${OBF_SERVICE_NAME}.service <<EOFS
|
||||
[Unit]
|
||||
Description=Phobos WireGuard Obfuscator
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/${OBF_BINARY_NAME} --config $PHOBOS_DIR/${OBF_CONF_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOFS
|
||||
|
||||
if [ ! -f /usr/local/bin/${OBF_BINARY_NAME} ]; then
|
||||
local target_path="/usr/local/bin/${OBF_BINARY_NAME}"
|
||||
if [ -f /opt/bin/${OBF_BINARY_NAME} ]; then
|
||||
cp /opt/bin/${OBF_BINARY_NAME} "$target_path"
|
||||
chmod +x "$target_path"
|
||||
elif [ -f /usr/bin/${OBF_BINARY_NAME} ]; then
|
||||
cp /usr/bin/${OBF_BINARY_NAME} "$target_path"
|
||||
chmod +x "$target_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable ${OBF_SERVICE_NAME}
|
||||
systemctl start ${OBF_SERVICE_NAME}
|
||||
|
||||
log "Ожидание запуска obfuscator (до 10 секунд)..."
|
||||
local wait_count=0
|
||||
while [ $wait_count -lt 10 ]; do
|
||||
sleep 1
|
||||
if systemctl is-active --quiet ${OBF_SERVICE_NAME}; then
|
||||
log "✓ Obfuscator успешно запущен"
|
||||
break
|
||||
fi
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
if [ $wait_count -ge 10 ]; then
|
||||
log "ПРЕДУПРЕЖДЕНИЕ: Obfuscator может быть не готов (таймаут 10 сек)"
|
||||
fi
|
||||
|
||||
log "Systemd service создан: /etc/systemd/system/${OBF_SERVICE_NAME}.service"
|
||||
}
|
||||
@@ -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() {
|
||||
log "Развертывание монитора здоровья Phobos..."
|
||||
|
||||
@@ -428,12 +438,19 @@ deploy_health_monitor() {
|
||||
cp "phobos-health.sh" "$PHOBOS_DIR/phobos-health.sh"
|
||||
chmod +x "$PHOBOS_DIR/phobos-health.sh"
|
||||
|
||||
if [ -f "failover.conf" ]; then
|
||||
cp "failover.conf" "$PHOBOS_DIR/failover.conf"
|
||||
chmod 600 "$PHOBOS_DIR/failover.conf"
|
||||
# 02.11.2026: вторая и следующие учётки на одном роутере — свой каталог
|
||||
# (failover.conf, state, client_id, panel_*), свои строки cron с PHOBOS_ACC.
|
||||
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
|
||||
|
||||
mkdir -p "$PHOBOS_DIR/state"
|
||||
|
||||
# --- Phobos pull agent: panel -> router config sync (NAT-friendly) ---
|
||||
# Routers behind NAT cannot be reached by the panel over SSH, so they PULL
|
||||
@@ -441,49 +458,48 @@ deploy_health_monitor() {
|
||||
if [ -f "phobos-pull.sh" ]; then
|
||||
cp "phobos-pull.sh" "$PHOBOS_DIR/phobos-pull.sh"
|
||||
chmod +x "$PHOBOS_DIR/phobos-pull.sh"
|
||||
echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id"
|
||||
[ -f "pull_token" ] && cp "pull_token" "$PHOBOS_DIR/pull_token"
|
||||
echo "{{CLIENT_NAME}}" > "$A/client_id"
|
||||
[ -f "pull_token" ] && cp "pull_token" "$A/pull_token"
|
||||
[ -f "panel_tunnel_ip" ] && cp "panel_tunnel_ip" "$A/panel_tunnel_ip"
|
||||
[ -f "panel_port" ] && cp "panel_port" "$A/panel_port"
|
||||
[ -f "panel_scheme" ] && cp "panel_scheme" "$A/panel_scheme"
|
||||
[ -f "panel_url" ] && cp "panel_url" "$A/panel_url"
|
||||
[ -f "panel_urls" ] && cp "panel_urls" "$A/panel_urls"
|
||||
log " Pull-агент установлен (client_id={{CLIENT_NAME}})"
|
||||
fi
|
||||
|
||||
local cron_line="*/1 * * * * $PHOBOS_DIR/phobos-health.sh"
|
||||
local pull_line="*/1 * * * * $PHOBOS_DIR/phobos-pull.sh"
|
||||
local cron_line="*/1 * * * * ${envp}$PHOBOS_DIR/phobos-health.sh"
|
||||
local pull_line="*/1 * * * * ${envp}$PHOBOS_DIR/phobos-pull.sh"
|
||||
[ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line=""
|
||||
|
||||
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
|
||||
# Install cron if not present (Entware)
|
||||
if ! command -v crontab >/dev/null 2>&1 && command -v opkg >/dev/null 2>&1; then
|
||||
log " Установка cron через opkg..."
|
||||
opkg update >/dev/null 2>&1
|
||||
opkg install cron >/dev/null 2>&1
|
||||
/opt/etc/init.d/S10cron start >/dev/null 2>&1 || true
|
||||
fi
|
||||
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"
|
||||
# Демон — busybox crond (есть в Entware как /opt/sbin/crond). Entware-пакет cron
|
||||
# (vixie, PROCS=cron) на Keenetic задачи не выполнял — заменяем (01.11.2026)
|
||||
local I=/opt/etc/init.d/S10cron
|
||||
if [ ! -x $I ] || grep -q "^PROCS=cron$" $I; then
|
||||
[ -x $I ] && $I stop </dev/null >/dev/null 2>&1
|
||||
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
|
||||
chmod +x $I
|
||||
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
|
||||
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)"
|
||||
elif [ "$ROUTER_PLATFORM" = "linux" ]; then
|
||||
{ echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line"; } > /etc/cron.d/phobos-health
|
||||
chmod 644 /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*
|
||||
log " Cron добавлен в /etc/cron.d/phobos-health (health + pull)"
|
||||
fi
|
||||
|
||||
log " Монитор здоровья: $PHOBOS_DIR/phobos-health.sh"
|
||||
log " Failover конфиг: $PHOBOS_DIR/failover.conf"
|
||||
log " Failover конфиг: $A/failover.conf"
|
||||
log " Проверка каждую минуту через cron"
|
||||
}
|
||||
|
||||
|
||||
94
overlay/install-wireguard.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
install_wireguard_linux() {
|
||||
log "Установка WireGuard для Linux..."
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update || log "ПРЕДУПРЕЖДЕНИЕ: Не удалось обновить список пакетов"
|
||||
apt-get install -y wireguard wireguard-tools resolvconf net-tools || {
|
||||
log "ОШИБКА: Не удалось установить WireGuard"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
log "ОШИБКА: apt-get не найден. Поддерживаются только Ubuntu/Debian"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "✓ WireGuard установлен"
|
||||
return 0
|
||||
}
|
||||
|
||||
configure_wireguard_linux() {
|
||||
log ""
|
||||
log "==> Настройка WireGuard через systemd..."
|
||||
|
||||
local wg_interface="${OBF_WG_IFACE}"
|
||||
local wg_config_dir="/etc/wireguard"
|
||||
mkdir -p "$wg_config_dir"
|
||||
|
||||
cp "$PHOBOS_DIR/${CLIENT_NAME}.conf" "$wg_config_dir/${wg_interface}.conf"
|
||||
chmod 600 "$wg_config_dir/${wg_interface}.conf"
|
||||
|
||||
log "Настройка routing: VPN как запасной интерфейс..."
|
||||
|
||||
local client_ip=$(grep '^Address' "$wg_config_dir/${wg_interface}.conf" | cut -d'=' -f2 | tr -d ' ' | cut -d',' -f1 | cut -d'/' -f1)
|
||||
local route_target=$(echo "$client_ip" | cut -d'.' -f1-2).0.0/16
|
||||
|
||||
sed -i '/^MTU/a Table = off' "$wg_config_dir/${wg_interface}.conf"
|
||||
sed -i "/^Table = off/a PostUp = ip route add $route_target dev %i" "$wg_config_dir/${wg_interface}.conf"
|
||||
sed -i "/^PostUp/a PostDown = ip route del $route_target dev %i || true" "$wg_config_dir/${wg_interface}.conf"
|
||||
|
||||
log "Настройка зависимости WireGuard от obfuscator..."
|
||||
mkdir -p "/etc/systemd/system/wg-quick@${wg_interface}.service.d"
|
||||
cat > "/etc/systemd/system/wg-quick@${wg_interface}.service.d/override.conf" <<EOF
|
||||
[Unit]
|
||||
After=${OBF_SERVICE_NAME}.service
|
||||
Requires=${OBF_SERVICE_NAME}.service
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
log "Включение и запуск WireGuard интерфейса ${wg_interface}..."
|
||||
systemctl enable wg-quick@${wg_interface} || log "ПРЕДУПРЕЖДЕНИЕ: Не удалось включить автозапуск"
|
||||
systemctl start wg-quick@${wg_interface} || {
|
||||
log "ОШИБКА: Не удалось запустить WireGuard"
|
||||
return 1
|
||||
}
|
||||
|
||||
sleep 3
|
||||
|
||||
if systemctl is-active --quiet wg-quick@${wg_interface}; then
|
||||
log "✓ WireGuard успешно запущен (интерфейс: ${wg_interface})"
|
||||
log " VPN настроен как запасной интерфейс (не перехватывает системный трафик)"
|
||||
|
||||
if wg show ${wg_interface} 2>/dev/null | grep -q "latest handshake"; then
|
||||
log "✓ Туннель установлен и работает"
|
||||
else
|
||||
log " Ожидание установки туннеля..."
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
log "ПРЕДУПРЕЖДЕНИЕ: WireGuard может быть не запущен, проверьте статус"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
configure_ufw_linux() {
|
||||
log ""
|
||||
log "==> Проверка UFW firewall..."
|
||||
|
||||
if ! command -v ufw >/dev/null 2>&1; then
|
||||
log "UFW не установлен, пропуск настройки"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "✓ UFW обнаружен"
|
||||
log " Клиентская конфигурация не требует открытия портов:"
|
||||
log " - obfuscator слушает на 127.0.0.1:13255 (локальный интерфейс)"
|
||||
log " - obfuscator делает исходящие соединения (разрешены по умолчанию)"
|
||||
|
||||
if ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
log " UFW активен, клиент будет работать корректно"
|
||||
else
|
||||
log " UFW неактивен"
|
||||
fi
|
||||
}
|
||||
93
overlay/lib-client.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
detect_router_platform() {
|
||||
if [ -f /etc/openwrt_release ] || [ -f /etc/openwrt_version ]; then
|
||||
echo "openwrt"
|
||||
elif [ -f /opt/etc/.entware_install_log ] || [ -d /opt/etc/ndm ]; then
|
||||
echo "keenetic"
|
||||
elif [ -f /etc/debian_version ]; then
|
||||
echo "linux"
|
||||
elif [ -d /etc/config ] && command -v uci >/dev/null 2>&1; then
|
||||
echo "openwrt"
|
||||
elif [ -f /opt/etc/init.d/rc.func ]; then
|
||||
echo "keenetic"
|
||||
elif [ -d /run/systemd/system ] || pidof systemd >/dev/null 2>&1; then
|
||||
echo "linux"
|
||||
else
|
||||
local uname_output=$(uname -a)
|
||||
if echo "$uname_output" | grep -qi "Keenetic\|Netcraze"; then
|
||||
echo "keenetic"
|
||||
elif echo "$uname_output" | grep -qi "OpenWrt\|LEDE\|ImmortalWrt"; then
|
||||
echo "openwrt"
|
||||
elif echo "$uname_output" | grep -qi "Linux"; then
|
||||
echo "linux"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
detect_phobos_dir() {
|
||||
local platform="$1"
|
||||
|
||||
if [ "$platform" = "keenetic" ]; then
|
||||
echo "/opt/etc/Phobos"
|
||||
elif [ "$platform" = "openwrt" ]; then
|
||||
echo "/etc/Phobos"
|
||||
elif [ "$platform" = "linux" ]; then
|
||||
echo "/opt/Phobos"
|
||||
else
|
||||
if [ -d "/opt" ] && [ -d "/opt/etc" ]; then
|
||||
echo "/opt/etc/Phobos"
|
||||
else
|
||||
echo "/etc/Phobos"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
local arch=$(uname -m)
|
||||
case "$arch" in
|
||||
mips)
|
||||
if [ "$(echo -n I | hexdump -o 2>/dev/null | awk 'NR==1{print $2}')" = "000111" ]; then
|
||||
echo "mipsel"
|
||||
else
|
||||
echo "mips"
|
||||
fi
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "aarch64"
|
||||
;;
|
||||
armv7l|armv6l)
|
||||
echo "armv7"
|
||||
;;
|
||||
x86_64)
|
||||
echo "x86_64"
|
||||
;;
|
||||
*)
|
||||
echo "unknown"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Этот скрипт требует root привилегии. Запустите: su -c '$0'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
print_status() {
|
||||
local status="$1"
|
||||
local message="$2"
|
||||
|
||||
if [ "${status}" = "OK" ]; then
|
||||
printf "\\033[0;32m✓\\033[0m %s\n" "${message}"
|
||||
elif [ "${status}" = "WARN" ]; then
|
||||
printf "\\033[1;33m⚠\\033[0m %s\n" "${message}"
|
||||
else
|
||||
printf "\\033[0;31m✗\\033[0m %s\n" "${message}"
|
||||
fi
|
||||
}
|
||||
68
overlay/lib-core.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PHOBOS_DIR="${PHOBOS_DIR:-/opt/Phobos}"
|
||||
SERVER_ENV="${SERVER_ENV:-$PHOBOS_DIR/server/server.env}"
|
||||
CLIENTS_DIR="${CLIENTS_DIR:-$PHOBOS_DIR/clients}"
|
||||
PACKAGES_DIR="${PACKAGES_DIR:-$PHOBOS_DIR/packages}"
|
||||
WWW_DIR="${WWW_DIR:-$PHOBOS_DIR/www}"
|
||||
TOKENS_DIR="${TOKENS_DIR:-$PHOBOS_DIR/tokens}"
|
||||
TOKENS_FILE="${TOKENS_FILE:-$TOKENS_DIR/tokens.json}"
|
||||
REPO_DIR="${REPO_DIR:-$PHOBOS_DIR/repo}"
|
||||
WG_CONFIG="${WG_CONFIG:-/etc/wireguard/wg0.conf}"
|
||||
TOKEN_TTL="${TOKEN_TTL:-86400}"
|
||||
CLIENT_WG_PORT="${CLIENT_WG_PORT:-13255}"
|
||||
|
||||
log_info() { echo "[INFO] $*"; }
|
||||
log_warn() { echo "[WARN] $*" >&2; }
|
||||
log_success() { echo "[OK] $*"; }
|
||||
die() { echo "[ERROR] $*" >&2; exit 1; }
|
||||
|
||||
check_root() {
|
||||
[ "$(id -u)" = "0" ] || die "run as root"
|
||||
}
|
||||
|
||||
load_env() {
|
||||
if [ -f "$SERVER_ENV" ]; then
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
k="${line%%=*}"
|
||||
v="${line#*=}"
|
||||
case "$k" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
export "$k=$v"
|
||||
done < "$SERVER_ENV"
|
||||
fi
|
||||
|
||||
if [ -z "${SERVER_PUBLIC_IP_V4:-}" ]; then
|
||||
SERVER_PUBLIC_IP_V4="$(curl -4 -fsS --max-time 6 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}')"
|
||||
export SERVER_PUBLIC_IP_V4
|
||||
fi
|
||||
|
||||
if [ -z "${SERVER_WG_PUBLIC_KEY:-}" ]; then
|
||||
SERVER_WG_PUBLIC_KEY="$(wg show wg0 public-key 2>/dev/null || true)"
|
||||
export SERVER_WG_PUBLIC_KEY
|
||||
fi
|
||||
|
||||
if [ -n "${OBFUSCATOR_PORTS:-}" ]; then
|
||||
OBFUSCATOR_PORT="$(echo "$OBFUSCATOR_PORTS" | cut -d',' -f1 | tr -d ' ')"
|
||||
export OBFUSCATOR_PORT
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p \
|
||||
"$CLIENTS_DIR" \
|
||||
"$PACKAGES_DIR" \
|
||||
"$WWW_DIR/init" \
|
||||
"$WWW_DIR/packages" \
|
||||
"$TOKENS_DIR" \
|
||||
"$REPO_DIR/server/scripts" \
|
||||
"$REPO_DIR/client/templates"
|
||||
|
||||
[ -f "$TOKENS_FILE" ] || echo "[]" > "$TOKENS_FILE"
|
||||
}
|
||||
@@ -6,6 +6,12 @@ check_root
|
||||
load_env
|
||||
ensure_dirs
|
||||
|
||||
# server.env stores OBFUSCATOR_PORTS (plural, comma list). Client obfuscator
|
||||
# must target an ACTUAL listening port. lib-core load_env defaults OBFUSCATOR_PORT
|
||||
# to 51821 (NOT listened on) -> handshake failed. Override unconditionally from
|
||||
# the real port list when present.
|
||||
[ -n "${OBFUSCATOR_PORTS:-}" ] && export OBFUSCATOR_PORT="$(echo "$OBFUSCATOR_PORTS" | cut -d',' -f1)"
|
||||
|
||||
CMD="${1:-help}"
|
||||
CLIENT_ARG="${2:-}"
|
||||
EXTRA_ARG="${3:-}"
|
||||
@@ -39,38 +45,41 @@ action_add() {
|
||||
local server_pub="$SERVER_WG_PUBLIC_KEY"
|
||||
local server_ip_v4="${SERVER_PUBLIC_IP_V4:-}"
|
||||
local server_ip_v6="${SERVER_PUBLIC_IP_V6:-}"
|
||||
local server_wg_network="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||
|
||||
local client_ip_v4="$manual_ip"
|
||||
local ipv4_prefix_main=$(echo "${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}" | cut -d'/' -f1 | cut -d'.' -f1-2)
|
||||
local ipv6_prefix_main=$(echo "${SERVER_WG_IPV6_NETWORK:-fd00:10:25::/48}" | cut -d'/' -f1 | sed 's/::.*//')
|
||||
|
||||
if [[ -z "$client_ip_v4" ]]; then
|
||||
log_info "Поиск свободного IP..."
|
||||
declare -A used_ips
|
||||
|
||||
local used_ips=""
|
||||
for d in "$CLIENTS_DIR"/*; do
|
||||
if [[ -d "$d" ]] && [[ -f "$d/metadata.json" ]]; then
|
||||
local ip=$(jq -r '.tunnel_ip_v4 // empty' "$d/metadata.json" 2>/dev/null)
|
||||
[[ -n "$ip" ]] && used_ips["$ip"]=1
|
||||
local ip=$(jq -r '.tunnel_ip_v4 // empty' "$d/metadata.json" 2>/dev/null | cut -d/ -f1)
|
||||
[[ -n "$ip" ]] && used_ips="${used_ips}${ip}\n"
|
||||
fi
|
||||
done
|
||||
|
||||
used_ips["${ipv4_prefix_main}.0.1"]=1
|
||||
|
||||
local found=false
|
||||
for oct3 in {0..255}; do
|
||||
local start_oct4=2
|
||||
for oct4 in $(seq $start_oct4 254); do
|
||||
local candidate="${ipv4_prefix_main}.${oct3}.${oct4}"
|
||||
if [[ -z "${used_ips[$candidate]:-}" ]]; then
|
||||
client_ip_v4="$candidate"
|
||||
found=true
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
[[ "$found" == "false" ]] && die "Нет свободных IP в подсети."
|
||||
client_ip_v4="$(USED_IPS="$(printf "%b" "$used_ips")" python3 - "$server_wg_network" <<'PY'
|
||||
import ipaddress
|
||||
import os
|
||||
import sys
|
||||
|
||||
net = ipaddress.ip_network(sys.argv[1], strict=False)
|
||||
used = {line.strip() for line in os.environ.get("USED_IPS", "").splitlines() if line.strip()}
|
||||
hosts = net.hosts()
|
||||
reserved_first = True
|
||||
for candidate in hosts:
|
||||
ip = str(candidate)
|
||||
if reserved_first:
|
||||
reserved_first = False
|
||||
continue
|
||||
if ip not in used:
|
||||
print(ip)
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
)" || die "Нет свободных IP в подсети."
|
||||
fi
|
||||
|
||||
local oct3=$(echo "$client_ip_v4" | cut -d. -f3)
|
||||
@@ -99,7 +108,7 @@ action_add() {
|
||||
[Interface]
|
||||
PrivateKey = $priv_key
|
||||
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
|
||||
|
||||
[Peer]
|
||||
@@ -114,7 +123,7 @@ EOF
|
||||
[instance]
|
||||
source-if = 127.0.0.1
|
||||
source-lport = ${CLIENT_WG_PORT:-13255}
|
||||
target = $SERVER_PUBLIC_IP_V4:${OBFUSCATOR_PORT:-51821}
|
||||
target = ${CLIENT_ENDPOINT:-$SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORT:-51821}
|
||||
key = ${OBFUSCATOR_KEY:-KEY}
|
||||
masking = ${OBFUSCATOR_MASKING:-AUTO}
|
||||
verbose = INFO
|
||||
@@ -204,24 +213,42 @@ action_package() {
|
||||
local tmp=$(mktemp -d)
|
||||
local pkg_root="$tmp/phobos-$id"
|
||||
|
||||
mkdir -p "$PHOBOS_DIR/bin"
|
||||
mkdir -p "$pkg_root/bin"
|
||||
|
||||
cp "$dir/${id}.conf" "$pkg_root/${id}.conf"
|
||||
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
|
||||
|
||||
for arch in mipsel mips aarch64 armv7 x86_64; do
|
||||
[[ -f "$PHOBOS_DIR/bin/wg-obfuscator-$arch" ]] && cp "$PHOBOS_DIR/bin/wg-obfuscator-$arch" "$pkg_root/bin/"
|
||||
# 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/') || _tag="" # ponytail: GitHub API rate limit/недоступность не должны ронять сборку пакета — берём уже скачанные бинарники
|
||||
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")
|
||||
for _arch in aarch64 mipsel mips armv7 x86_64; do
|
||||
if [[ ! -f "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" ]] && [[ -n "$_tag" ]]; then
|
||||
local _t; _t=$(mktemp -d)
|
||||
if curl -fsSL -m30 "${_cb}/wg-obfuscator-${_tag}-${_am[$_arch]}.tar.gz" -o "$_t/o.tgz" 2>/dev/null; then
|
||||
tar -xzf "$_t/o.tgz" -C "$_t" 2>/dev/null || true
|
||||
local _b; _b=$(find "$_t" -name "wg-obfuscator" -type f | head -1)
|
||||
[[ -n "$_b" ]] && cp "$_b" "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" && chmod +x "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" && log_info "+wg-obfuscator-$_arch (ClusterM ${_tag})"
|
||||
fi
|
||||
rm -rf "$_t"
|
||||
fi
|
||||
[[ -f "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" ]] && cp "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" "$pkg_root/bin/"
|
||||
done
|
||||
|
||||
local tpl_dir="$REPO_DIR/client/templates"
|
||||
|
||||
if [[ -d "$tpl_dir" ]]; then
|
||||
for required in install-router.sh.template lib-client.sh install-obfuscator.sh install-wireguard.sh; do
|
||||
[[ -f "$tpl_dir/$required" ]] || die "Обязательный шаблон $required не найден в $tpl_dir. Выполни phobos-update и пересоздай пакет."
|
||||
done
|
||||
cp "$tpl_dir/install-router.sh.template" "$pkg_root/install-router.sh"
|
||||
sed -i "s|{{CLIENT_NAME}}|${id}|g" "$pkg_root/install-router.sh"
|
||||
chmod +x "$pkg_root/install-router.sh"
|
||||
[[ -f "$tpl_dir/lib-client.sh" ]] && cp "$tpl_dir/lib-client.sh" "$pkg_root/lib-client.sh"
|
||||
[[ -f "$tpl_dir/install-obfuscator.sh" ]] && cp "$tpl_dir/install-obfuscator.sh" "$pkg_root/install-obfuscator.sh"
|
||||
[[ -f "$tpl_dir/install-wireguard.sh" ]] && cp "$tpl_dir/install-wireguard.sh" "$pkg_root/install-wireguard.sh"
|
||||
cp "$tpl_dir/lib-client.sh" "$pkg_root/lib-client.sh"
|
||||
cp "$tpl_dir/install-obfuscator.sh" "$pkg_root/install-obfuscator.sh"
|
||||
cp "$tpl_dir/install-wireguard.sh" "$pkg_root/install-wireguard.sh"
|
||||
chmod +x "$pkg_root/lib-client.sh" "$pkg_root/install-obfuscator.sh" "$pkg_root/install-wireguard.sh"
|
||||
for f in router-configure-wireguard router-configure-wireguard-openwrt phobos-uninstall 3xui; do
|
||||
[[ -f "$tpl_dir/$f.sh" ]] && cp "$tpl_dir/$f.sh" "$pkg_root/$f.sh" && chmod +x "$pkg_root/$f.sh"
|
||||
done
|
||||
@@ -247,10 +274,42 @@ action_package() {
|
||||
[[ -n "$PULL_TOKEN" ]] && echo "$PULL_TOKEN" > "$pkg_root/pull_token"
|
||||
# Generate failover.conf with current server data
|
||||
source "$PHOBOS_DIR/server/server.env"
|
||||
local panel_port="${HTTP_PORT:-}"
|
||||
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
|
||||
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
|
||||
fi
|
||||
panel_port="${panel_port:-8443}"
|
||||
local panel_scheme="${PANEL_SCHEME:-}"
|
||||
if [[ -z "$panel_scheme" && -f /opt/phobos-panel/.scheme ]]; then
|
||||
panel_scheme=$(cat /opt/phobos-panel/.scheme 2>/dev/null || true)
|
||||
fi
|
||||
case "$panel_scheme" in http|https) ;; *) panel_scheme="http";; esac
|
||||
local panel_tunnel_ip=""
|
||||
panel_tunnel_ip="$(python3 - "${SERVER_WG_IPV4_ADDRESS:-}" "${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}" <<'PY'
|
||||
import ipaddress, sys
|
||||
iface = (sys.argv[1] or "").strip()
|
||||
network = (sys.argv[2] or "10.25.0.0/16").strip()
|
||||
if iface:
|
||||
try:
|
||||
print(ipaddress.ip_interface(iface).ip)
|
||||
raise SystemExit(0)
|
||||
except Exception:
|
||||
pass
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
print(next(net.hosts()))
|
||||
PY
|
||||
)"
|
||||
[[ -n "$panel_tunnel_ip" ]] && echo "$panel_tunnel_ip" > "$pkg_root/panel_tunnel_ip"
|
||||
echo "$panel_port" > "$pkg_root/panel_port"
|
||||
echo "$panel_scheme" > "$pkg_root/panel_scheme"
|
||||
echo "${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}" > "$pkg_root/panel_url"
|
||||
printf 'http://%s:8444 %s://%s:%s %s://%s:%s\n' \
|
||||
"$panel_tunnel_ip" "$panel_scheme" "$panel_tunnel_ip" "$panel_port" \
|
||||
"$panel_scheme" "$SERVER_PUBLIC_IP_V4" "$panel_port" > "$pkg_root/panel_urls"
|
||||
cat > "$pkg_root/failover.conf" <<FAILEOF
|
||||
# Phobos Failover Configuration
|
||||
SERVER_1=${SERVER_PUBLIC_IP_V4}:51821,51822,51823
|
||||
KEY_1=${OBFUSCATOR_KEY}
|
||||
SERVER_1=${CLIENT_ENDPOINT:-$SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORTS:-51821,51822,51823}
|
||||
KEY_1=${OBFUSCATOR_KEY:-}
|
||||
FAILEOF
|
||||
|
||||
find "$pkg_root" -type f ! -path "*/bin/*" -exec sed -i 's/\r$//' {} \;
|
||||
@@ -325,20 +384,45 @@ action_link() {
|
||||
ln -s "$PACKAGES_DIR/phobos-$id.tar.gz" "$link_dir/phobos-$id.tar.gz"
|
||||
|
||||
mkdir -p "$WWW_DIR/init"
|
||||
local script_url="http://${SERVER_PUBLIC_IP_V4}:${HTTP_PORT:-80}/packages/$token/phobos-$id.tar.gz"
|
||||
local panel_port="${HTTP_PORT:-}"
|
||||
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
|
||||
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
|
||||
fi
|
||||
panel_port="${panel_port:-8443}"
|
||||
local panel_scheme="${PANEL_SCHEME:-}"
|
||||
if [[ -z "$panel_scheme" && -f /opt/phobos-panel/.scheme ]]; then
|
||||
panel_scheme=$(cat /opt/phobos-panel/.scheme 2>/dev/null || true)
|
||||
fi
|
||||
case "$panel_scheme" in http|https) ;; *) panel_scheme="http";; esac
|
||||
local primary_url="${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}/packages/$token/phobos-$id.tar.gz"
|
||||
local fallback_url="http://${SERVER_PUBLIC_IP_V4}:80/packages/$token/phobos-$id.tar.gz"
|
||||
|
||||
cat > "$WWW_DIR/init/$token.sh" <<EOF
|
||||
#!/bin/sh
|
||||
url="$script_url"
|
||||
urls="$primary_url $fallback_url"
|
||||
dir="/tmp/phobos_install_\$\$"
|
||||
mkdir -p "\$dir"
|
||||
echo "Downloading..."
|
||||
if command -v curl >/dev/null; then
|
||||
curl -L -s -o "\$dir/package.tar.gz" "\$url"
|
||||
else
|
||||
wget -q -O "\$dir/package.tar.gz" "\$url"
|
||||
fi
|
||||
if [ ! -f "\$dir/package.tar.gz" ]; then echo "Download failed"; exit 1; fi
|
||||
ok=0
|
||||
for url in \$urls; do
|
||||
rm -f "\$dir/package.tar.gz"
|
||||
if command -v curl >/dev/null; then
|
||||
curl -k -L -s -o "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
|
||||
else
|
||||
wget --no-check-certificate -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || wget -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
|
||||
fi
|
||||
if grep -qi '<html' "\$dir/package.tar.gz" 2>/dev/null; then
|
||||
echo "Bad response from \$url (HTML/proxy page), trying next..."
|
||||
continue
|
||||
fi
|
||||
tar tzf "\$dir/package.tar.gz" >/dev/null 2>&1 || {
|
||||
echo "Bad package from \$url, trying next..."
|
||||
continue
|
||||
}
|
||||
ok=1
|
||||
break
|
||||
done
|
||||
[ "\$ok" = "1" ] || { echo "Download failed: no valid package"; exit 1; }
|
||||
cd "\$dir"
|
||||
tar xzf package.tar.gz
|
||||
cd "phobos-$id"
|
||||
@@ -353,7 +437,9 @@ EOF
|
||||
chmod 755 "$PACKAGES_DIR" 2>/dev/null
|
||||
chmod 644 "$PACKAGES_DIR/phobos-$id.tar.gz" 2>/dev/null
|
||||
|
||||
local cmd="curl -s http://${SERVER_PUBLIC_IP_V4}:${HTTP_PORT:-80}/init/$token.sh | sh"
|
||||
local init_primary="${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}/init/$token.sh"
|
||||
local init_fallback="http://${SERVER_PUBLIC_IP_V4}:80/init/$token.sh"
|
||||
local cmd="p=/tmp/phobos-init-\$\$.sh; ok=0; for u in $init_primary $init_fallback; do rm -f \$p; (wget -q -O \$p \$u || wget --no-check-certificate -q -O \$p \$u || curl -kfsSL -o \$p \$u || curl -fsSL -o \$p \$u) 2>/dev/null || continue; head -c 2 \$p | grep -q \"#!\" || continue; sh \$p && ok=1 && break; done; rm -f \$p; [ \$ok -eq 1 ] || echo ERROR_install_script_download_failed"
|
||||
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/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.
|
||||
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
|
||||
@@ -21,21 +22,30 @@
|
||||
# PANEL env or default below -> panel base URL
|
||||
# ============================================================
|
||||
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"
|
||||
LOG="$PHOBOS_DIR/health.log"
|
||||
# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 is the wg0 of whatever
|
||||
# server the tunnel currently terminates on, so management rides the same
|
||||
# obfuscated channel as data — survives a public-IP ban and follows failover.
|
||||
# - 10.25.0.1:8444 -> secondary server agent (phobos-api)
|
||||
# - 10.25.0.1:10514 -> primary server panel
|
||||
LOG="$ACC_DIR/health.log"
|
||||
# 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
|
||||
# same obfuscated channel as data — survives a public-IP ban and follows
|
||||
# failover.
|
||||
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
|
||||
# - panel_tunnel_ip:<panel_port> -> primary server panel
|
||||
# - public panel IP -> bootstrap / if tunnel is down
|
||||
PANEL="${PANEL:-http://212.118.52.193:10514}"
|
||||
PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}"
|
||||
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
|
||||
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
|
||||
TMP="/tmp/failover.conf.pull"
|
||||
LOCK="/tmp/phobos-pull.lock"
|
||||
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"
|
||||
PANEL="${PANEL:-$(cat "$ACC_DIR/panel_url" 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"
|
||||
# + public URLs the panel announced in failover.conf (second IP etc., 09.33)
|
||||
_extra=$(grep "^PANEL_URLS_EXTRA=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||
for _u in $_extra; do case " $PANEL_URLS " in *" $_u "*) ;; *) PANEL_URLS="$PANEL_URLS $_u" ;; esac; done
|
||||
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"; }
|
||||
|
||||
@@ -49,12 +59,46 @@ trap 'rm -f "$LOCK"' EXIT
|
||||
|
||||
[ -z "$TOKEN" ] && exit 0
|
||||
|
||||
detect_tunnel_iface() {
|
||||
ip route get "$PANEL_TUNNEL_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1
|
||||
}
|
||||
|
||||
fetch_config() {
|
||||
base="$1"
|
||||
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
|
||||
case "$base" in
|
||||
"http://${PANEL_TUNNEL_IP}:"*|"https://${PANEL_TUNNEL_IP}:"*)
|
||||
iface=$(detect_tunnel_iface)
|
||||
if [ -n "$iface" ] && command -v curl >/dev/null; then
|
||||
curl -k --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if command -v curl >/dev/null; then
|
||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||
fi
|
||||
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).
|
||||
do_pull() {
|
||||
ensure_udptcp
|
||||
# Try each source (tunnel first); accept first that returns a valid conf.
|
||||
got=0
|
||||
for base in $PANEL_URLS; do
|
||||
curl -s -m 8 -o "$TMP" "${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}" 2>/dev/null || continue
|
||||
fetch_config "$base" || continue
|
||||
if grep -q "^SERVER_1=" "$TMP" 2>/dev/null; then got=1; break; fi
|
||||
done
|
||||
[ "$got" = 1 ] || { rm -f "$TMP"; return 0; }
|
||||
@@ -77,6 +121,48 @@ do_pull() {
|
||||
if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then
|
||||
sh "$HEALTH" apply-server 1
|
||||
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
|
||||
|
||||
67
overlay/phobos-ttyd.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/bin/sh
|
||||
# ============================================================
|
||||
# PCA Phobos — ttyd web-terminal на роутере Keenetic
|
||||
#
|
||||
# phobos-ttyd.sh <client_id> <token> [server_tunnel_ip]
|
||||
#
|
||||
# Ставит ttyd (web pty), открывает порт на WG-интерфейсе ТОЛЬКО для панели
|
||||
# (server tunnel IP), создаёт init-сервис (переживает перезагрузку).
|
||||
# Панель проксирует терминал к роутеру через WG-туннель (за NAT, без SSH).
|
||||
# ============================================================
|
||||
CID="${1:?usage: phobos-ttyd.sh <client_id> <token> [server_ip]}"
|
||||
TOKEN="${2:?token required}"
|
||||
SRV_IP="${3:-$(cat /opt/etc/Phobos/panel_tunnel_ip 2>/dev/null)}"
|
||||
[ -n "$SRV_IP" ] || SRV_IP="10.25.0.1"
|
||||
PORT="${TTYD_PORT:-7681}"
|
||||
SHELL_BIN="$( [ -x /opt/bin/bash ] && echo /opt/bin/bash || echo sh )"
|
||||
|
||||
# WG tunnel netdev (best effort: interface holding RFC1918 or explicit panel route)
|
||||
WGDEV=$(ip route get "$SRV_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
||||
[ -z "$WGDEV" ] && WGDEV=nwg3
|
||||
|
||||
opkg update >/dev/null 2>&1
|
||||
opkg install ttyd >/dev/null 2>&1
|
||||
opkg install iptables >/dev/null 2>&1
|
||||
TTYD_BIN="$(command -v ttyd || echo /opt/bin/ttyd)"
|
||||
[ -x "$TTYD_BIN" ] || { echo "ERROR: ttyd not installed"; exit 1; }
|
||||
|
||||
mkdir -p /opt/etc/Phobos
|
||||
echo "$TOKEN" > /opt/etc/Phobos/ttyd_token
|
||||
chmod 600 /opt/etc/Phobos/ttyd_token
|
||||
|
||||
# init.d service: opens firewall for panel + runs ttyd (base-path + basic auth)
|
||||
cat > /opt/etc/init.d/S51phobos-ttyd <<EOF
|
||||
#!/bin/sh
|
||||
PORT=$PORT
|
||||
WGDEV=$WGDEV
|
||||
SRV_IP=$SRV_IP
|
||||
CID=$CID
|
||||
TTYD=$TTYD_BIN
|
||||
SH=$SHELL_BIN
|
||||
TOKEN=\$(cat /opt/etc/Phobos/ttyd_token 2>/dev/null)
|
||||
start() {
|
||||
# firewall: allow ONLY the panel (server tunnel IP) to reach ttyd over WG
|
||||
iptables -D INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT 2>/dev/null
|
||||
iptables -I INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT
|
||||
pkill -f "ttyd -p \$PORT" 2>/dev/null; sleep 1
|
||||
# busybox has no setsid/nohup; plain background detaches fine on Entware.
|
||||
# no -i flag => ttyd binds all interfaces (0.0.0.0).
|
||||
"\$TTYD" -p "\$PORT" -b "/ttyd/\$CID" -c "phobos:\$TOKEN" --writable "\$SH" >/tmp/phobos-ttyd.log 2>&1 &
|
||||
sleep 1
|
||||
echo "ttyd started on \$PORT (base /ttyd/\$CID, panel-only via \$SRV_IP)"
|
||||
}
|
||||
stop() {
|
||||
pkill -f "ttyd -p \$PORT" 2>/dev/null
|
||||
iptables -D INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT 2>/dev/null
|
||||
}
|
||||
case "\$1" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
restart) stop; sleep 1; start ;;
|
||||
*) echo "usage: \$0 {start|stop|restart}" ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x /opt/etc/init.d/S51phobos-ttyd
|
||||
/opt/etc/init.d/S51phobos-ttyd restart
|
||||
|
||||
echo "OK: ttyd на роутере, base-path /ttyd/$CID, порт $PORT (только для $SRV_IP по $WGDEV)."
|
||||
543
overlay/phobos-uninstall.sh
Executable file
@@ -0,0 +1,543 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
PHOBOS_DIR=""
|
||||
ROUTER_PLATFORM=""
|
||||
SELECTED_CONF=""
|
||||
IS_FULL_REMOVAL="1"
|
||||
OBFUSCATOR_CONFIGS=""
|
||||
OBFUSCATOR_CONFIG_COUNT=0
|
||||
|
||||
. "$(dirname "$0")/lib-client.sh"
|
||||
|
||||
collect_obf_configs() {
|
||||
OBFUSCATOR_CONFIGS=""
|
||||
OBFUSCATOR_CONFIG_COUNT=0
|
||||
[ -d "$PHOBOS_DIR" ] || return
|
||||
for f in "$PHOBOS_DIR"/wg-obfuscator*.conf; do
|
||||
[ -f "$f" ] || continue
|
||||
OBFUSCATOR_CONFIGS="${OBFUSCATOR_CONFIGS}$(basename "$f" .conf) "
|
||||
OBFUSCATOR_CONFIG_COUNT=$((OBFUSCATOR_CONFIG_COUNT + 1))
|
||||
done
|
||||
}
|
||||
|
||||
get_config_label() {
|
||||
local obf_conf_base="$1"
|
||||
local idx_suffix="${obf_conf_base#wg-obfuscator}"
|
||||
local wg_iface="phobos${idx_suffix}"
|
||||
local addr=""
|
||||
if [ -f "/etc/wireguard/${wg_iface}.conf" ]; then
|
||||
addr=$(grep '^Address' "/etc/wireguard/${wg_iface}.conf" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' | cut -d'/' -f1 | cut -d',' -f1) || addr=""
|
||||
fi
|
||||
if [ -n "$addr" ]; then
|
||||
printf "%s [интерфейс: %s, адрес: %s]" "$obf_conf_base" "$wg_iface" "$addr"
|
||||
else
|
||||
printf "%s" "$obf_conf_base"
|
||||
fi
|
||||
}
|
||||
|
||||
select_config_to_remove() {
|
||||
collect_obf_configs
|
||||
|
||||
if [ "$OBFUSCATOR_CONFIG_COUNT" -eq 0 ]; then
|
||||
IS_FULL_REMOVAL="1"
|
||||
SELECTED_CONF="all"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$OBFUSCATOR_CONFIG_COUNT" -eq 1 ]; then
|
||||
SELECTED_CONF=$(echo "$OBFUSCATOR_CONFIGS" | tr -s ' ' '\n' | grep -v '^$' | head -1)
|
||||
IS_FULL_REMOVAL="1"
|
||||
log "Обнаружена конфигурация: ${SELECTED_CONF}"
|
||||
return
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "Обнаружено несколько конфигураций:"
|
||||
log ""
|
||||
|
||||
local i=1
|
||||
for conf_base in $OBFUSCATOR_CONFIGS; do
|
||||
local label
|
||||
label=$(get_config_label "$conf_base")
|
||||
log " ${i}) ${label}"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
local all_num=$i
|
||||
log " ${all_num}) Удалить все"
|
||||
log ""
|
||||
|
||||
printf "Введите номер конфигурации для удаления: "
|
||||
read -r answer || answer=""
|
||||
|
||||
case "$answer" in
|
||||
''|*[!0-9]*)
|
||||
log "ОШИБКА: Неверный ввод"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$answer" -eq "$all_num" ]; then
|
||||
IS_FULL_REMOVAL="1"
|
||||
SELECTED_CONF="all"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$answer" -lt 1 ] || [ "$answer" -ge "$all_num" ]; then
|
||||
log "ОШИБКА: Номер вне диапазона"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local selected_i=1
|
||||
for conf_base in $OBFUSCATOR_CONFIGS; do
|
||||
if [ "$selected_i" -eq "$answer" ]; then
|
||||
SELECTED_CONF="$conf_base"
|
||||
IS_FULL_REMOVAL="0"
|
||||
return
|
||||
fi
|
||||
selected_i=$((selected_i + 1))
|
||||
done
|
||||
}
|
||||
|
||||
remove_single_obfuscator_instance() {
|
||||
local obf_conf_base="$1"
|
||||
local idx_suffix="${obf_conf_base#wg-obfuscator}"
|
||||
local binary_name="wg-obfuscator${idx_suffix}"
|
||||
local service_name="phobos-obfuscator${idx_suffix}"
|
||||
local wg_iface="phobos${idx_suffix}"
|
||||
|
||||
log "Удаление конфигурации ${obf_conf_base}..."
|
||||
|
||||
local link_file="$PHOBOS_DIR/${obf_conf_base}.link"
|
||||
local linked_client=""
|
||||
if [ -f "$link_file" ]; then
|
||||
linked_client=$(cat "$link_file" 2>/dev/null) || linked_client=""
|
||||
fi
|
||||
|
||||
local privkey_fallback=""
|
||||
if [ -z "$linked_client" ] && [ "$ROUTER_PLATFORM" = "linux" ] && [ -f "/etc/wireguard/${wg_iface}.conf" ]; then
|
||||
privkey_fallback=$(grep '^PrivateKey' "/etc/wireguard/${wg_iface}.conf" 2>/dev/null | cut -d'=' -f2- | tr -d ' \t') || privkey_fallback=""
|
||||
fi
|
||||
|
||||
for f in /opt/etc/init.d/S[0-9]*wg-obfuscator*; do
|
||||
[ -f "$f" ] || continue
|
||||
if grep -q "^PROCS=${binary_name}$" "$f" 2>/dev/null; then
|
||||
"$f" stop >/dev/null 2>&1 || true
|
||||
rm -f "$f"
|
||||
log " ✓ Удален init-скрипт: $f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "/etc/init.d/${service_name}" ]; then
|
||||
/etc/init.d/${service_name} stop >/dev/null 2>&1 || true
|
||||
/etc/init.d/${service_name} disable >/dev/null 2>&1 || true
|
||||
rm -f "/etc/init.d/${service_name}"
|
||||
log " ✓ Удален procd init-скрипт: /etc/init.d/${service_name}"
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -f "/etc/systemd/system/${service_name}.service" ]; then
|
||||
systemctl stop "${service_name}" >/dev/null 2>&1 || true
|
||||
systemctl disable "${service_name}" >/dev/null 2>&1 || true
|
||||
rm -f "/etc/systemd/system/${service_name}.service"
|
||||
log " ✓ Удален systemd сервис: /etc/systemd/system/${service_name}.service"
|
||||
fi
|
||||
|
||||
for dir in /opt/bin /usr/bin /usr/local/bin; do
|
||||
if [ -f "${dir}/${binary_name}" ]; then
|
||||
rm -f "${dir}/${binary_name}"
|
||||
log " ✓ Удален бинарник: ${dir}/${binary_name}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ROUTER_PLATFORM" = "linux" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop "wg-quick@${wg_iface}" >/dev/null 2>&1 || true
|
||||
systemctl disable "wg-quick@${wg_iface}" >/dev/null 2>&1 || true
|
||||
if [ -d "/etc/systemd/system/wg-quick@${wg_iface}.service.d" ]; then
|
||||
rm -rf "/etc/systemd/system/wg-quick@${wg_iface}.service.d"
|
||||
log " ✓ Удален systemd override: wg-quick@${wg_iface}"
|
||||
fi
|
||||
if [ -f "/etc/wireguard/${wg_iface}.conf" ]; then
|
||||
rm -f "/etc/wireguard/${wg_iface}.conf"
|
||||
log " ✓ Удален WireGuard конфиг: /etc/wireguard/${wg_iface}.conf"
|
||||
fi
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
elif [ "$ROUTER_PLATFORM" = "keenetic" ]; then
|
||||
if command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 && [ -n "$linked_client" ]; then
|
||||
local target_desc="Phobos-${linked_client}"
|
||||
log " Удаление WireGuard интерфейса Keenetic: ${target_desc}..."
|
||||
local iface_list=$(curl -s "http://127.0.0.1:79/rci/show/interface" 2>/dev/null || echo "")
|
||||
if [ -n "$iface_list" ] && echo "$iface_list" | jq -e . >/dev/null 2>&1; then
|
||||
local iface_id=$(echo "$iface_list" | jq -r --arg desc "$target_desc" 'to_entries[] | select((.value.description? // "") == $desc) | .key' 2>/dev/null)
|
||||
if [ -n "$iface_id" ]; then
|
||||
local del_json=$(cat <<EOF
|
||||
{
|
||||
"interface": {
|
||||
"$iface_id": {
|
||||
"no": true
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
local del_result=$(echo "$del_json" | curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- \
|
||||
"http://127.0.0.1:79/rci/" 2>/dev/null)
|
||||
if echo "$del_result" | jq -e '.status == "error"' >/dev/null 2>&1; then
|
||||
local err_msg=$(echo "$del_result" | jq -r '.message // "Unknown error"' 2>/dev/null)
|
||||
log " ОШИБКА при удалении ${iface_id}: $err_msg"
|
||||
else
|
||||
log " ✓ Интерфейс ${iface_id} (${target_desc}) удален"
|
||||
curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"system":{"configuration":{"save":{}}}}' \
|
||||
"http://127.0.0.1:79/rci/" >/dev/null 2>&1
|
||||
log " ✓ Конфигурация сохранена"
|
||||
fi
|
||||
else
|
||||
log " Интерфейс с описанием '${target_desc}' не найден"
|
||||
fi
|
||||
else
|
||||
log " RCI API недоступен, пропускаем удаление интерфейса Keenetic"
|
||||
fi
|
||||
else
|
||||
log " curl/jq не доступны или имя клиента не определено, пропускаем удаление интерфейса Keenetic"
|
||||
fi
|
||||
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
||||
remove_wireguard_interfaces_openwrt
|
||||
remove_firewall_zone_openwrt
|
||||
fi
|
||||
|
||||
if [ -f "$PHOBOS_DIR/${obf_conf_base}.conf" ]; then
|
||||
rm -f "$PHOBOS_DIR/${obf_conf_base}.conf"
|
||||
log " ✓ Удален конфиг obfuscator: $PHOBOS_DIR/${obf_conf_base}.conf"
|
||||
fi
|
||||
|
||||
[ -f "$link_file" ] && rm -f "$link_file"
|
||||
|
||||
if [ -n "$linked_client" ] && [ -f "$PHOBOS_DIR/${linked_client}.conf" ]; then
|
||||
rm -f "$PHOBOS_DIR/${linked_client}.conf"
|
||||
log " ✓ Удален конфиг клиента: ${linked_client}.conf"
|
||||
elif [ -n "$privkey_fallback" ]; then
|
||||
for client_conf in "$PHOBOS_DIR"/*.conf; do
|
||||
[ -f "$client_conf" ] || continue
|
||||
case "$(basename "$client_conf")" in
|
||||
wg-obfuscator*.conf) continue ;;
|
||||
esac
|
||||
if grep -qF "PrivateKey = ${privkey_fallback}" "$client_conf" 2>/dev/null; then
|
||||
rm -f "$client_conf"
|
||||
log " ✓ Удален конфиг клиента: $(basename "$client_conf")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
local remaining=0
|
||||
for f in "$PHOBOS_DIR"/*.conf; do
|
||||
[ -f "$f" ] && remaining=$((remaining + 1))
|
||||
done
|
||||
|
||||
if [ "$remaining" -eq 0 ] && [ -d "$PHOBOS_DIR" ]; then
|
||||
rm -rf "$PHOBOS_DIR"
|
||||
log " ✓ Удалена директория: $PHOBOS_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_obfuscator() {
|
||||
log "Остановка wg-obfuscator..."
|
||||
|
||||
for f in /opt/etc/init.d/S[0-9]*wg-obfuscator*; do
|
||||
[ -f "$f" ] && "$f" stop >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
for f in /etc/init.d/phobos-obfuscator*; do
|
||||
if [ -f "$f" ]; then
|
||||
"$f" stop >/dev/null 2>&1 || true
|
||||
"$f" disable >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl list-units --no-legend --plain 'phobos-obfuscator*.service' | awk '{print $1}' | while read -r unit; do
|
||||
systemctl stop "$unit" >/dev/null 2>&1 || true
|
||||
systemctl disable "$unit" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
if ps | grep -v grep | grep -q wg-obfuscator; then
|
||||
log " Принудительное завершение процесса..."
|
||||
pkill -f wg-obfuscator >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
remove_wireguard_interfaces_keenetic() {
|
||||
log "Удаление WireGuard интерфейсов Phobos (Keenetic)..."
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
|
||||
log " curl или jq не установлен, пропускаем удаление интерфейсов"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local interfaces=$(curl -s "http://127.0.0.1:79/rci/show/interface" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$interfaces" ] || ! echo "$interfaces" | jq -e . >/dev/null 2>&1; then
|
||||
log " RCI API недоступен или вернул некорректный JSON"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local removed_count=0
|
||||
|
||||
local phobos_interfaces=$(echo "$interfaces" | jq -r 'to_entries[] | select(.value.description? // "" | startswith("Phobos-")) | .key' 2>/dev/null)
|
||||
|
||||
if [ -z "$phobos_interfaces" ]; then
|
||||
log " Интерфейсы Phobos не найдены в системе"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$phobos_interfaces" | while read -r interface_id; do
|
||||
if [ -z "$interface_id" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local interface_desc=$(echo "$interfaces" | jq -r --arg id "$interface_id" '.[$id].description // "unknown"' 2>/dev/null)
|
||||
|
||||
log " Удаление интерфейса: $interface_id ($interface_desc)"
|
||||
|
||||
local delete_json=$(cat <<EOF
|
||||
{
|
||||
"interface": {
|
||||
"$interface_id": {
|
||||
"no": true
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
local result=$(echo "$delete_json" | curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- \
|
||||
"http://127.0.0.1:79/rci/" 2>/dev/null)
|
||||
|
||||
if echo "$result" | jq -e '.status == "error"' >/dev/null 2>&1; then
|
||||
local error_msg=$(echo "$result" | jq -r '.message // "Unknown error"' 2>/dev/null)
|
||||
log " ОШИБКА при удалении $interface_id: $error_msg"
|
||||
else
|
||||
log " ✓ Интерфейс $interface_id удален"
|
||||
removed_count=$((removed_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
local save_result=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"system":{"configuration":{"save":{}}}}' \
|
||||
"http://127.0.0.1:79/rci/" 2>/dev/null)
|
||||
|
||||
if [ $removed_count -gt 0 ]; then
|
||||
log "✓ Удалено интерфейсов: $removed_count"
|
||||
log "✓ Конфигурация сохранена"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_wireguard_interfaces_openwrt() {
|
||||
log "Удаление WireGuard интерфейсов Phobos (OpenWRT)..."
|
||||
|
||||
if ! command -v uci >/dev/null 2>&1; then
|
||||
log " uci не установлен, пропускаем удаление интерфейсов"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local interface_name="phobos_wg"
|
||||
|
||||
if uci -q get network.${interface_name} >/dev/null 2>&1; then
|
||||
log " Удаление интерфейса: ${interface_name}"
|
||||
|
||||
ifdown ${interface_name} >/dev/null 2>&1 || true
|
||||
|
||||
uci -q delete network.${interface_name} || true
|
||||
|
||||
local peers=$(uci show network 2>/dev/null | grep "wireguard_${interface_name}" | cut -d'.' -f2 | cut -d'=' -f1 | sort -u)
|
||||
for peer in $peers; do
|
||||
uci -q delete network.$peer || true
|
||||
done
|
||||
|
||||
uci commit network
|
||||
|
||||
log " ✓ Интерфейс ${interface_name} удален"
|
||||
else
|
||||
log " Интерфейс ${interface_name} не найден"
|
||||
fi
|
||||
|
||||
/etc/init.d/network reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
remove_firewall_zone_openwrt() {
|
||||
log "Удаление файрволл зоны Phobos (OpenWRT)..."
|
||||
|
||||
if ! command -v uci >/dev/null 2>&1; then
|
||||
log " uci не установлен, пропускаем удаление зоны"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local zone_name="phobos"
|
||||
|
||||
if uci -q get firewall.${zone_name} >/dev/null 2>&1; then
|
||||
log " Удаление зоны: ${zone_name}"
|
||||
uci -q delete firewall.${zone_name} || true
|
||||
uci commit firewall
|
||||
log " ✓ Зона ${zone_name} удалена"
|
||||
|
||||
/etc/init.d/firewall reload >/dev/null 2>&1 || true
|
||||
else
|
||||
log " Зона ${zone_name} не найдена"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_wireguard_interfaces_linux() {
|
||||
log "Удаление WireGuard интерфейсов Phobos (Linux)..."
|
||||
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
log " systemctl не найден, пропускаем удаление интерфейсов"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for wg_conf in /etc/wireguard/phobos*.conf; do
|
||||
[ -f "$wg_conf" ] || continue
|
||||
local wg_interface
|
||||
wg_interface=$(basename "$wg_conf" .conf)
|
||||
|
||||
if systemctl is-enabled --quiet wg-quick@${wg_interface} 2>/dev/null || systemctl is-active --quiet wg-quick@${wg_interface} 2>/dev/null; then
|
||||
log " Остановка и удаление WireGuard сервиса: wg-quick@${wg_interface}"
|
||||
systemctl stop wg-quick@${wg_interface} >/dev/null 2>&1 || true
|
||||
systemctl disable wg-quick@${wg_interface} >/dev/null 2>&1 || true
|
||||
log " ✓ Сервис wg-quick@${wg_interface} остановлен и отключен"
|
||||
fi
|
||||
|
||||
if [ -d "/etc/systemd/system/wg-quick@${wg_interface}.service.d" ]; then
|
||||
log " Удаление systemd override: /etc/systemd/system/wg-quick@${wg_interface}.service.d"
|
||||
rm -rf "/etc/systemd/system/wg-quick@${wg_interface}.service.d"
|
||||
log " ✓ Systemd override удален"
|
||||
fi
|
||||
|
||||
log " Удаление конфигурации: $wg_conf"
|
||||
rm -f "$wg_conf"
|
||||
log " ✓ Конфигурационный файл WireGuard удален"
|
||||
done
|
||||
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
remove_files() {
|
||||
log "Удаление файлов Phobos..."
|
||||
|
||||
for f in /opt/etc/init.d/S[0-9]*wg-obfuscator*; do
|
||||
if [ -f "$f" ]; then
|
||||
rm -f "$f"
|
||||
log " ✓ Удален init-скрипт: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in /etc/init.d/phobos-obfuscator*; do
|
||||
if [ -f "$f" ]; then
|
||||
rm -f "$f"
|
||||
log " ✓ Удален procd init-скрипт: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in /opt/bin/wg-obfuscator*; do
|
||||
if [ -f "$f" ]; then
|
||||
rm -f "$f"
|
||||
log " ✓ Удален бинарник: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in /usr/bin/wg-obfuscator*; do
|
||||
if [ -f "$f" ]; then
|
||||
rm -f "$f"
|
||||
log " ✓ Удален бинарник: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in /usr/local/bin/wg-obfuscator*; do
|
||||
if [ -f "$f" ]; then
|
||||
rm -f "$f"
|
||||
log " ✓ Удален бинарник: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
local daemon_reload_needed=0
|
||||
for f in /etc/systemd/system/phobos-obfuscator*.service; do
|
||||
if [ -f "$f" ]; then
|
||||
rm -f "$f"
|
||||
daemon_reload_needed=1
|
||||
log " ✓ Удален systemd сервис: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $daemon_reload_needed -eq 1 ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -d "$PHOBOS_DIR" ]; then
|
||||
rm -rf "$PHOBOS_DIR"
|
||||
log " ✓ Удалена директория: $PHOBOS_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
show_final_info() {
|
||||
log ""
|
||||
log "╔════════════════════════════════════════════════════════════╗"
|
||||
log "║ Phobos успешно удален ║"
|
||||
log "╚════════════════════════════════════════════════════════════╝"
|
||||
log ""
|
||||
log "Удалены компоненты:"
|
||||
log " - wg-obfuscator (процесс, init-скрипт/сервис, бинарник)"
|
||||
log " - WireGuard интерфейсы Phobos"
|
||||
log " - Конфигурационные файлы"
|
||||
log ""
|
||||
}
|
||||
|
||||
main() {
|
||||
ROUTER_PLATFORM=$(detect_router_platform)
|
||||
PHOBOS_DIR=$(detect_phobos_dir "$ROUTER_PLATFORM")
|
||||
|
||||
log "==> Начало удаления Phobos (платформа: $ROUTER_PLATFORM)"
|
||||
log "==> Директория: $PHOBOS_DIR"
|
||||
|
||||
check_root
|
||||
|
||||
select_config_to_remove
|
||||
|
||||
if [ "$IS_FULL_REMOVAL" = "1" ]; then
|
||||
stop_obfuscator
|
||||
|
||||
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
|
||||
remove_wireguard_interfaces_keenetic
|
||||
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
||||
remove_wireguard_interfaces_openwrt
|
||||
remove_firewall_zone_openwrt
|
||||
elif [ "$ROUTER_PLATFORM" = "linux" ]; then
|
||||
remove_wireguard_interfaces_linux
|
||||
else
|
||||
log "ПРЕДУПРЕЖДЕНИЕ: Неизвестная платформа, пропускаем удаление WireGuard интерфейсов"
|
||||
fi
|
||||
|
||||
remove_files
|
||||
|
||||
show_final_info
|
||||
else
|
||||
log "==> Удаление конфигурации: ${SELECTED_CONF}"
|
||||
remove_single_obfuscator_instance "$SELECTED_CONF"
|
||||
log ""
|
||||
log "╔════════════════════════════════════════════════════════════╗"
|
||||
log "║ Конфигурация успешно удалена ║"
|
||||
log "╚════════════════════════════════════════════════════════════╝"
|
||||
log ""
|
||||
fi
|
||||
|
||||
log "==> Удаление завершено."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
349
overlay/router-configure-wireguard-openwrt.sh
Executable file
@@ -0,0 +1,349 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
CLIENT_NAME=""
|
||||
CLIENT_PRIVATE_KEY=""
|
||||
CLIENT_IP=""
|
||||
CLIENT_IPV6=""
|
||||
SERVER_PUBLIC_KEY=""
|
||||
ENDPOINT_PORT=13255
|
||||
KEEPALIVE=25
|
||||
MTU=1420
|
||||
FALLBACK_CONFIG=""
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo "[ERROR] $*" >&2
|
||||
log "ERROR: $*"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Options:
|
||||
--client-name NAME Client name (required)
|
||||
--client-private-key KEY WireGuard private key (required)
|
||||
--client-ip IP Client tunnel IPv4 address (required)
|
||||
--client-ipv6 IP Client tunnel IPv6 address (required)
|
||||
--server-public-key KEY Server WireGuard public key (required)
|
||||
--endpoint-port PORT Local obfuscator port (default: 13255)
|
||||
--keepalive SECONDS Keepalive interval (default: 25)
|
||||
--mtu MTU Interface MTU (default: 1420)
|
||||
--fallback-config PATH Path to fallback .conf file
|
||||
--help Show this help
|
||||
|
||||
Example:
|
||||
$0 --client-name home \\
|
||||
--client-private-key "ABCD..." \\
|
||||
--client-ip 10.25.0.4/32 \\
|
||||
--client-ipv6 fd00:10:25::4/128 \\
|
||||
--server-public-key "EFGH..." \\
|
||||
--endpoint-port 13255 \\
|
||||
--fallback-config /opt/etc/Phobos/home.conf
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_dependencies() {
|
||||
local missing=""
|
||||
|
||||
for cmd in uci wg; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
missing="$missing $cmd"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
error "Missing required utilities:$missing"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--client-name)
|
||||
CLIENT_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--client-private-key)
|
||||
CLIENT_PRIVATE_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--client-ip)
|
||||
CLIENT_IP="$2"
|
||||
shift 2
|
||||
;;
|
||||
--client-ipv6)
|
||||
CLIENT_IPV6="$2"
|
||||
shift 2
|
||||
;;
|
||||
--server-public-key)
|
||||
SERVER_PUBLIC_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--endpoint-port)
|
||||
ENDPOINT_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--keepalive)
|
||||
KEEPALIVE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mtu)
|
||||
MTU="$2"
|
||||
shift 2
|
||||
;;
|
||||
--fallback-config)
|
||||
FALLBACK_CONFIG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
error "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "${CLIENT_NAME}" ] || [ -z "${CLIENT_PRIVATE_KEY}" ] || \
|
||||
[ -z "${CLIENT_IP}" ] || [ -z "${CLIENT_IPV6}" ] || \
|
||||
[ -z "${SERVER_PUBLIC_KEY}" ]; then
|
||||
error "Missing required parameters"
|
||||
usage
|
||||
fi
|
||||
}
|
||||
|
||||
check_existing_interface() {
|
||||
local interface_name="phobos_wg"
|
||||
|
||||
if uci -q get network.${interface_name} >/dev/null 2>&1; then
|
||||
log "Найден существующий интерфейс: ${interface_name}"
|
||||
return 0
|
||||
else
|
||||
log "Существующий интерфейс Phobos не найден"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
remove_existing_interface() {
|
||||
local interface_name="phobos_wg"
|
||||
|
||||
log "Удаление существующего интерфейса ${interface_name}..."
|
||||
|
||||
uci -q delete network.${interface_name} || true
|
||||
|
||||
local peers=$(uci show network | grep "wireguard_${interface_name}" | cut -d'.' -f2 | cut -d'=' -f1 | sort -u)
|
||||
for peer in $peers; do
|
||||
uci -q delete network.$peer || true
|
||||
done
|
||||
|
||||
uci commit network
|
||||
log "Существующий интерфейс удален"
|
||||
}
|
||||
|
||||
configure_wireguard_interface() {
|
||||
local interface_name="phobos_wg"
|
||||
|
||||
log "Настройка интерфейса ${interface_name} через UCI..."
|
||||
|
||||
uci set network.${interface_name}=interface
|
||||
uci set network.${interface_name}.proto='wireguard'
|
||||
uci set network.${interface_name}.private_key="${CLIENT_PRIVATE_KEY}"
|
||||
uci set network.${interface_name}.mtu="${MTU}"
|
||||
|
||||
uci add_list network.${interface_name}.addresses="${CLIENT_IP}"
|
||||
|
||||
if [ "${CLIENT_IPV6}" != "none" ] && [ -n "${CLIENT_IPV6}" ]; then
|
||||
uci add_list network.${interface_name}.addresses="${CLIENT_IPV6}"
|
||||
fi
|
||||
|
||||
local peer_name="wgpeer_${interface_name}"
|
||||
uci add network wireguard_${interface_name}
|
||||
uci rename network.@wireguard_${interface_name}[-1]="${peer_name}"
|
||||
uci set network.${peer_name}.public_key="${SERVER_PUBLIC_KEY}"
|
||||
uci set network.${peer_name}.description="Phobos VPS Server"
|
||||
uci set network.${peer_name}.endpoint_host='127.0.0.1'
|
||||
uci set network.${peer_name}.endpoint_port="${ENDPOINT_PORT}"
|
||||
uci set network.${peer_name}.persistent_keepalive="${KEEPALIVE}"
|
||||
uci set network.${peer_name}.route_allowed_ips='0'
|
||||
|
||||
uci add_list network.${peer_name}.allowed_ips='0.0.0.0/0'
|
||||
uci add_list network.${peer_name}.allowed_ips='::/0'
|
||||
|
||||
uci commit network
|
||||
|
||||
log "Интерфейс ${interface_name} настроен ✓"
|
||||
return 0
|
||||
}
|
||||
|
||||
configure_firewall_zone() {
|
||||
local zone_name="phobos"
|
||||
local interface_name="phobos_wg"
|
||||
|
||||
log "Настройка файрволла для зоны ${zone_name}..."
|
||||
|
||||
if uci -q get firewall.${zone_name} >/dev/null 2>&1; then
|
||||
log "Зона ${zone_name} уже существует, обновляем..."
|
||||
uci delete firewall.${zone_name}
|
||||
fi
|
||||
|
||||
uci add firewall zone
|
||||
uci rename firewall.@zone[-1]="${zone_name}"
|
||||
uci set firewall.${zone_name}.name="${zone_name}"
|
||||
uci add_list firewall.${zone_name}.network="${interface_name}"
|
||||
uci set firewall.${zone_name}.input='REJECT'
|
||||
uci set firewall.${zone_name}.output='ACCEPT'
|
||||
uci set firewall.${zone_name}.forward='REJECT'
|
||||
uci set firewall.${zone_name}.masq='0'
|
||||
uci set firewall.${zone_name}.mtu_fix='1'
|
||||
|
||||
uci commit firewall
|
||||
|
||||
log "Файрволл зона ${zone_name} настроена ✓"
|
||||
return 0
|
||||
}
|
||||
|
||||
restart_network_services() {
|
||||
log "Перезапуск сетевых сервисов..."
|
||||
|
||||
/etc/init.d/network reload >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
|
||||
ifup phobos_wg >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
|
||||
/etc/init.d/firewall reload >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
log "Сетевые сервисы перезапущены ✓"
|
||||
}
|
||||
|
||||
verify_interface_created() {
|
||||
log "Проверка создания интерфейса WireGuard..."
|
||||
|
||||
local max_attempts=5
|
||||
local attempt=1
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if ip link show phobos_wg >/dev/null 2>&1; then
|
||||
log "Интерфейс phobos_wg найден (попытка $attempt/$max_attempts)"
|
||||
|
||||
if wg show phobos_wg >/dev/null 2>&1; then
|
||||
log "WireGuard интерфейс phobos_wg активен"
|
||||
|
||||
if uci -q get network.phobos_wg >/dev/null 2>&1; then
|
||||
log "UCI конфигурация phobos_wg найдена"
|
||||
return 0
|
||||
else
|
||||
log "UCI конфигурация phobos_wg не найдена, но интерфейс работает"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
log "Интерфейс найден, но WireGuard не активен (попытка $attempt/$max_attempts)"
|
||||
fi
|
||||
else
|
||||
log "Интерфейс phobos_wg еще не создан (попытка $attempt/$max_attempts)..."
|
||||
fi
|
||||
|
||||
if [ $attempt -lt $max_attempts ]; then
|
||||
sleep 2
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
error "Интерфейс phobos_wg не найден после $max_attempts попыток"
|
||||
return 1
|
||||
}
|
||||
|
||||
show_fallback_instructions() {
|
||||
cat <<EOF
|
||||
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ Требуется ручная настройка WireGuard ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
|
||||
Конфигурация сохранена в: ${FALLBACK_CONFIG}
|
||||
|
||||
Инструкция по ручной настройке:
|
||||
1. Установите пакеты: opkg install kmod-wireguard wireguard-tools wireguard-tools
|
||||
2. Импортируйте конфигурацию из ${FALLBACK_CONFIG}
|
||||
3. Настройте интерфейс через UCI или LuCI веб-интерфейс
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
if ! check_dependencies; then
|
||||
show_fallback_instructions
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "=== Phobos WireGuard OpenWRT Configuration ==="
|
||||
log "Клиент: ${CLIENT_NAME}"
|
||||
|
||||
if check_existing_interface; then
|
||||
remove_existing_interface
|
||||
fi
|
||||
|
||||
if ! configure_wireguard_interface; then
|
||||
error "Не удалось настроить WireGuard через UCI"
|
||||
show_fallback_instructions
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! configure_firewall_zone; then
|
||||
error "Не удалось настроить файрволл"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
restart_network_services
|
||||
|
||||
log ""
|
||||
log "Ожидание применения конфигурации..."
|
||||
sleep 5
|
||||
|
||||
if verify_interface_created; then
|
||||
log ""
|
||||
log "WireGuard успешно настроен на OpenWRT"
|
||||
log ""
|
||||
log "Интерфейс: phobos_wg"
|
||||
log "Файрволл зона: phobos (без форвардинга)"
|
||||
log ""
|
||||
log "Для маршрутизации трафика через туннель настройте правила"
|
||||
log "файрволла и маршрутизацию вручную через LuCI или UCI."
|
||||
log ""
|
||||
|
||||
log "Текущий статус интерфейса:"
|
||||
ip addr show phobos_wg 2>/dev/null | sed 's/^/ /' || true
|
||||
log ""
|
||||
|
||||
exit 0
|
||||
else
|
||||
log ""
|
||||
log "Не удалось подтвердить создание интерфейса WireGuard"
|
||||
log ""
|
||||
log "Проверьте вручную:"
|
||||
log " ip link show phobos_wg"
|
||||
log " wg show phobos_wg"
|
||||
log " uci show network.phobos_wg"
|
||||
log ""
|
||||
log "Если интерфейс существует (ip link show phobos_wg работает),"
|
||||
log "то настройка прошла успешно, несмотря на ошибку проверки."
|
||||
log ""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -60,7 +60,7 @@ Options:
|
||||
Example:
|
||||
$0 --client-name Pegacomp \\
|
||||
--client-private-key "ABCD..." \\
|
||||
--client-ip 10.25.0.4 \\
|
||||
--client-ip 10.26.0.4 \\
|
||||
--client-ipv6 fd00:10:25::4 \\
|
||||
--server-public-key "EFGH..." \\
|
||||
--endpoint-port 13255 \\
|
||||
@@ -195,6 +195,7 @@ configure_wireguard_interface() {
|
||||
local description="Phobos-${CLIENT_NAME}"
|
||||
local client_ip_addr=$(echo "${CLIENT_IP}" | cut -d'/' -f1)
|
||||
local client_ipv6_block="${CLIENT_IPV6}"
|
||||
local route_target=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0/16
|
||||
|
||||
log "Настройка интерфейса ${interface_name}..."
|
||||
|
||||
@@ -275,6 +276,41 @@ EOF
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Настраиваем статический маршрут в созданный интерфейс отдельным вызовом RCI
|
||||
local route_target_net=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0
|
||||
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
|
||||
{
|
||||
"network": "${route_target_net}",
|
||||
"mask": "${route_mask}",
|
||||
"interface": "${interface_name}",
|
||||
"auto": true
|
||||
}
|
||||
EOF
|
||||
)
|
||||
local route_result=$(echo "${route_json}" | curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- \
|
||||
"http://localhost:79/rci/ip/route/" 2>/dev/null)
|
||||
|
||||
if echo "${route_result}" | jq -e '.status[] | select(.status == "error")' >/dev/null 2>&1; then
|
||||
local error_msg=$(echo "${route_result}" | jq -r '.status[] | select(.status == "error") | .message // "Unknown error"' 2>/dev/null)
|
||||
error "RCI API отклонил настройку маршрута: ${error_msg}"
|
||||
log "JSON запрос:"
|
||||
log "${route_json}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Интерфейс ${interface_name} создан ✓"
|
||||
|
||||
return 0
|
||||
|
||||
@@ -6,7 +6,7 @@ Wants=wg-quick@wg0.service
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/phobos-panel
|
||||
ExecStart=/usr/bin/gunicorn -w 1 -b 0.0.0.0:8443 app:app
|
||||
ExecStart=/usr/bin/gunicorn -w 1 --threads 20 -b 0.0.0.0:8443 app:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
126
promote.sh
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# PCA Phobos — promote / sync каналов (maintainer-инструмент)
|
||||
#
|
||||
# Каналы и репозитории:
|
||||
# stable -> публичный adleibzon-rgb/PCA_Phobos ветка main
|
||||
# beta -> приватный adleibzon-rgb/PCA_Phobos-dev ветка beta
|
||||
# dev -> приватный adleibzon-rgb/PCA_Phobos-dev ветка dev
|
||||
#
|
||||
# Поток фич — строго в одну сторону: dev -> beta -> stable
|
||||
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
|
||||
#
|
||||
# Использование (GH_TOKEN в env):
|
||||
# GH_TOKEN=ghp_xxx ./promote.sh status
|
||||
# GH_TOKEN=ghp_xxx ./promote.sh dev2beta
|
||||
# GH_TOKEN=ghp_xxx ./promote.sh beta2stable 1.3.0
|
||||
# GH_TOKEN=ghp_xxx ./promote.sh forward # main->beta->dev (после хотфикса)
|
||||
#
|
||||
# Мёрж обычный (без -X). При конфликте — НЕ пушит, оставляет рабочую копию
|
||||
# в $WORK для ручного разбора. Версии (VERSION) выставляются по каналу.
|
||||
# ============================================================
|
||||
set -e
|
||||
GH_TOKEN="${GH_TOKEN:-}"
|
||||
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
|
||||
PUB="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos.git"
|
||||
PRIV="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos-dev.git"
|
||||
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
|
||||
|
||||
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
|
||||
|
||||
init_work() {
|
||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
||||
git -C "$WORK" init -q
|
||||
git -C "$WORK" config user.email "maintainer@pca"
|
||||
git -C "$WORK" config user.name "PCA promote"
|
||||
}
|
||||
|
||||
co_branch() { # $1=branch -> checkout local copy of remote branch
|
||||
local br="$1" repo; repo=$(repo_for "$br")
|
||||
git -C "$WORK" fetch -q "$repo" "$br"
|
||||
git -C "$WORK" checkout -q -B "$br" FETCH_HEAD
|
||||
}
|
||||
|
||||
ver_now() { tr -d ' \r\n' < "$WORK/VERSION" 2>/dev/null; }
|
||||
|
||||
do_merge() { # $1=incoming-ref-msg label ; merges FETCH_HEAD into current branch
|
||||
if ! git -C "$WORK" merge --no-edit -m "$1" FETCH_HEAD; then
|
||||
echo ""
|
||||
echo "!!! КОНФЛИКТ. Не запушено. Разрули вручную:"
|
||||
echo " cd $WORK && git status # правь файлы, git add, git commit"
|
||||
echo " затем git push <repo> <branch>"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
|
||||
status)
|
||||
init_work
|
||||
echo "== Версии каналов =="
|
||||
for br in main beta dev; do
|
||||
co_branch "$br" >/dev/null 2>&1 || { printf " %-7s (нет)\n" "$br"; continue; }
|
||||
printf " %-7s %s (%s)\n" "$br" "$(ver_now)" "$(git -C "$WORK" rev-parse --short HEAD)"
|
||||
done
|
||||
echo "Поток: dev -> beta -> stable. После хотфикса в stable: ./promote.sh forward"
|
||||
;;
|
||||
|
||||
dev2beta)
|
||||
init_work
|
||||
echo "== Промоут dev -> beta =="
|
||||
co_branch beta
|
||||
git -C "$WORK" fetch -q "$PRIV" dev
|
||||
DEVVER=$(git -C "$WORK" show FETCH_HEAD:VERSION | tr -d ' \r\n')
|
||||
do_merge "promote: merge dev into beta"
|
||||
BETAVER="${DEVVER%-dev}-beta"
|
||||
echo "$BETAVER" > "$WORK/VERSION"
|
||||
git -C "$WORK" commit -q -am "promote: beta VERSION -> $BETAVER" || true
|
||||
git -C "$WORK" push -q "$PRIV" beta:beta
|
||||
git -C "$WORK" tag -f "v$BETAVER" >/dev/null; git -C "$WORK" push -fq "$PRIV" "v$BETAVER"
|
||||
echo "OK -> beta = $BETAVER (тег v$BETAVER)"
|
||||
;;
|
||||
|
||||
beta2stable)
|
||||
STABLEVER="${2:-}"
|
||||
[ -n "$STABLEVER" ] || { echo "Укажи версию: ./promote.sh beta2stable 1.3.0"; exit 1; }
|
||||
init_work
|
||||
echo "== Релиз beta -> stable (v$STABLEVER) =="
|
||||
co_branch main
|
||||
git -C "$WORK" fetch -q "$PRIV" beta
|
||||
do_merge "release: merge beta into stable v$STABLEVER"
|
||||
echo "$STABLEVER" > "$WORK/VERSION"
|
||||
git -C "$WORK" commit -q -am "release: stable VERSION -> $STABLEVER" || true
|
||||
git -C "$WORK" push -q "$PUB" main:main
|
||||
git -C "$WORK" tag -f "v$STABLEVER" >/dev/null; git -C "$WORK" push -fq "$PUB" "v$STABLEVER"
|
||||
echo "OK -> stable = $STABLEVER (тег v$STABLEVER). Дальше: ./promote.sh forward"
|
||||
;;
|
||||
|
||||
forward)
|
||||
init_work
|
||||
echo "== Forward-port stable -> beta -> dev =="
|
||||
# main -> beta
|
||||
co_branch beta
|
||||
BETAVER=$(ver_now)
|
||||
git -C "$WORK" fetch -q "$PUB" main
|
||||
do_merge "forward: merge stable into beta"
|
||||
echo "$BETAVER" > "$WORK/VERSION"; git -C "$WORK" commit -q -am "forward: keep beta VERSION $BETAVER" || true
|
||||
git -C "$WORK" push -q "$PRIV" beta:beta
|
||||
echo " beta <- stable OK"
|
||||
# beta -> dev
|
||||
co_branch dev
|
||||
DEVVER=$(ver_now)
|
||||
git -C "$WORK" fetch -q "$PRIV" beta
|
||||
do_merge "forward: merge beta into dev"
|
||||
echo "$DEVVER" > "$WORK/VERSION"; git -C "$WORK" commit -q -am "forward: keep dev VERSION $DEVVER" || true
|
||||
git -C "$WORK" push -q "$PRIV" dev:dev
|
||||
echo " dev <- beta OK. Forward-port завершён."
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "promote.sh — продвижение каналов PCA Phobos"
|
||||
echo " status версии всех каналов"
|
||||
echo " dev2beta dev -> beta (кандидат)"
|
||||
echo " beta2stable <version> beta -> stable (релиз + тег)"
|
||||
echo " forward stable -> beta -> dev (после хотфикса)"
|
||||
;;
|
||||
esac
|
||||
191
server/api.py
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phobos Secondary Server API — peer management + health."""
|
||||
import json, os, subprocess
|
||||
import json, os, subprocess, time, hmac
|
||||
from pathlib import Path
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -17,7 +18,50 @@ def load_env():
|
||||
def check_api_key():
|
||||
env = load_env()
|
||||
key = request.headers.get("X-API-Key", "")
|
||||
return key == env.get("MAIN_API_KEY", "")
|
||||
return hmac.compare_digest(key, env.get("MAIN_API_KEY", ""))
|
||||
|
||||
|
||||
def default_iface():
|
||||
try:
|
||||
out = subprocess.check_output(["ip", "route", "get", "8.8.8.8"], text=True, timeout=4)
|
||||
parts = out.split()
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if part == "dev":
|
||||
return parts[i + 1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def cpu_percent():
|
||||
try:
|
||||
with open("/proc/stat") as fh:
|
||||
a = fh.readline().split()[1:]
|
||||
time.sleep(0.1)
|
||||
with open("/proc/stat") as fh:
|
||||
b = fh.readline().split()[1:]
|
||||
a = [int(x) for x in a]
|
||||
b = [int(x) for x in b]
|
||||
idle = b[3] - a[3]
|
||||
total = sum(b) - sum(a)
|
||||
if total <= 0:
|
||||
return "?"
|
||||
return f"{round(100 - (idle * 100.0 / total), 1)}%"
|
||||
except Exception:
|
||||
return "?"
|
||||
|
||||
def net_bytes():
|
||||
try:
|
||||
iface = default_iface()
|
||||
with open("/proc/net/dev") as fh:
|
||||
for line in fh:
|
||||
if line.split(":")[0].strip() == iface:
|
||||
p = line.split(":")[1].split()
|
||||
return {"iface": iface, "rx": int(p[0]), "tx": int(p[8]), "ts": int(time.time())}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
@@ -25,11 +69,16 @@ def health():
|
||||
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
||||
peer_keys = []
|
||||
handshakes = {}
|
||||
transfer = {}
|
||||
for line in out.strip().split("\n")[1:]:
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 4:
|
||||
pub = parts[0]
|
||||
peer_keys.append(pub)
|
||||
try:
|
||||
transfer[pub] = [int(parts[5]), int(parts[6])]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
handshakes[pub] = int(parts[4])
|
||||
except Exception:
|
||||
@@ -39,18 +88,46 @@ def health():
|
||||
peers = 0
|
||||
peer_keys = []
|
||||
handshakes = {}
|
||||
transfer = {}
|
||||
cpu = cpu_percent()
|
||||
try:
|
||||
out = subprocess.check_output("top -bn1 | grep Cpu", shell=True, text=True, timeout=5)
|
||||
idle = float([x for x in out.split(",") if "id" in x][0].split()[0])
|
||||
cpu = f"{round(100 - idle, 1)}%"
|
||||
except Exception:
|
||||
cpu = "?"
|
||||
try:
|
||||
mem = subprocess.check_output("free -m", shell=True, text=True).split("\n")[1].split()
|
||||
mem_str = f"{mem[2]}/{mem[1]}MB"
|
||||
parts = Path("/proc/meminfo").read_text().splitlines()
|
||||
vals = {}
|
||||
for line in parts:
|
||||
k, v = line.split(":", 1)
|
||||
vals[k] = int(v.strip().split()[0])
|
||||
total = vals.get("MemTotal", 0) // 1024
|
||||
avail = vals.get("MemAvailable", 0) // 1024
|
||||
used = max(total - avail, 0)
|
||||
mem_str = f"{used}/{total}MB"
|
||||
except Exception:
|
||||
mem_str = "?"
|
||||
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str})
|
||||
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "transfer": transfer, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
|
||||
|
||||
|
||||
@app.route("/api/obf-health")
|
||||
def obf_health():
|
||||
"""Return 200 only when all local wg-obfuscator-* services are active."""
|
||||
try:
|
||||
listing = subprocess.check_output(
|
||||
["systemctl", "list-units", "--type=service", "--no-legend",
|
||||
"wg-obfuscator-*"], text=True, timeout=5)
|
||||
names = [ln.split()[0] for ln in listing.splitlines() if ln.strip()]
|
||||
except Exception:
|
||||
names = []
|
||||
if not names:
|
||||
env = load_env()
|
||||
ports = [p.strip() for p in env.get("OBFUSCATOR_PORTS", "2083,5443,993").split(",") if p.strip()]
|
||||
names = [f"wg-obfuscator-{p}.service" for p in ports]
|
||||
bad = []
|
||||
for name in names:
|
||||
status = subprocess.getoutput("systemctl is-active " + name).strip()
|
||||
if status != "active":
|
||||
bad.append(name + "=" + status)
|
||||
if bad:
|
||||
return ("obf-down: " + ",".join(bad), 503, {"Content-Type": "text/plain"})
|
||||
return ("obf-ok " + str(len(names)), 200, {"Content-Type": "text/plain"})
|
||||
|
||||
|
||||
@app.route("/api/peers", methods=["GET"])
|
||||
def list_peers():
|
||||
@@ -67,11 +144,96 @@ def list_peers():
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
LIMIT_SCRIPT = "/opt/Phobos/server/phobos-limit.sh"
|
||||
|
||||
|
||||
def ensure_script(path):
|
||||
"""Fetch a helper script from the main panel if this agent predates it."""
|
||||
if os.path.exists(path):
|
||||
return
|
||||
env = load_env()
|
||||
url = "%s://%s:%s/server/%s" % (env.get("MAIN_PROTO", "http"), env.get("MAIN_SERVER", ""),
|
||||
env.get("MAIN_PORT", ""), os.path.basename(path))
|
||||
subprocess.run(["curl", "-kfsSL", "-m", "20", url, "-o", path], timeout=30)
|
||||
if os.path.exists(path):
|
||||
os.chmod(path, 0o755)
|
||||
|
||||
|
||||
@app.route("/api/speedtest", methods=["POST"])
|
||||
def speedtest():
|
||||
if not check_api_key():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
path = "/opt/Phobos/server/phobos-speedtest.sh"
|
||||
ensure_script(path)
|
||||
try:
|
||||
r = subprocess.run([path], capture_output=True, text=True, timeout=80)
|
||||
return jsonify(json.loads(r.stdout.strip().splitlines()[-1]))
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
|
||||
# --- 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"])
|
||||
def set_limits():
|
||||
"""Per-client speed limits pushed by the main panel ("<tunnel_ip> <mbit>" lines)."""
|
||||
if not check_api_key():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
text = (request.get_json(force=True, silent=True) or {}).get("limits", "")
|
||||
Path("/opt/Phobos/server/limits.conf").write_text(text)
|
||||
ensure_script(LIMIT_SCRIPT)
|
||||
try:
|
||||
r = subprocess.run([LIMIT_SCRIPT, "apply"], capture_output=True, text=True, timeout=30)
|
||||
return jsonify({"status": "ok" if r.returncode == 0 else "error", "out": (r.stdout + r.stderr)[-500:]})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route("/api/peers/add", methods=["POST"])
|
||||
def add_peer():
|
||||
if not check_api_key():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
data = request.json
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
pub_key = data.get("public_key", "")
|
||||
allowed_ips = data.get("allowed_ips", "")
|
||||
if not pub_key or not allowed_ips:
|
||||
@@ -87,7 +249,8 @@ def add_peer():
|
||||
def remove_peer():
|
||||
if not check_api_key():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
pub_key = request.json.get("public_key", "")
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
pub_key = data.get("public_key", "")
|
||||
if not pub_key:
|
||||
return jsonify({"error": "missing public_key"}), 400
|
||||
try:
|
||||
@@ -116,7 +279,7 @@ ROUTER_CONFIGS_DIR = "/opt/Phobos/server/router-configs"
|
||||
@app.route("/api/router-config/<client_id>")
|
||||
def router_config(client_id):
|
||||
"""Serve a client's failover.conf so routers can PULL via the tunnel
|
||||
(http://10.25.0.1:8444/...). Token via ?token= or X-API-Key."""
|
||||
(http://<server-tunnel-ip>:8444/...). Token via ?token= or X-API-Key."""
|
||||
token = request.args.get("token", "") or request.headers.get("X-API-Key", "")
|
||||
if token != load_env().get("MAIN_API_KEY", ""):
|
||||
return ("forbidden", 403)
|
||||
|
||||
8664
server/data/ru-aggregated.zone
Normal file
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
|
||||
93
server/phobos-backup.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# PCA Phobos — полный бэкап / восстановление сервера
|
||||
#
|
||||
# phobos-backup create # создать бэкап -> /opt/Phobos/backups/*.tar.gz
|
||||
# phobos-backup restore <file> # восстановить из бэкапа
|
||||
# phobos-backup list # список бэкапов
|
||||
#
|
||||
# В бэкап входит ВСЁ состояние (НЕ бинарники/пакеты):
|
||||
# WireGuard ключ сервера (wg0.conf), server.env (OBF/WG ключи),
|
||||
# обфускатор-конфиги, все клиенты+ключи, токены, settings.json панели
|
||||
# (router_access, метки, назначения, токен подписчика, версия/канал/порт).
|
||||
#
|
||||
# ⚠ Бэкап содержит ПРИВАТНЫЕ КЛЮЧИ — храни в защищённом месте.
|
||||
#
|
||||
# Disaster recovery на чистом VPS:
|
||||
# 1) поставить turnkey: bash <(curl -fsSL .../install.sh) (ставит бинарники/сервисы)
|
||||
# 2) залить бэкап и: phobos-backup restore phobos-backup-*.tar.gz
|
||||
# Ключ сервера восстановится — клиенты переподключатся без пересоздания.
|
||||
# ============================================================
|
||||
set -u
|
||||
PHOBOS_DIR=/opt/Phobos
|
||||
PANEL_DIR=/opt/phobos-panel
|
||||
BACKUP_DIR="$PHOBOS_DIR/backups"
|
||||
|
||||
panel_service_name() {
|
||||
for name in phobos-dev-test phobos-panel; do
|
||||
systemctl status "$name" >/dev/null 2>&1 && { echo "$name"; return; }
|
||||
done
|
||||
echo phobos-panel
|
||||
}
|
||||
|
||||
PATHS=(
|
||||
/etc/wireguard/wg0.conf
|
||||
"$PHOBOS_DIR/server/server.env"
|
||||
"$PHOBOS_DIR/clients"
|
||||
"$PHOBOS_DIR/tokens"
|
||||
"$PHOBOS_DIR/server/warp"
|
||||
"$PANEL_DIR/settings.json"
|
||||
"$PANEL_DIR/servers.json"
|
||||
"$PANEL_DIR/.version"
|
||||
"$PANEL_DIR/.channel"
|
||||
"$PANEL_DIR/.port"
|
||||
"$PANEL_DIR/.scheme"
|
||||
"$PANEL_DIR/.gh_token"
|
||||
)
|
||||
|
||||
case "${1:-}" in
|
||||
create)
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
OUT="$BACKUP_DIR/phobos-backup-$(hostname -s 2>/dev/null || echo srv)-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
# -P: absolute paths (restore lands in original locations). obfuscator confs by glob.
|
||||
EXIST=()
|
||||
for p in "${PATHS[@]}"; do [ -e "$p" ] && EXIST+=("$p"); done
|
||||
for f in "$PHOBOS_DIR"/server/wg-obfuscator-*.conf; do [ -e "$f" ] && EXIST+=("$f"); done
|
||||
tar -czf "$OUT" -P --ignore-failed-read "${EXIST[@]}" 2>/dev/null
|
||||
chmod 600 "$OUT"
|
||||
echo "$OUT"
|
||||
;;
|
||||
restore)
|
||||
FILE="${2:-}"
|
||||
[ -f "$FILE" ] || { echo "Файл не найден: $FILE"; exit 1; }
|
||||
# sanity: must be a phobos backup (contains server.env or wg0.conf)
|
||||
if ! tar -tzf "$FILE" 2>/dev/null | grep -qE 'server\.env|wg0\.conf'; then
|
||||
echo "Не похоже на бэкап PCA Phobos (нет server.env/wg0.conf). Отмена."; exit 1
|
||||
fi
|
||||
# safety snapshot before overwrite
|
||||
PRE="$BACKUP_DIR/pre-restore-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
PEX=()
|
||||
for p in "${PATHS[@]}"; do [ -e "$p" ] && PEX+=("$p"); done
|
||||
for f in "$PHOBOS_DIR"/server/wg-obfuscator-*.conf; do [ -e "$f" ] && PEX+=("$f"); done
|
||||
[ ${#PEX[@]} -gt 0 ] && tar -czf "$PRE" -P --ignore-failed-read "${PEX[@]}" 2>/dev/null && echo "Снимок до восстановления: $PRE"
|
||||
tar -xzf "$FILE" -P
|
||||
chmod 600 /etc/wireguard/wg0.conf 2>/dev/null || true
|
||||
systemctl restart wg-quick@wg0 2>/dev/null || true
|
||||
for s in $(systemctl list-units --all --plain --no-legend 'wg-obfuscator-*' 2>/dev/null | awk '{print $1}'); do
|
||||
systemctl restart "$s" 2>/dev/null || true
|
||||
done
|
||||
systemctl restart "$(panel_service_name)" 2>/dev/null || true
|
||||
echo "Восстановлено из $FILE. Сервисы перезапущены."
|
||||
;;
|
||||
list)
|
||||
echo "Бэкапы в $BACKUP_DIR:"
|
||||
ls -lht "$BACKUP_DIR"/phobos-backup-*.tar.gz 2>/dev/null | awk '{print " "$9" ("$5")"}' || echo " нет"
|
||||
;;
|
||||
*)
|
||||
echo "phobos-backup — бэкап/восстановление сервера PCA Phobos"
|
||||
echo " create создать бэкап"
|
||||
echo " restore <file> восстановить"
|
||||
echo " list список бэкапов"
|
||||
;;
|
||||
esac
|
||||
93
server/phobos-cascade.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# PCA Phobos — cascade relay. Run on an ENTRY VPS (e.g. in Russia, "white" IP):
|
||||
# clients connect to this server, UDP obfuscator ports are forwarded to the EXIT
|
||||
# Phobos server abroad. The entry box needs no Phobos, no keys, no panel.
|
||||
# bash <(curl -fsSL .../server/phobos-cascade.sh) enable <exit_host> [ports]
|
||||
# phobos-cascade.sh disable | status | apply
|
||||
# Then in the exit server panel: Settings -> "Адрес для клиентов" -> entry IP/domain.
|
||||
set -uo pipefail
|
||||
STATE=/etc/phobos-cascade.env
|
||||
BIN=/usr/local/sbin/phobos-cascade
|
||||
SELF_URL="${SELF_URL:-https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/phobos-cascade.sh}"
|
||||
TAG="phobos-cascade"
|
||||
|
||||
clear_rules() {
|
||||
local t
|
||||
for t in nat filter; do
|
||||
iptables -t $t -S 2>/dev/null | grep -- "--comment $TAG" | sed 's/^-A /-D /' | \
|
||||
while read -r rule; do eval iptables -t $t $rule 2>/dev/null; done
|
||||
done
|
||||
}
|
||||
|
||||
apply() {
|
||||
[ -f "$STATE" ] || { echo "cascade: not configured"; return 0; }
|
||||
. "$STATE"
|
||||
local ip p
|
||||
ip=$(getent ahostsv4 "$EXIT_HOST" | awk 'NR==1{print $1}')
|
||||
[ -n "$ip" ] || { echo "cascade: cannot resolve $EXIT_HOST" >&2; return 1; }
|
||||
sysctl -qw net.ipv4.ip_forward=1
|
||||
clear_rules
|
||||
for p in ${PORTS//,/ }; do
|
||||
iptables -t nat -A PREROUTING -p udp --dport "$p" -m comment --comment $TAG -j DNAT --to-destination "$ip:$p"
|
||||
iptables -t nat -A POSTROUTING -p udp -d "$ip" --dport "$p" -m comment --comment $TAG -j MASQUERADE
|
||||
iptables -I FORWARD -p udp -d "$ip" --dport "$p" -m comment --comment $TAG -j ACCEPT
|
||||
iptables -I FORWARD -p udp -s "$ip" --sport "$p" -m comment --comment $TAG -j ACCEPT
|
||||
done
|
||||
echo "cascade: udp ${PORTS} -> ${EXIT_HOST} (${ip})"
|
||||
}
|
||||
|
||||
enable() {
|
||||
local host="${1:-}" ports="${2:-2083,5443,993}"
|
||||
[ -n "$host" ] || { echo "usage: $0 enable <exit_host> [ports]" >&2; exit 2; }
|
||||
[[ "$ports" =~ ^[0-9]+(,[0-9]+)*$ ]] || { echo "bad ports: $ports" >&2; exit 2; }
|
||||
command -v iptables >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq iptables >/dev/null
|
||||
printf 'EXIT_HOST=%s\nPORTS=%s\n' "$host" "$ports" > "$STATE"
|
||||
# bash <(curl ...) gives a pipe as $0 -> fetch a real copy for the systemd unit
|
||||
if [ "$(readlink -f "$0")" != "$BIN" ]; then
|
||||
if [ -f "$0" ]; then cp "$0" "$BIN"; else curl -kfsSL -m30 "$SELF_URL" -o "$BIN" || { echo "cannot save $BIN" >&2; exit 1; }; fi
|
||||
chmod +x "$BIN"
|
||||
fi
|
||||
cat > /etc/systemd/system/phobos-cascade.service <<EOF
|
||||
[Unit]
|
||||
Description=PCA Phobos cascade relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=$BIN apply
|
||||
ExecStop=$BIN stop
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable phobos-cascade.service >/dev/null 2>&1
|
||||
systemctl restart phobos-cascade.service
|
||||
apply >/dev/null
|
||||
status
|
||||
}
|
||||
|
||||
disable() {
|
||||
systemctl disable --now phobos-cascade.service >/dev/null 2>&1 || true
|
||||
clear_rules
|
||||
rm -f /etc/systemd/system/phobos-cascade.service "$STATE"
|
||||
systemctl daemon-reload
|
||||
echo "cascade: disabled"
|
||||
}
|
||||
|
||||
status() {
|
||||
local host="" ports="" rules
|
||||
[ -f "$STATE" ] && . "$STATE" && host="$EXIT_HOST" && ports="$PORTS"
|
||||
rules=$(iptables -t nat -S PREROUTING 2>/dev/null | grep -c -- "--comment $TAG")
|
||||
printf '{"enabled": %s, "exit_host": "%s", "ports": "%s", "rules": %s}\n' \
|
||||
"$([ "$rules" -gt 0 ] && echo true || echo false)" "$host" "$ports" "$rules"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
enable) enable "${2:-}" "${3:-}" ;;
|
||||
apply) apply ;;
|
||||
stop) clear_rules ;;
|
||||
disable) disable ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 enable <exit_host> [ports]|disable|status"; exit 2 ;;
|
||||
esac
|
||||
105
server/phobos-domain.sh
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# PCA Phobos — panel domain: https://<domain>/ via nginx + Let's Encrypt.
|
||||
# The panel keeps listening on its old http port, so routers/secondaries that
|
||||
# already know http://IP:PORT keep working; the domain is an extra entrance.
|
||||
# phobos-domain.sh set <domain> [email] (SELF_SIGNED=1 for LAN tests)
|
||||
# phobos-domain.sh remove | status
|
||||
set -uo pipefail
|
||||
PANEL_DIR=/opt/phobos-panel
|
||||
SITE=/etc/nginx/sites-available/phobos-domain
|
||||
WEBROOT=/var/www/phobos-acme
|
||||
SS_DIR=/etc/phobos-domain
|
||||
|
||||
panel_port() { cat "$PANEL_DIR/.port" 2>/dev/null || echo 8443; }
|
||||
panel_scheme() { cat "$PANEL_DIR/.scheme" 2>/dev/null || echo http; }
|
||||
|
||||
write_site() { # $1=domain $2=cert $3=key ($2 empty -> http only, for ACME)
|
||||
local d="$1" up
|
||||
up="$(panel_scheme)://127.0.0.1:$(panel_port)"
|
||||
{
|
||||
echo "server {"
|
||||
echo " listen 80;"
|
||||
echo " listen [::]:80;"
|
||||
echo " server_name $d;"
|
||||
echo " location /.well-known/acme-challenge/ { root $WEBROOT; }"
|
||||
[ -n "$2" ] && echo " location / { return 301 https://\$host\$request_uri; }"
|
||||
echo "}"
|
||||
if [ -n "$2" ]; then
|
||||
cat <<EOF
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name $d;
|
||||
ssl_certificate $2;
|
||||
ssl_certificate_key $3;
|
||||
client_max_body_size 64m;
|
||||
location / {
|
||||
proxy_pass $up;
|
||||
proxy_ssl_verify off;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Forwarded-For \$remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
} > "$SITE"
|
||||
ln -sf "$SITE" /etc/nginx/sites-enabled/phobos-domain
|
||||
# nginx < 1.25 has no "http2 on;" -> fall back to the old listen flag
|
||||
nginx -t >/dev/null 2>&1 || sed -i -e '/http2 on;/d' -e 's/listen 443 ssl;/listen 443 ssl http2;/' -e 's/listen \[::\]:443 ssl;/listen [::]:443 ssl http2;/' "$SITE"
|
||||
nginx -t >/dev/null 2>&1 || { nginx -t; return 1; }
|
||||
systemctl reload nginx
|
||||
}
|
||||
|
||||
set_domain() {
|
||||
local d="${1:-}" email="${2:-}" cert key
|
||||
[[ "$d" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$ ]] || { echo "bad domain: $d" >&2; exit 2; }
|
||||
command -v nginx >/dev/null || { echo "nginx not installed" >&2; exit 1; }
|
||||
mkdir -p "$WEBROOT"
|
||||
write_site "$d" "" "" || exit 1
|
||||
if [ "${SELF_SIGNED:-0}" = 1 ]; then
|
||||
mkdir -p "$SS_DIR"
|
||||
openssl req -x509 -nodes -newkey rsa:2048 -days 825 -subj "/CN=$d" \
|
||||
-keyout "$SS_DIR/$d.key" -out "$SS_DIR/$d.crt" >/dev/null 2>&1
|
||||
cert="$SS_DIR/$d.crt"; key="$SS_DIR/$d.key"
|
||||
else
|
||||
command -v certbot >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq certbot >/dev/null
|
||||
local mail_args=(--register-unsafely-without-email)
|
||||
[ -n "$email" ] && mail_args=(-m "$email")
|
||||
certbot certonly --webroot -w "$WEBROOT" -d "$d" --agree-tos -n "${mail_args[@]}" \
|
||||
--deploy-hook "systemctl reload nginx" || {
|
||||
echo "certbot failed: check that $d points to this server and port 80 is open" >&2
|
||||
rm -f /etc/nginx/sites-enabled/phobos-domain; systemctl reload nginx; exit 1; }
|
||||
cert="/etc/letsencrypt/live/$d/fullchain.pem"; key="/etc/letsencrypt/live/$d/privkey.pem"
|
||||
fi
|
||||
write_site "$d" "$cert" "$key" || exit 1
|
||||
echo "$d" > "$PANEL_DIR/.domain"
|
||||
echo "domain: https://$d/ -> panel $(panel_scheme)://127.0.0.1:$(panel_port)"
|
||||
}
|
||||
|
||||
remove() {
|
||||
rm -f /etc/nginx/sites-enabled/phobos-domain "$SITE" "$PANEL_DIR/.domain"
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx
|
||||
echo "domain: removed (certificate kept in /etc/letsencrypt)"
|
||||
}
|
||||
|
||||
status() {
|
||||
local d exp=""
|
||||
d=$(cat "$PANEL_DIR/.domain" 2>/dev/null)
|
||||
if [ -n "$d" ]; then
|
||||
exp=$(echo | timeout 5 openssl s_client -connect 127.0.0.1:443 -servername "$d" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
fi
|
||||
printf '{"domain": "%s", "cert_expires": "%s"}\n' "$d" "$exp"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
set) set_domain "${2:-}" "${3:-}" ;;
|
||||
remove) remove ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 set <domain> [email]|remove|status"; exit 2 ;;
|
||||
esac
|
||||
113
server/phobos-guard.sh
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# PCA Phobos — guard: drop RKN probes / scanner subnets before they reach the server.
|
||||
# Idea and IP list: github.com/Loorrr293/Auto_IPtables (list: Loorrr293/blocklist).
|
||||
# We only download the IP list (data), never third-party code.
|
||||
# phobos-guard.sh enable [list_url] install nft table + daily timer
|
||||
# phobos-guard.sh update refresh the list now
|
||||
# phobos-guard.sh disable remove table + timer
|
||||
# phobos-guard.sh status JSON
|
||||
set -uo pipefail
|
||||
STATE=/etc/phobos-guard.env
|
||||
DEFAULT_URL="https://raw.githubusercontent.com/Loorrr293/blocklist/main/blocklist.txt"
|
||||
TABLE="inet phobos_guard"
|
||||
SELF="$(readlink -f "$0")"
|
||||
|
||||
list_url() { [ -f "$STATE" ] && . "$STATE"; echo "${GUARD_URL:-$DEFAULT_URL}"; }
|
||||
|
||||
ensure_nft() {
|
||||
command -v nft >/dev/null 2>&1 && return 0
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nftables >/dev/null 2>&1
|
||||
}
|
||||
|
||||
update_list() {
|
||||
local url tmp nf
|
||||
url="$(list_url)"
|
||||
tmp="$(mktemp)"; nf="$(mktemp)"
|
||||
curl -fsSL -m60 "$url" -o "$tmp" || { rm -f "$tmp" "$nf"; echo "download failed: $url" >&2; return 1; }
|
||||
nft add table $TABLE 2>/dev/null || true
|
||||
nft add set $TABLE v4 '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null || true
|
||||
nft add set $TABLE v6 '{ type ipv6_addr; flags interval; auto-merge; }' 2>/dev/null || true
|
||||
nft add chain $TABLE input '{ type filter hook input priority raw; policy accept; }' 2>/dev/null || true
|
||||
nft list chain $TABLE input | grep -q '@v4' || nft add rule $TABLE input ip saddr @v4 counter drop
|
||||
nft list chain $TABLE input | grep -q '@v6' || nft add rule $TABLE input ip6 saddr @v6 counter drop
|
||||
# collapse + skip private/loopback so a bad list can never cut LAN/tunnel/admin
|
||||
python3 - "$tmp" > "$nf" <<'PY'
|
||||
import ipaddress, sys
|
||||
v4, v6 = [], []
|
||||
for line in open(sys.argv[1], encoding="utf-8", errors="ignore"):
|
||||
for tok in line.split("#", 1)[0].split():
|
||||
try:
|
||||
n = ipaddress.ip_network(tok, strict=False)
|
||||
except ValueError:
|
||||
continue
|
||||
if n.is_private or n.is_loopback or n.is_link_local or n.prefixlen == 0:
|
||||
continue
|
||||
(v6 if n.version == 6 else v4).append(n)
|
||||
print("flush set inet phobos_guard v4")
|
||||
print("flush set inet phobos_guard v6")
|
||||
for name, nets in (("v4", v4), ("v6", v6)):
|
||||
nets = list(ipaddress.collapse_addresses(nets))
|
||||
for i in range(0, len(nets), 2000):
|
||||
print("add element inet phobos_guard %s { %s }" % (name, ", ".join(map(str, nets[i:i + 2000]))))
|
||||
PY
|
||||
local rc=0
|
||||
nft -f "$nf" || rc=1
|
||||
rm -f "$tmp" "$nf"
|
||||
[ "$rc" = 0 ] && echo "guard: list loaded from $url"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
enable() {
|
||||
ensure_nft || { echo "nftables install failed" >&2; exit 1; }
|
||||
[ -n "${1:-}" ] && echo "GUARD_URL=$1" > "$STATE" || touch "$STATE"
|
||||
cat > /etc/systemd/system/phobos-guard.service <<EOF
|
||||
[Unit]
|
||||
Description=PCA Phobos guard (RKN scanner blocklist)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$SELF update
|
||||
EOF
|
||||
cat > /etc/systemd/system/phobos-guard.timer <<'EOF'
|
||||
[Unit]
|
||||
Description=Refresh PCA Phobos guard list daily
|
||||
[Timer]
|
||||
OnBootSec=1min
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=1h
|
||||
Persistent=true
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now phobos-guard.timer >/dev/null 2>&1
|
||||
update_list
|
||||
}
|
||||
|
||||
disable() {
|
||||
systemctl disable --now phobos-guard.timer >/dev/null 2>&1 || true
|
||||
rm -f /etc/systemd/system/phobos-guard.service /etc/systemd/system/phobos-guard.timer "$STATE"
|
||||
systemctl daemon-reload
|
||||
nft delete table $TABLE 2>/dev/null || true
|
||||
echo "guard: disabled"
|
||||
}
|
||||
|
||||
status() {
|
||||
local on=false n4=0 drops=0
|
||||
if nft list table $TABLE >/dev/null 2>&1; then
|
||||
on=true
|
||||
n4=$(nft list set $TABLE v4 2>/dev/null | grep -o '[0-9.]*/[0-9]*\|[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*' | wc -l)
|
||||
drops=$(nft list chain $TABLE input 2>/dev/null | grep -o 'packets [0-9]*' | awk '{s+=$2} END {print s+0}')
|
||||
fi
|
||||
printf '{"enabled": %s, "networks": %s, "dropped_packets": %s, "timer": "%s", "url": "%s"}\n' \
|
||||
"$on" "$n4" "$drops" "$(systemctl is-active phobos-guard.timer 2>/dev/null)" "$(list_url)"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
enable) enable "${2:-}" ;;
|
||||
update) update_list ;;
|
||||
disable) disable ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 enable [url]|update|disable|status"; exit 2 ;;
|
||||
esac
|
||||
@@ -6,11 +6,19 @@
|
||||
# ============================================================
|
||||
|
||||
PHOBOS_DIR="/opt/etc/Phobos"
|
||||
CONF="$PHOBOS_DIR/failover.conf"
|
||||
STATE="$PHOBOS_DIR/state"
|
||||
LOG="$PHOBOS_DIR/health.log"
|
||||
# 02.11.2026: несколько учёток на одном роутере. Файлы учётки (failover.conf, state,
|
||||
# client_id, panel_*) — в PHOBOS_ACC (доп. учётки: $PHOBOS_DIR/acc/<клиент>),
|
||||
# главная — прямо в $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"
|
||||
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
|
||||
@@ -25,7 +33,10 @@ PRIMARY_CHECK_INTERVAL=300
|
||||
# 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_PORT=10514
|
||||
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"
|
||||
@@ -52,6 +63,16 @@ 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
|
||||
@@ -62,6 +83,19 @@ detect_wg_interface() {
|
||||
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 ──
|
||||
if [ ! -f "$CONF" ]; then
|
||||
log "ERROR: no failover.conf"
|
||||
@@ -77,6 +111,7 @@ while true; do
|
||||
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}_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
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
@@ -91,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")
|
||||
FAIL_COUNT=$(cat "$STATE/fail_count" 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 || {
|
||||
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 ──
|
||||
# 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
|
||||
@@ -146,9 +195,13 @@ list_iface_keys() {
|
||||
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
|
||||
@@ -186,9 +239,18 @@ switch_wg_peer() {
|
||||
# Add the target peer if it is not already present
|
||||
if ! echo "$present" | grep -q "^${new_key}$"; then
|
||||
log "WG PEER add: $new_key"
|
||||
curl -s -X POST "http://localhost:79/rci/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-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
|
||||
# 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
|
||||
@@ -212,6 +274,7 @@ switch_endpoint() {
|
||||
fi
|
||||
|
||||
log "SWITCH → server $server_idx ($host:$port)"
|
||||
stop_tcp
|
||||
|
||||
# 1. Switch WG peer key if different server
|
||||
switch_wg_peer "$wg_key"
|
||||
@@ -225,13 +288,7 @@ switch_endpoint() {
|
||||
fi
|
||||
|
||||
# 3. Restart obfuscator
|
||||
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||
/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
|
||||
restart_obf
|
||||
|
||||
# 4. Save state (fail_count is managed by the caller, NOT reset here —
|
||||
# resetting on a port-hop would prevent escalation to server failover)
|
||||
@@ -239,6 +296,45 @@ switch_endpoint() {
|
||||
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() {
|
||||
port_count=$(get_port_count "$CURRENT_SERVER")
|
||||
@@ -253,7 +349,9 @@ try_next_port() {
|
||||
# ── Try next server ──
|
||||
try_next_server() {
|
||||
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
|
||||
|
||||
log "FAILOVER → server $next"
|
||||
@@ -263,7 +361,7 @@ try_next_server() {
|
||||
|
||||
# ── Check if primary is back ──
|
||||
check_primary() {
|
||||
[ "$CURRENT_SERVER" -eq 1 ] && return
|
||||
[ "$CURRENT_SERVER" -eq 1 ] && [ "$TCP_MODE" = "0" ] && return
|
||||
|
||||
now=$(date +%s)
|
||||
elapsed=$((now - PRIMARY_CHECK_TS))
|
||||
@@ -274,13 +372,25 @@ check_primary() {
|
||||
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
|
||||
# obfuscator path is actually up. Anything else (000 no-response, 503
|
||||
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
|
||||
code=$(curl -s -m 4 -o /dev/null -w '%{http_code}' "http://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" 2>/dev/null)
|
||||
if [ "$code" = "200" ]; then
|
||||
log "PRIMARY ($host obf-health http=200) alive, switching back"
|
||||
ok_url=""
|
||||
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
|
||||
echo "0" > "$STATE/fail_count"
|
||||
else
|
||||
log "PRIMARY still down (obf-health http=${code:-none}), staying on server $CURRENT_SERVER"
|
||||
log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -318,6 +428,8 @@ heal_lan_routing() {
|
||||
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"
|
||||
@@ -394,12 +506,31 @@ fi
|
||||
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
|
||||
exit 0
|
||||
fi
|
||||
@@ -415,23 +546,30 @@ log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PO
|
||||
# Stage 1 (fail 1): Restart obfuscator
|
||||
if [ "$FAIL_COUNT" -eq 1 ]; then
|
||||
log "ACTION: restart obfuscator"
|
||||
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||
/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
|
||||
restart_obf
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server
|
||||
if [ "$FAIL_COUNT" -eq 2 ]; then
|
||||
# TCP mode went stale → back to the UDP round (next server)
|
||||
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
|
||||
exit 0
|
||||
fi
|
||||
# only one port → fall through to server failover
|
||||
log "single port, escalate to failover"
|
||||
log "ports of server $CURRENT_SERVER exhausted, 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
|
||||
|
||||
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server
|
||||
|
||||
50
server/phobos-limit.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# PCA Phobos — per-client speed limit on wg0 (tc).
|
||||
# limits file: one line per client "<tunnel_ip> <mbit>" (download = upload = mbit)
|
||||
# phobos-limit.sh apply [file] rebuild tc from file (default below)
|
||||
# phobos-limit.sh clear | status
|
||||
set -uo pipefail
|
||||
DEV="${WG_DEV:-wg0}"
|
||||
FILE="${2:-/opt/Phobos/server/limits.conf}"
|
||||
|
||||
clear_tc() {
|
||||
tc qdisc del dev "$DEV" root 2>/dev/null
|
||||
tc qdisc del dev "$DEV" ingress 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
apply() {
|
||||
ip link show "$DEV" >/dev/null 2>&1 || { echo "limit: $DEV is down"; return 0; }
|
||||
clear_tc
|
||||
[ -s "$FILE" ] || { echo "limit: no limits"; return 0; }
|
||||
tc qdisc add dev "$DEV" root handle 1: htb default 9999 r2q 1000
|
||||
tc class add dev "$DEV" parent 1: classid 1:9999 htb rate 10gbit 2>/dev/null
|
||||
tc qdisc add dev "$DEV" handle ffff: ingress
|
||||
local n=10 ip mbit count=0
|
||||
while read -r ip mbit _; do
|
||||
[[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ && "$mbit" =~ ^[0-9]+$ && "$mbit" -gt 0 ]] || continue
|
||||
# download (server -> client): shaped, packets queue instead of dropping
|
||||
tc class add dev "$DEV" parent 1: classid 1:$n htb rate "${mbit}mbit" ceil "${mbit}mbit" burst 64k
|
||||
tc qdisc add dev "$DEV" parent 1:$n fq_codel 2>/dev/null
|
||||
tc filter add dev "$DEV" parent 1: protocol ip prio 1 u32 match ip dst "$ip/32" flowid 1:$n
|
||||
# upload (client -> server): policed on ingress
|
||||
tc filter add dev "$DEV" parent ffff: protocol ip prio 1 u32 match ip src "$ip/32" \
|
||||
police rate "${mbit}mbit" burst $(( mbit * 64 + 256 ))k drop flowid :1
|
||||
n=$((n + 1)); count=$((count + 1))
|
||||
done < "$FILE"
|
||||
echo "limit: $count client(s) limited on $DEV"
|
||||
}
|
||||
|
||||
status() {
|
||||
local cnt
|
||||
cnt=$(tc class show dev "$DEV" 2>/dev/null | grep -c 'htb 1:[1-9][0-9]* ' )
|
||||
cnt=$((cnt > 0 ? cnt - 1 : 0))
|
||||
printf '{"device": "%s", "limited": %s, "file": "%s"}\n' "$DEV" "$cnt" "$FILE"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
apply) apply ;;
|
||||
clear) clear_tc; echo "limit: cleared" ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 apply [file]|clear|status"; exit 2 ;;
|
||||
esac
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/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.
|
||||
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
|
||||
@@ -21,21 +22,30 @@
|
||||
# PANEL env or default below -> panel base URL
|
||||
# ============================================================
|
||||
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"
|
||||
LOG="$PHOBOS_DIR/health.log"
|
||||
# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 is the wg0 of whatever
|
||||
# server the tunnel currently terminates on, so management rides the same
|
||||
# obfuscated channel as data — survives a public-IP ban and follows failover.
|
||||
# - 10.25.0.1:8444 -> secondary server agent (phobos-api)
|
||||
# - 10.25.0.1:10514 -> primary server panel
|
||||
LOG="$ACC_DIR/health.log"
|
||||
# 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
|
||||
# same obfuscated channel as data — survives a public-IP ban and follows
|
||||
# failover.
|
||||
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
|
||||
# - panel_tunnel_ip:<panel_port> -> primary server panel
|
||||
# - public panel IP -> bootstrap / if tunnel is down
|
||||
PANEL="${PANEL:-http://212.118.52.193:10514}"
|
||||
PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}"
|
||||
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
|
||||
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
|
||||
TMP="/tmp/failover.conf.pull"
|
||||
LOCK="/tmp/phobos-pull.lock"
|
||||
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"
|
||||
PANEL="${PANEL:-$(cat "$ACC_DIR/panel_url" 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"
|
||||
# + public URLs the panel announced in failover.conf (second IP etc., 09.33)
|
||||
_extra=$(grep "^PANEL_URLS_EXTRA=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||
for _u in $_extra; do case " $PANEL_URLS " in *" $_u "*) ;; *) PANEL_URLS="$PANEL_URLS $_u" ;; esac; done
|
||||
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"; }
|
||||
|
||||
@@ -49,12 +59,46 @@ trap 'rm -f "$LOCK"' EXIT
|
||||
|
||||
[ -z "$TOKEN" ] && exit 0
|
||||
|
||||
detect_tunnel_iface() {
|
||||
ip route get "$PANEL_TUNNEL_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1
|
||||
}
|
||||
|
||||
fetch_config() {
|
||||
base="$1"
|
||||
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
|
||||
case "$base" in
|
||||
"http://${PANEL_TUNNEL_IP}:"*|"https://${PANEL_TUNNEL_IP}:"*)
|
||||
iface=$(detect_tunnel_iface)
|
||||
if [ -n "$iface" ] && command -v curl >/dev/null; then
|
||||
curl -k --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if command -v curl >/dev/null; then
|
||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||
fi
|
||||
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).
|
||||
do_pull() {
|
||||
ensure_udptcp
|
||||
# Try each source (tunnel first); accept first that returns a valid conf.
|
||||
got=0
|
||||
for base in $PANEL_URLS; do
|
||||
curl -s -m 8 -o "$TMP" "${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}" 2>/dev/null || continue
|
||||
fetch_config "$base" || continue
|
||||
if grep -q "^SERVER_1=" "$TMP" 2>/dev/null; then got=1; break; fi
|
||||
done
|
||||
[ "$got" = 1 ] || { rm -f "$TMP"; return 0; }
|
||||
@@ -77,6 +121,48 @@ do_pull() {
|
||||
if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then
|
||||
sh "$HEALTH" apply-server 1
|
||||
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
|
||||
|
||||
117
server/phobos-self-check.sh
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PHOBOS_DIR="${PHOBOS_DIR:-/opt/Phobos}"
|
||||
SERVER_ENV="${SERVER_ENV:-$PHOBOS_DIR/server/server.env}"
|
||||
REPO_DIR="${REPO_DIR:-$PHOBOS_DIR/repo}"
|
||||
WG_IFACE="${WG_IFACE:-wg0}"
|
||||
FIX=0
|
||||
|
||||
for arg in "$@"; do
|
||||
[ "$arg" = "--fix" ] && FIX=1
|
||||
done
|
||||
|
||||
log() { echo "[self-check] $*"; }
|
||||
fail() { echo "[self-check][ERROR] $*" >&2; exit 1; }
|
||||
warn() { echo "[self-check][WARN] $*" >&2; }
|
||||
|
||||
env_get() {
|
||||
local key="$1"
|
||||
[ -f "$SERVER_ENV" ] || return 0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
"$key="*) printf '%s\n' "${line#*=}"; return 0 ;;
|
||||
esac
|
||||
done < "$SERVER_ENV"
|
||||
}
|
||||
|
||||
env_set() {
|
||||
local key="$1" value="$2" tmp
|
||||
[ -f "$SERVER_ENV" ] || fail "server.env не найден: $SERVER_ENV"
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="$key" -v v="$value" '
|
||||
BEGIN { done=0 }
|
||||
$0 ~ "^" k "=" { print k "=" v; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print k "=" v }
|
||||
' "$SERVER_ENV" > "$tmp"
|
||||
cat "$tmp" > "$SERVER_ENV"
|
||||
rm -f "$tmp"
|
||||
chmod 600 "$SERVER_ENV" 2>/dev/null || true
|
||||
}
|
||||
|
||||
normalize_wg_key() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys
|
||||
value = (sys.argv[1] or "").strip()
|
||||
alphabet = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
|
||||
if value and value.lower() != "none" and all(ch in alphabet for ch in value):
|
||||
padded = value + ("=" * ((4 - len(value) % 4) % 4))
|
||||
if len(padded) == 44:
|
||||
value = padded
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
check_env_key() {
|
||||
local key="$1" value normalized
|
||||
value="$(env_get "$key" || true)"
|
||||
[ -n "$value" ] || fail "$key пустой или отсутствует в server.env"
|
||||
normalized="$(normalize_wg_key "$value")"
|
||||
if [ "$normalized" != "$value" ]; then
|
||||
if [ "$FIX" = 1 ]; then
|
||||
env_set "$key" "$normalized"
|
||||
log "$key: восстановлен base64 padding"
|
||||
value="$normalized"
|
||||
else
|
||||
fail "$key имеет длину ${#value}, нужен padding. Запусти phobos-self-check --fix"
|
||||
fi
|
||||
fi
|
||||
[ "${#value}" = 44 ] || fail "$key имеет длину ${#value}, ожидается 44"
|
||||
}
|
||||
|
||||
check_public_key_matches_wg() {
|
||||
local env_pub wg_pub
|
||||
env_pub="$(env_get SERVER_WG_PUBLIC_KEY || true)"
|
||||
wg_pub="$(wg show "$WG_IFACE" public-key 2>/dev/null || true)"
|
||||
[ -n "$wg_pub" ] || return 0
|
||||
if [ "$env_pub" != "$wg_pub" ]; then
|
||||
if [ "$FIX" = 1 ]; then
|
||||
env_set SERVER_WG_PUBLIC_KEY "$wg_pub"
|
||||
log "SERVER_WG_PUBLIC_KEY: обновлен из wg show $WG_IFACE"
|
||||
else
|
||||
fail "SERVER_WG_PUBLIC_KEY не совпадает с wg show $WG_IFACE"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
check_router_templates() {
|
||||
local tpl="$REPO_DIR/client/templates" missing=0 file
|
||||
for file in install-router.sh.template lib-client.sh install-obfuscator.sh install-wireguard.sh; do
|
||||
if [ ! -s "$tpl/$file" ]; then
|
||||
warn "нет обязательного router helper: $tpl/$file"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
[ "$missing" = 0 ] || fail "router package будет битый. Выполни phobos-update stable|beta|dev"
|
||||
}
|
||||
|
||||
check_scripts_syntax() {
|
||||
local script
|
||||
for script in \
|
||||
"$REPO_DIR/server/scripts/lib-core.sh" \
|
||||
"$REPO_DIR/server/scripts/phobos-client.sh" \
|
||||
"$REPO_DIR/client/templates/install-router.sh.template" \
|
||||
"$REPO_DIR/client/templates/lib-client.sh" \
|
||||
"$REPO_DIR/client/templates/install-obfuscator.sh" \
|
||||
"$REPO_DIR/client/templates/install-wireguard.sh"; do
|
||||
[ -f "$script" ] && bash -n "$script"
|
||||
done
|
||||
}
|
||||
|
||||
[ -f "$SERVER_ENV" ] || fail "server.env не найден: $SERVER_ENV"
|
||||
check_env_key SERVER_WG_PUBLIC_KEY
|
||||
check_public_key_matches_wg
|
||||
check_router_templates
|
||||
check_scripts_syntax
|
||||
log "OK"
|
||||
28
server/phobos-speedtest.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# PCA Phobos — real server internet speed: download/upload actual bytes, print JSON.
|
||||
# Cloudflare speed endpoints first, Hetzner/OVH files as download fallback.
|
||||
# phobos-speedtest.sh [seconds]
|
||||
set -uo pipefail
|
||||
T="${1:-8}"
|
||||
CF="https://speed.cloudflare.com"
|
||||
|
||||
down_mbit() { # $1=url -> Mbit/s
|
||||
curl -s -o /dev/null -m "$T" -w '%{speed_download}' "$1" 2>/dev/null | awk '{printf "%.1f", $1*8/1e6}'
|
||||
}
|
||||
|
||||
ping_ms() {
|
||||
curl -s -o /dev/null -m 5 -w '%{time_connect}' "$CF/__down?bytes=0" 2>/dev/null | awk '{printf "%.0f", $1*1000}'
|
||||
}
|
||||
|
||||
dl=$(down_mbit "$CF/__down?bytes=500000000")
|
||||
src="cloudflare"
|
||||
if [ -z "$dl" ] || awk "BEGIN{exit !($dl < 1)}"; then
|
||||
for u in https://speed.hetzner.de/1GB.bin https://proof.ovh.net/files/1Gb.dat; do
|
||||
dl=$(down_mbit "$u"); src="$u"
|
||||
awk "BEGIN{exit !($dl >= 1)}" && break
|
||||
done
|
||||
fi
|
||||
ul=$(head -c 200000000 /dev/zero | curl -s -o /dev/null -m "$T" -w '%{speed_upload}' \
|
||||
-X POST --data-binary @- "$CF/__up" 2>/dev/null | awk '{printf "%.1f", $1*8/1e6}')
|
||||
printf '{"download_mbit": %s, "upload_mbit": %s, "ping_ms": %s, "source": "%s", "ts": %s}\n' \
|
||||
"${dl:-0}" "${ul:-0}" "$(ping_ms || echo 0)" "$src" "$(date +%s)"
|
||||
65
server/phobos-tcp.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# PCA Phobos — TCP fallback: clients whose UDP is cut reach the obfuscator over TCP.
|
||||
# udptcp -s 0.0.0.0:<TCP_PORT> -> 127.0.0.1:<first obfuscator port> (UDP).
|
||||
# phobos-tcp.sh enable [port] pick a free port (443, 8443, 2096) unless given; idempotent
|
||||
# phobos-tcp.sh disable | status
|
||||
set -uo pipefail
|
||||
PHOBOS_DIR=/opt/Phobos
|
||||
ENV="$PHOBOS_DIR/server/server.env"
|
||||
BIN="$PHOBOS_DIR/bin/udptcp"
|
||||
|
||||
env_get() { grep -E "^$1=" "$ENV" 2>/dev/null | tail -1 | cut -d= -f2-; }
|
||||
env_set() {
|
||||
grep -v -E "^$1=" "$ENV" > "$ENV.tmp" 2>/dev/null || true
|
||||
echo "$1=$2" >> "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
|
||||
}
|
||||
port_busy() { ss -lntH "sport = :$1" 2>/dev/null | grep -q .; }
|
||||
|
||||
enable() {
|
||||
[ -x "$BIN" ] || { echo "udptcp binary missing: $BIN (phobos-update)" >&2; exit 1; }
|
||||
local port="${1:-$(env_get TCP_PORT)}" obf
|
||||
obf=$(env_get OBFUSCATOR_PORTS | cut -d, -f1); obf="${obf:-2083}"
|
||||
# keep our own port if the service already holds it
|
||||
if [ -z "$port" ] || { port_busy "$port" && ! systemctl is-active -q phobos-tcp; }; then
|
||||
port=""
|
||||
for p in 443 8443 2096 4433; do port_busy "$p" || { port=$p; break; }; done
|
||||
fi
|
||||
[ -n "$port" ] || { echo "no free TCP port (443/8443/2096/4433)" >&2; exit 1; }
|
||||
env_set TCP_PORT "$port"
|
||||
cat > /etc/systemd/system/phobos-tcp.service <<EOF
|
||||
[Unit]
|
||||
Description=PCA Phobos TCP fallback (udptcp -> obfuscator :$obf)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
ExecStart=$BIN -s 0.0.0.0:$port 127.0.0.1:$obf
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable -q phobos-tcp
|
||||
systemctl restart phobos-tcp
|
||||
sleep 1
|
||||
status
|
||||
}
|
||||
|
||||
disable() {
|
||||
systemctl disable --now phobos-tcp >/dev/null 2>&1 || true
|
||||
rm -f /etc/systemd/system/phobos-tcp.service
|
||||
systemctl daemon-reload
|
||||
echo "tcp fallback: disabled"
|
||||
}
|
||||
|
||||
status() {
|
||||
printf '{"enabled": %s, "port": "%s"}\n' \
|
||||
"$(systemctl is-active -q phobos-tcp && echo true || echo false)" "$(env_get TCP_PORT)"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
enable) enable "${2:-}" ;;
|
||||
disable) disable ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 enable [port]|disable|status"; exit 2 ;;
|
||||
esac
|
||||
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]
|
||||
555
server/phobos-warp.sh
Executable file
@@ -0,0 +1,555 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVER_ENV="/opt/Phobos/server/server.env"
|
||||
WARP_DIR="/opt/Phobos/server/warp"
|
||||
WGCF_BIN="/usr/local/bin/wgcf"
|
||||
WARP_IF="warp"
|
||||
WARP_CONF="/etc/wireguard/${WARP_IF}.conf"
|
||||
WARP_TABLE="51820"
|
||||
WARP_RULE_PRIO="11930"
|
||||
STATE_FILE="${WARP_DIR}/state.env"
|
||||
FREE_PROFILE="${WARP_DIR}/wgcf-profile.conf"
|
||||
CUSTOM_PROFILE="${WARP_DIR}/custom-profile.conf"
|
||||
LICENSE_FILE="${WARP_DIR}/license.key"
|
||||
RELAY_TAG_PREFIX="PHOBOS_WARP_RELAY"
|
||||
|
||||
load_env() {
|
||||
if [ -f "$SERVER_ENV" ]; then
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
k="${line%%=*}"
|
||||
v="${line#*=}"
|
||||
case "$k" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
export "$k=$v"
|
||||
done < "$SERVER_ENV"
|
||||
fi
|
||||
}
|
||||
|
||||
load_state() {
|
||||
STATE_MODE=""
|
||||
STATE_PROFILE_SOURCE=""
|
||||
STATE_RELAY_PORTS=""
|
||||
STATE_RELAY_TARGET=""
|
||||
STATE_RELAY_TARGET_IP=""
|
||||
STATE_CLIENTS=""
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$STATE_FILE"
|
||||
STATE_MODE="${MODE:-}"
|
||||
STATE_PROFILE_SOURCE="${PROFILE_SOURCE:-}"
|
||||
STATE_RELAY_PORTS="${RELAY_PORTS:-}"
|
||||
STATE_RELAY_TARGET="${RELAY_TARGET:-}"
|
||||
STATE_RELAY_TARGET_IP="${RELAY_TARGET_IP:-}"
|
||||
STATE_CLIENTS="${CLIENTS:-}"
|
||||
fi
|
||||
}
|
||||
|
||||
save_state() {
|
||||
mkdir -p "$WARP_DIR"
|
||||
cat > "$STATE_FILE" <<EOF
|
||||
MODE=${1:-}
|
||||
PROFILE_SOURCE=${2:-}
|
||||
RELAY_PORTS=${3:-}
|
||||
RELAY_TARGET=${4:-}
|
||||
RELAY_TARGET_IP=${5:-}
|
||||
CLIENTS=${6:-}
|
||||
EOF
|
||||
}
|
||||
|
||||
server_public_ip() {
|
||||
if [ -n "${SERVER_PUBLIC_IP_V4:-}" ]; then
|
||||
printf '%s\n' "$SERVER_PUBLIC_IP_V4"
|
||||
else
|
||||
curl -4 -fsS --max-time 6 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
wg_subnet() {
|
||||
if [ -n "${SERVER_WG_IPV4_NETWORK:-}" ]; then
|
||||
printf '%s\n' "$SERVER_WG_IPV4_NETWORK"
|
||||
return
|
||||
fi
|
||||
if [ -f /etc/wireguard/wg0.conf ]; then
|
||||
python3 - <<'PY' /etc/wireguard/wg0.conf
|
||||
import ipaddress, re, sys
|
||||
for raw in open(sys.argv[1], encoding="utf-8"):
|
||||
m = re.match(r"^Address\s*=\s*(.+)$", raw.strip())
|
||||
if not m:
|
||||
continue
|
||||
value = m.group(1).split(",", 1)[0].strip()
|
||||
print(ipaddress.ip_interface(value).network)
|
||||
break
|
||||
PY
|
||||
return
|
||||
fi
|
||||
printf '10.25.0.0/16\n'
|
||||
}
|
||||
|
||||
arch_name() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "amd64" ;;
|
||||
aarch64|arm64) echo "arm64" ;;
|
||||
armv7l) echo "armv7" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_wgcf() {
|
||||
if [ -x "$WGCF_BIN" ]; then
|
||||
return
|
||||
fi
|
||||
local arch version url
|
||||
arch="$(arch_name)"
|
||||
[ -n "$arch" ] || { echo "unsupported architecture: $(uname -m)"; exit 1; }
|
||||
version="2.2.26"
|
||||
url="https://github.com/ViRb3/wgcf/releases/download/v${version}/wgcf_${version}_linux_${arch}"
|
||||
curl -fsSL "$url" -o "$WGCF_BIN"
|
||||
chmod +x "$WGCF_BIN"
|
||||
}
|
||||
|
||||
ensure_free_profile() {
|
||||
local license_key=""
|
||||
mkdir -p "$WARP_DIR"
|
||||
cd "$WARP_DIR"
|
||||
ensure_wgcf
|
||||
if [ ! -f "${WARP_DIR}/wgcf-account.toml" ]; then
|
||||
"$WGCF_BIN" register --accept-tos >/tmp/phobos-warp-register.log 2>&1
|
||||
fi
|
||||
if [ -s "$LICENSE_FILE" ]; then
|
||||
license_key="$(tr -d '\r\n' < "$LICENSE_FILE")"
|
||||
if [ -n "$license_key" ]; then
|
||||
"$WGCF_BIN" update --license "$license_key" >/tmp/phobos-warp-license.log 2>&1 || {
|
||||
echo "failed to apply WARP+ license key" >&2
|
||||
cat /tmp/phobos-warp-license.log >&2 || true
|
||||
exit 1
|
||||
}
|
||||
rm -f "$FREE_PROFILE"
|
||||
fi
|
||||
fi
|
||||
if [ ! -f "$FREE_PROFILE" ] || [ ! -s "$FREE_PROFILE" ]; then
|
||||
"$WGCF_BIN" generate >/tmp/phobos-warp-generate.log 2>&1 || {
|
||||
echo "failed to generate wgcf profile" >&2
|
||||
cat /tmp/phobos-warp-generate.log >&2 || true
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_custom_profile() {
|
||||
[ -s "$CUSTOM_PROFILE" ] || { echo "custom profile not found: $CUSTOM_PROFILE" >&2; exit 1; }
|
||||
}
|
||||
|
||||
profile_path_for() {
|
||||
case "${1:-free}" in
|
||||
custom) ensure_custom_profile; printf '%s\n' "$CUSTOM_PROFILE" ;;
|
||||
*) ensure_free_profile; printf '%s\n' "$FREE_PROFILE" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
profile_endpoint() {
|
||||
awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$1" | tr -d ' '
|
||||
}
|
||||
|
||||
resolve_host_ipv4() {
|
||||
getent ahostsv4 "$1" | awk 'NR==1{print $1}'
|
||||
}
|
||||
|
||||
build_warp_conf() {
|
||||
local profile_path subnet
|
||||
profile_path="$1"
|
||||
subnet="$(wg_subnet)"
|
||||
python3 - "$profile_path" "$WARP_CONF" "$subnet" "$WARP_TABLE" "$WARP_RULE_PRIO" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
profile_path = Path(sys.argv[1])
|
||||
out_path = Path(sys.argv[2])
|
||||
subnet = sys.argv[3]
|
||||
table = sys.argv[4]
|
||||
prio = sys.argv[5]
|
||||
|
||||
section = None
|
||||
data = {"Interface": {}, "Peer": {}}
|
||||
for raw in profile_path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line.strip("[]")
|
||||
continue
|
||||
if "=" not in line or section not in data:
|
||||
continue
|
||||
k, v = [x.strip() for x in line.split("=", 1)]
|
||||
data[section][k] = v
|
||||
|
||||
iface = data["Interface"]
|
||||
peer = data["Peer"]
|
||||
|
||||
post_up = (
|
||||
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
|
||||
f"ip route replace {subnet} dev wg0 table {table}; "
|
||||
f"ip route replace default dev %i table {table}; "
|
||||
"iptables -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 %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || "
|
||||
"iptables -I FORWARD 1 -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; "
|
||||
f"iptables -t nat -C POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || "
|
||||
f"iptables -t nat -A POSTROUTING -s {subnet} -o %i -j MASQUERADE"
|
||||
)
|
||||
post_down = (
|
||||
f"ip route del {subnet} dev wg0 table {table} 2>/dev/null || true; "
|
||||
f"ip route del default dev %i table {table} 2>/dev/null || true; "
|
||||
"iptables -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 %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true; "
|
||||
f"iptables -t nat -D POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || true"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"[Interface]",
|
||||
f"PrivateKey = {iface.get('PrivateKey', '')}",
|
||||
f"Address = {iface.get('Address', '')}",
|
||||
"MTU = 1280",
|
||||
"Table = off",
|
||||
f"PostUp = {post_up}",
|
||||
f"PostDown = {post_down}",
|
||||
"",
|
||||
"[Peer]",
|
||||
f"PublicKey = {peer.get('PublicKey', '')}",
|
||||
f"AllowedIPs = {peer.get('AllowedIPs', '0.0.0.0/0, ::/0')}",
|
||||
f"Endpoint = {peer.get('Endpoint', '')}",
|
||||
f"PersistentKeepalive = {peer.get('PersistentKeepalive', '25')}",
|
||||
"",
|
||||
]
|
||||
|
||||
out_path.write_text("\n".join(lines))
|
||||
PY
|
||||
chmod 600 "$WARP_CONF"
|
||||
}
|
||||
|
||||
clear_egress() {
|
||||
systemctl disable --now "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
||||
ip route del "$(wg_subnet)" dev wg0 table "$WARP_TABLE" 2>/dev/null || true
|
||||
ip route del default dev "$WARP_IF" table "$WARP_TABLE" 2>/dev/null || true
|
||||
iptables -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 "$WARP_IF" -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
||||
iptables -t nat -D POSTROUTING -s "$(wg_subnet)" -o "$WARP_IF" -j MASQUERADE 2>/dev/null || true
|
||||
}
|
||||
|
||||
clear_legacy_subnet_rule() {
|
||||
local subnet
|
||||
subnet="$(wg_subnet)"
|
||||
ip rule del from "$subnet" table "$WARP_TABLE" priority "$WARP_RULE_PRIO" 2>/dev/null || true
|
||||
ip rule del from "$subnet" lookup "$WARP_TABLE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
client_rule_prio() {
|
||||
python3 - "$1" "$WARP_RULE_PRIO" <<'PY'
|
||||
import sys
|
||||
ip = sys.argv[1].split('/')[0].strip()
|
||||
base = int(sys.argv[2])
|
||||
parts = [int(x) for x in ip.split('.')]
|
||||
value = ((parts[2] << 8) + parts[3]) % 5000
|
||||
print(base + 1 + value)
|
||||
PY
|
||||
}
|
||||
|
||||
normalize_client_ip() {
|
||||
printf '%s\n' "${1%%/*}" | tr -d ' '
|
||||
}
|
||||
|
||||
state_clients_csv_add() {
|
||||
local ip csv
|
||||
ip="$(normalize_client_ip "$1")"
|
||||
csv="${2:-}"
|
||||
python3 - "$ip" "$csv" <<'PY'
|
||||
import sys
|
||||
ip = sys.argv[1]
|
||||
items = [x for x in sys.argv[2].split(',') if x]
|
||||
if ip not in items:
|
||||
items.append(ip)
|
||||
print(",".join(items))
|
||||
PY
|
||||
}
|
||||
|
||||
state_clients_csv_del() {
|
||||
local ip csv
|
||||
ip="$(normalize_client_ip "$1")"
|
||||
csv="${2:-}"
|
||||
python3 - "$ip" "$csv" <<'PY'
|
||||
import sys
|
||||
ip = sys.argv[1]
|
||||
items = [x for x in sys.argv[2].split(',') if x and x != ip]
|
||||
print(",".join(items))
|
||||
PY
|
||||
}
|
||||
|
||||
clear_client_rules() {
|
||||
load_state
|
||||
local ip prio
|
||||
IFS=',' read -r -a __clients <<< "${STATE_CLIENTS:-}"
|
||||
for ip in "${__clients[@]}"; do
|
||||
[ -n "$ip" ] || continue
|
||||
prio="$(client_rule_prio "$ip")"
|
||||
ip rule del from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
reapply_client_rules() {
|
||||
local csv="${1:-}" ip prio
|
||||
IFS=',' read -r -a __clients <<< "$csv"
|
||||
for ip in "${__clients[@]}"; do
|
||||
[ -n "$ip" ] || continue
|
||||
prio="$(client_rule_prio "$ip")"
|
||||
ip rule add from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
relay_ports_array() {
|
||||
local raw="${1:-4500}"
|
||||
raw="${raw// /}"
|
||||
IFS=',' read -r -a RELAY_PORTS_ARRAY <<< "$raw"
|
||||
}
|
||||
|
||||
relay_rule_tag() {
|
||||
printf '%s:%s:%s:%s\n' "$RELAY_TAG_PREFIX" "$1" "$2" "$3"
|
||||
}
|
||||
|
||||
clear_relay() {
|
||||
load_state
|
||||
local lines line cmd
|
||||
lines="$(iptables-save -t nat 2>/dev/null | grep -F "$RELAY_TAG_PREFIX" || true)"
|
||||
if [ -n "$lines" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
case "$line" in
|
||||
-A\ *) cmd="iptables -t nat ${line/-A /-D }"; eval "$cmd" >/dev/null 2>&1 || true ;;
|
||||
esac
|
||||
done <<EOF
|
||||
$lines
|
||||
EOF
|
||||
fi
|
||||
lines="$(iptables-save 2>/dev/null | grep -F "$RELAY_TAG_PREFIX" || true)"
|
||||
if [ -n "$lines" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
case "$line" in
|
||||
-A\ FORWARD\ *) cmd="iptables ${line/-A /-D }"; eval "$cmd" >/dev/null 2>&1 || true ;;
|
||||
esac
|
||||
done <<EOF
|
||||
$lines
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
disable_all() {
|
||||
clear_legacy_subnet_rule
|
||||
clear_client_rules
|
||||
clear_egress
|
||||
clear_relay
|
||||
}
|
||||
|
||||
relay_enabled() {
|
||||
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
|
||||
}
|
||||
|
||||
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() {
|
||||
local profile_source profile_path existing_clients
|
||||
load_env
|
||||
load_state
|
||||
existing_clients="${STATE_CLIENTS:-}"
|
||||
profile_source="${1:-free}"
|
||||
disable_all
|
||||
profile_path="$(profile_path_for "$profile_source")"
|
||||
build_warp_conf "$profile_path"
|
||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
||||
systemctl restart "wg-quick@${WARP_IF}"
|
||||
pick_warp_endpoint
|
||||
clear_legacy_subnet_rule
|
||||
reapply_client_rules "$existing_clients"
|
||||
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
|
||||
status_json
|
||||
}
|
||||
|
||||
enable_relay() {
|
||||
local profile_source ports target profile_path endpoint host port_num target_ip server_ip port tag
|
||||
load_env
|
||||
profile_source="${1:-free}"
|
||||
ports="${2:-4500}"
|
||||
target="${3:-}"
|
||||
disable_all
|
||||
profile_path="$(profile_path_for "$profile_source")"
|
||||
endpoint="${target:-$(profile_endpoint "$profile_path")}"
|
||||
[ -n "$endpoint" ] || { echo "relay target endpoint is empty" >&2; exit 1; }
|
||||
host="${endpoint%:*}"
|
||||
port_num="${endpoint##*:}"
|
||||
target_ip="$(resolve_host_ipv4 "$host")"
|
||||
[ -n "$target_ip" ] || { echo "failed to resolve endpoint host: $host" >&2; exit 1; }
|
||||
server_ip="$(server_public_ip)"
|
||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||
relay_ports_array "$ports"
|
||||
for port in "${RELAY_PORTS_ARRAY[@]}"; do
|
||||
[ -n "$port" ] || continue
|
||||
tag="$(relay_rule_tag "$port" "$target_ip" "$port_num")"
|
||||
iptables -t nat -C PREROUTING -d "$server_ip" -p udp --dport "$port" -m comment --comment "$tag" -j DNAT --to-destination "$target_ip:$port_num" 2>/dev/null \
|
||||
|| iptables -t nat -A PREROUTING -d "$server_ip" -p udp --dport "$port" -m comment --comment "$tag" -j DNAT --to-destination "$target_ip:$port_num"
|
||||
iptables -t nat -C POSTROUTING -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j MASQUERADE 2>/dev/null \
|
||||
|| iptables -t nat -A POSTROUTING -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j MASQUERADE
|
||||
iptables -C FORWARD -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j ACCEPT 2>/dev/null \
|
||||
|| iptables -A FORWARD -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j ACCEPT
|
||||
iptables -C FORWARD -p udp -s "$target_ip" --sport "$port_num" -m comment --comment "$tag" -j ACCEPT 2>/dev/null \
|
||||
|| iptables -A FORWARD -p udp -s "$target_ip" --sport "$port_num" -m comment --comment "$tag" -j ACCEPT
|
||||
done
|
||||
save_state "relay" "$profile_source" "$ports" "$endpoint" "$target_ip" ""
|
||||
status_json
|
||||
}
|
||||
|
||||
client_enable() {
|
||||
local ip prio updated
|
||||
ip="$(normalize_client_ip "${1:-}")"
|
||||
[ -n "$ip" ] || { echo "client ip required" >&2; exit 1; }
|
||||
load_state
|
||||
[ "${STATE_MODE:-}" = "egress" ] || { echo "WARP egress mode is not active" >&2; exit 1; }
|
||||
systemctl is-active --quiet "wg-quick@${WARP_IF}" || { echo "WARP interface is not active" >&2; exit 1; }
|
||||
prio="$(client_rule_prio "$ip")"
|
||||
ip rule add from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
|
||||
updated="$(state_clients_csv_add "$ip" "${STATE_CLIENTS:-}")"
|
||||
save_state "${STATE_MODE:-egress}" "${STATE_PROFILE_SOURCE:-free}" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$updated"
|
||||
status_json
|
||||
}
|
||||
|
||||
client_disable() {
|
||||
local ip prio updated
|
||||
ip="$(normalize_client_ip "${1:-}")"
|
||||
[ -n "$ip" ] || { echo "client ip required" >&2; exit 1; }
|
||||
load_state
|
||||
prio="$(client_rule_prio "$ip")"
|
||||
ip rule del from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
|
||||
updated="$(state_clients_csv_del "$ip" "${STATE_CLIENTS:-}")"
|
||||
save_state "${STATE_MODE:-}" "${STATE_PROFILE_SOURCE:-}" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$updated"
|
||||
status_json
|
||||
}
|
||||
|
||||
status_json() {
|
||||
load_env
|
||||
load_state
|
||||
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on handshake=-1
|
||||
subnet="$(wg_subnet)"
|
||||
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
|
||||
mode="${STATE_MODE:-}"
|
||||
profile_source="${STATE_PROFILE_SOURCE:-}"
|
||||
configured=false
|
||||
endpoint=""
|
||||
pubkey=""
|
||||
egress=""
|
||||
relay_on=false
|
||||
|
||||
if [ "$service" = "active" ]; then
|
||||
mode="${mode:-egress}"
|
||||
configured=true
|
||||
endpoint="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" 2>/dev/null || true)"
|
||||
pubkey="$(wg show "${WARP_IF}" public-key 2>/dev/null || true)"
|
||||
egress="$(curl -4 --interface "${WARP_IF}" -fsS --max-time 6 https://api.ipify.org 2>/dev/null || true)"
|
||||
handshake="$(warp_handshake_age)"
|
||||
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
||||
fi
|
||||
|
||||
if relay_enabled; then
|
||||
relay_on=true
|
||||
[ -n "$mode" ] || mode="relay"
|
||||
configured=true
|
||||
[ -n "$endpoint" ] || endpoint="${STATE_RELAY_TARGET:-}"
|
||||
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
||||
fi
|
||||
|
||||
enabled=false
|
||||
if [ "$service" = "active" ] || [ "$relay_on" = true ]; then
|
||||
enabled=true
|
||||
fi
|
||||
|
||||
python3 - "$enabled" "$configured" "$service" "$subnet" "$endpoint" "$pubkey" "$egress" "$mode" "$profile_source" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$( [ -s "$CUSTOM_PROFILE" ] && echo true || echo false )" "$relay_on" "$( [ -s "$LICENSE_FILE" ] && echo true || echo false )" "${STATE_CLIENTS:-}" "$handshake" <<'PY'
|
||||
import json, sys
|
||||
enabled, configured, service, subnet, endpoint, pubkey, egress, mode, profile_source, relay_ports, relay_target, relay_target_ip, custom_saved, relay_on, license_saved, clients_csv, handshake = sys.argv[1:]
|
||||
if relay_on == "true" and service != "active":
|
||||
service = "relay"
|
||||
print(json.dumps({
|
||||
"enabled": enabled == "true",
|
||||
"configured": configured == "true",
|
||||
"service": service or "inactive",
|
||||
"mode": mode or "off",
|
||||
"profile_source": profile_source or "free",
|
||||
"subnet": subnet,
|
||||
"endpoint": endpoint,
|
||||
"public_key": pubkey,
|
||||
"egress_ipv4": egress,
|
||||
"handshake_age": int(handshake),
|
||||
"relay_enabled": relay_on == "true",
|
||||
"relay_ports": relay_ports,
|
||||
"relay_target": relay_target,
|
||||
"relay_target_ip": relay_target_ip,
|
||||
"clients": [x for x in clients_csv.split(",") if x],
|
||||
"custom_profile_saved": custom_saved == "true",
|
||||
"license_key_saved": license_saved == "true",
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
disable_warp() {
|
||||
disable_all
|
||||
save_state "" "" "" "" "" ""
|
||||
status_json
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
enable|up)
|
||||
case "${2:-egress}" in
|
||||
relay) enable_relay "${3:-free}" "${4:-4500}" "${5:-}" ;;
|
||||
*) enable_egress "${3:-free}" ;;
|
||||
esac
|
||||
;;
|
||||
client-enable) client_enable "${2:-}" ;;
|
||||
client-disable) client_disable "${2:-}" ;;
|
||||
disable|down) disable_warp ;;
|
||||
status|check|--json) status_json ;;
|
||||
*)
|
||||
echo "usage: $0 {status|enable [egress|relay] [free|custom] [ports] [endpoint]|client-enable <ip>|client-disable <ip>|disable}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -4,8 +4,8 @@
|
||||
# Deploys WG + obfuscator + mini-API (no web panel)
|
||||
#
|
||||
# Usage:
|
||||
# MAIN_SERVER=212.118.52.193 MAIN_API_KEY=secret123 \
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)
|
||||
# MAIN_PROTO=http MAIN_SERVER=203.0.113.10 MAIN_PORT=10514 MAIN_API_KEY=secret123 \
|
||||
# bash <(curl -fsSL http://203.0.113.10:10514/server/secondary-setup.sh)
|
||||
#
|
||||
# Uninstall:
|
||||
# bash /opt/Phobos/server/phobos-secondary-uninstall.sh
|
||||
@@ -15,12 +15,50 @@ set -e
|
||||
|
||||
MAIN_SERVER="${MAIN_SERVER:?Set MAIN_SERVER=ip_of_main_server}"
|
||||
MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
|
||||
MAIN_PROTO="${MAIN_PROTO:-http}"
|
||||
MAIN_PORT="${MAIN_PORT:-8443}"
|
||||
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="$(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"
|
||||
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||
import ipaddress, sys
|
||||
net = ipaddress.ip_network(sys.argv[1], strict=False)
|
||||
print(next(net.hosts()))
|
||||
PY
|
||||
)"
|
||||
WG_PREFIXLEN="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||
import ipaddress, sys
|
||||
print(ipaddress.ip_network(sys.argv[1], strict=False).prefixlen)
|
||||
PY
|
||||
)"
|
||||
|
||||
SERVER_IP=$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}')
|
||||
SERVER_IP="${SERVER_IP:-$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}')}"
|
||||
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
||||
IFACE="${IFACE:-eth0}"
|
||||
case "$MAIN_PROTO" in http|https) ;; *) echo "ERROR: MAIN_PROTO must be http or https"; exit 1;; esac
|
||||
|
||||
# ── Pre-flight safety checks ──
|
||||
echo ""
|
||||
@@ -30,6 +68,8 @@ echo "╠═══════════════════════
|
||||
echo "║ Server IP : $SERVER_IP"
|
||||
echo "║ Interface : $IFACE"
|
||||
echo "║ Main server : $MAIN_SERVER"
|
||||
echo "║ Main proto : $MAIN_PROTO"
|
||||
echo "║ Main port : $MAIN_PORT"
|
||||
echo "║ OBF ports : $OBF_PORTS"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
@@ -104,7 +144,7 @@ if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Found $WARNINGS warning(s). Existing configs will be preserved where possible."
|
||||
echo "Press Enter to continue or Ctrl+C to abort..."
|
||||
read -r < /dev/tty 2>/dev/null || true
|
||||
[ -t 0 ] && read -r < /dev/tty 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -126,13 +166,17 @@ fi
|
||||
# ── 1. Dependencies ──
|
||||
echo "[1/7] Installing dependencies..."
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard jq curl python3 python3-flask gunicorn 2>/dev/null
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard wireguard-tools iptables jq curl git ca-certificates python3 python3-flask gunicorn 2>/dev/null
|
||||
|
||||
# ── 2. Phobos binaries ──
|
||||
echo "[2/7] Getting Phobos binaries..."
|
||||
mkdir -p "$PHOBOS_DIR"/{server,clients,bin}
|
||||
|
||||
if [ ! -f /usr/local/bin/wg-obfuscator ]; then
|
||||
ARCH="$(uname -m)"
|
||||
OBF_ARCH="$ARCH"
|
||||
[ "$OBF_ARCH" = "amd64" ] && OBF_ARCH="x86_64"
|
||||
[ "$OBF_ARCH" = "arm64" ] && OBF_ARCH="aarch64"
|
||||
REPO_DIR="/tmp/phobos-repo"
|
||||
rm -rf "$REPO_DIR"
|
||||
mkdir -p "$REPO_DIR"
|
||||
@@ -142,10 +186,34 @@ if [ ! -f /usr/local/bin/wg-obfuscator ]; then
|
||||
git config core.sparseCheckout true
|
||||
echo "wg-obfuscator" > .git/info/sparse-checkout
|
||||
git pull origin main -q 2>/dev/null
|
||||
cp -f wg-obfuscator/bin/wg-obfuscator-$(uname -m) "$PHOBOS_DIR/bin/" 2>/dev/null || true
|
||||
chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-*
|
||||
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-$(uname -m)" /usr/local/bin/wg-obfuscator
|
||||
cp -f wg-obfuscator/bin/wg-obfuscator-* "$PHOBOS_DIR/bin/" 2>/dev/null || true
|
||||
chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-* 2>/dev/null || true
|
||||
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${OBF_ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
|
||||
rm -rf "$REPO_DIR"
|
||||
|
||||
if [ ! -x /usr/local/bin/wg-obfuscator ]; then
|
||||
OBF_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/')
|
||||
CB="https://github.com/ClusterM/wg-obfuscator/releases/download/${OBF_TAG}"
|
||||
for MAP in "x86_64:linux-x64" "aarch64:linux-arm64" "mipsel:linux-mipsel-mips32" "mips:linux-mips-mips32" "armv7:linux-armv7-hf"; do
|
||||
DA="${MAP%%:*}"
|
||||
SS="${MAP##*:}"
|
||||
[ "$DA" = "$OBF_ARCH" ] || continue
|
||||
[ -n "$OBF_TAG" ] || continue
|
||||
TMP_DIR=$(mktemp -d)
|
||||
if curl -fsSL -m30 "${CB}/wg-obfuscator-${OBF_TAG}-${SS}.tar.gz" -o "$TMP_DIR/o.tgz" 2>/dev/null &&
|
||||
tar -xzf "$TMP_DIR/o.tgz" -C "$TMP_DIR" 2>/dev/null; then
|
||||
BIN_PATH=$(find "$TMP_DIR" -name "wg-obfuscator" -type f | head -1)
|
||||
if [ -n "$BIN_PATH" ]; then
|
||||
cp "$BIN_PATH" "$PHOBOS_DIR/bin/wg-obfuscator-${DA}"
|
||||
chmod +x "$PHOBOS_DIR/bin/wg-obfuscator-${DA}"
|
||||
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${DA}" /usr/local/bin/wg-obfuscator
|
||||
fi
|
||||
fi
|
||||
rm -rf "$TMP_DIR"
|
||||
done
|
||||
fi
|
||||
|
||||
[ -x /usr/local/bin/wg-obfuscator ] || { echo "ERROR: obfuscator binary for $ARCH missing"; exit 1; }
|
||||
echo " Obfuscator installed."
|
||||
else
|
||||
echo " Obfuscator already installed, skipping."
|
||||
@@ -163,7 +231,7 @@ else
|
||||
|
||||
cat > /etc/wireguard/wg0.conf << WGEOF
|
||||
[Interface]
|
||||
Address = 10.25.0.1/16
|
||||
Address = ${WG_SERVER_IPV4}/${WG_PREFIXLEN}
|
||||
ListenPort = 51820
|
||||
PrivateKey = $WG_PRIV
|
||||
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
|
||||
@@ -229,23 +297,26 @@ cat > "$PHOBOS_DIR/server/server.env" << EOF
|
||||
SERVER_WG_PRIVATE_KEY=$WG_PRIV
|
||||
SERVER_WG_PUBLIC_KEY=$WG_PUB
|
||||
SERVER_PUBLIC_IP_V4=$SERVER_IP
|
||||
SERVER_WG_IPV4_NETWORK=$SERVER_WG_IPV4_NETWORK
|
||||
SERVER_WG_IPV4_ADDRESS=${WG_SERVER_IPV4}/${WG_PREFIXLEN}
|
||||
OBFUSCATOR_KEY=$OBF_KEY
|
||||
OBFUSCATOR_PORTS=$OBF_PORTS
|
||||
MAIN_SERVER=$MAIN_SERVER
|
||||
MAIN_PROTO=$MAIN_PROTO
|
||||
MAIN_API_KEY=$MAIN_API_KEY
|
||||
MAIN_PORT=$MAIN_PORT
|
||||
ROLE=secondary
|
||||
EOF
|
||||
|
||||
# ── 6. Mini-API ──
|
||||
echo "[6/7] Setting up mini-API..."
|
||||
# Mini-API (fetched from PCA — includes /api/router-config for tunnel pull)
|
||||
# Mini-API (fetched from the main panel first, then PCA GitHub fallback)
|
||||
PCA_BRANCH="${PCA_BRANCH:-main}"
|
||||
RAW="https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}"
|
||||
if [ -n "${GH_TOKEN:-}" ]; then
|
||||
curl -fsSL -H "Authorization: token $GH_TOKEN" "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
||||
else
|
||||
curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
||||
fi
|
||||
RAW="${PCA_RAW_BASE:-https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/${PCA_BRANCH}}"
|
||||
PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/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 "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; }
|
||||
|
||||
cat > /etc/systemd/system/phobos-api.service << EOF
|
||||
@@ -291,7 +362,7 @@ echo ""
|
||||
echo "Backups (if any) in $PHOBOS_DIR/backup/ will be KEPT."
|
||||
echo ""
|
||||
echo "Press Enter to continue or Ctrl+C to abort..."
|
||||
read -r < /dev/tty 2>/dev/null || true
|
||||
[ -t 0 ] && read -r < /dev/tty 2>/dev/null || true
|
||||
|
||||
echo "Stopping services..."
|
||||
# Stop obfuscator instances
|
||||
@@ -367,7 +438,7 @@ REG_DATA=$(cat << EOF
|
||||
}
|
||||
EOF
|
||||
)
|
||||
curl -s -X POST "http://${MAIN_SERVER}:$(cat /opt/phobos-panel/.port 2>/dev/null || echo 8443)/api/servers/register" \
|
||||
curl -ks -X POST "${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/api/servers/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${MAIN_API_KEY}" \
|
||||
-d "$REG_DATA" || echo " (Registration will be done manually via panel)"
|
||||
|
||||
6
udptcp/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# udptcp — резервный TCP-канал PCA Phobos
|
||||
|
||||
UDP-пакеты (уже обфусцированные) внутри TCP: кадр = 2 байта длины + пакет.
|
||||
- сервер: `udptcp -s 0.0.0.0:443 127.0.0.1:2083` (служба `phobos-tcp`, порт в server.env `TCP_PORT`);
|
||||
- клиент: `udptcp -c 127.0.0.1:13256 <сервер>:443`, обфускатор клиента шлёт на 127.0.0.1:13256.
|
||||
Сборка: `gcc -O2 -static -pthread -o bin/udptcp-x86_64 udptcp.c` (+ aarch64-linux-gnu-gcc), Android — NDK (см. android-client/README.md).
|
||||
BIN
udptcp/bin/udptcp-aarch64
Executable file
BIN
udptcp/bin/udptcp-x86_64
Executable file
175
udptcp/udptcp.c
Normal file
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* udptcp — PCA Phobos fallback transport: UDP datagrams carried over TCP.
|
||||
* Frames on the TCP stream: 2-byte big-endian length + payload.
|
||||
*
|
||||
* server: udptcp -s <listen_ip:port> <udp_target_ip:port>
|
||||
* each TCP connection gets its own UDP socket to the target
|
||||
* (the local wg-obfuscator), so the obfuscator sees one client per conn.
|
||||
* client: udptcp -c <local_udp_ip:port> <tcp_server_host:port>
|
||||
* local UDP peer (client wg-obfuscator) <-> one TCP connection, reconnects.
|
||||
*
|
||||
* Plain C, no deps: builds for Linux servers, Android (NDK) and iOS.
|
||||
*/
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MAXPKT 65535
|
||||
|
||||
static int parse_addr(const char *s, struct sockaddr_storage *ss, socklen_t *len, int socktype) {
|
||||
char host[256];
|
||||
const char *colon = strrchr(s, ':');
|
||||
if (!colon || colon == s || (size_t)(colon - s) >= sizeof host) return -1;
|
||||
memcpy(host, s, colon - s);
|
||||
host[colon - s] = 0;
|
||||
struct addrinfo hints = {0}, *res;
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = socktype;
|
||||
if (getaddrinfo(host, colon + 1, &hints, &res) != 0) return -1;
|
||||
memcpy(ss, res->ai_addr, res->ai_addrlen);
|
||||
*len = res->ai_addrlen;
|
||||
freeaddrinfo(res);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int read_full(int fd, unsigned char *buf, size_t n) {
|
||||
size_t got = 0;
|
||||
while (got < n) {
|
||||
ssize_t r = read(fd, buf + got, n - got);
|
||||
if (r <= 0) return -1;
|
||||
got += (size_t)r;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int write_frame(int fd, const unsigned char *p, size_t n) {
|
||||
unsigned char hdr[2] = {(unsigned char)(n >> 8), (unsigned char)(n & 0xff)};
|
||||
if (write(fd, hdr, 2) != 2) return -1;
|
||||
size_t sent = 0;
|
||||
while (sent < n) {
|
||||
ssize_t w = write(fd, p + sent, n - sent);
|
||||
if (w <= 0) return -1;
|
||||
sent += (size_t)w;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void tune_tcp(int fd) {
|
||||
int one = 1;
|
||||
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
|
||||
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof one);
|
||||
}
|
||||
|
||||
/* ── server ─────────────────────────────────────────────────────────────── */
|
||||
static struct sockaddr_storage g_target;
|
||||
static socklen_t g_target_len;
|
||||
|
||||
static void *serve_conn(void *arg) {
|
||||
int tcp = (int)(long)arg;
|
||||
unsigned char buf[MAXPKT + 2];
|
||||
int udp = socket(g_target.ss_family, SOCK_DGRAM, 0);
|
||||
if (udp < 0 || connect(udp, (struct sockaddr *)&g_target, g_target_len) < 0) goto out;
|
||||
tune_tcp(tcp);
|
||||
struct pollfd p[2] = {{tcp, POLLIN, 0}, {udp, POLLIN, 0}};
|
||||
for (;;) {
|
||||
if (poll(p, 2, 300000) <= 0) break; /* 5 min idle -> drop */
|
||||
if (p[0].revents & (POLLIN | POLLHUP | POLLERR)) {
|
||||
unsigned char h[2];
|
||||
if (read_full(tcp, h, 2) < 0) break;
|
||||
size_t n = ((size_t)h[0] << 8) | h[1];
|
||||
if (n == 0 || read_full(tcp, buf, n) < 0) break;
|
||||
send(udp, buf, n, 0);
|
||||
}
|
||||
if (p[1].revents & POLLIN) {
|
||||
ssize_t n = recv(udp, buf, MAXPKT, 0);
|
||||
if (n > 0 && write_frame(tcp, buf, (size_t)n) < 0) break;
|
||||
}
|
||||
}
|
||||
out:
|
||||
if (udp >= 0) close(udp);
|
||||
close(tcp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int run_server(const char *listen_s, const char *target_s) {
|
||||
struct sockaddr_storage la; socklen_t ll;
|
||||
if (parse_addr(listen_s, &la, &ll, SOCK_STREAM) || parse_addr(target_s, &g_target, &g_target_len, SOCK_DGRAM)) {
|
||||
fprintf(stderr, "bad address\n"); return 2;
|
||||
}
|
||||
int ls = socket(la.ss_family, SOCK_STREAM, 0), one = 1;
|
||||
setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
||||
if (bind(ls, (struct sockaddr *)&la, ll) < 0 || listen(ls, 128) < 0) { perror("listen"); return 1; }
|
||||
fprintf(stderr, "udptcp server %s -> udp %s\n", listen_s, target_s);
|
||||
for (;;) {
|
||||
int c = accept(ls, NULL, NULL);
|
||||
if (c < 0) { if (errno == EINTR) continue; perror("accept"); sleep(1); continue; }
|
||||
pthread_t t;
|
||||
if (pthread_create(&t, NULL, serve_conn, (void *)(long)c) == 0) pthread_detach(t);
|
||||
else close(c);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── client ─────────────────────────────────────────────────────────────── */
|
||||
static int connect_tcp(const char *server_s) {
|
||||
struct sockaddr_storage sa; socklen_t sl;
|
||||
if (parse_addr(server_s, &sa, &sl, SOCK_STREAM)) return -1; /* re-resolve each time */
|
||||
int s = socket(sa.ss_family, SOCK_STREAM, 0);
|
||||
if (s < 0) return -1;
|
||||
if (connect(s, (struct sockaddr *)&sa, sl) < 0) { close(s); return -1; }
|
||||
tune_tcp(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
static int run_client(const char *local_s, const char *server_s) {
|
||||
struct sockaddr_storage la, peer; socklen_t ll, pl = 0;
|
||||
if (parse_addr(local_s, &la, &ll, SOCK_DGRAM)) { fprintf(stderr, "bad address\n"); return 2; }
|
||||
int us = socket(la.ss_family, SOCK_DGRAM, 0);
|
||||
if (bind(us, (struct sockaddr *)&la, ll) < 0) { perror("bind"); return 1; }
|
||||
fprintf(stderr, "udptcp client udp %s -> tcp %s\n", local_s, server_s);
|
||||
unsigned char buf[MAXPKT + 2];
|
||||
int tcp = -1;
|
||||
time_t last_try = 0;
|
||||
for (;;) {
|
||||
struct pollfd p[2] = {{us, POLLIN, 0}, {tcp, POLLIN, 0}};
|
||||
poll(p, tcp >= 0 ? 2 : 1, 1000);
|
||||
if (p[0].revents & POLLIN) {
|
||||
pl = sizeof peer;
|
||||
ssize_t n = recvfrom(us, buf, MAXPKT, 0, (struct sockaddr *)&peer, &pl);
|
||||
if (n > 0) {
|
||||
if (tcp < 0 && time(NULL) != last_try) {
|
||||
last_try = time(NULL);
|
||||
tcp = connect_tcp(server_s);
|
||||
fprintf(stderr, tcp >= 0 ? "connected\n" : "connect failed\n");
|
||||
}
|
||||
if (tcp >= 0 && write_frame(tcp, buf, (size_t)n) < 0) { close(tcp); tcp = -1; }
|
||||
}
|
||||
}
|
||||
if (tcp >= 0 && (p[1].revents & (POLLIN | POLLHUP | POLLERR))) {
|
||||
unsigned char h[2];
|
||||
if (read_full(tcp, h, 2) < 0) { close(tcp); tcp = -1; continue; }
|
||||
size_t n = ((size_t)h[0] << 8) | h[1];
|
||||
if (n == 0 || read_full(tcp, buf, n) < 0) { close(tcp); tcp = -1; continue; }
|
||||
if (pl) sendto(us, buf, n, 0, (struct sockaddr *)&peer, pl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
if (argc == 4 && !strcmp(argv[1], "-s")) return run_server(argv[2], argv[3]);
|
||||
if (argc == 4 && !strcmp(argv[1], "-c")) return run_client(argv[2], argv[3]);
|
||||
fprintf(stderr, "usage: udptcp -s <listen ip:port> <udp target ip:port>\n"
|
||||
" udptcp -c <local udp ip:port> <tcp server host:port>\n");
|
||||
return 2;
|
||||
}
|
||||
73
uninstall.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# PCA Phobos — полное удаление сервера (primary / panel node)
|
||||
#
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/uninstall.sh)
|
||||
#
|
||||
# Останавливает и удаляет: панель, обфускатор-сервисы, WireGuard wg0,
|
||||
# nginx-сайт, watchdog-cron, бинарники, iptables-правила, каталоги Phobos.
|
||||
# По умолчанию спрашивает подтверждение. PURGE=1 — без вопросов.
|
||||
# ============================================================
|
||||
set -u
|
||||
PHOBOS_DIR="/opt/Phobos"
|
||||
PANEL_DIR="/opt/phobos-panel"
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Запусти от root"; exit 1; }
|
||||
|
||||
echo "=================================================="
|
||||
echo " PCA Phobos — УДАЛЕНИЕ сервера"
|
||||
echo " Будут удалены: панель, obfuscator, wg0, nginx-сайт,"
|
||||
echo " watchdog, бинарники, $PHOBOS_DIR, $PANEL_DIR"
|
||||
echo "=================================================="
|
||||
if [ "${PURGE:-0}" != "1" ]; then
|
||||
printf "Точно удалить ВСЁ? [yes/NO]: "
|
||||
read -r ans </dev/tty 2>/dev/null || read -r ans
|
||||
# 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
|
||||
|
||||
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}"
|
||||
|
||||
echo "[1/8] stop + disable services..."
|
||||
# obfuscator services (any port)
|
||||
for svc in $(systemctl list-units --all --plain --no-legend 'wg-obfuscator-*' 2>/dev/null | awk '{print $1}'); do
|
||||
systemctl stop "$svc" 2>/dev/null; systemctl disable "$svc" 2>/dev/null
|
||||
done
|
||||
for svc in phobos-panel phobos-api wg-quick@wg0; do
|
||||
systemctl stop "$svc" 2>/dev/null; systemctl disable "$svc" 2>/dev/null
|
||||
done
|
||||
|
||||
echo "[2/8] remove systemd units..."
|
||||
rm -f /etc/systemd/system/wg-obfuscator-*.service 2>/dev/null
|
||||
rm -f /etc/systemd/system/phobos-panel.service /etc/systemd/system/phobos-api.service 2>/dev/null
|
||||
systemctl daemon-reload 2>/dev/null
|
||||
|
||||
echo "[3/8] remove WireGuard wg0..."
|
||||
rm -f /etc/wireguard/wg0.conf 2>/dev/null
|
||||
|
||||
echo "[4/8] remove iptables rules..."
|
||||
iptables -t nat -D POSTROUTING -o "$IFACE" -j MASQUERADE 2>/dev/null || true
|
||||
iptables -D FORWARD -i wg0 -j ACCEPT 2>/dev/null || true
|
||||
iptables -D FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
||||
iptables -D INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null || true
|
||||
|
||||
echo "[5/8] remove nginx site..."
|
||||
rm -f /etc/nginx/sites-enabled/phobos /etc/nginx/sites-available/phobos 2>/dev/null
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true
|
||||
|
||||
echo "[6/8] remove watchdog cron..."
|
||||
( crontab -l 2>/dev/null | grep -v 'phobos-router-watchdog' | grep -v '/opt/Phobos' || true ) | crontab - 2>/dev/null || true
|
||||
|
||||
echo "[7/8] remove binaries / symlinks..."
|
||||
rm -f /usr/local/bin/wg-obfuscator /usr/local/bin/phobos-update 2>/dev/null
|
||||
|
||||
echo "[8/8] remove directories..."
|
||||
rm -rf "$PHOBOS_DIR" "$PANEL_DIR" 2>/dev/null
|
||||
|
||||
echo "=================================================="
|
||||
echo " Phobos удалён."
|
||||
echo " (WireGuard/nginx/cron-пакеты НЕ удалены — общие; убрать вручную при желании)"
|
||||
echo "=================================================="
|
||||