43 Commits

Author SHA1 Message Date
Бобырев Андрей
dff6af2c8e release: 1.4.1 — TCP fallback, Russian sites direct in the Android client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:27:23 +03:00
Бобырев Андрей
071d3695e6 android-client 1.4.1: Russian sites direct by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:03:02 +03:00
Бобырев Андрей
5354656bb1 fix(cascade): panel serves phobos-cascade.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:34:30 +03:00
Бобырев Андрей
5a01a6eed2 release: 1.4.0 — distribution via git.bobyrev.com
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:31:23 +03:00
Бобырев Андрей
7eac63343d release: 1.4.0 — one version for stable/beta/dev
Light/dark theme and compact mobile layout, per-client speed limit, real
speed tests, panel domain (Let's Encrypt), client address + cascade relay,
RKN scanner guard, WARP, backup/restore, router web terminal, Android VPN
client with split tunnelling and Android admin app. Updating from 1.2.x
keeps keys, server.env and clients; tested 1.2.8 -> 1.4.0 on live VMs.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:24:35 +03:00
Андрей Бобырев
9f402be886 fix(stable): api.py in managed_files (port from dev)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 02:48:35 +03:00
Андрей Бобырев
8971565e6e fix: add server/api.py to managed_files (phobos-update now deploys agent)
The secondary agent api.py was not included in the update manifest, so
phobos-update left stale api.py on the server after channel switches.
Adding it ensures bandwidth-aware /api/health reaches secondaries on update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 02:48:35 +03:00
Андрей Бобырев
b6525bc0ae stable 1.2.5: token field + channel buttons in version card
Ports the channel UI to stable so users who installed the open stable
version can paste their subscriber token (save_token) and switch to
beta/dev straight from Настройки → Версия и обновления. Adds
gh_token_present/save_gh_token helpers and stable/beta/dev quick buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 01:57:19 +03:00
Андрей Бобырев
d072c8227f feat: private dev/beta channels via subscriber token (repo split)
Closed channels (beta, dev) now live in a separate PRIVATE repository
andrey271192/PCA_Phobos-dev. Access is gated by a read-only subscriber
GH_TOKEN (distributed via Boosty) instead of a shared hash key — the
token both authenticates the private raw fetch AND acts as the gate, so
there is a single secret and the code is genuinely hidden (public repo
holds stable/main only).

install.sh: CHANNEL=beta|dev -> private repo, requires GH_TOKEN, persists
it to $PANEL_DIR/.gh_token + writes .channel. stable stays public/no-token.
update.sh: per-ref repo routing (repo_for), gh_curl injects the token for
private channels, token read from env or saved .gh_token, --check/--versions
reflect public vs private, stable path unchanged and tokenless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 01:12:32 +03:00
Андрей Бобырев
7b941dc532 docs: README channels — stable open, beta/dev key-gated (Boosty)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 00:59:45 +03:00
Андрей Бобырев
8c1654ecaa feat: 3-channel model (stable open / beta + dev key-gated)
Channels:
  stable -> branch main  (open, no key)
  beta   -> branch beta  (release candidate, key-gated)
  dev    -> branch dev    (active development, key-gated)

beta and dev require PHOBOS_KEY (subscriber key distributed via Boosty);
only the sha256 hash is embedded in install.sh / update.sh. stable stays
open. install.sh honours CHANNEL=stable|beta|dev; phobos-update accepts
stable|beta|dev|<tag> and gates the closed channels. --check now lists all
three channels with a [закрыт ключом] marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 00:58:06 +03:00
phobos
dc83d90a35 v1.2.4: install stable by default; beta opt-in via CHANNEL=beta (documented) 2026-05-31 00:38:50 +03:00
phobos
e0ea54e83a v1.2.3: debounce server DOWN alerts (3 cycles + retry) fix flapping; update.sh aborts on missing version + --check/--versions + beta channel; panel Check button 2026-05-30 23:52:47 +03:00
phobos
628973374e v1.2.2: contact Telegram -> group @PCAdministration (README + panel footer) 2026-05-30 18:24:24 +03:00
phobos
041a533457 v1.2.1: fix bot button parsing (no arg from button text); bot create sends QR photo + phobos:// link; delete shows inline client picker 2026-05-30 15:14:58 +03:00
phobos
48ae0c2fd8 v1.2.0: Telegram bot buttons (reply keyboard + inline delete + status) 2026-05-30 15:09:26 +03:00
82 changed files with 17215 additions and 420 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__/
*.pyc

10
BETA.md Normal file
View File

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

132
CHANGELOG.md Normal file
View File

@@ -0,0 +1,132 @@
# Changelog — PCA Phobos
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
Обновление: `phobos-update stable` · `GH_TOKEN=… phobos-update beta|dev` · из панели → «Версия и обновления».
---
## Как обновиться после фиксов
```bash
# stable (открытый)
phobos-update stable
# beta/dev (токен уже сохранён в /opt/phobos-panel/.gh_token)
phobos-update beta # или: phobos-update dev
```
Затем **пересоздать клиентов** в панели (старые конфиги могли содержать неверный порт обфускатора).
Обновляются: панель, скрипты онбординга, агент, updater. Ключи WireGuard, `server.env`, клиенты — НЕ трогаются.
---
## 1.4.1 — резервный TCP-канал, «РФ напрямую»
- **feat:** резервный канал TCP: если провайдер режет UDP, клиент сам переходит на TCP (порт 443, иначе 8443/2096/4433). Сервер: служба `phobos-tcp` (`udptcp`), ставится и включается автоматически при установке и обновлении. Проверено реальной блокировкой UDP: переключение за ~15 с, без блокировки остаётся UDP.
- **feat (Android 1.4.2):** «Российские сайты — напрямую, без VPN» (по умолчанию), выбор канала Авто/UDP/TCP, журнал TCP-канала.
- **fix:** установка и обновления с git.bobyrev.com (GitHub — запасной), каскад раздаёт панель.
- Ограничения: TCP-резерв пока только на основном сервере (не на вторых) и только в приложении PCA Phobos VPN (роутеры и PhobosWG — по-прежнему UDP с перебором портов).
## 1.4.0 — единая версия (stable = beta = dev)
Три канала сведены в одну версию: всё из dev (WARP, ttyd, бэкап, маршрут Keenetic, параметры обфускатора)
+ фиксы beta (обновлятор через API, порт/схема панели) + новое:
- **feat:** светлая/тёмная тема, компактные карточки клиентов, мобильная версия без горизонтальной прокрутки.
- **feat:** лимит скорости на клиента (tc на wg0, применяется и на вторых серверах).
- **feat:** замер скорости сервера (реальный) и клиента (живой + `/speedtest` на устройстве через туннель).
- **feat:** домен панели (nginx + Let's Encrypt), адрес для клиентов по домену, каскад (`phobos-cascade.sh`).
- **feat:** сторож — блоклист сканеров РКН (`phobos-guard.sh`).
- **fix:** secondary-setup на чистой Ubuntu 24.04 (нет iptables → wg0 не поднимался), без tty, `SERVER_IP` для NAT; клиенты досылаются на новый сервер сразу.
- **fix:** очистка orphan-peer (не было WG_CONFIG), дубль карточки WARP и кнопки WARP, имена клиентов кириллицей.
- **fix:** после обновления старым обновлятором 1.2.x/1.3.x панель сама докачивает новые скрипты.
- **fix:** APK без токена подписчика — из публичного релиза (как в 1.2.8).
Обновление: `phobos-update` (stable) — клиенты, ключи и `server.env` не трогаются. Проверено на школе: 1.2.8 → 1.4.0, старый клиент работает без переустановки.
## stable
### v1.2.7
- **fix:** импорт `phobos://` в PhobosWG. Конфиги дополняются `= none` для недостающих полей (`PresharedKey` и др.) перед base64 — иначе приложение писало «невозможно импортировать туннель».
### v1.2.6
- **fix:** бинарники wg-obfuscator для роутеров. Keenetic = aarch64, а Ground-Zerro раздаёт только x86_64. Теперь install.sh и сборка пакета клиента авто-качают aarch64/mipsel/armv7 из ClusterM/wg-obfuscator releases.
### v1.2.5
- **feat:** поле токена подписчика + кнопки каналов (stable/beta/dev) прямо в карточке «Версия и обновления». Подписчик вставляет токен в панель и переключается на beta/dev без консоли.
### v1.2.4
- **feat:** установка по умолчанию стабильная; beta/dev — опционально (`CHANNEL=`).
### v1.2.3
- **fix:** дебаунс DOWN-алертов (3 цикла + повтор) — убирает ложный флап серверов.
- **fix:** `phobos-update` отменяет обновление на несуществующую версию (текущая не трогается).
- **feat:** `--check` / `--versions`, кнопка «Проверить обновления».
### v1.2.2
- контакт во всех местах → группа `@PCAdministration`.
---
## Критичные фиксы (общие, во всех каналах)
| Версия | Проблема | Решение |
|--------|----------|---------|
| 1.2.8 / 1.3.x | Android PhobosWG: «Неправильная длина ключа в PublicKey» | Android/iOS payload восстанавливает потерянный `=` в WireGuard ключах и не добавляет `PresharedKey = none`; QR/link нужно открыть заново |
| 1.2.8 / 1.3.x | Установка/обновление могли пройти, но потом панель генерила кривую ссылку | `phobos-self-check --fix` встроен в install/update и доступен как отдельная команда |
| 1.2.7 / 1.3.x | PhobosWG: «невозможно импортировать туннель» | `phobos://` дополняется `= none` для всех обязательных полей (`PresharedKey`) по спеке формата |
| 1.2.8 / 1.3.x | Install `[6/9] PCA patches`: `curl: (23) Failure writing output to destination` | `fetch()` создаёт папку назначения перед `curl -o` |
| 1.2.8 / 1.3.x | VPS x86_64: `ERROR: obfuscator binary for x86_64 missing` | авто-загрузка `linux-x64` из ClusterM releases + нормализация `amd64 -> x86_64` |
| 1.2.6 / 1.3.x | Роутер aarch64: «бинарник не найден в архиве» | авто-загрузка aarch64/mipsel/armv7 из ClusterM releases |
| (внутр.) | Клиент не подключается, «НЕТ СОЕДИНЕНИЯ», handshake не встаёт | клиентский обфускатор целился в порт 51821 (никто не слушал); теперь берёт первый из `OBFUSCATOR_PORTS` (2083) |
| (внутр.) | агент secondary не обновлялся через `phobos-update` | `server/api.py` добавлен в managed_files |
---
## beta (приватный)
### v1.3.0-beta
- **fix:** install/update теперь запускают `phobos-self-check --fix`: автоматически чинят потерянный `=` в `SERVER_WG_PUBLIC_KEY`, сверяют ключ с `wg0`, проверяют router helper-файлы и shell syntax до выдачи клиентских ссылок.
- **fix:** Android/iOS phone configs теперь восстанавливают потерянный `=` в WireGuard `PrivateKey/PublicKey/PresharedKey`. Это закрывает ошибку PhobosWG «Неправильная длина ключа в PublicKey».
- **fix:** `lib-core.sh` больше не теряет финальный `=` при чтении `server.env`, поэтому новые клиентские `.conf` получают полный `PublicKey`.
- **fix:** клиентский router package теперь содержит обязательные `lib-client.sh`, `install-obfuscator.sh`, `install-wireguard.sh` и дополнительные helper-скрипты из PCA repo, поэтому `wget .../init/TOKEN.sh | sh` больше не падает с `can't open './lib-client.sh'`.
- **fix:** шаг `[6/9] PCA patches` больше не падает с `curl: (23) Failure writing output to destination`, если папки `repo/server/scripts` или `repo/client/templates` не были созданы upstream sparse clone.
- **fix:** установка `wg-obfuscator` на x86_64 больше не падает с `ERROR: obfuscator binary for x86_64 missing`. Installer и secondary installer берут `linux-x64` из ClusterM releases.
- **fix:** свежая beta-установка снова получает `lib-core.sh`, без которого client helper мог ломаться на чистой машине.
- **fix:** старые ссылки `/tags` теперь ведут в `/labels`, без 404.
- **fix:** поле имени клиента больше не даёт ошибку в консоли Chrome из-за HTML `pattern`. UI bench-test прошёл без console/page errors.
- **fix:** WG tunnel subnet перестала быть жёстко зашита в `10.25.0.0/16`. Installer, secondary deploy, client packaging и router pull теперь используют `SERVER_WG_IPV4_NETWORK`, так что `10.26`/`10.27` и другие отдельные WG-сети на разных VPS работают без ручных правок.
- **fix:** в клиентский пакет добавляется `panel_tunnel_ip`, а `phobos-pull` использует его как tunnel endpoint панели вместо жёсткого `10.25.0.1`.
- скачивание `.conf` для телефона (Android `phobos://` + QR, iOS обычный WireGuard + QR);
- кнопка скачивания приложения **PhobosWG (APK)** — авто-загрузка из GitHub Release приватного repo;
- все критичные фиксы выше.
---
## dev (приватный, активная разработка)
### v1.3.4-dev
- **fix:** install/update теперь запускают `phobos-self-check --fix`: автоматически чинят потерянный `=` в `SERVER_WG_PUBLIC_KEY`, сверяют ключ с `wg0`, проверяют router helper-файлы и shell syntax до выдачи клиентских ссылок.
- **fix:** Android/iOS phone configs теперь восстанавливают потерянный `=` в WireGuard `PrivateKey/PublicKey/PresharedKey`. Это закрывает ошибку PhobosWG «Неправильная длина ключа в PublicKey».
- **fix:** `lib-core.sh` и WARP helper больше не теряют финальный `=` при чтении `server.env`, поэтому новые клиентские `.conf` получают полный `PublicKey`.
- **fix:** клиентский router package теперь содержит обязательные `lib-client.sh`, `install-obfuscator.sh`, `install-wireguard.sh` и дополнительные helper-скрипты из PCA repo, поэтому `wget .../init/TOKEN.sh | sh` больше не падает с `can't open './lib-client.sh'`.
- **fix:** шаг `[6/9] PCA patches` больше не падает с `curl: (23) Failure writing output to destination`, если папки `repo/server/scripts` или `repo/client/templates` не были созданы upstream sparse clone.
- **fix:** установка `wg-obfuscator` на x86_64 больше не падает с `ERROR: obfuscator binary for x86_64 missing`. Installer и secondary installer берут `linux-x64` из ClusterM releases.
- **fix:** поле имени клиента больше не даёт ошибку в консоли Chrome из-за HTML `pattern`. UI bench-test прошёл без console/page errors.
- **fix:** WG tunnel subnet больше не зашита в `10.25.0.0/16`. `install.sh`, `secondary-setup.sh`, client packaging, router pull и secondary deploy теперь берут `SERVER_WG_IPV4_NETWORK`, поэтому один сервер можно держать на `10.25`, другой на `10.26`, `10.27` и дальше без ручного `nano`.
- **fix:** в клиентский пакет добавляется `panel_tunnel_ip`, а `phobos-pull`/`phobos-ttyd` используют его вместо жёсткого `10.25.0.1`. Это убирает поломку управления роутером после смены WG-подсети на сервере.
- **fix:** per-client WARP стал действительно точечным. Удалено старое правило на всю подсеть WG, правила теперь ставятся и снимаются только по `client tunnel IP /32`.
- **fix:** клиентские пакеты и install-команды снова собираются стабильно: создаётся `/opt/Phobos/bin`, шаблон `install-router.sh.template` обрабатывается мягко, пустой `OBFUSCATOR_KEY` больше не валит сборку.
- **fix:** `/clients` больше не падает при неполной выкладке dev-стенда. Добавлены обязательные `lib-core.sh` и client templates в install/update flow.
- **fix:** “живой peer есть, клиента нет” больше не выглядит как пропавший клиент. Сиротские peer теперь отображаются в `Сессии` и `Клиенты` как `orphan-*`, с кнопками `WARP`, `Kick`, `Purge`.
- **fix:** старые ссылки `/tags` теперь ведут в `/labels`.
- **security:** login hardening — `HttpOnly` + `SameSite=Lax` cookie, ограничение попыток входа, constant-time compare для пароля и API keys, upload limit `64 MB`.
- **ops:** `phobos-update` и `phobos-backup restore` теперь сами определяют активный panel service (`phobos-panel` или `phobos-dev-test`) и перезапускают правильный unit.
- **security/cleanup:** из secondary API убран `shell=True` там, где он был не нужен; чтение CPU/mem/network переведено на более безопасный путь.
### v1.3.1-dev
- **баланс по трафику:** агенты отдают пропускную способность канала (rx/tx NIC), `server_load = 0.45·cpu + 0.30·mem + 0.25·bw`; карточки серверов показывают ↕ Mbps;
- **WARP / WARP+:** в `/settings` добавлен отдельный блок с режимами `egress`, `relay`, `custom profile` и полем `WARP+ key`;
- **egress mode:** исходящий трафик клиентов можно завернуть через Cloudflare WARP на стороне сервера;
- **relay mode:** добавлен UDP relay по мотивам `warp-relay` для отдельных WARP/WireGuard профилей;
- **custom profile:** полный `warp.conf` можно вставить и сохранить прямо из веб-панели;
- **help UI:** у блока WARP появился `?` с человеческим объяснением режимов, ограничений и сценариев использования;
- все фиксы и фичи из beta.

View File

@@ -17,7 +17,8 @@
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен. - 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово. - 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама. - ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
- 📱 **Телефоны** — Android (PhobosWG, с обфускацией) и iPhone (обычный WireGuard) через QR-код. - 📱 **Телефоны** — своё приложение **PCA Phobos VPN** для Android (обфускация + раздельный трафик по приложениям), PhobosWG, iPhone (обычный WireGuard) — через QR/ссылку.
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
--- ---
@@ -25,47 +26,72 @@
- **Активные сессии** — VPN IP, Real IP, метка, handshake, трафик RX/TX, Kick. - **Активные сессии** — VPN IP, Real IP, метка, handshake, трафик RX/TX, Kick.
- **Клиенты VPN** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера. - **Клиенты VPN** — добавление/удаление, статус online (по handshake на любом сервере), назначение сервера.
- **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM), резервные серверы; роутеры переключаются автоматически. - **Мульти-сервер + failover** — приоритеты, балансировка по нагрузке (CPU/RAM + пропускная способность канала), резервные серверы; роутеры переключаются автоматически.
- **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP. - **NAT-friendly управление** — роутеры сами тянут конфиг по туннелю (`10.25.0.1`), переживает бан публичного IP.
- **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS. - **Авто-восстановление роутеров** — серверный сторож (watchdog) поднимает роутер после неудачной перезагрузки через KeenDNS.
- **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR. - **Конфиги для телефона** — 📱 Android `phobos://` + QR, 🍎 iPhone обычный WireGuard + QR.
- **Метки** — имя объекта по IP (видно в сессиях и Telegram). - **Метки** — имя объекта по IP (видно в сессиях и Telegram).
- **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения. - **Срок подписки** — дата окончания, обратный отсчёт, автокик + предупреждения.
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление. - **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия. - **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия. **Светлая и тёмная тема** (кнопка ◐), компактная вёрстка — удобно с телефона.
- **Лимит скорости на клиента** — Мбит/с в карточке клиента; работает на всех серверах, сам восстанавливается после перезапуска WireGuard.
- **Замер скорости** — сервера (реальная скачка/отдача, кнопка ⚡ на «Серверах»), клиента (живая скорость в карточке + страница `http://10.25.0.1:<порт>/speedtest` на устройстве через VPN).
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
- **Версии и обновления** — обновление и откат прямо из панели. - **Версии и обновления** — обновление и откат прямо из панели.
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении. - **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
- **Telegram-бот** — управление клиентами командами прямо из чата: - **Telegram-бот** — управление клиентами командами прямо из чата:
- `/add <имя>` — создать клиента (бот пришлёт команду установки) - `/add <имя>` — создать клиента (бот пришлёт команду установки)
- `/del <имя>` — удалить клиента - `/del <имя>` — удалить клиента
- `/list` — список клиентов - `/list` — список клиентов
(Работает только для чата из настроек `tg_chat_id`.) - **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь.
(Работает только для чата из настроек `tg_chat_id`.) Бот **необязателен**: хочешь — впиши токен от @BotFather и свой chat id в «Настройки», не хочешь — оставь пустым.
--- ---
## Скриншоты ## Скриншоты
| Сессии | Клиенты | Серверы | | Клиенты (светлая) | Клиенты (тёмная) |
|--------|---------|---------| |---|---|
| ![Клиенты](docs/img/clients.png) | ![Клиенты тёмная](docs/img/clients-dark.png) |
| **Сессии** | **Серверы: замер скорости, второй сервер** |
| ![Сессии](docs/img/sessions.png) | ![Серверы](docs/img/servers.png) |
| Телефон: панель | Телефон: тёмная | Android: VPN-клиент | Android: раздельный трафик | Android: админ-приложение |
|---|---|---|---|---|
| ![](docs/img/mobile-clients.png) | ![](docs/img/mobile-clients-dark.png) | ![](docs/img/android-vpn.png) | ![](docs/img/android-vpn-apps.png) | ![](docs/img/android-admin.png) |
Настройки (домен, каскад, сторож, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png)
--------|---------|---------|
| ![Сессии](docs/img/sessions.png) | ![Клиенты](docs/img/clients.png) | ![Серверы](docs/img/servers.png) | | ![Сессии](docs/img/sessions.png) | ![Клиенты](docs/img/clients.png) | ![Серверы](docs/img/servers.png) |
> Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`). > Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`).
--- ---
> **Источник с 23.10.2026:** `git.bobyrev.com/PCA/PCA_Phobos` (GitHub-аккаунт временно недоступен). Серверы на 1.2.x обновляются одной командой:
> ```bash
> bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/update.sh)
> ```
## Быстрый старт (с чистого VPS, одной командой) ## Быстрый старт (с чистого VPS, одной командой)
Ставит **всё** со всеми зависимостями: wg-obfuscator, WireGuard, обфускатор-сервисы, веб-панель, nginx, скрипты онбординга роутеров, сторож авто-восстановления. Предустановленный Phobos **не требуется**. Ставит **всё** со всеми зависимостями: wg-obfuscator, WireGuard, обфускатор-сервисы, веб-панель, nginx, скрипты онбординга роутеров, сторож авто-восстановления. Предустановленный Phobos **не требуется**.
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh) bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
``` ```
### С параметрами ### С параметрами
```bash ```bash
PANEL_PASS=МойПароль TG_TOKEN=123:abc TG_CHAT=123456789 \ PANEL_PASS=МойПароль TG_TOKEN=123:abc TG_CHAT=123456789 \
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh) bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
``` ```
| Переменная | По умолчанию | Описание | | Переменная | По умолчанию | Описание |
@@ -76,20 +102,44 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора | | `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления | | `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
| `ALLOW_PLAIN_WG` | пусто | `=1` открыть порт 51820 для iOS WireGuard (без обфускации) | | `ALLOW_PLAIN_WG` | пусто | `=1` открыть порт 51820 для iOS WireGuard (без обфускации) |
| `PANEL_TLS_CERT` / `PANEL_TLS_KEY` | пусто | Включить HTTPS для панели через свой сертификат |
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их. После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
### Вторичный сервер (для failover / балансировки) ### Вторичный сервер (для failover / балансировки)
```bash ```bash
MAIN_SERVER=<ip_основного> MAIN_API_KEY=<API key из основного> \ MAIN_PROTO=http MAIN_SERVER=<ip_основного> MAIN_PORT=<порт_панели> MAIN_API_KEY=<API key из основного> \
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh) bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/secondary-setup.sh)
``` ```
Затем в панели → **Серверы** → добавь его в список. Сервер сам зарегистрируется в панели, клиенты досылаются на него сразу. Если сервер за NAT — добавь `SERVER_IP=<внешний_ip>`.
### Каскад (вход в РФ → выход за рубежом)
На входном VPS (чистый Debian/Ubuntu, Phobos не нужен):
```bash
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/phobos-cascade.sh) enable <IP_выходного_сервера> 2083,5443,993
```
Затем в панели выходного сервера → **Настройки → Адрес для клиентов и каскад** → впиши IP/домен входа. Роутеры переключатся сами (~15 с), телефонам — заново открыть ссылку/QR. Отключить: `phobos-cascade disable`.
### Сторонние «сторожа» по просьбам участников
- Защита от зондов РКН ([Loorrr293/Auto_IPtables](https://github.com/Loorrr293/Auto_IPtables)) — **встроена**: «Настройки → Защита сервера», включается кнопкой.
- «Прикрывашка» ([miha75vu-bit/Flashkeen](https://github.com/miha75vu-bit/Flashkeen)) — WARP на выход + Psiphon; интерактивная, ставится по SSH: `bash <(curl -fsSL https://raw.githubusercontent.com/miha75vu-bit/Flashkeen/main/wp)`. Перестраивает выход wg0 — не включай одновременно со встроенным WARP egress.
- ipidea-kimwolf-ban — репозиторий автора удалён (404), встроить нечего.
--- ---
## Приложения для Android
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.1/PCA-Phobos-VPN.apk) · исходники `android-client/`.
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.1/PCA-Phobos.apk) · исходники `android/`.
## Клиенты для телефона ## Клиенты для телефона
В таблице **Клиенты** у каждого клиента есть кнопки: В таблице **Клиенты** у каждого клиента есть кнопки:
@@ -100,7 +150,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей. > Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
**Приложения:** **Приложения:**
- **Android (PhobosWG):** автор пока не публикует APK официально. Положи `.apk` на сервер в `/opt/Phobos/www/app/PhobosWG.apk` — панель сама покажет кнопку «Скачать приложение PhobosWG» на странице Android-клиента (раздаётся по `http://<сервер>/app/PhobosWG.apk`). - **Android (PhobosWG):** кнопка «Скачать приложение PhobosWG (APK)» на странице Android-клиента. Можно положить свой `.apk` в `/opt/Phobos/www/app/PhobosWG.apk` — тогда панель раздаёт его.
- **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента. - **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента.
--- ---
@@ -114,7 +164,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main
**Из консоли:** **Из консоли:**
```bash ```bash
phobos-update # обновить до последней версии phobos-update # обновить до последней версии (stable)
phobos-update beta|dev # каналы для подписчиков (GH_TOKEN), сейчас = та же 1.4.0
phobos-update v1.1.0 # обновить до конкретной версии (git tag) phobos-update v1.1.0 # обновить до конкретной версии (git tag)
phobos-update --rollback # откатить на предыдущую версию phobos-update --rollback # откатить на предыдущую версию
phobos-update --version # показать установленную и доступную версию phobos-update --version # показать установленную и доступную версию
@@ -139,6 +190,9 @@ phobos-update --list # список бэкапов
| **Смена сервера применяется не сразу** | роутер тянет конфиг по cron | Применяется за ~12–15 сек, не мгновенно | | **Смена сервера применяется не сразу** | роутер тянет конфиг по cron | Применяется за ~12–15 сек, не мгновенно |
| **Забыл порт панели** | случайный порт | `cat /opt/phobos-panel/.port` | | **Забыл порт панели** | случайный порт | `cat /opt/phobos-panel/.port` |
| **Забыл API key** | | `grep server_api_key /opt/phobos-panel/settings.json` | | **Забыл API key** | | `grep server_api_key /opt/phobos-panel/settings.json` |
| **Домен: certbot failed** | A-запись не на этот сервер / закрыт 80 | `dig +short домен` = IP сервера; открой 80/tcp; повтори в «Настройках» |
| **Лимит скорости не действует** | wg0 перезапущен | панель сама восстановит за ≤1 мин; вручную: `phobos-limit.sh apply` |
| **Кнопки клиента «уезжают»** | старая версия | обнови до 1.4.0 — карточки клиентов компактные, телефоны тоже |
Логи: `journalctl -u phobos-panel -n 50` · сторож: `/opt/Phobos/server/watchdog.log` · роутер: `/opt/etc/Phobos/health.log`. Логи: `journalctl -u phobos-panel -n 50` · сторож: `/opt/Phobos/server/watchdog.log` · роутер: `/opt/etc/Phobos/health.log`.
@@ -168,7 +222,7 @@ phobos-update --list # список бэкапов
- 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate) - 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate)
- 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) - 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
- ✉️ **Telegram:** [@Iot_andrey](https://t.me/Iot_andrey) — вопросы, баги, идеи - ✉️ **Telegram:** [@PCAdministration](https://t.me/PCAdministration) — вопросы, баги, идеи
## На основе и благодарности ## На основе и благодарности

View File

@@ -1 +1 @@
1.1.0 1.4.1

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

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

View File

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

20
android-client/README.md Normal file
View File

@@ -0,0 +1,20 @@
# PCA Phobos VPN — Android-клиент
Своё приложение-клиент: импорт `phobos://` из панели, встроенный wg-obfuscator + WireGuard,
**раздельный трафик** по приложениям (весь / только выбранные / все кроме выбранных).
- WireGuard: `com.wireguard.android:tunnel` (Apache-2.0), `libs/`.
- Обфускатор: [ClusterM/wg-obfuscator](https://github.com/ClusterM/wg-obfuscator) v1.6 (GPL-3.0),
собран NDK под bionic: `jni/<abi>/libwgobfuscator.so`. Статический glibc-бинарник с роутеров
внутри приложения падает с SIGSYS (seccomp), поэтому только bionic-сборка.
- Само приложение всегда вне туннеля — иначе обфускатор не достучится до сервера.
Сборка без Gradle: `KS_PASS=… ./build.sh` → `build/PCA-Phobos-VPN.apk`
(ключ подписи — `~/.android/pca-phobos.jks`, пароль в ДОСТУПЫ.md; ключ не менять, иначе обновления не встанут).
Пересборка обфускатора:
```bash
NDK=~/Library/Android/sdk/ndk/29.0.14206865/toolchains/llvm/prebuilt/darwin-x86_64/bin
$NDK/aarch64-linux-android24-clang -O2 -std=gnu11 -o jni/arm64-v8a/libwgobfuscator.so wg-obfuscator/*.c
$NDK/armv7a-linux-androideabi24-clang -O2 -std=gnu11 -o jni/armeabi-v7a/libwgobfuscator.so wg-obfuscator/*.c
```

File diff suppressed because one or more lines are too long

34
android-client/build.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Build PCA Phobos VPN (client) without Gradle. Needs libs/: tunnel-*.aar (com.wireguard.android:tunnel),
# collection-*.jar, annotation-*.jar; jni/<abi>/libwgobfuscator.so (static ClusterM wg-obfuscator).
# KS_PASS=… ./build.sh -> build/PCA-Phobos-VPN.apk
set -euo pipefail
cd "$(dirname "$0")"
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
KS="${KS:-$HOME/.android/pca-phobos.jks}"
KS_PASS="${KS_PASS:?set KS_PASS}"
AAR=$(ls libs/tunnel-*.aar | tail -1)
rm -rf build && mkdir -p build/res build/classes build/dex build/aar build/apk/lib
unzip -q "$AAR" -d build/aar
LIBS="build/aar/classes.jar:$(ls libs/*.jar | tr '\n' ':')"
"$BT/aapt2" compile --dir res -o build/res/
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml -A assets \
--java build/gen build/res/*.flat --min-sdk-version 24 --target-sdk-version 35
javac -source 11 -target 11 -nowarn -classpath "$JAR:$LIBS" -d build/classes \
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath\|^Note:\|^warning: \[options\]\|^1 warning" || true
[ -f build/classes/com/pca/phobos/vpn/MainActivity.class ] || { echo "javac failed"; exit 1; }
"$BT/d8" --release --lib "$JAR" --min-api 24 --output build/dex \
$(find build/classes -name '*.class') build/aar/classes.jar libs/*.jar
cp build/base.apk build/unsigned.apk
(cd build/dex && zip -q ../unsigned.apk classes*.dex)
for abi in arm64-v8a armeabi-v7a; do
mkdir -p "build/apk/lib/$abi"
cp build/aar/jni/$abi/*.so "build/apk/lib/$abi/"
cp "jni/$abi/libwgobfuscator.so" "jni/$abi/libudptcp.so" "build/apk/lib/$abi/"
done
(cd build/apk && zip -qr ../unsigned.apk lib)
"$BT/zipalign" -f 4 build/unsigned.apk build/aligned.apk
"$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$KS_PASS" --out build/PCA-Phobos-VPN.apk build/aligned.apk
"$BT/apksigner" verify build/PCA-Phobos-VPN.apk && ls -la build/PCA-Phobos-VPN.apk

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#1e1b4b" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#4f46e5"
android:pathData="M54,18 L82,28 L82,52 C82,70 70,84 54,90 C38,84 26,70 26,52 L26,28 Z" />
<path android:fillColor="#c7d2fe"
android:pathData="M54,30 L72,36.5 L72,52 C72,64 64,73.5 54,78 C44,73.5 36,64 36,52 L36,36.5 Z" />
<path android:fillColor="#1e1b4b"
android:pathData="M47,46 h14 v5 h-4.5 v16 h-5 v-16 h-4.5 z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
<item name="android:colorPrimary">#a5b4fc</item>
<item name="android:colorPrimaryDark">#1e1b4b</item>
<item name="android:colorAccent">#818cf8</item>
<item name="android:windowBackground">#0f172a</item>
<item name="android:statusBarColor">#1e1b4b</item>
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:colorPrimary">#4338ca</item>
<item name="android:colorPrimaryDark">#1e1b4b</item>
<item name="android:colorAccent">#4f46e5</item>
<item name="android:windowBackground">#f1f5f9</item>
<item name="android:statusBarColor">#1e1b4b</item>
</style>
</resources>

View File

@@ -0,0 +1,537 @@
package com.pca.phobos.vpn;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ClipboardManager;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.net.VpnService;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import com.wireguard.android.backend.GoBackend;
import com.wireguard.android.backend.Statistics;
import com.wireguard.android.backend.Tunnel;
import com.wireguard.config.Config;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* PCA Phobos VPN — client: imports a phobos:// link from the panel, runs the
* bundled wg-obfuscator + userspace WireGuard, split tunnelling by apps.
* The app itself is always outside the tunnel so the obfuscator reaches the server directly.
*/
public class MainActivity extends Activity {
static final int LPORT = 13255;
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
static GoBackend backend;
static Process obfuscator, relay;
static final int TCP_LPORT = 13256;
static volatile String transport = ""; // "UDP" | "TCP" while up
static final Tunnel TUNNEL = new Tunnel() {
public String getName() { return "phobos"; }
public void onStateChange(Tunnel.State s) { }
};
private final ExecutorService bg = Executors.newSingleThreadExecutor();
private final Handler ui = new Handler(Looper.getMainLooper());
private SharedPreferences prefs;
private boolean dark;
private TextView status, traffic;
private Button power;
private long lastRx, lastTx, lastT;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
if (backend == null) backend = new GoBackend(getApplicationContext());
handleIntent(getIntent());
render();
}
@Override
protected void onNewIntent(Intent i) {
super.onNewIntent(i);
handleIntent(i);
render();
}
private void handleIntent(Intent i) {
if (i != null && i.getData() != null && "phobos".equals(i.getData().getScheme())) {
importLink(i.getDataString());
}
}
// ── colors / widgets ──────────────────────────────────────────────────
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
private GradientDrawable round(int color, int r) {
GradientDrawable g = new GradientDrawable();
g.setColor(color);
g.setCornerRadius(dp(r));
return g;
}
private Button button(String label, int color, View.OnClickListener l) {
Button b = new Button(this);
b.setText(label);
b.setAllCaps(false);
b.setTextColor(Color.WHITE);
b.setBackground(round(color, 10));
b.setOnClickListener(l);
return b;
}
private TextView tv(String s, int color, int size, boolean bold) {
TextView t = new TextView(this);
t.setText(s);
t.setTextColor(color);
t.setTextSize(size);
if (bold) t.setTypeface(Typeface.DEFAULT_BOLD);
return t;
}
private LinearLayout.LayoutParams mw(int top) {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
lp.topMargin = dp(top);
return lp;
}
// ── profile ───────────────────────────────────────────────────────────
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
private void importLink(String link) {
try {
String s = link.trim();
String name = "Phobos";
int hash = s.indexOf('#');
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
s = s.replaceFirst("^phobos://", "").trim();
String conf = new String(Base64.decode(s, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING), StandardCharsets.UTF_8);
if (!conf.contains("[Interface]") || !conf.contains("[instance]")) throw new IllegalArgumentException("not a Phobos config");
Config.parse(new BufferedReader(new StringReader(wgPart(conf, MODE_ALL, "")))); // validate
prefs.edit().putString("conf", conf).putString("name", name).apply();
toast("Профиль «" + name + "» импортирован");
} catch (Exception e) {
toast("Не удалось импортировать: " + e.getMessage());
}
}
/** Split payload into sections: header -> lines (drop "= none" placeholders). */
private static List<String> section(String conf, String header) {
List<String> out = new ArrayList<>();
boolean in = false;
for (String raw : conf.split("\n")) {
String ln = raw.trim();
if (ln.startsWith("[") && ln.endsWith("]")) { in = ln.equalsIgnoreCase(header); continue; }
if (!in || ln.isEmpty() || !ln.contains("=")) continue;
if (ln.substring(ln.indexOf('=') + 1).trim().equalsIgnoreCase("none")) continue;
out.add(ln);
}
return out;
}
private static String key(String line) { return line.substring(0, line.indexOf('=')).trim().toLowerCase(); }
private static String val(String line) { return line.substring(line.indexOf('=') + 1).trim(); }
String wgPart(String conf, String mode, String apps) {
StringBuilder sb = new StringBuilder("[Interface]\n");
for (String ln : section(conf, "[Interface]")) {
String k = key(ln);
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
sb.append(ln).append('\n');
}
List<String> sel = new ArrayList<>();
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
sel.remove(getPackageNameSafe());
sb.append("IncludedApplications = ").append(String.join(", ", sel)).append('\n');
} else {
Set<String> ex = new LinkedHashSet<>();
ex.add(getPackageNameSafe()); // obfuscator must bypass the tunnel
if (MODE_EXCEPT.equals(mode)) ex.addAll(sel);
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
}
sb.append("\n[Peer]\n");
String ruBypass = prefs != null && prefs.getBoolean("ru_direct", true) ? ruBypassAllowedIps() : null;
for (String ln : section(conf, "[Peer]")) {
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
if (key(ln).equals("allowedips") && ruBypass != null) ln = "AllowedIPs = " + ruBypass;
sb.append(ln).append('\n');
}
return sb.toString();
}
private String ruCache;
/** Internet minus Russian networks (assets/ru_bypass.txt, generated from ipdeny). */
private String ruBypassAllowedIps() {
if (ruCache != null) return ruCache;
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(
getAssets().open("ru_bypass.txt"), StandardCharsets.UTF_8))) {
for (String ln; (ln = r.readLine()) != null; ) {
if (!ln.startsWith("#") && ln.contains("/")) { ruCache = ln.trim(); break; }
}
} catch (Exception e) { log("ru_bypass: " + e); }
return ruCache;
}
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
private String obfPart(String conf, boolean viaTcp) {
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
for (String ln : section(conf, "[instance]")) {
String k = key(ln);
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
if (viaTcp && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
sb.append(ln).append('\n');
}
sb.append("verbose = INFO\n");
return sb.toString();
}
/** "# pca-tcp = 443" written by the panel when the server has the TCP fallback. */
private static String tcpPort(String conf) {
for (String ln : conf.split("\n")) {
String t = ln.trim();
if (t.startsWith("#") && t.contains("pca-tcp") && t.contains("=")) return val(t);
}
return "";
}
private String target(String conf) {
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
return "?";
}
// ── connect / disconnect ──────────────────────────────────────────────
private boolean isUp() {
try { return backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
}
private void toggle() {
if (isUp()) { bg.execute(this::down); return; }
if (prefs.getString("conf", "").isEmpty()) { toast("Сначала импортируйте ссылку phobos:// из панели"); return; }
Intent prep = VpnService.prepare(this);
if (prep != null) startActivityForResult(prep, 7);
else bg.execute(this::up);
}
@Override
protected void onActivityResult(int req, int res, Intent data) {
if (req == 7 && res == RESULT_OK) bg.execute(this::up);
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
}
/** Start (or restart) obfuscator; via TCP it dials the local udptcp relay. */
private void startTransport(String conf, boolean viaTcp) throws Exception {
stopObfuscator();
String lib = getApplicationInfo().nativeLibraryDir;
if (viaTcp) {
String host = target(conf).replaceAll(":\\d+$", "");
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
}
File cfg = new File(getFilesDir(), "obf.conf");
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp).getBytes(StandardCharsets.UTF_8)); }
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
Thread.sleep(600);
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
transport = viaTcp ? "TCP" : "UDP";
log("transport " + transport + " -> " + target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : ""));
}
private void up() {
try {
String conf = prefs.getString("conf", "");
String mode = prefs.getString("transport", "auto");
boolean tcpAvail = !tcpPort(conf).isEmpty();
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
startTransport(conf, "tcp".equals(mode));
Config c = Config.parse(new BufferedReader(new StringReader(
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
backend.setState(TUNNEL, Tunnel.State.UP, c);
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", transport " + mode);
post("Подключено (" + transport + ")");
if ("auto".equals(mode) && tcpAvail) watchUdp(conf);
} catch (Throwable e) {
stopObfuscator();
log("up failed: " + e);
post("Ошибка: " + e.getMessage());
}
}
/** Auto: no bytes back over UDP within 15 s -> UDP is cut, move the obfuscator to TCP. */
private void watchUdp(String conf) {
new Thread(() -> {
try {
Thread.sleep(15000);
if (!isUp() || !"UDP".equals(transport)) return;
if (backend.getStatistics(TUNNEL).totalRx() > 0) return;
log("UDP: no answer in 15 s -> switching to TCP");
startTransport(conf, true);
post("UDP не отвечает — переключился на TCP");
} catch (Throwable e) { log("watchUdp: " + e); }
}).start();
}
private void down() {
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
stopObfuscator();
transport = "";
post("Отключено");
}
private static void stopObfuscator() {
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
if (relay != null) { relay.destroy(); relay = null; }
}
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
// ── UI ────────────────────────────────────────────────────────────────
private void render() {
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(bg());
LinearLayout h = new LinearLayout(this);
h.setBackgroundColor(0xFF1E1B4B);
h.setPadding(dp(16), dp(14), dp(16), dp(14));
h.addView(tv("🛡 PCA Phobos VPN", 0xFFC7D2FE, 18, true));
root.addView(h);
ScrollView sv = new ScrollView(this);
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(16), dp(16), dp(16), dp(24));
sv.addView(box);
String conf = prefs.getString("conf", "");
LinearLayout prof = new LinearLayout(this);
prof.setOrientation(LinearLayout.VERTICAL);
prof.setBackground(round(card(), 14));
prof.setPadding(dp(16), dp(14), dp(16), dp(14));
if (conf.isEmpty()) {
prof.addView(tv("Профиль не импортирован", text(), 16, true));
prof.addView(tv("В панели PCA Phobos откройте клиента → 📱 Android → нажмите ссылку phobos:// "
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
} else {
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
prof.addView(tv("Сервер: " + target(conf), muted(), 14, false));
}
box.addView(prof, mw(0));
power = new Button(this);
power.setAllCaps(false);
power.setTextColor(Color.WHITE);
power.setTextSize(20);
power.setTypeface(Typeface.DEFAULT_BOLD);
power.setOnClickListener(v -> toggle());
LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams(dp(180), dp(180));
plp.gravity = Gravity.CENTER_HORIZONTAL;
plp.topMargin = dp(24);
box.addView(power, plp);
status = tv("", text(), 16, true);
status.setGravity(Gravity.CENTER);
box.addView(status, mw(12));
traffic = tv("", muted(), 14, false);
traffic.setGravity(Gravity.CENTER);
box.addView(traffic, mw(2));
// split tunnelling
LinearLayout split = new LinearLayout(this);
split.setOrientation(LinearLayout.VERTICAL);
split.setBackground(round(card(), 14));
split.setPadding(dp(16), dp(14), dp(16), dp(14));
split.addView(tv("Раздельный трафик", text(), 16, true));
RadioGroup rg = new RadioGroup(this);
String mode = prefs.getString("mode", MODE_ALL);
String[][] modes = {{MODE_ALL, "Весь трафик через VPN"}, {MODE_ONLY, "Только выбранные приложения"},
{MODE_EXCEPT, "Все, кроме выбранных приложений"}};
for (String[] m : modes) {
RadioButton r = new RadioButton(this);
r.setText(m[1]);
r.setTextColor(text());
r.setId(View.generateViewId());
r.setChecked(m[0].equals(mode));
r.setOnClickListener(v -> { prefs.edit().putString("mode", m[0]).apply(); applyIfUp(); refresh(); });
rg.addView(r);
}
split.addView(rg);
android.widget.CheckBox ru = new android.widget.CheckBox(this);
ru.setText("Российские сайты — напрямую, без VPN");
ru.setTextColor(text());
ru.setChecked(prefs.getBoolean("ru_direct", true));
ru.setOnCheckedChangeListener((b, on) -> { prefs.edit().putBoolean("ru_direct", on).apply(); applyIfUp(); });
split.addView(ru, mw(4));
split.addView(tv("Канал до сервера", text(), 14, true), mw(8));
RadioGroup tg = new RadioGroup(this);
tg.setOrientation(RadioGroup.HORIZONTAL);
String tmode = prefs.getString("transport", "auto");
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}}) {
RadioButton r = new RadioButton(this);
r.setText(m[1]);
r.setTextColor(text());
r.setId(View.generateViewId());
r.setChecked(m[0].equals(tmode));
r.setOnClickListener(v -> { prefs.edit().putString("transport", m[0]).apply(); applyIfUp(); });
tg.addView(r);
}
split.addView(tg);
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
muted(), 12, false), mw(6));
box.addView(split, mw(20));
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
box.addView(button("Проверить скорость", 0xFF0EA5E9, v -> speedtest()), mw(8));
box.addView(button("Журнал", 0xFF475569, v -> showLog()), mw(8));
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
refresh();
ui.removeCallbacksAndMessages(null);
ui.postDelayed(new Runnable() { public void run() { refresh(); ui.postDelayed(this, 2000); } }, 2000);
}
private void refresh() {
if (power == null) return;
boolean up = isUp();
power.setText(up ? "Отключить" : "Подключить");
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
status.setText(up ? "🟢 VPN включён" + (transport.isEmpty() ? "" : " · " + transport) : "⚪ VPN выключен");
if (up) {
try {
Statistics st = backend.getStatistics(TUNNEL);
long rx = st.totalRx(), tx = st.totalTx(), now = System.currentTimeMillis();
String speed = "";
if (lastT > 0 && now > lastT) {
double s = (now - lastT) / 1000.0;
speed = String.format(" · ↓ %.1f / ↑ %.1f Мбит/с", (rx - lastRx) * 8 / s / 1e6, (tx - lastTx) * 8 / s / 1e6);
}
lastRx = rx; lastTx = tx; lastT = now;
traffic.setText(String.format("Получено %.1f МБ · Отправлено %.1f МБ", rx / 1048576.0, tx / 1048576.0) + speed);
} catch (Exception e) { traffic.setText(""); }
} else { traffic.setText(""); lastT = 0; }
}
private void applyIfUp() { if (isUp()) bg.execute(() -> { down(); up(); }); }
private void importDialog() {
final EditText e = new EditText(this);
e.setHint("phobos://…");
ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
if (cm != null && cm.getPrimaryClip() != null && cm.getPrimaryClip().getItemCount() > 0) {
CharSequence c = cm.getPrimaryClip().getItemAt(0).getText();
if (c != null && c.toString().trim().startsWith("phobos://")) e.setText(c);
}
new AlertDialog.Builder(this).setTitle("Ссылка из панели").setView(e)
.setPositiveButton("Импорт", (d, w) -> { importLink(e.getText().toString()); applyIfUp(); render(); })
.setNegativeButton("Отмена", null).show();
}
private void pickApps() {
PackageManager pm = getPackageManager();
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
List<ResolveInfo> ris = pm.queryIntentActivities(main, 0);
List<String[]> apps = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (ResolveInfo ri : ris) {
String p = ri.activityInfo.packageName;
if (p.equals(getPackageName()) || !seen.add(p)) continue;
apps.add(new String[]{String.valueOf(ri.loadLabel(pm)), p});
}
Collections.sort(apps, (a, b) -> a[0].compareToIgnoreCase(b[0]));
final Set<String> chosen = new LinkedHashSet<>(Arrays.asList(prefs.getString("apps", "").split(",")));
chosen.remove("");
String[] labels = new String[apps.size()];
boolean[] checked = new boolean[apps.size()];
for (int i = 0; i < apps.size(); i++) { labels[i] = apps.get(i)[0]; checked[i] = chosen.contains(apps.get(i)[1]); }
new AlertDialog.Builder(this).setTitle("Приложения")
.setMultiChoiceItems(labels, checked, (d, i, on) -> { if (on) chosen.add(apps.get(i)[1]); else chosen.remove(apps.get(i)[1]); })
.setPositiveButton("Готово", (d, w) -> { prefs.edit().putString("apps", String.join(",", chosen)).apply(); applyIfUp(); render(); })
.setNegativeButton("Отмена", null).show();
}
/** Real speed through the tunnel: the browser is inside the VPN (unless excluded by the split). */
private void speedtest() {
if (!isUp()) { toast("Сначала подключитесь"); return; }
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://speed.cloudflare.com/")));
}
private void log(String s) {
try (FileOutputStream o = new FileOutputStream(new File(getFilesDir(), "app.log"), true)) {
o.write((new java.util.Date() + " " + s + "\n").getBytes(StandardCharsets.UTF_8));
} catch (Exception ignored) { }
}
private static String tail(File f, int max) {
try {
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
try (java.io.FileInputStream in = new java.io.FileInputStream(f)) {
byte[] buf = new byte[8192];
for (int n; (n = in.read(buf)) > 0; ) o.write(buf, 0, n);
}
String t = new String(o.toByteArray(), StandardCharsets.UTF_8);
return t.length() > max ? t.substring(t.length() - max) : t;
} catch (Exception e) { return ""; }
}
private void showLog() {
String t = "— приложение —\n" + tail(new File(getFilesDir(), "app.log"), 3000)
+ "\n— обфускатор —\n" + tail(new File(getFilesDir(), "obf.log"), 3000)
+ "\n— TCP-канал —\n" + tail(new File(getFilesDir(), "tcp.log"), 1500)
+ "\nобфускатор запущен: " + (obfuscator != null && obfuscator.isAlive());
TextView v = tv(t, text(), 12, false);
v.setTextIsSelectable(true);
v.setPadding(dp(16), dp(8), dp(16), dp(8));
ScrollView sv = new ScrollView(this);
sv.addView(v);
new AlertDialog.Builder(this).setTitle("Журнал").setView(sv).setPositiveButton("OK", null).show();
}
private void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_LONG).show(); }
}

1
android/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.pca.phobos"
android:versionCode="140"
android:versionName="1.4.0">
<uses-sdk android:minSdkVersion="23" android:targetSdkVersion="35" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="PCA Phobos"
android:icon="@drawable/ic_launcher"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:allowBackup="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

4
android/README.md Normal file
View File

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

26
android/build.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Build PCA Phobos Android app without Gradle: aapt2 -> javac -> d8 -> zipalign -> apksigner.
# ANDROID_HOME=~/Library/Android/sdk KS=~/.android/pca-phobos.jks KS_PASS=… ./build.sh
# Output: build/PCA-Phobos.apk. The keystore is NOT in git (keep the same one, or updates won't install).
set -euo pipefail
cd "$(dirname "$0")"
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
KS="${KS:-$HOME/.android/pca-phobos.jks}"
KS_PASS="${KS_PASS:?set KS_PASS (keystore password)}"
rm -rf build && mkdir -p build/res build/classes build/dex
"$BT/aapt2" compile --dir res -o build/res/
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml \
--java build/gen build/res/*.flat --min-sdk-version 23 --target-sdk-version 35
javac -source 11 -target 11 -nowarn -classpath "$JAR" -d build/classes \
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath" || true
"$BT/d8" --release --lib "$JAR" --min-api 23 --output build/dex $(find build/classes -name '*.class')
cp build/base.apk build/unsigned.apk
(cd build/dex && zip -q ../unsigned.apk classes.dex)
"$BT/zipalign" -f 4 build/unsigned.apk build/aligned.apk
[ -f "$KS" ] || keytool -genkeypair -keystore "$KS" -storepass "$KS_PASS" -keypass "$KS_PASS" \
-alias pca -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=PCA Phobos, O=PCAdministration" >/dev/null
"$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$KS_PASS" --out build/PCA-Phobos.apk build/aligned.apk
"$BT/apksigner" verify build/PCA-Phobos.apk && ls -la build/PCA-Phobos.apk

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#1e1b4b" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#4f46e5"
android:pathData="M54,18 L82,28 L82,52 C82,70 70,84 54,90 C38,84 26,70 26,52 L26,28 Z" />
<path android:fillColor="#c7d2fe"
android:pathData="M54,30 L72,36.5 L72,52 C72,64 64,73.5 54,78 C44,73.5 36,64 36,52 L36,36.5 Z" />
<path android:fillColor="#1e1b4b"
android:pathData="M47,46 h14 v5 h-4.5 v16 h-5 v-16 h-4.5 z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
<item name="android:colorPrimary">#a5b4fc</item>
<item name="android:colorPrimaryDark">#1e1b4b</item>
<item name="android:colorAccent">#818cf8</item>
<item name="android:windowBackground">#0f172a</item>
<item name="android:statusBarColor">#1e1b4b</item>
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:colorPrimary">#4338ca</item>
<item name="android:colorPrimaryDark">#1e1b4b</item>
<item name="android:colorAccent">#4f46e5</item>
<item name="android:windowBackground">#f1f5f9</item>
<item name="android:statusBarColor">#1e1b4b</item>
</style>
</resources>

View File

@@ -0,0 +1,367 @@
package com.pca.phobos;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.os.Bundle;
import android.text.InputType;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.CookieManager;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* PCA Phobos — admin app: a list of your Phobos panels, each opens in a WebView.
* phobos:// links are handed to PhobosWG, downloads go to the system browser.
*/
public class MainActivity extends Activity {
private SharedPreferences prefs;
private WebView web;
private ProgressBar progress;
private TextView title;
private boolean dark;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
prefs = getSharedPreferences("phobos", MODE_PRIVATE);
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)
== Configuration.UI_MODE_NIGHT_YES;
JSONArray list = panels();
String last = prefs.getString("last", "");
if (!last.isEmpty() && b == null) openPanel(last, nameFor(last));
else if (list.length() == 0) showEditor(null, -1);
else showList();
}
// ── colors ────────────────────────────────────────────────────────────
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
private static final int ACCENT = 0xFF4F46E5;
private static final int HEADER = 0xFF1E1B4B;
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
private GradientDrawable round(int color, int radius) {
GradientDrawable g = new GradientDrawable();
g.setColor(color);
g.setCornerRadius(dp(radius));
return g;
}
private Button button(String label, int color, View.OnClickListener l) {
Button btn = new Button(this);
btn.setText(label);
btn.setAllCaps(false);
btn.setTextColor(Color.WHITE);
btn.setBackground(round(color, 10));
btn.setOnClickListener(l);
return btn;
}
private LinearLayout header(String text, View right) {
LinearLayout h = new LinearLayout(this);
h.setBackgroundColor(HEADER);
h.setGravity(Gravity.CENTER_VERTICAL);
h.setPadding(dp(16), dp(12), dp(8), dp(12));
TextView t = new TextView(this);
t.setText("🛡 " + text);
t.setTextColor(0xFFC7D2FE);
t.setTextSize(18);
t.setTypeface(Typeface.DEFAULT_BOLD);
t.setSingleLine(true);
h.addView(t, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
if (right != null) h.addView(right);
title = t;
return h;
}
// ── storage ───────────────────────────────────────────────────────────
private JSONArray panels() {
try { return new JSONArray(prefs.getString("panels", "[]")); }
catch (Exception e) { return new JSONArray(); }
}
private void savePanels(JSONArray a) { prefs.edit().putString("panels", a.toString()).apply(); }
private String nameFor(String url) {
JSONArray a = panels();
for (int i = 0; i < a.length(); i++) {
JSONObject o = a.optJSONObject(i);
if (o != null && url.equals(o.optString("url"))) return o.optString("name", url);
}
return url;
}
static String normalize(String raw) {
String u = raw.trim();
if (u.isEmpty()) return "";
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "http://" + u;
while (u.endsWith("/")) u = u.substring(0, u.length() - 1);
return u;
}
// ── screens ───────────────────────────────────────────────────────────
private void showList() {
web = null;
prefs.edit().remove("last").apply();
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(bg());
root.addView(header("PCA Phobos", null));
ScrollView sv = new ScrollView(this);
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(16), dp(16), dp(16), dp(16));
sv.addView(box);
TextView hint = new TextView(this);
hint.setText("Ваши серверы Phobos. Нажмите — откроется панель. Долгое нажатие — изменить.");
hint.setTextColor(muted());
hint.setPadding(0, 0, 0, dp(12));
box.addView(hint);
final JSONArray a = panels();
for (int i = 0; i < a.length(); i++) {
final int idx = i;
final JSONObject o = a.optJSONObject(i);
if (o == null) continue;
LinearLayout c = new LinearLayout(this);
c.setOrientation(LinearLayout.VERTICAL);
c.setBackground(round(card(), 12));
c.setPadding(dp(16), dp(14), dp(16), dp(14));
TextView n = new TextView(this);
n.setText(o.optString("name"));
n.setTextColor(text());
n.setTextSize(17);
n.setTypeface(Typeface.DEFAULT_BOLD);
TextView u = new TextView(this);
u.setText(o.optString("url"));
u.setTextColor(muted());
c.addView(n);
c.addView(u);
c.setOnClickListener(v -> openPanel(o.optString("url"), o.optString("name")));
c.setOnLongClickListener(v -> { showEditor(o, idx); return true; });
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
lp.bottomMargin = dp(10);
box.addView(c, lp);
}
box.addView(button("+ Добавить сервер", ACCENT, v -> showEditor(null, -1)));
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
}
private void showEditor(final JSONObject existing, final int idx) {
web = null;
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(bg());
root.addView(header(existing == null ? "Новый сервер" : "Изменить сервер", null));
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(16), dp(16), dp(16), dp(16));
final EditText name = field("Название (например: Амстердам)", existing == null ? "" : existing.optString("name"));
final EditText url = field("Адрес панели: http://IP:порт или https://домен", existing == null ? "" : existing.optString("url"));
url.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
box.addView(label("Название"));
box.addView(name);
box.addView(label("Адрес панели"));
box.addView(url);
TextView tip = new TextView(this);
tip.setText("Адрес и порт панели печатает установщик в конце установки (или: cat /opt/phobos-panel/.port). "
+ "Если задан домен в «Настройках» панели — используйте https://домен.");
tip.setTextColor(muted());
tip.setPadding(0, dp(4), 0, dp(16));
box.addView(tip);
box.addView(button("Сохранить и открыть", ACCENT, v -> {
String u = normalize(url.getText().toString());
if (u.isEmpty()) { toast("Введите адрес панели"); return; }
String n = name.getText().toString().trim();
if (n.isEmpty()) n = Uri.parse(u).getHost();
try {
JSONArray a = panels();
JSONObject o = new JSONObject().put("name", n).put("url", u);
if (idx >= 0) a.put(idx, o); else a.put(o);
savePanels(a);
} catch (Exception ignored) { }
openPanel(u, n);
}));
if (existing != null) {
Button del = button("Удалить из списка", 0xFFDC2626, v -> {
JSONArray a = panels(), out = new JSONArray();
for (int i = 0; i < a.length(); i++) if (i != idx) out.put(a.opt(i));
savePanels(out);
showList();
});
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
lp.topMargin = dp(10);
box.addView(del, lp);
}
if (panels().length() > 0) {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
lp.topMargin = dp(10);
box.addView(button("Назад", 0xFF475569, v -> showList()), lp);
}
root.addView(box);
setContentView(root);
}
private TextView label(String s) {
TextView t = new TextView(this);
t.setText(s);
t.setTextColor(muted());
t.setPadding(0, dp(8), 0, dp(4));
return t;
}
private EditText field(String hint, String value) {
EditText e = new EditText(this);
e.setHint(hint);
e.setText(value);
e.setSingleLine(true);
e.setTextColor(text());
e.setHintTextColor(muted());
e.setBackground(round(card(), 10));
e.setPadding(dp(12), dp(12), dp(12), dp(12));
return e;
}
private void openPanel(final String url, String name) {
prefs.edit().putString("last", url).apply();
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(bg());
LinearLayout actions = new LinearLayout(this);
TextView reload = headerAction("⟳", v -> { if (web != null) web.reload(); });
TextView servers = headerAction("☰", v -> showList());
actions.addView(reload);
actions.addView(servers);
root.addView(header(name, actions));
progress = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal);
progress.setMax(100);
root.addView(progress, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3)));
web = new WebView(this);
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true);
s.setLoadWithOverviewMode(true);
s.setUseWideViewPort(true);
s.setBuiltInZoomControls(true);
s.setDisplayZoomControls(false);
s.setUserAgentString(s.getUserAgentString() + " PCAPhobosApp/1.4.0");
CookieManager.getInstance().setAcceptCookie(true);
web.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest req) {
Uri u = req.getUrl();
String scheme = u.getScheme() == null ? "" : u.getScheme();
if (scheme.equals("http") || scheme.equals("https")) {
Uri base = Uri.parse(url);
// other sites (GitHub, Boosty, Telegram…) -> system browser / apps
if (u.getHost() != null && !u.getHost().equals(base.getHost())) { external(u); return true; }
return false;
}
external(u); // phobos:// -> PhobosWG, tg:// -> Telegram
return true;
}
@Override
public void onPageFinished(WebView view, String u) {
progress.setVisibility(View.GONE);
CookieManager.getInstance().flush();
}
@Override
public void onReceivedError(WebView view, int code, String desc, String failing) {
if (failing != null && failing.startsWith(url)) {
view.loadDataWithBaseURL(null, errorPage(url, desc), "text/html", "utf-8", null);
}
}
});
web.setWebChromeClient(new WebChromeClient() {
@Override
public void onProgressChanged(WebView view, int p) {
progress.setVisibility(p < 100 ? View.VISIBLE : View.GONE);
progress.setProgress(p);
}
});
web.setDownloadListener((dlUrl, ua, cd, mime, len) -> external(Uri.parse(dlUrl)));
root.addView(web, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
web.loadUrl(url + "/");
}
private TextView headerAction(String s, View.OnClickListener l) {
TextView t = new TextView(this);
t.setText(s);
t.setTextColor(0xFFC7D2FE);
t.setTextSize(22);
t.setPadding(dp(14), 0, dp(14), 0);
t.setOnClickListener(l);
return t;
}
private String errorPage(String url, String desc) {
String bgc = dark ? "#0f172a" : "#f1f5f9", fg = dark ? "#e2e8f0" : "#0f172a";
return "<html><body style='font-family:sans-serif;background:" + bgc + ";color:" + fg
+ ";padding:24px;text-align:center'><h2>Панель недоступна</h2><p>" + url + "</p><p style='color:#64748b'>"
+ desc + "</p><p>Проверьте адрес и порт, интернет и что сервер включён. Кнопка ⟳ — повторить.</p></body></html>";
}
private void external(Uri u) {
try {
startActivity(new Intent(Intent.ACTION_VIEW, u));
} catch (ActivityNotFoundException e) {
if ("phobos".equals(u.getScheme())) {
new AlertDialog.Builder(this)
.setTitle("Нужно приложение PhobosWG")
.setMessage("Ссылки phobos:// открывает приложение PhobosWG. Скачайте его на странице Android-клиента в панели (кнопка «Скачать приложение PhobosWG»).")
.setPositiveButton("OK", null).show();
} else {
toast("Нет приложения для " + u.getScheme());
}
}
}
private void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_SHORT).show(); }
@Override
public void onBackPressed() {
if (web != null && web.canGoBack()) { web.goBack(); return; }
if (web != null && panels().length() > 0) { showList(); return; }
super.onBackPressed();
}
}

2937
app.py

File diff suppressed because it is too large Load Diff

107
docs/PORTS.md Normal file
View File

@@ -0,0 +1,107 @@
# PCA Phobos ports and firewall checklist
This page is the short truth table for normal Phobos work. Use it before
opening a ticket about "router does not switch", "secondary is online but sync
fails", or "panel is too exposed".
## Primary server
| Port | Protocol | Who connects | Required | Why |
| --- | --- | --- | --- | --- |
| `PANEL_PORT` | TCP | admin browser, secondary setup/register, router pull fallback | yes | Web panel and `/api/*`. Real value: `cat /opt/phobos-panel/.port`. |
| `80` | TCP | routers during install, Android APK download | recommended | nginx serves `/init/`, `/packages/`, `/app/`. |
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. Real value: `grep OBFUSCATOR_PORTS /opt/Phobos/server/server.env`. |
| `51820` | UDP | plain WireGuard clients only | no by default | Usually blocked to force obfuscation. Open only if installed with `ALLOW_PLAIN_WG=1` for iOS/plain WG. |
| `22` | TCP | admin only | optional | SSH maintenance. Do not expose broadly. |
`PANEL_PORT` is random on clean install unless passed as env:
```bash
cat /opt/phobos-panel/.port
```
## Secondary server
| Port | Protocol | Who connects | Required | Why |
| --- | --- | --- | --- | --- |
| `8444` | TCP | primary panel, routers via tunnel/public path | yes | Mini-API: health, peer sync, router config cache. |
| `2083,5443,993` | UDP | routers and Android PhobosWG | yes | wg-obfuscator public entry ports. |
| `51820` | UDP | nobody public | no | WireGuard listens locally behind obfuscator; public access is blocked. |
| `22` | TCP | admin during install | optional | SSH maintenance. |
If secondary is "Online" but `Info`, `Sync`, or client assignment fails, check
`8444/tcp` first and confirm the API key matches the main panel key.
## Router outbound access
Routers need outbound access to:
- every server UDP obfuscator port, default `2083,5443,993`;
- primary `PANEL_PORT/tcp` for initial install and public pull fallback;
- primary `80/tcp` if using the nginx install fallback;
- tunnel management paths after VPN is up:
- `http://10.25.0.1:8444` for secondary mini-API;
- `http(s)://10.25.0.1:<PANEL_PORT>` for primary panel API.
Current router packages write these files on the router:
```bash
/opt/etc/Phobos/panel_url
/opt/etc/Phobos/panel_urls
/opt/etc/Phobos/panel_port
/opt/etc/Phobos/panel_scheme
```
If a router does not switch after changing its server in the panel, check:
```bash
cat /opt/etc/Phobos/panel_urls
cat /opt/etc/Phobos/failover.conf
tail -n 80 /opt/etc/Phobos/health.log
/opt/etc/Phobos/phobos-pull.sh
```
If `panel_urls` contains an old IP or old panel port, create a fresh install
command in the panel and reinstall/update that router package.
## HTTPS panel
Native HTTPS is supported by the primary installer:
```bash
PANEL_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem \
PANEL_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem \
bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
```
Then the panel runs as:
```text
https://<server-ip>:<PANEL_PORT>
```
The generated router and secondary commands use `curl -k`/`wget
--no-check-certificate` fallback so a self-signed certificate can work for
bootstrap. For humans in a browser, use a real certificate to avoid warnings.
## Can I IP-whitelist the panel port?
Yes, but with care. `PANEL_PORT` is not only the admin UI. It also carries
server registration and router pull API. If you whitelist it too tightly, these
features break:
- secondary auto-register;
- `Info` / `Sync` / client assignment to secondary;
- router pull fallback before tunnel is up;
- router config update when public path is the only available path.
Minimum allowlist for `PANEL_PORT`:
- your admin IPs;
- all secondary server public IPs;
- router public IPs, if they are static;
- or no router IP restriction until first install is complete and tunnel pull is proven.
For CGNAT/dynamic-router fleets, do not rely on a strict public-IP allowlist for
router management. Use strong panel password, API key secrecy, optional HTTPS,
and keep only the required ports open.

160
docs/WARP_DEV_NOTES.md Normal file
View File

@@ -0,0 +1,160 @@
# WARP in `dev` — modes, checks, current limits
Date: `2026-06-06`
Channel: `dev`
Version: `v1.3.1-dev`
## What was added
The `dev` panel now has a dedicated `WARP / WARP+` block in `/settings`.
Supported modes:
- `egress` — route client outbound traffic through WARP on the server side;
- `relay` — expose a UDP relay for a separate WARP/WireGuard client profile;
- `custom profile` — save a full custom `warp.conf` from the web UI;
- `WARP+ key` — save a license key and rebuild the profile through `wgcf`.
The UI also has a `?` help block with plain-language explanations:
- what each mode does;
- when to use `free` vs `custom`;
- what `WARP+ key` changes;
- what `Enable` and `Disable` do;
- how per-client WARP works from the client list.
## How the modes work
### `egress`
Use this when you want Phobos clients to keep connecting the usual way, but send
their outgoing traffic through Cloudflare WARP after they land on the server.
Implementation:
- WireGuard clients still connect to the Phobos server;
- server-side policy routing sends only selected client tunnel IPs through `warp`;
- NAT is done with `POSTROUTING MASQUERADE`;
- client rows in `/clients` have `WARP ON/OFF`, which adds/removes `ip rule from <client-ip>/32`.
### `relay`
Use this when you need a separate relay endpoint for another WARP/WireGuard
client profile.
Important:
- this is **not** a drop-in replacement for the normal Phobos router config;
- the client must use a separate WARP/WireGuard profile aimed at the relay port;
- this mode is for relay-style scenarios, not for replacing the regular
`home.conf` path one-to-one.
Implementation:
- UDP relay rules are added with tagged iptables entries;
- incoming UDP on the selected port is DNAT'ed to the target WARP endpoint;
- outgoing packets are MASQUERADE'd back through the relay host.
### `custom profile` and `WARP+`
Two ways to use a non-default profile:
- paste a full custom `warp.conf`;
- save a `WARP+ key` and let `wgcf` rebuild the profile from it.
This keeps the UI simple:
- `free WARP` still works out of the box;
- `WARP+` is possible when the owner has a real key;
- no hidden magic is required outside the panel.
## Live checks completed
### Server-side checks
Passed:
- `python3 -m py_compile app.py server/api.py`
- `bash -n install.sh update.sh server/phobos-warp.sh`
- live `status` checks on dev server `64.188.81.115`
Verified on the dev server:
- `egress` mode enables cleanly;
- current egress IPv4 is reported in status JSON;
- `custom profile` and `WARP+ key` save from the web UI;
- relay rules appear with separate tagged iptables entries.
### Real router check
Router used for testing:
- public SSH: `95.72.5.253`
- web: `https://homesmart.netcraze.pro/dashboard`
Confirmed:
- real router traffic was sent through the dev server;
- with `egress` enabled, a live `curl https://api.ipify.org` from the router
returned WARP egress IP `104.28.193.219`;
- this proves the real data path for `router -> Phobos -> WARP -> internet`.
### Relay check
Confirmed:
- relay mode enables from the server side;
- relay iptables rules are created;
- counters increase on live traffic;
- a separate WARP profile pointed at the relay port completes handshake.
Not fully closed yet:
- relay handshake and packet flow were confirmed;
- a full end-to-end "internet works through relay profile" check on the router
was not fully finished in a clean state before router access became limited.
## Router recovery note
During live tests, WAN SSH access to the router stopped responding, so full file
restore over shell was blocked.
What was still restored safely:
- temporary KeenDNS helper proxies created for testing were removed;
- temporary extra WireGuard peer entries were cleaned up;
- the router is currently left on a **working direct dev WireGuard peer** so it
stays reachable through the test tunnel rather than returning to a broken
down state.
What still needs one final shell pass later:
- restore `/opt/etc/Phobos/home.conf` from backup;
- restore `/opt/etc/Phobos/wg-obfuscator.conf` from backup;
- restore `/opt/etc/Phobos/failover.conf` from backup;
- restore minute cron jobs for `phobos-health.sh` and `phobos-pull.sh`.
Known backup path on the router:
- `/opt/etc/Phobos/test-backup-20260606-044834`
## Current honest status
Ready now:
- `egress` mode
- per-client `WARP ON/OFF`
- `custom profile`
- `WARP+ key`
- UI help text
Partially verified:
- `relay` mode
Extra audit fixes after live testing:
- orphan WireGuard peers now show up in both `Sessions` and `Clients` as `orphan-*` instead of disappearing into `unknown`;
- orphan rows can be `Kick`ed, `Purge`d, and also toggled through per-peer WARP;
- client package build no longer fails when `/opt/Phobos/bin` is missing or `OBFUSCATOR_KEY` is empty;
- update / restore scripts now restart the active panel service automatically (`phobos-panel` or `phobos-dev-test`).

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
docs/img/clients.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
docs/img/servers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
docs/img/sessions.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
docs/img/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@@ -3,7 +3,7 @@
# PCA Phobos — TURNKEY installer (primary / panel node) # PCA Phobos — TURNKEY installer (primary / panel node)
# #
# One command, all dependencies, from a clean VPS: # One command, all dependencies, from a clean VPS:
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh) # bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh)
# #
# Installs, in order: # Installs, in order:
# deps -> wg-obfuscator (Ground-Zerro) -> WireGuard wg0 -> # deps -> wg-obfuscator (Ground-Zerro) -> WireGuard wg0 ->
@@ -13,6 +13,7 @@
# #
# Env (all optional): # Env (all optional):
# PANEL_PORT random 10000-59999 web panel port # PANEL_PORT random 10000-59999 web panel port
# PANEL_TLS_CERT / PANEL_TLS_KEY enable native HTTPS for panel
# PANEL_PASS OcAdmin2026! panel admin password # PANEL_PASS OcAdmin2026! panel admin password
# API_KEY random shared key (agents + router pull token) # API_KEY random shared key (agents + router pull token)
# OBF_PORTS 2083,5443,993 obfuscator listen ports # OBF_PORTS 2083,5443,993 obfuscator listen ports
@@ -22,21 +23,51 @@
set -e set -e
PANEL_PASS="${PANEL_PASS:-OcAdmin2026!}" PANEL_PASS="${PANEL_PASS:-OcAdmin2026!}"
PANEL_TLS_CERT="${PANEL_TLS_CERT:-}"
PANEL_TLS_KEY="${PANEL_TLS_KEY:-}"
PANEL_SCHEME="${PANEL_SCHEME:-http}"
TG_TOKEN="${TG_TOKEN:-}" TG_TOKEN="${TG_TOKEN:-}"
TG_CHAT="${TG_CHAT:-}" TG_CHAT="${TG_CHAT:-}"
OBF_PORTS="${OBF_PORTS:-2083,5443,993}" OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
PCA_BRANCH="${PCA_BRANCH:-main}" SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
GH_TOKEN="${GH_TOKEN:-}" # set for private-repo installs; empty for public # Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
CHANNEL="${CHANNEL:-stable}"
case "$CHANNEL" in
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
*) PCA_BRANCH="${PCA_BRANCH:-$CHANNEL}";;
esac
case "$PCA_BRANCH" in v[0-9]*) _kind=tag;; *) _kind=branch;; esac
PHOBOS_DIR="/opt/Phobos" PHOBOS_DIR="/opt/Phobos"
PANEL_DIR="/opt/phobos-panel" PANEL_DIR="/opt/phobos-panel"
RAW="https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}" RAW="$PCA_GIT/raw/$_kind/$PCA_BRANCH"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ "$EUID" -eq 0 ] || { echo "Run as root"; exit 1; } [ "$EUID" -eq 0 ] || { echo "Run as root"; exit 1; }
if [ -z "$PANEL_PORT" ]; then if [ -z "$PANEL_PORT" ]; then
PANEL_PORT=$(shuf -i 10000-59999 -n 1 2>/dev/null || awk 'BEGIN{srand(); print int(10000+rand()*50000)}') PANEL_PORT=$(shuf -i 10000-59999 -n 1 2>/dev/null || awk 'BEGIN{srand(); print int(10000+rand()*50000)}')
fi fi
if [ -n "$PANEL_TLS_CERT" ] || [ -n "$PANEL_TLS_KEY" ]; then
[ -f "$PANEL_TLS_CERT" ] || { echo "ERROR: PANEL_TLS_CERT not found: $PANEL_TLS_CERT"; exit 1; }
[ -f "$PANEL_TLS_KEY" ] || { echo "ERROR: PANEL_TLS_KEY not found: $PANEL_TLS_KEY"; exit 1; }
PANEL_SCHEME="https"
fi
case "$PANEL_SCHEME" in http|https) ;; *) echo "ERROR: PANEL_SCHEME must be http or https"; exit 1;; esac
API_KEY="${API_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24)}" API_KEY="${API_KEY:-$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24)}"
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
net = ipaddress.ip_network(sys.argv[1], strict=False)
print(next(net.hosts()))
PY
)"
WG_PREFIXLEN="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
print(ipaddress.ip_network(sys.argv[1], strict=False).prefixlen)
PY
)"
SERVER_IP=$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}') SERVER_IP=$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}')
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1) IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
@@ -53,24 +84,52 @@ echo "============================================"
echo "[1/9] dependencies..." echo "[1/9] dependencies..."
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq apt-get update -qq
apt-get install -y -qq wireguard wireguard-tools iptables jq curl git \ apt-get install -y -qq wireguard wireguard-tools iptables jq curl git ca-certificates \
python3 python3-flask gunicorn nginx cron python3-qrcode >/dev/null python3 python3-flask python3-pip gunicorn nginx cron python3-qrcode sshpass >/dev/null
apt-get install -y -qq python3-flask-sock >/dev/null 2>&1 || true
apt-get install -y -qq python3-asyncssh >/dev/null 2>&1 || true
apt-get install -y -qq python3-websocket >/dev/null 2>&1 || true
python3 - <<'PY' >/dev/null 2>&1 || python3 -m pip install flask-sock asyncssh websocket-client >/dev/null 2>&1 || python3 -m pip install --break-system-packages flask-sock asyncssh websocket-client >/dev/null
import flask_sock, asyncssh, websocket
PY
mkdir -p "$PANEL_DIR/static"
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css -o "$PANEL_DIR/static/xterm.min.css" 2>/dev/null || true
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js -o "$PANEL_DIR/static/xterm.min.js" 2>/dev/null || true
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js -o "$PANEL_DIR/static/xterm-addon-fit.min.js" 2>/dev/null || true
systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true
# ── 2. wg-obfuscator binary (Ground-Zerro) ── # ── 2. wg-obfuscator binary (Ground-Zerro) ──
echo "[2/9] wg-obfuscator..." echo "[2/9] wg-obfuscator..."
mkdir -p "$PHOBOS_DIR"/{server,clients,bin,tokens,www/init,www/packages,packages} mkdir -p "$PHOBOS_DIR"/{server,clients,bin,tokens,www/init,www/packages,packages}
OBF_ARCH="$ARCH"
[ "$OBF_ARCH" = "amd64" ] && OBF_ARCH="x86_64"
[ "$OBF_ARCH" = "arm64" ] && OBF_ARCH="aarch64"
if [ ! -x /usr/local/bin/wg-obfuscator ]; then if [ ! -x /usr/local/bin/wg-obfuscator ]; then
R=/tmp/phobos-obf; rm -rf "$R"; mkdir -p "$R"; cd "$R" R=/tmp/phobos-obf; rm -rf "$R"; mkdir -p "$R"; cd "$R"
git init -q; git remote add origin https://github.com/Ground-Zerro/Phobos.git git init -q; git remote add origin https://github.com/Ground-Zerro/Phobos.git
git config core.sparseCheckout true; echo "wg-obfuscator" > .git/info/sparse-checkout git config core.sparseCheckout true; echo "wg-obfuscator" > .git/info/sparse-checkout
git pull origin main -q git pull origin main -q
cp -f "wg-obfuscator/bin/wg-obfuscator-${ARCH}" "$PHOBOS_DIR/bin/" 2>/dev/null || true cp -f "wg-obfuscator/bin/"wg-obfuscator-* "$PHOBOS_DIR/bin/" 2>/dev/null || true
chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-* 2>/dev/null || true chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-* 2>/dev/null || true
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${ARCH}" /usr/local/bin/wg-obfuscator ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${OBF_ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
cd /; rm -rf "$R" cd /; rm -rf "$R"
fi fi
_OBF_TAG=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
_CB="https://github.com/ClusterM/wg-obfuscator/releases/download/${_OBF_TAG}"
for _am in "x86_64:linux-x64" "aarch64:linux-arm64" "mipsel:linux-mipsel-mips32" "mips:linux-mips-mips32" "armv7:linux-armv7-hf"; do
_da="${_am%%:*}"; _ss="${_am##*:}"
if [ ! -f "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" ] && [ -n "$_OBF_TAG" ]; then
_t=$(mktemp -d)
curl -fsSL -m30 "${_CB}/wg-obfuscator-${_OBF_TAG}-${_ss}.tar.gz" -o "$_t/o.tgz" 2>/dev/null &&
tar -xzf "$_t/o.tgz" -C "$_t" 2>/dev/null &&
_b=$(find "$_t" -name "wg-obfuscator" -type f | head -1) &&
[ -n "$_b" ] && cp "$_b" "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" && chmod +x "$PHOBOS_DIR/bin/wg-obfuscator-${_da}" && echo " +wg-obfuscator-${_da} (ClusterM ${_OBF_TAG})"
rm -rf "$_t"
fi
done
[ -x /usr/local/bin/wg-obfuscator ] || ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${OBF_ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
[ -x /usr/local/bin/wg-obfuscator ] || { echo "ERROR: obfuscator binary for $ARCH missing"; exit 1; } [ -x /usr/local/bin/wg-obfuscator ] || { echo "ERROR: obfuscator binary for $ARCH missing"; exit 1; }
echo " bins: $(ls $PHOBOS_DIR/bin/ | tr '\n' ' ')"
# ── 3. Phobos repo (onboarding scripts) ── # ── 3. Phobos repo (onboarding scripts) ──
echo "[3/9] Phobos repo (onboarding scripts)..." echo "[3/9] Phobos repo (onboarding scripts)..."
@@ -88,7 +147,7 @@ if [ ! -f /etc/wireguard/wg0.conf ]; then
WG_PRIV=$(wg genkey); WG_PUB=$(echo "$WG_PRIV" | wg pubkey) WG_PRIV=$(wg genkey); WG_PUB=$(echo "$WG_PRIV" | wg pubkey)
cat > /etc/wireguard/wg0.conf <<WG cat > /etc/wireguard/wg0.conf <<WG
[Interface] [Interface]
Address = 10.25.0.1/16 Address = ${WG_SERVER_IPV4}/${WG_PREFIXLEN}
ListenPort = 51820 ListenPort = 51820
PrivateKey = $WG_PRIV PrivateKey = $WG_PRIV
PostUp = iptables -I FORWARD 1 -i wg0 -j ACCEPT; iptables -I FORWARD 1 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE PostUp = iptables -I FORWARD 1 -i wg0 -j ACCEPT; iptables -I FORWARD 1 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
@@ -148,6 +207,8 @@ cat > "$PHOBOS_DIR/server/server.env" <<EOF
SERVER_WG_PRIVATE_KEY=$WG_PRIV SERVER_WG_PRIVATE_KEY=$WG_PRIV
SERVER_WG_PUBLIC_KEY=$WG_PUB SERVER_WG_PUBLIC_KEY=$WG_PUB
SERVER_PUBLIC_IP_V4=$SERVER_IP SERVER_PUBLIC_IP_V4=$SERVER_IP
SERVER_WG_IPV4_NETWORK=$SERVER_WG_IPV4_NETWORK
SERVER_WG_IPV4_ADDRESS=${WG_SERVER_IPV4}/${WG_PREFIXLEN}
OBFUSCATOR_KEY=$OBF_KEY OBFUSCATOR_KEY=$OBF_KEY
OBFUSCATOR_PORTS=$OBF_PORTS OBFUSCATOR_PORTS=$OBF_PORTS
CLIENT_WG_PORT=51820 CLIENT_WG_PORT=51820
@@ -157,23 +218,44 @@ EOF
# ── 6. PCA patches over onboarding scripts + server-side helpers ── # ── 6. PCA patches over onboarding scripts + server-side helpers ──
echo "[6/9] PCA patches (tunnel-pull, self-heal, watchdog, 403 fix)..." echo "[6/9] PCA patches (tunnel-pull, self-heal, watchdog, 403 fix)..."
fetch() { fetch() {
if [ -n "$GH_TOKEN" ]; then mkdir -p "$(dirname "$2")"
curl -fsSL -m20 -H "Authorization: token $GH_TOKEN" "$RAW/$1" -o "$2" && return 0 # ponytail: файл из архива рядом с install.sh — не ходим в сеть.
else if [ -f "$SRC/$1" ]; then cp -f "$SRC/$1" "$2" && return 0; fi
curl -fsSL -m20 "$RAW/$1" -o "$2" && return 0 curl -fsSL -m20 "$RAW/$1?cb=$(date +%s)" -o "$2" && return 0
fi curl -fsSL -m20 "$GH_RAW/$PCA_BRANCH/$1?cb=$(date +%s)" -o "$2" && return 0
echo " WARN: fetch $1 failed"; return 1 echo " WARN: fetch $1 failed"; return 1
} }
fetch overlay/lib-core.sh "$PHOBOS_DIR/repo/server/scripts/lib-core.sh" && chmod +x "$PHOBOS_DIR/repo/server/scripts/lib-core.sh"
fetch overlay/phobos-client.sh "$PHOBOS_DIR/repo/server/scripts/phobos-client.sh" && chmod +x "$PHOBOS_DIR/repo/server/scripts/phobos-client.sh" fetch overlay/phobos-client.sh "$PHOBOS_DIR/repo/server/scripts/phobos-client.sh" && chmod +x "$PHOBOS_DIR/repo/server/scripts/phobos-client.sh"
fetch overlay/install-router.sh.template "$PHOBOS_DIR/repo/client/templates/install-router.sh.template" fetch overlay/install-router.sh.template "$PHOBOS_DIR/repo/client/templates/install-router.sh.template"
fetch overlay/lib-client.sh "$PHOBOS_DIR/repo/client/templates/lib-client.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/lib-client.sh"
fetch overlay/install-obfuscator.sh "$PHOBOS_DIR/repo/client/templates/install-obfuscator.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/install-obfuscator.sh"
fetch overlay/install-wireguard.sh "$PHOBOS_DIR/repo/client/templates/install-wireguard.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/install-wireguard.sh"
fetch overlay/router-configure-wireguard.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh" fetch overlay/router-configure-wireguard.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh"
fetch overlay/router-configure-wireguard-openwrt.sh "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh"
fetch overlay/phobos-pull.sh "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh" fetch overlay/phobos-pull.sh "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-pull.sh"
fetch overlay/phobos-ttyd.sh "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" && cp "$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh" "$PHOBOS_DIR/www/phobos-ttyd.sh" 2>/dev/null || true
fetch overlay/phobos-uninstall.sh "$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh"
fetch overlay/3xui.sh "$PHOBOS_DIR/repo/client/templates/3xui.sh" && chmod +x "$PHOBOS_DIR/repo/client/templates/3xui.sh"
fetch server/phobos-warp.sh "$PHOBOS_DIR/server/phobos-warp.sh" && chmod +x "$PHOBOS_DIR/server/phobos-warp.sh" && ln -sf "$PHOBOS_DIR/server/phobos-warp.sh" /usr/local/bin/phobos-warp
fetch server/phobos-guard.sh "$PHOBOS_DIR/server/phobos-guard.sh" && chmod +x "$PHOBOS_DIR/server/phobos-guard.sh" && ln -sf "$PHOBOS_DIR/server/phobos-guard.sh" /usr/local/bin/phobos-guard
fetch server/phobos-cascade.sh "$PHOBOS_DIR/server/phobos-cascade.sh" && chmod +x "$PHOBOS_DIR/server/phobos-cascade.sh" && ln -sf "$PHOBOS_DIR/server/phobos-cascade.sh" /usr/local/bin/phobos-cascade
fetch server/phobos-domain.sh "$PHOBOS_DIR/server/phobos-domain.sh" && chmod +x "$PHOBOS_DIR/server/phobos-domain.sh" && ln -sf "$PHOBOS_DIR/server/phobos-domain.sh" /usr/local/bin/phobos-domain
fetch server/phobos-limit.sh "$PHOBOS_DIR/server/phobos-limit.sh" && chmod +x "$PHOBOS_DIR/server/phobos-limit.sh"
fetch server/phobos-speedtest.sh "$PHOBOS_DIR/server/phobos-speedtest.sh" && chmod +x "$PHOBOS_DIR/server/phobos-speedtest.sh"
fetch server/phobos-tcp.sh "$PHOBOS_DIR/server/phobos-tcp.sh" && chmod +x "$PHOBOS_DIR/server/phobos-tcp.sh" && ln -sf "$PHOBOS_DIR/server/phobos-tcp.sh" /usr/local/bin/phobos-tcp
fetch "udptcp/bin/udptcp-$OBF_ARCH" "$PHOBOS_DIR/bin/udptcp" && chmod +x "$PHOBOS_DIR/bin/udptcp"
fetch server/phobos-health.sh "$PHOBOS_DIR/server/phobos-health.sh" && chmod +x "$PHOBOS_DIR/server/phobos-health.sh" fetch server/phobos-health.sh "$PHOBOS_DIR/server/phobos-health.sh" && chmod +x "$PHOBOS_DIR/server/phobos-health.sh"
fetch server/phobos-self-check.sh "$PHOBOS_DIR/server/phobos-self-check.sh" && chmod +x "$PHOBOS_DIR/server/phobos-self-check.sh" && ln -sf "$PHOBOS_DIR/server/phobos-self-check.sh" /usr/local/bin/phobos-self-check
fetch server/phobos-pull.sh "$PHOBOS_DIR/server/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/server/phobos-pull.sh" fetch server/phobos-pull.sh "$PHOBOS_DIR/server/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/server/phobos-pull.sh"
fetch server/phobos-router-watchdog.py "$PHOBOS_DIR/server/phobos-router-watchdog.py" fetch server/phobos-router-watchdog.py "$PHOBOS_DIR/server/phobos-router-watchdog.py"
fetch server/api.py "$PHOBOS_DIR/server/api.py"
fetch server/secondary-setup.sh "$PHOBOS_DIR/server/secondary-setup.sh" && chmod +x "$PHOBOS_DIR/server/secondary-setup.sh"
fetch update.sh "$PHOBOS_DIR/server/update.sh" && chmod +x "$PHOBOS_DIR/server/update.sh" && ln -sf "$PHOBOS_DIR/server/update.sh" /usr/local/bin/phobos-update fetch update.sh "$PHOBOS_DIR/server/update.sh" && chmod +x "$PHOBOS_DIR/server/update.sh" && ln -sf "$PHOBOS_DIR/server/update.sh" /usr/local/bin/phobos-update
fetch server/phobos-backup.sh "$PHOBOS_DIR/server/phobos-backup.sh" && chmod +x "$PHOBOS_DIR/server/phobos-backup.sh" && ln -sf "$PHOBOS_DIR/server/phobos-backup.sh" /usr/local/bin/phobos-backup
mkdir -p "$PANEL_DIR"; fetch VERSION "$PANEL_DIR/.version" 2>/dev/null || true mkdir -p "$PANEL_DIR"; fetch VERSION "$PANEL_DIR/.version" 2>/dev/null || true
[ -f "$PHOBOS_DIR/tokens/tokens.json" ] || echo '[]' > "$PHOBOS_DIR/tokens/tokens.json" [ -f "$PHOBOS_DIR/tokens/tokens.json" ] || echo '[]' > "$PHOBOS_DIR/tokens/tokens.json"
"$PHOBOS_DIR/server/phobos-self-check.sh" --fix
# ── 7. web panel ── # ── 7. web panel ──
echo "[7/9] web panel..." echo "[7/9] web panel..."
@@ -195,6 +277,11 @@ if [ ! -f "$PANEL_DIR/settings.json" ]; then
EOF EOF
fi fi
echo "$PANEL_PORT" > "$PANEL_DIR/.port" echo "$PANEL_PORT" > "$PANEL_DIR/.port"
echo "$PANEL_SCHEME" > "$PANEL_DIR/.scheme"
TLS_ARGS=""
if [ "$PANEL_SCHEME" = "https" ]; then
TLS_ARGS="--certfile $PANEL_TLS_CERT --keyfile $PANEL_TLS_KEY"
fi
cat > /etc/systemd/system/phobos-panel.service <<EOF cat > /etc/systemd/system/phobos-panel.service <<EOF
[Unit] [Unit]
Description=Phobos VPN Web Panel Description=Phobos VPN Web Panel
@@ -203,15 +290,19 @@ Wants=wg-quick@wg0.service
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=$PANEL_DIR WorkingDirectory=$PANEL_DIR
ExecStart=/usr/bin/gunicorn -w 1 -b 0.0.0.0:$PANEL_PORT app:app ExecStart=/usr/bin/gunicorn -w 1 --threads 20 $TLS_ARGS -b 0.0.0.0:$PANEL_PORT app:app
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=PANEL_SCHEME=$PANEL_SCHEME
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
systemctl daemon-reload; systemctl enable phobos-panel -q; systemctl restart phobos-panel systemctl daemon-reload; systemctl enable phobos-panel -q; systemctl restart phobos-panel
# TCP fallback for clients whose UDP is cut (port 443/8443/... picked automatically)
"$PHOBOS_DIR/server/phobos-tcp.sh" enable || echo " WARN: TCP fallback not enabled"
# ── 8. nginx (serve /init + /packages over plain HTTP for routers) ── # ── 8. nginx (serve /init + /packages over plain HTTP for routers) ──
echo "[8/9] nginx..." echo "[8/9] nginx..."
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
@@ -240,10 +331,15 @@ if [ -f "$PHOBOS_DIR/server/phobos-router-watchdog.py" ]; then
fi fi
sleep 2 sleep 2
# persist channel so phobos-update follows it
mkdir -p "$PANEL_DIR"
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
echo "" echo ""
echo "============================================" echo "============================================"
echo " Installation complete" echo " Installation complete"
echo " Panel : http://$SERVER_IP:$PANEL_PORT" echo " Panel : $PANEL_SCHEME://$SERVER_IP:$PANEL_PORT"
echo " Login : admin" echo " Login : admin"
echo " Pass : $PANEL_PASS" echo " Pass : $PANEL_PASS"
echo " API key (agents+pull): $API_KEY" echo " API key (agents+pull): $API_KEY"

456
overlay/3xui.sh Executable file
View File

@@ -0,0 +1,456 @@
#!/bin/bash
GREEN='\033[92m'
YELLOW='\033[93m'
RED='\033[91m'
BLUE='\033[94m'
CYAN='\033[96m'
RESET='\033[0m'
BOLD='\033[1m'
print_status() {
local message="$1"
local status="${2:-info}"
local prefix=""
case "$status" in
success) prefix="${GREEN}[OK]" ;;
error) prefix="${RED}[ERR]" ;;
warning) prefix="${YELLOW}[WARN]" ;;
info) prefix="${BLUE}[INFO]" ;;
debug) prefix="${CYAN}[DBG]" ;;
esac
echo -e "${prefix} ${message}${RESET}"
}
check_dependencies() {
local missing=()
for cmd in jq sqlite3; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
print_status "Installing missing dependencies: ${missing[*]}" "info"
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq "${missing[@]}" >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
yum install -y -q "${missing[@]/sqlite3/sqlite}" >/dev/null 2>&1
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q "${missing[@]/sqlite3/sqlite}" >/dev/null 2>&1
elif command -v apk >/dev/null 2>&1; then
apk add --quiet "${missing[@]/sqlite3/sqlite}" >/dev/null 2>&1
fi
for cmd in "${missing[@]}"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
print_status "Failed to install $cmd" "error"
exit 1
fi
done
print_status "Dependencies installed successfully" "success"
fi
}
parse_wireguard_conf() {
local config_path="$1"
local section=""
if [[ ! -f "$config_path" ]]; then
print_status "File $config_path not found" "error"
exit 1
fi
INTERFACE_PRIVATEKEY=""
INTERFACE_ADDRESS=""
INTERFACE_MTU="1420"
PEER_PUBLICKEY=""
PEER_ENDPOINT=""
PEER_ALLOWEDIPS=""
PEER_PRESHAREDKEY=""
PEER_KEEPALIVE="0"
while IFS= read -r line || [[ -n "$line" ]]; do
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ "$line" == "["*"]" ]]; then
section=$(echo "$line" | tr -d '[]' | tr '[:upper:]' '[:lower:]')
continue
fi
if [[ "$line" == *"="* ]]; then
local key=$(echo "$line" | cut -d'=' -f1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
local value=$(echo "$line" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [[ "$section" == "interface" ]]; then
case "$key" in
PrivateKey) INTERFACE_PRIVATEKEY="$value" ;;
Address) INTERFACE_ADDRESS="$value" ;;
MTU) INTERFACE_MTU="$value" ;;
esac
elif [[ "$section" == "peer" ]]; then
case "$key" in
PublicKey) PEER_PUBLICKEY="$value" ;;
Endpoint) PEER_ENDPOINT="$value" ;;
AllowedIPs) PEER_ALLOWEDIPS="$value" ;;
PresharedKey|PreSharedKey) PEER_PRESHAREDKEY="$value" ;;
PersistentKeepalive) PEER_KEEPALIVE="$value" ;;
esac
fi
fi
done < "$config_path"
}
extract_endpoint() {
local endpoint="$1"
if [[ "$endpoint" == "["*"]:"* ]]; then
SERVER_ADDRESS=$(echo "$endpoint" | sed 's/^\[\([^]]*\)\]:.*/\1/')
SERVER_PORT=$(echo "$endpoint" | sed 's/.*\]:\([0-9]*\).*/\1/')
else
SERVER_ADDRESS=$(echo "$endpoint" | rev | cut -d':' -f2- | rev)
SERVER_PORT=$(echo "$endpoint" | rev | cut -d':' -f1 | rev)
fi
[[ -z "$SERVER_PORT" ]] && SERVER_PORT="51820"
}
build_local_addresses() {
local addresses="$1"
local result="[]"
IFS=',' read -ra ADDR_ARRAY <<< "$addresses"
for addr in "${ADDR_ARRAY[@]}"; do
addr=$(echo "$addr" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$addr" ]] && continue
local ip_part="$addr"
if [[ "$addr" == *"/"* ]]; then
ip_part=$(echo "$addr" | cut -d'/' -f1)
fi
if [[ "$ip_part" == *":"* ]]; then
result=$(echo "$result" | jq --arg ip "${ip_part}/128" '. + [$ip]')
else
result=$(echo "$result" | jq --arg ip "${ip_part}/32" '. + [$ip]')
fi
done
echo "$result"
}
build_allowed_ips() {
local allowed="$1"
local result="[]"
if [[ -z "$allowed" ]]; then
echo '["0.0.0.0/0", "::/0"]'
return
fi
IFS=',' read -ra IP_ARRAY <<< "$allowed"
for ip in "${IP_ARRAY[@]}"; do
ip=$(echo "$ip" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$ip" ]] && continue
result=$(echo "$result" | jq --arg ip "$ip" '. + [$ip]')
done
if [[ "$result" == "[]" ]]; then
echo '["0.0.0.0/0", "::/0"]'
else
echo "$result"
fi
}
convert_to_xray_outbound() {
local tag="${1:-Phobos}"
extract_endpoint "$PEER_ENDPOINT"
local local_addresses=$(build_local_addresses "$INTERFACE_ADDRESS")
local allowed_ips=$(build_allowed_ips "$PEER_ALLOWEDIPS")
local peer_config
peer_config=$(jq -n \
--arg pubkey "$PEER_PUBLICKEY" \
--argjson allowed "$allowed_ips" \
--arg endpoint "${SERVER_ADDRESS}:${SERVER_PORT}" \
'{
"publicKey": $pubkey,
"allowedIPs": $allowed,
"endpoint": $endpoint
}')
if [[ -n "$PEER_KEEPALIVE" && "$PEER_KEEPALIVE" != "0" ]]; then
peer_config=$(echo "$peer_config" | jq --argjson ka "$PEER_KEEPALIVE" '. + {"keepAlive": $ka}')
fi
if [[ -n "$PEER_PRESHAREDKEY" ]]; then
peer_config=$(echo "$peer_config" | jq --arg psk "$PEER_PRESHAREDKEY" '. + {"preSharedKey": $psk}')
fi
local mtu="${INTERFACE_MTU:-1420}"
OUTBOUND_JSON=$(jq -n \
--arg tag "$tag" \
--argjson mtu "$mtu" \
--arg secret "$INTERFACE_PRIVATEKEY" \
--argjson addresses "$local_addresses" \
--argjson peer "$peer_config" \
'{
"protocol": "wireguard",
"settings": {
"mtu": $mtu,
"secretKey": $secret,
"address": $addresses,
"workers": 2,
"peers": [$peer],
"noKernelTun": true
},
"tag": $tag
}')
}
get_default_xray_config() {
cat <<'XRAY_CONFIG'
{
"api": {
"services": ["HandlerService", "StatsService", "LoggerService"],
"tag": "api"
},
"inbounds": [
{
"listen": "127.0.0.1",
"port": 62789,
"protocol": "dokodemo-door",
"settings": {"address": "127.0.0.1"},
"tag": "api"
}
],
"outbounds": [
{"protocol": "freedom", "settings": {}, "tag": "direct"},
{"protocol": "blackhole", "settings": {}, "tag": "block"}
],
"policy": {
"levels": {"0": {"statsUserDownlink": true, "statsUserUplink": true}},
"system": {
"statsInboundDownlink": true,
"statsInboundUplink": true,
"statsOutboundDownlink": true,
"statsOutboundUplink": true
}
},
"routing": {
"domainStrategy": "AsIs",
"rules": [
{"inboundTag": ["api"], "outboundTag": "api", "type": "field"},
{"ip": ["geoip:private"], "outboundTag": "block", "type": "field"}
]
},
"stats": {}
}
XRAY_CONFIG
}
find_database() {
local paths=(
"/etc/x-ui/x-ui.db"
"/usr/local/x-ui/x-ui.db"
"/opt/x-ui/x-ui.db"
"./x-ui.db"
)
for path in "${paths[@]}"; do
if [[ -f "$path" ]]; then
print_status "Database found: $path" "success" >&2
echo "$path"
return 0
fi
done
return 1
}
diagnose_database() {
local db_path="$1"
local tables=$(sqlite3 "$db_path" "SELECT name FROM sqlite_master WHERE type='table';" 2>/dev/null)
print_status "Found tables: $tables" "debug" >&2
if echo "$tables" | grep -q "settings"; then
local keys=$(sqlite3 "$db_path" "SELECT key FROM settings;" 2>/dev/null)
print_status "Keys in settings: $keys" "debug" >&2
for key in xrayTemplateConfig xrayConfig xray_config config; do
local result=$(sqlite3 "$db_path" "SELECT value FROM settings WHERE key = '$key';" 2>/dev/null)
if [[ -n "$result" ]]; then
print_status "Found config key: $key" "success" >&2
echo "$key"
return 0
fi
done
fi
return 1
}
write_setting_to_db() {
local db_path="$1"
local config_key="$2"
local config_value="$3"
local tmp
tmp=$(mktemp)
printf '%s' "$config_value" > "$tmp"
sqlite3 "$db_path" "INSERT OR REPLACE INTO settings (key, value) VALUES ('$config_key', readfile('$tmp'));"
local ret=$?
rm -f "$tmp"
return $ret
}
import_via_database() {
local db_path="$1"
print_status "Importing to database..." "info"
if [[ ! -f "$db_path" ]]; then
print_status "Database not found: $db_path" "error"
return 1
fi
local config_key=$(diagnose_database "$db_path")
if [[ -z "$config_key" ]]; then
print_status "Xray config not found in database, creating new..." "warning"
local base_config=$(get_default_xray_config)
local new_config=$(echo "$base_config" | jq --argjson ob "$OUTBOUND_JSON" '.outbounds = [$ob] + .outbounds')
if write_setting_to_db "$db_path" "xrayTemplateConfig" "$new_config"; then
print_status "Base Xray config created with outbound" "success"
return 0
else
print_status "Failed to create config" "error"
return 1
fi
fi
local current_config=$(sqlite3 "$db_path" "SELECT value FROM settings WHERE key = '$config_key';" 2>/dev/null)
if [[ -z "$current_config" ]]; then
print_status "Xray config not found" "error"
return 1
fi
if ! echo "$current_config" | jq . >/dev/null 2>&1; then
print_status "Invalid JSON in config" "error"
return 1
fi
local tag=$(echo "$OUTBOUND_JSON" | jq -r '.tag')
local existing_tag=$(echo "$current_config" | jq -r --arg t "$tag" '.outbounds[]? | select(.tag == $t) | .tag' 2>/dev/null)
if [[ -n "$existing_tag" ]]; then
print_status "Outbound '$tag' exists, replacing..." "warning"
current_config=$(echo "$current_config" | jq --arg t "$tag" '.outbounds = [.outbounds[] | select(.tag != $t)]')
fi
local new_config=$(echo "$current_config" | jq --argjson ob "$OUTBOUND_JSON" '.outbounds = [$ob] + .outbounds')
if write_setting_to_db "$db_path" "$config_key" "$new_config"; then
print_status "Outbound added to database" "success"
return 0
else
print_status "Failed to update database" "error"
return 1
fi
}
restart_xui_service() {
print_status "Restarting x-ui service..." "info"
if systemctl restart x-ui 2>/dev/null; then
sleep 1
if systemctl is-active --quiet x-ui; then
print_status "x-ui service restarted and active" "success"
return 0
fi
fi
for cmd in "service x-ui restart" "/etc/init.d/x-ui restart" "x-ui restart"; do
if $cmd 2>/dev/null; then
print_status "Service restarted via: $cmd" "success"
return 0
fi
done
print_status "Failed to restart service automatically" "error"
print_status "Restart manually: systemctl restart x-ui" "info"
return 1
}
main() {
echo -e "\n${BOLD}=== WireGuard to 3x-ui Auto-Import Tool ===${RESET}\n"
check_dependencies
local config_path="$1"
if [[ -z "$config_path" ]]; then
echo -n "Path to wireguard.conf: "
read config_path
fi
print_status "Parsing $config_path..." "info"
parse_wireguard_conf "$config_path"
print_status "Converting to Xray format..." "info"
convert_to_xray_outbound "Phobos"
echo -e "\n${BOLD}Outbound configuration:${RESET}"
echo "$OUTBOUND_JSON" | jq .
echo
local db_path="/etc/x-ui/x-ui.db"
if [[ ! -f "$db_path" ]]; then
print_status "Database not found at: $db_path" "warning"
db_path=$(find_database)
if [[ -z "$db_path" ]]; then
print_status "Database not found in standard locations" "error"
print_status "Make sure 3x-ui is installed" "info"
exit 1
fi
fi
systemctl stop x-ui 2>/dev/null
if ! import_via_database "$db_path"; then
systemctl start x-ui 2>/dev/null
print_status "Database import failed" "error"
exit 1
fi
local output_path="${config_path%.*}_3xui_outbound.json"
echo "$OUTBOUND_JSON" | jq . > "$output_path" 2>/dev/null
if [[ $? -eq 0 ]]; then
print_status "JSON saved to: $output_path" "success"
fi
echo
local restart_ok=0
restart_xui_service && restart_ok=1
echo
print_status "Import completed successfully!" "success"
echo
print_status "${BOLD}Info:${RESET}" "info"
print_status " Outbound 'Phobos' added to config" "info"
if [[ $restart_ok -eq 1 ]]; then
print_status " x-ui service restarted" "info"
else
print_status " x-ui service needs manual restart" "warning"
fi
print_status " JSON config saved to $output_path" "info"
echo
print_status "${BOLD}Verification:${RESET}" "info"
print_status " Open 3x-ui web panel" "info"
print_status " Go to Outbounds section" "info"
print_status " Verify 'Phobos' outbound is present" "info"
echo
}
main "$@"

248
overlay/install-obfuscator.sh Executable file
View File

@@ -0,0 +1,248 @@
OBF_BINARY_NAME="wg-obfuscator"
OBF_CONF_NAME="wg-obfuscator.conf"
OBF_INIT_NAME="S49wg-obfuscator"
OBF_SERVICE_NAME="phobos-obfuscator"
OBF_WG_IFACE="phobos"
resolve_install_names() {
local binary_dir
local binary_path
if [ "$ROUTER_PLATFORM" = "openwrt" ]; then
binary_dir="/usr/bin"
elif [ "$ROUTER_PLATFORM" = "linux" ]; then
binary_dir="/usr/local/bin"
else
binary_dir="/opt/bin"
fi
binary_path="${binary_dir}/wg-obfuscator"
if [ ! -f "$binary_path" ] && [ ! -f "$PHOBOS_DIR/wg-obfuscator.conf" ]; then
return
fi
if [ -f "$binary_path" ] && [ -f "$PHOBOS_DIR/${CLIENT_NAME}.conf" ]; then
return
fi
local idx=1
while [ -f "${binary_dir}/wg-obfuscator${idx}" ]; do
idx=$((idx + 1))
done
local init_num=50
while [ -f "/opt/etc/init.d/S${init_num}wg-obfuscator" ]; do
init_num=$((init_num + 1))
done
OBF_BINARY_NAME="wg-obfuscator${idx}"
OBF_CONF_NAME="wg-obfuscator${idx}.conf"
OBF_INIT_NAME="S${init_num}wg-obfuscator"
OBF_SERVICE_NAME="phobos-obfuscator${idx}"
OBF_WG_IFACE="phobos${idx}"
}
install_obfuscator() {
local arch=$1
log "Установка wg-obfuscator для архитектуры $arch..."
if [ "$OBF_BINARY_NAME" = "wg-obfuscator" ]; then
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
log "Остановка текущего процесса wg-obfuscator..."
/opt/etc/init.d/S49wg-obfuscator stop >/dev/null 2>&1 || true
fi
if [ -f /etc/init.d/phobos-obfuscator ]; then
log "Остановка текущего процесса wg-obfuscator..."
/etc/init.d/phobos-obfuscator stop >/dev/null 2>&1 || true
fi
fi
local binary_name="wg-obfuscator-$arch"
if [ ! -f "bin/$binary_name" ]; then
log "Ошибка: бинарник $binary_name не найден в архиве"
log "Доступные бинарники:"
ls -1 bin/
exit 1
fi
local target_path="/opt/bin/${OBF_BINARY_NAME}"
if [ "$ROUTER_PLATFORM" = "openwrt" ]; then
target_path="/usr/bin/${OBF_BINARY_NAME}"
fi
if [ -f "$target_path" ]; then
rm "$target_path"
fi
if [ "$ROUTER_PLATFORM" = "openwrt" ]; then
mkdir -p /usr/bin
else
mkdir -p /opt/bin
fi
cp "bin/$binary_name" "$target_path"
chmod +x "$target_path"
log "Бинарник wg-obfuscator установлен в $target_path"
}
create_init_script() {
log "Создание init-скрипта для obfuscator..."
cat > /opt/etc/init.d/${OBF_INIT_NAME} <<EOF
#!/bin/sh
ENABLED=yes
PROCS=${OBF_BINARY_NAME}
ARGS="--config $PHOBOS_DIR/${OBF_CONF_NAME}"
PREARGS=""
DESC=\$PROCS
PATH=/opt/sbin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
. /opt/etc/init.d/rc.func
EOF
chmod +x /opt/etc/init.d/${OBF_INIT_NAME}
log "Init-скрипт создан: /opt/etc/init.d/${OBF_INIT_NAME}"
}
start_obfuscator() {
log "Запуск wg-obfuscator..."
if [ "$ROUTER_PLATFORM" = "keenetic" ] || [ -f /opt/etc/init.d/${OBF_INIT_NAME} ]; then
/opt/etc/init.d/${OBF_INIT_NAME} start
elif [ "$ROUTER_PLATFORM" = "openwrt" ] && [ -f /etc/init.d/${OBF_SERVICE_NAME} ]; then
/etc/init.d/${OBF_SERVICE_NAME} start
/etc/init.d/${OBF_SERVICE_NAME} enable
fi
sleep 2
local process_found=0
if command -v pidof >/dev/null 2>&1 && pidof ${OBF_BINARY_NAME} >/dev/null 2>&1; then
process_found=1
elif command -v pgrep >/dev/null 2>&1 && pgrep -f wg-obfuscator >/dev/null 2>&1; then
process_found=1
elif ps w 2>/dev/null | grep -v grep | grep -q "wg-obfuscator\|/opt/bin/wg-obfuscator\|/usr/bin/wg-obfuscator"; then
process_found=1
elif ps 2>/dev/null | grep -v grep | grep -q "wg-obfuscator"; then
process_found=1
fi
if [ $process_found -eq 0 ]; then
if [ "$ROUTER_PLATFORM" = "keenetic" ] && [ -f /opt/etc/init.d/${OBF_INIT_NAME} ]; then
local status_output=$(/opt/etc/init.d/${OBF_INIT_NAME} status 2>&1 || echo "")
if echo "$status_output" | grep -q "alive"; then
process_found=1
fi
elif [ "$ROUTER_PLATFORM" = "openwrt" ] && [ -f /etc/init.d/${OBF_SERVICE_NAME} ]; then
if /etc/init.d/${OBF_SERVICE_NAME} status >/dev/null 2>&1; then
process_found=1
fi
fi
fi
if [ $process_found -eq 1 ]; then
log "✓ wg-obfuscator успешно запущен"
else
log "✗ wg-obfuscator не запущен. Проверьте логи."
fi
}
create_procd_init_script() {
log "Создание procd init-скрипта для obfuscator..."
cat > /etc/init.d/${OBF_SERVICE_NAME} <<EOF
#!/bin/sh /etc/rc.common
START=49
STOP=51
USE_PROCD=1
PROG=/usr/bin/${OBF_BINARY_NAME}
CONFIG_FILE=/etc/Phobos/${OBF_CONF_NAME}
start_service() {
if [ ! -f "\$PROG" ]; then
echo "Error: wg-obfuscator not found at \$PROG"
return 1
fi
if [ ! -f "\$CONFIG_FILE" ]; then
echo "Error: config not found at \$CONFIG_FILE"
return 1
fi
procd_open_instance
procd_set_param command \$PROG --config \$CONFIG_FILE
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
EOF
chmod +x /etc/init.d/${OBF_SERVICE_NAME}
log "Procd init-скрипт создан: /etc/init.d/${OBF_SERVICE_NAME}"
}
create_systemd_obfuscator_service() {
log "Создание systemd service для obfuscator..."
cat > /etc/systemd/system/${OBF_SERVICE_NAME}.service <<EOFS
[Unit]
Description=Phobos WireGuard Obfuscator
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/${OBF_BINARY_NAME} --config $PHOBOS_DIR/${OBF_CONF_NAME}
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOFS
if [ ! -f /usr/local/bin/${OBF_BINARY_NAME} ]; then
local target_path="/usr/local/bin/${OBF_BINARY_NAME}"
if [ -f /opt/bin/${OBF_BINARY_NAME} ]; then
cp /opt/bin/${OBF_BINARY_NAME} "$target_path"
chmod +x "$target_path"
elif [ -f /usr/bin/${OBF_BINARY_NAME} ]; then
cp /usr/bin/${OBF_BINARY_NAME} "$target_path"
chmod +x "$target_path"
fi
fi
systemctl daemon-reload
systemctl enable ${OBF_SERVICE_NAME}
systemctl start ${OBF_SERVICE_NAME}
log "Ожидание запуска obfuscator (до 10 секунд)..."
local wait_count=0
while [ $wait_count -lt 10 ]; do
sleep 1
if systemctl is-active --quiet ${OBF_SERVICE_NAME}; then
log "✓ Obfuscator успешно запущен"
break
fi
wait_count=$((wait_count + 1))
done
if [ $wait_count -ge 10 ]; then
log "ПРЕДУПРЕЖДЕНИЕ: Obfuscator может быть не готов (таймаут 10 сек)"
fi
log "Systemd service создан: /etc/systemd/system/${OBF_SERVICE_NAME}.service"
}

View File

@@ -443,6 +443,11 @@ deploy_health_monitor() {
chmod +x "$PHOBOS_DIR/phobos-pull.sh" chmod +x "$PHOBOS_DIR/phobos-pull.sh"
echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id" echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id"
[ -f "pull_token" ] && cp "pull_token" "$PHOBOS_DIR/pull_token" [ -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"
log " Pull-агент установлен (client_id={{CLIENT_NAME}})" log " Pull-агент установлен (client_id={{CLIENT_NAME}})"
fi fi

94
overlay/install-wireguard.sh Executable file
View File

@@ -0,0 +1,94 @@
install_wireguard_linux() {
log "Установка WireGuard для Linux..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update || log "ПРЕДУПРЕЖДЕНИЕ: Не удалось обновить список пакетов"
apt-get install -y wireguard wireguard-tools resolvconf net-tools || {
log "ОШИБКА: Не удалось установить WireGuard"
return 1
}
else
log "ОШИБКА: apt-get не найден. Поддерживаются только Ubuntu/Debian"
return 1
fi
log "✓ WireGuard установлен"
return 0
}
configure_wireguard_linux() {
log ""
log "==> Настройка WireGuard через systemd..."
local wg_interface="${OBF_WG_IFACE}"
local wg_config_dir="/etc/wireguard"
mkdir -p "$wg_config_dir"
cp "$PHOBOS_DIR/${CLIENT_NAME}.conf" "$wg_config_dir/${wg_interface}.conf"
chmod 600 "$wg_config_dir/${wg_interface}.conf"
log "Настройка routing: VPN как запасной интерфейс..."
local client_ip=$(grep '^Address' "$wg_config_dir/${wg_interface}.conf" | cut -d'=' -f2 | tr -d ' ' | cut -d',' -f1 | cut -d'/' -f1)
local route_target=$(echo "$client_ip" | cut -d'.' -f1-2).0.0/16
sed -i '/^MTU/a Table = off' "$wg_config_dir/${wg_interface}.conf"
sed -i "/^Table = off/a PostUp = ip route add $route_target dev %i" "$wg_config_dir/${wg_interface}.conf"
sed -i "/^PostUp/a PostDown = ip route del $route_target dev %i || true" "$wg_config_dir/${wg_interface}.conf"
log "Настройка зависимости WireGuard от obfuscator..."
mkdir -p "/etc/systemd/system/wg-quick@${wg_interface}.service.d"
cat > "/etc/systemd/system/wg-quick@${wg_interface}.service.d/override.conf" <<EOF
[Unit]
After=${OBF_SERVICE_NAME}.service
Requires=${OBF_SERVICE_NAME}.service
EOF
systemctl daemon-reload
log "Включение и запуск WireGuard интерфейса ${wg_interface}..."
systemctl enable wg-quick@${wg_interface} || log "ПРЕДУПРЕЖДЕНИЕ: Не удалось включить автозапуск"
systemctl start wg-quick@${wg_interface} || {
log "ОШИБКА: Не удалось запустить WireGuard"
return 1
}
sleep 3
if systemctl is-active --quiet wg-quick@${wg_interface}; then
log "✓ WireGuard успешно запущен (интерфейс: ${wg_interface})"
log " VPN настроен как запасной интерфейс (не перехватывает системный трафик)"
if wg show ${wg_interface} 2>/dev/null | grep -q "latest handshake"; then
log "✓ Туннель установлен и работает"
else
log " Ожидание установки туннеля..."
fi
return 0
else
log "ПРЕДУПРЕЖДЕНИЕ: WireGuard может быть не запущен, проверьте статус"
return 1
fi
}
configure_ufw_linux() {
log ""
log "==> Проверка UFW firewall..."
if ! command -v ufw >/dev/null 2>&1; then
log "UFW не установлен, пропуск настройки"
return 0
fi
log "✓ UFW обнаружен"
log " Клиентская конфигурация не требует открытия портов:"
log " - obfuscator слушает на 127.0.0.1:13255 (локальный интерфейс)"
log " - obfuscator делает исходящие соединения (разрешены по умолчанию)"
if ufw status 2>/dev/null | grep -q "Status: active"; then
log " UFW активен, клиент будет работать корректно"
else
log " UFW неактивен"
fi
}

93
overlay/lib-client.sh Executable file
View File

@@ -0,0 +1,93 @@
detect_router_platform() {
if [ -f /etc/openwrt_release ] || [ -f /etc/openwrt_version ]; then
echo "openwrt"
elif [ -f /opt/etc/.entware_install_log ] || [ -d /opt/etc/ndm ]; then
echo "keenetic"
elif [ -f /etc/debian_version ]; then
echo "linux"
elif [ -d /etc/config ] && command -v uci >/dev/null 2>&1; then
echo "openwrt"
elif [ -f /opt/etc/init.d/rc.func ]; then
echo "keenetic"
elif [ -d /run/systemd/system ] || pidof systemd >/dev/null 2>&1; then
echo "linux"
else
local uname_output=$(uname -a)
if echo "$uname_output" | grep -qi "Keenetic\|Netcraze"; then
echo "keenetic"
elif echo "$uname_output" | grep -qi "OpenWrt\|LEDE\|ImmortalWrt"; then
echo "openwrt"
elif echo "$uname_output" | grep -qi "Linux"; then
echo "linux"
else
echo "unknown"
fi
fi
}
detect_phobos_dir() {
local platform="$1"
if [ "$platform" = "keenetic" ]; then
echo "/opt/etc/Phobos"
elif [ "$platform" = "openwrt" ]; then
echo "/etc/Phobos"
elif [ "$platform" = "linux" ]; then
echo "/opt/Phobos"
else
if [ -d "/opt" ] && [ -d "/opt/etc" ]; then
echo "/opt/etc/Phobos"
else
echo "/etc/Phobos"
fi
fi
}
detect_arch() {
local arch=$(uname -m)
case "$arch" in
mips)
if [ "$(echo -n I | hexdump -o 2>/dev/null | awk 'NR==1{print $2}')" = "000111" ]; then
echo "mipsel"
else
echo "mips"
fi
;;
aarch64|arm64)
echo "aarch64"
;;
armv7l|armv6l)
echo "armv7"
;;
x86_64)
echo "x86_64"
;;
*)
echo "unknown"
;;
esac
}
check_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "Этот скрипт требует root привилегии. Запустите: su -c '$0'"
exit 1
fi
}
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
print_status() {
local status="$1"
local message="$2"
if [ "${status}" = "OK" ]; then
printf "\\033[0;32m✓\\033[0m %s\n" "${message}"
elif [ "${status}" = "WARN" ]; then
printf "\\033[1;33m⚠\\033[0m %s\n" "${message}"
else
printf "\\033[0;31m✗\\033[0m %s\n" "${message}"
fi
}

68
overlay/lib-core.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
PHOBOS_DIR="${PHOBOS_DIR:-/opt/Phobos}"
SERVER_ENV="${SERVER_ENV:-$PHOBOS_DIR/server/server.env}"
CLIENTS_DIR="${CLIENTS_DIR:-$PHOBOS_DIR/clients}"
PACKAGES_DIR="${PACKAGES_DIR:-$PHOBOS_DIR/packages}"
WWW_DIR="${WWW_DIR:-$PHOBOS_DIR/www}"
TOKENS_DIR="${TOKENS_DIR:-$PHOBOS_DIR/tokens}"
TOKENS_FILE="${TOKENS_FILE:-$TOKENS_DIR/tokens.json}"
REPO_DIR="${REPO_DIR:-$PHOBOS_DIR/repo}"
WG_CONFIG="${WG_CONFIG:-/etc/wireguard/wg0.conf}"
TOKEN_TTL="${TOKEN_TTL:-86400}"
CLIENT_WG_PORT="${CLIENT_WG_PORT:-13255}"
log_info() { echo "[INFO] $*"; }
log_warn() { echo "[WARN] $*" >&2; }
log_success() { echo "[OK] $*"; }
die() { echo "[ERROR] $*" >&2; exit 1; }
check_root() {
[ "$(id -u)" = "0" ] || die "run as root"
}
load_env() {
if [ -f "$SERVER_ENV" ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) continue ;;
esac
k="${line%%=*}"
v="${line#*=}"
case "$k" in
''|\#*) continue ;;
esac
export "$k=$v"
done < "$SERVER_ENV"
fi
if [ -z "${SERVER_PUBLIC_IP_V4:-}" ]; then
SERVER_PUBLIC_IP_V4="$(curl -4 -fsS --max-time 6 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}')"
export SERVER_PUBLIC_IP_V4
fi
if [ -z "${SERVER_WG_PUBLIC_KEY:-}" ]; then
SERVER_WG_PUBLIC_KEY="$(wg show wg0 public-key 2>/dev/null || true)"
export SERVER_WG_PUBLIC_KEY
fi
if [ -n "${OBFUSCATOR_PORTS:-}" ]; then
OBFUSCATOR_PORT="$(echo "$OBFUSCATOR_PORTS" | cut -d',' -f1 | tr -d ' ')"
export OBFUSCATOR_PORT
fi
}
ensure_dirs() {
mkdir -p \
"$CLIENTS_DIR" \
"$PACKAGES_DIR" \
"$WWW_DIR/init" \
"$WWW_DIR/packages" \
"$TOKENS_DIR" \
"$REPO_DIR/server/scripts" \
"$REPO_DIR/client/templates"
[ -f "$TOKENS_FILE" ] || echo "[]" > "$TOKENS_FILE"
}

View File

@@ -6,6 +6,12 @@ check_root
load_env load_env
ensure_dirs ensure_dirs
# server.env stores OBFUSCATOR_PORTS (plural, comma list). Client obfuscator
# must target an ACTUAL listening port. lib-core load_env defaults OBFUSCATOR_PORT
# to 51821 (NOT listened on) -> handshake failed. Override unconditionally from
# the real port list when present.
[ -n "${OBFUSCATOR_PORTS:-}" ] && export OBFUSCATOR_PORT="$(echo "$OBFUSCATOR_PORTS" | cut -d',' -f1)"
CMD="${1:-help}" CMD="${1:-help}"
CLIENT_ARG="${2:-}" CLIENT_ARG="${2:-}"
EXTRA_ARG="${3:-}" EXTRA_ARG="${3:-}"
@@ -39,38 +45,41 @@ action_add() {
local server_pub="$SERVER_WG_PUBLIC_KEY" local server_pub="$SERVER_WG_PUBLIC_KEY"
local server_ip_v4="${SERVER_PUBLIC_IP_V4:-}" local server_ip_v4="${SERVER_PUBLIC_IP_V4:-}"
local server_ip_v6="${SERVER_PUBLIC_IP_V6:-}" local server_ip_v6="${SERVER_PUBLIC_IP_V6:-}"
local server_wg_network="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
local client_ip_v4="$manual_ip" local client_ip_v4="$manual_ip"
local ipv4_prefix_main=$(echo "${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}" | cut -d'/' -f1 | cut -d'.' -f1-2)
local ipv6_prefix_main=$(echo "${SERVER_WG_IPV6_NETWORK:-fd00:10:25::/48}" | cut -d'/' -f1 | sed 's/::.*//') local ipv6_prefix_main=$(echo "${SERVER_WG_IPV6_NETWORK:-fd00:10:25::/48}" | cut -d'/' -f1 | sed 's/::.*//')
if [[ -z "$client_ip_v4" ]]; then if [[ -z "$client_ip_v4" ]]; then
log_info "Поиск свободного IP..." log_info "Поиск свободного IP..."
declare -A used_ips local used_ips=""
for d in "$CLIENTS_DIR"/*; do for d in "$CLIENTS_DIR"/*; do
if [[ -d "$d" ]] && [[ -f "$d/metadata.json" ]]; then if [[ -d "$d" ]] && [[ -f "$d/metadata.json" ]]; then
local ip=$(jq -r '.tunnel_ip_v4 // empty' "$d/metadata.json" 2>/dev/null) local ip=$(jq -r '.tunnel_ip_v4 // empty' "$d/metadata.json" 2>/dev/null | cut -d/ -f1)
[[ -n "$ip" ]] && used_ips["$ip"]=1 [[ -n "$ip" ]] && used_ips="${used_ips}${ip}\n"
fi fi
done done
used_ips["${ipv4_prefix_main}.0.1"]=1 client_ip_v4="$(USED_IPS="$(printf "%b" "$used_ips")" python3 - "$server_wg_network" <<'PY'
import ipaddress
import os
import sys
local found=false net = ipaddress.ip_network(sys.argv[1], strict=False)
for oct3 in {0..255}; do used = {line.strip() for line in os.environ.get("USED_IPS", "").splitlines() if line.strip()}
local start_oct4=2 hosts = net.hosts()
for oct4 in $(seq $start_oct4 254); do reserved_first = True
local candidate="${ipv4_prefix_main}.${oct3}.${oct4}" for candidate in hosts:
if [[ -z "${used_ips[$candidate]:-}" ]]; then ip = str(candidate)
client_ip_v4="$candidate" if reserved_first:
found=true reserved_first = False
break 2 continue
fi if ip not in used:
done print(ip)
done raise SystemExit(0)
raise SystemExit(1)
[[ "$found" == "false" ]] && die "Нет свободных IP в подсети." PY
)" || die "Нет свободных IP в подсети."
fi fi
local oct3=$(echo "$client_ip_v4" | cut -d. -f3) local oct3=$(echo "$client_ip_v4" | cut -d. -f3)
@@ -114,7 +123,7 @@ EOF
[instance] [instance]
source-if = 127.0.0.1 source-if = 127.0.0.1
source-lport = ${CLIENT_WG_PORT:-13255} source-lport = ${CLIENT_WG_PORT:-13255}
target = $SERVER_PUBLIC_IP_V4:${OBFUSCATOR_PORT:-51821} target = ${CLIENT_ENDPOINT:-$SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORT:-51821}
key = ${OBFUSCATOR_KEY:-KEY} key = ${OBFUSCATOR_KEY:-KEY}
masking = ${OBFUSCATOR_MASKING:-AUTO} masking = ${OBFUSCATOR_MASKING:-AUTO}
verbose = INFO verbose = INFO
@@ -204,24 +213,42 @@ action_package() {
local tmp=$(mktemp -d) local tmp=$(mktemp -d)
local pkg_root="$tmp/phobos-$id" local pkg_root="$tmp/phobos-$id"
mkdir -p "$PHOBOS_DIR/bin"
mkdir -p "$pkg_root/bin" mkdir -p "$pkg_root/bin"
cp "$dir/${id}.conf" "$pkg_root/${id}.conf" cp "$dir/${id}.conf" "$pkg_root/${id}.conf"
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf" cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
for arch in mipsel mips aarch64 armv7 x86_64; do # Fetch missing router-arch binaries from ClusterM on-demand
[[ -f "$PHOBOS_DIR/bin/wg-obfuscator-$arch" ]] && cp "$PHOBOS_DIR/bin/wg-obfuscator-$arch" "$pkg_root/bin/" 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 _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
for _arch in aarch64 mipsel mips armv7 x86_64; do
if [[ ! -f "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" ]] && [[ -n "$_tag" ]]; then
local _t; _t=$(mktemp -d)
if curl -fsSL -m30 "${_cb}/wg-obfuscator-${_tag}-${_am[$_arch]}.tar.gz" -o "$_t/o.tgz" 2>/dev/null; then
tar -xzf "$_t/o.tgz" -C "$_t" 2>/dev/null || true
local _b; _b=$(find "$_t" -name "wg-obfuscator" -type f | head -1)
[[ -n "$_b" ]] && cp "$_b" "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" && chmod +x "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" && log_info "+wg-obfuscator-$_arch (ClusterM ${_tag})"
fi
rm -rf "$_t"
fi
[[ -f "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" ]] && cp "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" "$pkg_root/bin/"
done done
local tpl_dir="$REPO_DIR/client/templates" local tpl_dir="$REPO_DIR/client/templates"
if [[ -d "$tpl_dir" ]]; then if [[ -d "$tpl_dir" ]]; then
for required in install-router.sh.template lib-client.sh install-obfuscator.sh install-wireguard.sh; do
[[ -f "$tpl_dir/$required" ]] || die "Обязательный шаблон $required не найден в $tpl_dir. Выполни phobos-update и пересоздай пакет."
done
cp "$tpl_dir/install-router.sh.template" "$pkg_root/install-router.sh" cp "$tpl_dir/install-router.sh.template" "$pkg_root/install-router.sh"
sed -i "s|{{CLIENT_NAME}}|${id}|g" "$pkg_root/install-router.sh" sed -i "s|{{CLIENT_NAME}}|${id}|g" "$pkg_root/install-router.sh"
chmod +x "$pkg_root/install-router.sh" chmod +x "$pkg_root/install-router.sh"
[[ -f "$tpl_dir/lib-client.sh" ]] && cp "$tpl_dir/lib-client.sh" "$pkg_root/lib-client.sh" cp "$tpl_dir/lib-client.sh" "$pkg_root/lib-client.sh"
[[ -f "$tpl_dir/install-obfuscator.sh" ]] && cp "$tpl_dir/install-obfuscator.sh" "$pkg_root/install-obfuscator.sh" cp "$tpl_dir/install-obfuscator.sh" "$pkg_root/install-obfuscator.sh"
[[ -f "$tpl_dir/install-wireguard.sh" ]] && cp "$tpl_dir/install-wireguard.sh" "$pkg_root/install-wireguard.sh" cp "$tpl_dir/install-wireguard.sh" "$pkg_root/install-wireguard.sh"
chmod +x "$pkg_root/lib-client.sh" "$pkg_root/install-obfuscator.sh" "$pkg_root/install-wireguard.sh"
for f in router-configure-wireguard router-configure-wireguard-openwrt phobos-uninstall 3xui; do for f in router-configure-wireguard router-configure-wireguard-openwrt phobos-uninstall 3xui; do
[[ -f "$tpl_dir/$f.sh" ]] && cp "$tpl_dir/$f.sh" "$pkg_root/$f.sh" && chmod +x "$pkg_root/$f.sh" [[ -f "$tpl_dir/$f.sh" ]] && cp "$tpl_dir/$f.sh" "$pkg_root/$f.sh" && chmod +x "$pkg_root/$f.sh"
done done
@@ -247,10 +274,42 @@ action_package() {
[[ -n "$PULL_TOKEN" ]] && echo "$PULL_TOKEN" > "$pkg_root/pull_token" [[ -n "$PULL_TOKEN" ]] && echo "$PULL_TOKEN" > "$pkg_root/pull_token"
# Generate failover.conf with current server data # Generate failover.conf with current server data
source "$PHOBOS_DIR/server/server.env" source "$PHOBOS_DIR/server/server.env"
local panel_port="${HTTP_PORT:-}"
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
fi
panel_port="${panel_port:-8443}"
local panel_scheme="${PANEL_SCHEME:-}"
if [[ -z "$panel_scheme" && -f /opt/phobos-panel/.scheme ]]; then
panel_scheme=$(cat /opt/phobos-panel/.scheme 2>/dev/null || true)
fi
case "$panel_scheme" in http|https) ;; *) panel_scheme="http";; esac
local panel_tunnel_ip=""
panel_tunnel_ip="$(python3 - "${SERVER_WG_IPV4_ADDRESS:-}" "${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}" <<'PY'
import ipaddress, sys
iface = (sys.argv[1] or "").strip()
network = (sys.argv[2] or "10.25.0.0/16").strip()
if iface:
try:
print(ipaddress.ip_interface(iface).ip)
raise SystemExit(0)
except Exception:
pass
net = ipaddress.ip_network(network, strict=False)
print(next(net.hosts()))
PY
)"
[[ -n "$panel_tunnel_ip" ]] && echo "$panel_tunnel_ip" > "$pkg_root/panel_tunnel_ip"
echo "$panel_port" > "$pkg_root/panel_port"
echo "$panel_scheme" > "$pkg_root/panel_scheme"
echo "${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}" > "$pkg_root/panel_url"
printf 'http://%s:8444 %s://%s:%s %s://%s:%s\n' \
"$panel_tunnel_ip" "$panel_scheme" "$panel_tunnel_ip" "$panel_port" \
"$panel_scheme" "$SERVER_PUBLIC_IP_V4" "$panel_port" > "$pkg_root/panel_urls"
cat > "$pkg_root/failover.conf" <<FAILEOF cat > "$pkg_root/failover.conf" <<FAILEOF
# Phobos Failover Configuration # Phobos Failover Configuration
SERVER_1=${SERVER_PUBLIC_IP_V4}:51821,51822,51823 SERVER_1=${CLIENT_ENDPOINT:-$SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORTS:-51821,51822,51823}
KEY_1=${OBFUSCATOR_KEY} KEY_1=${OBFUSCATOR_KEY:-}
FAILEOF FAILEOF
find "$pkg_root" -type f ! -path "*/bin/*" -exec sed -i 's/\r$//' {} \; find "$pkg_root" -type f ! -path "*/bin/*" -exec sed -i 's/\r$//' {} \;
@@ -325,20 +384,45 @@ action_link() {
ln -s "$PACKAGES_DIR/phobos-$id.tar.gz" "$link_dir/phobos-$id.tar.gz" ln -s "$PACKAGES_DIR/phobos-$id.tar.gz" "$link_dir/phobos-$id.tar.gz"
mkdir -p "$WWW_DIR/init" mkdir -p "$WWW_DIR/init"
local script_url="http://${SERVER_PUBLIC_IP_V4}:${HTTP_PORT:-80}/packages/$token/phobos-$id.tar.gz" local panel_port="${HTTP_PORT:-}"
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
fi
panel_port="${panel_port:-8443}"
local panel_scheme="${PANEL_SCHEME:-}"
if [[ -z "$panel_scheme" && -f /opt/phobos-panel/.scheme ]]; then
panel_scheme=$(cat /opt/phobos-panel/.scheme 2>/dev/null || true)
fi
case "$panel_scheme" in http|https) ;; *) panel_scheme="http";; esac
local primary_url="${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}/packages/$token/phobos-$id.tar.gz"
local fallback_url="http://${SERVER_PUBLIC_IP_V4}:80/packages/$token/phobos-$id.tar.gz"
cat > "$WWW_DIR/init/$token.sh" <<EOF cat > "$WWW_DIR/init/$token.sh" <<EOF
#!/bin/sh #!/bin/sh
url="$script_url" urls="$primary_url $fallback_url"
dir="/tmp/phobos_install_\$\$" dir="/tmp/phobos_install_\$\$"
mkdir -p "\$dir" mkdir -p "\$dir"
echo "Downloading..." echo "Downloading..."
if command -v curl >/dev/null; then ok=0
curl -L -s -o "\$dir/package.tar.gz" "\$url" for url in \$urls; do
else rm -f "\$dir/package.tar.gz"
wget -q -O "\$dir/package.tar.gz" "\$url" if command -v curl >/dev/null; then
fi curl -k -L -s -o "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
if [ ! -f "\$dir/package.tar.gz" ]; then echo "Download failed"; exit 1; fi else
wget --no-check-certificate -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || wget -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
fi
if grep -qi '<html' "\$dir/package.tar.gz" 2>/dev/null; then
echo "Bad response from \$url (HTML/proxy page), trying next..."
continue
fi
tar tzf "\$dir/package.tar.gz" >/dev/null 2>&1 || {
echo "Bad package from \$url, trying next..."
continue
}
ok=1
break
done
[ "\$ok" = "1" ] || { echo "Download failed: no valid package"; exit 1; }
cd "\$dir" cd "\$dir"
tar xzf package.tar.gz tar xzf package.tar.gz
cd "phobos-$id" cd "phobos-$id"
@@ -353,7 +437,9 @@ EOF
chmod 755 "$PACKAGES_DIR" 2>/dev/null chmod 755 "$PACKAGES_DIR" 2>/dev/null
chmod 644 "$PACKAGES_DIR/phobos-$id.tar.gz" 2>/dev/null chmod 644 "$PACKAGES_DIR/phobos-$id.tar.gz" 2>/dev/null
local cmd="curl -s http://${SERVER_PUBLIC_IP_V4}:${HTTP_PORT:-80}/init/$token.sh | sh" local init_primary="${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}/init/$token.sh"
local init_fallback="http://${SERVER_PUBLIC_IP_V4}:80/init/$token.sh"
local cmd="p=/tmp/phobos-init-\$\$.sh; ok=0; for u in $init_primary $init_fallback; do rm -f \$p; (wget -q -O \$p \$u || wget --no-check-certificate -q -O \$p \$u || curl -kfsSL -o \$p \$u || curl -fsSL -o \$p \$u) 2>/dev/null || continue; head -c 2 \$p | grep -q \"#!\" || continue; sh \$p && ok=1 && break; done; rm -f \$p; [ \$ok -eq 1 ] || echo ERROR_install_script_download_failed"
echo "" echo ""
echo "==================================================" echo "=================================================="

View File

@@ -24,14 +24,18 @@ PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf" CONF="$PHOBOS_DIR/failover.conf"
HEALTH="$PHOBOS_DIR/phobos-health.sh" HEALTH="$PHOBOS_DIR/phobos-health.sh"
LOG="$PHOBOS_DIR/health.log" LOG="$PHOBOS_DIR/health.log"
# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 is the wg0 of whatever # Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
# server the tunnel currently terminates on, so management rides the same # whatever server the tunnel currently terminates on, so management rides the
# obfuscated channel as data — survives a public-IP ban and follows failover. # same obfuscated channel as data — survives a public-IP ban and follows
# - 10.25.0.1:8444 -> secondary server agent (phobos-api) # failover.
# - 10.25.0.1:10514 -> primary server panel # - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
# - panel_tunnel_ip:<panel_port> -> primary server panel
# - public panel IP -> bootstrap / if tunnel is down # - public panel IP -> bootstrap / if tunnel is down
PANEL="${PANEL:-http://212.118.52.193:10514}" PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$PHOBOS_DIR/panel_tunnel_ip" 2>/dev/null)}"
PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}" [ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
PANEL="${PANEL:-$(cat "$PHOBOS_DIR/panel_url" 2>/dev/null)}"
PANEL_URLS="${PANEL_URLS:-$(cat "$PHOBOS_DIR/panel_urls" 2>/dev/null)}"
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home") CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null) TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
TMP="/tmp/failover.conf.pull" TMP="/tmp/failover.conf.pull"
@@ -49,12 +53,33 @@ trap 'rm -f "$LOCK"' EXIT
[ -z "$TOKEN" ] && exit 0 [ -z "$TOKEN" ] && exit 0
detect_tunnel_iface() {
ip route get "$PANEL_TUNNEL_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1
}
fetch_config() {
base="$1"
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
case "$base" in
"http://${PANEL_TUNNEL_IP}:"*|"https://${PANEL_TUNNEL_IP}:"*)
iface=$(detect_tunnel_iface)
if [ -n "$iface" ] && command -v curl >/dev/null; then
curl -k --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
fi
;;
esac
if command -v curl >/dev/null; then
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
fi
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
}
# One fetch + compare + apply pass. Returns 0 always (best-effort). # One fetch + compare + apply pass. Returns 0 always (best-effort).
do_pull() { do_pull() {
# Try each source (tunnel first); accept first that returns a valid conf. # Try each source (tunnel first); accept first that returns a valid conf.
got=0 got=0
for base in $PANEL_URLS; do for base in $PANEL_URLS; do
curl -s -m 8 -o "$TMP" "${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}" 2>/dev/null || continue fetch_config "$base" || continue
if grep -q "^SERVER_1=" "$TMP" 2>/dev/null; then got=1; break; fi if grep -q "^SERVER_1=" "$TMP" 2>/dev/null; then got=1; break; fi
done done
[ "$got" = 1 ] || { rm -f "$TMP"; return 0; } [ "$got" = 1 ] || { rm -f "$TMP"; return 0; }
@@ -77,6 +102,40 @@ do_pull() {
if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then
sh "$HEALTH" apply-server 1 sh "$HEALTH" apply-server 1
fi fi
apply_obf_params
}
# Apply obfuscator params (masking/max-dummy/idle) delivered in failover.conf.
apply_obf_params() {
M=$(grep "^OBF_MASKING=" "$CONF" 2>/dev/null | cut -d= -f2-)
[ -z "$M" ] && return 0
D=$(grep "^OBF_MAX_DUMMY=" "$CONF" 2>/dev/null | cut -d= -f2-)
I=$(grep "^OBF_IDLE=" "$CONF" 2>/dev/null | cut -d= -f2-)
changed=0
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; do
[ -f "$OBF" ] || continue
cur=$(grep "^masking = " "$OBF" 2>/dev/null | sed 's/masking = //')
[ "$cur" != "$M" ] && { sed -i "s/^masking = .*/masking = $M/" "$OBF"; changed=1; }
if [ -n "$D" ]; then
cur=$(grep "^max-dummy = " "$OBF" 2>/dev/null | sed 's/max-dummy = //')
[ "$cur" != "$D" ] && { sed -i "s/^max-dummy = .*/max-dummy = $D/" "$OBF"; changed=1; }
fi
if [ -n "$I" ]; then
cur=$(grep "^idle-timeout = " "$OBF" 2>/dev/null | sed 's/idle-timeout = //')
[ "$cur" != "$I" ] && { sed -i "s/^idle-timeout = .*/idle-timeout = $I/" "$OBF"; changed=1; }
fi
done
[ "$changed" = 0 ] && return 0
log "PULL: obf params applied (masking=$M dummy=${D:-} idle=${I:-}), restart obfuscator"
if ls /opt/etc/init.d/S*obfuscator* >/dev/null 2>&1; then
for sc in /opt/etc/init.d/S*obfuscator*; do [ -x "$sc" ] && "$sc" restart >/dev/null 2>&1; done
elif command -v systemctl >/dev/null 2>&1; then
systemctl restart 'wg-obfuscator*' >/dev/null 2>&1 || true
else
killall wg-obfuscator 2>/dev/null; sleep 1
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; do [ -f "$OBF" ] && wg-obfuscator --config "$OBF" >/dev/null 2>&1 & done
fi
} }
# Inner loop: cron fires this every 60s, but we poll ~4x per minute so a # Inner loop: cron fires this every 60s, but we poll ~4x per minute so a

67
overlay/phobos-ttyd.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/sh
# ============================================================
# PCA Phobos — ttyd web-terminal на роутере Keenetic
#
# phobos-ttyd.sh <client_id> <token> [server_tunnel_ip]
#
# Ставит ttyd (web pty), открывает порт на WG-интерфейсе ТОЛЬКО для панели
# (server tunnel IP), создаёт init-сервис (переживает перезагрузку).
# Панель проксирует терминал к роутеру через WG-туннель (за NAT, без SSH).
# ============================================================
CID="${1:?usage: phobos-ttyd.sh <client_id> <token> [server_ip]}"
TOKEN="${2:?token required}"
SRV_IP="${3:-$(cat /opt/etc/Phobos/panel_tunnel_ip 2>/dev/null)}"
[ -n "$SRV_IP" ] || SRV_IP="10.25.0.1"
PORT="${TTYD_PORT:-7681}"
SHELL_BIN="$( [ -x /opt/bin/bash ] && echo /opt/bin/bash || echo sh )"
# WG tunnel netdev (best effort: interface holding RFC1918 or explicit panel route)
WGDEV=$(ip route get "$SRV_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
[ -z "$WGDEV" ] && WGDEV=nwg3
opkg update >/dev/null 2>&1
opkg install ttyd >/dev/null 2>&1
opkg install iptables >/dev/null 2>&1
TTYD_BIN="$(command -v ttyd || echo /opt/bin/ttyd)"
[ -x "$TTYD_BIN" ] || { echo "ERROR: ttyd not installed"; exit 1; }
mkdir -p /opt/etc/Phobos
echo "$TOKEN" > /opt/etc/Phobos/ttyd_token
chmod 600 /opt/etc/Phobos/ttyd_token
# init.d service: opens firewall for panel + runs ttyd (base-path + basic auth)
cat > /opt/etc/init.d/S51phobos-ttyd <<EOF
#!/bin/sh
PORT=$PORT
WGDEV=$WGDEV
SRV_IP=$SRV_IP
CID=$CID
TTYD=$TTYD_BIN
SH=$SHELL_BIN
TOKEN=\$(cat /opt/etc/Phobos/ttyd_token 2>/dev/null)
start() {
# firewall: allow ONLY the panel (server tunnel IP) to reach ttyd over WG
iptables -D INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT 2>/dev/null
iptables -I INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT
pkill -f "ttyd -p \$PORT" 2>/dev/null; sleep 1
# busybox has no setsid/nohup; plain background detaches fine on Entware.
# no -i flag => ttyd binds all interfaces (0.0.0.0).
"\$TTYD" -p "\$PORT" -b "/ttyd/\$CID" -c "phobos:\$TOKEN" --writable "\$SH" >/tmp/phobos-ttyd.log 2>&1 &
sleep 1
echo "ttyd started on \$PORT (base /ttyd/\$CID, panel-only via \$SRV_IP)"
}
stop() {
pkill -f "ttyd -p \$PORT" 2>/dev/null
iptables -D INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT 2>/dev/null
}
case "\$1" in
start) start ;;
stop) stop ;;
restart) stop; sleep 1; start ;;
*) echo "usage: \$0 {start|stop|restart}" ;;
esac
EOF
chmod +x /opt/etc/init.d/S51phobos-ttyd
/opt/etc/init.d/S51phobos-ttyd restart
echo "OK: ttyd на роутере, base-path /ttyd/$CID, порт $PORT (только для $SRV_IP по $WGDEV)."

543
overlay/phobos-uninstall.sh Executable file
View File

@@ -0,0 +1,543 @@
#!/bin/sh
set -e
PHOBOS_DIR=""
ROUTER_PLATFORM=""
SELECTED_CONF=""
IS_FULL_REMOVAL="1"
OBFUSCATOR_CONFIGS=""
OBFUSCATOR_CONFIG_COUNT=0
. "$(dirname "$0")/lib-client.sh"
collect_obf_configs() {
OBFUSCATOR_CONFIGS=""
OBFUSCATOR_CONFIG_COUNT=0
[ -d "$PHOBOS_DIR" ] || return
for f in "$PHOBOS_DIR"/wg-obfuscator*.conf; do
[ -f "$f" ] || continue
OBFUSCATOR_CONFIGS="${OBFUSCATOR_CONFIGS}$(basename "$f" .conf) "
OBFUSCATOR_CONFIG_COUNT=$((OBFUSCATOR_CONFIG_COUNT + 1))
done
}
get_config_label() {
local obf_conf_base="$1"
local idx_suffix="${obf_conf_base#wg-obfuscator}"
local wg_iface="phobos${idx_suffix}"
local addr=""
if [ -f "/etc/wireguard/${wg_iface}.conf" ]; then
addr=$(grep '^Address' "/etc/wireguard/${wg_iface}.conf" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' | cut -d'/' -f1 | cut -d',' -f1) || addr=""
fi
if [ -n "$addr" ]; then
printf "%s [интерфейс: %s, адрес: %s]" "$obf_conf_base" "$wg_iface" "$addr"
else
printf "%s" "$obf_conf_base"
fi
}
select_config_to_remove() {
collect_obf_configs
if [ "$OBFUSCATOR_CONFIG_COUNT" -eq 0 ]; then
IS_FULL_REMOVAL="1"
SELECTED_CONF="all"
return
fi
if [ "$OBFUSCATOR_CONFIG_COUNT" -eq 1 ]; then
SELECTED_CONF=$(echo "$OBFUSCATOR_CONFIGS" | tr -s ' ' '\n' | grep -v '^$' | head -1)
IS_FULL_REMOVAL="1"
log "Обнаружена конфигурация: ${SELECTED_CONF}"
return
fi
log ""
log "Обнаружено несколько конфигураций:"
log ""
local i=1
for conf_base in $OBFUSCATOR_CONFIGS; do
local label
label=$(get_config_label "$conf_base")
log " ${i}) ${label}"
i=$((i + 1))
done
local all_num=$i
log " ${all_num}) Удалить все"
log ""
printf "Введите номер конфигурации для удаления: "
read -r answer || answer=""
case "$answer" in
''|*[!0-9]*)
log "ОШИБКА: Неверный ввод"
exit 1
;;
esac
if [ "$answer" -eq "$all_num" ]; then
IS_FULL_REMOVAL="1"
SELECTED_CONF="all"
return
fi
if [ "$answer" -lt 1 ] || [ "$answer" -ge "$all_num" ]; then
log "ОШИБКА: Номер вне диапазона"
exit 1
fi
local selected_i=1
for conf_base in $OBFUSCATOR_CONFIGS; do
if [ "$selected_i" -eq "$answer" ]; then
SELECTED_CONF="$conf_base"
IS_FULL_REMOVAL="0"
return
fi
selected_i=$((selected_i + 1))
done
}
remove_single_obfuscator_instance() {
local obf_conf_base="$1"
local idx_suffix="${obf_conf_base#wg-obfuscator}"
local binary_name="wg-obfuscator${idx_suffix}"
local service_name="phobos-obfuscator${idx_suffix}"
local wg_iface="phobos${idx_suffix}"
log "Удаление конфигурации ${obf_conf_base}..."
local link_file="$PHOBOS_DIR/${obf_conf_base}.link"
local linked_client=""
if [ -f "$link_file" ]; then
linked_client=$(cat "$link_file" 2>/dev/null) || linked_client=""
fi
local privkey_fallback=""
if [ -z "$linked_client" ] && [ "$ROUTER_PLATFORM" = "linux" ] && [ -f "/etc/wireguard/${wg_iface}.conf" ]; then
privkey_fallback=$(grep '^PrivateKey' "/etc/wireguard/${wg_iface}.conf" 2>/dev/null | cut -d'=' -f2- | tr -d ' \t') || privkey_fallback=""
fi
for f in /opt/etc/init.d/S[0-9]*wg-obfuscator*; do
[ -f "$f" ] || continue
if grep -q "^PROCS=${binary_name}$" "$f" 2>/dev/null; then
"$f" stop >/dev/null 2>&1 || true
rm -f "$f"
log " ✓ Удален init-скрипт: $f"
break
fi
done
if [ -f "/etc/init.d/${service_name}" ]; then
/etc/init.d/${service_name} stop >/dev/null 2>&1 || true
/etc/init.d/${service_name} disable >/dev/null 2>&1 || true
rm -f "/etc/init.d/${service_name}"
log " ✓ Удален procd init-скрипт: /etc/init.d/${service_name}"
fi
if command -v systemctl >/dev/null 2>&1 && [ -f "/etc/systemd/system/${service_name}.service" ]; then
systemctl stop "${service_name}" >/dev/null 2>&1 || true
systemctl disable "${service_name}" >/dev/null 2>&1 || true
rm -f "/etc/systemd/system/${service_name}.service"
log " ✓ Удален systemd сервис: /etc/systemd/system/${service_name}.service"
fi
for dir in /opt/bin /usr/bin /usr/local/bin; do
if [ -f "${dir}/${binary_name}" ]; then
rm -f "${dir}/${binary_name}"
log " ✓ Удален бинарник: ${dir}/${binary_name}"
fi
done
if [ "$ROUTER_PLATFORM" = "linux" ] && command -v systemctl >/dev/null 2>&1; then
systemctl stop "wg-quick@${wg_iface}" >/dev/null 2>&1 || true
systemctl disable "wg-quick@${wg_iface}" >/dev/null 2>&1 || true
if [ -d "/etc/systemd/system/wg-quick@${wg_iface}.service.d" ]; then
rm -rf "/etc/systemd/system/wg-quick@${wg_iface}.service.d"
log " ✓ Удален systemd override: wg-quick@${wg_iface}"
fi
if [ -f "/etc/wireguard/${wg_iface}.conf" ]; then
rm -f "/etc/wireguard/${wg_iface}.conf"
log " ✓ Удален WireGuard конфиг: /etc/wireguard/${wg_iface}.conf"
fi
systemctl daemon-reload >/dev/null 2>&1 || true
elif [ "$ROUTER_PLATFORM" = "keenetic" ]; then
if command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 && [ -n "$linked_client" ]; then
local target_desc="Phobos-${linked_client}"
log " Удаление WireGuard интерфейса Keenetic: ${target_desc}..."
local iface_list=$(curl -s "http://127.0.0.1:79/rci/show/interface" 2>/dev/null || echo "")
if [ -n "$iface_list" ] && echo "$iface_list" | jq -e . >/dev/null 2>&1; then
local iface_id=$(echo "$iface_list" | jq -r --arg desc "$target_desc" 'to_entries[] | select((.value.description? // "") == $desc) | .key' 2>/dev/null)
if [ -n "$iface_id" ]; then
local del_json=$(cat <<EOF
{
"interface": {
"$iface_id": {
"no": true
}
}
}
EOF
)
local del_result=$(echo "$del_json" | curl -s -X POST \
-H "Content-Type: application/json" \
-d @- \
"http://127.0.0.1:79/rci/" 2>/dev/null)
if echo "$del_result" | jq -e '.status == "error"' >/dev/null 2>&1; then
local err_msg=$(echo "$del_result" | jq -r '.message // "Unknown error"' 2>/dev/null)
log " ОШИБКА при удалении ${iface_id}: $err_msg"
else
log " ✓ Интерфейс ${iface_id} (${target_desc}) удален"
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"system":{"configuration":{"save":{}}}}' \
"http://127.0.0.1:79/rci/" >/dev/null 2>&1
log " ✓ Конфигурация сохранена"
fi
else
log " Интерфейс с описанием '${target_desc}' не найден"
fi
else
log " RCI API недоступен, пропускаем удаление интерфейса Keenetic"
fi
else
log " curl/jq не доступны или имя клиента не определено, пропускаем удаление интерфейса Keenetic"
fi
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
remove_wireguard_interfaces_openwrt
remove_firewall_zone_openwrt
fi
if [ -f "$PHOBOS_DIR/${obf_conf_base}.conf" ]; then
rm -f "$PHOBOS_DIR/${obf_conf_base}.conf"
log " ✓ Удален конфиг obfuscator: $PHOBOS_DIR/${obf_conf_base}.conf"
fi
[ -f "$link_file" ] && rm -f "$link_file"
if [ -n "$linked_client" ] && [ -f "$PHOBOS_DIR/${linked_client}.conf" ]; then
rm -f "$PHOBOS_DIR/${linked_client}.conf"
log " ✓ Удален конфиг клиента: ${linked_client}.conf"
elif [ -n "$privkey_fallback" ]; then
for client_conf in "$PHOBOS_DIR"/*.conf; do
[ -f "$client_conf" ] || continue
case "$(basename "$client_conf")" in
wg-obfuscator*.conf) continue ;;
esac
if grep -qF "PrivateKey = ${privkey_fallback}" "$client_conf" 2>/dev/null; then
rm -f "$client_conf"
log " ✓ Удален конфиг клиента: $(basename "$client_conf")"
break
fi
done
fi
local remaining=0
for f in "$PHOBOS_DIR"/*.conf; do
[ -f "$f" ] && remaining=$((remaining + 1))
done
if [ "$remaining" -eq 0 ] && [ -d "$PHOBOS_DIR" ]; then
rm -rf "$PHOBOS_DIR"
log " ✓ Удалена директория: $PHOBOS_DIR"
fi
}
stop_obfuscator() {
log "Остановка wg-obfuscator..."
for f in /opt/etc/init.d/S[0-9]*wg-obfuscator*; do
[ -f "$f" ] && "$f" stop >/dev/null 2>&1 || true
done
for f in /etc/init.d/phobos-obfuscator*; do
if [ -f "$f" ]; then
"$f" stop >/dev/null 2>&1 || true
"$f" disable >/dev/null 2>&1 || true
fi
done
if command -v systemctl >/dev/null 2>&1; then
systemctl list-units --no-legend --plain 'phobos-obfuscator*.service' | awk '{print $1}' | while read -r unit; do
systemctl stop "$unit" >/dev/null 2>&1 || true
systemctl disable "$unit" >/dev/null 2>&1 || true
done
fi
if ps | grep -v grep | grep -q wg-obfuscator; then
log " Принудительное завершение процесса..."
pkill -f wg-obfuscator >/dev/null 2>&1 || true
fi
}
remove_wireguard_interfaces_keenetic() {
log "Удаление WireGuard интерфейсов Phobos (Keenetic)..."
if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
log " curl или jq не установлен, пропускаем удаление интерфейсов"
return 0
fi
local interfaces=$(curl -s "http://127.0.0.1:79/rci/show/interface" 2>/dev/null || echo "")
if [ -z "$interfaces" ] || ! echo "$interfaces" | jq -e . >/dev/null 2>&1; then
log " RCI API недоступен или вернул некорректный JSON"
return 0
fi
local removed_count=0
local phobos_interfaces=$(echo "$interfaces" | jq -r 'to_entries[] | select(.value.description? // "" | startswith("Phobos-")) | .key' 2>/dev/null)
if [ -z "$phobos_interfaces" ]; then
log " Интерфейсы Phobos не найдены в системе"
return 0
fi
echo "$phobos_interfaces" | while read -r interface_id; do
if [ -z "$interface_id" ]; then
continue
fi
local interface_desc=$(echo "$interfaces" | jq -r --arg id "$interface_id" '.[$id].description // "unknown"' 2>/dev/null)
log " Удаление интерфейса: $interface_id ($interface_desc)"
local delete_json=$(cat <<EOF
{
"interface": {
"$interface_id": {
"no": true
}
}
}
EOF
)
local result=$(echo "$delete_json" | curl -s -X POST \
-H "Content-Type: application/json" \
-d @- \
"http://127.0.0.1:79/rci/" 2>/dev/null)
if echo "$result" | jq -e '.status == "error"' >/dev/null 2>&1; then
local error_msg=$(echo "$result" | jq -r '.message // "Unknown error"' 2>/dev/null)
log " ОШИБКА при удалении $interface_id: $error_msg"
else
log " ✓ Интерфейс $interface_id удален"
removed_count=$((removed_count + 1))
fi
done
local save_result=$(curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"system":{"configuration":{"save":{}}}}' \
"http://127.0.0.1:79/rci/" 2>/dev/null)
if [ $removed_count -gt 0 ]; then
log "✓ Удалено интерфейсов: $removed_count"
log "✓ Конфигурация сохранена"
fi
}
remove_wireguard_interfaces_openwrt() {
log "Удаление WireGuard интерфейсов Phobos (OpenWRT)..."
if ! command -v uci >/dev/null 2>&1; then
log " uci не установлен, пропускаем удаление интерфейсов"
return 0
fi
local interface_name="phobos_wg"
if uci -q get network.${interface_name} >/dev/null 2>&1; then
log " Удаление интерфейса: ${interface_name}"
ifdown ${interface_name} >/dev/null 2>&1 || true
uci -q delete network.${interface_name} || true
local peers=$(uci show network 2>/dev/null | grep "wireguard_${interface_name}" | cut -d'.' -f2 | cut -d'=' -f1 | sort -u)
for peer in $peers; do
uci -q delete network.$peer || true
done
uci commit network
log " ✓ Интерфейс ${interface_name} удален"
else
log " Интерфейс ${interface_name} не найден"
fi
/etc/init.d/network reload >/dev/null 2>&1 || true
}
remove_firewall_zone_openwrt() {
log "Удаление файрволл зоны Phobos (OpenWRT)..."
if ! command -v uci >/dev/null 2>&1; then
log " uci не установлен, пропускаем удаление зоны"
return 0
fi
local zone_name="phobos"
if uci -q get firewall.${zone_name} >/dev/null 2>&1; then
log " Удаление зоны: ${zone_name}"
uci -q delete firewall.${zone_name} || true
uci commit firewall
log " ✓ Зона ${zone_name} удалена"
/etc/init.d/firewall reload >/dev/null 2>&1 || true
else
log " Зона ${zone_name} не найдена"
fi
}
remove_wireguard_interfaces_linux() {
log "Удаление WireGuard интерфейсов Phobos (Linux)..."
if ! command -v systemctl >/dev/null 2>&1; then
log " systemctl не найден, пропускаем удаление интерфейсов"
return 0
fi
for wg_conf in /etc/wireguard/phobos*.conf; do
[ -f "$wg_conf" ] || continue
local wg_interface
wg_interface=$(basename "$wg_conf" .conf)
if systemctl is-enabled --quiet wg-quick@${wg_interface} 2>/dev/null || systemctl is-active --quiet wg-quick@${wg_interface} 2>/dev/null; then
log " Остановка и удаление WireGuard сервиса: wg-quick@${wg_interface}"
systemctl stop wg-quick@${wg_interface} >/dev/null 2>&1 || true
systemctl disable wg-quick@${wg_interface} >/dev/null 2>&1 || true
log " ✓ Сервис wg-quick@${wg_interface} остановлен и отключен"
fi
if [ -d "/etc/systemd/system/wg-quick@${wg_interface}.service.d" ]; then
log " Удаление systemd override: /etc/systemd/system/wg-quick@${wg_interface}.service.d"
rm -rf "/etc/systemd/system/wg-quick@${wg_interface}.service.d"
log " ✓ Systemd override удален"
fi
log " Удаление конфигурации: $wg_conf"
rm -f "$wg_conf"
log " ✓ Конфигурационный файл WireGuard удален"
done
systemctl daemon-reload >/dev/null 2>&1 || true
}
remove_files() {
log "Удаление файлов Phobos..."
for f in /opt/etc/init.d/S[0-9]*wg-obfuscator*; do
if [ -f "$f" ]; then
rm -f "$f"
log " ✓ Удален init-скрипт: $f"
fi
done
for f in /etc/init.d/phobos-obfuscator*; do
if [ -f "$f" ]; then
rm -f "$f"
log " ✓ Удален procd init-скрипт: $f"
fi
done
for f in /opt/bin/wg-obfuscator*; do
if [ -f "$f" ]; then
rm -f "$f"
log " ✓ Удален бинарник: $f"
fi
done
for f in /usr/bin/wg-obfuscator*; do
if [ -f "$f" ]; then
rm -f "$f"
log " ✓ Удален бинарник: $f"
fi
done
for f in /usr/local/bin/wg-obfuscator*; do
if [ -f "$f" ]; then
rm -f "$f"
log " ✓ Удален бинарник: $f"
fi
done
local daemon_reload_needed=0
for f in /etc/systemd/system/phobos-obfuscator*.service; do
if [ -f "$f" ]; then
rm -f "$f"
daemon_reload_needed=1
log " ✓ Удален systemd сервис: $f"
fi
done
if [ $daemon_reload_needed -eq 1 ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
if [ -d "$PHOBOS_DIR" ]; then
rm -rf "$PHOBOS_DIR"
log " ✓ Удалена директория: $PHOBOS_DIR"
fi
}
show_final_info() {
log ""
log "╔════════════════════════════════════════════════════════════╗"
log "║ Phobos успешно удален ║"
log "╚════════════════════════════════════════════════════════════╝"
log ""
log "Удалены компоненты:"
log " - wg-obfuscator (процесс, init-скрипт/сервис, бинарник)"
log " - WireGuard интерфейсы Phobos"
log " - Конфигурационные файлы"
log ""
}
main() {
ROUTER_PLATFORM=$(detect_router_platform)
PHOBOS_DIR=$(detect_phobos_dir "$ROUTER_PLATFORM")
log "==> Начало удаления Phobos (платформа: $ROUTER_PLATFORM)"
log "==> Директория: $PHOBOS_DIR"
check_root
select_config_to_remove
if [ "$IS_FULL_REMOVAL" = "1" ]; then
stop_obfuscator
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
remove_wireguard_interfaces_keenetic
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
remove_wireguard_interfaces_openwrt
remove_firewall_zone_openwrt
elif [ "$ROUTER_PLATFORM" = "linux" ]; then
remove_wireguard_interfaces_linux
else
log "ПРЕДУПРЕЖДЕНИЕ: Неизвестная платформа, пропускаем удаление WireGuard интерфейсов"
fi
remove_files
show_final_info
else
log "==> Удаление конфигурации: ${SELECTED_CONF}"
remove_single_obfuscator_instance "$SELECTED_CONF"
log ""
log "╔════════════════════════════════════════════════════════════╗"
log "║ Конфигурация успешно удалена ║"
log "╚════════════════════════════════════════════════════════════╝"
log ""
fi
log "==> Удаление завершено."
}
main "$@"

View File

@@ -0,0 +1,349 @@
#!/bin/sh
set -e
CLIENT_NAME=""
CLIENT_PRIVATE_KEY=""
CLIENT_IP=""
CLIENT_IPV6=""
SERVER_PUBLIC_KEY=""
ENDPOINT_PORT=13255
KEEPALIVE=25
MTU=1420
FALLBACK_CONFIG=""
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
error() {
echo "[ERROR] $*" >&2
log "ERROR: $*"
}
usage() {
cat <<EOF
Usage: $0 [OPTIONS]
Options:
--client-name NAME Client name (required)
--client-private-key KEY WireGuard private key (required)
--client-ip IP Client tunnel IPv4 address (required)
--client-ipv6 IP Client tunnel IPv6 address (required)
--server-public-key KEY Server WireGuard public key (required)
--endpoint-port PORT Local obfuscator port (default: 13255)
--keepalive SECONDS Keepalive interval (default: 25)
--mtu MTU Interface MTU (default: 1420)
--fallback-config PATH Path to fallback .conf file
--help Show this help
Example:
$0 --client-name home \\
--client-private-key "ABCD..." \\
--client-ip 10.25.0.4/32 \\
--client-ipv6 fd00:10:25::4/128 \\
--server-public-key "EFGH..." \\
--endpoint-port 13255 \\
--fallback-config /opt/etc/Phobos/home.conf
EOF
exit 1
}
check_dependencies() {
local missing=""
for cmd in uci wg; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing="$missing $cmd"
fi
done
if [ -n "$missing" ]; then
error "Missing required utilities:$missing"
return 1
fi
return 0
}
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
--client-name)
CLIENT_NAME="$2"
shift 2
;;
--client-private-key)
CLIENT_PRIVATE_KEY="$2"
shift 2
;;
--client-ip)
CLIENT_IP="$2"
shift 2
;;
--client-ipv6)
CLIENT_IPV6="$2"
shift 2
;;
--server-public-key)
SERVER_PUBLIC_KEY="$2"
shift 2
;;
--endpoint-port)
ENDPOINT_PORT="$2"
shift 2
;;
--keepalive)
KEEPALIVE="$2"
shift 2
;;
--mtu)
MTU="$2"
shift 2
;;
--fallback-config)
FALLBACK_CONFIG="$2"
shift 2
;;
--help)
usage
;;
*)
error "Unknown option: $1"
usage
;;
esac
done
if [ -z "${CLIENT_NAME}" ] || [ -z "${CLIENT_PRIVATE_KEY}" ] || \
[ -z "${CLIENT_IP}" ] || [ -z "${CLIENT_IPV6}" ] || \
[ -z "${SERVER_PUBLIC_KEY}" ]; then
error "Missing required parameters"
usage
fi
}
check_existing_interface() {
local interface_name="phobos_wg"
if uci -q get network.${interface_name} >/dev/null 2>&1; then
log "Найден существующий интерфейс: ${interface_name}"
return 0
else
log "Существующий интерфейс Phobos не найден"
return 1
fi
}
remove_existing_interface() {
local interface_name="phobos_wg"
log "Удаление существующего интерфейса ${interface_name}..."
uci -q delete network.${interface_name} || true
local peers=$(uci show network | grep "wireguard_${interface_name}" | cut -d'.' -f2 | cut -d'=' -f1 | sort -u)
for peer in $peers; do
uci -q delete network.$peer || true
done
uci commit network
log "Существующий интерфейс удален"
}
configure_wireguard_interface() {
local interface_name="phobos_wg"
log "Настройка интерфейса ${interface_name} через UCI..."
uci set network.${interface_name}=interface
uci set network.${interface_name}.proto='wireguard'
uci set network.${interface_name}.private_key="${CLIENT_PRIVATE_KEY}"
uci set network.${interface_name}.mtu="${MTU}"
uci add_list network.${interface_name}.addresses="${CLIENT_IP}"
if [ "${CLIENT_IPV6}" != "none" ] && [ -n "${CLIENT_IPV6}" ]; then
uci add_list network.${interface_name}.addresses="${CLIENT_IPV6}"
fi
local peer_name="wgpeer_${interface_name}"
uci add network wireguard_${interface_name}
uci rename network.@wireguard_${interface_name}[-1]="${peer_name}"
uci set network.${peer_name}.public_key="${SERVER_PUBLIC_KEY}"
uci set network.${peer_name}.description="Phobos VPS Server"
uci set network.${peer_name}.endpoint_host='127.0.0.1'
uci set network.${peer_name}.endpoint_port="${ENDPOINT_PORT}"
uci set network.${peer_name}.persistent_keepalive="${KEEPALIVE}"
uci set network.${peer_name}.route_allowed_ips='0'
uci add_list network.${peer_name}.allowed_ips='0.0.0.0/0'
uci add_list network.${peer_name}.allowed_ips='::/0'
uci commit network
log "Интерфейс ${interface_name} настроен ✓"
return 0
}
configure_firewall_zone() {
local zone_name="phobos"
local interface_name="phobos_wg"
log "Настройка файрволла для зоны ${zone_name}..."
if uci -q get firewall.${zone_name} >/dev/null 2>&1; then
log "Зона ${zone_name} уже существует, обновляем..."
uci delete firewall.${zone_name}
fi
uci add firewall zone
uci rename firewall.@zone[-1]="${zone_name}"
uci set firewall.${zone_name}.name="${zone_name}"
uci add_list firewall.${zone_name}.network="${interface_name}"
uci set firewall.${zone_name}.input='REJECT'
uci set firewall.${zone_name}.output='ACCEPT'
uci set firewall.${zone_name}.forward='REJECT'
uci set firewall.${zone_name}.masq='0'
uci set firewall.${zone_name}.mtu_fix='1'
uci commit firewall
log "Файрволл зона ${zone_name} настроена ✓"
return 0
}
restart_network_services() {
log "Перезапуск сетевых сервисов..."
/etc/init.d/network reload >/dev/null 2>&1 || true
sleep 3
ifup phobos_wg >/dev/null 2>&1 || true
sleep 2
/etc/init.d/firewall reload >/dev/null 2>&1 || true
sleep 1
log "Сетевые сервисы перезапущены ✓"
}
verify_interface_created() {
log "Проверка создания интерфейса WireGuard..."
local max_attempts=5
local attempt=1
while [ $attempt -le $max_attempts ]; do
if ip link show phobos_wg >/dev/null 2>&1; then
log "Интерфейс phobos_wg найден (попытка $attempt/$max_attempts)"
if wg show phobos_wg >/dev/null 2>&1; then
log "WireGuard интерфейс phobos_wg активен"
if uci -q get network.phobos_wg >/dev/null 2>&1; then
log "UCI конфигурация phobos_wg найдена"
return 0
else
log "UCI конфигурация phobos_wg не найдена, но интерфейс работает"
return 0
fi
else
log "Интерфейс найден, но WireGuard не активен (попытка $attempt/$max_attempts)"
fi
else
log "Интерфейс phobos_wg еще не создан (попытка $attempt/$max_attempts)..."
fi
if [ $attempt -lt $max_attempts ]; then
sleep 2
fi
attempt=$((attempt + 1))
done
error "Интерфейс phobos_wg не найден после $max_attempts попыток"
return 1
}
show_fallback_instructions() {
cat <<EOF
╔═══════════════════════════════════════════════════════════╗
║ Требуется ручная настройка WireGuard ║
╚═══════════════════════════════════════════════════════════╝
Конфигурация сохранена в: ${FALLBACK_CONFIG}
Инструкция по ручной настройке:
1. Установите пакеты: opkg install kmod-wireguard wireguard-tools wireguard-tools
2. Импортируйте конфигурацию из ${FALLBACK_CONFIG}
3. Настройте интерфейс через UCI или LuCI веб-интерфейс
EOF
}
main() {
parse_args "$@"
if ! check_dependencies; then
show_fallback_instructions
exit 1
fi
log "=== Phobos WireGuard OpenWRT Configuration ==="
log "Клиент: ${CLIENT_NAME}"
if check_existing_interface; then
remove_existing_interface
fi
if ! configure_wireguard_interface; then
error "Не удалось настроить WireGuard через UCI"
show_fallback_instructions
exit 1
fi
if ! configure_firewall_zone; then
error "Не удалось настроить файрволл"
exit 1
fi
restart_network_services
log ""
log "Ожидание применения конфигурации..."
sleep 5
if verify_interface_created; then
log ""
log "WireGuard успешно настроен на OpenWRT"
log ""
log "Интерфейс: phobos_wg"
log "Файрволл зона: phobos (без форвардинга)"
log ""
log "Для маршрутизации трафика через туннель настройте правила"
log "файрволла и маршрутизацию вручную через LuCI или UCI."
log ""
log "Текущий статус интерфейса:"
ip addr show phobos_wg 2>/dev/null | sed 's/^/ /' || true
log ""
exit 0
else
log ""
log "Не удалось подтвердить создание интерфейса WireGuard"
log ""
log "Проверьте вручную:"
log " ip link show phobos_wg"
log " wg show phobos_wg"
log " uci show network.phobos_wg"
log ""
log "Если интерфейс существует (ip link show phobos_wg работает),"
log "то настройка прошла успешно, несмотря на ошибку проверки."
log ""
exit 1
fi
}
main "$@"

View File

@@ -60,7 +60,7 @@ Options:
Example: Example:
$0 --client-name Pegacomp \\ $0 --client-name Pegacomp \\
--client-private-key "ABCD..." \\ --client-private-key "ABCD..." \\
--client-ip 10.25.0.4 \\ --client-ip 10.26.0.4 \\
--client-ipv6 fd00:10:25::4 \\ --client-ipv6 fd00:10:25::4 \\
--server-public-key "EFGH..." \\ --server-public-key "EFGH..." \\
--endpoint-port 13255 \\ --endpoint-port 13255 \\
@@ -195,6 +195,7 @@ configure_wireguard_interface() {
local description="Phobos-${CLIENT_NAME}" local description="Phobos-${CLIENT_NAME}"
local client_ip_addr=$(echo "${CLIENT_IP}" | cut -d'/' -f1) local client_ip_addr=$(echo "${CLIENT_IP}" | cut -d'/' -f1)
local client_ipv6_block="${CLIENT_IPV6}" local client_ipv6_block="${CLIENT_IPV6}"
local route_target=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0/16
log "Настройка интерфейса ${interface_name}..." log "Настройка интерфейса ${interface_name}..."
@@ -275,6 +276,31 @@ EOF
return 1 return 1
fi fi
# Настраиваем статический маршрут в созданный интерфейс отдельным вызовом RCI
local route_target_net=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0
log "Настройка маршрута для подсети туннеля (${route_target_net}/16) -> ${interface_name}..."
local route_json=$(cat <<EOF
{
"network": "${route_target_net}",
"mask": "255.255.0.0",
"interface": "${interface_name}",
"auto": true
}
EOF
)
local route_result=$(echo "${route_json}" | curl -s -X POST \
-H "Content-Type: application/json" \
-d @- \
"http://localhost:79/rci/ip/route/" 2>/dev/null)
if echo "${route_result}" | jq -e '.status[] | select(.status == "error")' >/dev/null 2>&1; then
local error_msg=$(echo "${route_result}" | jq -r '.status[] | select(.status == "error") | .message // "Unknown error"' 2>/dev/null)
error "RCI API отклонил настройку маршрута: ${error_msg}"
log "JSON запрос:"
log "${route_json}"
return 1
fi
log "Интерфейс ${interface_name} создан ✓" log "Интерфейс ${interface_name} создан ✓"
return 0 return 0

View File

@@ -6,7 +6,7 @@ Wants=wg-quick@wg0.service
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=/opt/phobos-panel WorkingDirectory=/opt/phobos-panel
ExecStart=/usr/bin/gunicorn -w 1 -b 0.0.0.0:8443 app:app ExecStart=/usr/bin/gunicorn -w 1 --threads 20 -b 0.0.0.0:8443 app:app
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1

126
promote.sh Executable file
View File

@@ -0,0 +1,126 @@
#!/bin/bash
# ============================================================
# PCA Phobos — promote / sync каналов (maintainer-инструмент)
#
# Каналы и репозитории:
# stable -> публичный andrey271192/PCA_Phobos ветка main
# beta -> приватный andrey271192/PCA_Phobos-dev ветка beta
# dev -> приватный andrey271192/PCA_Phobos-dev ветка dev
#
# Поток фич — строго в одну сторону: dev -> beta -> stable
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
#
# Использование (GH_TOKEN в env):
# GH_TOKEN=ghp_xxx ./promote.sh status
# GH_TOKEN=ghp_xxx ./promote.sh dev2beta
# GH_TOKEN=ghp_xxx ./promote.sh beta2stable 1.3.0
# GH_TOKEN=ghp_xxx ./promote.sh forward # main->beta->dev (после хотфикса)
#
# Мёрж обычный (без -X). При конфликте — НЕ пушит, оставляет рабочую копию
# в $WORK для ручного разбора. Версии (VERSION) выставляются по каналу.
# ============================================================
set -e
GH_TOKEN="${GH_TOKEN:-}"
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
PUB="https://andrey271192:${GH_TOKEN}@github.com/andrey271192/PCA_Phobos.git"
PRIV="https://andrey271192:${GH_TOKEN}@github.com/andrey271192/PCA_Phobos-dev.git"
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
init_work() {
rm -rf "$WORK"; mkdir -p "$WORK"
git -C "$WORK" init -q
git -C "$WORK" config user.email "maintainer@pca"
git -C "$WORK" config user.name "PCA promote"
}
co_branch() { # $1=branch -> checkout local copy of remote branch
local br="$1" repo; repo=$(repo_for "$br")
git -C "$WORK" fetch -q "$repo" "$br"
git -C "$WORK" checkout -q -B "$br" FETCH_HEAD
}
ver_now() { tr -d ' \r\n' < "$WORK/VERSION" 2>/dev/null; }
do_merge() { # $1=incoming-ref-msg label ; merges FETCH_HEAD into current branch
if ! git -C "$WORK" merge --no-edit -m "$1" FETCH_HEAD; then
echo ""
echo "!!! КОНФЛИКТ. Не запушено. Разрули вручную:"
echo " cd $WORK && git status # правь файлы, git add, git commit"
echo " затем git push <repo> <branch>"
exit 1
fi
}
case "${1:-status}" in
status)
init_work
echo "== Версии каналов =="
for br in main beta dev; do
co_branch "$br" >/dev/null 2>&1 || { printf " %-7s (нет)\n" "$br"; continue; }
printf " %-7s %s (%s)\n" "$br" "$(ver_now)" "$(git -C "$WORK" rev-parse --short HEAD)"
done
echo "Поток: dev -> beta -> stable. После хотфикса в stable: ./promote.sh forward"
;;
dev2beta)
init_work
echo "== Промоут dev -> beta =="
co_branch beta
git -C "$WORK" fetch -q "$PRIV" dev
DEVVER=$(git -C "$WORK" show FETCH_HEAD:VERSION | tr -d ' \r\n')
do_merge "promote: merge dev into beta"
BETAVER="${DEVVER%-dev}-beta"
echo "$BETAVER" > "$WORK/VERSION"
git -C "$WORK" commit -q -am "promote: beta VERSION -> $BETAVER" || true
git -C "$WORK" push -q "$PRIV" beta:beta
git -C "$WORK" tag -f "v$BETAVER" >/dev/null; git -C "$WORK" push -fq "$PRIV" "v$BETAVER"
echo "OK -> beta = $BETAVER (тег v$BETAVER)"
;;
beta2stable)
STABLEVER="${2:-}"
[ -n "$STABLEVER" ] || { echo "Укажи версию: ./promote.sh beta2stable 1.3.0"; exit 1; }
init_work
echo "== Релиз beta -> stable (v$STABLEVER) =="
co_branch main
git -C "$WORK" fetch -q "$PRIV" beta
do_merge "release: merge beta into stable v$STABLEVER"
echo "$STABLEVER" > "$WORK/VERSION"
git -C "$WORK" commit -q -am "release: stable VERSION -> $STABLEVER" || true
git -C "$WORK" push -q "$PUB" main:main
git -C "$WORK" tag -f "v$STABLEVER" >/dev/null; git -C "$WORK" push -fq "$PUB" "v$STABLEVER"
echo "OK -> stable = $STABLEVER (тег v$STABLEVER). Дальше: ./promote.sh forward"
;;
forward)
init_work
echo "== Forward-port stable -> beta -> dev =="
# main -> beta
co_branch beta
BETAVER=$(ver_now)
git -C "$WORK" fetch -q "$PUB" main
do_merge "forward: merge stable into beta"
echo "$BETAVER" > "$WORK/VERSION"; git -C "$WORK" commit -q -am "forward: keep beta VERSION $BETAVER" || true
git -C "$WORK" push -q "$PRIV" beta:beta
echo " beta <- stable OK"
# beta -> dev
co_branch dev
DEVVER=$(ver_now)
git -C "$WORK" fetch -q "$PRIV" beta
do_merge "forward: merge beta into dev"
echo "$DEVVER" > "$WORK/VERSION"; git -C "$WORK" commit -q -am "forward: keep dev VERSION $DEVVER" || true
git -C "$WORK" push -q "$PRIV" dev:dev
echo " dev <- beta OK. Forward-port завершён."
;;
*)
echo "promote.sh — продвижение каналов PCA Phobos"
echo " status версии всех каналов"
echo " dev2beta dev -> beta (кандидат)"
echo " beta2stable <version> beta -> stable (релиз + тег)"
echo " forward stable -> beta -> dev (после хотфикса)"
;;
esac

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Phobos Secondary Server API — peer management + health.""" """Phobos Secondary Server API — peer management + health."""
import json, os, subprocess import json, os, subprocess, time, hmac
from pathlib import Path
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
app = Flask(__name__) app = Flask(__name__)
@@ -17,7 +18,50 @@ def load_env():
def check_api_key(): def check_api_key():
env = load_env() env = load_env()
key = request.headers.get("X-API-Key", "") key = request.headers.get("X-API-Key", "")
return key == env.get("MAIN_API_KEY", "") return hmac.compare_digest(key, env.get("MAIN_API_KEY", ""))
def default_iface():
try:
out = subprocess.check_output(["ip", "route", "get", "8.8.8.8"], text=True, timeout=4)
parts = out.split()
for i, part in enumerate(parts[:-1]):
if part == "dev":
return parts[i + 1].strip()
except Exception:
pass
return ""
def cpu_percent():
try:
with open("/proc/stat") as fh:
a = fh.readline().split()[1:]
time.sleep(0.1)
with open("/proc/stat") as fh:
b = fh.readline().split()[1:]
a = [int(x) for x in a]
b = [int(x) for x in b]
idle = b[3] - a[3]
total = sum(b) - sum(a)
if total <= 0:
return "?"
return f"{round(100 - (idle * 100.0 / total), 1)}%"
except Exception:
return "?"
def net_bytes():
try:
iface = default_iface()
with open("/proc/net/dev") as fh:
for line in fh:
if line.split(":")[0].strip() == iface:
p = line.split(":")[1].split()
return {"iface": iface, "rx": int(p[0]), "tx": int(p[8]), "ts": int(time.time())}
except Exception:
pass
return {}
@app.route("/api/health") @app.route("/api/health")
def health(): def health():
@@ -39,18 +83,45 @@ def health():
peers = 0 peers = 0
peer_keys = [] peer_keys = []
handshakes = {} handshakes = {}
cpu = cpu_percent()
try: try:
out = subprocess.check_output("top -bn1 | grep Cpu", shell=True, text=True, timeout=5) parts = Path("/proc/meminfo").read_text().splitlines()
idle = float([x for x in out.split(",") if "id" in x][0].split()[0]) vals = {}
cpu = f"{round(100 - idle, 1)}%" for line in parts:
except Exception: k, v = line.split(":", 1)
cpu = "?" vals[k] = int(v.strip().split()[0])
try: total = vals.get("MemTotal", 0) // 1024
mem = subprocess.check_output("free -m", shell=True, text=True).split("\n")[1].split() avail = vals.get("MemAvailable", 0) // 1024
mem_str = f"{mem[2]}/{mem[1]}MB" used = max(total - avail, 0)
mem_str = f"{used}/{total}MB"
except Exception: except Exception:
mem_str = "?" mem_str = "?"
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str}) return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
@app.route("/api/obf-health")
def obf_health():
"""Return 200 only when all local wg-obfuscator-* services are active."""
try:
listing = subprocess.check_output(
["systemctl", "list-units", "--type=service", "--no-legend",
"wg-obfuscator-*"], text=True, timeout=5)
names = [ln.split()[0] for ln in listing.splitlines() if ln.strip()]
except Exception:
names = []
if not names:
env = load_env()
ports = [p.strip() for p in env.get("OBFUSCATOR_PORTS", "2083,5443,993").split(",") if p.strip()]
names = [f"wg-obfuscator-{p}.service" for p in ports]
bad = []
for name in names:
status = subprocess.getoutput("systemctl is-active " + name).strip()
if status != "active":
bad.append(name + "=" + status)
if bad:
return ("obf-down: " + ",".join(bad), 503, {"Content-Type": "text/plain"})
return ("obf-ok " + str(len(names)), 200, {"Content-Type": "text/plain"})
@app.route("/api/peers", methods=["GET"]) @app.route("/api/peers", methods=["GET"])
def list_peers(): def list_peers():
@@ -67,11 +138,54 @@ def list_peers():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
LIMIT_SCRIPT = "/opt/Phobos/server/phobos-limit.sh"
def ensure_script(path):
"""Fetch a helper script from the main panel if this agent predates it."""
if os.path.exists(path):
return
env = load_env()
url = "%s://%s:%s/server/%s" % (env.get("MAIN_PROTO", "http"), env.get("MAIN_SERVER", ""),
env.get("MAIN_PORT", ""), os.path.basename(path))
subprocess.run(["curl", "-kfsSL", "-m", "20", url, "-o", path], timeout=30)
if os.path.exists(path):
os.chmod(path, 0o755)
@app.route("/api/speedtest", methods=["POST"])
def speedtest():
if not check_api_key():
return jsonify({"error": "unauthorized"}), 401
path = "/opt/Phobos/server/phobos-speedtest.sh"
ensure_script(path)
try:
r = subprocess.run([path], capture_output=True, text=True, timeout=80)
return jsonify(json.loads(r.stdout.strip().splitlines()[-1]))
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/limits", methods=["POST"])
def set_limits():
"""Per-client speed limits pushed by the main panel ("<tunnel_ip> <mbit>" lines)."""
if not check_api_key():
return jsonify({"error": "unauthorized"}), 401
text = (request.get_json(force=True, silent=True) or {}).get("limits", "")
Path("/opt/Phobos/server/limits.conf").write_text(text)
ensure_script(LIMIT_SCRIPT)
try:
r = subprocess.run([LIMIT_SCRIPT, "apply"], capture_output=True, text=True, timeout=30)
return jsonify({"status": "ok" if r.returncode == 0 else "error", "out": (r.stdout + r.stderr)[-500:]})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/peers/add", methods=["POST"]) @app.route("/api/peers/add", methods=["POST"])
def add_peer(): def add_peer():
if not check_api_key(): if not check_api_key():
return jsonify({"error": "unauthorized"}), 401 return jsonify({"error": "unauthorized"}), 401
data = request.json data = request.get_json(force=True, silent=True) or {}
pub_key = data.get("public_key", "") pub_key = data.get("public_key", "")
allowed_ips = data.get("allowed_ips", "") allowed_ips = data.get("allowed_ips", "")
if not pub_key or not allowed_ips: if not pub_key or not allowed_ips:
@@ -87,7 +201,8 @@ def add_peer():
def remove_peer(): def remove_peer():
if not check_api_key(): if not check_api_key():
return jsonify({"error": "unauthorized"}), 401 return jsonify({"error": "unauthorized"}), 401
pub_key = request.json.get("public_key", "") data = request.get_json(force=True, silent=True) or {}
pub_key = data.get("public_key", "")
if not pub_key: if not pub_key:
return jsonify({"error": "missing public_key"}), 400 return jsonify({"error": "missing public_key"}), 400
try: try:
@@ -116,7 +231,7 @@ ROUTER_CONFIGS_DIR = "/opt/Phobos/server/router-configs"
@app.route("/api/router-config/<client_id>") @app.route("/api/router-config/<client_id>")
def router_config(client_id): def router_config(client_id):
"""Serve a client's failover.conf so routers can PULL via the tunnel """Serve a client's failover.conf so routers can PULL via the tunnel
(http://10.25.0.1:8444/...). Token via ?token= or X-API-Key.""" (http://<server-tunnel-ip>:8444/...). Token via ?token= or X-API-Key."""
token = request.args.get("token", "") or request.headers.get("X-API-Key", "") token = request.args.get("token", "") or request.headers.get("X-API-Key", "")
if token != load_env().get("MAIN_API_KEY", ""): if token != load_env().get("MAIN_API_KEY", ""):
return ("forbidden", 403) return ("forbidden", 403)

File diff suppressed because it is too large Load Diff

93
server/phobos-backup.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
# ============================================================
# PCA Phobos — полный бэкап / восстановление сервера
#
# phobos-backup create # создать бэкап -> /opt/Phobos/backups/*.tar.gz
# phobos-backup restore <file> # восстановить из бэкапа
# phobos-backup list # список бэкапов
#
# В бэкап входит ВСЁ состояние (НЕ бинарники/пакеты):
# WireGuard ключ сервера (wg0.conf), server.env (OBF/WG ключи),
# обфускатор-конфиги, все клиенты+ключи, токены, settings.json панели
# (router_access, метки, назначения, токен подписчика, версия/канал/порт).
#
# ⚠ Бэкап содержит ПРИВАТНЫЕ КЛЮЧИ — храни в защищённом месте.
#
# Disaster recovery на чистом VPS:
# 1) поставить turnkey: bash <(curl -fsSL .../install.sh) (ставит бинарники/сервисы)
# 2) залить бэкап и: phobos-backup restore phobos-backup-*.tar.gz
# Ключ сервера восстановится — клиенты переподключатся без пересоздания.
# ============================================================
set -u
PHOBOS_DIR=/opt/Phobos
PANEL_DIR=/opt/phobos-panel
BACKUP_DIR="$PHOBOS_DIR/backups"
panel_service_name() {
for name in phobos-dev-test phobos-panel; do
systemctl status "$name" >/dev/null 2>&1 && { echo "$name"; return; }
done
echo phobos-panel
}
PATHS=(
/etc/wireguard/wg0.conf
"$PHOBOS_DIR/server/server.env"
"$PHOBOS_DIR/clients"
"$PHOBOS_DIR/tokens"
"$PHOBOS_DIR/server/warp"
"$PANEL_DIR/settings.json"
"$PANEL_DIR/servers.json"
"$PANEL_DIR/.version"
"$PANEL_DIR/.channel"
"$PANEL_DIR/.port"
"$PANEL_DIR/.scheme"
"$PANEL_DIR/.gh_token"
)
case "${1:-}" in
create)
mkdir -p "$BACKUP_DIR"
OUT="$BACKUP_DIR/phobos-backup-$(hostname -s 2>/dev/null || echo srv)-$(date +%Y%m%d-%H%M%S).tar.gz"
# -P: absolute paths (restore lands in original locations). obfuscator confs by glob.
EXIST=()
for p in "${PATHS[@]}"; do [ -e "$p" ] && EXIST+=("$p"); done
for f in "$PHOBOS_DIR"/server/wg-obfuscator-*.conf; do [ -e "$f" ] && EXIST+=("$f"); done
tar -czf "$OUT" -P --ignore-failed-read "${EXIST[@]}" 2>/dev/null
chmod 600 "$OUT"
echo "$OUT"
;;
restore)
FILE="${2:-}"
[ -f "$FILE" ] || { echo "Файл не найден: $FILE"; exit 1; }
# sanity: must be a phobos backup (contains server.env or wg0.conf)
if ! tar -tzf "$FILE" 2>/dev/null | grep -qE 'server\.env|wg0\.conf'; then
echo "Не похоже на бэкап PCA Phobos (нет server.env/wg0.conf). Отмена."; exit 1
fi
# safety snapshot before overwrite
PRE="$BACKUP_DIR/pre-restore-$(date +%Y%m%d-%H%M%S).tar.gz"
mkdir -p "$BACKUP_DIR"
PEX=()
for p in "${PATHS[@]}"; do [ -e "$p" ] && PEX+=("$p"); done
for f in "$PHOBOS_DIR"/server/wg-obfuscator-*.conf; do [ -e "$f" ] && PEX+=("$f"); done
[ ${#PEX[@]} -gt 0 ] && tar -czf "$PRE" -P --ignore-failed-read "${PEX[@]}" 2>/dev/null && echo "Снимок до восстановления: $PRE"
tar -xzf "$FILE" -P
chmod 600 /etc/wireguard/wg0.conf 2>/dev/null || true
systemctl restart wg-quick@wg0 2>/dev/null || true
for s in $(systemctl list-units --all --plain --no-legend 'wg-obfuscator-*' 2>/dev/null | awk '{print $1}'); do
systemctl restart "$s" 2>/dev/null || true
done
systemctl restart "$(panel_service_name)" 2>/dev/null || true
echo "Восстановлено из $FILE. Сервисы перезапущены."
;;
list)
echo "Бэкапы в $BACKUP_DIR:"
ls -lht "$BACKUP_DIR"/phobos-backup-*.tar.gz 2>/dev/null | awk '{print " "$9" ("$5")"}' || echo " нет"
;;
*)
echo "phobos-backup — бэкап/восстановление сервера PCA Phobos"
echo " create создать бэкап"
echo " restore <file> восстановить"
echo " list список бэкапов"
;;
esac

93
server/phobos-cascade.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# PCA Phobos — cascade relay. Run on an ENTRY VPS (e.g. in Russia, "white" IP):
# clients connect to this server, UDP obfuscator ports are forwarded to the EXIT
# Phobos server abroad. The entry box needs no Phobos, no keys, no panel.
# bash <(curl -fsSL .../server/phobos-cascade.sh) enable <exit_host> [ports]
# phobos-cascade.sh disable | status | apply
# Then in the exit server panel: Settings -> "Адрес для клиентов" -> entry IP/domain.
set -uo pipefail
STATE=/etc/phobos-cascade.env
BIN=/usr/local/sbin/phobos-cascade
SELF_URL="${SELF_URL:-https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/phobos-cascade.sh}"
TAG="phobos-cascade"
clear_rules() {
local t
for t in nat filter; do
iptables -t $t -S 2>/dev/null | grep -- "--comment $TAG" | sed 's/^-A /-D /' | \
while read -r rule; do eval iptables -t $t $rule 2>/dev/null; done
done
}
apply() {
[ -f "$STATE" ] || { echo "cascade: not configured"; return 0; }
. "$STATE"
local ip p
ip=$(getent ahostsv4 "$EXIT_HOST" | awk 'NR==1{print $1}')
[ -n "$ip" ] || { echo "cascade: cannot resolve $EXIT_HOST" >&2; return 1; }
sysctl -qw net.ipv4.ip_forward=1
clear_rules
for p in ${PORTS//,/ }; do
iptables -t nat -A PREROUTING -p udp --dport "$p" -m comment --comment $TAG -j DNAT --to-destination "$ip:$p"
iptables -t nat -A POSTROUTING -p udp -d "$ip" --dport "$p" -m comment --comment $TAG -j MASQUERADE
iptables -I FORWARD -p udp -d "$ip" --dport "$p" -m comment --comment $TAG -j ACCEPT
iptables -I FORWARD -p udp -s "$ip" --sport "$p" -m comment --comment $TAG -j ACCEPT
done
echo "cascade: udp ${PORTS} -> ${EXIT_HOST} (${ip})"
}
enable() {
local host="${1:-}" ports="${2:-2083,5443,993}"
[ -n "$host" ] || { echo "usage: $0 enable <exit_host> [ports]" >&2; exit 2; }
[[ "$ports" =~ ^[0-9]+(,[0-9]+)*$ ]] || { echo "bad ports: $ports" >&2; exit 2; }
command -v iptables >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq iptables >/dev/null
printf 'EXIT_HOST=%s\nPORTS=%s\n' "$host" "$ports" > "$STATE"
# bash <(curl ...) gives a pipe as $0 -> fetch a real copy for the systemd unit
if [ "$(readlink -f "$0")" != "$BIN" ]; then
if [ -f "$0" ]; then cp "$0" "$BIN"; else curl -kfsSL -m30 "$SELF_URL" -o "$BIN" || { echo "cannot save $BIN" >&2; exit 1; }; fi
chmod +x "$BIN"
fi
cat > /etc/systemd/system/phobos-cascade.service <<EOF
[Unit]
Description=PCA Phobos cascade relay
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=$BIN apply
ExecStop=$BIN stop
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable phobos-cascade.service >/dev/null 2>&1
systemctl restart phobos-cascade.service
apply >/dev/null
status
}
disable() {
systemctl disable --now phobos-cascade.service >/dev/null 2>&1 || true
clear_rules
rm -f /etc/systemd/system/phobos-cascade.service "$STATE"
systemctl daemon-reload
echo "cascade: disabled"
}
status() {
local host="" ports="" rules
[ -f "$STATE" ] && . "$STATE" && host="$EXIT_HOST" && ports="$PORTS"
rules=$(iptables -t nat -S PREROUTING 2>/dev/null | grep -c -- "--comment $TAG")
printf '{"enabled": %s, "exit_host": "%s", "ports": "%s", "rules": %s}\n' \
"$([ "$rules" -gt 0 ] && echo true || echo false)" "$host" "$ports" "$rules"
}
case "${1:-status}" in
enable) enable "${2:-}" "${3:-}" ;;
apply) apply ;;
stop) clear_rules ;;
disable) disable ;;
status) status ;;
*) echo "usage: $0 enable <exit_host> [ports]|disable|status"; exit 2 ;;
esac

105
server/phobos-domain.sh Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# PCA Phobos — panel domain: https://<domain>/ via nginx + Let's Encrypt.
# The panel keeps listening on its old http port, so routers/secondaries that
# already know http://IP:PORT keep working; the domain is an extra entrance.
# phobos-domain.sh set <domain> [email] (SELF_SIGNED=1 for LAN tests)
# phobos-domain.sh remove | status
set -uo pipefail
PANEL_DIR=/opt/phobos-panel
SITE=/etc/nginx/sites-available/phobos-domain
WEBROOT=/var/www/phobos-acme
SS_DIR=/etc/phobos-domain
panel_port() { cat "$PANEL_DIR/.port" 2>/dev/null || echo 8443; }
panel_scheme() { cat "$PANEL_DIR/.scheme" 2>/dev/null || echo http; }
write_site() { # $1=domain $2=cert $3=key ($2 empty -> http only, for ACME)
local d="$1" up
up="$(panel_scheme)://127.0.0.1:$(panel_port)"
{
echo "server {"
echo " listen 80;"
echo " listen [::]:80;"
echo " server_name $d;"
echo " location /.well-known/acme-challenge/ { root $WEBROOT; }"
[ -n "$2" ] && echo " location / { return 301 https://\$host\$request_uri; }"
echo "}"
if [ -n "$2" ]; then
cat <<EOF
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name $d;
ssl_certificate $2;
ssl_certificate_key $3;
client_max_body_size 64m;
location / {
proxy_pass $up;
proxy_ssl_verify off;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
EOF
fi
} > "$SITE"
ln -sf "$SITE" /etc/nginx/sites-enabled/phobos-domain
# nginx < 1.25 has no "http2 on;" -> fall back to the old listen flag
nginx -t >/dev/null 2>&1 || sed -i -e '/http2 on;/d' -e 's/listen 443 ssl;/listen 443 ssl http2;/' -e 's/listen \[::\]:443 ssl;/listen [::]:443 ssl http2;/' "$SITE"
nginx -t >/dev/null 2>&1 || { nginx -t; return 1; }
systemctl reload nginx
}
set_domain() {
local d="${1:-}" email="${2:-}" cert key
[[ "$d" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$ ]] || { echo "bad domain: $d" >&2; exit 2; }
command -v nginx >/dev/null || { echo "nginx not installed" >&2; exit 1; }
mkdir -p "$WEBROOT"
write_site "$d" "" "" || exit 1
if [ "${SELF_SIGNED:-0}" = 1 ]; then
mkdir -p "$SS_DIR"
openssl req -x509 -nodes -newkey rsa:2048 -days 825 -subj "/CN=$d" \
-keyout "$SS_DIR/$d.key" -out "$SS_DIR/$d.crt" >/dev/null 2>&1
cert="$SS_DIR/$d.crt"; key="$SS_DIR/$d.key"
else
command -v certbot >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq certbot >/dev/null
local mail_args=(--register-unsafely-without-email)
[ -n "$email" ] && mail_args=(-m "$email")
certbot certonly --webroot -w "$WEBROOT" -d "$d" --agree-tos -n "${mail_args[@]}" \
--deploy-hook "systemctl reload nginx" || {
echo "certbot failed: check that $d points to this server and port 80 is open" >&2
rm -f /etc/nginx/sites-enabled/phobos-domain; systemctl reload nginx; exit 1; }
cert="/etc/letsencrypt/live/$d/fullchain.pem"; key="/etc/letsencrypt/live/$d/privkey.pem"
fi
write_site "$d" "$cert" "$key" || exit 1
echo "$d" > "$PANEL_DIR/.domain"
echo "domain: https://$d/ -> panel $(panel_scheme)://127.0.0.1:$(panel_port)"
}
remove() {
rm -f /etc/nginx/sites-enabled/phobos-domain "$SITE" "$PANEL_DIR/.domain"
nginx -t >/dev/null 2>&1 && systemctl reload nginx
echo "domain: removed (certificate kept in /etc/letsencrypt)"
}
status() {
local d exp=""
d=$(cat "$PANEL_DIR/.domain" 2>/dev/null)
if [ -n "$d" ]; then
exp=$(echo | timeout 5 openssl s_client -connect 127.0.0.1:443 -servername "$d" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
fi
printf '{"domain": "%s", "cert_expires": "%s"}\n' "$d" "$exp"
}
case "${1:-status}" in
set) set_domain "${2:-}" "${3:-}" ;;
remove) remove ;;
status) status ;;
*) echo "usage: $0 set <domain> [email]|remove|status"; exit 2 ;;
esac

113
server/phobos-guard.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# PCA Phobos — guard: drop RKN probes / scanner subnets before they reach the server.
# Idea and IP list: github.com/Loorrr293/Auto_IPtables (list: Loorrr293/blocklist).
# We only download the IP list (data), never third-party code.
# phobos-guard.sh enable [list_url] install nft table + daily timer
# phobos-guard.sh update refresh the list now
# phobos-guard.sh disable remove table + timer
# phobos-guard.sh status JSON
set -uo pipefail
STATE=/etc/phobos-guard.env
DEFAULT_URL="https://raw.githubusercontent.com/Loorrr293/blocklist/main/blocklist.txt"
TABLE="inet phobos_guard"
SELF="$(readlink -f "$0")"
list_url() { [ -f "$STATE" ] && . "$STATE"; echo "${GUARD_URL:-$DEFAULT_URL}"; }
ensure_nft() {
command -v nft >/dev/null 2>&1 && return 0
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nftables >/dev/null 2>&1
}
update_list() {
local url tmp nf
url="$(list_url)"
tmp="$(mktemp)"; nf="$(mktemp)"
curl -fsSL -m60 "$url" -o "$tmp" || { rm -f "$tmp" "$nf"; echo "download failed: $url" >&2; return 1; }
nft add table $TABLE 2>/dev/null || true
nft add set $TABLE v4 '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null || true
nft add set $TABLE v6 '{ type ipv6_addr; flags interval; auto-merge; }' 2>/dev/null || true
nft add chain $TABLE input '{ type filter hook input priority raw; policy accept; }' 2>/dev/null || true
nft list chain $TABLE input | grep -q '@v4' || nft add rule $TABLE input ip saddr @v4 counter drop
nft list chain $TABLE input | grep -q '@v6' || nft add rule $TABLE input ip6 saddr @v6 counter drop
# collapse + skip private/loopback so a bad list can never cut LAN/tunnel/admin
python3 - "$tmp" > "$nf" <<'PY'
import ipaddress, sys
v4, v6 = [], []
for line in open(sys.argv[1], encoding="utf-8", errors="ignore"):
for tok in line.split("#", 1)[0].split():
try:
n = ipaddress.ip_network(tok, strict=False)
except ValueError:
continue
if n.is_private or n.is_loopback or n.is_link_local or n.prefixlen == 0:
continue
(v6 if n.version == 6 else v4).append(n)
print("flush set inet phobos_guard v4")
print("flush set inet phobos_guard v6")
for name, nets in (("v4", v4), ("v6", v6)):
nets = list(ipaddress.collapse_addresses(nets))
for i in range(0, len(nets), 2000):
print("add element inet phobos_guard %s { %s }" % (name, ", ".join(map(str, nets[i:i + 2000]))))
PY
local rc=0
nft -f "$nf" || rc=1
rm -f "$tmp" "$nf"
[ "$rc" = 0 ] && echo "guard: list loaded from $url"
return "$rc"
}
enable() {
ensure_nft || { echo "nftables install failed" >&2; exit 1; }
[ -n "${1:-}" ] && echo "GUARD_URL=$1" > "$STATE" || touch "$STATE"
cat > /etc/systemd/system/phobos-guard.service <<EOF
[Unit]
Description=PCA Phobos guard (RKN scanner blocklist)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$SELF update
EOF
cat > /etc/systemd/system/phobos-guard.timer <<'EOF'
[Unit]
Description=Refresh PCA Phobos guard list daily
[Timer]
OnBootSec=1min
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now phobos-guard.timer >/dev/null 2>&1
update_list
}
disable() {
systemctl disable --now phobos-guard.timer >/dev/null 2>&1 || true
rm -f /etc/systemd/system/phobos-guard.service /etc/systemd/system/phobos-guard.timer "$STATE"
systemctl daemon-reload
nft delete table $TABLE 2>/dev/null || true
echo "guard: disabled"
}
status() {
local on=false n4=0 drops=0
if nft list table $TABLE >/dev/null 2>&1; then
on=true
n4=$(nft list set $TABLE v4 2>/dev/null | grep -o '[0-9.]*/[0-9]*\|[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*' | wc -l)
drops=$(nft list chain $TABLE input 2>/dev/null | grep -o 'packets [0-9]*' | awk '{s+=$2} END {print s+0}')
fi
printf '{"enabled": %s, "networks": %s, "dropped_packets": %s, "timer": "%s", "url": "%s"}\n' \
"$on" "$n4" "$drops" "$(systemctl is-active phobos-guard.timer 2>/dev/null)" "$(list_url)"
}
case "${1:-status}" in
enable) enable "${2:-}" ;;
update) update_list ;;
disable) disable ;;
status) status ;;
*) echo "usage: $0 enable [url]|update|disable|status"; exit 2 ;;
esac

View File

@@ -25,7 +25,10 @@ PRIMARY_CHECK_INTERVAL=300
# wg-obfuscator-* services are active — the bare panel port stays up even # wg-obfuscator-* services are active — the bare panel port stays up even
# when the tunnel path is dead, so we must NOT switch back on panel liveness # when the tunnel path is dead, so we must NOT switch back on panel liveness
# alone (that caused premature switchback to a dead primary). # alone (that caused premature switchback to a dead primary).
PRIMARY_PROBE_PORT=10514 PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$PHOBOS_DIR/panel_scheme" 2>/dev/null)}"
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$PHOBOS_DIR/panel_port" 2>/dev/null)}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
PRIMARY_PROBE_PATH="/api/obf-health" PRIMARY_PROBE_PATH="/api/obf-health"
# Connectivity check targets (logged for context only) # Connectivity check targets (logged for context only)
CHECK_HOST_1="8.8.8.8" CHECK_HOST_1="8.8.8.8"
@@ -274,13 +277,22 @@ check_primary() {
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's # Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
# obfuscator path is actually up. Anything else (000 no-response, 503 # obfuscator path is actually up. Anything else (000 no-response, 503
# obf-down, redirects) means the tunnel path is NOT viable → stay put. # obf-down, redirects) means the tunnel path is NOT viable → stay put.
code=$(curl -s -m 4 -o /dev/null -w '%{http_code}' "http://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" 2>/dev/null) 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 if [ "$code" = "200" ]; then
log "PRIMARY ($host obf-health http=200) alive, switching back" ok_url="$url"
break
fi
done
if [ -n "$ok_url" ]; then
log "PRIMARY ($ok_url http=200) alive, switching back"
switch_endpoint 1 0 switch_endpoint 1 0
echo "0" > "$STATE/fail_count" echo "0" > "$STATE/fail_count"
else else
log "PRIMARY still down (obf-health http=${code:-none}), staying on server $CURRENT_SERVER" log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
fi fi
} }

50
server/phobos-limit.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# PCA Phobos — per-client speed limit on wg0 (tc).
# limits file: one line per client "<tunnel_ip> <mbit>" (download = upload = mbit)
# phobos-limit.sh apply [file] rebuild tc from file (default below)
# phobos-limit.sh clear | status
set -uo pipefail
DEV="${WG_DEV:-wg0}"
FILE="${2:-/opt/Phobos/server/limits.conf}"
clear_tc() {
tc qdisc del dev "$DEV" root 2>/dev/null
tc qdisc del dev "$DEV" ingress 2>/dev/null
return 0
}
apply() {
ip link show "$DEV" >/dev/null 2>&1 || { echo "limit: $DEV is down"; return 0; }
clear_tc
[ -s "$FILE" ] || { echo "limit: no limits"; return 0; }
tc qdisc add dev "$DEV" root handle 1: htb default 9999 r2q 1000
tc class add dev "$DEV" parent 1: classid 1:9999 htb rate 10gbit 2>/dev/null
tc qdisc add dev "$DEV" handle ffff: ingress
local n=10 ip mbit count=0
while read -r ip mbit _; do
[[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ && "$mbit" =~ ^[0-9]+$ && "$mbit" -gt 0 ]] || continue
# download (server -> client): shaped, packets queue instead of dropping
tc class add dev "$DEV" parent 1: classid 1:$n htb rate "${mbit}mbit" ceil "${mbit}mbit" burst 64k
tc qdisc add dev "$DEV" parent 1:$n fq_codel 2>/dev/null
tc filter add dev "$DEV" parent 1: protocol ip prio 1 u32 match ip dst "$ip/32" flowid 1:$n
# upload (client -> server): policed on ingress
tc filter add dev "$DEV" parent ffff: protocol ip prio 1 u32 match ip src "$ip/32" \
police rate "${mbit}mbit" burst $(( mbit * 64 + 256 ))k drop flowid :1
n=$((n + 1)); count=$((count + 1))
done < "$FILE"
echo "limit: $count client(s) limited on $DEV"
}
status() {
local cnt
cnt=$(tc class show dev "$DEV" 2>/dev/null | grep -c 'htb 1:[1-9][0-9]* ' )
cnt=$((cnt > 0 ? cnt - 1 : 0))
printf '{"device": "%s", "limited": %s, "file": "%s"}\n' "$DEV" "$cnt" "$FILE"
}
case "${1:-status}" in
apply) apply ;;
clear) clear_tc; echo "limit: cleared" ;;
status) status ;;
*) echo "usage: $0 apply [file]|clear|status"; exit 2 ;;
esac

View File

@@ -24,14 +24,18 @@ PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf" CONF="$PHOBOS_DIR/failover.conf"
HEALTH="$PHOBOS_DIR/phobos-health.sh" HEALTH="$PHOBOS_DIR/phobos-health.sh"
LOG="$PHOBOS_DIR/health.log" LOG="$PHOBOS_DIR/health.log"
# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 is the wg0 of whatever # Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
# server the tunnel currently terminates on, so management rides the same # whatever server the tunnel currently terminates on, so management rides the
# obfuscated channel as data — survives a public-IP ban and follows failover. # same obfuscated channel as data — survives a public-IP ban and follows
# - 10.25.0.1:8444 -> secondary server agent (phobos-api) # failover.
# - 10.25.0.1:10514 -> primary server panel # - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
# - panel_tunnel_ip:<panel_port> -> primary server panel
# - public panel IP -> bootstrap / if tunnel is down # - public panel IP -> bootstrap / if tunnel is down
PANEL="${PANEL:-http://212.118.52.193:10514}" PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$PHOBOS_DIR/panel_tunnel_ip" 2>/dev/null)}"
PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}" [ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
PANEL="${PANEL:-$(cat "$PHOBOS_DIR/panel_url" 2>/dev/null)}"
PANEL_URLS="${PANEL_URLS:-$(cat "$PHOBOS_DIR/panel_urls" 2>/dev/null)}"
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home") CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null) TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
TMP="/tmp/failover.conf.pull" TMP="/tmp/failover.conf.pull"
@@ -49,12 +53,33 @@ trap 'rm -f "$LOCK"' EXIT
[ -z "$TOKEN" ] && exit 0 [ -z "$TOKEN" ] && exit 0
detect_tunnel_iface() {
ip route get "$PANEL_TUNNEL_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1
}
fetch_config() {
base="$1"
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
case "$base" in
"http://${PANEL_TUNNEL_IP}:"*|"https://${PANEL_TUNNEL_IP}:"*)
iface=$(detect_tunnel_iface)
if [ -n "$iface" ] && command -v curl >/dev/null; then
curl -k --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
fi
;;
esac
if command -v curl >/dev/null; then
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
fi
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
}
# One fetch + compare + apply pass. Returns 0 always (best-effort). # One fetch + compare + apply pass. Returns 0 always (best-effort).
do_pull() { do_pull() {
# Try each source (tunnel first); accept first that returns a valid conf. # Try each source (tunnel first); accept first that returns a valid conf.
got=0 got=0
for base in $PANEL_URLS; do for base in $PANEL_URLS; do
curl -s -m 8 -o "$TMP" "${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}" 2>/dev/null || continue fetch_config "$base" || continue
if grep -q "^SERVER_1=" "$TMP" 2>/dev/null; then got=1; break; fi if grep -q "^SERVER_1=" "$TMP" 2>/dev/null; then got=1; break; fi
done done
[ "$got" = 1 ] || { rm -f "$TMP"; return 0; } [ "$got" = 1 ] || { rm -f "$TMP"; return 0; }

117
server/phobos-self-check.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
set -euo pipefail
PHOBOS_DIR="${PHOBOS_DIR:-/opt/Phobos}"
SERVER_ENV="${SERVER_ENV:-$PHOBOS_DIR/server/server.env}"
REPO_DIR="${REPO_DIR:-$PHOBOS_DIR/repo}"
WG_IFACE="${WG_IFACE:-wg0}"
FIX=0
for arg in "$@"; do
[ "$arg" = "--fix" ] && FIX=1
done
log() { echo "[self-check] $*"; }
fail() { echo "[self-check][ERROR] $*" >&2; exit 1; }
warn() { echo "[self-check][WARN] $*" >&2; }
env_get() {
local key="$1"
[ -f "$SERVER_ENV" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"$key="*) printf '%s\n' "${line#*=}"; return 0 ;;
esac
done < "$SERVER_ENV"
}
env_set() {
local key="$1" value="$2" tmp
[ -f "$SERVER_ENV" ] || fail "server.env не найден: $SERVER_ENV"
tmp="$(mktemp)"
awk -v k="$key" -v v="$value" '
BEGIN { done=0 }
$0 ~ "^" k "=" { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "$SERVER_ENV" > "$tmp"
cat "$tmp" > "$SERVER_ENV"
rm -f "$tmp"
chmod 600 "$SERVER_ENV" 2>/dev/null || true
}
normalize_wg_key() {
python3 - "$1" <<'PY'
import sys
value = (sys.argv[1] or "").strip()
alphabet = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
if value and value.lower() != "none" and all(ch in alphabet for ch in value):
padded = value + ("=" * ((4 - len(value) % 4) % 4))
if len(padded) == 44:
value = padded
print(value)
PY
}
check_env_key() {
local key="$1" value normalized
value="$(env_get "$key" || true)"
[ -n "$value" ] || fail "$key пустой или отсутствует в server.env"
normalized="$(normalize_wg_key "$value")"
if [ "$normalized" != "$value" ]; then
if [ "$FIX" = 1 ]; then
env_set "$key" "$normalized"
log "$key: восстановлен base64 padding"
value="$normalized"
else
fail "$key имеет длину ${#value}, нужен padding. Запусти phobos-self-check --fix"
fi
fi
[ "${#value}" = 44 ] || fail "$key имеет длину ${#value}, ожидается 44"
}
check_public_key_matches_wg() {
local env_pub wg_pub
env_pub="$(env_get SERVER_WG_PUBLIC_KEY || true)"
wg_pub="$(wg show "$WG_IFACE" public-key 2>/dev/null || true)"
[ -n "$wg_pub" ] || return 0
if [ "$env_pub" != "$wg_pub" ]; then
if [ "$FIX" = 1 ]; then
env_set SERVER_WG_PUBLIC_KEY "$wg_pub"
log "SERVER_WG_PUBLIC_KEY: обновлен из wg show $WG_IFACE"
else
fail "SERVER_WG_PUBLIC_KEY не совпадает с wg show $WG_IFACE"
fi
fi
}
check_router_templates() {
local tpl="$REPO_DIR/client/templates" missing=0 file
for file in install-router.sh.template lib-client.sh install-obfuscator.sh install-wireguard.sh; do
if [ ! -s "$tpl/$file" ]; then
warn "нет обязательного router helper: $tpl/$file"
missing=1
fi
done
[ "$missing" = 0 ] || fail "router package будет битый. Выполни phobos-update stable|beta|dev"
}
check_scripts_syntax() {
local script
for script in \
"$REPO_DIR/server/scripts/lib-core.sh" \
"$REPO_DIR/server/scripts/phobos-client.sh" \
"$REPO_DIR/client/templates/install-router.sh.template" \
"$REPO_DIR/client/templates/lib-client.sh" \
"$REPO_DIR/client/templates/install-obfuscator.sh" \
"$REPO_DIR/client/templates/install-wireguard.sh"; do
[ -f "$script" ] && bash -n "$script"
done
}
[ -f "$SERVER_ENV" ] || fail "server.env не найден: $SERVER_ENV"
check_env_key SERVER_WG_PUBLIC_KEY
check_public_key_matches_wg
check_router_templates
check_scripts_syntax
log "OK"

28
server/phobos-speedtest.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# PCA Phobos — real server internet speed: download/upload actual bytes, print JSON.
# Cloudflare speed endpoints first, Hetzner/OVH files as download fallback.
# phobos-speedtest.sh [seconds]
set -uo pipefail
T="${1:-8}"
CF="https://speed.cloudflare.com"
down_mbit() { # $1=url -> Mbit/s
curl -s -o /dev/null -m "$T" -w '%{speed_download}' "$1" 2>/dev/null | awk '{printf "%.1f", $1*8/1e6}'
}
ping_ms() {
curl -s -o /dev/null -m 5 -w '%{time_connect}' "$CF/__down?bytes=0" 2>/dev/null | awk '{printf "%.0f", $1*1000}'
}
dl=$(down_mbit "$CF/__down?bytes=500000000")
src="cloudflare"
if [ -z "$dl" ] || awk "BEGIN{exit !($dl < 1)}"; then
for u in https://speed.hetzner.de/1GB.bin https://proof.ovh.net/files/1Gb.dat; do
dl=$(down_mbit "$u"); src="$u"
awk "BEGIN{exit !($dl >= 1)}" && break
done
fi
ul=$(head -c 200000000 /dev/zero | curl -s -o /dev/null -m "$T" -w '%{speed_upload}' \
-X POST --data-binary @- "$CF/__up" 2>/dev/null | awk '{printf "%.1f", $1*8/1e6}')
printf '{"download_mbit": %s, "upload_mbit": %s, "ping_ms": %s, "source": "%s", "ts": %s}\n' \
"${dl:-0}" "${ul:-0}" "$(ping_ms || echo 0)" "$src" "$(date +%s)"

65
server/phobos-tcp.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# PCA Phobos — TCP fallback: clients whose UDP is cut reach the obfuscator over TCP.
# udptcp -s 0.0.0.0:<TCP_PORT> -> 127.0.0.1:<first obfuscator port> (UDP).
# phobos-tcp.sh enable [port] pick a free port (443, 8443, 2096) unless given; idempotent
# phobos-tcp.sh disable | status
set -uo pipefail
PHOBOS_DIR=/opt/Phobos
ENV="$PHOBOS_DIR/server/server.env"
BIN="$PHOBOS_DIR/bin/udptcp"
env_get() { grep -E "^$1=" "$ENV" 2>/dev/null | tail -1 | cut -d= -f2-; }
env_set() {
grep -v -E "^$1=" "$ENV" > "$ENV.tmp" 2>/dev/null || true
echo "$1=$2" >> "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
}
port_busy() { ss -lntH "sport = :$1" 2>/dev/null | grep -q .; }
enable() {
[ -x "$BIN" ] || { echo "udptcp binary missing: $BIN (phobos-update)" >&2; exit 1; }
local port="${1:-$(env_get TCP_PORT)}" obf
obf=$(env_get OBFUSCATOR_PORTS | cut -d, -f1); obf="${obf:-2083}"
# keep our own port if the service already holds it
if [ -z "$port" ] || { port_busy "$port" && ! systemctl is-active -q phobos-tcp; }; then
port=""
for p in 443 8443 2096 4433; do port_busy "$p" || { port=$p; break; }; done
fi
[ -n "$port" ] || { echo "no free TCP port (443/8443/2096/4433)" >&2; exit 1; }
env_set TCP_PORT "$port"
cat > /etc/systemd/system/phobos-tcp.service <<EOF
[Unit]
Description=PCA Phobos TCP fallback (udptcp -> obfuscator :$obf)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=$BIN -s 0.0.0.0:$port 127.0.0.1:$obf
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable -q phobos-tcp
systemctl restart phobos-tcp
sleep 1
status
}
disable() {
systemctl disable --now phobos-tcp >/dev/null 2>&1 || true
rm -f /etc/systemd/system/phobos-tcp.service
systemctl daemon-reload
echo "tcp fallback: disabled"
}
status() {
printf '{"enabled": %s, "port": "%s"}\n' \
"$(systemctl is-active -q phobos-tcp && echo true || echo false)" "$(env_get TCP_PORT)"
}
case "${1:-status}" in
enable) enable "${2:-}" ;;
disable) disable ;;
status) status ;;
*) echo "usage: $0 enable [port]|disable|status"; exit 2 ;;
esac

519
server/phobos-warp.sh Executable file
View File

@@ -0,0 +1,519 @@
#!/bin/bash
set -euo pipefail
SERVER_ENV="/opt/Phobos/server/server.env"
WARP_DIR="/opt/Phobos/server/warp"
WGCF_BIN="/usr/local/bin/wgcf"
WARP_IF="warp"
WARP_CONF="/etc/wireguard/${WARP_IF}.conf"
WARP_TABLE="51820"
WARP_RULE_PRIO="11930"
STATE_FILE="${WARP_DIR}/state.env"
FREE_PROFILE="${WARP_DIR}/wgcf-profile.conf"
CUSTOM_PROFILE="${WARP_DIR}/custom-profile.conf"
LICENSE_FILE="${WARP_DIR}/license.key"
RELAY_TAG_PREFIX="PHOBOS_WARP_RELAY"
load_env() {
if [ -f "$SERVER_ENV" ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) continue ;;
esac
k="${line%%=*}"
v="${line#*=}"
case "$k" in
''|\#*) continue ;;
esac
export "$k=$v"
done < "$SERVER_ENV"
fi
}
load_state() {
STATE_MODE=""
STATE_PROFILE_SOURCE=""
STATE_RELAY_PORTS=""
STATE_RELAY_TARGET=""
STATE_RELAY_TARGET_IP=""
STATE_CLIENTS=""
if [ -f "$STATE_FILE" ]; then
# shellcheck disable=SC1090
. "$STATE_FILE"
STATE_MODE="${MODE:-}"
STATE_PROFILE_SOURCE="${PROFILE_SOURCE:-}"
STATE_RELAY_PORTS="${RELAY_PORTS:-}"
STATE_RELAY_TARGET="${RELAY_TARGET:-}"
STATE_RELAY_TARGET_IP="${RELAY_TARGET_IP:-}"
STATE_CLIENTS="${CLIENTS:-}"
fi
}
save_state() {
mkdir -p "$WARP_DIR"
cat > "$STATE_FILE" <<EOF
MODE=${1:-}
PROFILE_SOURCE=${2:-}
RELAY_PORTS=${3:-}
RELAY_TARGET=${4:-}
RELAY_TARGET_IP=${5:-}
CLIENTS=${6:-}
EOF
}
server_public_ip() {
if [ -n "${SERVER_PUBLIC_IP_V4:-}" ]; then
printf '%s\n' "$SERVER_PUBLIC_IP_V4"
else
curl -4 -fsS --max-time 6 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'
fi
}
wg_subnet() {
if [ -n "${SERVER_WG_IPV4_NETWORK:-}" ]; then
printf '%s\n' "$SERVER_WG_IPV4_NETWORK"
return
fi
if [ -f /etc/wireguard/wg0.conf ]; then
python3 - <<'PY' /etc/wireguard/wg0.conf
import ipaddress, re, sys
for raw in open(sys.argv[1], encoding="utf-8"):
m = re.match(r"^Address\s*=\s*(.+)$", raw.strip())
if not m:
continue
value = m.group(1).split(",", 1)[0].strip()
print(ipaddress.ip_interface(value).network)
break
PY
return
fi
printf '10.25.0.0/16\n'
}
arch_name() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
armv7l) echo "armv7" ;;
*) echo "" ;;
esac
}
ensure_wgcf() {
if [ -x "$WGCF_BIN" ]; then
return
fi
local arch version url
arch="$(arch_name)"
[ -n "$arch" ] || { echo "unsupported architecture: $(uname -m)"; exit 1; }
version="2.2.26"
url="https://github.com/ViRb3/wgcf/releases/download/v${version}/wgcf_${version}_linux_${arch}"
curl -fsSL "$url" -o "$WGCF_BIN"
chmod +x "$WGCF_BIN"
}
ensure_free_profile() {
local license_key=""
mkdir -p "$WARP_DIR"
cd "$WARP_DIR"
ensure_wgcf
if [ ! -f "${WARP_DIR}/wgcf-account.toml" ]; then
"$WGCF_BIN" register --accept-tos >/tmp/phobos-warp-register.log 2>&1
fi
if [ -s "$LICENSE_FILE" ]; then
license_key="$(tr -d '\r\n' < "$LICENSE_FILE")"
if [ -n "$license_key" ]; then
"$WGCF_BIN" update --license "$license_key" >/tmp/phobos-warp-license.log 2>&1 || {
echo "failed to apply WARP+ license key" >&2
cat /tmp/phobos-warp-license.log >&2 || true
exit 1
}
rm -f "$FREE_PROFILE"
fi
fi
if [ ! -f "$FREE_PROFILE" ] || [ ! -s "$FREE_PROFILE" ]; then
"$WGCF_BIN" generate >/tmp/phobos-warp-generate.log 2>&1 || {
echo "failed to generate wgcf profile" >&2
cat /tmp/phobos-warp-generate.log >&2 || true
exit 1
}
fi
}
ensure_custom_profile() {
[ -s "$CUSTOM_PROFILE" ] || { echo "custom profile not found: $CUSTOM_PROFILE" >&2; exit 1; }
}
profile_path_for() {
case "${1:-free}" in
custom) ensure_custom_profile; printf '%s\n' "$CUSTOM_PROFILE" ;;
*) ensure_free_profile; printf '%s\n' "$FREE_PROFILE" ;;
esac
}
profile_endpoint() {
awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$1" | tr -d ' '
}
resolve_host_ipv4() {
getent ahostsv4 "$1" | awk 'NR==1{print $1}'
}
build_warp_conf() {
local profile_path subnet
profile_path="$1"
subnet="$(wg_subnet)"
python3 - "$profile_path" "$WARP_CONF" "$subnet" "$WARP_TABLE" "$WARP_RULE_PRIO" <<'PY'
import sys
from pathlib import Path
profile_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
subnet = sys.argv[3]
table = sys.argv[4]
prio = sys.argv[5]
section = None
data = {"Interface": {}, "Peer": {}}
for raw in profile_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line.strip("[]")
continue
if "=" not in line or section not in data:
continue
k, v = [x.strip() for x in line.split("=", 1)]
data[section][k] = v
iface = data["Interface"]
peer = data["Peer"]
post_up = (
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
f"ip route replace {subnet} dev wg0 table {table}; "
f"ip route replace default dev %i table {table}; "
"iptables -C FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || iptables -I FORWARD 1 -i wg0 -o %i -j ACCEPT; "
"iptables -C FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || "
"iptables -I FORWARD 1 -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; "
f"iptables -t nat -C POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || "
f"iptables -t nat -A POSTROUTING -s {subnet} -o %i -j MASQUERADE"
)
post_down = (
f"ip route del {subnet} dev wg0 table {table} 2>/dev/null || true; "
f"ip route del default dev %i table {table} 2>/dev/null || true; "
"iptables -D FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || true; "
"iptables -D FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true; "
f"iptables -t nat -D POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || true"
)
lines = [
"[Interface]",
f"PrivateKey = {iface.get('PrivateKey', '')}",
f"Address = {iface.get('Address', '')}",
"MTU = 1280",
"Table = off",
f"PostUp = {post_up}",
f"PostDown = {post_down}",
"",
"[Peer]",
f"PublicKey = {peer.get('PublicKey', '')}",
f"AllowedIPs = {peer.get('AllowedIPs', '0.0.0.0/0, ::/0')}",
f"Endpoint = {peer.get('Endpoint', '')}",
f"PersistentKeepalive = {peer.get('PersistentKeepalive', '25')}",
"",
]
out_path.write_text("\n".join(lines))
PY
chmod 600 "$WARP_CONF"
}
clear_egress() {
systemctl disable --now "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
ip route del "$(wg_subnet)" dev wg0 table "$WARP_TABLE" 2>/dev/null || true
ip route del default dev "$WARP_IF" table "$WARP_TABLE" 2>/dev/null || true
iptables -D FORWARD -i wg0 -o "$WARP_IF" -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -i "$WARP_IF" -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
iptables -t nat -D POSTROUTING -s "$(wg_subnet)" -o "$WARP_IF" -j MASQUERADE 2>/dev/null || true
}
clear_legacy_subnet_rule() {
local subnet
subnet="$(wg_subnet)"
ip rule del from "$subnet" table "$WARP_TABLE" priority "$WARP_RULE_PRIO" 2>/dev/null || true
ip rule del from "$subnet" lookup "$WARP_TABLE" 2>/dev/null || true
}
client_rule_prio() {
python3 - "$1" "$WARP_RULE_PRIO" <<'PY'
import sys
ip = sys.argv[1].split('/')[0].strip()
base = int(sys.argv[2])
parts = [int(x) for x in ip.split('.')]
value = ((parts[2] << 8) + parts[3]) % 5000
print(base + 1 + value)
PY
}
normalize_client_ip() {
printf '%s\n' "${1%%/*}" | tr -d ' '
}
state_clients_csv_add() {
local ip csv
ip="$(normalize_client_ip "$1")"
csv="${2:-}"
python3 - "$ip" "$csv" <<'PY'
import sys
ip = sys.argv[1]
items = [x for x in sys.argv[2].split(',') if x]
if ip not in items:
items.append(ip)
print(",".join(items))
PY
}
state_clients_csv_del() {
local ip csv
ip="$(normalize_client_ip "$1")"
csv="${2:-}"
python3 - "$ip" "$csv" <<'PY'
import sys
ip = sys.argv[1]
items = [x for x in sys.argv[2].split(',') if x and x != ip]
print(",".join(items))
PY
}
clear_client_rules() {
load_state
local ip prio
IFS=',' read -r -a __clients <<< "${STATE_CLIENTS:-}"
for ip in "${__clients[@]}"; do
[ -n "$ip" ] || continue
prio="$(client_rule_prio "$ip")"
ip rule del from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
done
}
reapply_client_rules() {
local csv="${1:-}" ip prio
IFS=',' read -r -a __clients <<< "$csv"
for ip in "${__clients[@]}"; do
[ -n "$ip" ] || continue
prio="$(client_rule_prio "$ip")"
ip rule add from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
done
}
relay_ports_array() {
local raw="${1:-4500}"
raw="${raw// /}"
IFS=',' read -r -a RELAY_PORTS_ARRAY <<< "$raw"
}
relay_rule_tag() {
printf '%s:%s:%s:%s\n' "$RELAY_TAG_PREFIX" "$1" "$2" "$3"
}
clear_relay() {
load_state
local lines line cmd
lines="$(iptables-save -t nat 2>/dev/null | grep -F "$RELAY_TAG_PREFIX" || true)"
if [ -n "$lines" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
-A\ *) cmd="iptables -t nat ${line/-A /-D }"; eval "$cmd" >/dev/null 2>&1 || true ;;
esac
done <<EOF
$lines
EOF
fi
lines="$(iptables-save 2>/dev/null | grep -F "$RELAY_TAG_PREFIX" || true)"
if [ -n "$lines" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
-A\ FORWARD\ *) cmd="iptables ${line/-A /-D }"; eval "$cmd" >/dev/null 2>&1 || true ;;
esac
done <<EOF
$lines
EOF
fi
}
disable_all() {
clear_legacy_subnet_rule
clear_client_rules
clear_egress
clear_relay
}
relay_enabled() {
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
}
enable_egress() {
local profile_source profile_path existing_clients
load_env
load_state
existing_clients="${STATE_CLIENTS:-}"
profile_source="${1:-free}"
disable_all
profile_path="$(profile_path_for "$profile_source")"
build_warp_conf "$profile_path"
sysctl -w net.ipv4.ip_forward=1 >/dev/null
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
systemctl restart "wg-quick@${WARP_IF}"
clear_legacy_subnet_rule
reapply_client_rules "$existing_clients"
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
status_json
}
enable_relay() {
local profile_source ports target profile_path endpoint host port_num target_ip server_ip port tag
load_env
profile_source="${1:-free}"
ports="${2:-4500}"
target="${3:-}"
disable_all
profile_path="$(profile_path_for "$profile_source")"
endpoint="${target:-$(profile_endpoint "$profile_path")}"
[ -n "$endpoint" ] || { echo "relay target endpoint is empty" >&2; exit 1; }
host="${endpoint%:*}"
port_num="${endpoint##*:}"
target_ip="$(resolve_host_ipv4 "$host")"
[ -n "$target_ip" ] || { echo "failed to resolve endpoint host: $host" >&2; exit 1; }
server_ip="$(server_public_ip)"
sysctl -w net.ipv4.ip_forward=1 >/dev/null
relay_ports_array "$ports"
for port in "${RELAY_PORTS_ARRAY[@]}"; do
[ -n "$port" ] || continue
tag="$(relay_rule_tag "$port" "$target_ip" "$port_num")"
iptables -t nat -C PREROUTING -d "$server_ip" -p udp --dport "$port" -m comment --comment "$tag" -j DNAT --to-destination "$target_ip:$port_num" 2>/dev/null \
|| iptables -t nat -A PREROUTING -d "$server_ip" -p udp --dport "$port" -m comment --comment "$tag" -j DNAT --to-destination "$target_ip:$port_num"
iptables -t nat -C POSTROUTING -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j MASQUERADE 2>/dev/null \
|| iptables -t nat -A POSTROUTING -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j MASQUERADE
iptables -C FORWARD -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j ACCEPT 2>/dev/null \
|| iptables -A FORWARD -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j ACCEPT
iptables -C FORWARD -p udp -s "$target_ip" --sport "$port_num" -m comment --comment "$tag" -j ACCEPT 2>/dev/null \
|| iptables -A FORWARD -p udp -s "$target_ip" --sport "$port_num" -m comment --comment "$tag" -j ACCEPT
done
save_state "relay" "$profile_source" "$ports" "$endpoint" "$target_ip" ""
status_json
}
client_enable() {
local ip prio updated
ip="$(normalize_client_ip "${1:-}")"
[ -n "$ip" ] || { echo "client ip required" >&2; exit 1; }
load_state
[ "${STATE_MODE:-}" = "egress" ] || { echo "WARP egress mode is not active" >&2; exit 1; }
systemctl is-active --quiet "wg-quick@${WARP_IF}" || { echo "WARP interface is not active" >&2; exit 1; }
prio="$(client_rule_prio "$ip")"
ip rule add from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
updated="$(state_clients_csv_add "$ip" "${STATE_CLIENTS:-}")"
save_state "${STATE_MODE:-egress}" "${STATE_PROFILE_SOURCE:-free}" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$updated"
status_json
}
client_disable() {
local ip prio updated
ip="$(normalize_client_ip "${1:-}")"
[ -n "$ip" ] || { echo "client ip required" >&2; exit 1; }
load_state
prio="$(client_rule_prio "$ip")"
ip rule del from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
updated="$(state_clients_csv_del "$ip" "${STATE_CLIENTS:-}")"
save_state "${STATE_MODE:-}" "${STATE_PROFILE_SOURCE:-}" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$updated"
status_json
}
status_json() {
load_env
load_state
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on
subnet="$(wg_subnet)"
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
mode="${STATE_MODE:-}"
profile_source="${STATE_PROFILE_SOURCE:-}"
configured=false
endpoint=""
pubkey=""
egress=""
relay_on=false
if [ "$service" = "active" ]; then
mode="${mode:-egress}"
configured=true
endpoint="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" 2>/dev/null || true)"
pubkey="$(wg show "${WARP_IF}" public-key 2>/dev/null || true)"
egress="$(curl -4 --interface "${WARP_IF}" -fsS --max-time 6 https://api.ipify.org 2>/dev/null || true)"
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
fi
if relay_enabled; then
relay_on=true
[ -n "$mode" ] || mode="relay"
configured=true
[ -n "$endpoint" ] || endpoint="${STATE_RELAY_TARGET:-}"
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
fi
enabled=false
if [ "$service" = "active" ] || [ "$relay_on" = true ]; then
enabled=true
fi
python3 - "$enabled" "$configured" "$service" "$subnet" "$endpoint" "$pubkey" "$egress" "$mode" "$profile_source" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$( [ -s "$CUSTOM_PROFILE" ] && echo true || echo false )" "$relay_on" "$( [ -s "$LICENSE_FILE" ] && echo true || echo false )" "${STATE_CLIENTS:-}" <<'PY'
import json, sys
enabled, configured, service, subnet, endpoint, pubkey, egress, mode, profile_source, relay_ports, relay_target, relay_target_ip, custom_saved, relay_on, license_saved, clients_csv = sys.argv[1:]
if relay_on == "true" and service != "active":
service = "relay"
print(json.dumps({
"enabled": enabled == "true",
"configured": configured == "true",
"service": service or "inactive",
"mode": mode or "off",
"profile_source": profile_source or "free",
"subnet": subnet,
"endpoint": endpoint,
"public_key": pubkey,
"egress_ipv4": egress,
"relay_enabled": relay_on == "true",
"relay_ports": relay_ports,
"relay_target": relay_target,
"relay_target_ip": relay_target_ip,
"clients": [x for x in clients_csv.split(",") if x],
"custom_profile_saved": custom_saved == "true",
"license_key_saved": license_saved == "true",
}, ensure_ascii=False))
PY
}
disable_warp() {
disable_all
save_state "" "" "" "" "" ""
status_json
}
case "${1:-status}" in
enable|up)
case "${2:-egress}" in
relay) enable_relay "${3:-free}" "${4:-4500}" "${5:-}" ;;
*) enable_egress "${3:-free}" ;;
esac
;;
client-enable) client_enable "${2:-}" ;;
client-disable) client_disable "${2:-}" ;;
disable|down) disable_warp ;;
status|check|--json) status_json ;;
*)
echo "usage: $0 {status|enable [egress|relay] [free|custom] [ports] [endpoint]|client-enable <ip>|client-disable <ip>|disable}" >&2
exit 1
;;
esac

View File

@@ -4,8 +4,8 @@
# Deploys WG + obfuscator + mini-API (no web panel) # Deploys WG + obfuscator + mini-API (no web panel)
# #
# Usage: # Usage:
# MAIN_SERVER=212.118.52.193 MAIN_API_KEY=secret123 \ # MAIN_PROTO=http MAIN_SERVER=203.0.113.10 MAIN_PORT=10514 MAIN_API_KEY=secret123 \
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh) # bash <(curl -fsSL http://203.0.113.10:10514/server/secondary-setup.sh)
# #
# Uninstall: # Uninstall:
# bash /opt/Phobos/server/phobos-secondary-uninstall.sh # bash /opt/Phobos/server/phobos-secondary-uninstall.sh
@@ -15,12 +15,27 @@ set -e
MAIN_SERVER="${MAIN_SERVER:?Set MAIN_SERVER=ip_of_main_server}" MAIN_SERVER="${MAIN_SERVER:?Set MAIN_SERVER=ip_of_main_server}"
MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}" MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
MAIN_PROTO="${MAIN_PROTO:-http}"
MAIN_PORT="${MAIN_PORT:-8443}"
OBF_PORTS="${OBF_PORTS:-2083,5443,993}" OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
PHOBOS_DIR="/opt/Phobos" PHOBOS_DIR="/opt/Phobos"
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
net = ipaddress.ip_network(sys.argv[1], strict=False)
print(next(net.hosts()))
PY
)"
WG_PREFIXLEN="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
print(ipaddress.ip_network(sys.argv[1], strict=False).prefixlen)
PY
)"
SERVER_IP=$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}') SERVER_IP="${SERVER_IP:-$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}')}"
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1) IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
IFACE="${IFACE:-eth0}" IFACE="${IFACE:-eth0}"
case "$MAIN_PROTO" in http|https) ;; *) echo "ERROR: MAIN_PROTO must be http or https"; exit 1;; esac
# ── Pre-flight safety checks ── # ── Pre-flight safety checks ──
echo "" echo ""
@@ -30,6 +45,8 @@ echo "╠═══════════════════════
echo "║ Server IP : $SERVER_IP" echo "║ Server IP : $SERVER_IP"
echo "║ Interface : $IFACE" echo "║ Interface : $IFACE"
echo "║ Main server : $MAIN_SERVER" echo "║ Main server : $MAIN_SERVER"
echo "║ Main proto : $MAIN_PROTO"
echo "║ Main port : $MAIN_PORT"
echo "║ OBF ports : $OBF_PORTS" echo "║ OBF ports : $OBF_PORTS"
echo "╚══════════════════════════════════════════════════════╝" echo "╚══════════════════════════════════════════════════════╝"
echo "" echo ""
@@ -104,7 +121,7 @@ if [ "$WARNINGS" -gt 0 ]; then
echo "" echo ""
echo "Found $WARNINGS warning(s). Existing configs will be preserved where possible." echo "Found $WARNINGS warning(s). Existing configs will be preserved where possible."
echo "Press Enter to continue or Ctrl+C to abort..." echo "Press Enter to continue or Ctrl+C to abort..."
read -r < /dev/tty 2>/dev/null || true [ -t 0 ] && read -r < /dev/tty 2>/dev/null || true
fi fi
echo "" echo ""
@@ -126,13 +143,17 @@ fi
# ── 1. Dependencies ── # ── 1. Dependencies ──
echo "[1/7] Installing dependencies..." echo "[1/7] Installing dependencies..."
apt-get update -qq apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard jq curl python3 python3-flask gunicorn 2>/dev/null DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard wireguard-tools iptables jq curl git ca-certificates python3 python3-flask gunicorn 2>/dev/null
# ── 2. Phobos binaries ── # ── 2. Phobos binaries ──
echo "[2/7] Getting Phobos binaries..." echo "[2/7] Getting Phobos binaries..."
mkdir -p "$PHOBOS_DIR"/{server,clients,bin} mkdir -p "$PHOBOS_DIR"/{server,clients,bin}
if [ ! -f /usr/local/bin/wg-obfuscator ]; then if [ ! -f /usr/local/bin/wg-obfuscator ]; then
ARCH="$(uname -m)"
OBF_ARCH="$ARCH"
[ "$OBF_ARCH" = "amd64" ] && OBF_ARCH="x86_64"
[ "$OBF_ARCH" = "arm64" ] && OBF_ARCH="aarch64"
REPO_DIR="/tmp/phobos-repo" REPO_DIR="/tmp/phobos-repo"
rm -rf "$REPO_DIR" rm -rf "$REPO_DIR"
mkdir -p "$REPO_DIR" mkdir -p "$REPO_DIR"
@@ -142,10 +163,34 @@ if [ ! -f /usr/local/bin/wg-obfuscator ]; then
git config core.sparseCheckout true git config core.sparseCheckout true
echo "wg-obfuscator" > .git/info/sparse-checkout echo "wg-obfuscator" > .git/info/sparse-checkout
git pull origin main -q 2>/dev/null git pull origin main -q 2>/dev/null
cp -f wg-obfuscator/bin/wg-obfuscator-$(uname -m) "$PHOBOS_DIR/bin/" 2>/dev/null || true cp -f wg-obfuscator/bin/wg-obfuscator-* "$PHOBOS_DIR/bin/" 2>/dev/null || true
chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-* chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-* 2>/dev/null || true
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-$(uname -m)" /usr/local/bin/wg-obfuscator ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${OBF_ARCH}" /usr/local/bin/wg-obfuscator 2>/dev/null || true
rm -rf "$REPO_DIR" rm -rf "$REPO_DIR"
if [ ! -x /usr/local/bin/wg-obfuscator ]; then
OBF_TAG=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
CB="https://github.com/ClusterM/wg-obfuscator/releases/download/${OBF_TAG}"
for MAP in "x86_64:linux-x64" "aarch64:linux-arm64" "mipsel:linux-mipsel-mips32" "mips:linux-mips-mips32" "armv7:linux-armv7-hf"; do
DA="${MAP%%:*}"
SS="${MAP##*:}"
[ "$DA" = "$OBF_ARCH" ] || continue
[ -n "$OBF_TAG" ] || continue
TMP_DIR=$(mktemp -d)
if curl -fsSL -m30 "${CB}/wg-obfuscator-${OBF_TAG}-${SS}.tar.gz" -o "$TMP_DIR/o.tgz" 2>/dev/null &&
tar -xzf "$TMP_DIR/o.tgz" -C "$TMP_DIR" 2>/dev/null; then
BIN_PATH=$(find "$TMP_DIR" -name "wg-obfuscator" -type f | head -1)
if [ -n "$BIN_PATH" ]; then
cp "$BIN_PATH" "$PHOBOS_DIR/bin/wg-obfuscator-${DA}"
chmod +x "$PHOBOS_DIR/bin/wg-obfuscator-${DA}"
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-${DA}" /usr/local/bin/wg-obfuscator
fi
fi
rm -rf "$TMP_DIR"
done
fi
[ -x /usr/local/bin/wg-obfuscator ] || { echo "ERROR: obfuscator binary for $ARCH missing"; exit 1; }
echo " Obfuscator installed." echo " Obfuscator installed."
else else
echo " Obfuscator already installed, skipping." echo " Obfuscator already installed, skipping."
@@ -163,7 +208,7 @@ else
cat > /etc/wireguard/wg0.conf << WGEOF cat > /etc/wireguard/wg0.conf << WGEOF
[Interface] [Interface]
Address = 10.25.0.1/16 Address = ${WG_SERVER_IPV4}/${WG_PREFIXLEN}
ListenPort = 51820 ListenPort = 51820
PrivateKey = $WG_PRIV PrivateKey = $WG_PRIV
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
@@ -229,23 +274,26 @@ cat > "$PHOBOS_DIR/server/server.env" << EOF
SERVER_WG_PRIVATE_KEY=$WG_PRIV SERVER_WG_PRIVATE_KEY=$WG_PRIV
SERVER_WG_PUBLIC_KEY=$WG_PUB SERVER_WG_PUBLIC_KEY=$WG_PUB
SERVER_PUBLIC_IP_V4=$SERVER_IP SERVER_PUBLIC_IP_V4=$SERVER_IP
SERVER_WG_IPV4_NETWORK=$SERVER_WG_IPV4_NETWORK
SERVER_WG_IPV4_ADDRESS=${WG_SERVER_IPV4}/${WG_PREFIXLEN}
OBFUSCATOR_KEY=$OBF_KEY OBFUSCATOR_KEY=$OBF_KEY
OBFUSCATOR_PORTS=$OBF_PORTS OBFUSCATOR_PORTS=$OBF_PORTS
MAIN_SERVER=$MAIN_SERVER MAIN_SERVER=$MAIN_SERVER
MAIN_PROTO=$MAIN_PROTO
MAIN_API_KEY=$MAIN_API_KEY MAIN_API_KEY=$MAIN_API_KEY
MAIN_PORT=$MAIN_PORT
ROLE=secondary ROLE=secondary
EOF EOF
# ── 6. Mini-API ── # ── 6. Mini-API ──
echo "[6/7] Setting up mini-API..." echo "[6/7] Setting up mini-API..."
# Mini-API (fetched from PCA — includes /api/router-config for tunnel pull) # Mini-API (fetched from the main panel first, then PCA GitHub fallback)
PCA_BRANCH="${PCA_BRANCH:-main}" PCA_BRANCH="${PCA_BRANCH:-main}"
RAW="https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}" RAW="${PCA_RAW_BASE:-https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/${PCA_BRANCH}}"
if [ -n "${GH_TOKEN:-}" ]; then PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/server/api.py"
curl -fsSL -H "Authorization: token $GH_TOKEN" "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" curl -kfsSL "$PANEL_BOOTSTRAP" -o "$PHOBOS_DIR/server/api.py" \
else || curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" \
curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" || curl -fsSL "https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}/server/api.py" -o "$PHOBOS_DIR/server/api.py"
fi
[ -s "$PHOBOS_DIR/server/api.py" ] || { echo "ERROR: api.py fetch failed"; exit 1; } [ -s "$PHOBOS_DIR/server/api.py" ] || { echo "ERROR: api.py fetch failed"; exit 1; }
cat > /etc/systemd/system/phobos-api.service << EOF cat > /etc/systemd/system/phobos-api.service << EOF
@@ -291,7 +339,7 @@ echo ""
echo "Backups (if any) in $PHOBOS_DIR/backup/ will be KEPT." echo "Backups (if any) in $PHOBOS_DIR/backup/ will be KEPT."
echo "" echo ""
echo "Press Enter to continue or Ctrl+C to abort..." echo "Press Enter to continue or Ctrl+C to abort..."
read -r < /dev/tty 2>/dev/null || true [ -t 0 ] && read -r < /dev/tty 2>/dev/null || true
echo "Stopping services..." echo "Stopping services..."
# Stop obfuscator instances # Stop obfuscator instances
@@ -367,7 +415,7 @@ REG_DATA=$(cat << EOF
} }
EOF EOF
) )
curl -s -X POST "http://${MAIN_SERVER}:$(cat /opt/phobos-panel/.port 2>/dev/null || echo 8443)/api/servers/register" \ curl -ks -X POST "${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/api/servers/register" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-Key: ${MAIN_API_KEY}" \ -H "X-API-Key: ${MAIN_API_KEY}" \
-d "$REG_DATA" || echo " (Registration will be done manually via panel)" -d "$REG_DATA" || echo " (Registration will be done manually via panel)"

6
udptcp/README.md Normal file
View File

@@ -0,0 +1,6 @@
# udptcp — резервный TCP-канал PCA Phobos
UDP-пакеты (уже обфусцированные) внутри TCP: кадр = 2 байта длины + пакет.
- сервер: `udptcp -s 0.0.0.0:443 127.0.0.1:2083` (служба `phobos-tcp`, порт в server.env `TCP_PORT`);
- клиент: `udptcp -c 127.0.0.1:13256 <сервер>:443`, обфускатор клиента шлёт на 127.0.0.1:13256.
Сборка: `gcc -O2 -static -pthread -o bin/udptcp-x86_64 udptcp.c` (+ aarch64-linux-gnu-gcc), Android — NDK (см. android-client/README.md).

BIN
udptcp/bin/udptcp-aarch64 Executable file

Binary file not shown.

BIN
udptcp/bin/udptcp-x86_64 Executable file

Binary file not shown.

175
udptcp/udptcp.c Normal file
View File

@@ -0,0 +1,175 @@
/*
* udptcp — PCA Phobos fallback transport: UDP datagrams carried over TCP.
* Frames on the TCP stream: 2-byte big-endian length + payload.
*
* server: udptcp -s <listen_ip:port> <udp_target_ip:port>
* each TCP connection gets its own UDP socket to the target
* (the local wg-obfuscator), so the obfuscator sees one client per conn.
* client: udptcp -c <local_udp_ip:port> <tcp_server_host:port>
* local UDP peer (client wg-obfuscator) <-> one TCP connection, reconnects.
*
* Plain C, no deps: builds for Linux servers, Android (NDK) and iOS.
*/
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#define MAXPKT 65535
static int parse_addr(const char *s, struct sockaddr_storage *ss, socklen_t *len, int socktype) {
char host[256];
const char *colon = strrchr(s, ':');
if (!colon || colon == s || (size_t)(colon - s) >= sizeof host) return -1;
memcpy(host, s, colon - s);
host[colon - s] = 0;
struct addrinfo hints = {0}, *res;
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = socktype;
if (getaddrinfo(host, colon + 1, &hints, &res) != 0) return -1;
memcpy(ss, res->ai_addr, res->ai_addrlen);
*len = res->ai_addrlen;
freeaddrinfo(res);
return 0;
}
static int read_full(int fd, unsigned char *buf, size_t n) {
size_t got = 0;
while (got < n) {
ssize_t r = read(fd, buf + got, n - got);
if (r <= 0) return -1;
got += (size_t)r;
}
return 0;
}
static int write_frame(int fd, const unsigned char *p, size_t n) {
unsigned char hdr[2] = {(unsigned char)(n >> 8), (unsigned char)(n & 0xff)};
if (write(fd, hdr, 2) != 2) return -1;
size_t sent = 0;
while (sent < n) {
ssize_t w = write(fd, p + sent, n - sent);
if (w <= 0) return -1;
sent += (size_t)w;
}
return 0;
}
static void tune_tcp(int fd) {
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof one);
}
/* ── server ─────────────────────────────────────────────────────────────── */
static struct sockaddr_storage g_target;
static socklen_t g_target_len;
static void *serve_conn(void *arg) {
int tcp = (int)(long)arg;
unsigned char buf[MAXPKT + 2];
int udp = socket(g_target.ss_family, SOCK_DGRAM, 0);
if (udp < 0 || connect(udp, (struct sockaddr *)&g_target, g_target_len) < 0) goto out;
tune_tcp(tcp);
struct pollfd p[2] = {{tcp, POLLIN, 0}, {udp, POLLIN, 0}};
for (;;) {
if (poll(p, 2, 300000) <= 0) break; /* 5 min idle -> drop */
if (p[0].revents & (POLLIN | POLLHUP | POLLERR)) {
unsigned char h[2];
if (read_full(tcp, h, 2) < 0) break;
size_t n = ((size_t)h[0] << 8) | h[1];
if (n == 0 || read_full(tcp, buf, n) < 0) break;
send(udp, buf, n, 0);
}
if (p[1].revents & POLLIN) {
ssize_t n = recv(udp, buf, MAXPKT, 0);
if (n > 0 && write_frame(tcp, buf, (size_t)n) < 0) break;
}
}
out:
if (udp >= 0) close(udp);
close(tcp);
return NULL;
}
static int run_server(const char *listen_s, const char *target_s) {
struct sockaddr_storage la; socklen_t ll;
if (parse_addr(listen_s, &la, &ll, SOCK_STREAM) || parse_addr(target_s, &g_target, &g_target_len, SOCK_DGRAM)) {
fprintf(stderr, "bad address\n"); return 2;
}
int ls = socket(la.ss_family, SOCK_STREAM, 0), one = 1;
setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
if (bind(ls, (struct sockaddr *)&la, ll) < 0 || listen(ls, 128) < 0) { perror("listen"); return 1; }
fprintf(stderr, "udptcp server %s -> udp %s\n", listen_s, target_s);
for (;;) {
int c = accept(ls, NULL, NULL);
if (c < 0) { if (errno == EINTR) continue; perror("accept"); sleep(1); continue; }
pthread_t t;
if (pthread_create(&t, NULL, serve_conn, (void *)(long)c) == 0) pthread_detach(t);
else close(c);
}
}
/* ── client ─────────────────────────────────────────────────────────────── */
static int connect_tcp(const char *server_s) {
struct sockaddr_storage sa; socklen_t sl;
if (parse_addr(server_s, &sa, &sl, SOCK_STREAM)) return -1; /* re-resolve each time */
int s = socket(sa.ss_family, SOCK_STREAM, 0);
if (s < 0) return -1;
if (connect(s, (struct sockaddr *)&sa, sl) < 0) { close(s); return -1; }
tune_tcp(s);
return s;
}
static int run_client(const char *local_s, const char *server_s) {
struct sockaddr_storage la, peer; socklen_t ll, pl = 0;
if (parse_addr(local_s, &la, &ll, SOCK_DGRAM)) { fprintf(stderr, "bad address\n"); return 2; }
int us = socket(la.ss_family, SOCK_DGRAM, 0);
if (bind(us, (struct sockaddr *)&la, ll) < 0) { perror("bind"); return 1; }
fprintf(stderr, "udptcp client udp %s -> tcp %s\n", local_s, server_s);
unsigned char buf[MAXPKT + 2];
int tcp = -1;
time_t last_try = 0;
for (;;) {
struct pollfd p[2] = {{us, POLLIN, 0}, {tcp, POLLIN, 0}};
poll(p, tcp >= 0 ? 2 : 1, 1000);
if (p[0].revents & POLLIN) {
pl = sizeof peer;
ssize_t n = recvfrom(us, buf, MAXPKT, 0, (struct sockaddr *)&peer, &pl);
if (n > 0) {
if (tcp < 0 && time(NULL) != last_try) {
last_try = time(NULL);
tcp = connect_tcp(server_s);
fprintf(stderr, tcp >= 0 ? "connected\n" : "connect failed\n");
}
if (tcp >= 0 && write_frame(tcp, buf, (size_t)n) < 0) { close(tcp); tcp = -1; }
}
}
if (tcp >= 0 && (p[1].revents & (POLLIN | POLLHUP | POLLERR))) {
unsigned char h[2];
if (read_full(tcp, h, 2) < 0) { close(tcp); tcp = -1; continue; }
size_t n = ((size_t)h[0] << 8) | h[1];
if (n == 0 || read_full(tcp, buf, n) < 0) { close(tcp); tcp = -1; continue; }
if (pl) sendto(us, buf, n, 0, (struct sockaddr *)&peer, pl);
}
}
}
int main(int argc, char **argv) {
signal(SIGPIPE, SIG_IGN);
if (argc == 4 && !strcmp(argv[1], "-s")) return run_server(argv[2], argv[3]);
if (argc == 4 && !strcmp(argv[1], "-c")) return run_client(argv[2], argv[3]);
fprintf(stderr, "usage: udptcp -s <listen ip:port> <udp target ip:port>\n"
" udptcp -c <local udp ip:port> <tcp server host:port>\n");
return 2;
}

70
uninstall.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# ============================================================
# PCA Phobos — полное удаление сервера (primary / panel node)
#
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/uninstall.sh)
#
# Останавливает и удаляет: панель, обфускатор-сервисы, WireGuard wg0,
# nginx-сайт, watchdog-cron, бинарники, iptables-правила, каталоги Phobos.
# По умолчанию спрашивает подтверждение. PURGE=1 — без вопросов.
# ============================================================
set -u
PHOBOS_DIR="/opt/Phobos"
PANEL_DIR="/opt/phobos-panel"
[ "$(id -u)" -eq 0 ] || { echo "Запусти от root"; exit 1; }
echo "=================================================="
echo " PCA Phobos — УДАЛЕНИЕ сервера"
echo " Будут удалены: панель, obfuscator, wg0, nginx-сайт,"
echo " watchdog, бинарники, $PHOBOS_DIR, $PANEL_DIR"
echo "=================================================="
if [ "${PURGE:-0}" != "1" ]; then
printf "Точно удалить ВСЁ? [yes/NO]: "
read ans
[ "$ans" = "yes" ] || { echo "Отменено."; exit 0; }
fi
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
IFACE="${IFACE:-eth0}"
echo "[1/8] stop + disable services..."
# obfuscator services (any port)
for svc in $(systemctl list-units --all --plain --no-legend 'wg-obfuscator-*' 2>/dev/null | awk '{print $1}'); do
systemctl stop "$svc" 2>/dev/null; systemctl disable "$svc" 2>/dev/null
done
for svc in phobos-panel phobos-api wg-quick@wg0; do
systemctl stop "$svc" 2>/dev/null; systemctl disable "$svc" 2>/dev/null
done
echo "[2/8] remove systemd units..."
rm -f /etc/systemd/system/wg-obfuscator-*.service 2>/dev/null
rm -f /etc/systemd/system/phobos-panel.service /etc/systemd/system/phobos-api.service 2>/dev/null
systemctl daemon-reload 2>/dev/null
echo "[3/8] remove WireGuard wg0..."
rm -f /etc/wireguard/wg0.conf 2>/dev/null
echo "[4/8] remove iptables rules..."
iptables -t nat -D POSTROUTING -o "$IFACE" -j MASQUERADE 2>/dev/null || true
iptables -D FORWARD -i wg0 -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
iptables -D INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null || true
echo "[5/8] remove nginx site..."
rm -f /etc/nginx/sites-enabled/phobos /etc/nginx/sites-available/phobos 2>/dev/null
nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true
echo "[6/8] remove watchdog cron..."
( crontab -l 2>/dev/null | grep -v 'phobos-router-watchdog' | grep -v '/opt/Phobos' || true ) | crontab - 2>/dev/null || true
echo "[7/8] remove binaries / symlinks..."
rm -f /usr/local/bin/wg-obfuscator /usr/local/bin/phobos-update 2>/dev/null
echo "[8/8] remove directories..."
rm -rf "$PHOBOS_DIR" "$PANEL_DIR" 2>/dev/null
echo "=================================================="
echo " Phobos удалён."
echo " (WireGuard/nginx/cron-пакеты НЕ удалены — общие; убрать вручную при желании)"
echo "=================================================="

213
update.sh
View File

@@ -2,40 +2,145 @@
# ============================================================ # ============================================================
# PCA Phobos — updater / rollback # PCA Phobos — updater / rollback
# #
# phobos-update # обновить до последней версии (main) # phobos-update # обновить до стабильной (канал stable / main)
# phobos-update v1.1.0 # обновить до конкретной версии (git tag) # phobos-update stable|beta|dev # канал (ветка main/beta/dev)
# phobos-update v1.2.4 # конкретная версия (git tag)
# phobos-update --rollback # откатить на предыдущую версию (из бэкапа) # phobos-update --rollback # откатить на предыдущую версию (из бэкапа)
# phobos-update --version # показать установленную и последнюю версию # phobos-update --check # установленная + версии всех каналов
# phobos-update --list # список бэкапов для отката # phobos-update --list # список бэкапов для отката
# #
# Обновляет ТОЛЬКО PCA-слой (панель + скрипты онбординга/служебные). # Источник: Gitea git.bobyrev.com/PCA/PCA_Phobos (основной), GitHub — запасной.
# НЕ трогает ключи WireGuard, server.env, клиентов — они сохраняются. # Свой адрес: PCA_GIT=https://… phobos-update
# Перед каждым обновлением делает бэкап; откат восстанавливает его. #
# Обновляет ТОЛЬКО PCA-слой (панель + скрипты). НЕ трогает ключи WireGuard,
# server.env, клиентов. Перед каждым обновлением — бэкап; откат восстанавливает.
# ============================================================ # ============================================================
set -e set -e
PHOBOS_DIR=/opt/Phobos PHOBOS_DIR=/opt/Phobos
PANEL_DIR=/opt/phobos-panel PANEL_DIR=/opt/phobos-panel
REPO=andrey271192/PCA_Phobos PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
RAW="https://raw.githubusercontent.com/$REPO" GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
BACKUPS="$PHOBOS_DIR/updates" BACKUPS="$PHOBOS_DIR/updates"
VERFILE="$PANEL_DIR/.version" VERFILE="$PANEL_DIR/.version"
CUR=$(cat "$VERFILE" 2>/dev/null || echo "unknown") CUR=$(cat "$VERFILE" 2>/dev/null || echo "unknown")
# Токен подписчика для приватных каналов: env > сохранённый файл
GH_TOKEN="${GH_TOKEN:-$(cat "$PANEL_DIR/.gh_token" 2>/dev/null || true)}"
panel_service_name() {
for name in phobos-dev-test phobos-panel; do
systemctl status "$name" >/dev/null 2>&1 && { echo "$name"; return; }
done
echo phobos-panel
}
restart_panel_service() {
local svc
svc="$(panel_service_name)"
systemctl restart "$svc" 2>/dev/null || true
}
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
is_private() { return 1; }
fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallback
local ref="$1" rp="$2" out="$3" kind=branch
case "$ref" in v[0-9]*) kind=tag;; esac
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out"
}
fetch_repo_text() { # $1=ref $2=repo_path
local tmp rc
tmp=$(mktemp)
fetch_repo_file "$1" "$2" "$tmp"; rc=$?
if [ "$rc" = 0 ]; then cat "$tmp"; fi
rm -f "$tmp"
return "$rc"
}
managed_files() { managed_files() {
cat <<LIST cat <<LIST
$PANEL_DIR/app.py|app.py $PANEL_DIR/app.py|app.py
$PHOBOS_DIR/repo/server/scripts/lib-core.sh|overlay/lib-core.sh
$PHOBOS_DIR/repo/server/scripts/phobos-client.sh|overlay/phobos-client.sh $PHOBOS_DIR/repo/server/scripts/phobos-client.sh|overlay/phobos-client.sh
$PHOBOS_DIR/repo/client/templates/install-router.sh.template|overlay/install-router.sh.template $PHOBOS_DIR/repo/client/templates/install-router.sh.template|overlay/install-router.sh.template
$PHOBOS_DIR/repo/client/templates/lib-client.sh|overlay/lib-client.sh
$PHOBOS_DIR/repo/client/templates/install-obfuscator.sh|overlay/install-obfuscator.sh
$PHOBOS_DIR/repo/client/templates/install-wireguard.sh|overlay/install-wireguard.sh
$PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh|overlay/router-configure-wireguard.sh $PHOBOS_DIR/repo/client/templates/router-configure-wireguard.sh|overlay/router-configure-wireguard.sh
$PHOBOS_DIR/repo/client/templates/router-configure-wireguard-openwrt.sh|overlay/router-configure-wireguard-openwrt.sh
$PHOBOS_DIR/repo/client/templates/phobos-pull.sh|overlay/phobos-pull.sh $PHOBOS_DIR/repo/client/templates/phobos-pull.sh|overlay/phobos-pull.sh
$PHOBOS_DIR/repo/client/templates/phobos-ttyd.sh|overlay/phobos-ttyd.sh
$PHOBOS_DIR/repo/client/templates/phobos-uninstall.sh|overlay/phobos-uninstall.sh
$PHOBOS_DIR/repo/client/templates/3xui.sh|overlay/3xui.sh
$PHOBOS_DIR/server/phobos-warp.sh|server/phobos-warp.sh
$PHOBOS_DIR/server/phobos-health.sh|server/phobos-health.sh $PHOBOS_DIR/server/phobos-health.sh|server/phobos-health.sh
$PHOBOS_DIR/server/phobos-self-check.sh|server/phobos-self-check.sh
$PHOBOS_DIR/server/phobos-pull.sh|server/phobos-pull.sh $PHOBOS_DIR/server/phobos-pull.sh|server/phobos-pull.sh
$PHOBOS_DIR/server/phobos-router-watchdog.py|server/phobos-router-watchdog.py $PHOBOS_DIR/server/phobos-router-watchdog.py|server/phobos-router-watchdog.py
$PHOBOS_DIR/server/api.py|server/api.py
$PHOBOS_DIR/server/secondary-setup.sh|server/secondary-setup.sh
$PHOBOS_DIR/server/phobos-backup.sh|server/phobos-backup.sh
$PHOBOS_DIR/server/phobos-guard.sh|server/phobos-guard.sh
$PHOBOS_DIR/server/phobos-cascade.sh|server/phobos-cascade.sh
$PHOBOS_DIR/server/phobos-domain.sh|server/phobos-domain.sh
$PHOBOS_DIR/server/phobos-limit.sh|server/phobos-limit.sh
$PHOBOS_DIR/server/phobos-speedtest.sh|server/phobos-speedtest.sh
$PHOBOS_DIR/server/phobos-tcp.sh|server/phobos-tcp.sh
$PHOBOS_DIR/server/update.sh|update.sh $PHOBOS_DIR/server/update.sh|update.sh
LIST LIST
} }
latest_version() { curl -fsSL -m10 "$RAW/main/VERSION" 2>/dev/null | tr -d ' \r\n'; } ensure_tcp_fallback() { # $1=ref ; primary only (secondaries have no panel)
[ -f "$PANEL_DIR/app.py" ] || return 0
local arch tmp
arch=$(uname -m); case "$arch" in amd64) arch=x86_64;; arm64) arch=aarch64;; esac
tmp=$(mktemp)
if fetch_repo_file "$1" "udptcp/bin/udptcp-$arch" "$tmp" && [ -s "$tmp" ]; then
install -m755 "$tmp" "$PHOBOS_DIR/bin/udptcp"
fi
rm -f "$tmp"
[ -x "$PHOBOS_DIR/server/phobos-tcp.sh" ] && ln -sf "$PHOBOS_DIR/server/phobos-tcp.sh" /usr/local/bin/phobos-tcp
[ -x "$PHOBOS_DIR/bin/udptcp" ] && "$PHOBOS_DIR/server/phobos-tcp.sh" enable >/dev/null 2>&1 \
&& echo "TCP fallback: port $(grep '^TCP_PORT=' "$PHOBOS_DIR/server/server.env" | cut -d= -f2)" || true
}
run_self_check() {
if [ -x "$PHOBOS_DIR/server/phobos-self-check.sh" ]; then
ln -sf "$PHOBOS_DIR/server/phobos-self-check.sh" /usr/local/bin/phobos-self-check 2>/dev/null || true
"$PHOBOS_DIR/server/phobos-self-check.sh" --fix
else
echo " WARN: phobos-self-check.sh не найден"
fi
[ -x "$PHOBOS_DIR/server/phobos-backup.sh" ] && ln -sf "$PHOBOS_DIR/server/phobos-backup.sh" /usr/local/bin/phobos-backup 2>/dev/null || true
[ -x "$PHOBOS_DIR/server/phobos-warp.sh" ] && ln -sf "$PHOBOS_DIR/server/phobos-warp.sh" /usr/local/bin/phobos-warp 2>/dev/null || true
for _n in phobos-guard phobos-cascade phobos-domain; do [ -x "$PHOBOS_DIR/server/$_n.sh" ] && ln -sf "$PHOBOS_DIR/server/$_n.sh" /usr/local/bin/$_n 2>/dev/null || true; done
}
refresh_router_packages() {
local script="$PHOBOS_DIR/repo/server/scripts/phobos-client.sh"
[ -x "$script" ] || return 0
[ -d "$PHOBOS_DIR/clients" ] || return 0
local rebuilt=0 failed=0 id
for dir in "$PHOBOS_DIR/clients"/*; do
[ -d "$dir" ] || continue
id=$(basename "$dir")
if "$script" package "$id" >/dev/null 2>&1; then
rebuilt=$((rebuilt + 1))
else
failed=$((failed + 1))
echo " WARN: router package rebuild failed for $id"
fi
done
[ "$rebuilt" = 0 ] && [ "$failed" = 0 ] || echo "Router packages rebuilt: $rebuilt, failed: $failed"
}
# fetch VERSION of a channel (public repos / or private if token present)
chan_version() { # $1 = ref(channel)
if is_private "$1" && [ -z "$GH_TOKEN" ]; then echo ""; return; fi
fetch_repo_text "$1" VERSION 2>/dev/null | tr -d ' \r\n'
}
latest_version() { fetch_repo_text main VERSION 2>/dev/null | tr -d ' \r\n'; }
do_backup() { do_backup() {
local dir="$BACKUPS/${CUR}-$(date +%Y%m%d-%H%M%S)" local dir="$BACKUPS/${CUR}-$(date +%Y%m%d-%H%M%S)"
@@ -51,7 +156,7 @@ apply_ref() {
local ref="$1" local ref="$1"
managed_files | while IFS='|' read -r lp rp; do managed_files | while IFS='|' read -r lp rp; do
local tmp; tmp=$(mktemp) local tmp; tmp=$(mktemp)
if curl -fsSL -m25 "$RAW/$ref/$rp" -o "$tmp" && [ -s "$tmp" ]; then if fetch_repo_file "$ref" "$rp" "$tmp" && [ -s "$tmp" ]; then
mkdir -p "$(dirname "$lp")"; mv "$tmp" "$lp" mkdir -p "$(dirname "$lp")"; mv "$tmp" "$lp"
case "$lp" in *.sh|*.py) chmod +x "$lp" 2>/dev/null || true;; esac case "$lp" in *.sh|*.py) chmod +x "$lp" 2>/dev/null || true;; esac
else else
@@ -60,10 +165,52 @@ apply_ref() {
done done
} }
ensure_runtime_deps() {
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sshpass python3-pip >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-flask-sock >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-asyncssh >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-websocket >/dev/null 2>&1 || true
fi
python3 - <<'PY' >/dev/null 2>&1 || python3 -m pip install flask-sock asyncssh websocket-client >/dev/null 2>&1 || python3 -m pip install --break-system-packages flask-sock asyncssh websocket-client >/dev/null 2>&1 || true
import flask_sock, asyncssh, websocket
PY
mkdir -p "$PANEL_DIR/static"
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css -o "$PANEL_DIR/static/xterm.min.css" 2>/dev/null || true
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js -o "$PANEL_DIR/static/xterm.min.js" 2>/dev/null || true
curl -fsSL -m20 https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js -o "$PANEL_DIR/static/xterm-addon-fit.min.js" 2>/dev/null || true
if [ -f /etc/systemd/system/phobos-panel.service ]; then
if grep -q 'ExecStart=/usr/bin/gunicorn -w 1 -b ' /etc/systemd/system/phobos-panel.service; then
sed -i 's#ExecStart=/usr/bin/gunicorn -w 1 -b #ExecStart=/usr/bin/gunicorn -w 1 --threads 20 -b #' /etc/systemd/system/phobos-panel.service
systemctl daemon-reload 2>/dev/null || true
fi
if grep -q '0.0.0.0:8443 app:app' /etc/systemd/system/phobos-panel.service && [ -s "$PANEL_DIR/.port" ]; then
port=$(cat "$PANEL_DIR/.port" 2>/dev/null || echo 8443)
sed -i "s#0.0.0.0:8443 app:app#0.0.0.0:${port} app:app#" /etc/systemd/system/phobos-panel.service
systemctl daemon-reload 2>/dev/null || true
fi
fi
}
ref_exists() {
local tmp rc
tmp=$(mktemp)
fetch_repo_file "$1" app.py "$tmp" >/dev/null 2>&1; rc=$?
rm -f "$tmp"
return "$rc"
}
case "${1:-}" in case "${1:-}" in
--version|-v) --version|-v|--check)
echo "Установлено: $CUR" echo "Установлено: $CUR"
echo "Доступно: $(latest_version)" echo "Стабильная: $(latest_version) [открытый]"
echo "Бета (beta): $(chan_version beta)"
echo "Разработка(dev):$(chan_version dev)"
;;
--versions|--tags)
echo "Версии (теги):"
curl -fsSL -m10 "${PCA_GIT%/*/*}/api/v1/repos/${PCA_GIT#*://*/}/tags?limit=100" 2>/dev/null \
| grep -oE '"name": *"[^"]+"' | sed 's/.*"name": *"\(.*\)"/ \1/' || echo " (нет данных)"
;; ;;
--list) --list)
echo "Бэкапы (для отката):" echo "Бэкапы (для отката):"
@@ -78,17 +225,51 @@ case "${1:-}" in
[ -f "$last/$rp" ] && { mkdir -p "$(dirname "$lp")"; cp "$last/$rp" "$lp"; chmod +x "$lp" 2>/dev/null || true; } [ -f "$last/$rp" ] && { mkdir -p "$(dirname "$lp")"; cp "$last/$rp" "$lp"; chmod +x "$lp" 2>/dev/null || true; }
done done
echo "$fromver" > "$VERFILE" echo "$fromver" > "$VERFILE"
systemctl restart phobos-panel 2>/dev/null || true run_self_check
restart_panel_service
refresh_router_packages
echo "Откат выполнен -> $fromver" echo "Откат выполнен -> $fromver"
;; ;;
*) *)
ref="${1:-main}"; [ "$ref" = "latest" ] && ref="main" ref="${1:-main}"
target=$(curl -fsSL -m10 "$RAW/$ref/VERSION" 2>/dev/null | tr -d ' \r\n'); [ -z "$target" ] && target="$ref" case "$ref" in latest|stable) ref="main";; esac
# Каналы beta/dev — приватный репозиторий, нужен токен подписчика
if is_private "$ref" && [ -z "$GH_TOKEN" ]; then
echo "Канал '$ref' закрыт (бета/разработка) — приватный репозиторий."
echo "Нужен read-only токен подписчика:"
echo " GH_TOKEN=ваш_токен phobos-update $ref"
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
exit 1
fi
if ! ref_exists "$ref"; then
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
exit 1
fi
# сохранить токен для будущих обновлений приватного канала
if is_private "$ref" && [ -n "$GH_TOKEN" ]; then
( umask 077; printf %s "$GH_TOKEN" > "$PANEL_DIR/.gh_token" )
fi
repo_ref="$ref"
# run the TARGET version's updater: its file list knows the new files
if [ -z "${PHOBOS_UPDATE_REEXEC:-}" ]; then
_new=$(mktemp)
if fetch_repo_file "$ref" update.sh "$_new" && head -1 "$_new" | grep -q '^#!' && ! cmp -s "$_new" "$0"; then
chmod +x "$_new"
PHOBOS_UPDATE_REEXEC=1 GH_TOKEN="$GH_TOKEN" exec bash "$_new" "$@"
fi
rm -f "$_new"
fi
target=$(fetch_repo_text "$ref" VERSION 2>/dev/null | tr -d ' \r\n'); [ -z "$target" ] && target="$ref"
echo "Текущая: $CUR -> целевая: $target ($ref)" echo "Текущая: $CUR -> целевая: $target ($ref)"
b=$(do_backup); echo "Бэкап: $b" b=$(do_backup); echo "Бэкап: $b"
apply_ref "$ref" apply_ref "$ref"
ensure_runtime_deps
run_self_check
ensure_tcp_fallback "$ref"
refresh_router_packages
echo "$target" > "$VERFILE" echo "$target" > "$VERFILE"
systemctl restart phobos-panel 2>/dev/null || true restart_panel_service
echo "Обновлено до $target. Откат: phobos-update --rollback" echo "Обновлено до $target. Откат: phobos-update --rollback"
;; ;;
esac esac