20 Commits

Author SHA1 Message Date
Andrei Leibzon
1f3eced228 release 1.5.0: one panel in all channels; Apple apps (iOS, new macOS tab) only in beta/dev
- stable gets the beta panel and server features (new design, presets, firewall,
  monthly traffic, router failover over all ports/addresses + TCP, altip, HR tab,
  Windows tab, update banner); ios/ macos/ windows/ sources stay out of stable
- menu item "Update to N" under Settings when a new version is out
- mobile: top menu no longer stretches to 200px
- urllib User-Agent: Cloudflare in front of git.bobyrev.com returns 403 to Python-urllib
- docs: GitHub adleibzon-rgb as source, password/uninstall/devices sections,
  new screenshots, PORTS.md TCP fallback and second IP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:12:13 +03:00
Andrei Leibzon
22537b3aad README: что делать, если забыл пароль панели
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:38:24 +03:00
Andrei Leibzon
f0f4f334a5 release 1.4.11: update banner (new panel/app versions), GitHub adleibzon-rgb as primary source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:12:21 +03:00
Andrei Leibzon
ad839e4cad Fix router install: missing /opt/etc/crontab no longer aborts under set -e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:32:48 +03:00
Бобырев Андрей
28986677eb release 1.4.10: накопительный счётчик трафика за месяц на клиента
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:38:50 +03:00
Бобырев Андрей
af4c269438 release 1.4.9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:00:07 +03:00
Бобырев Андрей
528d799edc panel: команда secondary с подсетью главной; install: в сводке реальные ключ и пароль
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 22:59:24 +03:00
Бобырев Андрей
31978f22f0 panel: «Как добавить второй сервер» на странице Серверы; install: предупреждение и подсеть с битами хоста -> /24
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 22:51:03 +03:00
Бобырев Андрей
c5e9e45141 router install: stop S10cron only if present, warn when crond is missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:59:46 +03:00
Бобырев Андрей
51ec84db6c uninstall: accept yes with CR/spaces/case, read from tty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:44:32 +03:00
Бобырев Андрей
d030344b00 release: 1.4.8 — сторож роутера не трогает чужие туннели
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:12:38 +03:00
Бобырев Андрей
8dc9fda65b release: 1.4.7 — failover на Keenetic, cron, таймаут pull
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:41:40 +03:00
Бобырев Андрей
8bfc84f315 phobos-health: переключение сервера больше не убивает туннель
Пир добавлялся с зашитым 127.0.0.1:13255, а обфускатор на части роутеров
слушает другой порт (51820) — после любого failover туннель был мёртв.
Порт теперь из wg-obfuscator.conf; пир через ndmc по шагам, allow-ips
0.0.0.0/0 и перезапуск интерфейса (ядро иначе не берёт allow-ips).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit cbc1bd712f4ac0854d00b18d349d0a9a7d41cc8b)
2026-11-01 16:41:24 +03:00
Бобырев Андрей
eefe3fc2af phobos-pull: таймаут wget 8 с — мёртвый туннель вешал pull навсегда
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c2d367ad527c3b14ba9b5ba421e74ae7a0cb7d33)
2026-11-01 16:41:24 +03:00
Бобырев Андрей
15011c2756 phobos-client.sh: лимит GitHub API не валит сборку пакета 2026-11-01 15:18:23 +03:00
Бобырев Андрей
29790aaf4d release: 1.4.6 — router-config accepts server_api_key as pull token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 04:43:06 +03:00
Бобырев Андрей
884df15f42 release: 1.4.5 — subnet at install, beta/dev from private Gitea repo by token
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 03:48:28 +03:00
Бобырев Андрей
eb2b2768cd install: take subnet from existing wg0.conf on reinstall
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 03:00:20 +03:00
Бобырев Андрей
147ef3a42d feat: ask VPN client subnet at install (SERVER_WG_IPV4_NETWORK)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:46:49 +03:00
112 changed files with 3434 additions and 8348 deletions

3
APPS Normal file
View File

@@ -0,0 +1,3 @@
Android (PCA Phobos VPN)=1.4.6
iPhone (TestFlight)=1.4.5
Windows=1.4.5-beta

15
BETA.md
View File

@@ -1,10 +1,11 @@
# Каналы PCA Phobos
С версии **1.4.0** все три канала (stable / beta / dev) — одна и та же версия.
Новые фичи по-прежнему сначала выкатываются в `dev`, затем в `beta`, затем в `main`
(`promote.sh`), но база одна — без расхождений.
С **1.5.0** все три канала — одна и та же панель и одни и те же серверные функции.
```bash
phobos-update # stable (открытый)
GH_TOKEN=… phobos-update beta|dev # для подписчиков Boosty
```
| Канал | Для кого | Отличие |
|---|---|---|
| **stable** (`main`, открытый) | все | без приложений Apple: в окне «Подключение» нет вкладок iOS и macOS |
| **beta** | подписчики Boosty (токен) | + приложения PCA Phobos VPN для iPhone (TestFlight) и macOS (.pkg) |
| **dev** | подписчики, тест нового | то же, что beta, новые функции появляются здесь первыми |
Переключение: `phobos-update stable|beta|dev` или «Настройки → Версия и обновления». Токен подписчика: там же.

View File

@@ -1,3 +1,18 @@
## 1.5.0 — 2026-11-11
- **Одна версия во всех каналах.** stable получил всё из beta: новый дизайн (боковое меню, «Сессии» с графиками, окна «Правка» / «Подключение» / «Роутер»), пресеты обфускатора, firewall по клиенту, трафик за месяц, DNS для клиентов, автопереход роутеров по всем портам и адресам + TCP, второй IP сервера (`phobos-altip`), несколько учёток на роутере, IPv6 для телефонов, вкладки Windows и HR, баннер «Доступна новая версия».
- **Приложения Apple — только beta/dev.** В beta/dev окно «Подключение» показывает вкладки **iOS** и новую **macOS** (установщик PCA Phobos для macOS 1.4.3 скачивается из закрытого релиза по токену подписчика). В stable этих вкладок нет, у iPhone — только обычный WireGuard; исходники iOS/macOS/Windows в открытый репозиторий не входят.
- Меню: при новой версии под «Настройками» — зелёный пункт «Обновить до N». На телефоне верхнее меню больше не раздувается до 200 px.
- Скачивание из git.bobyrev.com (Windows, macOS, проверка версий) — свой User-Agent: Cloudflare отвечал 403 на «Python-urllib».
- Ссылка на Android-приложение — релиз v1.4.10 (PCA Phobos VPN 1.4.6).
- Документация: источник — GitHub adleibzon-rgb (git.bobyrev.com — запасной), разделы «Пароль панели», «Удаление», «Подключение устройств», свежие скриншоты; PORTS.md — TCP-резерв и второй IP.
## 1.4.11 — 2026-11-11
- Панель сама раз в час проверяет свой канал (stable/beta/dev): если вышла новая версия — сверху зелёная полоса «Доступна новая версия» и кнопка «Обновить». В окне перед обновлением — что нового, как сделать бэкап и как откатиться.
## 1.4.9 — 2026-11-03
- Команда secondary сама подставляет подсеть главной; install.sh в сводке показывает реальные ключ API и пароль.
- Панель «Серверы»: сверху блок «Как добавить второй сервер» (по-русски, команда secondary, предупреждение про независимые панели и подсети); ручное добавление свёрнуто. install.sh: предупреждение перед вопросом о подсети; подсеть `10.25.11.0/16` → `10.25.11.0/24` вместо молчаливого `10.25.0.0/16`.
# Changelog — PCA Phobos
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
@@ -5,6 +20,9 @@
---
## 1.4.10 — 2026-11-03
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
## Как обновиться после фиксов
```bash
@@ -19,6 +37,26 @@ phobos-update beta # или: phobos-update dev
---
## 1.4.8 — сторож роутера не трогает чужие туннели
- Исправлено: если сторож не находил интерфейс Phobos (удалён или переименован), он брал Wireguard3 по умолчанию и прописывал туда пир Phobos — ломался посторонний WireGuard-туннель. Теперь он просто пропускает проверку.
- Роутерам: обновить скрипты из панели или переустановить клиента; серверу — `phobos-update stable`.
## 1.4.7 — роутеры Keenetic сами переключаются между серверами
- Исправлено: после переключения на другой сервер туннель на роутере переставал работать — пир прописывался на порт обфускатора 13255, а он мог слушать другой. Порт теперь берётся из `wg-obfuscator.conf`.
- Исправлено: проверка и обновление конфига по расписанию на Keenetic не запускались — задачи писались в `/opt/etc/crontab`, который демон не читает. Теперь busybox `crond` и crontab пользователя root.
- Исправлено: `phobos-pull` навсегда зависал на `wget`, если туннель не работает (таймаут 8 с).
- Роутерам: переустановить клиента или обновить скрипты из панели; серверу — `phobos-update stable`.
## 1.4.6 — роутеры снова получают смену адреса
- Исправлено: роутер получал 403 при pull конфига, если клиент создан через API/бота (`/api/client/ensure`) — смена адреса/сервера до него не доходила. Теперь принимается и общий ключ из пакета, и личный токен клиента. Роутеры переустанавливать не нужно — достаточно обновить панель.
## 1.4.5 — подсеть при установке, beta/dev по токену
- **feat:** beta и dev теперь берутся из приватного репозитория на Gitea (GitHub заблокирован): `phobos-update beta|dev` с токеном подписчика — в панели «Настройки → Версия и обновления → токен подписчика» или `GH_TOKEN=… phobos-update beta`. Без токена updater сообщает, что канал закрыт, и ничего не трогает.
- **fix:** при переустановке на сервер с готовым `wg0.conf` подсеть берётся из него.
- **feat:** подсеть VPN-клиентов задаётся при установке: `install.sh` и `secondary-setup.sh` спрашивают её в терминале (Enter = `10.25.0.0/16`) или берут `SERVER_WG_IPV4_NETWORK=10.25.1.0/24`. Неверная или публичная сеть — установка останавливается с ошибкой.
## 1.4.4 — канал «Облако» (через KeenDNS своего Keenetic)
- **Канал «Облако»** в Android PCA Phobos VPN 1.4.4 и iPhone (TestFlight 1.0 (6)): телефон → облако KeenDNS → ваш Keenetic → сервер. Помогает, когда сам сервер заблокирован, а сайты Keenetic открываются; белый IP роутеру не нужен.
- **wsgw** — шлюз WebSocket → UDP для Keenetic (Entware: aarch64, mipsel, mips) и Linux. Инструкция с картинками — [docs/cloud.md](docs/cloud.md).

119
README.md
View File

@@ -17,7 +17,7 @@
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
- 📱 **Телефоны** — своё приложение **PCA Phobos VPN** для Android (обфускация + раздельный трафик по приложениям), PhobosWG, iPhone (обычный WireGuard) — через QR/ссылку.
- 📱 **Телефоны и ПК** — свои приложения **PCA Phobos VPN**: Android (APK) и Windows (по токену подписчика). Плюс PhobosWG и обычный WireGuard — через QR/ссылку. Приложения для iPhone и macOS — в каналах beta/dev.
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
---
@@ -29,7 +29,7 @@
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM + пропускная способность канала), резервные серверы; роутеры переключаются автоматически.
- **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP.
- **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS.
- **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR.
- **Окно «Подключение»** — Android / Windows / Router / HR (+ обычный WireGuard для iPhone).
- **Метки** — имя объекта по IP (видно в сессиях и Telegram).
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
@@ -43,6 +43,13 @@
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
- **Новый дизайн (с 1.5.0 во всех каналах)** — боковое меню, «Сессии» с графиками CPU/RAM/скорости, кольцо трафика по клиентам; клиенты — таблица и окна «Правка», «Подключение» (Android / iOS / Windows / Router / **HR** для HydraRoute), «Роутер» (SSH / RCI / TTYD).
- **Пресеты обфускатора** — свой набор портов и ключ на группу клиентов; **firewall по клиенту** (🛡 — только разрешённые адреса).
- **Трафик за месяц** по каждому клиенту (сумма по всем серверам), история по месяцам.
- **DNS для клиентов** — задаётся в «Настройках».
- **Автопереход роутера (1.5.0)** — при сбое сторож перебирает все порты и все адреса сервера (второй IP, домены), затем TCP; на основной возвращается сам, если там снова есть рукопожатие.
- **Второй IP сервера** (`phobos-altip`) — если провайдер клиента режет основной адрес, роутер уходит на второй.
- **Несколько учёток на одном роутере**, **IPv6 для телефонов**, **ссылка для HydraRoute** (строго по формату автора Phobos).
- **Версии и обновления** — обновление и откат прямо из панели.
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
- **Telegram-бот** — управление клиентами командами прямо из чата:
@@ -56,8 +63,20 @@
## Скриншоты
| Клиенты (светлая) | Клиенты (тёмная) |
| Сессии (светлая) | Сессии (тёмная) |
|---|---|
| ![Сессии](docs/img/sessions.png) | ![Сессии тёмная](docs/img/sessions-dark.png) |
| **Клиенты** | **Серверы: второй сервер, домен для клиентов** |
| ![Клиенты](docs/img/clients.png) | ![Серверы](docs/img/servers.png) |
| Телефон: клиенты | Телефон: тёмная | Телефон: серверы |
|---|---|---|
| ![](docs/img/mobile-clients.png) | ![](docs/img/mobile-clients-dark.png) | ![](docs/img/mobile-servers.png) |
Настройки (пароль, домен, каскад, DNS, сторож, обновления, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png).
Android-приложения: [VPN-клиент](docs/img/android-vpn.png) · [раздельный трафик](docs/img/android-vpn-apps.png) · [админ](docs/img/android-admin.png).
---|---|
| ![Клиенты](docs/img/clients.png) | ![Клиенты тёмная](docs/img/clients-dark.png) |
| **Сессии** | **Серверы: замер скорости, второй сервер** |
| ![Сессии](docs/img/sessions.png) | ![Серверы](docs/img/servers.png) |
@@ -68,37 +87,35 @@
Настройки (домен, каскад, сторож, 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 обновляются одной командой:
> **Откуда ставится (с 11.11.2026):** основной источник — GitHub [`adleibzon-rgb/PCA_Phobos`](https://github.com/adleibzon-rgb/PCA_Phobos), запасной — зеркало `git.bobyrev.com/PCA/PCA_Phobos` (обновлятор сам переходит на него, если GitHub недоступен). Старый аккаунт `andrey271192` заблокирован — ссылки на него не работают.
> Серверы на старых версиях (1.2.x–1.4.x) обновляются одной командой:
> ```bash
> bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/update.sh)
> bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/update.sh)
> ```
> Если GitHub с сервера не открывается — то же с зеркала: `bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/update.sh)`
## Быстрый старт (с чистого VPS, одной командой)
Ставит **всё** со всеми зависимостями: wg-obfuscator, WireGuard, обфускатор-сервисы, веб-панель, nginx, скрипты онбординга роутеров, сторож авто-восстановления. Предустановленный Phobos **не требуется**.
```bash
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/install.sh)
```
### С параметрами
```bash
PANEL_PASS=МойПароль TG_TOKEN=123:abc TG_CHAT=123456789 \
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/install.sh)
```
| Переменная | По умолчанию | Описание |
|-------------------|-----------------|----------|
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
| `PANEL_PORT` | случайный | Порт веб-панели |
| `SERVER_WG_IPV4_NETWORK` | `10.25.0.0/16` | Подсеть VPN-клиентов (например `10.25.1.0/24`). Без переменной установщик спросит в терминале. Сервер получает первый адрес (`10.25.1.1`). Задаётся только при установке |
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
@@ -108,12 +125,13 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
DNS клиентов и свой AdGuard Home: [docs/DNS.md](docs/DNS.md). WARP — проверка и проблемы: [docs/WARP.md](docs/WARP.md).
### Вторичный сервер (для failover / балансировки)
```bash
MAIN_PROTO=http MAIN_SERVER=<ip_основного> MAIN_PORT=<порт_панели> MAIN_API_KEY=<API key из основного> \
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/secondary-setup.sh)
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/server/secondary-setup.sh)
```
Сервер сам зарегистрируется в панели, клиенты досылаются на него сразу. Если сервер за NAT — добавь `SERVER_IP=<внешний_ip>`.
@@ -123,7 +141,7 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/
На входном 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
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/server/phobos-cascade.sh) enable <IP_выходного_сервера> 2083,5443,993
```
Затем в панели выходного сервера → **Настройки → Адрес для клиентов и каскад** → впиши IP/домен входа. Роутеры переключатся сами (~15 с), телефонам — заново открыть ссылку/QR. Отключить: `phobos-cascade disable`.
@@ -138,38 +156,39 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/
## Приложения для Android
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.4/PCA-Phobos-VPN.apk) · исходники `android-client/`.
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.10/PCA-Phobos-VPN.apk) · исходники `android-client/`.
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.4/PCA-Phobos.apk) · исходники `android/`.
## Клиенты для телефона
## Подключение устройств
В таблице **Клиенты** у каждого клиента есть кнопки:
Клиенты → у клиента окно **«Подключение»** с вкладками:
- 📱 **Android** — `phobos://`-ссылка + QR для приложения **PhobosWG** (с обфускацией). Скан QR → импорт.
- 🍎 **iPhone / iOS** — обычный WireGuard-конфиг + QR для официального WireGuard (**без** обфускации). Требует открытого порта 51820 (`ALLOW_PLAIN_WG=1` при установке).
- **Android** — `phobos://`-ссылка + QR для **PCA Phobos VPN** или **PhobosWG** (с обфускацией), файл `.conf`.
- **Windows** — ссылка `phobos://` и кнопка скачивания **PCA Phobos для Windows** (архив из закрытого релиза, нужен токен подписчика в «Настройках»).
- **Router** — команда установки на Keenetic/OpenWrt (Entware) и команда удаления.
- **HR** — ссылка для HydraRoute Neo строго по формату автора Phobos.
- 🍎 **iPhone** в stable — только обычный WireGuard (без обфускации, нужен порт 51820, `ALLOW_PLAIN_WG=1`). Приложения **PCA Phobos VPN для iPhone и macOS** (с обфускацией, автопереход) — в каналах **beta/dev** для подписчиков Boosty.
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
**Приложения:**
- **Android (PhobosWG):** кнопка «Скачать приложение 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`).
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**.
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**. С 1.4.11 панель сама раз в час проверяет свой канал: вышла новая версия — сверху зелёная полоса «Доступна новая версия N» с кнопкой **Обновить** (в окне — что нового, бэкап, откат). Там же видно, если вышли новые версии приложений. В меню под «Настройками» в это время горит зелёный пункт **«Обновить до N»**.
> Панели до 1.4.11 этой полосы не показывают — один раз обнови вручную (`phobos-update`), дальше она появляется сама.
**Из консоли:**
```bash
phobos-update # обновить до последней версии (stable)
phobos-update beta|dev # каналы для подписчиков (GH_TOKEN), сейчас = та же 1.4.0
phobos-update beta|dev # каналы для подписчиков Boosty (токен сохраняется в /opt/phobos-panel/.gh_token)
phobos-update v1.1.0 # обновить до конкретной версии (git tag)
phobos-update --rollback # откатить на предыдущую версию
phobos-update --version # показать установленную и доступную версию
phobos-update --check # установленная версия и версии всех каналов
phobos-update --list # список бэкапов
```
@@ -177,11 +196,53 @@ phobos-update --list # список бэкапов
---
## Пароль панели — сменить или вспомнить
- **Сменить из панели:** «Настройки» → поле **«Пароль панели»** → новый пароль → «Сохранить». Пустое поле — пароль не меняется. Логин всегда `admin`.
- **Вспомнить:** `grep admin_pass /opt/phobos-panel/settings.json`
- **Сменить без входа (из консоли сервера):**
```bash
python3 - <<'EOF'
import json; p="/opt/phobos-panel/settings.json"; s=json.load(open(p))
s["admin_pass"]="НовыйПароль"; json.dump(s, open(p,"w"), ensure_ascii=False, indent=2)
EOF
systemctl restart phobos-panel
```
- Пароль по умолчанию (если не задавал `PANEL_PASS` при установке): `OcAdmin2026!` — смени сразу после установки.
- Порт панели: `cat /opt/phobos-panel/.port` · API key: `grep server_api_key /opt/phobos-panel/settings.json`.
---
## Удаление
**Сервер целиком** (панель, обфускатор, WireGuard `wg0`, nginx-сайт, сторож, правила iptables, каталоги Phobos):
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/uninstall.sh)
```
Спросит подтверждение — ответь `yes` (или `y` / `да`; до 1.4.6 работал только полный `yes`). Без вопросов: `PURGE=1 bash <(curl -fsSL …/uninstall.sh)`. Перед удалением сохрани бэкап из панели («Настройки» → «Бэкап»), если сервер понадобится снова. Клиенты на других серверах при удалении одного сервера продолжат работать (если есть резервный).
**Один клиент** — в панели «Клиенты» → 🗑 у клиента: ключ отзывается на всех серверах, роутер/телефон больше не подключится.
**С роутера Keenetic** (по SSH в Entware) — команда есть и в панели, внизу окна установки роутера («Удалить с роутера»):
```bash
/opt/etc/Phobos/phobos-uninstall.sh
```
Скрипт останавливает обфускатор, удаляет его конфиги и сам удаляет интерфейс WireGuard Phobos на роутере.
**Телефон / Windows:** удалить туннель в приложении и само приложение; в Windows — `uninstall.cmd` из папки программы.
---
## Возможные проблемы и решения
| Симптом | Причина | Решение |
|---------|---------|---------|
| **Команда установки → 403 / не качается** | репозиторий приватный | Сделай репозиторий публичным, либо ставь с токеном: `GH_TOKEN=ghp_xxx bash <(curl -fsSL -H "Authorization: token ghp_xxx" .../install.sh)` |
| **Команда установки → 403 / 404 / не качается** | старая ссылка (`andrey271192` заблокирован) или GitHub недоступен с сервера | Ссылки только `github.com/adleibzon-rgb/…`; если GitHub не открывается — ставь с зеркала `git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh` |
| **Удаление: «Отменено»** | старая версия uninstall.sh понимала только `yes` | Набери `yes` полностью или `PURGE=1` |
| **Панель не открывается** | панель перезапускалась (обновление) / сервер недоступен | Подожди 5 сек, обнови страницу. Проверь: `systemctl status phobos-panel` |
| **Клиент Offline, хотя работает** | роутер на резервном сервере | Норма — статус считается по handshake на **любом** сервере; обнови страницу |
| **«SSH timeout» при тесте роутера** | роутер за NAT, входящий SSH закрыт | Это нормально — управление идёт по pull-каналу через туннель, SSH не нужен |
@@ -190,6 +251,7 @@ phobos-update --list # список бэкапов
| **iPhone не подключается по `phobos://`** | на iOS нет приложения-обфускатора | Используй 🍎-конфиг (обычный WireGuard) + открой 51820 (`ALLOW_PLAIN_WG=1`) |
| **Смена сервера применяется не сразу** | роутер тянет конфиг по cron | Применяется за ~12–15 сек, не мгновенно |
| **Забыл порт панели** | случайный порт | `cat /opt/phobos-panel/.port` |
| **Забыл пароль панели** | | см. раздел [«Пароль панели»](#пароль-панели--сменить-или-вспомнить) |
| **Забыл API key** | | `grep server_api_key /opt/phobos-panel/settings.json` |
| **Домен: certbot failed** | A-запись не на этот сервер / закрыт 80 | `dig +short домен` = IP сервера; открой 80/tcp; повтори в «Настройках» |
| **Лимит скорости не действует** | wg0 перезапущен | панель сама восстановит за ≤1 мин; вручную: `phobos-limit.sh apply` |
@@ -234,3 +296,6 @@ PCA Phobos — это веб-панель и turnkey-инсталлятор по
- [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) — обфускация WireGuard-трафика (GPL-3.0), автор **ClusterM**. Поддержать: [Boosty](https://boosty.to/cluster) ❤️
Спасибо авторам за отличные инструменты.
## Смена сервера у клиента
См. [docs/SERVER-SWITCH.md](docs/SERVER-SWITCH.md): какие приложения переключаются сами (роутер, iOS, Android, Windows) и что делать в остальных.

View File

@@ -1 +1 @@
1.4.4
1.5.0

View File

@@ -1,15 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.pca.phobos.vpn"
android:versionCode="144"
android:versionName="1.4.4">
android:versionCode="147"
android:versionName="1.4.6">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:usesCleartextTraffic="true"
android:label="PCA Phobos VPN"
android:icon="@drawable/ic_launcher"
android:theme="@style/AppTheme"
@@ -33,6 +35,18 @@
</intent-filter>
</activity>
<!-- quick settings tile "PCA VPN" -->
<service
android:name=".PhobosTile"
android:exported="true"
android:label="PCA VPN"
android:icon="@drawable/ic_vpn"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- from com.wireguard.android:tunnel (AAR manifest, merged by hand: no Gradle) -->
<service
android:name="com.wireguard.android.backend.GoBackend$VpnService"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- monochrome shield for the status bar and the quick settings tile -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF"
android:pathData="M12,2 L20,5 L20,11 C20,16 16.6,20.2 12,22 C7.4,20.2 4,16 4,11 L4,5 Z M10.5,8 h3 v8 h-3 z" />
</vector>

View File

@@ -56,6 +56,7 @@ import java.util.concurrent.Executors;
*/
public class MainActivity extends Activity {
static final int LPORT = 13255;
static final String ACTION_TOGGLE = "com.pca.phobos.vpn.TOGGLE";
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
static GoBackend backend;
@@ -63,6 +64,15 @@ public class MainActivity extends Activity {
static final int TCP_LPORT = 13256;
static volatile String transport = ""; // "UDP" | "TCP" | "Облако" while up
static WsRelay wsRelay;
static V6Relay v6Relay;
static final int V6_LPORT = 13257;
static final Object LOCK = new Object();
/** Panel subscription (# pca-sub): servers in the panel's order, IPv6 entries last; failover walks them round-robin. */
static org.json.JSONArray sub = new org.json.JSONArray();
static volatile int subIndex;
static volatile String curConf = ""; // profile pointed at the server in use
static volatile long watchGen; // bumped on connect/disconnect: stops the old watcher
static final long SILENCE_MS = 40000; // no server reply this long -> next server
static final Tunnel TUNNEL = new Tunnel() {
public String getName() { return "phobos"; }
public void onStateChange(Tunnel.State s) { }
@@ -72,7 +82,7 @@ public class MainActivity extends Activity {
private final Handler ui = new Handler(Looper.getMainLooper());
private SharedPreferences prefs;
private boolean dark;
private TextView status, traffic;
private TextView status, traffic, server;
private Button power;
private long lastRx, lastTx, lastT;
@@ -82,6 +92,9 @@ public class MainActivity extends Activity {
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
if (backend == null) backend = new GoBackend(getApplicationContext());
if (android.os.Build.VERSION.SDK_INT >= 33 && checkSelfPermission("android.permission.POST_NOTIFICATIONS") != PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{"android.permission.POST_NOTIFICATIONS"}, 8);
}
handleIntent(getIntent());
render();
}
@@ -94,6 +107,14 @@ public class MainActivity extends Activity {
}
private void handleIntent(Intent i) {
if (i != null && ACTION_TOGGLE.equals(i.getAction())) {
setIntent(new Intent());
// tile: toggle and get out of the way, unless Android must ask for the VPN permission first
if (!isUp() && !prefs.getString("conf", "").isEmpty() && VpnService.prepare(this) == null) { bg.execute(this::up); finish(); return; }
if (isUp()) { bg.execute(this::down); finish(); return; }
toggle();
return;
}
if (i != null && i.getData() != null && "phobos".equals(i.getData().getScheme())) {
importLink(i.getDataString());
}
@@ -140,16 +161,21 @@ public class MainActivity extends Activity {
// ── profile ───────────────────────────────────────────────────────────
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
private void importLink(String link) {
private void importLink(String link) { importText(link, "Phobos"); }
/** A phobos:// link, or the text of a client .conf ([Interface] + [Peer] + [instance]). */
private void importText(String text, String name) {
try {
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
String s = text.replace("\uFEFF", "").trim(), conf = s;
if (s.startsWith("phobos://")) {
int hash = s.indexOf('#');
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
s = s.replaceFirst("^phobos://", "").trim();
conf = new String(Base64.decode(s, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING), StandardCharsets.UTF_8);
}
if (!conf.contains("[Interface]") || !conf.contains("[instance]"))
throw new IllegalArgumentException("нужен конфиг Phobos: секции [Interface], [Peer] и [instance]");
Config.parse(new BufferedReader(new StringReader(wgPart(conf, MODE_ALL, "", false)))); // validate
prefs.edit().putString("conf", conf).putString("name", name).apply();
toast("Профиль «" + name + "» импортирован");
} catch (Exception e) {
@@ -174,13 +200,15 @@ public class MainActivity extends Activity {
private static String key(String line) { return line.substring(0, line.indexOf('=')).trim().toLowerCase(); }
private static String val(String line) { return line.substring(line.indexOf('=') + 1).trim(); }
String wgPart(String conf, String mode, String apps) {
String wgPart(String conf, String mode, String apps, boolean ruRoutes) {
StringBuilder sb = new StringBuilder("[Interface]\n");
for (String ln : section(conf, "[Interface]")) {
String k = key(ln);
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
if (k.equals("dns") && !myDns().isEmpty()) continue;
sb.append(ln).append('\n');
}
if (!myDns().isEmpty()) sb.append("DNS = ").append(myDns()).append('\n');
List<String> sel = new ArrayList<>();
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
@@ -193,7 +221,7 @@ public class MainActivity extends Activity {
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;
String ruBypass = ruRoutes && prefs.getBoolean("ru_direct", true) ? ruBypassAllowedIps() : null;
for (String ln : section(conf, "[Peer]")) {
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
if (key(ln).equals("allowedips") && ruBypass != null) ln = "AllowedIPs = " + ruBypass;
@@ -202,33 +230,40 @@ public class MainActivity extends Activity {
return sb.toString();
}
private String ruCache;
/** Internet minus Russian networks (assets/ru_bypass.txt, generated from ipdeny). */
/** Internet minus Russian networks (assets/ru_nets.txt) and the user's own sites; resolves domains, so not on the UI thread. */
private String ruBypassAllowedIps() {
if (ruCache != null) return ruCache;
List<String> ru = new ArrayList<>();
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(
getAssets().open("ru_bypass.txt"), StandardCharsets.UTF_8))) {
getAssets().open("ru_nets.txt"), StandardCharsets.UTF_8))) {
for (String ln; (ln = r.readLine()) != null; ) {
if (!ln.startsWith("#") && ln.contains("/")) { ruCache = ln.trim(); break; }
if (!ln.startsWith("#") && ln.contains("/")) for (String c : ln.split(",")) ru.add(c.trim());
}
} catch (Exception e) { log("ru_bypass: " + e); }
return ruCache;
} catch (Exception e) { log("ru_nets: " + e); }
return RuNets.allowed(ru, lines(prefs.getString("my_direct", "")), lines(prefs.getString("my_vpn", "")));
}
private static List<String> lines(String s) {
List<String> out = new ArrayList<>();
for (String l : s.split("[\\n,\\s]+")) if (!l.trim().isEmpty()) out.add(l.trim());
return out;
}
/** User's own DNS servers ("1.1.1.1, 8.8.8.8"); empty = the profile's. */
private String myDns() { return prefs.getString("dns", ""); }
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
private static final java.util.Set<String> OBF_KEYS = new java.util.HashSet<>(java.util.Arrays.asList(
"target", "key", "masking", "idle-timeout", "max-dummy", "static-bindings", "max-clients", "target-if", "allow-clean", "in-timeout", "resolve-interval"));
private String obfPart(String conf, boolean viaRelay) {
private String obfPart(String conf, String targetOverride) {
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
for (String ln : section(conf, "[instance]")) {
String k = key(ln);
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
// configs from other Phobos builds carry keys our wg-obfuscator v1.6 rejects (obfuscate-bytes…)
if (!OBF_KEYS.contains(k)) continue;
if (viaRelay && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
if (targetOverride != null && k.equals("target")) ln = "target = " + targetOverride;
sb.append(ln).append('\n');
}
sb.append("verbose = INFO\n");
@@ -255,14 +290,88 @@ public class MainActivity extends Activity {
return "";
}
/** "# pca-sub = http://panel/api/phone-sub/<client>?token=…" */
private static String subUrl(String conf) {
for (String ln : conf.split("\n")) {
String t = ln.trim();
if (t.startsWith("#") && t.contains("pca-sub") && t.contains("=")) return val(t);
}
return "";
}
/** Same profile pointed at another subscription server (address, obfuscator key, server WG key, TCP port). */
static String applying(String conf, org.json.JSONObject s) {
String host = s.optString("host"), sec = "";
if (host.contains(":")) host = "[" + host + "]";
StringBuilder sb = new StringBuilder();
for (String raw : conf.split("\n", -1)) {
String ln = raw.trim();
if (ln.startsWith("[") && ln.endsWith("]")) sec = ln.toLowerCase();
else if (ln.startsWith("#")) { if (ln.contains("pca-tcp")) continue; }
else if (ln.contains("=")) {
String k = key(ln);
if (sec.equals("[instance]") && k.equals("target")) { sb.append("target = ").append(host).append(':').append(s.optInt("port")).append('\n'); continue; }
if (sec.equals("[instance]") && k.equals("key")) { sb.append("key = ").append(s.optString("key")).append('\n'); continue; }
if (sec.equals("[peer]") && k.equals("publickey")) { sb.append("PublicKey = ").append(s.optString("wg_pub")).append('\n'); continue; }
}
sb.append(raw).append('\n');
}
if (s.optInt("tcp") != 0) sb.append("# pca-tcp = ").append(s.optInt("tcp")).append('\n');
return sb.toString();
}
/** Pull the server list from the panel (the app is outside the tunnel); cached when the panel is unreachable. */
private void refreshSub(String conf) {
String u = subUrl(conf);
if (u.isEmpty()) { sub = new org.json.JSONArray(); return; }
try {
java.net.HttpURLConnection c = (java.net.HttpURLConnection) new java.net.URL(u).openConnection();
c.setConnectTimeout(6000); c.setReadTimeout(6000);
try (java.io.InputStream in = c.getInputStream()) {
String body = new String(readAll(in), StandardCharsets.UTF_8);
org.json.JSONArray a = new org.json.JSONObject(body).getJSONArray("servers");
if (a.length() > 0) prefs.edit().putString("sub", a.toString()).apply();
}
} catch (Exception e) { log("sub: " + e); }
try { sub = new org.json.JSONArray(prefs.getString("sub", "[]")); } catch (Exception e) { sub = new org.json.JSONArray(); }
}
private static byte[] readAll(java.io.InputStream in) throws java.io.IOException {
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
for (int n; (n = in.read(buf)) > 0; ) o.write(buf, 0, n);
return o.toByteArray();
}
private String target(String conf) {
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
return "?";
}
// ── connect / disconnect ──────────────────────────────────────────────
private boolean isUp() {
try { return backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
private boolean isUp() { return tunnelUp(); }
static boolean tunnelUp() {
try { return backend != null && backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
}
/** Ongoing notification = shield in the status bar while the VPN is on (GoBackend's service has none). */
private void notifyState(boolean up) {
android.app.NotificationManager nm = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (nm == null) return;
if (!up) { nm.cancel(1); PhobosTile.refresh(this); return; }
if (android.os.Build.VERSION.SDK_INT >= 26) {
nm.createNotificationChannel(new android.app.NotificationChannel("vpn", "VPN", android.app.NotificationManager.IMPORTANCE_LOW));
}
android.app.PendingIntent open = android.app.PendingIntent.getActivity(this, 0,
new Intent(this, MainActivity.class), android.app.PendingIntent.FLAG_IMMUTABLE);
android.app.Notification.Builder b = android.os.Build.VERSION.SDK_INT >= 26
? new android.app.Notification.Builder(this, "vpn") : new android.app.Notification.Builder(this);
b.setSmallIcon(R.drawable.ic_vpn).setContentTitle("VPN")
.setContentText("Сервер: " + target(curConf.isEmpty() ? prefs.getString("conf", "") : curConf))
.setOngoing(true).setContentIntent(open);
try { nm.notify(1, b.build()); } catch (SecurityException ignored) { }
PhobosTile.refresh(this);
}
private void toggle() {
@@ -275,6 +384,20 @@ public class MainActivity extends Activity {
@Override
protected void onActivityResult(int req, int res, Intent data) {
if (req == 9) {
if (res != RESULT_OK || data == null || data.getData() == null) return;
String name = "Phobos";
try (android.database.Cursor c = getContentResolver().query(data.getData(), null, null, null, null)) {
int i = c == null ? -1 : c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME);
if (i >= 0 && c.moveToFirst()) name = c.getString(i).replaceFirst("\\.[^.]*$", "");
} catch (Exception ignored) { }
try (java.io.InputStream in = getContentResolver().openInputStream(data.getData())) {
importText(new String(readAll(in), StandardCharsets.UTF_8), name);
} catch (Exception e) { toast("Не удалось прочитать файл: " + e.getMessage()); }
applyIfUp();
render();
return;
}
if (req == 7 && res == RESULT_OK) bg.execute(this::up);
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
}
@@ -289,13 +412,19 @@ public class MainActivity extends Activity {
wsRelay = new WsRelay(TCP_LPORT, wsUrl(conf), this::log);
wsRelay.start();
}
String target = target(conf), host = target.replaceAll(":\\d+$", "").replaceAll("[\\[\\]]", "");
String override = viaTcp || viaWs ? "127.0.0.1:" + TCP_LPORT : null;
if (!viaTcp && !viaWs && host.contains(":")) {
v6Relay = new V6Relay(V6_LPORT, host, Integer.parseInt(target.replaceAll("^.*:", "")));
v6Relay.start();
override = "127.0.0.1:" + V6_LPORT;
}
if (viaTcp) {
String host = target(conf).replaceAll(":\\d+$", "");
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
}
File cfg = new File(getFilesDir(), "obf.conf");
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp || viaWs).getBytes(StandardCharsets.UTF_8)); }
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, override).getBytes(StandardCharsets.UTF_8)); }
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
Thread.sleep(600);
@@ -306,32 +435,74 @@ public class MainActivity extends Activity {
}
private void up() {
try {
String conf = prefs.getString("conf", "");
String mode = prefs.getString("transport", "auto");
boolean tcpAvail = !tcpPort(conf).isEmpty();
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
startTransport(conf, "auto".equals(mode) ? "udp" : mode);
Config c = Config.parse(new BufferedReader(new StringReader(
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
backend.setState(TUNNEL, Tunnel.State.UP, c);
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", transport " + mode);
post("Подключено (" + transport + ")");
if ("auto".equals(mode) && tcpAvail) watchUdp(conf);
} catch (Throwable e) {
stopObfuscator();
log("up failed: " + e);
post("Ошибка: " + e.getMessage());
synchronized (LOCK) {
try {
String base = prefs.getString("conf", "");
refreshSub(base);
subIndex = 0;
connect(sub.length() > 0 ? applying(base, sub.getJSONObject(0)) : base);
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", servers " + Math.max(1, sub.length()));
notifyState(true);
post("Подключено (" + transport + ")");
if (sub.length() > 1 && !"cloud".equals(prefs.getString("transport", "auto"))) watchServer(base);
} catch (Throwable e) {
stopObfuscator();
log("up failed: " + e);
post("Ошибка: " + e.getMessage());
}
}
}
/** Obfuscator (+ relay) and WireGuard for one server; called again in place on failover. */
private void connect(String conf) throws Exception {
String mode = prefs.getString("transport", "auto");
boolean tcpAvail = !tcpPort(conf).isEmpty();
if ("tcp".equals(mode) && !tcpAvail && sub.length() == 0) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
startTransport(conf, "tcp".equals(mode) && !tcpAvail ? "udp" : "auto".equals(mode) ? "udp" : mode);
Config c = Config.parse(new BufferedReader(new StringReader(
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", ""), true))));
backend.setState(TUNNEL, Tunnel.State.UP, c);
curConf = conf;
if ("auto".equals(mode) && tcpAvail) watchUdp(conf);
}
/** Server answered nothing for SILENCE_MS -> next subscription server. Runs while the VPN service keeps the process alive. */
private void watchServer(String base) {
final long gen = ++watchGen;
new Thread(() -> {
long lastRx = -1, since = System.currentTimeMillis();
while (gen == watchGen) {
try { Thread.sleep(5000); } catch (InterruptedException e) { return; }
if (gen != watchGen || !isUp()) return;
long rx;
try { rx = backend.getStatistics(TUNNEL).totalRx(); } catch (Exception e) { continue; }
long now = System.currentTimeMillis();
if (rx != lastRx) { lastRx = rx; since = now; continue; }
if (now - since < SILENCE_MS) continue;
since = now;
synchronized (LOCK) {
if (gen != watchGen) return;
try {
subIndex = (subIndex + 1) % sub.length();
org.json.JSONObject s = sub.getJSONObject(subIndex);
log("server silent -> " + s.optString("host") + ":" + s.optInt("port"));
connect(applying(base, s));
notifyState(true);
post("Сервер не отвечал — перешёл на " + target(curConf));
} catch (Throwable e) { log("failover: " + e); }
}
}
}, "phobos-watch").start();
}
/** Auto: no bytes back over UDP within 15 s -> UDP is cut, move the obfuscator to TCP. */
private void watchUdp(String conf) {
new Thread(() -> {
try {
long rx0 = backend.getStatistics(TUNNEL).totalRx();
Thread.sleep(15000);
if (!isUp() || !"UDP".equals(transport)) return;
if (backend.getStatistics(TUNNEL).totalRx() > 0) return;
if (!isUp() || !"UDP".equals(transport) || !conf.equals(curConf)) return;
if (backend.getStatistics(TUNNEL).totalRx() > rx0) return;
log("UDP: no answer in 15 s -> switching to TCP");
startTransport(conf, "tcp");
post("UDP не отвечает — переключился на TCP");
@@ -340,9 +511,12 @@ public class MainActivity extends Activity {
}
private void down() {
watchGen++;
curConf = "";
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
stopObfuscator();
transport = "";
notifyState(false);
post("Отключено");
}
@@ -350,6 +524,7 @@ public class MainActivity extends Activity {
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
if (relay != null) { relay.destroy(); relay = null; }
if (wsRelay != null) { wsRelay.stop(); wsRelay = null; }
if (v6Relay != null) { v6Relay.stop(); v6Relay = null; }
}
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
@@ -383,7 +558,8 @@ public class MainActivity extends Activity {
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
} else {
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
prof.addView(tv("Сервер: " + target(conf), muted(), 14, false));
server = tv("", muted(), 14, false);
prof.addView(server);
}
box.addView(prof, mw(0));
@@ -448,13 +624,18 @@ public class MainActivity extends Activity {
tg.addView(r);
}
split.addView(tg);
int ns = lines(prefs.getString("my_direct", "")).size() + lines(prefs.getString("my_vpn", "")).size();
split.addView(button("Свои сайты (" + ns + ")", 0xFF475569, v -> sitesDialog()), mw(4));
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
split.addView(button("Свой DNS" + (myDns().isEmpty() ? "" : " (" + myDns() + ")"), 0xFF475569, v -> dnsDialog()), mw(8));
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
muted(), 12, false), mw(6));
box.addView(split, mw(20));
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
box.addView(button("Импорт из файла .conf", 0xFF4F46E5, v -> startActivityForResult(
new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"), 9)), mw(8));
box.addView(button("Проверить скорость", 0xFF0EA5E9, v -> speedtest()), mw(8));
box.addView(button("Журнал", 0xFF475569, v -> showLog()), mw(8));
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
@@ -468,6 +649,8 @@ public class MainActivity extends Activity {
if (power == null) return;
boolean up = isUp();
power.setText(up ? "Отключить" : "Подключить");
String conf = prefs.getString("conf", "");
if (server != null && !conf.isEmpty()) server.setText("Сервер: " + target(up && !curConf.isEmpty() ? curConf : conf));
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
status.setText(up ? "🟢 VPN включён" + (transport.isEmpty() ? "" : " · " + transport) : "⚪ VPN выключен");
if (up) {
@@ -500,6 +683,22 @@ public class MainActivity extends Activity {
.setNegativeButton("Отмена", null).show();
}
private void dnsDialog() {
final EditText e = new EditText(this);
e.setHint("1.1.1.1, 8.8.8.8");
e.setText(myDns());
new AlertDialog.Builder(this).setTitle("Свой DNS").setView(e)
.setMessage("Адреса DNS-серверов через запятую. Пусто — DNS из профиля.")
.setPositiveButton("Сохранить", (d, w) -> {
String v = String.join(", ", lines(e.getText().toString()));
if (!v.isEmpty() && !v.matches("[0-9a-fA-F:., ]+")) { toast("DNS — только IP-адреса, например 1.1.1.1"); return; }
prefs.edit().putString("dns", v).apply();
applyIfUp();
render();
})
.setNegativeButton("Отмена", null).show();
}
/** Channel "Облако": address of the user's Keenetic web app (KeenDNS cloud) with wsgw behind it. */
private void wsDialog() {
final EditText e = new EditText(this);
@@ -516,6 +715,36 @@ public class MainActivity extends Activity {
.setNegativeButton("Отмена", null).show();
}
/** User's own exceptions to "Russian sites directly": domains, IPs or networks, one per line. */
private void sitesDialog() {
LinearLayout l = new LinearLayout(this);
l.setOrientation(LinearLayout.VERTICAL);
l.setPadding(dp(16), dp(8), dp(16), 0);
l.addView(tv("Напрямую, без VPN (по одному на строку):", text(), 14, true));
final EditText direct = new EditText(this);
direct.setHint("mysite.ru\n1.2.3.4\n5.6.0.0/16");
direct.setText(prefs.getString("my_direct", ""));
direct.setMinLines(3);
l.addView(direct);
l.addView(tv("Всегда через VPN, даже если российский:", text(), 14, true), mw(8));
final EditText vpn = new EditText(this);
vpn.setHint("rutracker.org");
vpn.setText(prefs.getString("my_vpn", ""));
vpn.setMinLines(3);
l.addView(vpn);
l.addView(tv("Работает, когда включено «Российские сайты — напрямую». Сайт берётся по его текущим адресам при подключении.", muted(), 12, false), mw(6));
ScrollView sv = new ScrollView(this);
sv.addView(l);
new AlertDialog.Builder(this).setTitle("Свои сайты").setView(sv)
.setPositiveButton("Сохранить", (d, w) -> {
prefs.edit().putString("my_direct", direct.getText().toString().trim())
.putString("my_vpn", vpn.getText().toString().trim()).apply();
applyIfUp();
render();
})
.setNegativeButton("Отмена", null).show();
}
private void pickApps() {
PackageManager pm = getPackageManager();
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);

View File

@@ -0,0 +1,36 @@
package com.pca.phobos.vpn;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Build;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
/** Quick settings tile: on/off. The connect logic lives in MainActivity, which handles ACTION_TOGGLE and closes itself. */
public class PhobosTile extends TileService {
@Override
public void onStartListening() { update(); }
@Override
public void onClick() {
Intent i = new Intent(this, MainActivity.class).setAction(MainActivity.ACTION_TOGGLE)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (Build.VERSION.SDK_INT >= 34) {
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE));
} else {
startActivityAndCollapse(i);
}
}
private void update() {
Tile t = getQsTile();
if (t == null) return;
t.setState(MainActivity.tunnelUp() ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
/** Called by MainActivity after connect/disconnect so an open shade shows the new state. */
static void refresh(android.content.Context c) {
requestListeningState(c, new android.content.ComponentName(c, PhobosTile.class));
}
}

View File

@@ -0,0 +1,101 @@
package com.pca.phobos.vpn;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* AllowedIPs for "Russian sites directly": the whole IPv4 internet minus the direct set,
* where direct = Russian networks + the user's "direct" entries - the user's "via VPN" entries.
* Entries: IP, CIDR or domain (resolved to its current IPv4 addresses).
*/
final class RuNets {
private RuNets() { }
static String allowed(List<String> ruCidrs, List<String> direct, List<String> viaVpn) {
List<long[]> d = new ArrayList<>();
for (String c : ruCidrs) add(d, c);
for (String e : direct) for (String c : resolve(e)) add(d, c);
List<long[]> v = new ArrayList<>();
for (String e : viaVpn) for (String c : resolve(e)) add(v, c);
List<long[]> directSet = subtract(merge(d), merge(v));
// complement over 0.0.0.0 - 255.255.255.255
List<String> out = new ArrayList<>();
long next = 0;
for (long[] r : directSet) {
if (r[0] > next) cidrs(next, r[0] - 1, out);
next = Math.max(next, r[1] + 1);
}
if (next <= 0xFFFFFFFFL) cidrs(next, 0xFFFFFFFFL, out);
return String.join(", ", out);
}
/** "1.2.3.4", "1.2.3.0/24" or "site.ru" -> CIDRs (empty if it does not resolve). */
static List<String> resolve(String entry) {
List<String> out = new ArrayList<>();
String e = entry.trim().toLowerCase().replaceFirst("^\\w+://", "").replaceFirst("/$", "");
if (e.isEmpty() || e.startsWith("#")) return out;
if (e.matches("[0-9.]+(/\\d+)?")) { out.add(e.contains("/") ? e : e + "/32"); return out; }
e = e.replaceFirst("/.*$", "");
try {
for (InetAddress a : InetAddress.getAllByName(e)) if (a instanceof Inet4Address) out.add(a.getHostAddress() + "/32");
} catch (Exception ignored) { }
return out;
}
private static void add(List<long[]> list, String cidr) {
try {
String[] p = cidr.trim().split("/");
String[] o = p[0].split("\\.");
if (o.length != 4) return;
long ip = 0;
for (String x : o) ip = (ip << 8) | Integer.parseInt(x);
int bits = p.length > 1 ? Integer.parseInt(p[1]) : 32;
long size = 1L << (32 - bits);
long start = ip & ~(size - 1) & 0xFFFFFFFFL;
list.add(new long[]{start, start + size - 1});
} catch (Exception ignored) { }
}
private static List<long[]> merge(List<long[]> in) {
Collections.sort(in, (a, b) -> Long.compare(a[0], b[0]));
List<long[]> out = new ArrayList<>();
for (long[] r : in) {
if (!out.isEmpty() && r[0] <= out.get(out.size() - 1)[1] + 1) {
long[] last = out.get(out.size() - 1);
last[1] = Math.max(last[1], r[1]);
} else out.add(new long[]{r[0], r[1]});
}
return out;
}
private static List<long[]> subtract(List<long[]> a, List<long[]> b) {
List<long[]> out = new ArrayList<>();
for (long[] r : a) {
long s = r[0], e = r[1];
for (long[] x : b) {
if (x[1] < s || x[0] > e) continue;
if (x[0] > s) out.add(new long[]{s, x[0] - 1});
s = x[1] + 1;
if (s > e) break;
}
if (s <= e) out.add(new long[]{s, e});
}
return out;
}
private static void cidrs(long start, long end, List<String> out) {
while (start <= end) {
int bits = 32;
while (bits > 0) {
long size = 1L << (33 - bits);
if ((start & (size - 1)) != 0 || start + size - 1 > end) break;
bits--;
}
out.add(((start >> 24) & 255) + "." + ((start >> 16) & 255) + "." + ((start >> 8) & 255) + "." + (start & 255) + "/" + bits);
start += 1L << (32 - bits);
}
}
}

View File

@@ -0,0 +1,48 @@
package com.pca.phobos.vpn;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
/**
* 127.0.0.1:lport <-> [ipv6]:port over UDP: our wg-obfuscator is IPv4-only, the server side
* listens on IPv6 through phobos-v6relay. One upstream socket, like the server's relay.
*/
final class V6Relay {
private final DatagramSocket local, up;
private volatile SocketAddress peer;
private volatile boolean running = true;
V6Relay(int lport, String host, int port) throws Exception {
local = new DatagramSocket(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), lport));
up = new DatagramSocket();
up.connect(new InetSocketAddress(InetAddress.getByName(host), port));
}
void start() {
new Thread(() -> pump(local, true), "v6relay-out").start();
new Thread(() -> pump(up, false), "v6relay-in").start();
}
private void pump(DatagramSocket from, boolean out) {
byte[] buf = new byte[65535];
while (running) {
try {
DatagramPacket p = new DatagramPacket(buf, buf.length);
from.receive(p);
if (out) { peer = p.getSocketAddress(); up.send(new DatagramPacket(buf, p.getLength())); }
else if (peer != null) local.send(new DatagramPacket(buf, p.getLength(), peer));
} catch (Exception e) {
if (!running) return;
}
}
}
void stop() {
running = false;
local.close();
up.close();
}
}

1678
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,49 @@
#!/bin/sh
# ============================================================
# Phobos Health Monitor — Auto-heal + Failover for routers
# Runs via cron every 60 seconds on Keenetic/Entware
#
# Install: crontab -e → */1 * * * * /opt/etc/Phobos/phobos-health.sh
# Phobos Health Monitor v2 — Keenetic Edition
# Uses ndmc/RCI instead of wg-tools. Connectivity-based failover.
# Runs via cron every 60 seconds
# ============================================================
PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf"
STATE="$PHOBOS_DIR/state"
LOG="$PHOBOS_DIR/health.log"
WG_CONF="$PHOBOS_DIR/wg0.conf"
# 02.11.2026: несколько учёток на одном роутере. Файлы учётки (failover.conf, state,
# client_id, panel_*) — в PHOBOS_ACC (доп. учётки: $PHOBOS_DIR/acc/<клиент>),
# главная — прямо в $PHOBOS_DIR. Конфиги обфускаторов общие, свой ищем по *.link.
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
CONF="$ACC_DIR/failover.conf"
STATE="$ACC_DIR/state"
LOG="$ACC_DIR/health.log"
CID=$(cat "$ACC_DIR/client_id" 2>/dev/null)
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
LOCKFILE="/tmp/phobos-health.lock"
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
[ -n "$CID" ] && [ "$(cat "$l" 2>/dev/null)" = "$CID" ] && OBF_CONF="${l%.link}.conf" && break
done
LOCKFILE="/tmp/phobos-health${PHOBOS_ACC:+-$CID}.lock"
WG_IF="Wireguard3"
MAX_LOG_LINES=200
HANDSHAKE_WARN=180
HANDSHAKE_RESTART_OBF=300
HANDSHAKE_RESTART_WG=600
HANDSHAKE_FAILOVER=900
# Thresholds (seconds)
HANDSHAKE_WARN=150
HANDSHAKE_PORT_HOP=300
HANDSHAKE_SERVER_SWITCH=600
PRIMARY_CHECK_INTERVAL=300
# Primary-alive probe: servers block ICMP and busybox `nc -z` is unreliable,
# so reachability is tested with curl against the panel's obfuscator-health
# endpoint on the primary VPS. It returns http 200 ONLY when all
# wg-obfuscator-* services are active — the bare panel port stays up even
# when the tunnel path is dead, so we must NOT switch back on panel liveness
# alone (that caused premature switchback to a dead primary).
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$ACC_DIR/panel_scheme" 2>/dev/null)}"
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$ACC_DIR/panel_port" 2>/dev/null)}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
PRIMARY_PROBE_PATH="/api/obf-health"
# Connectivity check targets (logged for context only)
CHECK_HOST_1="8.8.8.8"
CHECK_HOST_2="1.1.1.1"
log() {
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
# Trim log
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
@@ -38,33 +59,59 @@ fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
# Create state dir
mkdir -p "$STATE"
# ── Detect WG interface ──
detect_wg_interface() {
# точное совпадение "Phobos-<клиент>"; доп. учётка не должна схватить чужой интерфейс
for i in 0 1 2 3 4 5 6 7 8 9; do
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
# HydraRoute Neo names the interface by the bare client id (09.33)
if [ -n "$CID" ] && echo "$desc" | grep -qE "(Phobos-|: )${CID}\$"; then
WG_IF="Wireguard${i}"
return 0
fi
done
[ -n "$PHOBOS_ACC" ] && return 1
for i in 0 1 2 3 4 5 6 7 8 9; do
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
if echo "$desc" | grep -qi phobos; then
WG_IF="Wireguard${i}"
return 0
fi
done
return 1
}
# ── Restart own obfuscator (init script whose ARGS name our config) ──
restart_obf() {
sc=$(grep -l "/$(basename "$OBF_CONF")\"\$" /opt/etc/init.d/S*obfuscator* 2>/dev/null | head -1)
[ -z "$sc" ] && [ -f /opt/etc/init.d/S49wg-obfuscator ] && sc=/opt/etc/init.d/S49wg-obfuscator
if [ -n "$sc" ]; then
"$sc" restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
}
# ── Read failover config ──
if [ ! -f "$CONF" ]; then
log "ERROR: no failover.conf"
exit 1
fi
# Parse servers: SERVER_1=ip:port1,port2,port3
# Parse current state
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
RESTART_COUNT=$(cat "$STATE/restart_count" 2>/dev/null || echo "0")
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
# Load server list
SERVER_COUNT=0
idx=1
while true; do
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
if [ -z "$val" ]; then
break
fi
[ -z "$val" ] && break
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
eval "SERVER_${idx}_TCP=$(grep "^TCP_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
SERVER_COUNT=$idx
idx=$((idx + 1))
done
@@ -74,22 +121,57 @@ if [ "$SERVER_COUNT" -eq 0 ]; then
exit 1
fi
# ── Get current handshake age ──
get_handshake_age() {
hs=$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}')
if [ -z "$hs" ] || [ "$hs" = "0" ]; then
echo "9999"
return
fi
now=$(date +%s)
age=$((now - hs))
echo "$age"
# Read state
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
TCP_MODE=$(cat "$STATE/tcp_mode" 2>/dev/null || echo "0") # 0 = UDP, N = TCP via server N
CYCLES=$(cat "$STATE/udp_cycles" 2>/dev/null || echo "0") # full UDP rounds without success
UDPTCP_BIN=/opt/bin/udptcp
UDPTCP_LPORT=13256
# ── Detect Phobos WG interface ──
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим (так сторож
# удалённой доп. учётки вписал пир Phobos в посторонний туннель).
detect_wg_interface || {
log "ERROR: own WG interface not found, skip"
exit 0
}
# ── Get port by index from comma-separated list ──
# ── Panel tunnel IP must go through our tunnel (HydraRoute-made interface has no
# such route → pull went to WAN and hung, 09.33) ──
PT_IP=$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)
if [ -n "$PT_IP" ] && ip route get "$PT_IP" 2>/dev/null | grep -q "dev ppp\|dev eth\|dev wan"; then
ndmc -c "ip route $PT_IP 255.255.255.255 $WG_IF auto" >/dev/null 2>&1 && log "ROUTE: $PT_IP via $WG_IF"
fi
# ── Get handshake age via ndmc ──
# A peer switch can leave the removed peer's stale handshake listed in ndmc
# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum
# positive, sane) handshake across all peers — that is the live tunnel.
get_handshake_age() {
hs=$(ndmc -c "show interface $WG_IF" 2>/dev/null \
| grep "last-handshake" \
| awk '{v=$2} v>0 && v<86400 {print v}' \
| sort -n | head -1)
if [ -z "$hs" ]; then
echo "9999"
else
echo "$hs"
fi
}
# ── Check real connectivity ──
check_connectivity() {
ping -c 1 -W 3 "$CHECK_HOST_1" >/dev/null 2>&1 && return 0
ping -c 1 -W 3 "$CHECK_HOST_2" >/dev/null 2>&1 && return 0
return 1
}
# ── Get port by index ──
get_port() {
server_idx=$1
port_idx=$2
server_idx=$1; port_idx=$2
eval "ports=\$SERVER_${server_idx}_PORTS"
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
}
@@ -100,6 +182,82 @@ get_port_count() {
echo "$ports" | tr ',' '\n' | wc -l
}
# ── Keys currently on the interface (includes the interface's OWN key) ──
# WG public keys are 43 base64 chars + '='. The interface's own public-key is
# also matched here — callers must only act on KNOWN server WGKEYs so the
# local key is never touched.
list_iface_keys() {
ndmc -c "show interface $WG_IF" 2>/dev/null \
| grep -oE '[A-Za-z0-9+/]{43}=' | sort -u
}
# Count how many KNOWN server WGKEYs are currently attached as peers.
count_server_peers() {
present=$(list_iface_keys)
n=0
seen=" "
i=1
while [ "$i" -le "$SERVER_COUNT" ]; do
eval "wk=\$SERVER_${i}_WGKEY"
# one server may have several addresses = same key in several entries
case "$seen" in *" $wk "*) i=$((i + 1)); continue ;; esac
seen="$seen$wk "
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
n=$((n + 1))
fi
i=$((i + 1))
done
echo "$n"
}
# ── Switch WG peer so EXACTLY ONE server peer (new_key) remains ──
# Failover adds a new peer; the RCI "remove" is unreliable on Keenetic and
# leaves dead peers behind. Multiple peers each with allow-ips 0.0.0.0/0 make
# egress routing ambiguous. We hard-purge every OTHER known server key via the
# ndmc CLI (reliable) — never the interface's own key — then ensure new_key.
switch_wg_peer() {
new_key=$1
if [ -z "$new_key" ]; then
log "WARN: no WGKEY for target server, skip peer switch"
return 1
fi
present=$(list_iface_keys)
# Purge any OTHER known server key that is attached
i=1
while [ "$i" -le "$SERVER_COUNT" ]; do
eval "wk=\$SERVER_${i}_WGKEY"
if [ -n "$wk" ] && [ "$wk" != "$new_key" ] && echo "$present" | grep -q "^${wk}$"; then
log "WG PEER purge: $wk"
ndmc -c "interface $WG_IF no wireguard peer $wk" >/dev/null 2>&1
fi
i=$((i + 1))
done
# Add the target peer if it is not already present
if ! echo "$present" | grep -q "^${new_key}$"; then
log "WG PEER add: $new_key"
# 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
# берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
# Через ndmc по шагам: JSON-RCI с allow-ips "::"/0 молча не ставил allow-ips.
lport=$(grep 'source-lport' "$OBF_CONF" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
lport=${lport:-13255}
ndmc -c "interface $WG_IF wireguard peer $new_key" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key endpoint 127.0.0.1:$lport" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
fi
# Persist config
ndmc -c "system configuration save" >/dev/null 2>&1
return 0
}
# ── Switch to specific server:port ──
switch_endpoint() {
server_idx=$1
@@ -107,6 +265,7 @@ switch_endpoint() {
eval "host=\$SERVER_${server_idx}_HOST"
eval "obf_key=\$SERVER_${server_idx}_KEY"
eval "wg_key=\$SERVER_${server_idx}_WGKEY"
port=$(get_port "$server_idx" "$port_idx")
if [ -z "$host" ] || [ -z "$port" ]; then
@@ -115,8 +274,12 @@ switch_endpoint() {
fi
log "SWITCH → server $server_idx ($host:$port)"
stop_tcp
# Update obfuscator config target
# 1. Switch WG peer key if different server
switch_wg_peer "$wg_key"
# 2. Update obfuscator config
if [ -f "$OBF_CONF" ]; then
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
if [ -n "$obf_key" ]; then
@@ -124,25 +287,59 @@ switch_endpoint() {
fi
fi
# Restart obfuscator
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
# 3. Restart obfuscator
restart_obf
# Save state
# 4. Save state (fail_count is managed by the caller, NOT reset here —
# resetting on a port-hop would prevent escalation to server failover)
echo "$server_idx" > "$STATE/current_server"
echo "$port_idx" > "$STATE/current_port_idx"
echo "0" > "$STATE/restart_count"
}
# ── TCP channel (09.33/1.5.0): obfuscated UDP inside TCP via local udptcp ──
stop_tcp() {
[ "$TCP_MODE" = "0" ] && ! pidof udptcp >/dev/null 2>&1 && return 0
killall udptcp 2>/dev/null
TCP_MODE=0
echo 0 > "$STATE/tcp_mode"
}
# start_tcp N — tunnel through server N's TCP port; 1 = started
start_tcp() {
n=$1
eval "host=\$SERVER_${n}_HOST"
eval "tport=\$SERVER_${n}_TCP"
eval "obf_key=\$SERVER_${n}_KEY"
eval "wg_key=\$SERVER_${n}_WGKEY"
[ -n "$tport" ] && [ -x "$UDPTCP_BIN" ] || return 1
killall udptcp 2>/dev/null
"$UDPTCP_BIN" -c "127.0.0.1:${UDPTCP_LPORT}" "${host}:${tport}" >/dev/null 2>&1 &
switch_wg_peer "$wg_key"
sed -i "s|^target = .*|target = 127.0.0.1:${UDPTCP_LPORT}|" "$OBF_CONF"
[ -n "$obf_key" ] && sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
restart_obf
TCP_MODE=$n
echo "$n" > "$STATE/tcp_mode"
echo "$n" > "$STATE/current_server"
log "TCP MODE → server $n (${host}:${tport} over TCP)"
return 0
}
# try_tcp_after N — first server after N (no wrap) that has a TCP port; 1 = none
try_tcp_after() {
i=$(( $1 + 1 ))
while [ "$i" -le "$SERVER_COUNT" ]; do
start_tcp "$i" && return 0
i=$((i + 1))
done
return 1
}
# ── Try next port on current server ──
try_next_port() {
port_count=$(get_port_count "$CURRENT_SERVER")
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
if [ "$next_idx" -eq 0 ]; then
return 1
fi
[ "$next_idx" -eq 0 ] && return 1
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
switch_endpoint "$CURRENT_SERVER" "$next_idx"
@@ -155,93 +352,233 @@ try_next_server() {
if [ "$next" -gt "$SERVER_COUNT" ]; then
next=1
fi
if [ "$next" -eq "$CURRENT_SERVER" ]; then
log "WARN: only one server, can't failover"
return 1
fi
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
log "FAILOVER → server $next"
switch_endpoint "$next" "0"
return 0
}
# ── Check if primary is back (every 5 min when on secondary) ──
# ── Check if primary is back ──
check_primary() {
if [ "$CURRENT_SERVER" -eq 1 ]; then
return
fi
[ "$CURRENT_SERVER" -eq 1 ] && [ "$TCP_MODE" = "0" ] && return
now=$(date +%s)
elapsed=$((now - PRIMARY_CHECK_TS))
if [ "$elapsed" -lt 300 ]; then
return
fi
[ "$elapsed" -lt "$PRIMARY_CHECK_INTERVAL" ] && return
echo "$now" > "$STATE/primary_check_ts"
eval "host=\$SERVER_1_HOST"
port=$(get_port 1 0)
# Quick UDP probe — send a byte, see if port responds
if ping -c 1 -W 2 "$host" >/dev/null 2>&1; then
log "PRIMARY alive, switching back"
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
# obfuscator path is actually up. Anything else (000 no-response, 503
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
ok_url=""
last_code=""
for url in "${PRIMARY_PROBE_SCHEME}://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" "http://${host}:8444${PRIMARY_PROBE_PATH}"; do
code=$(curl -k -s -m 4 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
last_code="$code"
if [ "$code" = "200" ]; then
ok_url="$url"
break
fi
done
if [ -n "$ok_url" ]; then
# HTTP alive does not mean UDP passes (провайдер режет UDP к IP, 09.33):
# пробуем, а если рукопожатия не будет — trial_check вернёт как было.
log "PRIMARY ($ok_url http=200) alive, TRIAL switch back"
echo "$CURRENT_SERVER $CURRENT_PORT_IDX $TCP_MODE $(date +%s)" > "$STATE/trial"
switch_endpoint 1 0
echo "0" > "$STATE/fail_count"
else
log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
fi
}
# ── Main logic ──
AGE=$(get_handshake_age)
# ── LAN routing self-heal ──────────────────────
# A router REBOOT silently drops the per-device `ip hotspot host <mac> policy`
# binding and can reset the WG interface security-level, so LAN clients leak to
# WAN (no VPN). This:
# 1) ensures WG_IF security-level = public (needed for masquerade),
# 2) LEARNS current host->policy bindings into state/lan-hosts (append-only),
# 3) RE-APPLIES any learned binding that is currently missing.
# Append-only learning means a reboot (which clears live bindings) never erases
# the record, so the next run restores them. ndmc show running-config is heavy,
# so the caller gates this to run only every few minutes.
heal_lan_routing() {
LANHOSTS="$STATE/lan-hosts"
rc=$(ndmc -c "show running-config" 2>/dev/null)
[ -z "$rc" ] && return 0
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
# All good, reset counters
if [ "$RESTART_COUNT" -gt 0 ]; then
log "OK: connection restored (age=${AGE}s)"
echo "0" > "$STATE/restart_count"
# 1) WG interface must be public
sl=$(echo "$rc" | awk -v ifc="interface $WG_IF" '$0~ifc{f=1} f&&/security-level/{print $2; exit}')
if [ -n "$sl" ] && [ "$sl" != "public" ]; then
ndmc -c "interface $WG_IF security-level public" >/dev/null 2>&1
ndmc -c "system configuration save" >/dev/null 2>&1
log "HEAL: $WG_IF security-level -> public"
fi
# 2) learn live bindings (append-only) into lan-hosts
touch "$LANHOSTS"
echo "$rc" | grep -oE "host [0-9a-f:]+ policy [A-Za-z0-9_]+" | while read -r _h mac _p pol; do
grep -q "^$mac " "$LANHOSTS" 2>/dev/null || echo "$mac $pol" >> "$LANHOSTS"
done
# 3) re-apply any learned binding that is missing live
changed=0
while read -r mac pol; do
[ -z "$mac" ] && continue
case "$mac" in \#*) continue;; esac
# политику удалили — не воскрешаем привязку к ней
echo "$rc" | grep -qx "ip policy $pol" || continue
if ! echo "$rc" | grep -q "host $mac policy $pol"; then
ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1
log "HEAL: re-bound host $mac -> $pol"
changed=1
fi
done < "$LANHOSTS"
[ "$changed" = 1 ] && ndmc -c "system configuration save" >/dev/null 2>&1
}
# ══════════════════════════════════════════════
# Explicit apply hook (used by phobos-pull.sh after a config change).
# `phobos-health.sh apply-server N` re-points the tunnel to SERVER_N
# immediately, skipping the failure-escalation logic. Reuses
# switch_endpoint so the WG-peer/obfuscator/state changes stay identical
# to a normal failover. Resets fail_count so the new server starts clean.
# ══════════════════════════════════════════════
if [ "$1" = "apply-server" ]; then
idx="${2:-1}"
eval "ahost=\$SERVER_${idx}_HOST"
if [ -z "$ahost" ]; then
log "APPLY: server $idx not in conf, ignore"
exit 1
fi
log "APPLY: pull requested server $idx ($ahost)"
switch_endpoint "$idx" "0"
echo "0" > "$STATE/fail_count"
echo "$idx" > "$STATE/current_server"
exit 0
fi
# ══════════════════════════════════════════════
# Pre-check: fix desync (obfuscator targeting wrong server)
# ══════════════════════════════════════════════
if [ -f "$OBF_CONF" ]; then
cur_target=$(grep "^target = " "$OBF_CONF" 2>/dev/null | sed 's/target = //')
eval "expected_host=\$SERVER_${CURRENT_SERVER}_HOST"
if [ -n "$cur_target" ] && [ -n "$expected_host" ]; then
echo "$cur_target" | grep -q "$expected_host" || {
log "DESYNC: obf=$cur_target state=server${CURRENT_SERVER}($expected_host). Resync."
switch_endpoint "$CURRENT_SERVER" "$CURRENT_PORT_IDX"
sleep 5
}
fi
fi
# ── LAN routing self-heal (gated ~5 min; a reboot drops host->policy bindings) ──
HEAL_TS=$(cat "$STATE/heal_ts" 2>/dev/null || echo 0)
now_heal=$(date +%s)
if [ $((now_heal - HEAL_TS)) -ge 300 ]; then
echo "$now_heal" > "$STATE/heal_ts"
heal_lan_routing
fi
# ── Peer hygiene: keep exactly ONE WG peer = current server's key ──
# Failovers can leave dead/duplicate peers; multiple 0.0.0.0/0 peers cause
# ambiguous egress routing. Self-heal here every run (cheap when already clean).
eval "cur_wgkey=\$SERVER_${CURRENT_SERVER}_WGKEY"
if [ -n "$cur_wgkey" ]; then
peer_n=$(count_server_peers)
if [ "$peer_n" -gt 1 ]; then
log "HYGIENE: $peer_n server peers present, purging to server${CURRENT_SERVER}"
switch_wg_peer "$cur_wgkey"
fi
fi
# ══════════════════════════════════════════════
# Main logic
# Tunnel health = WireGuard handshake age (authoritative: a fresh
# handshake only happens through the full obfuscator→server→WG path).
# WAN ping is logged for context ONLY — it does NOT gate decisions,
# because the router's default route is not the tunnel, so WAN can be
# up while the tunnel is dead (and a WAN blip must not cause failover).
# ══════════════════════════════════════════════
AGE=$(get_handshake_age)
WAN=$(check_connectivity && echo "yes" || echo "no")
# ── Trial of the primary: fresh handshake within ~2 min or go back ──
if [ -f "$STATE/trial" ]; then
read t_srv t_port t_tcp t_ts < "$STATE/trial"
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
log "TRIAL ok: primary works"
rm -f "$STATE/trial"; echo 0 > "$STATE/udp_cycles"
elif [ $(( $(date +%s) - ${t_ts:-0} )) -ge 110 ]; then
rm -f "$STATE/trial"
log "TRIAL failed: back to server $t_srv"
if [ "${t_tcp:-0}" != "0" ]; then start_tcp "$t_tcp" || switch_endpoint "$t_srv" "$t_port"
else switch_endpoint "$t_srv" "$t_port"; fi
echo 0 > "$STATE/fail_count"
exit 0
else
exit 0 # give the trial time
fi
fi
# Tunnel healthy = fresh handshake
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
if [ "$FAIL_COUNT" -gt 0 ]; then
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
echo "0" > "$STATE/fail_count"
fi
echo 0 > "$STATE/udp_cycles"
check_primary
exit 0
fi
log "STALE handshake: ${AGE}s (server=$CURRENT_SERVER port_idx=$CURRENT_PORT_IDX restarts=$RESTART_COUNT)"
# Stale handshake = tunnel down → escalate.
# fail_count is MONOTONIC: it climbs across stages and is only reset by the
# OK branch (real recovery) or after a successful failover to a NEW server
# (so the new server gets a fresh restart→port-hop→failover cycle).
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$FAIL_COUNT" > "$STATE/fail_count"
log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PORT_IDX fails=$FAIL_COUNT"
if [ "$AGE" -lt "$HANDSHAKE_RESTART_OBF" ]; then
# Restart obfuscator only
RESTART_COUNT=$((RESTART_COUNT + 1))
echo "$RESTART_COUNT" > "$STATE/restart_count"
log "ACTION: restart obfuscator (attempt $RESTART_COUNT)"
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
# Stage 1 (fail 1): Restart obfuscator
if [ "$FAIL_COUNT" -eq 1 ]; then
log "ACTION: restart obfuscator"
restart_obf
exit 0
fi
if [ "$AGE" -lt "$HANDSHAKE_RESTART_WG" ]; then
# Restart WireGuard + obfuscator
RESTART_COUNT=$((RESTART_COUNT + 1))
echo "$RESTART_COUNT" > "$STATE/restart_count"
log "ACTION: restart WG + obfuscator (attempt $RESTART_COUNT)"
wg-quick down wg0 2>/dev/null
killall wg-obfuscator 2>/dev/null
sleep 2
wg-quick up wg0 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
exit 0
# TCP mode went stale → back to the UDP round (next server)
if [ "$TCP_MODE" != "0" ] && [ "$FAIL_COUNT" -ge 2 ]; then
log "TCP stale on server $TCP_MODE"
if try_tcp_after "$TCP_MODE"; then echo 0 > "$STATE/fail_count"; exit 0; fi
stop_tcp
switch_endpoint 1 0; echo 0 > "$STATE/fail_count"; exit 0
fi
if [ "$AGE" -lt "$HANDSHAKE_FAILOVER" ]; then
# Try next port
log "ACTION: try port hop"
if ! try_next_port; then
log "ACTION: all ports exhausted, try next server"
try_next_server
# Stage 2 (fail 2+): hop through ALL ports of the SAME server (was: only one)
if [ "$FAIL_COUNT" -ge 2 ]; then
if try_next_port; then
exit 0
fi
exit 0
log "ports of server $CURRENT_SERVER exhausted, escalate to failover"
fi
# Full failover
log "ACTION: failover (handshake ${AGE}s)"
if ! try_next_server; then
# Only one server, cycle ports
try_next_port
# Whole UDP round failed (all servers × ports) → TCP, if any server offers it
if [ "$CURRENT_SERVER" -eq "$SERVER_COUNT" ]; then
CYCLES=$((CYCLES + 1)); echo "$CYCLES" > "$STATE/udp_cycles"
if try_tcp_after 0; then echo 0 > "$STATE/fail_count"; exit 0; fi
fi
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server
# gets its own restart→port-hop→failover cycle next ticks.
log "ACTION: server failover (fail $FAIL_COUNT)"
if try_next_server; then
echo "0" > "$STATE/fail_count"
else
# Only one server — cycle back to port 0 and restart escalation
echo "0" > "$STATE/fail_count"
switch_endpoint "$CURRENT_SERVER" "0"
fi

51
docs/DNS.md Normal file
View File

@@ -0,0 +1,51 @@
# DNS для клиентов и свой AdGuard Home
## Какие DNS получают клиенты
По умолчанию в конфиге клиента (секция `[Interface]`) стоит строка
```
DNS = 1.1.1.1, 8.8.8.8
```
То есть Cloudflare и Google.
## Где посмотреть и поменять
**В панели:** «Настройки» → «DNS для клиентов». Впишите адреса через запятую. Если поле пустое, используются 1.1.1.1 и 8.8.8.8.
- Новые клиенты получают новый DNS сразу.
- Уже выданным клиентам достаточно заново открыть ссылку или QR, пересоздавать клиента не нужно.
- Значение хранится в `/opt/Phobos/server/server.env` в строке `CLIENT_DNS=...`, без пробелов.
**Вручную, только для одного клиента:** исправьте строку `DNS =` в его `.conf` и заново импортируйте конфиг.
**Роутеры Keenetic** раздают устройствам в домашней сети свой собственный DNS, строка из конфига на них не влияет.
**Firewall клиента (🛡).** Если клиенту задан список разрешённых адресов, всё остальное блокируется, и запросы к DNS тоже. Добавьте в разрешённые адреса DNS-серверов, например `1.1.1.1:53/udp, 8.8.8.8:53/udp`.
## Свой AdGuard Home на том же VPS
> `127.0.0.1` в конфиг клиента прописывать **нельзя**. Строку `DNS =` выполняет устройство клиента, и `127.0.0.1` для него — это оно само, а не сервер.
Указывать нужно адрес сервера **внутри туннеля**. Это первый адрес подсети WG, его видно в строке `Address` файла `/etc/wireguard/wg0.conf` на сервере, например `10.25.11.1`. Закрытый наружу 53 порт ничему не мешает: запросы идут внутри туннеля через интерфейс `wg0`.
1. **AGH должен слушать адрес wg0.** В `AdGuardHome.yaml`, в разделе `dns.bind_hosts`, должен быть `10.25.11.1` или `0.0.0.0`. Если там только `127.0.0.1`, добавьте адрес wg0 и перезапустите AGH.
2. **Откройте 53 порт только из туннеля** (нужно, если входящие соединения на сервере закрыты):
```bash
iptables -I INPUT -i wg0 -p udp --dport 53 -j ACCEPT
iptables -I INPUT -i wg0 -p tcp --dport 53 -j ACCEPT
```
Чтобы правило пережило перезагрузку, сохраните его, например через `netfilter-persistent save`.
3. **Пропишите этот адрес клиентам:** панель → «Настройки» → «DNS для клиентов» → `10.25.11.1`.
4. Если у клиента включён firewall 🛡, добавьте в разрешённые `10.25.11.1:53/udp`.
Проверка с устройства клиента при включённом VPN:
```bash
nslookup ya.ru 10.25.11.1
```
Запрос должен появиться в журнале запросов AGH.
**Если серверов несколько.** Клиент может переключиться на другой сервер, а адрес DNS в конфиге один для всех. Поэтому либо ставьте AGH на каждый сервер так, чтобы он слушал адрес wg0 этого сервера, либо оставьте общедоступные DNS.

View File

@@ -11,6 +11,8 @@ fails", or "panel is too exposed".
| `PANEL_PORT` | TCP | admin browser, secondary setup/register, router pull fallback | yes | Web panel and `/api/*`. Real value: `cat /opt/phobos-panel/.port`. |
| `80` | TCP | routers during install, Android APK download | recommended | nginx serves `/init/`, `/packages/`, `/app/`. |
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. Real value: `grep OBFUSCATOR_PORTS /opt/Phobos/server/server.env`. |
| `443` (else `8443`/`2096`/`4433`) | TCP | PCA Phobos VPN apps, Windows, routers (1.5.0+) when UDP is blocked | recommended | TCP fallback `phobos-tcp` (udptcp) since 1.4.1; port chosen at install/update, shown in `phobos-update` output (`TCP fallback: port N`). |
| second public IP: obfuscator ports + TCP | UDP/TCP | routers whose ISP blocks the main IP | optional | `phobos-altip` (1.5.0) DNATs the second IP to the main one; list it in panel → «Адрес для клиентов» → spare addresses. |
| `51820` | UDP | plain WireGuard clients only | no by default | Usually blocked to force obfuscation. Open only if installed with `ALLOW_PLAIN_WG=1` for iOS/plain WG. |
| `22` | TCP | admin only | optional | SSH maintenance. Do not expose broadly. |

47
docs/SERVER-SWITCH.md Normal file
View File

@@ -0,0 +1,47 @@
# Смена сервера у клиента: что и как работает
Клиент (учётка) в панели привязан к серверу. Если в панели открыть «Редактировать» и выбрать другой
сервер, адрес меняется у клиента **автоматически только в наших приложениях**. Сторонние клиенты
(PhobosWG и др.) ничего не знают о панели — им нужно заново импортировать ссылку `phobos://`.
## Кто подхватывает смену сервера
| Клиент | Как узнаёт о смене | Задержка |
|---|---|---|
| Роутер (Keenetic/OpenWrt) | pull-агент `phobos-pull.sh` раз в минуту забирает `failover.conf`; `phobos-health.sh` видит рассинхрон (DESYNC) и переключает (SWITCH) | до ~1 мин |
| iOS (build 7+), Android 1.4.5+ | подписка `# pca-sub` в ссылке: приложение читает `/api/phone-sub/<клиент>?token=…` | при запуске и по таймеру |
| Windows 1.4.5+ | помощник `sub` читает `# pca-sub`, раз в минуту; при другом host/ключе переписывает `profile.conf` и переподключается | до ~1 мин |
| Сторонние (PhobosWG и др.) | никак | переимпорт ссылки вручную |
## Как это устроено
1. Панель → Клиенты → «Редактировать» → «Сервер» (поле `client_assignments` в `settings.json`).
2. Панель добавляет WG-пир клиента на выбранный сервер и рассылает конфиг (`fanout_router_config`).
3. «Переход на другие серверы»: «Да, при блокировке» — выбранный сервер первый, остальные запасные;
«Только этот сервер» (`client_pinned`) — в списке один выбранный сервер.
4. Список отдаётся роутеру (`failover.conf`) и телефонам/Windows (`/api/phone-sub`); первый в списке — назначенный.
## Что нужно, чтобы это работало
- **Проверено 03.11.2026** (главная панель на ВМ + secondary на VPS): регистрация (`registered`), пир клиента появляется на secondary, «Сервер» меняет порядок в `/api/phone-sub` (назначенный первый), «Только этот сервер» оставляет один. Команда secondary в панели сама подставляет подсеть главной.
- **Один сервер управления.** Остальные серверы ставятся как secondary командой из панели
(раздел «Серверы»): общий список клиентов и адресов. Отдельная панель на втором VPS (полный
`install.sh`) — это другая панель со своими клиентами, смена сервера между ними не работает.
- Если всё же ставите независимые серверы — у каждого своя подсеть (`10.25.0.0/16`, `10.25.11.0/24`, …),
иначе клиенты получат одинаковые IP и второй туннель на роутере не поднимется.
Подсеть вида `10.25.11.0/16` установщик превращает в `10.25.11.0/24`.
- В ссылке клиента должна быть строка `# pca-sub = …`. Старые ссылки без неё импортировать заново один раз.
- Несколько учёток на одном роутере (по стране на учётку): у каждой «Только этот сервер», в beta/dev
они ставятся в `/opt/etc/Phobos/acc/<учётка>/`. Не пробовать на канале stable (v1.4.7): там затрёт главную.
## Как проверить за минуту
```bash
# что панель отдаёт клиенту (первый сервер = назначенный)
curl -s "http://ПАНЕЛЬ:ПОРТ/api/phone-sub/КЛИЕНТ?token=ТОКЕН"
# роутер: новый сервер и журнал переключения
cat /opt/etc/Phobos/failover.conf | grep ^SERVER_1; tail /opt/etc/Phobos/health.log
# Windows: журнал и профиль
type C:\ProgramData\PCAPhobos\pcaphobos.log # «panel moved us to … -> reconnect»
```
## Ограничения
- Windows-помощник сравнивает host, ключ обфускатора и WG-ключ; смена только порта не вызывает переподключения.
- Пока клиент оффлайн, он узнает о смене при следующем запуске/подключении.

29
docs/WARP.md Normal file
View File

@@ -0,0 +1,29 @@
# WARP: проверка и типичные проблемы
WARP включается в панели: «Настройки» → WARP. Режим egress выпускает исходящий трафик клиентов в интернет через Cloudflare: всех клиентов или только отмеченных.
## Как понять, что WARP реально работает
В карточке WARP есть строка **Handshake**:
- **«N с назад»** — связь с Cloudflare есть.
- **«нет — UDP до Cloudflare не проходит»** — интерфейс поднят, но Cloudflare не отвечает. Трафик клиентов через WARP идти не будет.
Проверка с сервера:
```bash
wg show warp latest-handshakes
curl --interface warp -m 6 https://www.cloudflare.com/cdn-cgi/trace | grep warp= # ожидается warp=on
phobos-warp status
```
## Что делает панель сама
- **Ищет рабочий адрес Cloudflare.** При включении перебирает адреса и порты (2408 / 500 / 1701 / 4500) и записывает в конфиг первый, с которым появился handshake. Если не подошёл ни один, это занимает около минуты.
- **Исправляет размер пакетов.** У WARP MTU 1280, у клиентов 1420. Без поправки крупные TCP-пакеты терялись, и интернет у клиентов работал «через раз». Теперь сервер автоматически уменьшает размер TCP-пакетов под WARP (правило MSS-clamp).
## «У клиентов нет интернета», IP Cloudflare показывается через раз
1. Обновите панель до версии с этими исправлениями («Версия и обновления»), затем выключите и снова включите WARP.
2. Посмотрите строку Handshake. Если там «нет», значит UDP до Cloudflare с этого VPS режется. Обычно так бывает на VPS в РФ, где может не проходить даже регистрация WARP. В этом случае держите WARP выключенным или используйте его на сервере за рубежом.
3. Если включён firewall клиента (🛡), проверьте, что в разрешённых есть DNS-серверы. См. [DNS.md](DNS.md).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 608 KiB

View File

@@ -19,6 +19,7 @@
# OBF_PORTS 2083,5443,993 obfuscator listen ports
# TG_TOKEN / TG_CHAT Telegram alerts
# PCA_BRANCH main branch to pull PCA files from
# SKIP_NGINX 0 1 = do not touch an existing nginx
# ============================================================
set -e
@@ -29,11 +30,46 @@ PANEL_SCHEME="${PANEL_SCHEME:-http}"
TG_TOKEN="${TG_TOKEN:-}"
TG_CHAT="${TG_CHAT:-}"
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
# Уже есть wg0.conf (переустановка) — подсеть берём из него, иначе server.env разойдётся с интерфейсом
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -f /etc/wireguard/wg0.conf ]; then
SERVER_WG_IPV4_NETWORK="$(python3 - <<'PY' 2>/dev/null
import ipaddress, re
for ln in open("/etc/wireguard/wg0.conf"):
m = re.match(r"\s*Address\s*=\s*([^,\s]+)", ln)
if m:
print(ipaddress.ip_interface(m.group(1)).network); break
PY
)" || SERVER_WG_IPV4_NETWORK=""
fi
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
printf '\n!!! Это ВТОРОЙ сервер к уже работающей панели? Тогда прервите (Ctrl+C) и ставьте командой из панели\n «Серверы -> Как добавить второй сервер» (secondary-setup.sh), подсеть там по умолчанию.\n Своя подсеть нужна только для НЕЗАВИСИМОЙ панели (например 10.25.11.0/24, не пересекается с первой);\n смена сервера у клиентов между независимыми панелями не работает.\n\n' > /dev/tty
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
fi
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
try:
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
except ValueError:
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
# Считаем, что нужна подсеть этого адреса, и берём /24.
try:
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
except ValueError:
sys.exit("ERROR: bad subnet " + sys.argv[1])
if net.version != 4 or not net.is_private or net.prefixlen > 29:
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
print(net)
PY
)" || exit 1
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
CHANNEL="${CHANNEL:-stable}"
case "$CHANNEL" in
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
@@ -69,7 +105,8 @@ print(ipaddress.ip_network(sys.argv[1], strict=False).prefixlen)
PY
)"
SERVER_IP=$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}')
# ponytail: SERVER_IP можно задать явно — для серверов с несколькими IP
SERVER_IP="${SERVER_IP:-$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}')}"
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
IFACE="${IFACE:-eth0}"
ARCH=$(uname -m)
@@ -305,6 +342,8 @@ systemctl daemon-reload; systemctl enable phobos-panel -q; systemctl restart pho
# ── 8. nginx (serve /init + /packages over plain HTTP for routers) ──
echo "[8/9] nginx..."
# ponytail: SKIP_NGINX=1 — на сервере уже есть свой nginx; /init и /packages отдаёт панель
if [ "${SKIP_NGINX:-0}" = "1" ]; then echo " skipped (SKIP_NGINX=1)"; else
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
cat > /etc/nginx/sites-available/phobos <<'NGINX'
server {
@@ -317,8 +356,9 @@ server {
}
NGINX
ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos
mkdir -p /opt/Phobos/www/app; chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages /opt/Phobos/www/app
nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed"
fi
mkdir -p /opt/Phobos/www/app; chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages /opt/Phobos/www/app
# ── 9. router watchdog (auto reboot-recovery) ──
echo "[9/9] router watchdog..."
@@ -336,6 +376,13 @@ sleep 2
mkdir -p "$PANEL_DIR"
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
# при переустановке settings.json остаётся старый — в сводке показываем то, что реально в панели
_S="$PANEL_DIR/settings.json"
if [ -f "$_S" ]; then
API_KEY="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("server_api_key") or sys.argv[2])' "$_S" "$API_KEY" 2>/dev/null || echo "$API_KEY")"
PANEL_PASS="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("admin_pass") or sys.argv[2])' "$_S" "$PANEL_PASS" 2>/dev/null || echo "$PANEL_PASS")"
fi
echo ""
echo "============================================"
echo " Installation complete"

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

View File

@@ -1,45 +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>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.bobyrev.pcaphobos.link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>phobos</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,101 +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
}
/// "# pca-ws = wss://…" — the user's Keenetic (KeenDNS cloud subdomain -> wsgw), channel «Облако».
var wsURL: String? {
for rawLine in raw.split(separator: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("#"), line.contains("pca-ws"), let eq = line.firstIndex(of: "=") {
return line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
}
}
return nil
}
func obfuscatorConfig(listen: Int, target: String) -> String {
var out = "[instance]\nsource-if = 127.0.0.1\nsource-lport = \(listen)\ntarget = \(target)\n"
for item in lines("[instance]") where !["source-if", "source-lport", "target", "verbose"].contains(item.key) {
out += item.line + "\n"
}
return out + "verbose = INFO\n"
}
func wireGuardConfig(endpoint: String, allowedIPs: String?) -> String {
var out = "[Interface]\n"
for item in lines("[Interface]") where !item.key.hasSuffix("applications") && item.key != "mtu" { out += item.line + "\n" }
// ponytail: obfuscator masking adds bytes; the panel's default 1420 stalls on mobile networks,
// so 1420/missing -> 1280, any other MTU in the link wins (set per client in the panel)
let linkMTU = lines("[Interface]").first { $0.key == "mtu" }
.flatMap { Int($0.line.split(separator: "=").last?.trimmingCharacters(in: .whitespaces) ?? "") }
let mtu = (linkMTU == nil || linkMTU == 1420) ? 1280 : linkMTU!
out += "MTU = \(mtu)\n"
out += "\n[Peer]\n"
for item in lines("[Peer]") {
switch item.key {
case "endpoint": out += "Endpoint = \(endpoint)\n"
case "allowedips" where allowedIPs != nil: out += "AllowedIPs = \(allowedIPs!)\n"
default: out += item.line + "\n"
}
}
return out
}
}

View File

@@ -1,41 +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"))
precondition(wg.contains("MTU = 1280") && !wg.contains("1420")) // panel default -> 1280
let custom = PhobosProfile.fromLink("phobos://" + Data(conf.replacingOccurrences(of: "MTU = 1420", with: "MTU = 1300").utf8).base64EncodedString())!.0
precondition(custom.wireGuardConfig(endpoint: "e", allowedIPs: nil).contains("MTU = 1300")) // link MTU wins
print("profile_check ok")

View File

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

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>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.packet-tunnel</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

@@ -1,21 +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);
/* Mode "Облако": each packet from the obfuscator goes to out() (Swift sends it over WebSocket);
packets from the WebSocket are handed back with pcarelay_ws_in(). */
typedef void (*pcarelay_ws_out_fn)(const unsigned char *p, int n);
void pcarelay_use_ws(pcarelay_ws_out_fn out);
int pcarelay_is_ws(void);
void pcarelay_ws_in(const unsigned char *p, int n);
/* wg-obfuscator compiled in with -Dmain=wgobf_main (one instance per process). */
int wgobf_main(int argc, char **argv);
#endif

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

View File

@@ -1,193 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
import Network
import NetworkExtension
#if SWIFT_PACKAGE
import WireGuardKitC
#endif
/// A type alias for `Result` type that holds a tuple with source and resolved endpoint.
typealias EndpointResolutionResult = Result<(Endpoint, Endpoint), DNSResolutionError>
/// PCA Phobos: IPv4 CIDRs that must not go into the tunnel (set before adapter.start).
public enum PhobosRoutes {
public static var excludedIPv4: [String] = []
}
class PacketTunnelSettingsGenerator {
let tunnelConfiguration: TunnelConfiguration
let resolvedEndpoints: [Endpoint?]
init(tunnelConfiguration: TunnelConfiguration, resolvedEndpoints: [Endpoint?]) {
self.tunnelConfiguration = tunnelConfiguration
self.resolvedEndpoints = resolvedEndpoints
}
func endpointUapiConfiguration() -> (String, [EndpointResolutionResult?]) {
var resolutionResults = [EndpointResolutionResult?]()
var wgSettings = ""
assert(tunnelConfiguration.peers.count == resolvedEndpoints.count)
for (peer, resolvedEndpoint) in zip(self.tunnelConfiguration.peers, self.resolvedEndpoints) {
wgSettings.append("public_key=\(peer.publicKey.hexKey)\n")
let result = resolvedEndpoint.map(Self.reresolveEndpoint)
if case .success((_, let resolvedEndpoint)) = result {
if case .name = resolvedEndpoint.host { assert(false, "Endpoint is not resolved") }
wgSettings.append("endpoint=\(resolvedEndpoint.stringRepresentation)\n")
}
resolutionResults.append(result)
}
return (wgSettings, resolutionResults)
}
func uapiConfiguration() -> (String, [EndpointResolutionResult?]) {
var resolutionResults = [EndpointResolutionResult?]()
var wgSettings = ""
wgSettings.append("private_key=\(tunnelConfiguration.interface.privateKey.hexKey)\n")
if let listenPort = tunnelConfiguration.interface.listenPort {
wgSettings.append("listen_port=\(listenPort)\n")
}
if !tunnelConfiguration.peers.isEmpty {
wgSettings.append("replace_peers=true\n")
}
assert(tunnelConfiguration.peers.count == resolvedEndpoints.count)
for (peer, resolvedEndpoint) in zip(self.tunnelConfiguration.peers, self.resolvedEndpoints) {
wgSettings.append("public_key=\(peer.publicKey.hexKey)\n")
if let preSharedKey = peer.preSharedKey?.hexKey {
wgSettings.append("preshared_key=\(preSharedKey)\n")
}
let result = resolvedEndpoint.map(Self.reresolveEndpoint)
if case .success((_, let resolvedEndpoint)) = result {
if case .name = resolvedEndpoint.host { assert(false, "Endpoint is not resolved") }
wgSettings.append("endpoint=\(resolvedEndpoint.stringRepresentation)\n")
}
resolutionResults.append(result)
let persistentKeepAlive = peer.persistentKeepAlive ?? 0
wgSettings.append("persistent_keepalive_interval=\(persistentKeepAlive)\n")
if !peer.allowedIPs.isEmpty {
wgSettings.append("replace_allowed_ips=true\n")
peer.allowedIPs.forEach { wgSettings.append("allowed_ip=\($0.stringRepresentation)\n") }
}
}
return (wgSettings, resolutionResults)
}
func generateNetworkSettings() -> NEPacketTunnelNetworkSettings {
/* iOS requires a tunnel endpoint, whereas in WireGuard it's valid for
* a tunnel to have no endpoint, or for there to be many endpoints, in
* which case, displaying a single one in settings doesn't really
* make sense. So, we fill it in with this placeholder, which is not
* a valid IP address that will actually route over the Internet.
*/
let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
if !tunnelConfiguration.interface.dnsSearch.isEmpty || !tunnelConfiguration.interface.dns.isEmpty {
let dnsServerStrings = tunnelConfiguration.interface.dns.map { $0.stringRepresentation }
let dnsSettings = NEDNSSettings(servers: dnsServerStrings)
dnsSettings.searchDomains = tunnelConfiguration.interface.dnsSearch
if !tunnelConfiguration.interface.dns.isEmpty {
dnsSettings.matchDomains = [""] // All DNS queries must first go through the tunnel's DNS
}
networkSettings.dnsSettings = dnsSettings
}
let mtu = tunnelConfiguration.interface.mtu ?? 0
/* 0 means automatic MTU. In theory, we should just do
* `networkSettings.tunnelOverheadBytes = 80` but in
* practice there are too many broken networks out there.
* Instead set it to 1280. Boohoo. Maybe someday we'll
* add a nob, maybe, or iOS will do probing for us.
*/
if mtu == 0 {
#if os(iOS)
networkSettings.mtu = NSNumber(value: 1280)
#elseif os(macOS)
networkSettings.tunnelOverheadBytes = 80
#else
#error("Unimplemented")
#endif
} else {
networkSettings.mtu = NSNumber(value: mtu)
}
let (ipv4Addresses, ipv6Addresses) = addresses()
let (ipv4IncludedRoutes, ipv6IncludedRoutes) = includedRoutes()
let ipv4Settings = NEIPv4Settings(addresses: ipv4Addresses.map { $0.destinationAddress }, subnetMasks: ipv4Addresses.map { $0.destinationSubnetMask })
ipv4Settings.includedRoutes = ipv4IncludedRoutes
// PCA Phobos: "Russian sites direct" = these networks bypass the tunnel
ipv4Settings.excludedRoutes = PhobosRoutes.excludedIPv4.compactMap { cidr in
guard let range = IPAddressRange(from: cidr) else { return nil }
return NEIPv4Route(destinationAddress: "\(range.address)", subnetMask: "\(range.subnetMask())")
}
networkSettings.ipv4Settings = ipv4Settings
let ipv6Settings = NEIPv6Settings(addresses: ipv6Addresses.map { $0.destinationAddress }, networkPrefixLengths: ipv6Addresses.map { $0.destinationNetworkPrefixLength })
ipv6Settings.includedRoutes = ipv6IncludedRoutes
networkSettings.ipv6Settings = ipv6Settings
return networkSettings
}
private func addresses() -> ([NEIPv4Route], [NEIPv6Route]) {
var ipv4Routes = [NEIPv4Route]()
var ipv6Routes = [NEIPv6Route]()
for addressRange in tunnelConfiguration.interface.addresses {
if addressRange.address is IPv4Address {
ipv4Routes.append(NEIPv4Route(destinationAddress: "\(addressRange.address)", subnetMask: "\(addressRange.subnetMask())"))
} else if addressRange.address is IPv6Address {
/* Big fat ugly hack for broken iOS networking stack: the smallest prefix that will have
* any effect on iOS is a /120, so we clamp everything above to /120. This is potentially
* very bad, if various network parameters were actually relying on that subnet being
* intentionally small. TODO: talk about this with upstream iOS devs.
*/
ipv6Routes.append(NEIPv6Route(destinationAddress: "\(addressRange.address)", networkPrefixLength: NSNumber(value: min(120, addressRange.networkPrefixLength))))
}
}
return (ipv4Routes, ipv6Routes)
}
private func includedRoutes() -> ([NEIPv4Route], [NEIPv6Route]) {
var ipv4IncludedRoutes = [NEIPv4Route]()
var ipv6IncludedRoutes = [NEIPv6Route]()
for addressRange in tunnelConfiguration.interface.addresses {
if addressRange.address is IPv4Address {
let route = NEIPv4Route(destinationAddress: "\(addressRange.maskedAddress())", subnetMask: "\(addressRange.subnetMask())")
route.gatewayAddress = "\(addressRange.address)"
ipv4IncludedRoutes.append(route)
} else if addressRange.address is IPv6Address {
let route = NEIPv6Route(destinationAddress: "\(addressRange.maskedAddress())", networkPrefixLength: NSNumber(value: addressRange.networkPrefixLength))
route.gatewayAddress = "\(addressRange.address)"
ipv6IncludedRoutes.append(route)
}
}
for peer in tunnelConfiguration.peers {
for addressRange in peer.allowedIPs {
if addressRange.address is IPv4Address {
ipv4IncludedRoutes.append(NEIPv4Route(destinationAddress: "\(addressRange.address)", subnetMask: "\(addressRange.subnetMask())"))
} else if addressRange.address is IPv6Address {
ipv6IncludedRoutes.append(NEIPv6Route(destinationAddress: "\(addressRange.address)", networkPrefixLength: NSNumber(value: addressRange.networkPrefixLength)))
}
}
}
return (ipv4IncludedRoutes, ipv6IncludedRoutes)
}
private class func reresolveEndpoint(endpoint: Endpoint) -> EndpointResolutionResult {
return Result { (endpoint, try endpoint.withReresolvedIP()) }
.mapError { error -> DNSResolutionError in
// swiftlint:disable:next force_cast
return error as! DNSResolutionError
}
}
}

View File

@@ -1,40 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
public struct PeerConfiguration {
public var publicKey: PublicKey
public var preSharedKey: PreSharedKey?
public var allowedIPs = [IPAddressRange]()
public var endpoint: Endpoint?
public var persistentKeepAlive: UInt16?
public var rxBytes: UInt64?
public var txBytes: UInt64?
public var lastHandshakeTime: Date?
public init(publicKey: PublicKey) {
self.publicKey = publicKey
}
}
extension PeerConfiguration: Equatable {
public static func == (lhs: PeerConfiguration, rhs: PeerConfiguration) -> Bool {
return lhs.publicKey == rhs.publicKey &&
lhs.preSharedKey == rhs.preSharedKey &&
Set(lhs.allowedIPs) == Set(rhs.allowedIPs) &&
lhs.endpoint == rhs.endpoint &&
lhs.persistentKeepAlive == rhs.persistentKeepAlive
}
}
extension PeerConfiguration: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(publicKey)
hasher.combine(preSharedKey)
hasher.combine(Set(allowedIPs))
hasher.combine(endpoint)
hasher.combine(persistentKeepAlive)
}
}

View File

@@ -1,114 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
#if SWIFT_PACKAGE
import WireGuardKitC
#endif
/// The class describing a private key used by WireGuard.
public class PrivateKey: BaseKey {
/// Derived public key
public var publicKey: PublicKey {
return rawValue.withUnsafeBytes { (privateKeyBufferPointer: UnsafeRawBufferPointer) -> PublicKey in
var publicKeyData = Data(repeating: 0, count: Int(WG_KEY_LEN))
let privateKeyBytes = privateKeyBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
publicKeyData.withUnsafeMutableBytes { (publicKeyBufferPointer: UnsafeMutableRawBufferPointer) in
let publicKeyBytes = publicKeyBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
curve25519_derive_public_key(publicKeyBytes, privateKeyBytes)
}
return PublicKey(rawValue: publicKeyData)!
}
}
/// Initialize new private key
convenience public init() {
var privateKeyData = Data(repeating: 0, count: Int(WG_KEY_LEN))
privateKeyData.withUnsafeMutableBytes { (rawBufferPointer: UnsafeMutableRawBufferPointer) in
let privateKeyBytes = rawBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
curve25519_generate_private_key(privateKeyBytes)
}
self.init(rawValue: privateKeyData)!
}
}
/// The class describing a public key used by WireGuard.
public class PublicKey: BaseKey {}
/// The class describing a pre-shared key used by WireGuard.
public class PreSharedKey: BaseKey {}
/// The base key implementation. Should not be used directly.
public class BaseKey: RawRepresentable, Equatable, Hashable {
/// Raw key representation
public let rawValue: Data
/// Hex encoded representation
public var hexKey: String {
return rawValue.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> String in
let inBytes = rawBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
var outBytes = [CChar](repeating: 0, count: Int(WG_KEY_LEN_HEX))
key_to_hex(&outBytes, inBytes)
return String(cString: outBytes, encoding: .ascii)!
}
}
/// Base64 encoded representation
public var base64Key: String {
return rawValue.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> String in
let inBytes = rawBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
var outBytes = [CChar](repeating: 0, count: Int(WG_KEY_LEN_BASE64))
key_to_base64(&outBytes, inBytes)
return String(cString: outBytes, encoding: .ascii)!
}
}
/// Initialize the key with existing raw representation
required public init?(rawValue: Data) {
if rawValue.count == WG_KEY_LEN {
self.rawValue = rawValue
} else {
return nil
}
}
/// Initialize the key with hex representation
public convenience init?(hexKey: String) {
var bytes = Data(repeating: 0, count: Int(WG_KEY_LEN))
let success = bytes.withUnsafeMutableBytes { (bufferPointer: UnsafeMutableRawBufferPointer) -> Bool in
return key_from_hex(bufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self), hexKey)
}
if success {
self.init(rawValue: bytes)
} else {
return nil
}
}
/// Initialize the key with base64 representation
public convenience init?(base64Key: String) {
var bytes = Data(repeating: 0, count: Int(WG_KEY_LEN))
let success = bytes.withUnsafeMutableBytes { (bufferPointer: UnsafeMutableRawBufferPointer) -> Bool in
return key_from_base64(bufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self), base64Key)
}
if success {
self.init(rawValue: bytes)
} else {
return nil
}
}
public static func == (lhs: BaseKey, rhs: BaseKey) -> Bool {
return lhs.rawValue.withUnsafeBytes { (lhsBytes: UnsafeRawBufferPointer) -> Bool in
return rhs.rawValue.withUnsafeBytes { (rhsBytes: UnsafeRawBufferPointer) -> Bool in
return key_eq(
lhsBytes.baseAddress!.assumingMemoryBound(to: UInt8.self),
rhsBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
)
}
}
}
}

View File

@@ -1,30 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
public final class TunnelConfiguration {
public var name: String?
public var interface: InterfaceConfiguration
public let peers: [PeerConfiguration]
public init(name: String?, interface: InterfaceConfiguration, peers: [PeerConfiguration]) {
self.interface = interface
self.peers = peers
self.name = name
let peerPublicKeysArray = peers.map { $0.publicKey }
let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray)
if peerPublicKeysArray.count != peerPublicKeysSet.count {
fatalError("Two or more peers cannot have the same public key")
}
}
}
extension TunnelConfiguration: Equatable {
public static func == (lhs: TunnelConfiguration, rhs: TunnelConfiguration) -> Bool {
return lhs.name == rhs.name &&
lhs.interface == rhs.interface &&
Set(lhs.peers) == Set(rhs.peers)
}
}

View File

@@ -1,487 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
import Foundation
import NetworkExtension
#if SWIFT_PACKAGE
import WireGuardKitGo
import WireGuardKitC
#endif
public enum WireGuardAdapterError: Error {
/// Failure to locate tunnel file descriptor.
case cannotLocateTunnelFileDescriptor
/// Failure to perform an operation in such state.
case invalidState
/// Failure to resolve endpoints.
case dnsResolution([DNSResolutionError])
/// Failure to set network settings.
case setNetworkSettings(Error)
/// Failure to start WireGuard backend.
case startWireGuardBackend(Int32)
}
/// Enum representing internal state of the `WireGuardAdapter`
private enum State {
/// The tunnel is stopped
case stopped
/// The tunnel is up and running
case started(_ handle: Int32, _ settingsGenerator: PacketTunnelSettingsGenerator)
/// The tunnel is temporarily shutdown due to device going offline
case temporaryShutdown(_ settingsGenerator: PacketTunnelSettingsGenerator)
}
public class WireGuardAdapter {
public typealias LogHandler = (WireGuardLogLevel, String) -> Void
/// Network routes monitor.
private var networkMonitor: NWPathMonitor?
/// Packet tunnel provider.
private weak var packetTunnelProvider: NEPacketTunnelProvider?
/// Log handler closure.
private let logHandler: LogHandler
/// Private queue used to synchronize access to `WireGuardAdapter` members.
private let workQueue = DispatchQueue(label: "WireGuardAdapterWorkQueue")
/// Adapter state.
private var state: State = .stopped
/// Tunnel device file descriptor.
private var tunnelFileDescriptor: Int32? {
var ctlInfo = ctl_info()
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
_ = strcpy($0, "com.apple.net.utun_control")
}
}
for fd: Int32 in 0...1024 {
var addr = sockaddr_ctl()
var ret: Int32 = -1
var len = socklen_t(MemoryLayout.size(ofValue: addr))
withUnsafeMutablePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
ret = getpeername(fd, $0, &len)
}
}
if ret != 0 || addr.sc_family != AF_SYSTEM {
continue
}
if ctlInfo.ctl_id == 0 {
ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
if ret != 0 {
continue
}
}
if addr.sc_id == ctlInfo.ctl_id {
return fd
}
}
return nil
}
/// Returns a WireGuard version.
class var backendVersion: String {
guard let ver = wgVersion() else { return "unknown" }
let str = String(cString: ver)
free(UnsafeMutableRawPointer(mutating: ver))
return str
}
/// Returns the tunnel device interface name, or nil on error.
/// - Returns: String.
public var interfaceName: String? {
guard let tunnelFileDescriptor = self.tunnelFileDescriptor else { return nil }
var buffer = [UInt8](repeating: 0, count: Int(IFNAMSIZ))
return buffer.withUnsafeMutableBufferPointer { mutableBufferPointer in
guard let baseAddress = mutableBufferPointer.baseAddress else { return nil }
var ifnameSize = socklen_t(IFNAMSIZ)
let result = getsockopt(
tunnelFileDescriptor,
2 /* SYSPROTO_CONTROL */,
2 /* UTUN_OPT_IFNAME */,
baseAddress,
&ifnameSize)
if result == 0 {
return String(cString: baseAddress)
} else {
return nil
}
}
}
// MARK: - Initialization
/// Designated initializer.
/// - Parameter packetTunnelProvider: an instance of `NEPacketTunnelProvider`. Internally stored
/// as a weak reference.
/// - Parameter logHandler: a log handler closure.
public init(with packetTunnelProvider: NEPacketTunnelProvider, logHandler: @escaping LogHandler) {
self.packetTunnelProvider = packetTunnelProvider
self.logHandler = logHandler
setupLogHandler()
}
deinit {
// Force remove logger to make sure that no further calls to the instance of this class
// can happen after deallocation.
wgSetLogger(nil, nil)
// Cancel network monitor
networkMonitor?.cancel()
// Shutdown the tunnel
if case .started(let handle, _) = self.state {
wgTurnOff(handle)
}
}
// MARK: - Public methods
/// Returns a runtime configuration from WireGuard.
/// - Parameter completionHandler: completion handler.
public func getRuntimeConfiguration(completionHandler: @escaping (String?) -> Void) {
workQueue.async {
guard case .started(let handle, _) = self.state else {
completionHandler(nil)
return
}
if let settings = wgGetConfig(handle) {
completionHandler(String(cString: settings))
free(settings)
} else {
completionHandler(nil)
}
}
}
/// Start the tunnel tunnel.
/// - Parameters:
/// - tunnelConfiguration: tunnel configuration.
/// - completionHandler: completion handler.
public func start(tunnelConfiguration: TunnelConfiguration, completionHandler: @escaping (WireGuardAdapterError?) -> Void) {
workQueue.async {
guard case .stopped = self.state else {
completionHandler(.invalidState)
return
}
let networkMonitor = NWPathMonitor()
networkMonitor.pathUpdateHandler = { [weak self] path in
self?.didReceivePathUpdate(path: path)
}
networkMonitor.start(queue: self.workQueue)
do {
let settingsGenerator = try self.makeSettingsGenerator(with: tunnelConfiguration)
try self.setNetworkSettings(settingsGenerator.generateNetworkSettings())
let (wgConfig, resolutionResults) = settingsGenerator.uapiConfiguration()
self.logEndpointResolutionResults(resolutionResults)
self.state = .started(
try self.startWireGuardBackend(wgConfig: wgConfig),
settingsGenerator
)
self.networkMonitor = networkMonitor
completionHandler(nil)
} catch let error as WireGuardAdapterError {
networkMonitor.cancel()
completionHandler(error)
} catch {
fatalError()
}
}
}
/// Stop the tunnel.
/// - Parameter completionHandler: completion handler.
public func stop(completionHandler: @escaping (WireGuardAdapterError?) -> Void) {
workQueue.async {
switch self.state {
case .started(let handle, _):
wgTurnOff(handle)
case .temporaryShutdown:
break
case .stopped:
completionHandler(.invalidState)
return
}
self.networkMonitor?.cancel()
self.networkMonitor = nil
self.state = .stopped
completionHandler(nil)
}
}
/// Update runtime configuration.
/// - Parameters:
/// - tunnelConfiguration: tunnel configuration.
/// - completionHandler: completion handler.
public func update(tunnelConfiguration: TunnelConfiguration, completionHandler: @escaping (WireGuardAdapterError?) -> Void) {
workQueue.async {
if case .stopped = self.state {
completionHandler(.invalidState)
return
}
// Tell the system that the tunnel is going to reconnect using new WireGuard
// configuration.
// This will broadcast the `NEVPNStatusDidChange` notification to the GUI process.
self.packetTunnelProvider?.reasserting = true
defer {
self.packetTunnelProvider?.reasserting = false
}
do {
let settingsGenerator = try self.makeSettingsGenerator(with: tunnelConfiguration)
try self.setNetworkSettings(settingsGenerator.generateNetworkSettings())
switch self.state {
case .started(let handle, _):
let (wgConfig, resolutionResults) = settingsGenerator.uapiConfiguration()
self.logEndpointResolutionResults(resolutionResults)
wgSetConfig(handle, wgConfig)
#if os(iOS)
wgDisableSomeRoamingForBrokenMobileSemantics(handle)
#endif
self.state = .started(handle, settingsGenerator)
case .temporaryShutdown:
self.state = .temporaryShutdown(settingsGenerator)
case .stopped:
fatalError()
}
completionHandler(nil)
} catch let error as WireGuardAdapterError {
completionHandler(error)
} catch {
fatalError()
}
}
}
// MARK: - Private methods
/// Setup WireGuard log handler.
private func setupLogHandler() {
let context = Unmanaged.passUnretained(self).toOpaque()
wgSetLogger(context) { context, logLevel, message in
guard let context = context, let message = message else { return }
let unretainedSelf = Unmanaged<WireGuardAdapter>.fromOpaque(context)
.takeUnretainedValue()
let swiftString = String(cString: message).trimmingCharacters(in: .newlines)
let tunnelLogLevel = WireGuardLogLevel(rawValue: logLevel) ?? .verbose
unretainedSelf.logHandler(tunnelLogLevel, swiftString)
}
}
/// Set network tunnel configuration.
/// This method ensures that the call to `setTunnelNetworkSettings` does not time out, as in
/// certain scenarios the completion handler given to it may not be invoked by the system.
///
/// - Parameters:
/// - networkSettings: an instance of type `NEPacketTunnelNetworkSettings`.
/// - Throws: an error of type `WireGuardAdapterError`.
/// - Returns: `PacketTunnelSettingsGenerator`.
private func setNetworkSettings(_ networkSettings: NEPacketTunnelNetworkSettings) throws {
var systemError: Error?
let condition = NSCondition()
// Activate the condition
condition.lock()
defer { condition.unlock() }
self.packetTunnelProvider?.setTunnelNetworkSettings(networkSettings) { error in
systemError = error
condition.signal()
}
// Packet tunnel's `setTunnelNetworkSettings` times out in certain
// scenarios & never calls the given callback.
let setTunnelNetworkSettingsTimeout: TimeInterval = 5 // seconds
if condition.wait(until: Date().addingTimeInterval(setTunnelNetworkSettingsTimeout)) {
if let systemError = systemError {
throw WireGuardAdapterError.setNetworkSettings(systemError)
}
} else {
self.logHandler(.error, "setTunnelNetworkSettings timed out after 5 seconds; proceeding anyway")
}
}
/// Resolve peers of the given tunnel configuration.
/// - Parameter tunnelConfiguration: tunnel configuration.
/// - Throws: an error of type `WireGuardAdapterError`.
/// - Returns: The list of resolved endpoints.
private func resolvePeers(for tunnelConfiguration: TunnelConfiguration) throws -> [Endpoint?] {
let endpoints = tunnelConfiguration.peers.map { $0.endpoint }
let resolutionResults = DNSResolver.resolveSync(endpoints: endpoints)
let resolutionErrors = resolutionResults.compactMap { result -> DNSResolutionError? in
if case .failure(let error) = result {
return error
} else {
return nil
}
}
assert(endpoints.count == resolutionResults.count)
guard resolutionErrors.isEmpty else {
throw WireGuardAdapterError.dnsResolution(resolutionErrors)
}
let resolvedEndpoints = resolutionResults.map { result -> Endpoint? in
// swiftlint:disable:next force_try
return try! result?.get()
}
return resolvedEndpoints
}
/// Start WireGuard backend.
/// - Parameter wgConfig: WireGuard configuration
/// - Throws: an error of type `WireGuardAdapterError`
/// - Returns: tunnel handle
private func startWireGuardBackend(wgConfig: String) throws -> Int32 {
guard let tunnelFileDescriptor = self.tunnelFileDescriptor else {
throw WireGuardAdapterError.cannotLocateTunnelFileDescriptor
}
let handle = wgTurnOn(wgConfig, tunnelFileDescriptor)
if handle < 0 {
throw WireGuardAdapterError.startWireGuardBackend(handle)
}
#if os(iOS)
wgDisableSomeRoamingForBrokenMobileSemantics(handle)
#endif
return handle
}
/// Resolves the hostnames in the given tunnel configuration and return settings generator.
/// - Parameter tunnelConfiguration: an instance of type `TunnelConfiguration`.
/// - Throws: an error of type `WireGuardAdapterError`.
/// - Returns: an instance of type `PacketTunnelSettingsGenerator`.
private func makeSettingsGenerator(with tunnelConfiguration: TunnelConfiguration) throws -> PacketTunnelSettingsGenerator {
return PacketTunnelSettingsGenerator(
tunnelConfiguration: tunnelConfiguration,
resolvedEndpoints: try self.resolvePeers(for: tunnelConfiguration)
)
}
/// Log DNS resolution results.
/// - Parameter resolutionErrors: an array of type `[DNSResolutionError]`.
private func logEndpointResolutionResults(_ resolutionResults: [EndpointResolutionResult?]) {
for case .some(let result) in resolutionResults {
switch result {
case .success((let sourceEndpoint, let resolvedEndpoint)):
if sourceEndpoint.host == resolvedEndpoint.host {
self.logHandler(.verbose, "DNS64: mapped \(sourceEndpoint.host) to itself.")
} else {
self.logHandler(.verbose, "DNS64: mapped \(sourceEndpoint.host) to \(resolvedEndpoint.host)")
}
case .failure(let resolutionError):
self.logHandler(.error, "Failed to resolve endpoint \(resolutionError.address): \(resolutionError.errorDescription ?? "(nil)")")
}
}
}
/// Helper method used by network path monitor.
/// - Parameter path: new network path
private func didReceivePathUpdate(path: Network.NWPath) {
self.logHandler(.verbose, "Network change detected with \(path.status) route and interface order \(path.availableInterfaces)")
#if os(macOS)
if case .started(let handle, _) = self.state {
wgBumpSockets(handle)
}
#elseif os(iOS)
switch self.state {
case .started(let handle, let settingsGenerator):
if path.status.isSatisfiable {
let (wgConfig, resolutionResults) = settingsGenerator.endpointUapiConfiguration()
self.logEndpointResolutionResults(resolutionResults)
wgSetConfig(handle, wgConfig)
wgDisableSomeRoamingForBrokenMobileSemantics(handle)
wgBumpSockets(handle)
} else {
self.logHandler(.verbose, "Connectivity offline, pausing backend.")
self.state = .temporaryShutdown(settingsGenerator)
wgTurnOff(handle)
}
case .temporaryShutdown(let settingsGenerator):
guard path.status.isSatisfiable else { return }
self.logHandler(.verbose, "Connectivity online, resuming backend.")
do {
try self.setNetworkSettings(settingsGenerator.generateNetworkSettings())
let (wgConfig, resolutionResults) = settingsGenerator.uapiConfiguration()
self.logEndpointResolutionResults(resolutionResults)
self.state = .started(
try self.startWireGuardBackend(wgConfig: wgConfig),
settingsGenerator
)
} catch {
self.logHandler(.error, "Failed to restart backend: \(error.localizedDescription)")
}
case .stopped:
// no-op
break
}
#else
#error("Unsupported")
#endif
}
}
/// A enum describing WireGuard log levels defined in `api-apple.go`.
public enum WireGuardLogLevel: Int32 {
case verbose = 0
case error = 1
}
private extension Network.NWPath.Status {
/// Returns `true` if the path is potentially satisfiable.
var isSatisfiable: Bool {
switch self {
case .requiresConnection, .satisfied:
return true
case .unsatisfied:
return false
@unknown default:
return true
}
}
}

View File

@@ -1,21 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
#include <sys/types.h>
#include "key.h"
#include "x25519.h"
/* From <sys/kern_control.h> */
#define CTLIOCGINFO 0xc0644e03UL
struct ctl_info {
u_int32_t ctl_id;
char ctl_name[96];
};
struct sockaddr_ctl {
u_char sc_len;
u_char sc_family;
u_int16_t ss_sysaddr;
u_int32_t sc_id;
u_int32_t sc_unit;
u_int32_t sc_reserved[5];
};

View File

@@ -1,124 +0,0 @@
// SPDX-License-Identifier: MIT
/*
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
*
* This is a specialized constant-time base64/hex implementation that resists side-channel attacks.
*/
#include <string.h>
#include "key.h"
static inline void encode_base64(char dest[static 4], const uint8_t src[static 3])
{
const uint8_t input[] = { (src[0] >> 2) & 63, ((src[0] << 4) | (src[1] >> 4)) & 63, ((src[1] << 2) | (src[2] >> 6)) & 63, src[2] & 63 };
for (unsigned int i = 0; i < 4; ++i)
dest[i] = input[i] + 'A'
+ (((25 - input[i]) >> 8) & 6)
- (((51 - input[i]) >> 8) & 75)
- (((61 - input[i]) >> 8) & 15)
+ (((62 - input[i]) >> 8) & 3);
}
void key_to_base64(char base64[static WG_KEY_LEN_BASE64], const uint8_t key[static WG_KEY_LEN])
{
unsigned int i;
for (i = 0; i < WG_KEY_LEN / 3; ++i)
encode_base64(&base64[i * 4], &key[i * 3]);
encode_base64(&base64[i * 4], (const uint8_t[]){ key[i * 3 + 0], key[i * 3 + 1], 0 });
base64[WG_KEY_LEN_BASE64 - 2] = '=';
base64[WG_KEY_LEN_BASE64 - 1] = '\0';
}
static inline int decode_base64(const char src[static 4])
{
int val = 0;
for (unsigned int i = 0; i < 4; ++i)
val |= (-1
+ ((((('A' - 1) - src[i]) & (src[i] - ('Z' + 1))) >> 8) & (src[i] - 64))
+ ((((('a' - 1) - src[i]) & (src[i] - ('z' + 1))) >> 8) & (src[i] - 70))
+ ((((('0' - 1) - src[i]) & (src[i] - ('9' + 1))) >> 8) & (src[i] + 5))
+ ((((('+' - 1) - src[i]) & (src[i] - ('+' + 1))) >> 8) & 63)
+ ((((('/' - 1) - src[i]) & (src[i] - ('/' + 1))) >> 8) & 64)
) << (18 - 6 * i);
return val;
}
bool key_from_base64(uint8_t key[static WG_KEY_LEN], const char *base64)
{
unsigned int i;
volatile uint8_t ret = 0;
int val;
if (strlen(base64) != WG_KEY_LEN_BASE64 - 1 || base64[WG_KEY_LEN_BASE64 - 2] != '=')
return false;
for (i = 0; i < WG_KEY_LEN / 3; ++i) {
val = decode_base64(&base64[i * 4]);
ret |= (uint32_t)val >> 31;
key[i * 3 + 0] = (val >> 16) & 0xff;
key[i * 3 + 1] = (val >> 8) & 0xff;
key[i * 3 + 2] = val & 0xff;
}
val = decode_base64((const char[]){ base64[i * 4 + 0], base64[i * 4 + 1], base64[i * 4 + 2], 'A' });
ret |= ((uint32_t)val >> 31) | (val & 0xff);
key[i * 3 + 0] = (val >> 16) & 0xff;
key[i * 3 + 1] = (val >> 8) & 0xff;
return 1 & ((ret - 1) >> 8);
}
void key_to_hex(char hex[static WG_KEY_LEN_HEX], const uint8_t key[static WG_KEY_LEN])
{
unsigned int i;
for (i = 0; i < WG_KEY_LEN; ++i) {
hex[i * 2] = 87U + (key[i] >> 4) + ((((key[i] >> 4) - 10U) >> 8) & ~38U);
hex[i * 2 + 1] = 87U + (key[i] & 0xf) + ((((key[i] & 0xf) - 10U) >> 8) & ~38U);
}
hex[i * 2] = '\0';
}
bool key_from_hex(uint8_t key[static WG_KEY_LEN], const char *hex)
{
uint8_t c, c_acc, c_alpha0, c_alpha, c_num0, c_num, c_val;
volatile uint8_t ret = 0;
if (strlen(hex) != WG_KEY_LEN_HEX - 1)
return false;
for (unsigned int i = 0; i < WG_KEY_LEN_HEX - 1; i += 2) {
c = (uint8_t)hex[i];
c_num = c ^ 48U;
c_num0 = (c_num - 10U) >> 8;
c_alpha = (c & ~32U) - 55U;
c_alpha0 = ((c_alpha - 10U) ^ (c_alpha - 16U)) >> 8;
ret |= ((c_num0 | c_alpha0) - 1) >> 8;
c_val = (c_num0 & c_num) | (c_alpha0 & c_alpha);
c_acc = c_val * 16U;
c = (uint8_t)hex[i + 1];
c_num = c ^ 48U;
c_num0 = (c_num - 10U) >> 8;
c_alpha = (c & ~32U) - 55U;
c_alpha0 = ((c_alpha - 10U) ^ (c_alpha - 16U)) >> 8;
ret |= ((c_num0 | c_alpha0) - 1) >> 8;
c_val = (c_num0 & c_num) | (c_alpha0 & c_alpha);
key[i / 2] = c_acc | c_val;
}
return 1 & ((ret - 1) >> 8);
}
bool key_eq(const uint8_t key1[static WG_KEY_LEN], const uint8_t key2[static WG_KEY_LEN])
{
volatile uint8_t acc = 0;
for (unsigned int i = 0; i < WG_KEY_LEN; ++i) {
acc |= key1[i] ^ key2[i];
asm volatile("" : "=r"(acc) : "0"(acc));
}
return 1 & ((acc - 1) >> 8);
}

View File

@@ -1,24 +0,0 @@
/* SPDX-License-Identifier: MIT */
/*
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
*/
#ifndef KEY_H
#define KEY_H
#include <stdbool.h>
#include <stdint.h>
#define WG_KEY_LEN (32)
#define WG_KEY_LEN_BASE64 (45)
#define WG_KEY_LEN_HEX (65)
void key_to_base64(char base64[static WG_KEY_LEN_BASE64], const uint8_t key[static WG_KEY_LEN]);
bool key_from_base64(uint8_t key[static WG_KEY_LEN], const char *base64);
void key_to_hex(char hex[static WG_KEY_LEN_HEX], const uint8_t key[static WG_KEY_LEN]);
bool key_from_hex(uint8_t key[static WG_KEY_LEN], const char *hex);
bool key_eq(const uint8_t key1[static WG_KEY_LEN], const uint8_t key2[static WG_KEY_LEN]);
#endif

View File

@@ -1,4 +0,0 @@
module WireGuardKitC {
umbrella header "WireGuardKitC.h"
export *
}

View File

@@ -1,178 +0,0 @@
/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
*
* Curve25519 ECDH functions, based on TweetNaCl but cleaned up.
*/
#include <stdint.h>
#include <string.h>
#include <assert.h>
#include <CommonCrypto/CommonRandom.h>
#include "x25519.h"
typedef int64_t fe[16];
static inline void carry(fe o)
{
int i;
for (i = 0; i < 16; ++i) {
o[(i + 1) % 16] += (i == 15 ? 38 : 1) * (o[i] >> 16);
o[i] &= 0xffff;
}
}
static inline void cswap(fe p, fe q, int b)
{
int i;
int64_t t, c = ~(b - 1);
for (i = 0; i < 16; ++i) {
t = c & (p[i] ^ q[i]);
p[i] ^= t;
q[i] ^= t;
}
}
static inline void pack(uint8_t *o, const fe n)
{
int i, j, b;
fe m, t;
memcpy(t, n, sizeof(t));
carry(t);
carry(t);
carry(t);
for (j = 0; j < 2; ++j) {
m[0] = t[0] - 0xffed;
for (i = 1; i < 15; ++i) {
m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
m[i - 1] &= 0xffff;
}
m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1);
b = (m[15] >> 16) & 1;
m[14] &= 0xffff;
cswap(t, m, 1 - b);
}
for (i = 0; i < 16; ++i) {
o[2 * i] = t[i] & 0xff;
o[2 * i + 1] = t[i] >> 8;
}
}
static inline void unpack(fe o, const uint8_t *n)
{
int i;
for (i = 0; i < 16; ++i)
o[i] = n[2 * i] + ((int64_t)n[2 * i + 1] << 8);
o[15] &= 0x7fff;
}
static inline void add(fe o, const fe a, const fe b)
{
int i;
for (i = 0; i < 16; ++i)
o[i] = a[i] + b[i];
}
static inline void subtract(fe o, const fe a, const fe b)
{
int i;
for (i = 0; i < 16; ++i)
o[i] = a[i] - b[i];
}
static inline void multmod(fe o, const fe a, const fe b)
{
int i, j;
int64_t t[31] = { 0 };
for (i = 0; i < 16; ++i) {
for (j = 0; j < 16; ++j)
t[i + j] += a[i] * b[j];
}
for (i = 0; i < 15; ++i)
t[i] += 38 * t[i + 16];
memcpy(o, t, sizeof(fe));
carry(o);
carry(o);
}
static inline void invert(fe o, const fe i)
{
fe c;
int a;
memcpy(c, i, sizeof(c));
for (a = 253; a >= 0; --a) {
multmod(c, c, c);
if (a != 2 && a != 4)
multmod(c, c, i);
}
memcpy(o, c, sizeof(fe));
}
static void curve25519_shared_secret(uint8_t shared_secret[32], const uint8_t private_key[32], const uint8_t public_key[32])
{
static const fe a24 = { 0xdb41, 1 };
uint8_t z[32];
int64_t r;
int i;
fe a = { 1 }, b, c = { 0 }, d = { 1 }, e, f, x;
memcpy(z, private_key, sizeof(z));
z[31] = (z[31] & 127) | 64;
z[0] &= 248;
unpack(x, public_key);
memcpy(b, x, sizeof(b));
for (i = 254; i >= 0; --i) {
r = (z[i >> 3] >> (i & 7)) & 1;
cswap(a, b, (int)r);
cswap(c, d, (int)r);
add(e, a, c);
subtract(a, a, c);
add(c, b, d);
subtract(b, b, d);
multmod(d, e, e);
multmod(f, a, a);
multmod(a, c, a);
multmod(c, b, e);
add(e, a, c);
subtract(a, a, c);
multmod(b, a, a);
subtract(c, d, f);
multmod(a, c, a24);
add(a, a, d);
multmod(c, c, a);
multmod(a, d, f);
multmod(d, b, x);
multmod(b, e, e);
cswap(a, b, (int)r);
cswap(c, d, (int)r);
}
invert(c, c);
multmod(a, a, c);
pack(shared_secret, a);
}
void curve25519_derive_public_key(uint8_t public_key[32], const uint8_t private_key[32])
{
static const uint8_t basepoint[32] = { 9 };
curve25519_shared_secret(public_key, private_key, basepoint);
}
void curve25519_generate_private_key(uint8_t private_key[32])
{
assert(CCRandomGenerateBytes(private_key, 32) == kCCSuccess);
private_key[31] = (private_key[31] & 127) | 64;
private_key[0] &= 248;
}

View File

@@ -1,7 +0,0 @@
#ifndef X25519_H
#define X25519_H
void curve25519_derive_public_key(unsigned char public_key[32], const unsigned char private_key[32]);
void curve25519_generate_private_key(unsigned char private_key[32]);
#endif

View File

@@ -1 +0,0 @@
// Empty

View File

@@ -1,5 +0,0 @@
module WireGuardKitGo {
umbrella header "wireguard.h"
link "wg-go"
export *
}

View File

@@ -1,23 +0,0 @@
/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2018-2023 WireGuard LLC. All Rights Reserved.
*/
#ifndef WIREGUARD_H
#define WIREGUARD_H
#include <sys/types.h>
#include <stdint.h>
#include <stdbool.h>
typedef void(*logger_fn_t)(void *context, int level, const char *msg);
extern void wgSetLogger(void *context, logger_fn_t logger_fn);
extern int wgTurnOn(const char *settings, int32_t tun_fd);
extern void wgTurnOff(int handle);
extern int64_t wgSetConfig(int handle, const char *settings);
extern char *wgGetConfig(int handle);
extern void wgBumpSockets(int handle);
extern void wgDisableSomeRoamingForBrokenMobileSemantics(int handle);
extern const char *wgVersion();
#endif

View File

@@ -1,86 +0,0 @@
# xcodegen spec: `xcodegen generate` -> PCAPhobos.xcodeproj
# Signing: set DEVELOPMENT_TEAM (paid Apple Developer account — Network Extension needs it).
name: PCAPhobos
options:
bundleIdPrefix: com.bobyrev
deploymentTarget:
iOS: "16.0"
packages:
WireGuardKit:
path: WireGuardKit
settings:
base:
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "6"
DEVELOPMENT_TEAM: Q5FKU39TUA
CODE_SIGN_STYLE: Automatic
targets:
PCAPhobos:
type: application
platform: iOS
sources:
- App
- Shared
resources:
- App/Assets.xcassets
dependencies:
- target: PhobosTunnel
entitlements:
path: App/PCAPhobos.entitlements
properties:
com.apple.developer.networking.networkextension: [packet-tunnel-provider]
info:
path: App/Info.plist
properties:
CFBundleDisplayName: PCA Phobos
ITSAppUsesNonExemptEncryption: false
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
UILaunchScreen: {}
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
UIRequiresFullScreen: true
CFBundleURLTypes:
- CFBundleURLName: com.bobyrev.pcaphobos.link
CFBundleURLSchemes: [phobos]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.bobyrev.pcaphobos
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
PhobosTunnel:
type: app-extension
platform: iOS
sources:
- Tunnel/PacketTunnelProvider.swift
- Tunnel/TunnelConfiguration+WgQuickConfig.swift
- Tunnel/String+ArrayConversion.swift
- Tunnel/pcarelay.c
- Tunnel/WsLink.swift
- Shared
- path: Tunnel/ru_bypass.txt
buildPhase: resources
- path: Tunnel/obfuscator
compilerFlags: ["-Dmain=wgobf_main", "-w"]
excludes: ["*.h"]
dependencies:
- package: WireGuardKit
entitlements:
path: Tunnel/PhobosTunnel.entitlements
properties:
com.apple.developer.networking.networkextension: [packet-tunnel-provider]
info:
path: Tunnel/Info.plist
properties:
CFBundleDisplayName: PhobosTunnel
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension:
NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).PacketTunnelProvider
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.bobyrev.pcaphobos.tunnel
SWIFT_OBJC_BRIDGING_HEADER: Tunnel/Tunnel-Bridging-Header.h
HEADER_SEARCH_PATHS: ["$(PROJECT_DIR)/Tunnel", "$(PROJECT_DIR)/Tunnel/obfuscator"]
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": ["$(PROJECT_DIR)/WireGuardGo/iphoneos"]
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": ["$(PROJECT_DIR)/WireGuardGo/iphonesimulator"]
"EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64

View File

@@ -23,6 +23,20 @@ resolve_install_names() {
fi
if [ -f "$binary_path" ] && [ -f "$PHOBOS_DIR/${CLIENT_NAME}.conf" ]; then
# 02.11.2026: переустановка — берём те же имена, что у этой учётки (по *.link),
# иначе доп. учётка затёрла бы обфускатор главной
local l n sc
for l in "$PHOBOS_DIR"/wg-obfuscator?*.link; do
[ "$(cat "$l" 2>/dev/null)" = "${CLIENT_NAME}" ] || continue
n=$(basename "$l" .link); n=${n#wg-obfuscator}
sc=$(grep -l "/wg-obfuscator${n}.conf\"\$" /opt/etc/init.d/S*wg-obfuscator 2>/dev/null | head -1)
OBF_BINARY_NAME="wg-obfuscator${n}"
OBF_CONF_NAME="wg-obfuscator${n}.conf"
OBF_INIT_NAME=$(basename "${sc:-S50wg-obfuscator}")
OBF_SERVICE_NAME="phobos-obfuscator${n}"
OBF_WG_IFACE="phobos${n}"
return
done
return
fi

View File

@@ -417,6 +417,16 @@ show_final_info() {
}
# Убирает из crontab строки health/pull только этой учётки ($1 = "PHOBOS_ACC=<dir> "
# для доп. учётки, "PHOBOS_ACC=" для главной — у неё строки без PHOBOS_ACC).
drop_own_cron() {
if [ "$1" = "PHOBOS_ACC=" ]; then
awk '!/phobos-(health|pull)/ || /PHOBOS_ACC=/'
else
grep -v -F "$1"
fi
}
deploy_health_monitor() {
log "Развертывание монитора здоровья Phobos..."
@@ -428,12 +438,19 @@ deploy_health_monitor() {
cp "phobos-health.sh" "$PHOBOS_DIR/phobos-health.sh"
chmod +x "$PHOBOS_DIR/phobos-health.sh"
if [ -f "failover.conf" ]; then
cp "failover.conf" "$PHOBOS_DIR/failover.conf"
chmod 600 "$PHOBOS_DIR/failover.conf"
# 02.11.2026: вторая и следующие учётки на одном роутере — свой каталог
# (failover.conf, state, client_id, panel_*), свои строки cron с PHOBOS_ACC.
local A="$PHOBOS_DIR" envp="" mine="PHOBOS_ACC="
local main_cid=$(cat "$A/client_id" 2>/dev/null)
if [ -n "$main_cid" ] && [ "$main_cid" != "{{CLIENT_NAME}}" ]; then
A="$PHOBOS_DIR/acc/{{CLIENT_NAME}}"; envp="PHOBOS_ACC=$A "; mine="PHOBOS_ACC=$A "
log " Дополнительная учётка: $A (главная — $main_cid)"
fi
mkdir -p "$A/state"
if [ -f "failover.conf" ]; then
cp "failover.conf" "$A/failover.conf"
chmod 600 "$A/failover.conf"
fi
mkdir -p "$PHOBOS_DIR/state"
# --- Phobos pull agent: panel -> router config sync (NAT-friendly) ---
# Routers behind NAT cannot be reached by the panel over SSH, so they PULL
@@ -441,54 +458,48 @@ deploy_health_monitor() {
if [ -f "phobos-pull.sh" ]; then
cp "phobos-pull.sh" "$PHOBOS_DIR/phobos-pull.sh"
chmod +x "$PHOBOS_DIR/phobos-pull.sh"
echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id"
[ -f "pull_token" ] && cp "pull_token" "$PHOBOS_DIR/pull_token"
[ -f "panel_tunnel_ip" ] && cp "panel_tunnel_ip" "$PHOBOS_DIR/panel_tunnel_ip"
[ -f "panel_port" ] && cp "panel_port" "$PHOBOS_DIR/panel_port"
[ -f "panel_scheme" ] && cp "panel_scheme" "$PHOBOS_DIR/panel_scheme"
[ -f "panel_url" ] && cp "panel_url" "$PHOBOS_DIR/panel_url"
[ -f "panel_urls" ] && cp "panel_urls" "$PHOBOS_DIR/panel_urls"
echo "{{CLIENT_NAME}}" > "$A/client_id"
[ -f "pull_token" ] && cp "pull_token" "$A/pull_token"
[ -f "panel_tunnel_ip" ] && cp "panel_tunnel_ip" "$A/panel_tunnel_ip"
[ -f "panel_port" ] && cp "panel_port" "$A/panel_port"
[ -f "panel_scheme" ] && cp "panel_scheme" "$A/panel_scheme"
[ -f "panel_url" ] && cp "panel_url" "$A/panel_url"
[ -f "panel_urls" ] && cp "panel_urls" "$A/panel_urls"
log " Pull-агент установлен (client_id={{CLIENT_NAME}})"
fi
local cron_line="*/1 * * * * $PHOBOS_DIR/phobos-health.sh"
local pull_line="*/1 * * * * $PHOBOS_DIR/phobos-pull.sh"
local cron_line="*/1 * * * * ${envp}$PHOBOS_DIR/phobos-health.sh"
local pull_line="*/1 * * * * ${envp}$PHOBOS_DIR/phobos-pull.sh"
[ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line=""
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
# Install cron if not present (Entware)
if ! command -v crontab >/dev/null 2>&1 && command -v opkg >/dev/null 2>&1; then
log " Установка cron через opkg..."
opkg update >/dev/null 2>&1
opkg install cron >/dev/null 2>&1
/opt/etc/init.d/S10cron start >/dev/null 2>&1 || true
fi
local cron_file="/opt/etc/crontab"
# Create crontab file if missing
if [ ! -f "$cron_file" ]; then
mkdir -p /opt/etc
touch "$cron_file"
fi
if ! grep -q "phobos-health" "$cron_file" 2>/dev/null; then
echo "$cron_line" >> "$cron_file"
log " Cron (health) добавлен в $cron_file"
fi
if [ -n "$pull_line" ] && ! grep -q "phobos-pull" "$cron_file" 2>/dev/null; then
echo "$pull_line" >> "$cron_file"
log " Cron (pull) добавлен в $cron_file"
# Демон — busybox crond (есть в Entware как /opt/sbin/crond). Entware-пакет cron
# (vixie, PROCS=cron) на Keenetic задачи не выполнял — заменяем (01.11.2026)
local I=/opt/etc/init.d/S10cron
if [ ! -x $I ] || grep -q "^PROCS=cron$" $I; then
[ -x $I ] && $I stop </dev/null >/dev/null 2>&1
printf '#!/bin/sh\n\nENABLED=yes\nPROCS=crond\nARGS="-c /opt/var/spool/cron/crontabs -b"\nPREARGS=""\nDESC=$PROCS\nPATH=/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n. /opt/etc/init.d/rc.func\n' > $I
chmod +x $I
fi
[ -x /opt/sbin/crond ] || command -v crond >/dev/null 2>&1 || log " ВНИМАНИЕ: crond не найден — health/pull по расписанию работать не будут (opkg install cron или busybox с crond)"
mkdir -p /opt/var/spool/cron/crontabs
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
[ -z "$envp" ] && sed -i "/phobos-health\|phobos-pull/d" /opt/etc/crontab 2>/dev/null || true
(crontab -l 2>/dev/null | drop_own_cron "$mine"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
log " Cron добавлен через crontab (health + pull)"
/opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
(crontab -l 2>/dev/null | drop_own_cron "$mine"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
log " Cron добавлен через crontab (health + pull)"
elif [ "$ROUTER_PLATFORM" = "linux" ]; then
{ echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line"; } > /etc/cron.d/phobos-health
chmod 644 /etc/cron.d/phobos-health
{ echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line"; } > /etc/cron.d/phobos-health${envp:+-{{CLIENT_NAME}}}
chmod 644 /etc/cron.d/phobos-health*
log " Cron добавлен в /etc/cron.d/phobos-health (health + pull)"
fi
log " Монитор здоровья: $PHOBOS_DIR/phobos-health.sh"
log " Failover конфиг: $PHOBOS_DIR/failover.conf"
log " Failover конфиг: $A/failover.conf"
log " Проверка каждую минуту через cron"
}

View File

@@ -108,7 +108,7 @@ PY
[Interface]
PrivateKey = $priv_key
Address = $addr_str
DNS = 1.1.1.1, 8.8.8.8
DNS = ${CLIENT_DNS:-1.1.1.1, 8.8.8.8}
MTU = 1420
[Peer]
@@ -220,7 +220,7 @@ action_package() {
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
# Fetch missing router-arch binaries from ClusterM on-demand
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') || _tag="" # ponytail: GitHub API rate limit/недоступность не должны ронять сборку пакета — берём уже скачанные бинарники
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
for _arch in aarch64 mipsel mips armv7 x86_64; do

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