45 Commits

Author SHA1 Message Date
Бобырев Андрей
d030344b00 release: 1.4.8 — сторож роутера не трогает чужие туннели
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:12:38 +03:00
Бобырев Андрей
8dc9fda65b release: 1.4.7 — failover на Keenetic, cron, таймаут pull
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:41:40 +03:00
Бобырев Андрей
8bfc84f315 phobos-health: переключение сервера больше не убивает туннель
Пир добавлялся с зашитым 127.0.0.1:13255, а обфускатор на части роутеров
слушает другой порт (51820) — после любого failover туннель был мёртв.
Порт теперь из wg-obfuscator.conf; пир через ndmc по шагам, allow-ips
0.0.0.0/0 и перезапуск интерфейса (ядро иначе не берёт allow-ips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f934c5b3b0344264586a65b97b2b80b315c2e0c1)
2026-11-01 16:41:24 +03:00
Бобырев Андрей
381e97a564 Keenetic: health/pull по cron реально запускаются
Задачи писались в /opt/etc/crontab, который демоны cron на Keenetic не читают,
а Entware-пакет cron (vixie) задачи не выполнял. Теперь busybox crond
и crontab пользователя root; cron ставится, если нет S10cron.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit cbc1bd712f4ac0854d00b18d349d0a9a7d41cc8b)
2026-11-01 16:41:24 +03:00
Бобырев Андрей
eefe3fc2af phobos-pull: таймаут wget 8 с — мёртвый туннель вешал pull навсегда
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c2d367ad527c3b14ba9b5ba421e74ae7a0cb7d33)
2026-11-01 16:41:24 +03:00
Бобырев Андрей
15011c2756 phobos-client.sh: лимит GitHub API не валит сборку пакета 2026-11-01 15:18:23 +03:00
Бобырев Андрей
29790aaf4d release: 1.4.6 — router-config accepts server_api_key as pull token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 04:43:06 +03:00
Бобырев Андрей
884df15f42 release: 1.4.5 — subnet at install, beta/dev from private Gitea repo by token
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 03:48:28 +03:00
Бобырев Андрей
eb2b2768cd install: take subnet from existing wg0.conf on reinstall
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 03:00:20 +03:00
Бобырев Андрей
147ef3a42d feat: ask VPN client subnet at install (SERVER_WG_IPV4_NETWORK)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:46:49 +03:00
Бобырев Андрей
dde6403cea release: 1.4.4 — channel «Облако» (WebSocket via KeenDNS cloud to own Keenetic): wsgw gateway, Android 1.4.4, iOS, docs/cloud.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:16:31 +03:00
Бобырев Андрей
d795bc6f70 release: 1.4.2 — server obfuscator settings, Android 1.4.3 fix for configs with unknown obfuscator keys
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:13 +03:00
Бобырев Андрей
c139debd8d Keep desktop (macOS/Windows) clients private
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 04:15:32 +03:00
Бобырев Андрей
01632034b0 macos: PCA Phobos for Mac
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:54:34 +03:00
Бобырев Андрей
071c1dc7f1 ios: PCA Phobos VPN for iPhone (source)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:37:06 +03:00
Бобырев Андрей
dff6af2c8e release: 1.4.1 — TCP fallback, Russian sites direct in the Android client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:27:23 +03:00
Бобырев Андрей
071d3695e6 android-client 1.4.1: Russian sites direct by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:03:02 +03:00
Бобырев Андрей
5354656bb1 fix(cascade): panel serves phobos-cascade.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:34:30 +03:00
Бобырев Андрей
5a01a6eed2 release: 1.4.0 — distribution via git.bobyrev.com
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:31:23 +03:00
Бобырев Андрей
7eac63343d release: 1.4.0 — one version for stable/beta/dev
Light/dark theme and compact mobile layout, per-client speed limit, real
speed tests, panel domain (Let's Encrypt), client address + cascade relay,
RKN scanner guard, WARP, backup/restore, router web terminal, Android VPN
client with split tunnelling and Android admin app. Updating from 1.2.x
keeps keys, server.env and clients; tested 1.2.8 -> 1.4.0 on live VMs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:22:46 +03:00
andrey271192
093301eb51 fix: allocate client IPs from used metadata 2026-06-24 18:19:49 +03:00
andrey271192
1a0460b927 fix: add install self check 2026-06-12 12:43:09 +03:00
andrey271192
95806be3ed fix: preserve wireguard key padding 2026-06-12 12:34:18 +03:00
andrey271192
a686ea967e fix: ship router package helpers 2026-06-12 12:21:26 +03:00
andrey271192
8e491b8f29 docs: add install error fixes 2026-06-11 19:42:47 +03:00
andrey271192
e215cedbb3 fix: clean stable client input pattern 2026-06-11 19:33:06 +03:00
andrey271192
eac33b3cf6 fix: ship stable lib-core helper 2026-06-11 19:27:33 +03:00
andrey271192
3831292330 fix: create patch fetch directories 2026-06-11 19:03:08 +03:00
andrey271192
13644f6991 fix: fetch x64 obfuscator fallback 2026-06-11 14:40:55 +03:00
Андрей Бобырев
07fc4657bf feat: promote.sh — channel promotion/forward-port tool (dev->beta->stable)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:22:45 +03:00
andrey271192
e97b106adc docs: add user troubleshooting guide 2026-06-01 03:18:41 +03:00
Андрей Бобырев
390501ad34 fix: pull router config through tunnel iface 2026-06-01 01:50:37 +03:00
Андрей Бобырев
e430d1a70c fix: recover secondary API key handling 2026-06-01 00:54:26 +03:00
Андрей Бобырев
fde7b9b897 fix: restore short router install command 2026-06-01 00:48:08 +03:00
Андрей Бобырев
c1a151903a fix: harden router installs and failover sync 2026-06-01 00:02:40 +03:00
Codex
38ff3ae3dd Add stable APK download button 2026-05-31 16:13:20 +03:00
Андрей Бобырев
4c1c82afec stable 1.2.8: scannable QR (ECC L + native size)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:21:38 +03:00
Андрей Бобырев
0f98c074c8 docs: add full server uninstall section + link
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:11:06 +03:00
Андрей Бобырев
286142bd7a docs: APK downloaded via in-panel button (beta/dev), drop manual .apk placement note
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:07:47 +03:00
Андрей Бобырев
cf4b5f79b5 docs: CHANGELOG + troubleshooting (handshake/aarch64/phobos-import/uninstall) + update steps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:06:00 +03:00
Андрей Бобырев
5f97c6c6d7 feat: server uninstall.sh (full removal: panel, obfuscator, wg0, nginx, cron, iptables, dirs)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:59:49 +03:00
Андрей Бобырев
14bb6ae589 stable 1.2.7: phobos:// pad missing fields with '= none' (PhobosWG import fix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:57:21 +03:00
Андрей Бобырев
51712310f6 fix: force OBFUSCATOR_PORT from OBFUSCATOR_PORTS[0] (override lib-core 51821 default)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:56:01 +03:00
Андрей Бобырев
6ec1782d8b fix(stable): client obfuscator targets real listening port (OBFUSCATOR_PORTS[0])
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:53:37 +03:00
Андрей Бобырев
e61ec09b18 fix: find -type f for wg-obfuscator binary in archive (tar unpacks to dir)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:26:40 +03:00
164 changed files with 25955 additions and 588 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.pyc
windows/build/

10
BETA.md Normal file
View File

@@ -0,0 +1,10 @@
# Каналы PCA Phobos
С версии **1.4.0** все три канала (stable / beta / dev) — одна и та же версия.
Новые фичи по-прежнему сначала выкатываются в `dev`, затем в `beta`, затем в `main`
(`promote.sh`), но база одна — без расхождений.
```bash
phobos-update # stable (открытый)
GH_TOKEN=… phobos-update beta|dev # для подписчиков Boosty
```

162
CHANGELOG.md Normal file
View File

@@ -0,0 +1,162 @@
# Changelog — PCA Phobos
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
Обновление: `phobos-update stable` · `GH_TOKEN=… phobos-update beta|dev` · из панели → «Версия и обновления».
---
## Как обновиться после фиксов
```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.

View File

@@ -17,7 +17,8 @@
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
- 📱 **Телефоны** — Android (PhobosWG, с обфускацией) и iPhone (обычный WireGuard) через QR-код.
- 📱 **Телефоны** — своё приложение **PCA Phobos VPN** для Android (обфускация + раздельный трафик по приложениям), PhobosWG, iPhone (обычный WireGuard) — через QR/ссылку.
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
---
@@ -25,14 +26,23 @@
- **Активные сессии** — VPN IP, Real IP, метка, handshake, трафик RX/TX, Kick.
- **Клиенты VPN** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера.
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM), резервные серверы; роутеры переключаются автоматически.
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM + пропускная способность канала), резервные серверы; роутеры переключаются автоматически.
- **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP.
- **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS.
- **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR.
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия.
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия. **Светлая и тёмная тема** (кнопка ◐), компактная вёрстка — удобно с телефона.
- **Лимит скорости на клиента** — Мбит/с в карточке клиента; работает на всех серверах, сам восстанавливается после перезапуска WireGuard.
- **Замер скорости** — сервера (реальная скачка/отдача, кнопка ⚡ на «Серверах»), клиента (живая скорость в карточке + страница `http://10.25.0.1:<порт>/speedtest` на устройстве через VPN).
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
- **Канал «Облако»** — если сервер заблокирован целиком: телефон идёт через облако KeenDNS своего Keenetic. [Инструкция](docs/cloud.md).
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
- **Версии и обновления** — обновление и откат прямо из панели.
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
- **Telegram-бот** — управление клиентами командами прямо из чата:
@@ -40,69 +50,98 @@
- `/del <имя>` — удалить клиента
- `/list` — список клиентов
- **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь.
(Работает только для чата из настроек `tg_chat_id`.)
(Работает только для чата из настроек `tg_chat_id`.) Бот **необязателен**: хочешь — впиши токен от @BotFather и свой chat id в «Настройки», не хочешь — оставь пустым.
---
## Скриншоты
| Сессии | Клиенты | Серверы |
|--------|---------|---------|
| Клиенты (светлая) | Клиенты (тёмная) |
|---|---|
| ![Клиенты](docs/img/clients.png) | ![Клиенты тёмная](docs/img/clients-dark.png) |
| **Сессии** | **Серверы: замер скорости, второй сервер** |
| ![Сессии](docs/img/sessions.png) | ![Серверы](docs/img/servers.png) |
| Телефон: панель | Телефон: тёмная | Android: VPN-клиент | Android: раздельный трафик | Android: админ-приложение |
|---|---|---|---|---|
| ![](docs/img/mobile-clients.png) | ![](docs/img/mobile-clients-dark.png) | ![](docs/img/android-vpn.png) | ![](docs/img/android-vpn-apps.png) | ![](docs/img/android-admin.png) |
Настройки (домен, каскад, сторож, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png)
--------|---------|---------|
| ![Сессии](docs/img/sessions.png) | ![Клиенты](docs/img/clients.png) | ![Серверы](docs/img/servers.png) |
> Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`).
---
> **Источник с 23.10.2026:** `git.bobyrev.com/PCA/PCA_Phobos` (GitHub-аккаунт временно недоступен). Серверы на 1.2.x обновляются одной командой:
> ```bash
> 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://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/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://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/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 (без обфускации) |
| `CHANNEL` | `stable` | канал: `stable` (открытый) · `beta`/`dev` (под ключом `PHOBOS_KEY`) |
| `PANEL_TLS_CERT` / `PANEL_TLS_KEY` | пусто | Включить HTTPS для панели через свой сертификат |
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
> **Каналы:**
> - **stable** — стабильная (ветка `main`), ставится **без ключа**, по умолчанию.
> - **beta** — кандидат в релиз (тест), **под ключом** подписчика.
> - **dev** — активная разработка (нестабильно), **под ключом** подписчика.
>
> Ключ `PHOBOS_KEY` выдаётся по подписке [Boosty](https://boosty.to/andrey27). Закрытый канал:
> ```bash
> PHOBOS_KEY=ваш_ключ CHANNEL=dev bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
> ```
> Переключение после установки: `phobos-update stable` (без ключа) · `PHOBOS_KEY=... phobos-update beta|dev` · статус: `phobos-update --check`.
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
### Вторичный сервер (для failover / балансировки)
```bash
MAIN_SERVER=<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://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/secondary-setup.sh)
```
Затем в панели → **Серверы** → добавь его в список.
Сервер сам зарегистрируется в панели, клиенты досылаются на него сразу. Если сервер за NAT — добавь `SERVER_IP=<внешний_ip>`.
### Каскад (вход в РФ → выход за рубежом)
На входном VPS (чистый Debian/Ubuntu, Phobos не нужен):
```bash
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/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.4/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/`.
## Клиенты для телефона
В таблице **Клиенты** у каждого клиента есть кнопки:
@@ -113,7 +152,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
**Приложения:**
- **Android (PhobosWG):** автор пока не публикует APK официально. Положи `.apk` на сервер в `/opt/Phobos/www/app/PhobosWG.apk` — панель сама покажет кнопку «Скачать приложение PhobosWG» на странице Android-клиента (раздаётся по `http://<сервер>/app/PhobosWG.apk`).
- **Android (PhobosWG):** кнопка «Скачать приложение PhobosWG (APK)» на странице Android-клиента. Можно положить свой `.apk` в `/opt/Phobos/www/app/PhobosWG.apk` — тогда панель раздаёт его.
- **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента.
---
@@ -127,7 +166,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main
**Из консоли:**
```bash
phobos-update # обновить до последней версии
phobos-update # обновить до последней версии (stable)
phobos-update beta|dev # каналы для подписчиков (GH_TOKEN), сейчас = та же 1.4.0
phobos-update v1.1.0 # обновить до конкретной версии (git tag)
phobos-update --rollback # откатить на предыдущую версию
phobos-update --version # показать установленную и доступную версию
@@ -152,6 +192,9 @@ phobos-update --list # список бэкапов
| **Смена сервера применяется не сразу** | роутер тянет конфиг по 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`.

View File

@@ -1 +1 @@
1.2.6
1.4.8

2
android-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
build/
libs/x/

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.pca.phobos.vpn"
android:versionCode="144"
android:versionName="1.4.4">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<application
android:label="PCA Phobos VPN"
android:icon="@drawable/ic_launcher"
android:theme="@style/AppTheme"
android:extractNativeLibs="true"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- panel "phobos://" links open straight into the app -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="phobos" />
</intent-filter>
</activity>
<!-- from com.wireguard.android:tunnel (AAR manifest, merged by hand: no Gradle) -->
<service
android:name="com.wireguard.android.backend.GoBackend$VpnService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application>
</manifest>

20
android-client/README.md Normal file
View 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
```

File diff suppressed because one or more lines are too long

34
android-client/build.sh Executable file
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,580 @@
package com.pca.phobos.vpn;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ClipboardManager;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.net.VpnService;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import com.wireguard.android.backend.GoBackend;
import com.wireguard.android.backend.Statistics;
import com.wireguard.android.backend.Tunnel;
import com.wireguard.config.Config;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* PCA Phobos VPN — client: imports a phobos:// link from the panel, runs the
* bundled wg-obfuscator + userspace WireGuard, split tunnelling by apps.
* The app itself is always outside the tunnel so the obfuscator reaches the server directly.
*/
public class MainActivity extends Activity {
static final int LPORT = 13255;
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
static GoBackend backend;
static Process obfuscator, relay;
static final int TCP_LPORT = 13256;
static volatile String transport = ""; // "UDP" | "TCP" | "Облако" while up
static WsRelay wsRelay;
static final Tunnel TUNNEL = new Tunnel() {
public String getName() { return "phobos"; }
public void onStateChange(Tunnel.State s) { }
};
private final ExecutorService bg = Executors.newSingleThreadExecutor();
private final Handler ui = new Handler(Looper.getMainLooper());
private SharedPreferences prefs;
private boolean dark;
private TextView status, traffic;
private Button power;
private long lastRx, lastTx, lastT;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
if (backend == null) backend = new GoBackend(getApplicationContext());
handleIntent(getIntent());
render();
}
@Override
protected void onNewIntent(Intent i) {
super.onNewIntent(i);
handleIntent(i);
render();
}
private void handleIntent(Intent i) {
if (i != null && i.getData() != null && "phobos".equals(i.getData().getScheme())) {
importLink(i.getDataString());
}
}
// ── colors / widgets ──────────────────────────────────────────────────
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
private GradientDrawable round(int color, int r) {
GradientDrawable g = new GradientDrawable();
g.setColor(color);
g.setCornerRadius(dp(r));
return g;
}
private Button button(String label, int color, View.OnClickListener l) {
Button b = new Button(this);
b.setText(label);
b.setAllCaps(false);
b.setTextColor(Color.WHITE);
b.setBackground(round(color, 10));
b.setOnClickListener(l);
return b;
}
private TextView tv(String s, int color, int size, boolean bold) {
TextView t = new TextView(this);
t.setText(s);
t.setTextColor(color);
t.setTextSize(size);
if (bold) t.setTypeface(Typeface.DEFAULT_BOLD);
return t;
}
private LinearLayout.LayoutParams mw(int top) {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
lp.topMargin = dp(top);
return lp;
}
// ── profile ───────────────────────────────────────────────────────────
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
private void importLink(String link) {
try {
String s = link.trim();
String name = "Phobos";
int hash = s.indexOf('#');
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
s = s.replaceFirst("^phobos://", "").trim();
String conf = new String(Base64.decode(s, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING), StandardCharsets.UTF_8);
if (!conf.contains("[Interface]") || !conf.contains("[instance]")) throw new IllegalArgumentException("not a Phobos config");
Config.parse(new BufferedReader(new StringReader(wgPart(conf, MODE_ALL, "")))); // validate
prefs.edit().putString("conf", conf).putString("name", name).apply();
toast("Профиль «" + name + "» импортирован");
} catch (Exception e) {
toast("Не удалось импортировать: " + e.getMessage());
}
}
/** Split payload into sections: header -> lines (drop "= none" placeholders). */
private static List<String> section(String conf, String header) {
List<String> out = new ArrayList<>();
boolean in = false;
for (String raw : conf.split("\n")) {
String ln = raw.trim();
if (ln.startsWith("[") && ln.endsWith("]")) { in = ln.equalsIgnoreCase(header); continue; }
if (!in || ln.isEmpty() || !ln.contains("=")) continue;
if (ln.substring(ln.indexOf('=') + 1).trim().equalsIgnoreCase("none")) continue;
out.add(ln);
}
return out;
}
private static String key(String line) { return line.substring(0, line.indexOf('=')).trim().toLowerCase(); }
private static String val(String line) { return line.substring(line.indexOf('=') + 1).trim(); }
String wgPart(String conf, String mode, String apps) {
StringBuilder sb = new StringBuilder("[Interface]\n");
for (String ln : section(conf, "[Interface]")) {
String k = key(ln);
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
sb.append(ln).append('\n');
}
List<String> sel = new ArrayList<>();
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
sel.remove(getPackageNameSafe());
sb.append("IncludedApplications = ").append(String.join(", ", sel)).append('\n');
} else {
Set<String> ex = new LinkedHashSet<>();
ex.add(getPackageNameSafe()); // obfuscator must bypass the tunnel
if (MODE_EXCEPT.equals(mode)) ex.addAll(sel);
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
}
sb.append("\n[Peer]\n");
String ruBypass = prefs != null && 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();
}
private String ruCache;
/** Internet minus Russian networks (assets/ru_bypass.txt, generated from ipdeny). */
private String ruBypassAllowedIps() {
if (ruCache != null) return ruCache;
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(
getAssets().open("ru_bypass.txt"), StandardCharsets.UTF_8))) {
for (String ln; (ln = r.readLine()) != null; ) {
if (!ln.startsWith("#") && ln.contains("/")) { ruCache = ln.trim(); break; }
}
} catch (Exception e) { log("ru_bypass: " + e); }
return ruCache;
}
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, boolean viaRelay) {
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 (viaRelay && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
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 "";
}
private String target(String conf) {
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
return "?";
}
// ── connect / disconnect ──────────────────────────────────────────────
private boolean isUp() {
try { return backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
}
private void toggle() {
if (isUp()) { bg.execute(this::down); return; }
if (prefs.getString("conf", "").isEmpty()) { toast("Сначала импортируйте ссылку phobos:// из панели"); return; }
Intent prep = VpnService.prepare(this);
if (prep != null) startActivityForResult(prep, 7);
else bg.execute(this::up);
}
@Override
protected void onActivityResult(int req, int res, Intent data) {
if (req == 7 && res == RESULT_OK) bg.execute(this::up);
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
}
/** 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();
}
if (viaTcp) {
String host = target(conf).replaceAll(":\\d+$", "");
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, viaTcp || viaWs).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() {
try {
String conf = prefs.getString("conf", "");
String mode = prefs.getString("transport", "auto");
boolean tcpAvail = !tcpPort(conf).isEmpty();
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
startTransport(conf, "auto".equals(mode) ? "udp" : mode);
Config c = Config.parse(new BufferedReader(new StringReader(
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
backend.setState(TUNNEL, Tunnel.State.UP, c);
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", transport " + mode);
post("Подключено (" + transport + ")");
if ("auto".equals(mode) && tcpAvail) watchUdp(conf);
} catch (Throwable e) {
stopObfuscator();
log("up failed: " + e);
post("Ошибка: " + e.getMessage());
}
}
/** 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 {
Thread.sleep(15000);
if (!isUp() || !"UDP".equals(transport)) return;
if (backend.getStatistics(TUNNEL).totalRx() > 0) 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() {
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
stopObfuscator();
transport = "";
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; }
}
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
// ── UI ────────────────────────────────────────────────────────────────
private void render() {
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(bg());
LinearLayout h = new LinearLayout(this);
h.setBackgroundColor(0xFF1E1B4B);
h.setPadding(dp(16), dp(14), dp(16), dp(14));
h.addView(tv("🛡 PCA Phobos VPN", 0xFFC7D2FE, 18, true));
root.addView(h);
ScrollView sv = new ScrollView(this);
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(16), dp(16), dp(16), dp(24));
sv.addView(box);
String conf = prefs.getString("conf", "");
LinearLayout prof = new LinearLayout(this);
prof.setOrientation(LinearLayout.VERTICAL);
prof.setBackground(round(card(), 14));
prof.setPadding(dp(16), dp(14), dp(16), dp(14));
if (conf.isEmpty()) {
prof.addView(tv("Профиль не импортирован", text(), 16, true));
prof.addView(tv("В панели PCA Phobos откройте клиента → 📱 Android → нажмите ссылку phobos:// "
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
} else {
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
prof.addView(tv("Сервер: " + target(conf), muted(), 14, false));
}
box.addView(prof, mw(0));
power = new Button(this);
power.setAllCaps(false);
power.setTextColor(Color.WHITE);
power.setTextSize(20);
power.setTypeface(Typeface.DEFAULT_BOLD);
power.setOnClickListener(v -> toggle());
LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams(dp(180), dp(180));
plp.gravity = Gravity.CENTER_HORIZONTAL;
plp.topMargin = dp(24);
box.addView(power, plp);
status = tv("", text(), 16, true);
status.setGravity(Gravity.CENTER);
box.addView(status, mw(12));
traffic = tv("", muted(), 14, false);
traffic.setGravity(Gravity.CENTER);
box.addView(traffic, mw(2));
// split tunnelling
LinearLayout split = new LinearLayout(this);
split.setOrientation(LinearLayout.VERTICAL);
split.setBackground(round(card(), 14));
split.setPadding(dp(16), dp(14), dp(16), dp(14));
split.addView(tv("Раздельный трафик", text(), 16, true));
RadioGroup rg = new RadioGroup(this);
String mode = prefs.getString("mode", MODE_ALL);
String[][] modes = {{MODE_ALL, "Весь трафик через VPN"}, {MODE_ONLY, "Только выбранные приложения"},
{MODE_EXCEPT, "Все, кроме выбранных приложений"}};
for (String[] m : modes) {
RadioButton r = new RadioButton(this);
r.setText(m[1]);
r.setTextColor(text());
r.setId(View.generateViewId());
r.setChecked(m[0].equals(mode));
r.setOnClickListener(v -> { prefs.edit().putString("mode", m[0]).apply(); applyIfUp(); refresh(); });
rg.addView(r);
}
split.addView(rg);
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 n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
muted(), 12, false), mw(6));
box.addView(split, mw(20));
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
box.addView(button("Проверить скорость", 0xFF0EA5E9, v -> speedtest()), mw(8));
box.addView(button("Журнал", 0xFF475569, v -> showLog()), mw(8));
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
refresh();
ui.removeCallbacksAndMessages(null);
ui.postDelayed(new Runnable() { public void run() { refresh(); ui.postDelayed(this, 2000); } }, 2000);
}
private void refresh() {
if (power == null) return;
boolean up = isUp();
power.setText(up ? "Отключить" : "Подключить");
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
status.setText(up ? "🟢 VPN включён" + (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();
}
/** 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();
}
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(); }
}

View 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
View File

@@ -0,0 +1 @@
build/

View 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
View 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
View 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

View 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>

View 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>

View 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>

View 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();
}
}

2764
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,41 @@
#!/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"
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
LOCKFILE="/tmp/phobos-health.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 "$PHOBOS_DIR/panel_scheme" 2>/dev/null)}"
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$PHOBOS_DIR/panel_port" 2>/dev/null)}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
PRIMARY_PROBE_PATH="/api/obf-health"
# 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 +51,35 @@ fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
# Create state dir
mkdir -p "$STATE"
# ── Detect WG interface ──
detect_wg_interface() {
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
}
# ── 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-)"
SERVER_COUNT=$idx
idx=$((idx + 1))
done
@@ -74,22 +89,45 @@ 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")
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим туннелем.
detect_wg_interface || {
log "ERROR: own WG interface not found, skip"
exit 0
}
# ── Get port by index from comma-separated list ──
# ── 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 +138,78 @@ 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
i=1
while [ "$i" -le "$SERVER_COUNT" ]; do
eval "wk=\$SERVER_${i}_WGKEY"
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' "$PHOBOS_DIR/wg-obfuscator.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 +217,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
@@ -116,7 +227,10 @@ switch_endpoint() {
log "SWITCH → server $server_idx ($host:$port)"
# 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 +238,26 @@ switch_endpoint() {
fi
fi
# Restart obfuscator
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
# 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
# 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"
}
# ── 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"
@@ -152,96 +267,203 @@ try_next_port() {
# ── Try next server ──
try_next_server() {
next=$((CURRENT_SERVER + 1))
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" -gt "$SERVER_COUNT" ] && next=1
[ "$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 ] && 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
log "PRIMARY ($ok_url http=200) alive, switching back"
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
# 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
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")
# Tunnel healthy = fresh handshake
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"
if [ "$FAIL_COUNT" -gt 0 ]; then
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
echo "0" > "$STATE/fail_count"
fi
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" &
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
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 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
exit 0
fi
# Full failover
log "ACTION: failover (handshake ${AGE}s)"
if ! try_next_server; then
# Only one server, cycle ports
try_next_port
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server
if [ "$FAIL_COUNT" -eq 2 ]; then
if try_next_port; then
exit 0
fi
# only one port → fall through to server failover
log "single port, escalate to failover"
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

107
docs/PORTS.md Normal file
View File

@@ -0,0 +1,107 @@
# PCA Phobos ports and firewall checklist
This page is the short truth table for normal Phobos work. Use it before
opening a ticket about "router does not switch", "secondary is online but sync
fails", or "panel is too exposed".
## Primary server
| Port | Protocol | Who connects | Required | Why |
| --- | --- | --- | --- | --- |
| `PANEL_PORT` | TCP | admin browser, secondary setup/register, router pull fallback | yes | Web panel and `/api/*`. Real value: `cat /opt/phobos-panel/.port`. |
| `80` | TCP | routers during install, Android APK download | recommended | nginx serves `/init/`, `/packages/`, `/app/`. |
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. Real value: `grep OBFUSCATOR_PORTS /opt/Phobos/server/server.env`. |
| `51820` | UDP | plain WireGuard clients only | no by default | Usually blocked to force obfuscation. Open only if installed with `ALLOW_PLAIN_WG=1` for iOS/plain WG. |
| `22` | TCP | admin only | optional | SSH maintenance. Do not expose broadly. |
`PANEL_PORT` is random on clean install unless passed as env:
```bash
cat /opt/phobos-panel/.port
```
## Secondary server
| Port | Protocol | Who connects | Required | Why |
| --- | --- | --- | --- | --- |
| `8444` | TCP | primary panel, routers via tunnel/public path | yes | Mini-API: health, peer sync, router config cache. |
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. |
| `51820` | UDP | nobody public | no | WireGuard listens locally behind obfuscator; public access is blocked. |
| `22` | TCP | admin during install | optional | SSH maintenance. |
If secondary is "Online" but `Info`, `Sync`, or client assignment fails, check
`8444/tcp` first and confirm the API key matches the main panel key.
## Router outbound access
Routers need outbound access to:
- every server UDP obfuscator port, default `2083,5443,993`;
- primary `PANEL_PORT/tcp` for initial install and public pull fallback;
- primary `80/tcp` if using the nginx install fallback;
- tunnel management paths after VPN is up:
- `http://10.25.0.1:8444` for secondary mini-API;
- `http(s)://10.25.0.1:<PANEL_PORT>` for primary panel API.
Current router packages write these files on the router:
```bash
/opt/etc/Phobos/panel_url
/opt/etc/Phobos/panel_urls
/opt/etc/Phobos/panel_port
/opt/etc/Phobos/panel_scheme
```
If a router does not switch after changing its server in the panel, check:
```bash
cat /opt/etc/Phobos/panel_urls
cat /opt/etc/Phobos/failover.conf
tail -n 80 /opt/etc/Phobos/health.log
/opt/etc/Phobos/phobos-pull.sh
```
If `panel_urls` contains an old IP or old panel port, create a fresh install
command in the panel and reinstall/update that router package.
## HTTPS panel
Native HTTPS is supported by the primary installer:
```bash
PANEL_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem \
PANEL_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem \
bash <(curl -fsSL https://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.

160
docs/WARP_DEV_NOTES.md Normal file
View 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
View File

@@ -0,0 +1,139 @@
# Канал «Облако» — VPN через облако своего Keenetic (PCA Phobos 1.4.4)
![Схема канала «Облако»](img/cloud/skhema.png)
## Что это и зачем
Обычно телефон ходит к серверу 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 включён · Облако».
![Главный экран, канал «Облако»](img/cloud/android-1-glavnyj.png)
![Окно адреса роутера](img/cloud/android-2-adres.png)
### 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).
![Speedtest через канал «Облако»](img/cloud/android-3-skorost.png)
Адрес можно не вводить руками: если в ссылке клиента есть строка `# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
docs/img/android-vpn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
docs/img/clients-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
docs/img/clients.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
docs/img/cloud/skhema.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

37
docs/img/cloud/skhema.svg Normal file
View 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/ios-app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/img/mobile-clients.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
docs/img/mobile-servers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
docs/img/servers-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
docs/img/servers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
docs/img/sessions-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
docs/img/sessions.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
docs/img/settings-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
docs/img/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@@ -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,6 +13,7 @@
#
# 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
@@ -22,41 +23,78 @@
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}"
# Каналы: stable (открытый, публичный repo) · beta (тест) · dev (разработка).
# beta и dev живут в ПРИВАТНОМ repo PCA_Phobos-dev — доступ по токену подписчика
# (GH_TOKEN, read-only, выдаётся по подписке Boosty). stable — без токена.
GH_TOKEN="${GH_TOKEN:-}"
PUB_REPO="andrey271192/PCA_Phobos"
DEV_REPO="andrey271192/PCA_Phobos-dev"
# Уже есть 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 'Подсеть 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=False)
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/andrey271192/PCA_Phobos}"
CHANNEL="${CHANNEL:-stable}"
case "$CHANNEL" in
beta) REPO="$DEV_REPO"; PCA_BRANCH="${PCA_BRANCH:-beta}";;
dev) REPO="$DEV_REPO"; PCA_BRANCH="${PCA_BRANCH:-dev}";;
stable|main|"") REPO="$PUB_REPO"; PCA_BRANCH="${PCA_BRANCH:-main}";;
*) REPO="$PUB_REPO"; PCA_BRANCH="${PCA_BRANCH:-$CHANNEL}";;
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
*) PCA_BRANCH="${PCA_BRANCH:-$CHANNEL}";;
esac
if [ "$REPO" = "$DEV_REPO" ] && [ -z "$GH_TOKEN" ]; then
echo "Канал '$CHANNEL' закрыт — это $([ "$CHANNEL" = dev ] && echo 'разработка (нестабильно)' || echo 'бета (тест)'), приватный репозиторий."
echo "Нужен read-only токен подписчика:"
echo " GH_TOKEN=ваш_токен CHANNEL=$CHANNEL bash <(curl -fsSL https://raw.githubusercontent.com/$PUB_REPO/main/install.sh)"
echo "Токен выдаётся по подписке: https://boosty.to/andrey27"
echo "Стабильная версия ставится без токена (CHANNEL=stable, по умолчанию)."
exit 1
fi
[ "$REPO" = "$DEV_REPO" ] && echo "Токен принят — закрытый канал '$CHANNEL' (приватный repo)."
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/${REPO}/${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}')
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
@@ -73,13 +111,26 @@ 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
@@ -87,23 +138,23 @@ if [ ! -x /usr/local/bin/wg-obfuscator ]; then
git pull origin main -q
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 2>/dev/null || true
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 "aarch64:linux-arm64" "mipsel:linux-mipsel-mips32" "mips:linux-mips-mips32" "armv7:linux-armv7-hf"; do
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" | head -1) &&
_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-${ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
[ -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' ' ')"
@@ -123,7 +174,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
@@ -183,6 +234,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
@@ -192,23 +245,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..."
@@ -230,6 +304,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
@@ -238,15 +317,19 @@ 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..."
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
@@ -275,17 +358,15 @@ if [ -f "$PHOBOS_DIR/server/phobos-router-watchdog.py" ]; then
fi
sleep 2
# persist channel + subscriber token so phobos-update can reach private channels
# persist channel so phobos-update follows it
mkdir -p "$PANEL_DIR"
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
if [ "$REPO" = "$DEV_REPO" ] && [ -n "$GH_TOKEN" ]; then
( umask 077; printf %s "$GH_TOKEN" > "$PANEL_DIR/.gh_token" )
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"

3
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
build/
*.xcuserstate
xcuserdata/

View File

@@ -0,0 +1 @@
{"images":[{"filename":"icon-1024.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1 @@
{"info":{"author":"xcode","version":1}}

129
ios/App/ContentView.swift Normal file
View File

@@ -0,0 +1,129 @@
import NetworkExtension
import SwiftUI
struct ContentView: View {
@EnvironmentObject var vpn: VPNController
@State private var pasteText = ""
@State private var showImport = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 18) {
profileCard
if vpn.servers.count > 1 { serversCard }
powerButton
statusLine
optionsCard
Button { showImport = true } label: {
Label("Импорт ссылки phobos://", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity).padding(.vertical, 12)
}
.buttonStyle(.borderedProminent).tint(.indigo)
Link(destination: URL(string: "https://speed.cloudflare.com/")!) {
Label("Проверить скорость", systemImage: "speedometer").frame(maxWidth: .infinity).padding(.vertical, 12)
}
.buttonStyle(.bordered)
if !vpn.message.isEmpty {
Text(vpn.message).font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
}
.padding()
}
.navigationTitle("PCA Phobos VPN")
.alert("Ссылка из панели", isPresented: $showImport) {
TextField("phobos://…", text: $pasteText)
Button("Импорт") { vpn.importLink(pasteText); pasteText = "" }
Button("Из буфера") { vpn.importLink(UIPasteboard.general.string ?? "") }
Button("Отмена", role: .cancel) {}
} message: {
Text("В панели: клиент → 📱 → ссылка phobos://. Можно просто открыть ссылку на iPhone — профиль импортируется сам.")
}
}
}
private var profileCard: some View {
VStack(alignment: .leading, spacing: 4) {
if let p = vpn.profile {
Text(vpn.name).font(.title3.bold())
Text("Сервер: \(p.target ?? "?")").font(.subheadline).foregroundStyle(.secondary)
if let tcp = p.tcpPort { Text("Резервный TCP: порт \(tcp)").font(.caption).foregroundStyle(.secondary) }
} else {
Text("Профиль не импортирован").font(.headline)
Text("Откройте на iPhone ссылку phobos:// из панели PCA Phobos или вставьте её кнопкой ниже.")
.font(.subheadline).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
private var serversCard: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Серверы").font(.subheadline.bold()).padding(.bottom, 6)
ForEach(vpn.servers, id: \.self) { s in
let n = s["name"] ?? ""
HStack {
Image(systemName: n == vpn.name ? "checkmark.circle.fill" : "circle")
.foregroundStyle(n == vpn.name ? .green : .secondary)
Text(n)
Spacer()
if n != vpn.name {
Button(role: .destructive) { vpn.remove(n) } label: { Image(systemName: "trash") }
.buttonStyle(.borderless)
}
}
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture { vpn.select(n) }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
private var powerButton: some View {
Button(action: vpn.toggle) {
Text(vpn.isOn ? "Отключить" : "Подключить")
.font(.title2.bold()).foregroundStyle(.white)
.frame(width: 180, height: 180)
.background(Circle().fill(vpn.isOn ? Color.green : Color.indigo))
}
.padding(.top, 8)
}
private var statusLine: some View {
let text: String = {
switch vpn.status {
case .connected: return "🟢 VPN включён" + (vpn.transport.isEmpty ? "" : " · \(vpn.transport)")
case .connecting, .reasserting: return "🟡 Подключение…"
case .disconnecting: return "Отключение…"
default: return "⚪ VPN выключен"
}
}()
return Text(text).font(.headline)
}
private var optionsCard: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle("Российские сайты — напрямую, без VPN", isOn: $vpn.ruDirect)
VStack(alignment: .leading, spacing: 6) {
Text("Канал до сервера").font(.subheadline.bold())
Picker("Канал", selection: $vpn.transportMode) {
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp"); Text("Облако").tag("cloud")
}
.pickerStyle(.segmented)
if vpn.transportMode == "cloud" {
TextField("wss://phobos.имя.keenetic.pro/", text: $vpn.wsURL)
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
.textFieldStyle(.roundedBorder).onSubmit { vpn.wsURL = vpn.wsURL.trimmingCharacters(in: .whitespaces) }
Text("Облако: телефон → облако KeenDNS → ваш Keenetic → сервер. Работает, когда до сервера напрямую не достучаться.")
.font(.caption).foregroundStyle(.secondary)
}
Text("Авто: если UDP режут, через 15 секунд переходит на TCP. Изменения применяются сразу.")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}

45
ios/App/Info.plist Normal file
View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>PCA Phobos</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.bobyrev.pcaphobos.link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>phobos</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
</array>
</dict>
</plist>

131
ios/App/PCAPhobosApp.swift Normal file
View File

@@ -0,0 +1,131 @@
import NetworkExtension
import SwiftUI
@main
struct PCAPhobosApp: App {
@StateObject private var vpn = VPNController()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(vpn)
.onOpenURL { vpn.importLink($0.absoluteString) } // phobos:// from the panel
}
}
}
/// Owns the NETunnelProviderManager: profile, options, connect/disconnect, live status.
@MainActor
final class VPNController: ObservableObject {
@Published var name = UserDefaults.standard.string(forKey: "name") ?? ""
@Published var conf = UserDefaults.standard.string(forKey: "conf") ?? ""
@Published var ruDirect = UserDefaults.standard.object(forKey: "ruDirect") as? Bool ?? true { didSet { saveOptions() } }
@Published var transportMode = UserDefaults.standard.string(forKey: "transport") ?? "auto" { didSet { saveOptions() } }
/// Channel «Облако»: wss:// of the user's Keenetic (empty -> "# pca-ws" from the link).
@Published var wsURL = UserDefaults.standard.string(forKey: "wsURL") ?? "" { didSet { saveOptions() } }
/// Imported servers: [["name": …, "conf": …]]; name/conf above mirror the selected one.
@Published var servers = UserDefaults.standard.array(forKey: "servers") as? [[String: String]] ?? []
@Published var status: NEVPNStatus = .invalid
@Published var transport = ""
@Published var message = ""
private var manager: NETunnelProviderManager?
private var timer: Timer?
init() {
if servers.isEmpty && !conf.isEmpty { servers = [["name": name, "conf": conf]] } // pre-list single profile
Task { await load() }
NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in self?.status = self?.manager?.connection.status ?? .invalid }
}
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
Task { @MainActor in self?.pollTransport() }
}
}
var profile: PhobosProfile? { conf.isEmpty ? nil : PhobosProfile(conf) }
var isOn: Bool { status == .connected || status == .connecting || status == .reasserting }
func load() async {
let all = (try? await NETunnelProviderManager.loadAllFromPreferences()) ?? []
manager = all.first
status = manager?.connection.status ?? .invalid
}
func importLink(_ link: String) {
guard let parsed = PhobosProfile.fromLink(link) else { message = "Это не ссылка phobos:// из панели"; return }
servers.removeAll { $0["name"] == parsed.name } // same name = updated link
servers.append(["name": parsed.name, "conf": parsed.profile.raw])
select(parsed.name)
message = "Профиль «\(name)» импортирован"
}
func select(_ server: String) {
guard let s = servers.first(where: { $0["name"] == server }) else { return }
name = server
conf = s["conf"] ?? ""
persist()
Task { await save(reconnect: isOn) }
}
func remove(_ server: String) {
servers.removeAll { $0["name"] == server }
if server == name {
if let next = servers.first?["name"] { select(next); return }
if isOn { manager?.connection.stopVPNTunnel() }
name = ""; conf = ""
}
persist()
}
private func persist() {
UserDefaults.standard.set(servers, forKey: "servers")
UserDefaults.standard.set(conf, forKey: "conf")
UserDefaults.standard.set(name, forKey: "name")
}
private func saveOptions() {
UserDefaults.standard.set(ruDirect, forKey: "ruDirect")
UserDefaults.standard.set(transportMode, forKey: "transport")
UserDefaults.standard.set(wsURL, forKey: "wsURL")
Task { await save(reconnect: isOn) }
}
/// Write the VPN configuration to iOS settings (asks the user once).
func save(reconnect: Bool = false) async {
guard let profile else { return }
let m = manager ?? NETunnelProviderManager()
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = "com.bobyrev.pcaphobos.tunnel"
proto.serverAddress = profile.targetHost ?? "Phobos"
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode, "wsURL": wsURL]
m.protocolConfiguration = proto
m.localizedDescription = "PCA Phobos"
m.isEnabled = true
do {
try await m.saveToPreferences()
try await m.loadFromPreferences()
manager = m
if reconnect { m.connection.stopVPNTunnel(); try await Task.sleep(nanoseconds: 1_000_000_000); try m.connection.startVPNTunnel() }
} catch {
message = "Не удалось сохранить VPN: \(error.localizedDescription)"
}
}
func toggle() {
Task {
if isOn { manager?.connection.stopVPNTunnel(); return }
guard profile != nil else { message = "Сначала импортируйте ссылку phobos:// из панели"; return }
await save()
do { try manager?.connection.startVPNTunnel() } catch { message = "Ошибка: \(error.localizedDescription)" }
}
}
private func pollTransport() {
guard status == .connected, let session = manager?.connection as? NETunnelProviderSession else { transport = ""; return }
try? session.sendProviderMessage(Data()) { [weak self] data in
guard let data, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
Task { @MainActor in self?.transport = obj["transport"] as? String ?? "" }
}
}
}

View File

@@ -0,0 +1,576 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
008DA398D56DFBDB16D97CE1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF14952A4263FF0E004A3F04 /* ContentView.swift */; };
08C4842C3185E04B2154C971 /* WireGuardKit in Frameworks */ = {isa = PBXBuildFile; productRef = FA829997FAB330F6EFA104C9 /* WireGuardKit */; };
11F2A6A8D281AB36B45D4603 /* TunnelConfiguration+WgQuickConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */; };
1C05D29ACA95548374AD6FD9 /* logging.c in Sources */ = {isa = PBXBuildFile; fileRef = 9FC04650634F03E6DB09AB6B /* logging.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
38870650A7C2B1F0927A1230 /* wg-obfuscator.c in Sources */ = {isa = PBXBuildFile; fileRef = 1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */; };
56F35C68C6A387E499703CFF /* masking.c in Sources */ = {isa = PBXBuildFile; fileRef = CCE5C98CD905BDE16A8D83D4 /* masking.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
599057D9048B5FF1EA0E6000 /* WsLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC83AB79BE0E361F4EE90875 /* WsLink.swift */; };
5F5D376513C0B27C45A3E7B6 /* ru_bypass.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2572D9A6F822B038D0652619 /* ru_bypass.txt */; };
78B74F81E0042D9AD94B7A6B /* config.c in Sources */ = {isa = PBXBuildFile; fileRef = 0E5C1673464AAC3EF81DD2FE /* config.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */; };
99F5DB52833E3B5505EEA727 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E36BA501FDCB2034F09415A /* PacketTunnelProvider.swift */; };
B3BB8BE3409D63FEB59B5449 /* obfuscation.c in Sources */ = {isa = PBXBuildFile; fileRef = EF9115CA0D5EC52C8679A12A /* obfuscation.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
C3C41D3C419E8D17F20A38B5 /* PCAPhobosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */; };
C6116E8B9CDFEE363BCBD2D6 /* PhobosTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A81F96BF4131FDBB816623BE /* PhobosTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
CBC338B91B5FCE1E207B36AC /* PhobosProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */; };
D8CCC1E6CC8164EDD4E34D25 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6359B8954E69147DA50EDB46 /* Assets.xcassets */; };
DF6D62C526E908CA5CDDFCAE /* pcarelay.c in Sources */ = {isa = PBXBuildFile; fileRef = 211950F8776D3D14D7B9C49A /* pcarelay.c */; };
E88105F9325A93041D617213 /* masking_stun.c in Sources */ = {isa = PBXBuildFile; fileRef = 05DD44245829A52D32B42AF8 /* masking_stun.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
B9875EF5DEB6362919B43F22 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E59D1112D2B128DF6FE7A1E9 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0FD066BC2C51A1E092914FE8;
remoteInfo = PhobosTunnel;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9FC25420ED5EF973CE140584 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
C6116E8B9CDFEE363BCBD2D6 /* PhobosTunnel.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
027878C5E0654DB27FAE88A3 /* PCAPhobos.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = PCAPhobos.app; sourceTree = BUILT_PRODUCTS_DIR; };
05DD44245829A52D32B42AF8 /* masking_stun.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = masking_stun.c; sourceTree = "<group>"; };
0E5C1673464AAC3EF81DD2FE /* config.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = config.c; sourceTree = "<group>"; };
1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = "wg-obfuscator.c"; sourceTree = "<group>"; };
211950F8776D3D14D7B9C49A /* pcarelay.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = pcarelay.c; sourceTree = "<group>"; };
2572D9A6F822B038D0652619 /* ru_bypass.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = ru_bypass.txt; sourceTree = "<group>"; };
2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TunnelConfiguration+WgQuickConfig.swift"; sourceTree = "<group>"; };
6359B8954E69147DA50EDB46 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
722DE8EFC35A88BC68637FB4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+ArrayConversion.swift"; sourceTree = "<group>"; };
7E66B3AD4BB22D2AB5676E10 /* PCAPhobos.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PCAPhobos.entitlements; sourceTree = "<group>"; };
8E36BA501FDCB2034F09415A /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhobosProfile.swift; sourceTree = "<group>"; };
9FC04650634F03E6DB09AB6B /* logging.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = logging.c; sourceTree = "<group>"; };
A81F96BF4131FDBB816623BE /* PhobosTunnel.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = PhobosTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; };
B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PCAPhobosApp.swift; sourceTree = "<group>"; };
CA6480536DA633DB94B6930F /* WireGuardKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WireGuardKit; path = WireGuardKit; sourceTree = SOURCE_ROOT; };
CCE5C98CD905BDE16A8D83D4 /* masking.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = masking.c; sourceTree = "<group>"; };
EC83AB79BE0E361F4EE90875 /* WsLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WsLink.swift; sourceTree = "<group>"; };
EF9115CA0D5EC52C8679A12A /* obfuscation.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = obfuscation.c; sourceTree = "<group>"; };
FF14952A4263FF0E004A3F04 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
D366EA9C7C0EC88CAE5EA47E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
08C4842C3185E04B2154C971 /* WireGuardKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1EBBB94A186F7080FE3609C8 /* Products */ = {
isa = PBXGroup;
children = (
027878C5E0654DB27FAE88A3 /* PCAPhobos.app */,
A81F96BF4131FDBB816623BE /* PhobosTunnel.appex */,
);
name = Products;
sourceTree = "<group>";
};
2500D202DC9E96E7BBCE29B1 /* Tunnel */ = {
isa = PBXGroup;
children = (
8E36BA501FDCB2034F09415A /* PacketTunnelProvider.swift */,
211950F8776D3D14D7B9C49A /* pcarelay.c */,
2572D9A6F822B038D0652619 /* ru_bypass.txt */,
7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */,
2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */,
EC83AB79BE0E361F4EE90875 /* WsLink.swift */,
);
path = Tunnel;
sourceTree = "<group>";
};
527268DF962A8B7EC120C536 /* obfuscator */ = {
isa = PBXGroup;
children = (
0E5C1673464AAC3EF81DD2FE /* config.c */,
9FC04650634F03E6DB09AB6B /* logging.c */,
05DD44245829A52D32B42AF8 /* masking_stun.c */,
CCE5C98CD905BDE16A8D83D4 /* masking.c */,
EF9115CA0D5EC52C8679A12A /* obfuscation.c */,
1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */,
);
name = obfuscator;
path = Tunnel/obfuscator;
sourceTree = "<group>";
};
D4CB33FA621F53C8F82ED115 /* Packages */ = {
isa = PBXGroup;
children = (
CA6480536DA633DB94B6930F /* WireGuardKit */,
);
name = Packages;
sourceTree = "<group>";
};
DD1FD95C0B2DE48623872799 /* App */ = {
isa = PBXGroup;
children = (
6359B8954E69147DA50EDB46 /* Assets.xcassets */,
FF14952A4263FF0E004A3F04 /* ContentView.swift */,
722DE8EFC35A88BC68637FB4 /* Info.plist */,
7E66B3AD4BB22D2AB5676E10 /* PCAPhobos.entitlements */,
B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */,
);
path = App;
sourceTree = "<group>";
};
E31449B836D353F74991D905 = {
isa = PBXGroup;
children = (
DD1FD95C0B2DE48623872799 /* App */,
527268DF962A8B7EC120C536 /* obfuscator */,
D4CB33FA621F53C8F82ED115 /* Packages */,
F6FC482145D1EFAD5A15F63B /* Shared */,
2500D202DC9E96E7BBCE29B1 /* Tunnel */,
1EBBB94A186F7080FE3609C8 /* Products */,
);
sourceTree = "<group>";
};
F6FC482145D1EFAD5A15F63B /* Shared */ = {
isa = PBXGroup;
children = (
98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */,
);
path = Shared;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
0FD066BC2C51A1E092914FE8 /* PhobosTunnel */ = {
isa = PBXNativeTarget;
buildConfigurationList = 329F6A5A13B95B1525F94841 /* Build configuration list for PBXNativeTarget "PhobosTunnel" */;
buildPhases = (
173BA4C39EE769A34EBB19CB /* Sources */,
A5352C1E8C48FFB2CCEC38F3 /* Resources */,
D366EA9C7C0EC88CAE5EA47E /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = PhobosTunnel;
packageProductDependencies = (
FA829997FAB330F6EFA104C9 /* WireGuardKit */,
);
productName = PhobosTunnel;
productReference = A81F96BF4131FDBB816623BE /* PhobosTunnel.appex */;
productType = "com.apple.product-type.app-extension";
};
E1F7E69029753CF102C6CE2E /* PCAPhobos */ = {
isa = PBXNativeTarget;
buildConfigurationList = 61EF055D39FD21A29FA039D5 /* Build configuration list for PBXNativeTarget "PCAPhobos" */;
buildPhases = (
F83DEE91B85335727CA6B39E /* Sources */,
D18596DD6D613F6BC9647644 /* Resources */,
9FC25420ED5EF973CE140584 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
0B334C04E5AD898D8736FFAA /* PBXTargetDependency */,
);
name = PCAPhobos;
packageProductDependencies = (
);
productName = PCAPhobos;
productReference = 027878C5E0654DB27FAE88A3 /* PCAPhobos.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E59D1112D2B128DF6FE7A1E9 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
0FD066BC2C51A1E092914FE8 = {
DevelopmentTeam = Q5FKU39TUA;
ProvisioningStyle = Automatic;
};
E1F7E69029753CF102C6CE2E = {
DevelopmentTeam = Q5FKU39TUA;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = F73680B923D76C65D03B442C /* Build configuration list for PBXProject "PCAPhobos" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = E31449B836D353F74991D905;
minimizedProjectReferenceProxies = 1;
packageReferences = (
9F8EC62EA6724F5FA6B8BAE0 /* XCLocalSwiftPackageReference "WireGuardKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 1EBBB94A186F7080FE3609C8 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
E1F7E69029753CF102C6CE2E /* PCAPhobos */,
0FD066BC2C51A1E092914FE8 /* PhobosTunnel */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A5352C1E8C48FFB2CCEC38F3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5F5D376513C0B27C45A3E7B6 /* ru_bypass.txt in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D18596DD6D613F6BC9647644 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D8CCC1E6CC8164EDD4E34D25 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
173BA4C39EE769A34EBB19CB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
99F5DB52833E3B5505EEA727 /* PacketTunnelProvider.swift in Sources */,
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */,
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */,
11F2A6A8D281AB36B45D4603 /* TunnelConfiguration+WgQuickConfig.swift in Sources */,
599057D9048B5FF1EA0E6000 /* WsLink.swift in Sources */,
78B74F81E0042D9AD94B7A6B /* config.c in Sources */,
1C05D29ACA95548374AD6FD9 /* logging.c in Sources */,
56F35C68C6A387E499703CFF /* masking.c in Sources */,
E88105F9325A93041D617213 /* masking_stun.c in Sources */,
B3BB8BE3409D63FEB59B5449 /* obfuscation.c in Sources */,
DF6D62C526E908CA5CDDFCAE /* pcarelay.c in Sources */,
38870650A7C2B1F0927A1230 /* wg-obfuscator.c in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F83DEE91B85335727CA6B39E /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
008DA398D56DFBDB16D97CE1 /* ContentView.swift in Sources */,
C3C41D3C419E8D17F20A38B5 /* PCAPhobosApp.swift in Sources */,
CBC338B91B5FCE1E207B36AC /* PhobosProfile.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
0B334C04E5AD898D8736FFAA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0FD066BC2C51A1E092914FE8 /* PhobosTunnel */;
targetProxy = B9875EF5DEB6362919B43F22 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
35473CF1EA2AAAC350F346D0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/PCAPhobos.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.bobyrev.pcaphobos;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
62739003B63EE4EE3B3FF1FB /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Tunnel/PhobosTunnel.entitlements;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
HEADER_SEARCH_PATHS = (
"$(PROJECT_DIR)/Tunnel",
"$(PROJECT_DIR)/Tunnel/obfuscator",
);
INFOPLIST_FILE = Tunnel/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(PROJECT_DIR)/WireGuardGo/iphoneos",
);
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = (
"$(PROJECT_DIR)/WireGuardGo/iphonesimulator",
);
PRODUCT_BUNDLE_IDENTIFIER = com.bobyrev.pcaphobos.tunnel;
SDKROOT = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "Tunnel/Tunnel-Bridging-Header.h";
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
7ABA8220043DEF78154F9F38 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 6;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = Q5FKU39TUA;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
B5D2F9B70DD37D3A57737EED /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 6;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = Q5FKU39TUA;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
D0AEA32C3C7BFB88918AB206 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Tunnel/PhobosTunnel.entitlements;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
HEADER_SEARCH_PATHS = (
"$(PROJECT_DIR)/Tunnel",
"$(PROJECT_DIR)/Tunnel/obfuscator",
);
INFOPLIST_FILE = Tunnel/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(PROJECT_DIR)/WireGuardGo/iphoneos",
);
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = (
"$(PROJECT_DIR)/WireGuardGo/iphonesimulator",
);
PRODUCT_BUNDLE_IDENTIFIER = com.bobyrev.pcaphobos.tunnel;
SDKROOT = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "Tunnel/Tunnel-Bridging-Header.h";
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
D42BF07983FD43EBB6D84762 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/PCAPhobos.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.bobyrev.pcaphobos;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
329F6A5A13B95B1525F94841 /* Build configuration list for PBXNativeTarget "PhobosTunnel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D0AEA32C3C7BFB88918AB206 /* Debug */,
62739003B63EE4EE3B3FF1FB /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
61EF055D39FD21A29FA039D5 /* Build configuration list for PBXNativeTarget "PCAPhobos" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D42BF07983FD43EBB6D84762 /* Debug */,
35473CF1EA2AAAC350F346D0 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
F73680B923D76C65D03B442C /* Build configuration list for PBXProject "PCAPhobos" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B5D2F9B70DD37D3A57737EED /* Debug */,
7ABA8220043DEF78154F9F38 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
9F8EC62EA6724F5FA6B8BAE0 /* XCLocalSwiftPackageReference "WireGuardKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = WireGuardKit;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
FA829997FAB330F6EFA104C9 /* WireGuardKit */ = {
isa = XCSwiftPackageProductDependency;
productName = WireGuardKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = E59D1112D2B128DF6FE7A1E9 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

27
ios/README.md Normal file
View File

@@ -0,0 +1,27 @@
# PCA Phobos VPN — iPhone
То же, что Android-клиент: импорт `phobos://` из панели, обфускация (wg-obfuscator внутри
расширения), **российские сайты напрямую**, канал **Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP; «Облако» — через KeenDNS своего Keenetic, см. `../wsgw/README.md`).
Выбор отдельных приложений на iOS невозможен (Apple даёт это только через MDM) — делим по адресам.
Устройство:
- `App/` — SwiftUI-приложение (профиль, кнопка, настройки), `Shared/PhobosProfile.swift` — разбор ссылки.
- `Tunnel/` — Packet Tunnel: WireGuardKit → 127.0.0.1:13255 wg-obfuscator (`-Dmain=wgobf_main`, один
экземпляр: iOS запрещает fork) → 127.0.0.1:13258 `pcarelay.c` → сервер по UDP или TCP (формат udptcp),
либо «Облако»: пакеты из pcarelay в `WsLink.swift` (URLSessionWebSocketTask) → wss://поддомен роутера → wsgw → сервер.
- `WireGuardKit/` — wireguard-apple (MIT), `WireGuardGo/<sdk>/libwg-go.a` — собран `make` (iphoneos) и
`go build -buildmode c-archive` с `-target arm64-apple-ios15.0-simulator` (симулятор).
Сборка: `xcodegen generate` → `PCAPhobos.xcodeproj`. Проверка без телефона: `Tests/ProfileCheck`, режим «Облако» релея — `Tests/RelayCheck/main.c`.
## Установка на iPhone (нужен ПЛАТНЫЙ Apple Developer, $99/год)
Network Extension (packet-tunnel-provider) Apple выдаёт только платным аккаунтам — с бесплатным
Xcode не подпишет даже одно приложение. Когда аккаунт есть:
1. В `project.yml` вписать `DEVELOPMENT_TEAM: <Team ID>`, `xcodegen generate`.
2. Xcode → Signing: включить capability Network Extensions (Packet Tunnel) для обоих таргетов.
3. iPhone по кабелю → Run, либо Product → Archive → TestFlight (тестировщики по e-mail, 90 дней).
4. В панели клиента → 📱 → открыть ссылку `phobos://` на iPhone → «Открыть в PCA Phobos» → Подключить
→ разрешить добавление VPN.
Проверено 23.10.2026: сборка под iphoneos и симулятор, UI и импорт реальной ссылки в симуляторе.
Сам туннель в симуляторе iOS не работает — проверяется только на телефоне.

View File

@@ -0,0 +1,101 @@
import Foundation
/// Parsed phobos:// payload from the PCA Phobos panel:
/// [Interface] + [Peer] (WireGuard) + [instance] (wg-obfuscator) + "# pca-tcp = <port>".
struct PhobosProfile {
let raw: String
init(_ raw: String) { self.raw = raw }
/// phobos://<urlsafe base64>#name
static func fromLink(_ link: String) -> (profile: PhobosProfile, name: String)? {
var s = link.trimmingCharacters(in: .whitespacesAndNewlines)
guard s.hasPrefix("phobos://") else { return nil }
s.removeFirst("phobos://".count)
var name = "Phobos"
if let hash = s.firstIndex(of: "#") {
name = String(s[s.index(after: hash)...]).removingPercentEncoding ?? name
s = String(s[..<hash])
}
var b64 = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while b64.count % 4 != 0 { b64 += "=" }
guard let data = Data(base64Encoded: b64), let text = String(data: data, encoding: .utf8),
text.contains("[Interface]"), text.contains("[instance]") else { return nil }
return (PhobosProfile(text), name)
}
/// Lines of a section, without "= none" placeholders and comments.
func lines(_ header: String) -> [(key: String, line: String)] {
var inSection = false
var out: [(String, String)] = []
for rawLine in raw.split(separator: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("[") && line.hasSuffix("]") { inSection = line.lowercased() == header.lowercased(); continue }
guard inSection, !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue }
let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
if value.lowercased() == "none" { continue }
out.append((line[..<eq].trimmingCharacters(in: .whitespaces).lowercased(), line))
}
return out
}
private func value(_ header: String, _ key: String) -> String? {
lines(header).first { $0.key == key }.map { line in
line.line[line.line.index(after: line.line.firstIndex(of: "=")!)...].trimmingCharacters(in: .whitespaces)
}
}
var target: String? { value("[instance]", "target") }
var targetHost: String? { target.map { String($0[..<($0.lastIndex(of: ":") ?? $0.endIndex)]) } }
var targetPort: Int? { target.flatMap { $0.split(separator: ":").last.flatMap { Int($0) } } }
/// "# pca-tcp = 443" written by the panel when the server has the TCP fallback.
var tcpPort: Int? {
for rawLine in raw.split(separator: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("#"), line.contains("pca-tcp"), let eq = line.firstIndex(of: "=") {
return Int(line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces))
}
}
return nil
}
/// "# pca-ws = wss://…" — the user's Keenetic (KeenDNS cloud subdomain -> wsgw), channel «Облако».
var wsURL: String? {
for rawLine in raw.split(separator: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("#"), line.contains("pca-ws"), let eq = line.firstIndex(of: "=") {
return line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
}
}
return nil
}
func obfuscatorConfig(listen: Int, target: String) -> String {
var out = "[instance]\nsource-if = 127.0.0.1\nsource-lport = \(listen)\ntarget = \(target)\n"
for item in lines("[instance]") where !["source-if", "source-lport", "target", "verbose"].contains(item.key) {
out += item.line + "\n"
}
return out + "verbose = INFO\n"
}
func wireGuardConfig(endpoint: String, allowedIPs: String?) -> String {
var out = "[Interface]\n"
for item in lines("[Interface]") where !item.key.hasSuffix("applications") && item.key != "mtu" { out += item.line + "\n" }
// ponytail: obfuscator masking adds bytes; the panel's default 1420 stalls on mobile networks,
// so 1420/missing -> 1280, any other MTU in the link wins (set per client in the panel)
let linkMTU = lines("[Interface]").first { $0.key == "mtu" }
.flatMap { Int($0.line.split(separator: "=").last?.trimmingCharacters(in: .whitespaces) ?? "") }
let mtu = (linkMTU == nil || linkMTU == 1420) ? 1280 : linkMTU!
out += "MTU = \(mtu)\n"
out += "\n[Peer]\n"
for item in lines("[Peer]") {
switch item.key {
case "endpoint": out += "Endpoint = \(endpoint)\n"
case "allowedips" where allowedIPs != nil: out += "AllowedIPs = \(allowedIPs!)\n"
default: out += item.line + "\n"
}
}
return out
}
}

View File

@@ -0,0 +1,41 @@
// Self-check of phobos:// parsing (no device needed):
// cd ios/Tests/ProfileCheck && xcrun swiftc -o /tmp/pc ../../Shared/PhobosProfile.swift main.swift && /tmp/pc
import Foundation
let conf = """
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.25.0.9/32
DNS = 1.1.1.1
MTU = 1420
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
PresharedKey = none
Endpoint = 127.0.0.1:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
[instance]
source-if = 127.0.0.1
source-lport = 51820
target = 203.0.113.10:2083
key = testkey
masking = STUN
verbose = INFO
idle-timeout = 300
max-dummy = 4
# pca-tcp = 443
"""
let link = "phobos://" + Data(conf.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "") + "#demo"
let (p, name) = PhobosProfile.fromLink(link)!
precondition(name == "demo" && p.targetHost == "203.0.113.10" && p.targetPort == 2083 && p.tcpPort == 443)
let obf = p.obfuscatorConfig(listen: 13255, target: "127.0.0.1:13258")
precondition(obf.contains("target = 127.0.0.1:13258") && obf.contains("key = testkey") && !obf.contains("203.0.113.10"))
let wg = p.wireGuardConfig(endpoint: "127.0.0.1:13255", allowedIPs: "1.0.0.0/8")
precondition(wg.contains("Endpoint = 127.0.0.1:13255") && wg.contains("AllowedIPs = 1.0.0.0/8") && !wg.contains("= none"))
precondition(wg.contains("MTU = 1280") && !wg.contains("1420")) // panel default -> 1280
let custom = PhobosProfile.fromLink("phobos://" + Data(conf.replacingOccurrences(of: "MTU = 1420", with: "MTU = 1300").utf8).base64EncodedString())!.0
precondition(custom.wireGuardConfig(endpoint: "e", allowedIPs: nil).contains("MTU = 1300")) // link MTU wins
print("profile_check ok")

View File

@@ -0,0 +1,22 @@
/* Mode «Облако» of pcarelay without a phone: cc -I Tunnel -o /tmp/rc Tests/RelayCheck/main.c Tunnel/pcarelay.c && /tmp/rc */
#include "pcarelay.h"
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int wgobf_main(int c, char **v) { return 0; }
static void out(const unsigned char *p, int n) { unsigned char r[64]; memcpy(r, p, n); r[0] = 'R'; pcarelay_ws_in(r, n); }
int main(void) {
pcarelay_start(13299, "127.0.0.1", 9, 0);
pcarelay_use_ws(out);
usleep(200000);
assert(pcarelay_is_ws() && !pcarelay_is_tcp());
int s = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons(13299), .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
struct timeval tv = {2, 0}; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
sendto(s, "ping", 4, 0, (struct sockaddr *)&a, sizeof a);
char b[16] = {0}; int n = recv(s, b, sizeof b, 0);
assert(n == 4 && !memcmp(b, "Ring", 4) && pcarelay_rx_bytes() == 4);
puts("pcarelay ws: ok");
}

31
ios/Tunnel/Info.plist Normal file
View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>PhobosTunnel</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.packet-tunnel</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,107 @@
import Foundation
import NetworkExtension
import WireGuardKit
/// PCA Phobos packet tunnel:
/// WireGuard (userspace) -> 127.0.0.1:13255 wg-obfuscator -> 127.0.0.1:13258 pcarelay -> server (UDP or TCP).
/// providerConfiguration: "conf" (phobos:// payload), "ruDirect" (Bool), "transport" ("auto"|"udp"|"tcp"|"cloud"), "wsURL".
/// "cloud": pcarelay hands packets to WsLink -> wss://<Keenetic KeenDNS subdomain>/ -> wsgw on the router -> server UDP.
final class PacketTunnelProvider: NEPacketTunnelProvider {
private static let wgPort = 13255, relayPort = 13258
private static var obfuscatorStarted = false // one wg-obfuscator per process (it forks for more)
private lazy var adapter = WireGuardAdapter(with: self) { _, message in NSLog("wg: %@", message) }
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
let pc = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration ?? [:]
guard let conf = pc["conf"] as? String, !conf.isEmpty else {
completionHandler(NSError(domain: "PCAPhobos", code: 1, userInfo: [NSLocalizedDescriptionKey: "нет профиля"]))
return
}
let ruDirect = pc["ruDirect"] as? Bool ?? true
let transport = pc["transport"] as? String ?? "auto"
let profile = PhobosProfile(conf)
guard let host = profile.targetHost, let udpPort = profile.targetPort else {
completionHandler(NSError(domain: "PCAPhobos", code: 2, userInfo: [NSLocalizedDescriptionKey: "в профиле нет адреса сервера"]))
return
}
let tcpPort = profile.tcpPort ?? 0
if transport == "tcp" && tcpPort == 0 {
completionHandler(NSError(domain: "PCAPhobos", code: 3, userInfo: [NSLocalizedDescriptionKey: "на сервере нет TCP-канала"]))
return
}
let wsURL = (pc["wsURL"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? profile.wsURL
if transport == "cloud" {
guard let s = wsURL, let url = URL(string: s) else {
completionHandler(NSError(domain: "PCAPhobos", code: 4, userInfo: [NSLocalizedDescriptionKey: "не задан адрес роутера (wss://…) для канала «Облако»"]))
return
}
WsLink.shared.start(url)
pcarelay_use_ws { p, n in WsLink.shared.send(p, n) }
} else {
pcarelay_use_tcp(transport == "tcp" ? 1 : 0)
}
if !Self.obfuscatorStarted {
Self.obfuscatorStarted = true
pcarelay_start(Int32(Self.relayPort), host, Int32(udpPort), Int32(tcpPort))
let path = NSTemporaryDirectory() + "obf.conf"
try? profile.obfuscatorConfig(listen: Self.wgPort, target: "127.0.0.1:\(Self.relayPort)")
.write(toFile: path, atomically: true, encoding: .utf8)
Thread.detachNewThread {
var args: [UnsafeMutablePointer<CChar>?] = ["wg-obfuscator", "-c", path].map { strdup($0) }
_ = wgobf_main(Int32(args.count), &args)
}
}
// full tunnel; Russian networks (all sizes, incl. CDN /24s) are excluded routes when ruDirect
PhobosRoutes.excludedIPv4 = ruDirect ? Self.ruNetworks() : []
let wgText = profile.wireGuardConfig(endpoint: "127.0.0.1:\(Self.wgPort)", allowedIPs: nil)
let tunnelConfig: TunnelConfiguration
do {
tunnelConfig = try TunnelConfiguration(fromWgQuickConfig: wgText, called: "phobos")
} catch {
completionHandler(error)
return
}
adapter.start(tunnelConfiguration: tunnelConfig) { [weak self] error in
completionHandler(error)
if error == nil && transport == "auto" && tcpPort != 0 { self?.watchUdp() }
}
}
/// Auto: nothing came back over UDP within 15 s -> UDP is cut, move the relay to TCP.
private func watchUdp() {
DispatchQueue.global().asyncAfter(deadline: .now() + 15) {
if pcarelay_is_tcp() == 0 && pcarelay_rx_bytes() == 0 {
NSLog("PCAPhobos: no UDP answer in 15 s -> TCP")
pcarelay_use_tcp(1)
}
}
}
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
adapter.stop { _ in
completionHandler()
// the obfuscator thread cannot be stopped cleanly; a fresh process starts next time
exit(0)
}
}
/// App asks for status: {"transport": "UDP"|"TCP", "rx": bytes}
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
let status: [String: Any] = ["transport": pcarelay_is_ws() != 0 ? "Облако" : pcarelay_is_tcp() != 0 ? "TCP" : "UDP",
"rx": pcarelay_rx_bytes()]
completionHandler?(try? JSONSerialization.data(withJSONObject: status))
}
/// ru_bypass.txt: Russian IPv4 networks (comma-separated). IPv6 stays fully in the tunnel,
/// otherwise blocked sites leak over carrier IPv6.
private static func ruNetworks() -> [String] {
guard let url = Bundle.main.url(forResource: "ru_bypass", withExtension: "txt"),
let text = try? String(contentsOf: url, encoding: .utf8),
let line = text.split(separator: "\n").first(where: { !$0.hasPrefix("#") && $0.contains("/") }) else { return [] }
return line.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
extension String {
func splitToArray(separator: Character = ",", trimmingCharacters: CharacterSet? = nil) -> [String] {
return split(separator: separator)
.map {
if let charSet = trimmingCharacters {
return $0.trimmingCharacters(in: charSet)
} else {
return String($0)
}
}
}
}
extension Optional where Wrapped == String {
func splitToArray(separator: Character = ",", trimmingCharacters: CharacterSet? = nil) -> [String] {
switch self {
case .none:
return []
case .some(let wrapped):
return wrapped.splitToArray(separator: separator, trimmingCharacters: trimmingCharacters)
}
}
}

View File

@@ -0,0 +1 @@
#include "pcarelay.h"

View File

@@ -0,0 +1,253 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
import WireGuardKit
extension TunnelConfiguration {
enum ParserState {
case inInterfaceSection
case inPeerSection
case notInASection
}
enum ParseError: Error {
case invalidLine(String.SubSequence)
case noInterface
case multipleInterfaces
case interfaceHasNoPrivateKey
case interfaceHasInvalidPrivateKey(String)
case interfaceHasInvalidListenPort(String)
case interfaceHasInvalidAddress(String)
case interfaceHasInvalidDNS(String)
case interfaceHasInvalidMTU(String)
case interfaceHasUnrecognizedKey(String)
case peerHasNoPublicKey
case peerHasInvalidPublicKey(String)
case peerHasInvalidPreSharedKey(String)
case peerHasInvalidAllowedIP(String)
case peerHasInvalidEndpoint(String)
case peerHasInvalidPersistentKeepAlive(String)
case peerHasInvalidTransferBytes(String)
case peerHasInvalidLastHandshakeTime(String)
case peerHasUnrecognizedKey(String)
case multiplePeersWithSamePublicKey
case multipleEntriesForKey(String)
}
convenience init(fromWgQuickConfig wgQuickConfig: String, called name: String? = nil) throws {
var interfaceConfiguration: InterfaceConfiguration?
var peerConfigurations = [PeerConfiguration]()
let lines = wgQuickConfig.split { $0.isNewline }
var parserState = ParserState.notInASection
var attributes = [String: String]()
for (lineIndex, line) in lines.enumerated() {
var trimmedLine: String
if let commentRange = line.range(of: "#") {
trimmedLine = String(line[..<commentRange.lowerBound])
} else {
trimmedLine = String(line)
}
trimmedLine = trimmedLine.trimmingCharacters(in: .whitespacesAndNewlines)
let lowercasedLine = trimmedLine.lowercased()
if !trimmedLine.isEmpty {
if let equalsIndex = trimmedLine.firstIndex(of: "=") {
// Line contains an attribute
let keyWithCase = trimmedLine[..<equalsIndex].trimmingCharacters(in: .whitespacesAndNewlines)
let key = keyWithCase.lowercased()
let value = trimmedLine[trimmedLine.index(equalsIndex, offsetBy: 1)...].trimmingCharacters(in: .whitespacesAndNewlines)
let keysWithMultipleEntriesAllowed: Set<String> = ["address", "allowedips", "dns"]
if let presentValue = attributes[key] {
if keysWithMultipleEntriesAllowed.contains(key) {
attributes[key] = presentValue + "," + value
} else {
throw ParseError.multipleEntriesForKey(keyWithCase)
}
} else {
attributes[key] = value
}
let interfaceSectionKeys: Set<String> = ["privatekey", "listenport", "address", "dns", "mtu"]
let peerSectionKeys: Set<String> = ["publickey", "presharedkey", "allowedips", "endpoint", "persistentkeepalive"]
if parserState == .inInterfaceSection {
guard interfaceSectionKeys.contains(key) else {
throw ParseError.interfaceHasUnrecognizedKey(keyWithCase)
}
} else if parserState == .inPeerSection {
guard peerSectionKeys.contains(key) else {
throw ParseError.peerHasUnrecognizedKey(keyWithCase)
}
}
} else if lowercasedLine != "[interface]" && lowercasedLine != "[peer]" {
throw ParseError.invalidLine(line)
}
}
let isLastLine = lineIndex == lines.count - 1
if isLastLine || lowercasedLine == "[interface]" || lowercasedLine == "[peer]" {
// Previous section has ended; process the attributes collected so far
if parserState == .inInterfaceSection {
let interface = try TunnelConfiguration.collate(interfaceAttributes: attributes)
guard interfaceConfiguration == nil else { throw ParseError.multipleInterfaces }
interfaceConfiguration = interface
} else if parserState == .inPeerSection {
let peer = try TunnelConfiguration.collate(peerAttributes: attributes)
peerConfigurations.append(peer)
}
}
if lowercasedLine == "[interface]" {
parserState = .inInterfaceSection
attributes.removeAll()
} else if lowercasedLine == "[peer]" {
parserState = .inPeerSection
attributes.removeAll()
}
}
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray)
if peerPublicKeysArray.count != peerPublicKeysSet.count {
throw ParseError.multiplePeersWithSamePublicKey
}
if let interfaceConfiguration = interfaceConfiguration {
self.init(name: name, interface: interfaceConfiguration, peers: peerConfigurations)
} else {
throw ParseError.noInterface
}
}
func asWgQuickConfig() -> String {
var output = "[Interface]\n"
output.append("PrivateKey = \(interface.privateKey.base64Key)\n")
if let listenPort = interface.listenPort {
output.append("ListenPort = \(listenPort)\n")
}
if !interface.addresses.isEmpty {
let addressString = interface.addresses.map { $0.stringRepresentation }.joined(separator: ", ")
output.append("Address = \(addressString)\n")
}
if !interface.dns.isEmpty || !interface.dnsSearch.isEmpty {
var dnsLine = interface.dns.map { $0.stringRepresentation }
dnsLine.append(contentsOf: interface.dnsSearch)
let dnsString = dnsLine.joined(separator: ", ")
output.append("DNS = \(dnsString)\n")
}
if let mtu = interface.mtu {
output.append("MTU = \(mtu)\n")
}
for peer in peers {
output.append("\n[Peer]\n")
output.append("PublicKey = \(peer.publicKey.base64Key)\n")
if let preSharedKey = peer.preSharedKey?.base64Key {
output.append("PresharedKey = \(preSharedKey)\n")
}
if !peer.allowedIPs.isEmpty {
let allowedIPsString = peer.allowedIPs.map { $0.stringRepresentation }.joined(separator: ", ")
output.append("AllowedIPs = \(allowedIPsString)\n")
}
if let endpoint = peer.endpoint {
output.append("Endpoint = \(endpoint.stringRepresentation)\n")
}
if let persistentKeepAlive = peer.persistentKeepAlive {
output.append("PersistentKeepalive = \(persistentKeepAlive)\n")
}
}
return output
}
private static func collate(interfaceAttributes attributes: [String: String]) throws -> InterfaceConfiguration {
guard let privateKeyString = attributes["privatekey"] else {
throw ParseError.interfaceHasNoPrivateKey
}
guard let privateKey = PrivateKey(base64Key: privateKeyString) else {
throw ParseError.interfaceHasInvalidPrivateKey(privateKeyString)
}
var interface = InterfaceConfiguration(privateKey: privateKey)
if let listenPortString = attributes["listenport"] {
guard let listenPort = UInt16(listenPortString) else {
throw ParseError.interfaceHasInvalidListenPort(listenPortString)
}
interface.listenPort = listenPort
}
if let addressesString = attributes["address"] {
var addresses = [IPAddressRange]()
for addressString in addressesString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
guard let address = IPAddressRange(from: addressString) else {
throw ParseError.interfaceHasInvalidAddress(addressString)
}
addresses.append(address)
}
interface.addresses = addresses
}
if let dnsString = attributes["dns"] {
var dnsServers = [DNSServer]()
var dnsSearch = [String]()
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
if let dnsServer = DNSServer(from: dnsServerString) {
dnsServers.append(dnsServer)
} else {
dnsSearch.append(dnsServerString)
}
}
interface.dns = dnsServers
interface.dnsSearch = dnsSearch
}
if let mtuString = attributes["mtu"] {
guard let mtu = UInt16(mtuString) else {
throw ParseError.interfaceHasInvalidMTU(mtuString)
}
interface.mtu = mtu
}
return interface
}
private static func collate(peerAttributes attributes: [String: String]) throws -> PeerConfiguration {
guard let publicKeyString = attributes["publickey"] else {
throw ParseError.peerHasNoPublicKey
}
guard let publicKey = PublicKey(base64Key: publicKeyString) else {
throw ParseError.peerHasInvalidPublicKey(publicKeyString)
}
var peer = PeerConfiguration(publicKey: publicKey)
if let preSharedKeyString = attributes["presharedkey"] {
guard let preSharedKey = PreSharedKey(base64Key: preSharedKeyString) else {
throw ParseError.peerHasInvalidPreSharedKey(preSharedKeyString)
}
peer.preSharedKey = preSharedKey
}
if let allowedIPsString = attributes["allowedips"] {
var allowedIPs = [IPAddressRange]()
for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
guard let allowedIP = IPAddressRange(from: allowedIPString) else {
throw ParseError.peerHasInvalidAllowedIP(allowedIPString)
}
allowedIPs.append(allowedIP)
}
peer.allowedIPs = allowedIPs
}
if let endpointString = attributes["endpoint"] {
guard let endpoint = Endpoint(from: endpointString) else {
throw ParseError.peerHasInvalidEndpoint(endpointString)
}
peer.endpoint = endpoint
}
if let persistentKeepAliveString = attributes["persistentkeepalive"] {
guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else {
throw ParseError.peerHasInvalidPersistentKeepAlive(persistentKeepAliveString)
}
peer.persistentKeepAlive = persistentKeepAlive
}
return peer
}
}

59
ios/Tunnel/WsLink.swift Normal file
View File

@@ -0,0 +1,59 @@
import Foundation
/// Channel «Облако»: one binary WebSocket message = one obfuscated packet, both ways.
/// pcarelay (C) calls send() for every packet from the obfuscator; received messages go back via pcarelay_ws_in().
final class WsLink {
static let shared = WsLink()
private let q = DispatchQueue(label: "pca.ws")
private let session = URLSession(configuration: .ephemeral)
private var url: URL?
private var task: URLSessionWebSocketTask?
func start(_ url: URL) {
q.async { self.url = url; self.connect() }
}
func send(_ p: UnsafePointer<UInt8>?, _ n: Int32) {
guard let p, n > 0 else { return }
let data = Data(bytes: p, count: Int(n))
q.async {
if self.task == nil { self.connect() }
guard let t = self.task else { return }
t.send(.data(data)) { [weak self] err in
if let err { self?.q.async { if self?.task === t { self?.drop("send: \(err.localizedDescription)") } } }
}
}
}
private func connect() {
guard let url, task == nil else { return }
let t = session.webSocketTask(with: url)
t.maximumMessageSize = 1 << 20
task = t
t.resume()
NSLog("PCAPhobos ws: connect %@", url.absoluteString)
receive(t)
}
private func receive(_ t: URLSessionWebSocketTask) {
t.receive { [weak self] result in
switch result {
case .success(.data(let d)):
d.withUnsafeBytes { pcarelay_ws_in($0.bindMemory(to: UInt8.self).baseAddress, Int32(d.count)) }
self?.receive(t)
case .success:
self?.receive(t)
case .failure(let err):
self?.q.async { if self?.task === t { self?.drop("recv: \(err.localizedDescription)") } }
}
}
}
/// Next packet from the obfuscator reconnects (WireGuard keepalives make that quick).
private func drop(_ why: String) {
NSLog("PCAPhobos ws: %@", why)
task?.cancel(with: .goingAway, reason: nil)
task = nil
}
}

View File

@@ -0,0 +1,535 @@
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <ctype.h>
#include "config.h"
#include "wg-obfuscator.h"
#include "mini_argp.h"
#include "masking.h"
// Executable name
static const char *arg0;
/* The options we understand. */
static const mini_argp_opt options[] = {
{ "help", '?', 0 },
{ "config", 'c', 1 },
{ "source-if", 'i', 1 },
{ "source", 's', 1 },
{ "source-lport", 'p', 1 },
{ "target-if", 'o', 1 },
{ "target", 't', 1 },
{ "target-lport", 'r', 1 },
{ "key", 'k', 1 },
{ "masking", 'a', 1 },
{ "static-bindings", 'b', 1 },
{ "max-clients" , 'm', 1 },
{ "idle-timeout", 'l', 1 },
{ "in-timeout", 'n', 1 },
{ "max-dummy", 'd', 1 },
{ "fwmark", 'f', 1 },
{ "allow-clean", 'e', 0 },
{ "verbose", 'v', 1 },
{ "log-file", 'L', 1 },
{ "log-timestamps", 'T', 1 },
{ "resolve-interval", 'R', 1 },
{ 0 }
};
static void show_usage(void)
{
printf("Usage: %s [options]\n%s", arg0,
" -?, --help Give this help list\n"
"\n"
"Main settings:\n"
" -c, --config=<config_file> Read configuration from file\n"
" (can be used instead of the rest arguments)\n"
" -i, --source-if=<ip> Source interface to listen on\n"
" (optional, default - 0.0.0.0, e.g. all)\n"
" -p, --source-lport=<port> Source port to listen\n"
" -t, --target=<ip>:<port> Target IP and port\n"
" -k, --key=<key> Obfuscation key \n"
" (required, must be 1-255 characters long)\n"
" -a, --masking=<type> Masking type (optional, default - AUTO)\n"
" Supported values: STUN, AUTO, NONE\n"
" -b, --static-bindings=<ip>:<port>:<port>,...\n"
" Comma-separated static bindings for two-way mode\n"
" as <client_ip>:<client_port>:<forward_port>\n"
" You can also repeat this option.\n"
" -f, --fwmark=<mark> Firewall mark to set on all packets\n"
" (optional, default - 0, e.g. disabled)\n"
" -v, --verbose=<level> Verbosity level (optional, default - INFO)\n"
" ERRORS (critical errors only)\n"
" WARNINGS (important messages)\n"
" 2 - INFO (informational messages: status messages,\n"
" connection established, etc.)\n"
" 3 - DEBUG (detailed debug messages)\n"
" 4 - TRACE (very detailed debug messages, including\n"
" packet dumps)\n"
" -L, --log-file=<path> Write the log to this file instead of stderr\n"
" (optional, the file is reopened on SIGHUP)\n"
" -T, --log-timestamps=<val> Prefix log lines with a timestamp\n"
" (optional, default - AUTO, e.g. only when\n"
" writing to a log file)\n"
" Supported values: AUTO, TRUE, FALSE\n"
"\n"
"Additional options:\n"
" -m, --max-clients=<number> Maximum number of clients (default: 1024)\n"
" -l, --idle-timeout=<sec> Idle timeout in seconds (default: 300)\n"
" -n, --in-timeout=<sec> Incoming timeout in seconds (default: 0 - disabled)\n"
" -d, --max-dummy=<bytes> Maximum length of dummy bytes for data packets\n"
" (default: 4)\n"
" -e, --allow-clean For servers, allow non-obfuscated incoming connections\n"
" -R, --resolve-interval=<sec>\n"
" Re-resolve target and static-binding hostnames\n"
" every N seconds (default: 0 - disabled).\n"
" SIGHUP always triggers a refresh.\n"
" If non-zero, a failed resolve at startup is retried\n"
" instead of exiting.\n");
}
static int parse_opt(const char *lname, char sname, const char *val, void *ctx);
/**
* @brief Resets the configuration structure to its default values.
*
* This function clears the configuration structure by setting all fields to zero
* and resetting the verbosity level to the default value.
*
* @param config Pointer to the obfuscator_config structure to be reset.
*/
static void reset_config(obfuscator_config_t *config)
{
if (config->static_bindings) {
free(config->static_bindings);
}
memset(config, 0, sizeof(*config));
config->max_clients = MAX_CLIENTS_DEFAULT;
config->idle_timeout = IDLE_TIMEOUT_DEFAULT;
config->in_timeout = IN_TIMEOUT_DEFAULT;
config->max_dummy_length_data = MAX_DUMMY_LENGTH_DATA_DEFAULT;
config->log_timestamps = -1; // auto
verbose = LL_DEFAULT;
}
/**
* Checks if the given string represents a valid integer.
*
* @param str Pointer to the null-terminated string to check.
* @return Non-zero value if the string is a valid integer, 0 otherwise.
*/
static uint8_t is_integer(const char *str)
{
if (!str || !*str) {
return 0; // Empty string is not an integer
}
while (*str) {
if (!isdigit((unsigned char)*str)) {
return 0; // Non-digit character found
}
str++;
}
return 1; // All characters are digits
}
/**
* Parses a boolean value from a string.
*
* @param str Pointer to the null-terminated string to parse.
* @return 1 for true values ("true", "yes", "on", "1"),
* 0 for false values ("false", "no", "off", "0"),
* -1 if the string is not a valid boolean value.
*/
static int parse_bool(const char *str)
{
char lower[16];
if (!str || !*str || strlen(str) >= sizeof(lower)) {
return -1;
}
strcpy(lower, str);
for (char *p = lower; *p; ++p) *p = tolower((unsigned char)*p);
if (!strcmp(lower, "true") || !strcmp(lower, "yes") || !strcmp(lower, "on") || !strcmp(lower, "1")) {
return 1;
}
if (!strcmp(lower, "false") || !strcmp(lower, "no") || !strcmp(lower, "off") || !strcmp(lower, "0")) {
return 0;
}
return -1;
}
/**
* @brief Reads and processes the configuration file.
*
* This function opens the specified configuration file and parses its contents
* to initialize or update the application's configuration settings.
*
* @param filename The path to the configuration file to be read.
* @param config Pointer to the obfuscator_config structure where the parsed settings will be stored.
*/
static void read_config_file(const char *filename, obfuscator_config_t *config)
{
// Read configuration from the file
uint8_t first_section = 1; // Flag to indicate if this is the first section being processed
char line[10 * 1024]; // Buffer to hold each line from the config file
FILE *config_file = fopen(filename, "r");
if (config_file == NULL) {
perror("Can't open config file");
exit(EXIT_FAILURE);
}
while (fgets(line, sizeof(line), config_file)) {
// Remove trailing newlines, carriage returns, spaces and tabs
while (strlen(line) && (line[strlen(line) - 1] == '\n' || line[strlen(line) - 1] == '\r'
|| line[strlen(line) - 1] == ' ' || line[strlen(line) - 1] == '\t')) {
line[strlen(line) - 1] = 0;
}
// Remove leading spaces and tabs
while (strlen(line) && (line[0] == ' ' || line[0] == '\t')) {
memmove(line, line + 1, strlen(line));
}
// Ignore comments
char *comment_index = strstr(line, "#");
if (comment_index != NULL) {
*comment_index = 0;
}
// Skip empty lines or with spaces only
size_t line_len = strlen(line);
if (strspn(line, " \t\r\n") == line_len) {
continue;
}
// It can be new section
if (line_len > 0 && line[0] == '[' && line[line_len - 1] == ']') {
if (!first_section) {
// new config, need to fork the process
pid_t pid = fork();
if (pid < 0) {
log(LL_ERROR, "Can't fork a new instance - %s (%d)", strerror(errno), errno);
exit(EXIT_FAILURE);
}
if (pid == 0) {
// Close in the child process
fclose(config_file);
// Stop config file processing for this instance
return;
}
// Remember the instance to be able to forward signals to it
register_child_instance(pid);
}
size_t len = strlen(line) - 2;
if (len > sizeof(section_name) - 1) {
len = sizeof(section_name) - 1;
}
strncpy(section_name, line + 1, len);
section_name[len] = 0;
// Reset all the parameters
reset_config(config);
first_section = 0; // We have processed the first section
continue;
}
// Parse key-value pairs
char *key = strtok(line, "=");
key = trim(key);
while (strlen(key) && (key[strlen(key) - 1] == ' ' || key[strlen(key) - 1] == '\t' || key[strlen(key) - 1] == '\r' || key[strlen(key) - 1] == '\n')) {
key[strlen(key) - 1] = 0;
}
char *value = strtok(NULL, "=");
if (value == NULL) {
log(LL_ERROR, "Invalid configuration line: %s", line);
exit(EXIT_FAILURE);
}
value = trim(value);
if (!*value) {
log(LL_ERROR, "Invalid configuration line: %s", line);
exit(EXIT_FAILURE);
}
const mini_argp_opt *o = margp_find(options, key, 0);
if (o == NULL) {
log(LL_ERROR, "Unknown configuration key: %s", key);
exit(EXIT_FAILURE);
}
if (!o->has_arg) {
// Flag options are represented as booleans in the config file
int b = parse_bool(value);
if (b < 0) {
log(LL_ERROR, "Configuration key '%s' accepts only boolean values (true/yes/on/1 or false/no/off/0)", key);
exit(EXIT_FAILURE);
}
if (!b) {
continue;
}
value = NULL;
}
parse_opt(o->long_name, o->short_name, value, config);
}
fclose(config_file);
}
/* Parse a single option. */
static int parse_opt(const char *lname, char sname, const char *val, void *ctx)
{
obfuscator_config_t *config = (obfuscator_config_t *)ctx;
char val_lower[16];
switch (sname)
{
case '?':
// Show usage and exit
show_usage();
exit(EXIT_SUCCESS);
case 'c':
read_config_file(val, config);
break;
case 'i':
strncpy(config->client_interface, val, sizeof(config->client_interface) - 1);
config->client_interface[sizeof(config->client_interface) - 1] = 0; // Ensure null-termination
config->client_interface_set = 1;
break;
case 'p':
if (!is_integer(val)) {
log(LL_ERROR, "Invalid source port: %s (must be an integer)", val);
exit(EXIT_FAILURE);
}
config->listen_port = atoi(val);
if (config->listen_port <= 0 || config->listen_port > 65535) {
log(LL_ERROR, "Invalid listen port: %s (must be between 1 and 65535)", val);
exit(EXIT_FAILURE);
}
config->listen_port_set = 1;
break;
case 't':
strncpy(config->forward_host_port, val, sizeof(config->forward_host_port) - 1);
config->forward_host_port[sizeof(config->forward_host_port) - 1] = 0; // Ensure null-termination
config->forward_host_port_set = 1;
break;
case 'b':
{
char *new_bindings;
if (!config->static_bindings) {
new_bindings = strdup(val);
} else {
size_t old_len = strlen(config->static_bindings);
new_bindings = realloc(config->static_bindings, old_len + strlen(val) + 2);
if (new_bindings) {
new_bindings[old_len] = ',';
strcpy(new_bindings + old_len + 1, val);
}
}
if (!new_bindings) {
log(LL_ERROR, "Out of memory while parsing static-bindings");
exit(EXIT_FAILURE);
}
config->static_bindings = new_bindings;
config->static_bindings_set = 1;
}
break;
case 'k':
strncpy(config->xor_key, val, sizeof(config->xor_key));
config->xor_key[sizeof(config->xor_key) - 1] = 0; // Ensure null-termination
if (strlen(config->xor_key) == 0) {
log(LL_ERROR, "XOR key cannot be empty");
exit(EXIT_FAILURE);
}
config->xor_key_set = 1;
break;
case 'm':
if (!is_integer(val)) {
log(LL_ERROR, "Invalid maximum number of clients: %s (must be an integer)", val);
exit(EXIT_FAILURE);
}
config->max_clients = atoi(val);
if (config->max_clients <= 0) {
log(LL_ERROR, "Invalid maximum number of clients: %s (must be greater than 0)", val);
exit(EXIT_FAILURE);
}
break;
case 'l':
if (!is_integer(val)) {
log(LL_ERROR, "Invalid idle timeout: %s (must be an integer)", val);
exit(EXIT_FAILURE);
}
config->idle_timeout = atol(val);
if (config->idle_timeout <= 0) {
log(LL_ERROR, "Invalid idle timeout: %s (must be greater than 0)", val);
exit(EXIT_FAILURE);
}
config->idle_timeout *= 1000; // Convert to milliseconds
break;
case 'n':
if (!is_integer(val)) {
log(LL_ERROR, "Invalid incoming timeout: %s (must be an integer)", val);
exit(EXIT_FAILURE);
}
config->in_timeout = atol(val);
if (config->in_timeout <= 0) {
log(LL_ERROR, "Invalid incoming timeout: %s (must be greater than 0)", val);
exit(EXIT_FAILURE);
}
config->in_timeout *= 1000; // Convert to milliseconds
break;
case 'd':
if (!is_integer(val)) {
log(LL_ERROR, "Invalid maximum dummy length for data packets: %s (must be an integer)", val);
exit(EXIT_FAILURE);
}
config->max_dummy_length_data = atoi(val);
if (config->max_dummy_length_data < 0 || config->max_dummy_length_data > MAX_DUMMY_LENGTH_TOTAL) {
log(LL_ERROR, "Invalid maximum dummy length for data packets: %s (must be between 0 and %d)", val, MAX_DUMMY_LENGTH_TOTAL);
exit(EXIT_FAILURE);
}
break;
case 'e':
config->allow_clean = 1;
break;
case 'f':
// parse string with decimal and hexadecimal support
#ifdef __linux__
{
long int v = strtol(val, NULL, 0);
if (v <= 0 || v > UINT16_MAX) {
log(LL_ERROR, "Invalid firewall mark: %s", val);
exit(EXIT_FAILURE);
}
config->fwmark = (uint16_t)v;
}
#else
log(LL_WARN, "Firewall mark is not supported on this platform");
#endif
break;
case 'a':
{
strncpy(val_lower, val, sizeof(val_lower) - 1);
val_lower[sizeof(val_lower) - 1] = 0;
for (char *p = val_lower; *p; ++p) *p = tolower((unsigned char)*p);
if (strcmp(val_lower, "none") == 0) {
config->masking_handler = NULL;
config->masking_handler_set = 1;
break;
}
if (strcmp(val_lower, "auto") == 0) {
config->masking_handler = NULL;
config->masking_handler_set = 0;
break;
}
masking_handler_t *handler = get_masking_handler_by_name(val_lower);
if (handler == NULL) {
log(LL_ERROR, "Unknown masking type: %s", val);
exit(EXIT_FAILURE);
}
config->masking_handler = handler;
config->masking_handler_set = 1;
}
break;
case 'v':
strncpy(val_lower, val, sizeof(val_lower) - 1);
val_lower[sizeof(val_lower) - 1] = 0;
for (char *p = val_lower; *p; ++p) *p = tolower((unsigned char)*p);
if (strcmp(val_lower, "error") == 0) {
verbose = LL_ERROR;
} else if (strcmp(val_lower, "warn") == 0) {
verbose = LL_WARN;
} else if (strcmp(val_lower, "info") == 0) {
verbose = LL_INFO;
} else if (strcmp(val_lower, "debug") == 0) {
verbose = LL_DEBUG;
} else if (strcmp(val_lower, "trace") == 0) {
verbose = LL_TRACE;
} else {
// check if it's a number
if (is_integer(val)) {
verbose = atoi(val);
if (verbose < 0 || verbose > 4) {
log(LL_ERROR, "Invalid verbosity level: %s (must be one of 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE')", val);
exit(EXIT_FAILURE);
}
} else {
log(LL_ERROR, "Invalid verbosity level: %s (must be one of 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE')", val);
exit(EXIT_FAILURE);
}
}
break;
case 'L':
strncpy(config->log_file, val, sizeof(config->log_file) - 1);
config->log_file[sizeof(config->log_file) - 1] = 0; // Ensure null-termination
if (strlen(config->log_file) == 0) {
log(LL_ERROR, "Log file path cannot be empty");
exit(EXIT_FAILURE);
}
config->log_file_set = 1;
break;
case 'T':
{
strncpy(val_lower, val, sizeof(val_lower) - 1);
val_lower[sizeof(val_lower) - 1] = 0;
for (char *p = val_lower; *p; ++p) *p = tolower((unsigned char)*p);
if (strcmp(val_lower, "auto") == 0) {
config->log_timestamps = -1;
break;
}
int b = parse_bool(val_lower);
if (b < 0) {
log(LL_ERROR, "Invalid log timestamps mode: %s (must be one of 'AUTO', 'TRUE', 'FALSE')", val);
exit(EXIT_FAILURE);
}
config->log_timestamps = b;
}
break;
case 'R':
if (!is_integer(val)) {
log(LL_ERROR, "Invalid resolve interval: %s (must be an integer)", val);
exit(EXIT_FAILURE);
}
config->resolve_interval = atol(val);
if (config->resolve_interval < 0) {
log(LL_ERROR, "Invalid resolve interval: %s (must be 0 or greater)", val);
exit(EXIT_FAILURE);
}
config->resolve_interval *= 1000; // Convert to milliseconds
break;
default:
// should never happen
return -1;
}
return 0;
}
int parse_config(int argc, char **argv, obfuscator_config_t *config)
{
/* Parse command line arguments */
reset_config(config);
arg0 = argv[0]; // Save the executable name
if (argc == 1) {
fprintf(stderr, "No arguments provided, use \"%s --help\" command for usage information\n", argv[0]);
return -1;
}
if (mini_argp_parse(argc, argv, options, config, parse_opt) != 0) {
fprintf(stderr, "Failed to parse command line arguments\n");
return -1;
}
return 0;
}
/**
* @brief Removes leading and trailing whitespace characters from the input string.
*
* This function modifies the input string in place by trimming any whitespace
* characters (such as spaces, tabs, or newlines) from both the beginning and end.
*
* @param s Pointer to the null-terminated string to be trimmed.
* @return Pointer to the trimmed string.
*/
char *trim(char *s) {
char *end;
// Trim leading spaces, tabs, carriage returns and newlines
while (*s && (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n')) s++;
if (!*s) return s;
// Trim trailing spaces, tabs, carriage returns and newlines
end = s + strlen(s) - 1;
while (end > s && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) *end-- = 0;
return s;
}

View File

@@ -0,0 +1,9 @@
#ifndef _CONFIG_H_
#define _CONFIG_H_
#include "wg-obfuscator.h"
char *trim(char *s);
int parse_config(int argc, char **argv, obfuscator_config_t *config);
#endif // _CONFIG_H_

View File

@@ -0,0 +1,258 @@
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <time.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include "wg-obfuscator.h"
#ifndef O_BINARY
#define O_BINARY 0
#endif
#ifndef STDERR_FILENO
#define STDERR_FILENO 2
#endif
// Log files may contain client addresses, so they are not world-readable
#define LOG_FILE_MODE 0640
// File descriptor of the log file, -1 when logging to stderr
static int log_fd = -1;
// Path of the log file, kept to be able to reopen it on SIGHUP
static char *log_path = NULL;
// 1 if every line must be prefixed with a timestamp
static int log_timestamps = 0;
/**
* @brief Returns the single character representing a logging level.
*/
static char level_char(int level)
{
switch (level) {
case LL_ERROR: return 'E';
case LL_WARN: return 'W';
case LL_INFO: return 'I';
case LL_DEBUG: return 'D';
case LL_TRACE: return 'T';
default: return '?';
}
}
/**
* @brief Writes the whole buffer to the descriptor, retrying on partial writes.
*
* @param fd Descriptor to write to.
* @param buf Data to write.
* @param len Number of bytes to write.
*/
static void write_all(int fd, const char *buf, size_t len)
{
while (len) {
ssize_t written = write(fd, buf, len);
if (written <= 0) {
if (written < 0 && errno == EINTR) {
continue;
}
// Nothing sensible can be done about a broken log, drop the rest of the line
return;
}
buf += written;
len -= (size_t)written;
}
}
/**
* @brief Appends formatted text to the buffer without ever overflowing it.
*
* @param buf Buffer to append to.
* @param size Usable size of the buffer, including the terminating zero.
* @param len Current length of the text in the buffer.
* @param fmt Format string.
* @param args Arguments for the format string.
* @return New length of the text in the buffer.
*/
static size_t append_vfmt(char *buf, size_t size, size_t len, const char *fmt, va_list args)
{
int n;
if (len + 1 >= size) {
return len;
}
n = vsnprintf(buf + len, size - len, fmt, args);
if (n < 0) {
return len;
}
if ((size_t)n >= size - len) {
return size - 1; // truncated
}
return len + (size_t)n;
}
/**
* @brief Appends formatted text to the buffer without ever overflowing it.
*
* @param buf Buffer to append to.
* @param size Usable size of the buffer, including the terminating zero.
* @param len Current length of the text in the buffer.
* @param fmt Format string.
* @return New length of the text in the buffer.
*/
static size_t append_fmt(char *buf, size_t size, size_t len, const char *fmt, ...)
{
va_list args;
va_start(args, fmt);
len = append_vfmt(buf, size, len, fmt, args);
va_end(args);
return len;
}
/**
* @brief Writes the common line prefix (timestamp and instance name) to the buffer.
*
* @param buf Buffer to write to.
* @param size Usable size of the buffer, including the terminating zero.
* @param level Logging level of the line.
* @return Length of the prefix.
*/
static size_t log_prefix(char *buf, size_t size, int level)
{
size_t len = 0;
if (log_timestamps) {
struct timespec ts;
struct tm *tm;
if (clock_gettime(CLOCK_REALTIME, &ts) == 0 && (tm = localtime(&ts.tv_sec)) != NULL) {
len = strftime(buf, size, "%Y-%m-%d %H:%M:%S", tm);
len = append_fmt(buf, size, len, ".%03ld ", (long)(ts.tv_nsec / 1000000));
}
}
return append_fmt(buf, size, len, "[%s][%c] ", section_name, level_char(level));
}
/**
* @brief Terminates the line and writes it out with a single write() call.
*
* A single write() to a descriptor opened with O_APPEND keeps lines of different
* instances from interleaving when they share the same log file.
*
* @param buf Buffer holding the line, must have one spare byte for the newline.
* @param len Length of the line.
*/
static void log_emit(char *buf, size_t len)
{
buf[len++] = '\n';
write_all(log_fd >= 0 ? log_fd : STDERR_FILENO, buf, len);
}
/**
* @brief Writes a single log line.
*
* This is the implementation behind the log() macro, which is the one that
* checks the verbosity level, so the line written here is unconditional.
*
* @param level Logging level of the line.
* @param fmt Format string.
*/
void log_printf(int level, const char *fmt, ...)
{
char buf[LOG_LINE_MAX];
va_list args;
size_t len = log_prefix(buf, sizeof(buf) - 1, level);
va_start(args, fmt);
len = append_vfmt(buf, sizeof(buf) - 1, len, fmt, args);
va_end(args);
log_emit(buf, len);
}
/**
* @brief Writes a hexadecimal dump of a buffer, one or more prefixed lines.
*
* @param level Logging level of the dump.
* @param prefix Text to put right after the line prefix, e.g. a direction marker.
* @param data Data to dump.
* @param length Number of bytes to dump.
*/
void log_hexdump(int level, const char *prefix, const uint8_t *data, int length)
{
char buf[LOG_LINE_MAX];
int i = 0;
if (verbose < level) {
return;
}
// Long dumps are split into several lines, each one with its own prefix
do {
size_t len = log_prefix(buf, sizeof(buf) - 1, level);
len = append_fmt(buf, sizeof(buf) - 1, len, "%s", prefix);
while ((i < length) && (len + 3 < sizeof(buf) - 1)) {
len = append_fmt(buf, sizeof(buf) - 1, len, "%02X ", data[i++]);
}
log_emit(buf, len);
} while (i < length);
}
/**
* @brief Initializes logging.
*
* @param path Path of the log file or NULL to keep logging to stderr.
* @param timestamps_mode 1 to force timestamps on, 0 to force them off,
* negative to enable them only when logging to a file.
*/
void log_init(const char *path, int8_t timestamps_mode)
{
log_timestamps = (timestamps_mode >= 0) ? (timestamps_mode != 0) : (path != NULL);
if (!path) {
return;
}
log_path = strdup(path);
if (!log_path) {
fprintf(stderr, "Out of memory while initializing the log file\n");
exit(EXIT_FAILURE);
}
log_fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_BINARY, LOG_FILE_MODE);
if (log_fd < 0) {
// Logging into nowhere is worse than not starting at all
fprintf(stderr, "Can't open log file '%s' - %s (%d)\n", path, strerror(errno), errno);
exit(EXIT_FAILURE);
}
// Make every log file start with the version, no matter the verbosity level
log_printf(LL_INFO, "Starting %s", version_string());
}
/**
* @brief Reopens the log file, to be called after the log has been rotated.
*/
void log_reopen(void)
{
int fd;
if (!log_path) {
return;
}
fd = open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_BINARY, LOG_FILE_MODE);
if (fd < 0) {
// Keep the old descriptor, losing the log completely would be worse
fprintf(stderr, "Can't reopen log file '%s' - %s (%d)\n", log_path, strerror(errno), errno);
return;
}
if (log_fd >= 0) {
close(log_fd);
}
log_fd = fd;
log_printf(LL_INFO, "Log file reopened, %s", version_string());
}

View File

@@ -0,0 +1,170 @@
#include <stdint.h>
#include <stdio.h>
#include <ctype.h>
#include "wg-obfuscator.h"
#include "masking.h"
#include "masking_handlers.h"
static _Thread_local struct {
int listen_sock;
struct sockaddr_in *sender_addr;
int server_sock;
} g_send_ctx;
static ssize_t send_to_client_cb(uint8_t *buffer, int length) {
return sendto(g_send_ctx.listen_sock, buffer, length, 0, (struct sockaddr *)g_send_ctx.sender_addr, sizeof(*g_send_ctx.sender_addr));
}
static ssize_t send_to_server_cb(uint8_t *buffer, int length) {
return send(g_send_ctx.server_sock, buffer, length, 0);
}
masking_handler_t * get_masking_handler_by_name(const char *name) {
if (!name) return NULL;
for (int i = 0; masking_handlers[i]; ++i) {
char handler_name_lower[MASKING_HANDLER_NAME_LEN];
strncpy(handler_name_lower, masking_handlers[i]->name, sizeof(handler_name_lower) - 1);
handler_name_lower[sizeof(handler_name_lower) - 1] = 0;
for (char *p = handler_name_lower; *p; ++p) *p = tolower((unsigned char)*p);
if (strcmp(name, handler_name_lower) == 0) {
return masking_handlers[i];
}
}
return NULL;
}
void masking_on_handshake_req_from_client(obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *client_addr,
struct sockaddr_in *server_addr) {
if (!client->masking_handler || !client->masking_handler->on_handshake_req) {
return;
}
g_send_ctx.listen_sock = listen_sock;
g_send_ctx.sender_addr = &client->client_addr;
g_send_ctx.server_sock = client->server_sock;
client->masking_handler->on_handshake_req(config, client, DIR_CLIENT_TO_SERVER, client_addr, server_addr, send_to_client_cb, send_to_server_cb);
}
void masking_on_handshake_req_from_server(obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *client_addr,
struct sockaddr_in *server_addr) {
if (!client->masking_handler || !client->masking_handler->on_handshake_req) {
return;
}
g_send_ctx.listen_sock = listen_sock;
g_send_ctx.sender_addr = &client->client_addr;
g_send_ctx.server_sock = client->server_sock;
client->masking_handler->on_handshake_req(config, client, DIR_SERVER_TO_CLIENT, server_addr, client_addr, send_to_server_cb, send_to_client_cb);
}
int masking_unwrap_from_client(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client, // can be NULL!
int listen_sock,
struct sockaddr_in *client_addr,
struct sockaddr_in *server_addr,
masking_handler_t **masking_handler_out) {
g_send_ctx.listen_sock = listen_sock;
g_send_ctx.sender_addr = client_addr;
g_send_ctx.server_sock = client ? client->server_sock : 0;
if (!client && !config->masking_handler_set) {
// Brueteforce detection of masking type if no client entry and no default masking handler
for (int i = 0; masking_handlers[i]; ++i) {
// A handler may move *buffer_ptr while unwrapping. If it does not match
// (returns < 0), restore the pointer so the next handler sees the original data.
uint8_t *saved_buffer = *buffer_ptr;
int r = masking_handlers[i]->on_data_unwrap(buffer_ptr, length, config, NULL, DIR_CLIENT_TO_SERVER, client_addr, server_addr, send_to_client_cb, send_to_server_cb);
if (r < 0) {
*buffer_ptr = saved_buffer;
continue;
}
// Found a matching masking handler
log(LL_TRACE, "Autodetected masking handler for packet from %s:%d: %s", inet_ntoa(client_addr->sin_addr), ntohs(client_addr->sin_port), masking_handlers[i]->name);
if (masking_handler_out) {
*masking_handler_out = masking_handlers[i];
}
return r;
}
// No matching masking handler found
if (masking_handler_out) {
*masking_handler_out = NULL;
}
return length;
}
masking_handler_t *handler = client ? client->masking_handler : config->masking_handler;
if (!handler || !handler->on_data_unwrap) {
return length; // no masking handler, nothing to do
}
return handler->on_data_unwrap(buffer_ptr, length, config, client, DIR_CLIENT_TO_SERVER, client_addr, server_addr, send_to_client_cb, client ? send_to_server_cb : NULL);
}
int masking_unwrap_from_server(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *server_addr) {
if (!client->masking_handler || !client->masking_handler->on_data_unwrap) {
return length; // no masking handler, nothing to do
}
g_send_ctx.listen_sock = listen_sock;
g_send_ctx.sender_addr = &client->client_addr;
g_send_ctx.server_sock = client->server_sock;
return client->masking_handler->on_data_unwrap(buffer_ptr, length, config, client, DIR_SERVER_TO_CLIENT, server_addr, &client->client_addr, send_to_server_cb, send_to_client_cb);
}
int masking_data_wrap_to_client(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *server_addr) {
if (!client->masking_handler || !client->masking_handler->on_data_wrap) {
return length; // no masking handler, nothing to do
}
g_send_ctx.listen_sock = listen_sock;
g_send_ctx.sender_addr = &client->client_addr;
g_send_ctx.server_sock = client->server_sock;
return client->masking_handler->on_data_wrap(buffer_ptr, length, config, client, DIR_SERVER_TO_CLIENT, server_addr, &client->client_addr, send_to_server_cb, send_to_client_cb);
}
int masking_data_wrap_to_server(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *server_addr) {
if (!client->masking_handler || !client->masking_handler->on_data_wrap) {
return length; // no masking handler, nothing to do
}
g_send_ctx.listen_sock = listen_sock;
g_send_ctx.sender_addr = &client->client_addr;
g_send_ctx.server_sock = client->server_sock;
return client->masking_handler->on_data_wrap(buffer_ptr, length, config, client, DIR_CLIENT_TO_SERVER, &client->client_addr, server_addr, send_to_client_cb, send_to_server_cb);
}
void masking_on_timer(obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *server_addr) {
if (!client->masking_handler || !client->masking_handler->on_timer) {
return;
}
g_send_ctx.listen_sock = listen_sock;
g_send_ctx.sender_addr = &client->client_addr;
g_send_ctx.server_sock = client->server_sock;
client->masking_handler->on_timer(config, client, &client->client_addr, server_addr, send_to_client_cb, send_to_server_cb);
}

View File

@@ -0,0 +1,91 @@
#ifndef _MASKING_H_
#define _MASKING_H_
#include <stdint.h>
#include <netinet/in.h>
#include "wg-obfuscator.h"
#define MASKING_HANDLER_NAME_LEN 32
typedef ssize_t (*send_data_callback_t)(uint8_t *buffer, int length);
typedef void (*masking_event_handler_t)(obfuscator_config_t *config,
client_entry_t *client,
direction_t direction,
const struct sockaddr_in *src_addr,
const struct sockaddr_in *dest_addr,
send_data_callback_t send_back_callback,
send_data_callback_t send_forward_callback);
typedef int (*masking_data_handler_t)(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
direction_t direction,
const struct sockaddr_in *src_addr,
const struct sockaddr_in *dest_addr,
send_data_callback_t send_back_callback,
send_data_callback_t send_forward_callback);
typedef void (*masking_timer_handler_t)(obfuscator_config_t *config,
client_entry_t *client,
const struct sockaddr_in *client_addr,
const struct sockaddr_in *server_addr,
send_data_callback_t send_to_client_callback,
send_data_callback_t send_to_server_callback);
struct masking_handler {
char name[MASKING_HANDLER_NAME_LEN];
masking_event_handler_t on_handshake_req;
masking_data_handler_t on_data_wrap;
masking_data_handler_t on_data_unwrap;
masking_timer_handler_t on_timer;
uint32_t timer_interval_s;
};
typedef struct masking_handler masking_handler_t;
masking_handler_t * get_masking_handler_by_name(const char *name);
void masking_on_handshake_req_from_client(obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *client_addr,
struct sockaddr_in *server_addr);
void masking_on_handshake_req_from_server(obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *client_addr,
struct sockaddr_in *server_addr);
int masking_data_wrap_to_client(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *server_addr);
int masking_data_wrap_to_server(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *server_addr);
int masking_unwrap_from_client(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *client_addr,
struct sockaddr_in *server_addr,
masking_handler_t **masking_handler_out);
int masking_unwrap_from_server(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *server_addr);
void masking_on_timer(obfuscator_config_t *config,
client_entry_t *client,
int listen_sock,
struct sockaddr_in *server_addr);
#endif // _MASKING_H_

View File

@@ -0,0 +1,14 @@
#ifndef _MASKING_HANDLERS_H_
#define _MASKING_HANDLERS_H_
#include "masking.h"
/* List of available masking handlers */
#include "masking_stun.h"
static masking_handler_t * const masking_handlers[] = {
&stun_masking_handler,
NULL
};
#endif

View File

@@ -0,0 +1,298 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <assert.h>
#include "wg-obfuscator.h"
#include "masking.h"
#include "masking_stun.h"
// Bytes prepended in front of the payload when wrapping data in a STUN Data Indication
// (20-byte STUN header + 4-byte attribute header). Must fit into PREBUFFER_SIZE so that
// wrapping can be done by shifting the buffer pointer instead of moving the payload.
#define STUN_WRAP_OVERHEAD 24
_Static_assert(STUN_WRAP_OVERHEAD <= PREBUFFER_SIZE,
"PREBUFFER_SIZE is too small to hold the STUN wrap header");
static void rand_bytes(uint8_t* p, size_t n){ for(size_t i=0;i<n;i++) p[i]=rand()&0xFF; }
int stun_check_magic(const uint8_t *buf, size_t len) {
if (!buf || len < 8) return 0;
return !memcmp(buf + 4, COOKIE_BE, 4);
}
uint16_t stun_peek_type(const uint8_t *buf){
return (buf[0] << 8) | buf[1];
}
static int stun_write_header(uint8_t *b, uint16_t type, uint16_t mlen, const uint8_t txid[12]) {
b[0] = type >> 8;
b[1] = type & 0xFF;
b[2] = mlen >> 8;
b[3] = mlen & 0xFF;
memcpy(b + 4, COOKIE_BE, 4);
memcpy(b + 8, txid, 12);
return 20;
}
static int stun_attr_xor_mapped_addr(uint8_t *b, const struct sockaddr_in *src) {
// type,len
b[0] = STUN_ATTR_XORMAPPED >> 8;
b[1] = STUN_ATTR_XORMAPPED & 0xFF;
b[2] = 0;
b[3] = 8; // len=8 (family + port + addr)
b[4] = 0;
b[5] = 0x01; // family IPv4
memcpy(b + 6, &src->sin_port, 2); // network-order bytes
((uint8_t*)b)[6] ^= COOKIE_BE[0];
((uint8_t*)b)[7] ^= COOKIE_BE[1];
memcpy(b + 8, &src->sin_addr.s_addr, 4); // network-order bytes
((uint8_t*)b)[8] ^= COOKIE_BE[0];
((uint8_t*)b)[9] ^= COOKIE_BE[1];
((uint8_t*)b)[10] ^= COOKIE_BE[2];
((uint8_t*)b)[11] ^= COOKIE_BE[3];
return 12; // 4 hdr + 8 val
}
/*
static int stun_attr_software(uint8_t *b, const char *s) {
uint16_t n = (uint16_t)strlen(s);
uint16_t pad = (4 - (n & 3)) & 3;
if (b) {
b[0] = STUN_ATTR_SOFTWARE >> 8;
b[1] = STUN_ATTR_SOFTWARE&0xFF;
b[2] = n>>8;
b[3] = n&0xFF;
memcpy(b+4,s,n);
if (pad) memset(b+4+n,0,pad);
}
return 4 + n + pad;
}
*/
static uint32_t crc32(const uint8_t *p, size_t n) {
uint32_t crc = ~0u;
for(size_t i = 0; i < n; i++){
crc ^= p[i];
for(int k = 0; k < 8; k++)
crc = (crc >> 1) ^ (0xEDB88320u & (-(int)(crc & 1)));
}
return ~crc;
}
static int stun_attr_fingerprint(uint8_t *pkt, size_t cur_len) {
uint8_t *b = pkt + cur_len;
b[0] = STUN_ATTR_FINGERPR >> 8;
b[1] = STUN_ATTR_FINGERPR & 0xFF;
b[2] = 0;
b[3] = 4;
uint32_t fp = htonl(crc32(pkt, cur_len) ^ 0x5354554eu);
memcpy(b + 4, &fp, 4);
return 8; // 4 hdr + 4 val
}
static int stun_build_binding_request(uint8_t *out) {
uint8_t txid[12];
rand_bytes(txid, 12);
stun_write_header(out, STUN_BINDING_REQ, 0, txid);
size_t mlen = 0;
// optional SOFTWARE
//mlen += stun_attr_software(out+20+mlen, "wgo/1.0");
mlen += stun_attr_fingerprint(out, 20 + mlen);
out[2] = (mlen >> 8) & 0xFF;
out[3] = mlen & 0xFF;
return (int)(20 + mlen);
}
static int stun_build_binding_success(uint8_t *out,
const uint8_t txid[12],
const struct sockaddr_in *src) {
if (BUFFER_SIZE < 20 + 12 + 8) return -1; // header + XOR-MAPPED-ADDRESS + fingerprint
stun_write_header(out, STUN_BINDING_RESP, 0, (uint8_t*)txid);
size_t mlen = 0;
mlen += stun_attr_xor_mapped_addr(out + 20 + mlen, src);
// optional SOFTWARE
//mlen += stun_attr_software(out+20+mlen, "wgo/1.0");
mlen += stun_attr_fingerprint(out, 20 + mlen);
out[2] = (mlen >> 8) & 0xFF;
out[3] = mlen & 0xFF;
return (int)(20 + mlen);
}
static int stun_wrap(uint8_t **buf_ptr, size_t data_len) {
size_t mlen = 0;
// The caller guarantees the payload already fits into the main buffer (it was
// received/encoded within BUFFER_SIZE), so the wrapped packet cannot overflow
// the tail of full_buffer. The attribute length field is also 16-bit only.
// Note: BUFFER_SIZE (65535) equals the maximum value of a 16-bit unsigned
// integer (0xFFFF), so this single check covers both the buffer limit and
// the STUN attribute length field limit.
if (data_len > BUFFER_SIZE) {
log(LL_WARN, "Can't wrap data in STUN, data too large (%zu bytes)", data_len);
return -ENOMEM;
}
// Header is prepended into the prebuffer (checked at compile time via _Static_assert).
*buf_ptr -= STUN_WRAP_OVERHEAD;
uint8_t *buf = *buf_ptr;
uint8_t txid[12];
rand_bytes(txid,12);
mlen += stun_write_header(buf, STUN_TYPE_DATA_IND, 0, txid);
buf[mlen] = STUN_ATTR_DATA >> 8;
buf[mlen + 1] = STUN_ATTR_DATA & 0xFF;
buf[mlen + 2] = data_len >> 8;
buf[mlen + 3] = data_len & 0xFF;
return STUN_WRAP_OVERHEAD + data_len;
}
static int stun_unwrap(uint8_t **buf_ptr, size_t len) {
uint8_t *buf = *buf_ptr;
if (len < 20 + 4) return -1; // header + attr
uint16_t msg_type = (buf[0] << 8) | buf[1];
if (msg_type != STUN_TYPE_DATA_IND) return -1;
uint16_t msg_len = (buf[2] << 8) | buf[3];
if (msg_len + 20 > len) return -1;
uint16_t attr_type = (buf[20] << 8) | buf[21];
if (attr_type != STUN_ATTR_DATA) return -1;
uint16_t data_len = (buf[22] << 8) | buf[23];
if (data_len + 24 > len) return -1;
// Move the buffer pointer to avoid moving the data
*buf_ptr += 24;
return data_len;
}
static void stun_on_handshake_req(obfuscator_config_t *config,
client_entry_t *client,
direction_t direction,
const struct sockaddr_in *src_addr,
const struct sockaddr_in *dest_addr,
send_data_callback_t send_back_callback,
send_data_callback_t send_forward_callback) {
uint8_t buffer[128];
int len = stun_build_binding_request(buffer);
if (len < 0) return;
int sent = send_forward_callback(buffer, len);
if (sent < 0) {
serror("Can't send STUN binding request to %s:%d", inet_ntoa(dest_addr->sin_addr), ntohs(dest_addr->sin_port));
} else if (sent != len) {
log(LL_WARN, "Partial send of STUN binding request to %s:%d (%d of %d bytes)", inet_ntoa(dest_addr->sin_addr), ntohs(dest_addr->sin_port), sent, len);
} else {
log(LL_TRACE, "Sent STUN binding request (%d bytes) to %s:%d", len, inet_ntoa(dest_addr->sin_addr), ntohs(dest_addr->sin_port));
}
}
static int stun_on_data_unwrap(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
direction_t direction,
const struct sockaddr_in *src_addr,
const struct sockaddr_in *dest_addr,
send_data_callback_t send_back_callback,
send_data_callback_t send_forward_callback) {
if (!stun_check_magic(*buffer_ptr, length)) {
return -EINVAL;
}
uint16_t stun_type = stun_peek_type(*buffer_ptr);
switch (stun_type) {
case STUN_BINDING_REQ: {
// Received STUN Binding Request from client, send Binding Success Response
log(LL_TRACE, "Received STUN Binding Request from %s:%d", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
uint8_t txid[12];
memcpy(txid, (*buffer_ptr) + 8, 12);
int resp_len = stun_build_binding_success(*buffer_ptr, txid, src_addr);
if (resp_len > 0) {
int sent = send_back_callback(*buffer_ptr, resp_len);
if (sent < 0) {
serror("sendto STUN response to %s:%d", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
} else if (sent != resp_len) {
log(LL_WARN, "Partial send of STUN Binding Success Response to %s:%d (%d of %d bytes)", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port), sent, resp_len);
} else {
log(LL_TRACE, "Sent STUN Binding Success Response (%d bytes) to %s:%d", resp_len, inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
}
} else {
log(LL_ERROR, "Failed to build STUN Binding Success Response");
}
return 0;
}
case STUN_BINDING_RESP:
log(LL_TRACE, "Received STUN Binding Success Response from %s:%d, ignoring", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
return 0;
case STUN_TYPE_DATA_IND:
length = stun_unwrap(buffer_ptr, length);
if (length < 0) {
log(LL_DEBUG, "Failed to unwrap STUN Data Indication from %s:%d", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
return length;
}
log(LL_TRACE, "Unwrapped STUN Data Indication from %s:%d (%d bytes)", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port), length);
return length;
default:
log(LL_DEBUG, "Received unknown STUN type %04X from %s:%d, ignoring", stun_type, inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
return 0;
}
}
static int stun_on_data_wrap(uint8_t **buffer_ptr, int length,
obfuscator_config_t *config,
client_entry_t *client,
direction_t direction,
const struct sockaddr_in *src_addr,
const struct sockaddr_in *dest_addr,
send_data_callback_t send_back_callback,
send_data_callback_t send_forward_callback) {
return stun_wrap(buffer_ptr, length);
}
static void stun_on_timer(obfuscator_config_t *config,
client_entry_t *client,
const struct sockaddr_in *client_addr,
const struct sockaddr_in *server_addr,
send_data_callback_t send_to_client_callback,
send_data_callback_t send_to_server_callback) {
uint8_t buffer[128];
int len = stun_build_binding_request(buffer);
if (len < 0) return;
if (client->client_obfuscated) {
int sent = send_to_client_callback(buffer, len);
if (sent < 0) {
serror("STUN binding request to client");
} else if (sent != len) {
log(LL_WARN, "Partial send of STUN binding request to client (%d of %d bytes)", sent, len);
} else {
log(LL_TRACE, "Sent STUN binding request (%d bytes) to %s:%d", len, inet_ntoa(client_addr->sin_addr), ntohs(client_addr->sin_port));
}
}
if (client->server_obfuscated) {
int sent = send_to_server_callback(buffer, len);
if (sent < 0) {
serror("STUN binding request to server");
} else if (sent != len) {
log(LL_WARN, "Partial send of STUN binding request to server (%d of %d bytes)", sent, len);
} else {
log(LL_TRACE, "Sent STUN binding request (%d bytes) to %s:%d", len, inet_ntoa(server_addr->sin_addr), ntohs(server_addr->sin_port));
}
}
}
masking_handler_t stun_masking_handler = {
.name = "STUN",
.on_handshake_req = stun_on_handshake_req,
.on_data_wrap = stun_on_data_wrap,
.on_data_unwrap = stun_on_data_unwrap,
.on_timer = stun_on_timer,
.timer_interval_s = 10, // 10 seconds
};

View File

@@ -0,0 +1,19 @@
#ifndef _MASKING_STUN_H_
#define _MASKING_STUN_H_
#include <stdint.h>
#include <netinet/in.h>
#include "wg-obfuscator.h"
static const uint8_t COOKIE_BE[4] = {0x21,0x12,0xA4,0x42};
#define STUN_TYPE_DATA_IND 0x0115
#define STUN_BINDING_REQ 0x0001
#define STUN_BINDING_RESP 0x0101
#define STUN_ATTR_XORMAPPED 0x0020
#define STUN_ATTR_SOFTWARE 0x8022
#define STUN_ATTR_FINGERPR 0x8028
#define STUN_ATTR_DATA 0x0013
extern masking_handler_t stun_masking_handler;
#endif // _MASKING_STUN_H_

View File

@@ -0,0 +1,82 @@
/* mini_argp.h
* Tiny callback-based CLI parser */
#ifndef _MINI_ARGP_H_
#define _MINI_ARGP_H_
#include <stdio.h>
#include <string.h>
typedef int (*mini_argp_cb)(const char *lname, char sname,
const char *val, void *ctx);
typedef struct {
const char *long_name; /* "--target" or NULL */
char short_name; /* 't' or 0 */
int has_arg; /* 0 flag, 1 needs val */
mini_argp_cb cb; /* your handler */
} mini_argp_opt;
/* internal: find desc by name */
static const mini_argp_opt *
margp_find(const mini_argp_opt *o, char *lname, char sname)
{
for (; o->long_name || o->short_name; o++)
if ((lname && o->long_name && !strcmp(lname,o->long_name)) ||
(sname && o->short_name==sname))
return o;
return NULL;
}
static int mini_argp_parse(int argc, char **argv,
const mini_argp_opt *opts,
void *ctx,
mini_argp_cb cb)
{
for (int i = 1; i < argc; ++i) {
char *arg = argv[i];
/* long option: --foo or --foo=bar */
if (!strncmp(arg, "--", 2)) {
char *name = arg + 2, *val = NULL;
if ((val = strchr(name, '='))) { *val = 0; ++val; }
const mini_argp_opt *o = margp_find(opts, name, 0);
if (!o) { fprintf(stderr,"unknown --%s\n", name); return -1; }
if (o->has_arg && !val) {
if (++i == argc) { fprintf(stderr,"--%s needs value\n",name);return -1; }
val = argv[i];
}
if (!o->has_arg) val = NULL;
mini_argp_cb to_call = o->cb ? o->cb : cb;
if (to_call(o->long_name, o->short_name, val, ctx)) return -1;
}
/* short(s): -a, -fVAL, -xzvf */
else if (arg[0]=='-' && arg[1]) {
for (size_t pos=1; arg[pos]; ++pos) {
char c = arg[pos];
const mini_argp_opt *o = margp_find(opts,NULL,c);
if (!o){ fprintf(stderr,"unknown -%c\n",c); return -1;}
const char *val = NULL;
if (o->has_arg) {
if (arg[pos+1]) val = &arg[pos+1]; /* -fVAL */
else if (++i < argc) val = argv[i]; /* -f VAL */
else { fprintf(stderr,"-%c needs value\n",c); return -1;}
/* Stop further char-scanning: the rest of the arg is the value */
pos = strlen(arg)-1;
}
mini_argp_cb to_call = o->cb ? o->cb : cb;
if (to_call(o->long_name, o->short_name, val, ctx)) return -1;
}
}
/* positional not allowed */
else {
fprintf(stderr,"unexpected arg: %s\n",arg); return -1;
}
}
return 0;
}
#endif/* _MINI_ARGP_H_ */

View File

@@ -0,0 +1,130 @@
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "wg-obfuscator.h"
#include "obfuscation.h"
static uint8_t crc_a[256]; // step(crc, 0): contribution of the current CRC state
static uint8_t crc_b[256]; // step(0, inbyte): contribution of the input byte
static uint8_t tables_ready = 0;
typedef struct {
uint8_t *data; // cached keystream bytes, NULL until first allocation
int valid; // number of valid bytes in data
int cap; // allocated capacity of data
uint8_t crc; // CRC state right after the 'valid' bytes
} keystream_row_t;
static keystream_row_t rows[256];
// Single step of the original bit-by-bit CRC8 update, kept only to build the tables.
static uint8_t crc8_step(uint8_t crc, uint8_t inbyte) {
for (uint8_t j = 0; j < 8; j++) {
uint8_t mix = (crc ^ inbyte) & 0x01;
crc >>= 1;
if (mix) {
crc ^= 0x8C;
}
inbyte >>= 1;
}
return crc;
}
static void ensure_tables(void) {
if (tables_ready) {
return;
}
for (int i = 0; i < 256; i++) {
crc_a[i] = crc8_step((uint8_t)i, 0);
crc_b[i] = crc8_step(0, (uint8_t)i);
}
tables_ready = 1;
}
// Ensure the given row holds at least 'need' valid keystream bytes (capped at
// KEYSTREAM_ROW_MAX). On allocation failure the row is left as large as it could
// grow; the caller then computes the missing tail on the fly.
static void grow_row(keystream_row_t *row, int cls, int need, const char *key, int key_length) {
if (need > KEYSTREAM_ROW_MAX) {
need = KEYSTREAM_ROW_MAX;
}
if (row->valid >= need) {
return;
}
if (row->cap < need) {
int newcap = (need + 255) & ~255; // round up to a 256-byte chunk
if (newcap > KEYSTREAM_ROW_MAX) {
newcap = KEYSTREAM_ROW_MAX;
}
uint8_t *p = realloc(row->data, newcap);
if (!p) {
return; // keep whatever we already have; tail handled by caller
}
row->data = p;
row->cap = newcap;
}
uint8_t crc = row->crc;
int ki = row->valid % key_length;
for (int i = row->valid; i < need; i++) {
uint8_t inbyte = (uint8_t)(key[ki] + cls + key_length);
crc = crc_a[crc] ^ crc_b[inbyte];
row->data[i] = crc;
if (++ki == key_length) {
ki = 0;
}
}
row->valid = need;
row->crc = crc;
}
// XOR 'n' keystream bytes into dst, 8 bytes at a time. memcpy avoids both
// strict-aliasing violations and unaligned-access assumptions.
static void xor_block(uint8_t *dst, const uint8_t *ks, int n) {
int i = 0;
for (; i + 8 <= n; i += 8) {
uint64_t a, b;
memcpy(&a, dst + i, 8);
memcpy(&b, ks + i, 8);
a ^= b;
memcpy(dst + i, &a, 8);
}
for (; i < n; i++) {
dst[i] ^= ks[i];
}
}
void xor_data(uint8_t *buffer, int length, char *key, int key_length) {
if (length <= 0) {
return;
}
ensure_tables();
int cls = length & 0xFF;
keystream_row_t *row = &rows[cls];
int cached = (length <= KEYSTREAM_ROW_MAX) ? length : KEYSTREAM_ROW_MAX;
if (row->valid < cached) {
grow_row(row, cls, cached, key, key_length);
}
int n = (length < row->valid) ? length : row->valid;
if (n > 0) {
xor_block(buffer, row->data, n);
}
// Tail beyond the cache limit (or beyond what we could allocate): compute on
// the fly, continuing the CRC chain from the last cached state.
if (n < length) {
uint8_t crc = row->crc;
int ki = n % key_length;
for (int i = n; i < length; i++) {
uint8_t inbyte = (uint8_t)(key[ki] + cls + key_length);
crc = crc_a[crc] ^ crc_b[inbyte];
buffer[i] ^= crc;
if (++ki == key_length) {
ki = 0;
}
}
}
}

View File

@@ -0,0 +1,139 @@
#ifndef _OBFUSCATION_H_
#define _OBFUSCATION_H_
#include <stdint.h>
// Current obfuscation version
#define OBFUSCATION_VERSION 1
// Maximum length (in bytes) of a single cached keystream row.
// The keystream depends only on (key, length mod 256), so it is cached in 256
// lazily-grown rows. Packets longer than this limit are still handled correctly:
// the part beyond the limit is computed on the fly. Worst-case cache memory is
// 256 * KEYSTREAM_ROW_MAX bytes. 2048 covers a typical MTU plus masking overhead.
#define KEYSTREAM_ROW_MAX 2048
// WireGuard packet types
#define WG_TYPE_HANDSHAKE 0x01
#define WG_TYPE_HANDSHAKE_RESP 0x02
#define WG_TYPE_COOKIE 0x03
#define WG_TYPE_DATA 0x04
#define WG_TYPE(data) ((uint32_t)(data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24)))
#ifndef MIN
#define MIN(a, b) ((a) < (b) ? (a) : (b))
#endif
/**
* Checks if the given data is obfuscated.
*
* @param data Pointer to the data buffer to check.
* @return uint8_t Returns a non-zero value if the data is obfuscated, 0 otherwise.
*/
static inline uint8_t is_obfuscated(uint8_t *data) {
uint32_t packet_type = WG_TYPE(data);
return !(packet_type >= 1 && packet_type <= 4);
}
/**
* @brief XORs the data in the given buffer with the key-derived keystream.
*
* The keystream is a per-byte CRC8 value derived from the key, the byte position
* and the packet length; it depends only on (key, length mod 256), so it is
* cached (see obfuscation.c). The on-the-wire result is byte-for-byte identical
* to the original bit-by-bit implementation.
*
* @param buffer Pointer to the data buffer to be XORed.
* @param length Length of the data buffer in bytes.
* @param key Pointer to the key used for XOR operation.
* @param key_length Length of the key in bytes.
*/
void xor_data(uint8_t *buffer, int length, char *key, int key_length);
/**
* @brief Encodes the given buffer using the specified key and version.
*
* This function applies an encoding algorithm to the input buffer using the provided key and version.
* WARNING: buffer must be at least 4 bytes long and aligned to 4 bytes.
*
* @param buffer Pointer to the data buffer to encode.
* @param length Length of the data buffer in bytes.
* @param key Pointer to the key used for encoding.
* @param key_length Length of the key in bytes.
* @param version Encoding version to use.
* @param max_dummy_length_data Maximum length of dummy data for data packets.
* @return 0 on success, or a negative value on error.
*/
static inline int encode(uint8_t *buffer, int length, char *key, int key_length, uint8_t version, int max_dummy_length_data) {
if (version >= 1) {
uint32_t packet_type = WG_TYPE(buffer);
// Add some randomness to the packet
uint8_t rnd = 1 + (rand() % 255);
buffer[0] ^= rnd; // Xor the first byte to a random value
buffer[1] = rnd; // Set the second byte to a random value
// Add dummy data to the packet
if (length < MAX_DUMMY_LENGTH_TOTAL) {
uint16_t dummy_length = 0;
uint16_t max_dummy_length = MAX_DUMMY_LENGTH_TOTAL - length;
switch (packet_type) {
case WG_TYPE_HANDSHAKE:
case WG_TYPE_HANDSHAKE_RESP:
// length to MAX_DUMMY_LENGTH_HANDSHAKE
dummy_length = rand() % MIN(max_dummy_length, MAX_DUMMY_LENGTH_HANDSHAKE);
break;
case WG_TYPE_COOKIE:
case WG_TYPE_DATA:
// length to MAX_DUMMY_LENGTH_HANDSHAKE
if (max_dummy_length_data) {
dummy_length = rand() % MIN(max_dummy_length, max_dummy_length_data);
}
break;
default:
//assert(0);
break;
}
buffer[2] = dummy_length & 0xFF; // Set the dummy length in the packet
buffer[3] = dummy_length >> 8; // Set the dummy length in
if (dummy_length > 0) {
int i = length;
length += dummy_length;
for (; i < length; ++i) {
buffer[i] = 0xFF; // Fill with FFs, random data is not needed
}
}
}
}
xor_data(buffer, length, key, key_length);
return length;
}
/**
* Decodes the given buffer using the provided key.
*
* WARNING: buffer must be at least 4 bytes long and aligned to 4 bytes.
*
* @param buffer Pointer to the input buffer to decode.
* @param length Length of the input buffer.
* @param key Pointer to the key used for decoding.
* @param key_length Length of the key.
* @param version_out Pointer to a variable where the decoded version will be stored.
* @return Length of the decoded data (smaller than or equal to the input length).
*/
static inline int decode(uint8_t *buffer, int length, char *key, int key_length, uint8_t *version_out) {
xor_data(buffer, length, key, key_length);
if (!is_obfuscated(buffer)) {
// Looks like an old version
*version_out = 0;
return length;
}
buffer[0] ^= buffer[1]; // Restore the first byte by XORing it with the second byte
length -= (uint16_t)(buffer[2] | (buffer[3] << 8)); // Remove dummy data length from the packet
buffer[1] = buffer[2] = buffer[3] = 0; // Reset the dummy length field to 0
return length;
}
#endif // _OBFUSCATION_H_

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
#ifndef _WG_OBFUSCATOR_H_
#define _WG_OBFUSCATOR_H_
#include <arpa/inet.h>
#include <errno.h>
#include <stdint.h>
#include <sys/types.h>
#include "uthash.h"
// on Linux, use epoll for better performance
#ifdef __linux__
#define USE_EPOLL
#endif
#ifdef USE_EPOLL
#include <sys/epoll.h>
#define MAX_EVENTS 1024
#else
#include <poll.h>
#endif
#define WG_OBFUSCATOR_VERSION "1.6"
#define WG_OBFUSCATOR_GIT_REPO "https://github.com/ClusterM/wg-obfuscator"
#define LL_DEFAULT LL_INFO // default logging level
// Main parameters
// TODO: make these configurable via command line arguments or config file
#define BUFFER_SIZE 65535 // size of the buffer for receiving data from the clients and server
#define PREBUFFER_SIZE 1024 // size of the additional buffer size before the main buffer
#define POLL_TIMEOUT 5000 // in milliseconds
#define HANDSHAKE_TIMEOUT 5000 // in milliseconds
#define ITERATE_INTERVAL 1000 // in milliseconds
#define MAX_DUMMY_LENGTH_TOTAL 1024 // maximum length of a packet after dummy data extension
#define MAX_DUMMY_LENGTH_HANDSHAKE 512 // maximum length of dummy data for handshake packets
#define MAX_CLIENTS_DEFAULT 1024 // maximum number of clients
#define IDLE_TIMEOUT_DEFAULT 300000 // in milliseconds
#define IN_TIMEOUT_DEFAULT 0 // in milliseconds
#define MAX_DUMMY_LENGTH_DATA_DEFAULT 4 // maximum length of dummy data for data packets
// Default instance name
#define DEFAULT_INSTANCE_NAME "main"
// Logging levels
#define LL_ERROR 0
#define LL_WARN 1
#define LL_INFO 2
#define LL_DEBUG 3
#define LL_TRACE 4
// Maximum length of a single log line, longer hex dumps are split into several lines
#define LOG_LINE_MAX 16384
#define log(level, fmt, ...) do { if (verbose >= (level)) log_printf((level), fmt, ##__VA_ARGS__); } while (0)
#define serror_level(level, fmt, ...) log(level, fmt " - %s (%d)", ##__VA_ARGS__, strerror(errno), errno)
#define serror(fmt, ...) serror_level(LL_ERROR, fmt, ##__VA_ARGS__)
// Direction of... something
typedef enum {
DIR_CLIENT_TO_SERVER = 0,
DIR_SERVER_TO_CLIENT = 1,
} direction_t;
struct masking_handler; // forward declaration
typedef struct masking_handler masking_handler_t;
// Structure to hold obfuscator configuration
typedef struct {
int listen_port; // Listening port for the obfuscator
char forward_host_port[256]; // Host and port to forward the data to
char xor_key[256]; // Key for obfuscation
char client_interface[256]; // Client interface as a string
char *static_bindings; // Static bindings as a string
int max_clients; // Maximum number of clients
long idle_timeout; // Idle timeout in milliseconds
long in_timeout; // Incoming timeout in milliseconds
int max_dummy_length_data; // Maximum length of dummy data for data packets
uint32_t fwmark; // Firewall mark
masking_handler_t *masking_handler; // Masking handler to use
uint8_t allow_clean; // 1 if non-obfuscated (clean) clients are allowed, 0 otherwise
char log_file[512]; // Path of the log file
int8_t log_timestamps; // 1 to force timestamps on, 0 to force them off, -1 for auto
long resolve_interval; // Hostname re-resolve interval in milliseconds, 0 to disable periodic refresh
uint8_t log_file_set; // 1 if the log file is set, 0 otherwise
uint8_t listen_port_set; // 1 if the listen port is set, 0 otherwise
uint8_t forward_host_port_set; // 1 if the forward host and port are set, 0 otherwise
uint8_t xor_key_set; // 1 if the XOR key is set, 0 otherwise
uint8_t client_interface_set; // 1 if the client interface is set, 0 otherwise
uint8_t static_bindings_set; // 1 if the static bindings are set, 0 otherwise
uint8_t masking_handler_set; // 1 if the masking handler is set, 0 otherwise
} obfuscator_config_t;
// Structure to hold client connection information
typedef struct {
struct sockaddr_in client_addr; // client address and port (key for the hash table)
struct sockaddr_in our_addr; // our address and port on the server connection
long last_activity_time; // last time we received data from/to this client
long last_incoming_time; // last time we received data from the remote server
long last_handshake_request_time; // last time we received a handshake request from/to this client
long last_handshake_time; // last time we received a handshake response from/to this client
long last_masking_timer_time; // last time we called the masking timer handler for this client
int server_sock; // socket for the connection to the server
uint8_t version; // obfuscation version
masking_handler_t *masking_handler; // masking handler in use
uint8_t handshaked : 1; // 1 if the handshake is complete, 0 otherwise
uint8_t handshake_direction : 1; // 1 if the handshake is from client to server, 0 if from server to client
uint8_t client_obfuscated : 1; // 1 if the client is obfuscated, 0 otherwise
uint8_t server_obfuscated : 1; // 1 if the server is obfuscated, 0 otherwise
uint8_t client_clean : 1; // 1 if the client speaks plain (non-obfuscated) WireGuard, traffic is passed through as is (allow-clean mode)
uint8_t is_static : 1; // 1 if this is a static binding entry, 0 otherwise
char bind_host[256]; // Original hostname of a static binding, empty if the address is a literal or the entry is dynamic
UT_hash_handle hh;
} client_entry_t;
// Verbosity level
extern int verbose;
// Section name (for multiple instances)
extern char section_name[256];
const char *version_string(void);
void print_version(void);
void register_child_instance(pid_t pid);
void log_init(const char *path, int8_t timestamps_mode);
void log_reopen(void);
#ifdef __GNUC__
void log_printf(int level, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
#else
void log_printf(int level, const char *fmt, ...);
#endif
void log_hexdump(int level, const char *prefix, const uint8_t *data, int length);
#endif

141
ios/Tunnel/pcarelay.c Normal file
View File

@@ -0,0 +1,141 @@
/*
* pcarelay — in-process transport switch for the iOS packet tunnel.
* wg-obfuscator (one instance, iOS forbids fork) sends to 127.0.0.1:<local>;
* we forward either over UDP to the server's obfuscator port or over TCP to
* the server's udptcp (frames: 2-byte length + packet). Switch with pcarelay_use_tcp().
* Mode 2 ("Облако"): packets go to a Swift callback (WebSocket to the user's Keenetic), answers come back via pcarelay_ws_in().
*/
#include "pcarelay.h"
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
static atomic_int use_tcp = 0; /* 0 udp, 1 tcp, 2 ws */
static pcarelay_ws_out_fn ws_out;
static int g_local = -1;
static pthread_mutex_t peer_mu = PTHREAD_MUTEX_INITIALIZER;
static struct sockaddr_storage g_peer;
static socklen_t g_pl;
static atomic_ullong rx_bytes = 0;
static char g_host[256], g_udp_port[8], g_tcp_port[8];
static int g_local_port;
static int dial(int socktype, const char *port) {
struct addrinfo hints = {0}, *res, *r;
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = socktype;
if (getaddrinfo(g_host, port, &hints, &res) != 0) return -1;
int s = -1;
for (r = res; r; r = r->ai_next) {
s = socket(r->ai_family, r->ai_socktype, 0);
if (s < 0) continue;
if (connect(s, r->ai_addr, r->ai_addrlen) == 0) break;
close(s); s = -1;
}
freeaddrinfo(res);
if (s >= 0 && socktype == SOCK_STREAM) {
int one = 1;
setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof one);
setsockopt(s, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof one);
}
return s;
}
static int read_full(int fd, unsigned char *b, size_t n) {
size_t got = 0;
while (got < n) { ssize_t r = read(fd, b + got, n - got); if (r <= 0) return -1; got += (size_t)r; }
return 0;
}
static void *loop(void *arg) {
(void)arg;
struct sockaddr_in la = {0};
la.sin_family = AF_INET;
la.sin_port = htons((uint16_t)g_local_port);
la.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int local = socket(AF_INET, SOCK_DGRAM, 0);
if (local < 0 || bind(local, (struct sockaddr *)&la, sizeof la) < 0) return NULL;
g_local = local;
int udp = -1, tcp = -1;
struct sockaddr_storage peer; socklen_t pl = 0;
unsigned char buf[65537];
time_t last_dial = 0;
for (;;) {
int mode = atomic_load(&use_tcp), want_tcp = mode == 1;
if (mode == 2) { if (udp >= 0) { close(udp); udp = -1; } if (tcp >= 0) { close(tcp); tcp = -1; } }
if (!want_tcp && tcp >= 0) { close(tcp); tcp = -1; }
if (want_tcp && udp >= 0) { close(udp); udp = -1; }
if (mode == 0 && udp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); udp = dial(SOCK_DGRAM, g_udp_port); }
int up = want_tcp ? tcp : udp;
struct pollfd p[2] = {{local, POLLIN, 0}, {up, POLLIN, 0}};
poll(p, up >= 0 ? 2 : 1, 1000);
if (p[0].revents & POLLIN) {
pl = sizeof peer;
ssize_t n = recvfrom(local, buf, 65535, 0, (struct sockaddr *)&peer, &pl);
if (n <= 0) continue;
pthread_mutex_lock(&peer_mu); g_peer = peer; g_pl = pl; pthread_mutex_unlock(&peer_mu);
if (mode == 2) {
if (ws_out) ws_out(buf, (int)n);
} else if (want_tcp) {
if (tcp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); tcp = dial(SOCK_STREAM, g_tcp_port); }
if (tcp >= 0) {
unsigned char h[2] = {(unsigned char)(n >> 8), (unsigned char)n};
if (send(tcp, h, 2, 0) != 2 || send(tcp, buf, (size_t)n, 0) != n) { close(tcp); tcp = -1; }
}
} else if (udp >= 0) {
send(udp, buf, (size_t)n, 0);
}
}
if (up >= 0 && (p[1].revents & (POLLIN | POLLHUP | POLLERR))) {
ssize_t n;
if (want_tcp) {
unsigned char h[2];
if (read_full(tcp, h, 2) < 0) { close(tcp); tcp = -1; continue; }
n = (h[0] << 8) | h[1];
if (n == 0 || read_full(tcp, buf, (size_t)n) < 0) { close(tcp); tcp = -1; continue; }
} else {
n = recv(udp, buf, 65535, 0);
if (n <= 0) { close(udp); udp = -1; continue; }
}
atomic_fetch_add(&rx_bytes, (unsigned long long)n);
if (pl) sendto(local, buf, (size_t)n, 0, (struct sockaddr *)&peer, pl);
}
}
}
int pcarelay_start(int local_port, const char *host, int udp_port, int tcp_port) {
g_local_port = local_port;
snprintf(g_host, sizeof g_host, "%s", host);
snprintf(g_udp_port, sizeof g_udp_port, "%d", udp_port);
snprintf(g_tcp_port, sizeof g_tcp_port, "%d", tcp_port);
pthread_t t;
if (pthread_create(&t, NULL, loop, NULL) != 0) return -1;
pthread_detach(t);
return 0;
}
void pcarelay_use_tcp(int on) { atomic_store(&use_tcp, on ? 1 : 0); }
int pcarelay_is_tcp(void) { return atomic_load(&use_tcp) == 1; }
void pcarelay_use_ws(pcarelay_ws_out_fn out) { ws_out = out; atomic_store(&use_tcp, 2); }
int pcarelay_is_ws(void) { return atomic_load(&use_tcp) == 2; }
void pcarelay_ws_in(const unsigned char *p, int n) {
if (g_local < 0 || n <= 0) return;
atomic_fetch_add(&rx_bytes, (unsigned long long)n);
pthread_mutex_lock(&peer_mu);
struct sockaddr_storage peer = g_peer; socklen_t pl = g_pl;
pthread_mutex_unlock(&peer_mu);
if (pl) sendto(g_local, p, (size_t)n, 0, (struct sockaddr *)&peer, pl);
}
unsigned long long pcarelay_rx_bytes(void) { return atomic_load(&rx_bytes); }

21
ios/Tunnel/pcarelay.h Normal file
View File

@@ -0,0 +1,21 @@
#ifndef PCARELAY_H
#define PCARELAY_H
/* Start the transport switch thread: obfuscator -> 127.0.0.1:local_port -> host via UDP or TCP. */
int pcarelay_start(int local_port, const char *host, int udp_port, int tcp_port);
void pcarelay_use_tcp(int on);
int pcarelay_is_tcp(void);
/* Bytes received back from the server on any path (auto-detects a dead UDP). */
unsigned long long pcarelay_rx_bytes(void);
/* Mode "Облако": each packet from the obfuscator goes to out() (Swift sends it over WebSocket);
packets from the WebSocket are handed back with pcarelay_ws_in(). */
typedef void (*pcarelay_ws_out_fn)(const unsigned char *p, int n);
void pcarelay_use_ws(pcarelay_ws_out_fn out);
int pcarelay_is_ws(void);
void pcarelay_ws_in(const unsigned char *p, int n);
/* wg-obfuscator compiled in with -Dmain=wgobf_main (one instance per process). */
int wgobf_main(int argc, char **argv);
#endif

2
ios/Tunnel/ru_bypass.txt Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

19
ios/WireGuardKit/COPYING Normal file
View File

@@ -0,0 +1,19 @@
Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,15 @@
// swift-tools-version:5.5
// Vendored WireGuardKit (github.com/WireGuard/wireguard-apple, MIT). libwg-go.a is
// prebuilt in ../WireGuardGo/<sdk>/ — the app target adds it to LIBRARY_SEARCH_PATHS.
import PackageDescription
let package = Package(
name: "WireGuardKit",
platforms: [.iOS(.v15)],
products: [.library(name: "WireGuardKit", targets: ["WireGuardKit"])],
targets: [
.target(name: "WireGuardKit", dependencies: ["WireGuardKitGo", "WireGuardKitC"]),
.target(name: "WireGuardKitC", publicHeadersPath: "."),
.target(name: "WireGuardKitGo", publicHeadersPath: ".", linkerSettings: [.linkedLibrary("wg-go")]),
]
)

View File

@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
extension Array {
/// Returns an array containing the results of mapping the given closure over the sequence’s
/// elements concurrently.
///
/// - Parameters:
/// - queue: The queue for performing concurrent computations.
/// If the given queue is serial, the values are mapped in a serial fashion.
/// Pass `nil` to perform computations on the current queue.
/// - transform: the block to perform concurrent computations over the given element.
/// - Returns: an array of concurrently computed values.
func concurrentMap<U>(queue: DispatchQueue?, _ transform: (Element) -> U) -> [U] {
var result = [U?](repeating: nil, count: self.count)
let resultQueue = DispatchQueue(label: "ConcurrentMapQueue")
let execute = queue?.sync ?? { $0() }
execute {
DispatchQueue.concurrentPerform(iterations: self.count) { index in
let value = transform(self[index])
resultQueue.sync {
result[index] = value
}
}
}
return result.map { $0! }
}
}

Some files were not shown because too many files have changed in this diff Show More