1 Commits
beta ... dev

Author SHA1 Message Date
PCA
f9a3a27fca channel dev is token-only now: private repo PCA_Phobos-dev (stub)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 03:49:13 +03:00
150 changed files with 9 additions and 30382 deletions

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
custom:
- "https://boosty.to/andrey27/donate"
- "https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798"

3
.gitignore vendored
View File

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

10
BETA.md
View File

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

View File

@@ -1,137 +0,0 @@
# 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.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.

240
README.md
View File

@@ -1,235 +1,11 @@
# PCA Phobos — VPN, который не отключается
# PCA Phobos — канал dev закрыт
**Веб-панель и turnkey-установщик для [Phobos](https://github.com/Ground-Zerro/Phobos)** — обфусцированного WireGuard VPN. Ставится на чистый VPS одной командой, управляет десятками роутеров и устройств с одной страницы, сам переключается между серверами при сбоях и восстанавливается после перезагрузок.
Каналы **beta** и **dev** выдаются по токену подписчика (read-only токен Gitea) и лежат в приватном репозитории.
Здесь их нет — публичная только стабильная версия (`main`, теги `v*`).
<p align="center">
<img src="docs/img/sessions.png" alt="Панель — активные сессии" width="850">
</p>
Как подключить:
1. Обновись до стабильной 1.4.5 или новее: `phobos-update stable`
2. Вставь токен: панель → Настройки → Версия и обновления → «токен подписчика» → Сохранить токен
3. `phobos-update dev` (или кнопка dev в панели)
---
## Что это простыми словами
Обычный VPN падает, когда падает сервер или провайдер банит IP. **PCA Phobos — нет.**
- 🌐 **Маскированный WireGuard** — трафик не отличить от обычного, проходит мимо DPI и блокировок по сигнатуре.
- 🔁 **Авто-переключение между серверами** — сервер лёг или порт прикрыли → роутер сам прыгает на резервный за ~10–15 секунд, без твоего участия.
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
- 📱 **Телефоны** — своё приложение **PCA Phobos VPN** для Android (обфускация + раздельный трафик по приложениям), PhobosWG, iPhone (обычный WireGuard) — через QR/ссылку.
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
---
## Возможности
- **Активные сессии** — VPN IP, Real IP, метка, handshake, трафик RX/TX, Kick.
- **Клиенты VPN** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера.
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM + пропускная способность канала), резервные серверы; роутеры переключаются автоматически.
- **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP.
- **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS.
- **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR.
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия. **Светлая и тёмная тема** (кнопка ◐), компактная вёрстка — удобно с телефона.
- **Лимит скорости на клиента** — Мбит/с в карточке клиента; работает на всех серверах, сам восстанавливается после перезапуска WireGuard.
- **Замер скорости** — сервера (реальная скачка/отдача, кнопка ⚡ на «Серверах»), клиента (живая скорость в карточке + страница `http://10.25.0.1:<порт>/speedtest` на устройстве через VPN).
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
- **Версии и обновления** — обновление и откат прямо из панели.
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
- **Telegram-бот** — управление клиентами командами прямо из чата:
- `/add <имя>` — создать клиента (бот пришлёт команду установки)
- `/del <имя>` — удалить клиента
- `/list` — список клиентов
- **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь.
(Работает только для чата из настроек `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://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://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
```
| Переменная | По умолчанию | Описание |
|-------------------|-----------------|----------|
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
| `PANEL_PORT` | случайный | Порт веб-панели |
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
| `ALLOW_PLAIN_WG` | пусто | `=1` открыть порт 51820 для iOS WireGuard (без обфускации) |
| `PANEL_TLS_CERT` / `PANEL_TLS_KEY` | пусто | Включить HTTPS для панели через свой сертификат |
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
### Вторичный сервер (для failover / балансировки)
```bash
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.2/PCA-Phobos-VPN.apk) · исходники `android-client/`.
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.2/PCA-Phobos.apk) · исходники `android/`.
## Клиенты для телефона
В таблице **Клиенты** у каждого клиента есть кнопки:
- 📱 **Android** — `phobos://`-ссылка + QR для приложения **PhobosWG** (с обфускацией). Скан QR → импорт.
- 🍎 **iPhone / iOS** — обычный WireGuard-конфиг + QR для официального WireGuard (**без** обфускации). Требует открытого порта 51820 (`ALLOW_PLAIN_WG=1` при установке).
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
**Приложения:**
- **Android (PhobosWG):** кнопка «Скачать приложение PhobosWG (APK)» на странице Android-клиента. Можно положить свой `.apk` в `/opt/Phobos/www/app/PhobosWG.apk` — тогда панель раздаёт его.
- **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента.
---
## Обновление и откат
Проект версионируется (см. файл `VERSION` и git-теги `vX.Y.Z`).
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**.
**Из консоли:**
```bash
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 # показать установленную и доступную версию
phobos-update --list # список бэкапов
```
Обновляется только слой панели и скриптов. **Ключи WireGuard, `server.env` и клиенты не трогаются.** Перед каждым обновлением делается бэкап → откат восстанавливает его.
---
## Возможные проблемы и решения
| Симптом | Причина | Решение |
|---------|---------|---------|
| **Команда установки → 403 / не качается** | репозиторий приватный | Сделай репозиторий публичным, либо ставь с токеном: `GH_TOKEN=ghp_xxx bash <(curl -fsSL -H "Authorization: token ghp_xxx" .../install.sh)` |
| **Панель не открывается** | панель перезапускалась (обновление) / сервер недоступен | Подожди 5 сек, обнови страницу. Проверь: `systemctl status phobos-panel` |
| **Клиент Offline, хотя работает** | роутер на резервном сервере | Норма — статус считается по handshake на **любом** сервере; обнови страницу |
| **«SSH timeout» при тесте роутера** | роутер за NAT, входящий SSH закрыт | Это нормально — управление идёт по pull-каналу через туннель, SSH не нужен |
| **У роутера нет интернета на LAN-устройствах** | WG-интерфейс `private` / нет привязки устройства к политике | На роутере: `security-level public` + `ip hotspot host <mac> policy <PolicyName>`; health-скрипт чинит это сам в течение ~5 мин |
| **Роутер не поднялся после перезагрузки** | Entware/USB не запустил автозагрузку | Сторож (watchdog) поднимет за ~3 мин (нужны KeenDNS + web-логин роутера в `router_access`), либо перезагрузи роутер ещё раз |
| **iPhone не подключается по `phobos://`** | на iOS нет приложения-обфускатора | Используй 🍎-конфиг (обычный WireGuard) + открой 51820 (`ALLOW_PLAIN_WG=1`) |
| **Смена сервера применяется не сразу** | роутер тянет конфиг по cron | Применяется за ~12–15 сек, не мгновенно |
| **Забыл порт панели** | случайный порт | `cat /opt/phobos-panel/.port` |
| **Забыл API key** | | `grep server_api_key /opt/phobos-panel/settings.json` |
| **Домен: certbot failed** | A-запись не на этот сервер / закрыт 80 | `dig +short домен` = IP сервера; открой 80/tcp; повтори в «Настройках» |
| **Лимит скорости не действует** | wg0 перезапущен | панель сама восстановит за ≤1 мин; вручную: `phobos-limit.sh apply` |
| **Кнопки клиента «уезжают»** | старая версия | обнови до 1.4.0 — карточки клиентов компактные, телефоны тоже |
Логи: `journalctl -u phobos-panel -n 50` · сторож: `/opt/Phobos/server/watchdog.log` · роутер: `/opt/etc/Phobos/health.log`.
---
## Архитектура
```
Телефон/Роутер → wg-obfuscator (клиент) ──обфускация──► сервер :2083/5443/993
│ де-обфускация
WireGuard :51820
│ 10.25.0.x
┌──────────┴──────────┐
Веб-панель Failover на
(Flask+Gunicorn) резервные серверы
```
- Протокол: **WireGuard + wg-obfuscator** (обфускация от DPI).
- Управление роутерами — по **туннелю** (`10.25.0.1`), устойчиво к бану публичного IP.
- Состояние: JSON-файлы (без БД).
---
## Поддержка проекта
Если PCA Phobos оказался полезен — буду благодарен за поддержку:
- 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate)
- 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
- ✉️ **Telegram:** [@PCAdministration](https://t.me/PCAdministration) — вопросы, баги, идеи
## На основе и благодарности
PCA Phobos — это веб-панель и turnkey-инсталлятор поверх отличных открытых проектов:
- [**Phobos**](https://github.com/Ground-Zerro/Phobos) — обфусцированный WireGuard, автор **Ground_Zerro**. Поддержать: [Boosty](https://boosty.to/ground_zerro) ❤️
- [**WireGuard Easy**](https://github.com/wg-easy/wg-easy) — веб-панель WireGuard (AGPL-3.0), автор **Emile Nijssen**. Поддержать: [GitHub Sponsors](https://github.com/sponsors/WeeJeWel) ❤️
- [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) — обфускация WireGuard-трафика (GPL-3.0), автор **ClusterM**. Поддержать: [Boosty](https://boosty.to/cluster) ❤️
Спасибо авторам за отличные инструменты.
Токен — по подписке: https://boosty.to/andrey27

View File

@@ -1 +1 @@
1.4.2
token-required

View File

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

View File

@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.pca.phobos.vpn"
android:versionCode="143"
android:versionName="1.4.3">
<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>

View File

@@ -1,20 +0,0 @@
# 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

View File

@@ -1,34 +0,0 @@
#!/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

View File

@@ -1,12 +0,0 @@
<?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

@@ -1,10 +0,0 @@
<?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

@@ -1,10 +0,0 @@
<?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

@@ -1,542 +0,0 @@
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 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 viaTcp) {
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 (viaTcp && 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 "";
}
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 it dials the local udptcp relay. */
private void startTransport(String conf, boolean viaTcp) throws Exception {
stopObfuscator();
String lib = getApplicationInfo().nativeLibraryDir;
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).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 = viaTcp ? "TCP" : "UDP";
log("transport " + transport + " -> " + 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, "tcp".equals(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, true);
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; }
}
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"}}) {
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(); 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();
}
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(); }
}

1
android/.gitignore vendored
View File

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

View File

@@ -1,27 +0,0 @@
<?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>

View File

@@ -1,4 +0,0 @@
# PCA Phobos — админ-приложение (Android)
Список ваших панелей Phobos, каждая открывается в приложении (WebView), тема по системе,
ссылки `phobos://` передаются в VPN-клиент. Сборка: `KS_PASS=… ./build.sh` → `build/PCA-Phobos.apk`.

View File

@@ -1,26 +0,0 @@
#!/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

@@ -1,12 +0,0 @@
<?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

@@ -1,10 +0,0 @@
<?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

@@ -1,10 +0,0 @@
<?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

@@ -1,367 +0,0 @@
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();
}
}

4569
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
# Phobos Failover Configuration
# Format: SERVER_N=host:port1,port2,port3
# KEY_N=obfuscation_key_for_server_N
# Primary server (main, with web panel)
SERVER_1=144.124.252.104:2083,5443,993
KEY_1=Fc3X8GbVQD2FuHPbOjKiaP83nWSwNojJ
# Secondary server (backup, VPN only)
# SERVER_2=backup.example.com:51821,51822
# KEY_2=backup_obfuscation_key_here
# Emergency server (last resort)
# SERVER_3=emergency.example.com:43210
# KEY_3=emergency_key_here

View File

@@ -1,247 +0,0 @@
#!/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_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"
MAX_LOG_LINES=200
HANDSHAKE_WARN=180
HANDSHAKE_RESTART_OBF=300
HANDSHAKE_RESTART_WG=600
HANDSHAKE_FAILOVER=900
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
}
# Prevent concurrent runs
if [ -f "$LOCKFILE" ]; then
pid=$(cat "$LOCKFILE" 2>/dev/null)
if kill -0 "$pid" 2>/dev/null; then
exit 0
fi
fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
# Create state dir
mkdir -p "$STATE"
# ── 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
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-)"
SERVER_COUNT=$idx
idx=$((idx + 1))
done
if [ "$SERVER_COUNT" -eq 0 ]; then
log "ERROR: no servers in failover.conf"
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"
}
# ── Get port by index from comma-separated list ──
get_port() {
server_idx=$1
port_idx=$2
eval "ports=\$SERVER_${server_idx}_PORTS"
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
}
get_port_count() {
server_idx=$1
eval "ports=\$SERVER_${server_idx}_PORTS"
echo "$ports" | tr ',' '\n' | wc -l
}
# ── Switch to specific server:port ──
switch_endpoint() {
server_idx=$1
port_idx=$2
eval "host=\$SERVER_${server_idx}_HOST"
eval "obf_key=\$SERVER_${server_idx}_KEY"
port=$(get_port "$server_idx" "$port_idx")
if [ -z "$host" ] || [ -z "$port" ]; then
log "ERROR: invalid server $server_idx port_idx $port_idx"
return 1
fi
log "SWITCH → server $server_idx ($host:$port)"
# Update obfuscator config target
if [ -f "$OBF_CONF" ]; then
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
if [ -n "$obf_key" ]; then
sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
fi
fi
# Restart obfuscator
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
# Save state
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
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
switch_endpoint "$CURRENT_SERVER" "$next_idx"
return 0
}
# ── 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
log "FAILOVER → server $next"
switch_endpoint "$next" "0"
return 0
}
# ── Check if primary is back (every 5 min when on secondary) ──
check_primary() {
if [ "$CURRENT_SERVER" -eq 1 ]; then
return
fi
now=$(date +%s)
elapsed=$((now - PRIMARY_CHECK_TS))
if [ "$elapsed" -lt 300 ]; then
return
fi
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"
switch_endpoint 1 0
fi
}
# ── Main logic ──
AGE=$(get_handshake_age)
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"
fi
check_primary
exit 0
fi
log "STALE handshake: ${AGE}s (server=$CURRENT_SERVER port_idx=$CURRENT_PORT_IDX restarts=$RESTART_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
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
fi

View File

@@ -1,107 +0,0 @@
# 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.

View File

@@ -1,160 +0,0 @@
# 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`).

View File

@@ -1,9 +0,0 @@
# Скриншоты
Положи сюда 3 PNG (панель открыта — сделай скрины своих страниц):
- `sessions.png` — страница «Сессии»
- `clients.png` — страница «Клиенты»
- `servers.png` — страница «Серверы»
README в корне на них ссылается. Рекомендуемая ширина ~1000–1400px.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

View File

@@ -1,350 +0,0 @@
#!/bin/bash
# ============================================================
# PCA Phobos — TURNKEY installer (primary / panel node)
#
# One command, all dependencies, from a clean VPS:
# 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 ->
# obfuscator services -> Phobos repo (onboarding scripts) +
# PCA patches -> web panel -> nginx (/init,/packages) ->
# server-side router watchdog.
#
# Env (all optional):
# PANEL_PORT random 10000-59999 web panel port
# PANEL_TLS_CERT / PANEL_TLS_KEY enable native HTTPS for panel
# PANEL_PASS OcAdmin2026! panel admin password
# API_KEY random shared key (agents + router pull token)
# OBF_PORTS 2083,5443,993 obfuscator listen ports
# TG_TOKEN / TG_CHAT Telegram alerts
# PCA_BRANCH main branch to pull PCA files from
# ============================================================
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}"
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
# Источник: 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
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
*) PCA_BRANCH="${PCA_BRANCH:-$CHANNEL}";;
esac
case "$PCA_BRANCH" in v[0-9]*) _kind=tag;; *) _kind=branch;; esac
PHOBOS_DIR="/opt/Phobos"
PANEL_DIR="/opt/phobos-panel"
RAW="$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)
IFACE="${IFACE:-eth0}"
ARCH=$(uname -m)
echo "============================================"
echo " PCA Phobos - turnkey primary install"
echo " IP=$SERVER_IP iface=$IFACE arch=$ARCH"
echo " panel port=$PANEL_PORT obf ports=$OBF_PORTS"
echo "============================================"
# ── 1. dependencies ──
echo "[1/9] dependencies..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq wireguard wireguard-tools iptables jq curl git ca-certificates \
python3 python3-flask python3-pip gunicorn nginx cron python3-qrcode sshpass >/dev/null
apt-get install -y -qq python3-flask-sock >/dev/null 2>&1 || true
apt-get install -y -qq python3-asyncssh >/dev/null 2>&1 || true
apt-get install -y -qq python3-websocket >/dev/null 2>&1 || true
python3 - <<'PY' >/dev/null 2>&1 || python3 -m pip install flask-sock asyncssh websocket-client >/dev/null 2>&1 || python3 -m pip install --break-system-packages flask-sock asyncssh websocket-client >/dev/null
import flask_sock, asyncssh, websocket
PY
mkdir -p "$PANEL_DIR/static"
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css -o "$PANEL_DIR/static/xterm.min.css" 2>/dev/null || true
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js -o "$PANEL_DIR/static/xterm.min.js" 2>/dev/null || true
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js -o "$PANEL_DIR/static/xterm-addon-fit.min.js" 2>/dev/null || true
systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true
# ── 2. wg-obfuscator binary (Ground-Zerro) ──
echo "[2/9] wg-obfuscator..."
mkdir -p "$PHOBOS_DIR"/{server,clients,bin,tokens,www/init,www/packages,packages}
OBF_ARCH="$ARCH"
[ "$OBF_ARCH" = "amd64" ] && OBF_ARCH="x86_64"
[ "$OBF_ARCH" = "arm64" ] && OBF_ARCH="aarch64"
if [ ! -x /usr/local/bin/wg-obfuscator ]; then
R=/tmp/phobos-obf; rm -rf "$R"; mkdir -p "$R"; cd "$R"
git init -q; git remote add origin https://github.com/Ground-Zerro/Phobos.git
git config core.sparseCheckout true; echo "wg-obfuscator" > .git/info/sparse-checkout
git pull origin main -q
cp -f "wg-obfuscator/bin/"wg-obfuscator-* "$PHOBOS_DIR/bin/" 2>/dev/null || true
chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-* 2>/dev/null || true
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${OBF_ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
cd /; rm -rf "$R"
fi
_OBF_TAG=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
_CB="https://github.com/ClusterM/wg-obfuscator/releases/download/${_OBF_TAG}"
for _am in "x86_64:linux-x64" "aarch64:linux-arm64" "mipsel:linux-mipsel-mips32" "mips:linux-mips-mips32" "armv7:linux-armv7-hf"; do
_da="${_am%%:*}"; _ss="${_am##*:}"
if [ ! -f "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" ] && [ -n "$_OBF_TAG" ]; then
_t=$(mktemp -d)
curl -fsSL -m30 "${_CB}/wg-obfuscator-${_OBF_TAG}-${_ss}.tar.gz" -o "$_t/o.tgz" 2>/dev/null &&
tar -xzf "$_t/o.tgz" -C "$_t" 2>/dev/null &&
_b=$(find "$_t" -name "wg-obfuscator" -type f | head -1) &&
[ -n "$_b" ] && cp "$_b" "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" && chmod +x "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" && echo " +wg-obfuscator-${_da} (ClusterM ${_OBF_TAG})"
rm -rf "$_t"
fi
done
[ -x /usr/local/bin/wg-obfuscator ] || ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${OBF_ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
[ -x /usr/local/bin/wg-obfuscator ] || { echo "ERROR: obfuscator binary for $ARCH missing"; exit 1; }
echo " bins: $(ls $PHOBOS_DIR/bin/ | tr '\n' ' ')"
# ── 3. Phobos repo (onboarding scripts) ──
echo "[3/9] Phobos repo (onboarding scripts)..."
R="$PHOBOS_DIR/repo"; rm -rf "$R"; mkdir -p "$R"; cd "$R"
git init -q; git remote add origin https://github.com/Ground-Zerro/Phobos.git
git config core.sparseCheckout true
printf 'server\nclient\n' > .git/info/sparse-checkout
git pull origin main -q; rm -rf .git
find "$R" -name '*.sh' -exec chmod +x {} \; 2>/dev/null || true
cd /
# ── 4. WireGuard wg0 (primary) ──
echo "[4/9] WireGuard wg0..."
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 = ${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
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -D POSTROUTING -o $IFACE -j MASQUERADE
WG
chmod 600 /etc/wireguard/wg0.conf
else
WG_PRIV=$(grep '^PrivateKey' /etc/wireguard/wg0.conf | cut -d= -f2- | tr -d ' ')
WG_PUB=$(echo "$WG_PRIV" | wg pubkey)
fi
sysctl -w net.ipv4.ip_forward=1 -q
grep -q '^net.ipv4.ip_forward = 1' /etc/sysctl.conf || echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf
systemctl enable wg-quick@wg0 -q 2>/dev/null || true
systemctl restart wg-quick@wg0
# ── 5. obfuscator services (multi-port) ──
echo "[5/9] obfuscator services..."
OBF_KEY=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 32)
# Block direct WG (force obfuscation) unless ALLOW_PLAIN_WG=1 (e.g. for iOS WireGuard)
if [ -z "${ALLOW_PLAIN_WG:-}" ]; then
iptables -C INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null \
|| iptables -A INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP
else
iptables -D INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null || true
fi
IFS=',' read -ra PORTS <<< "$OBF_PORTS"
for PORT in "${PORTS[@]}"; do
cat > "$PHOBOS_DIR/server/wg-obfuscator-${PORT}.conf" <<EOF
[instance]
source-if = 0.0.0.0
source-lport = ${PORT}
target = 127.0.0.1:51820
key = ${OBF_KEY}
masking = AUTO
verbose = INFO
idle-timeout = 300
max-dummy = 50
EOF
cat > /etc/systemd/system/wg-obfuscator-${PORT}.service <<EOF
[Unit]
Description=WireGuard Obfuscator (port ${PORT})
After=network.target wg-quick@wg0.service
[Service]
Type=simple
ExecStart=/usr/local/bin/wg-obfuscator --config ${PHOBOS_DIR}/server/wg-obfuscator-${PORT}.conf
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
done
systemctl daemon-reload
for PORT in "${PORTS[@]}"; do systemctl enable wg-obfuscator-${PORT} -q; systemctl restart wg-obfuscator-${PORT}; done
# server.env (primary)
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
ROLE=primary
EOF
# ── 6. PCA patches over onboarding scripts + server-side helpers ──
echo "[6/9] PCA patches (tunnel-pull, self-heal, watchdog, 403 fix)..."
fetch() {
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..."
mkdir -p "$PANEL_DIR"
fetch app.py "$PANEL_DIR/app.py" || { echo "ERROR: panel app.py fetch failed"; exit 1; }
if [ ! -f "$PANEL_DIR/settings.json" ]; then
cat > "$PANEL_DIR/settings.json" <<EOF
{
"admin_pass": "$PANEL_PASS",
"tg_bot_token": "$TG_TOKEN",
"tg_chat_id": "$TG_CHAT",
"monitor_interval": 30,
"server_api_key": "$API_KEY",
"labels": {},
"subscriptions": {},
"router_access": {},
"client_assignments": {}
}
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
After=network.target wg-quick@wg0.service
Wants=wg-quick@wg0.service
[Service]
Type=simple
WorkingDirectory=$PANEL_DIR
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
cat > /etc/nginx/sites-available/phobos <<'NGINX'
server {
listen 80 default_server;
listen [::]:80 default_server;
location /init/ { alias /opt/Phobos/www/init/; default_type application/x-sh; }
location /packages/ { alias /opt/Phobos/www/packages/; default_type application/octet-stream; }
location /app/ { alias /opt/Phobos/www/app/; default_type application/octet-stream; }
location / { return 404; }
}
NGINX
ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos
mkdir -p /opt/Phobos/www/app; chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages /opt/Phobos/www/app
nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed"
# ── 9. router watchdog (auto reboot-recovery) ──
echo "[9/9] router watchdog..."
if [ -f "$PHOBOS_DIR/server/phobos-router-watchdog.py" ]; then
# set -e safe: grep -v on an empty crontab returns 1, so guard with || true
CRON_CUR=$(crontab -l 2>/dev/null | grep -v phobos-router-watchdog || true)
printf '%s\n%s\n' "$CRON_CUR" \
"*/3 * * * * /usr/bin/python3 $PHOBOS_DIR/server/phobos-router-watchdog.py >/dev/null 2>&1" \
| grep -v '^[[:space:]]*$' | crontab -
fi
sleep 2
# persist channel so phobos-update follows it
mkdir -p "$PANEL_DIR"
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
echo ""
echo "============================================"
echo " Installation complete"
echo " Panel : $PANEL_SCHEME://$SERVER_IP:$PANEL_PORT"
echo " Login : admin"
echo " Pass : $PANEL_PASS"
echo " API key (agents+pull): $API_KEY"
echo " WG pub: $WG_PUB"
echo "============================================"
echo "Status:"
for s in wg-quick@wg0 phobos-panel nginx; do printf " %-18s %s\n" "$s" "$(systemctl is-active $s 2>/dev/null)"; done
for PORT in "${PORTS[@]}"; do printf " %-18s %s\n" "wg-obfuscator-$PORT" "$(systemctl is-active wg-obfuscator-$PORT 2>/dev/null)"; done

3
ios/.gitignore vendored
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

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

View File

@@ -1,97 +0,0 @@
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
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 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")
}
.pickerStyle(.segmented)
Text("Авто: если UDP режут, через 15 секунд переходит на TCP. Изменения применяются сразу.")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}

View File

@@ -1,41 +0,0 @@
<?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>1.0</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>1</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>

View File

@@ -1,10 +0,0 @@
<?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

@@ -1,103 +0,0 @@
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() } }
@Published var status: NEVPNStatus = .invalid
@Published var transport = ""
@Published var message = ""
private var manager: NETunnelProviderManager?
private var timer: Timer?
init() {
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 }
conf = parsed.profile.raw
name = parsed.name
UserDefaults.standard.set(conf, forKey: "conf")
UserDefaults.standard.set(name, forKey: "name")
message = "Профиль «\(name)» импортирован"
Task { await save(reconnect: isOn) }
}
private func saveOptions() {
UserDefaults.standard.set(ruDirect, forKey: "ruDirect")
UserDefaults.standard.set(transportMode, forKey: "transport")
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]
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

@@ -1,572 +0,0 @@
// !$*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"; }; };
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>"; };
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 */,
);
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 = "";
ProvisioningStyle = Automatic;
};
E1F7E69029753CF102C6CE2E = {
DevelopmentTeam = "";
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 */,
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 = 141;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
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.4.1;
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 = 141;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
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.4.1;
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

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

View File

@@ -1,26 +0,0 @@
# PCA Phobos VPN — iPhone
То же, что Android-клиент: импорт `phobos://` из панели, обфускация (wg-obfuscator внутри
расширения), **российские сайты напрямую**, канал **Авто/UDP/TCP** (при блокировке UDP — сам на TCP).
Выбор отдельных приложений на 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).
- `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`.
## Установка на 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

@@ -1,84 +0,0 @@
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
}
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") { out += item.line + "\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

@@ -1,38 +0,0 @@
// 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"))
print("profile_check ok")

View File

@@ -1,31 +0,0 @@
<?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>1.0</string>
<key>CFBundleVersion</key>
<string>1</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

@@ -1,92 +0,0 @@
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").
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
}
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)
}
}
let allowed = ruDirect ? Self.ruBypassAllowedIPs() : nil
let wgText = profile.wireGuardConfig(endpoint: "127.0.0.1:\(Self.wgPort)", allowedIPs: allowed)
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_tcp() != 0 ? "TCP" : "UDP",
"rx": pcarelay_rx_bytes()]
completionHandler?(try? JSONSerialization.data(withJSONObject: status))
}
private static func ruBypassAllowedIPs() -> String? {
guard let url = Bundle.main.url(forResource: "ru_bypass", withExtension: "txt"),
let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
return text.split(separator: "\n").first { !$0.hasPrefix("#") && $0.contains("/") }.map(String.init)
}
}

View File

@@ -1,10 +0,0 @@
<?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

@@ -1,32 +0,0 @@
// 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

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

View File

@@ -1,253 +0,0 @@
// 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
}
}

View File

@@ -1,535 +0,0 @@
#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

@@ -1,9 +0,0 @@
#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

@@ -1,258 +0,0 @@
#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

@@ -1,170 +0,0 @@
#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

@@ -1,91 +0,0 @@
#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

@@ -1,14 +0,0 @@
#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

@@ -1,298 +0,0 @@
#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

@@ -1,19 +0,0 @@
#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

@@ -1,82 +0,0 @@
/* 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

@@ -1,130 +0,0 @@
#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

@@ -1,139 +0,0 @@
#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

@@ -1,135 +0,0 @@
#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

View File

@@ -1,118 +0,0 @@
/*
* 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().
*/
#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;
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;
int udp = -1, tcp = -1;
struct sockaddr_storage peer; socklen_t pl = 0;
unsigned char buf[65537];
time_t last_dial = 0;
for (;;) {
int want_tcp = atomic_load(&use_tcp);
if (!want_tcp && tcp >= 0) { close(tcp); tcp = -1; }
if (want_tcp && udp >= 0) { close(udp); udp = -1; }
if (!want_tcp && 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;
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); }
unsigned long long pcarelay_rx_bytes(void) { return atomic_load(&rx_bytes); }

View File

@@ -1,14 +0,0 @@
#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);
/* wg-obfuscator compiled in with -Dmain=wgobf_main (one instance per process). */
int wgobf_main(int argc, char **argv);
#endif

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,19 +0,0 @@
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

@@ -1,15 +0,0 @@
// 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

@@ -1,34 +0,0 @@
// 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! }
}
}

View File

@@ -1,153 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Network
import Foundation
enum DNSResolver {}
extension DNSResolver {
/// Concurrent queue used for DNS resolutions
private static let resolverQueue = DispatchQueue(label: "DNSResolverQueue", qos: .default, attributes: .concurrent)
static func resolveSync(endpoints: [Endpoint?]) -> [Result<Endpoint, DNSResolutionError>?] {
let isAllEndpointsAlreadyResolved = endpoints.allSatisfy { maybeEndpoint -> Bool in
return maybeEndpoint?.hasHostAsIPAddress() ?? true
}
if isAllEndpointsAlreadyResolved {
return endpoints.map { endpoint in
return endpoint.map { .success($0) }
}
}
return endpoints.concurrentMap(queue: resolverQueue) { endpoint -> Result<Endpoint, DNSResolutionError>? in
guard let endpoint = endpoint else { return nil }
if endpoint.hasHostAsIPAddress() {
return .success(endpoint)
} else {
return Result { try DNSResolver.resolveSync(endpoint: endpoint) }
.mapError { error -> DNSResolutionError in
// swiftlint:disable:next force_cast
return error as! DNSResolutionError
}
}
}
}
private static func resolveSync(endpoint: Endpoint) throws -> Endpoint {
guard case .name(let name, _) = endpoint.host else {
return endpoint
}
var hints = addrinfo()
hints.ai_flags = AI_ALL // We set this to ALL so that we get v4 addresses even on DNS64 networks
hints.ai_family = AF_UNSPEC
hints.ai_socktype = SOCK_DGRAM
hints.ai_protocol = IPPROTO_UDP
var resultPointer: UnsafeMutablePointer<addrinfo>?
defer {
resultPointer.flatMap { freeaddrinfo($0) }
}
let errorCode = getaddrinfo(name, "\(endpoint.port)", &hints, &resultPointer)
if errorCode != 0 {
throw DNSResolutionError(errorCode: errorCode, address: name)
}
var ipv4Address: IPv4Address?
var ipv6Address: IPv6Address?
var next: UnsafeMutablePointer<addrinfo>? = resultPointer
let iterator = AnyIterator { () -> addrinfo? in
let result = next?.pointee
next = result?.ai_next
return result
}
for addrInfo in iterator {
if let maybeIpv4Address = IPv4Address(addrInfo: addrInfo) {
ipv4Address = maybeIpv4Address
break // If we found an IPv4 address, we can stop
} else if let maybeIpv6Address = IPv6Address(addrInfo: addrInfo) {
ipv6Address = maybeIpv6Address
continue // If we already have an IPv6 address, we can skip this one
}
}
// We prefer an IPv4 address over an IPv6 address
if let ipv4Address = ipv4Address {
return Endpoint(host: .ipv4(ipv4Address), port: endpoint.port)
} else if let ipv6Address = ipv6Address {
return Endpoint(host: .ipv6(ipv6Address), port: endpoint.port)
} else {
// Must never happen
fatalError()
}
}
}
extension Endpoint {
func withReresolvedIP() throws -> Endpoint {
#if os(iOS)
let hostname: String
switch host {
case .name(let name, _):
hostname = name
case .ipv4(let address):
hostname = "\(address)"
case .ipv6(let address):
hostname = "\(address)"
@unknown default:
fatalError()
}
var hints = addrinfo()
hints.ai_family = AF_UNSPEC
hints.ai_socktype = SOCK_DGRAM
hints.ai_protocol = IPPROTO_UDP
hints.ai_flags = 0 // We set this to zero so that we actually resolve this using DNS64
var result: UnsafeMutablePointer<addrinfo>?
defer {
result.flatMap { freeaddrinfo($0) }
}
let errorCode = getaddrinfo(hostname, "\(self.port)", &hints, &result)
if errorCode != 0 {
throw DNSResolutionError(errorCode: errorCode, address: hostname)
}
let addrInfo = result!.pointee
if let ipv4Address = IPv4Address(addrInfo: addrInfo) {
return Endpoint(host: .ipv4(ipv4Address), port: port)
} else if let ipv6Address = IPv6Address(addrInfo: addrInfo) {
return Endpoint(host: .ipv6(ipv6Address), port: port)
} else {
fatalError()
}
#elseif os(macOS)
return self
#else
#error("Unimplemented")
#endif
}
}
/// An error type describing DNS resolution error
public struct DNSResolutionError: LocalizedError {
public let errorCode: Int32
public let address: String
init(errorCode: Int32, address: String) {
self.errorCode = errorCode
self.address = address
}
public var errorDescription: String? {
return String(cString: gai_strerror(errorCode))
}
}

View File

@@ -1,35 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
import Network
public struct DNSServer {
public let address: IPAddress
public init(address: IPAddress) {
self.address = address
}
}
extension DNSServer: Equatable {
public static func == (lhs: DNSServer, rhs: DNSServer) -> Bool {
return lhs.address.rawValue == rhs.address.rawValue
}
}
extension DNSServer {
public var stringRepresentation: String {
return "\(address)"
}
public init?(from addressString: String) {
if let addr = IPv4Address(addressString) {
address = addr
} else if let addr = IPv6Address(addressString) {
address = addr
} else {
return nil
}
}
}

View File

@@ -1,101 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
import Network
public struct Endpoint {
public let host: NWEndpoint.Host
public let port: NWEndpoint.Port
public init(host: NWEndpoint.Host, port: NWEndpoint.Port) {
self.host = host
self.port = port
}
}
extension Endpoint: Equatable {
public static func == (lhs: Endpoint, rhs: Endpoint) -> Bool {
return lhs.host == rhs.host && lhs.port == rhs.port
}
}
extension Endpoint: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(host)
hasher.combine(port)
}
}
extension Endpoint {
public var stringRepresentation: String {
switch host {
case .name(let hostname, _):
return "\(hostname):\(port)"
case .ipv4(let address):
return "\(address):\(port)"
case .ipv6(let address):
return "[\(address)]:\(port)"
@unknown default:
fatalError()
}
}
public init?(from string: String) {
// Separation of host and port is based on 'parse_endpoint' function in
// https://git.zx2c4.com/wireguard-tools/tree/src/config.c
guard !string.isEmpty else { return nil }
let startOfPort: String.Index
let hostString: String
if string.first! == "[" {
// Look for IPv6-style endpoint, like [::1]:80
let startOfHost = string.index(after: string.startIndex)
guard let endOfHost = string.dropFirst().firstIndex(of: "]") else { return nil }
let afterEndOfHost = string.index(after: endOfHost)
if afterEndOfHost == string.endIndex { return nil }
guard string[afterEndOfHost] == ":" else { return nil }
startOfPort = string.index(after: afterEndOfHost)
hostString = String(string[startOfHost ..< endOfHost])
} else {
// Look for an IPv4-style endpoint, like 127.0.0.1:80
guard let endOfHost = string.firstIndex(of: ":") else { return nil }
startOfPort = string.index(after: endOfHost)
hostString = String(string[string.startIndex ..< endOfHost])
}
guard let endpointPort = NWEndpoint.Port(String(string[startOfPort ..< string.endIndex])) else { return nil }
let invalidCharacterIndex = hostString.unicodeScalars.firstIndex { char in
return !CharacterSet.urlHostAllowed.contains(char)
}
guard invalidCharacterIndex == nil else { return nil }
host = NWEndpoint.Host(hostString)
port = endpointPort
}
}
extension Endpoint {
public func hasHostAsIPAddress() -> Bool {
switch host {
case .name:
return false
case .ipv4:
return true
case .ipv6:
return true
@unknown default:
fatalError()
}
}
public func hostname() -> String? {
switch host {
case .name(let hostname, _):
return hostname
case .ipv4:
return nil
case .ipv6:
return nil
@unknown default:
fatalError()
}
}
}

View File

@@ -1,29 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
import Network
extension IPv4Address {
init?(addrInfo: addrinfo) {
guard addrInfo.ai_family == AF_INET else { return nil }
let addressData = addrInfo.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { ptr -> Data in
return Data(bytes: &ptr.pointee.sin_addr, count: MemoryLayout<in_addr>.size)
}
self.init(addressData)
}
}
extension IPv6Address {
init?(addrInfo: addrinfo) {
guard addrInfo.ai_family == AF_INET6 else { return nil }
let addressData = addrInfo.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { ptr -> Data in
return Data(bytes: &ptr.pointee.sin6_addr, count: MemoryLayout<in6_addr>.size)
}
self.init(addressData)
}
}

View File

@@ -1,115 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
import Network
public struct IPAddressRange {
public let address: IPAddress
public let networkPrefixLength: UInt8
init(address: IPAddress, networkPrefixLength: UInt8) {
self.address = address
self.networkPrefixLength = networkPrefixLength
}
}
extension IPAddressRange: Equatable {
public static func == (lhs: IPAddressRange, rhs: IPAddressRange) -> Bool {
return lhs.address.rawValue == rhs.address.rawValue && lhs.networkPrefixLength == rhs.networkPrefixLength
}
}
extension IPAddressRange: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(address.rawValue)
hasher.combine(networkPrefixLength)
}
}
extension IPAddressRange {
public var stringRepresentation: String {
return "\(address)/\(networkPrefixLength)"
}
public init?(from string: String) {
guard let parsed = IPAddressRange.parseAddressString(string) else { return nil }
address = parsed.0
networkPrefixLength = parsed.1
}
private static func parseAddressString(_ string: String) -> (IPAddress, UInt8)? {
let endOfIPAddress = string.lastIndex(of: "/") ?? string.endIndex
let addressString = String(string[string.startIndex ..< endOfIPAddress])
let address: IPAddress
if let addr = IPv4Address(addressString) {
address = addr
} else if let addr = IPv6Address(addressString) {
address = addr
} else {
return nil
}
let maxNetworkPrefixLength: UInt8 = address is IPv4Address ? 32 : 128
var networkPrefixLength: UInt8
if endOfIPAddress < string.endIndex { // "/" was located
let indexOfNetworkPrefixLength = string.index(after: endOfIPAddress)
guard indexOfNetworkPrefixLength < string.endIndex else { return nil }
let networkPrefixLengthSubstring = string[indexOfNetworkPrefixLength ..< string.endIndex]
guard let npl = UInt8(networkPrefixLengthSubstring) else { return nil }
networkPrefixLength = min(npl, maxNetworkPrefixLength)
} else {
networkPrefixLength = maxNetworkPrefixLength
}
return (address, networkPrefixLength)
}
public func subnetMask() -> IPAddress {
if address is IPv4Address {
let mask = networkPrefixLength > 0 ? ~UInt32(0) << (32 - networkPrefixLength) : UInt32(0)
let bytes = Data([
UInt8(truncatingIfNeeded: mask >> 24),
UInt8(truncatingIfNeeded: mask >> 16),
UInt8(truncatingIfNeeded: mask >> 8),
UInt8(truncatingIfNeeded: mask >> 0)
])
return IPv4Address(bytes)!
}
if address is IPv6Address {
var bytes = Data(repeating: 0, count: 16)
for i in 0..<Int(networkPrefixLength/8) {
bytes[i] = 0xff
}
let nibble = networkPrefixLength % 32
if nibble != 0 {
let mask = ~UInt32(0) << (32 - nibble)
let i = Int(networkPrefixLength / 32 * 4)
bytes[i + 0] = UInt8(truncatingIfNeeded: mask >> 24)
bytes[i + 1] = UInt8(truncatingIfNeeded: mask >> 16)
bytes[i + 2] = UInt8(truncatingIfNeeded: mask >> 8)
bytes[i + 3] = UInt8(truncatingIfNeeded: mask >> 0)
}
return IPv6Address(bytes)!
}
fatalError()
}
public func maskedAddress() -> IPAddress {
let subnet = subnetMask().rawValue
var masked = Data(address.rawValue)
if subnet.count != masked.count {
fatalError()
}
for i in 0..<subnet.count {
masked[i] &= subnet[i]
}
if subnet.count == 4 {
return IPv4Address(masked)!
}
if subnet.count == 16 {
return IPv6Address(masked)!
}
fatalError()
}
}

View File

@@ -1,32 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
import Network
public struct InterfaceConfiguration {
public var privateKey: PrivateKey
public var addresses = [IPAddressRange]()
public var listenPort: UInt16?
public var mtu: UInt16?
public var dns = [DNSServer]()
public var dnsSearch = [String]()
public init(privateKey: PrivateKey) {
self.privateKey = privateKey
}
}
extension InterfaceConfiguration: Equatable {
public static func == (lhs: InterfaceConfiguration, rhs: InterfaceConfiguration) -> Bool {
let lhsAddresses = lhs.addresses.filter { $0.address is IPv4Address } + lhs.addresses.filter { $0.address is IPv6Address }
let rhsAddresses = rhs.addresses.filter { $0.address is IPv4Address } + rhs.addresses.filter { $0.address is IPv6Address }
return lhs.privateKey == rhs.privateKey &&
lhsAddresses == rhsAddresses &&
lhs.listenPort == rhs.listenPort &&
lhs.mtu == rhs.mtu &&
lhs.dns == rhs.dns &&
lhs.dnsSearch == rhs.dnsSearch
}
}

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