63 Commits
v1.2.4 ... main

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

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

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

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

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

3
.gitignore vendored Normal file
View File

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

3
APPS Normal file
View File

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

11
BETA.md Normal file
View File

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

180
CHANGELOG.md Normal file
View File

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

176
README.md
View File

@@ -17,7 +17,8 @@
- 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен. - 📡 **Работает за любым NAT** — серый IP, мобильный интернет, домашний роутер за провайдером. Управление едет внутри самого защищённого туннеля, поэтому бан публичного IP ему не страшен.
- 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово. - 🖥️ **Одна команда — новое устройство** — создал клиента в панели → вставил команду на роутер → готово.
- ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама. - ♻️ **Само-восстановление** — роутер перезагрузился, провайдер сменил IP, сервис отвалился → система поднимет всё обратно сама.
- 📱 **Телефоны** — Android (PhobosWG, с обфускацией) и iPhone (обычный WireGuard) через QR-код. - 📱 **Телефоны и ПК** — свои приложения **PCA Phobos VPN**: Android (APK) и Windows (по токену подписчика). Плюс PhobosWG и обычный WireGuard — через QR/ссылку. Приложения для iPhone и macOS — в каналах beta/dev.
- 🛠 **Админ-приложение** PCA Phobos для Android — все ваши панели в одном месте.
--- ---
@@ -25,14 +26,30 @@
- **Активные сессии** — 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 / Windows / Router / HR (+ обычный WireGuard для iPhone).
- **Метки** — имя объекта по 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), обфускация сохраняется.
- **Канал «Облако»** — если сервер заблокирован целиком: телефон идёт через облако KeenDNS своего Keenetic. [Инструкция](docs/cloud.md).
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
- **Новый дизайн (с 1.5.0 во всех каналах)** — боковое меню, «Сессии» с графиками CPU/RAM/скорости, кольцо трафика по клиентам; клиенты — таблица и окна «Правка», «Подключение» (Android / iOS / Windows / Router / **HR** для HydraRoute), «Роутер» (SSH / RCI / TTYD).
- **Пресеты обфускатора** — свой набор портов и ключ на группу клиентов; **firewall по клиенту** (🛡 — только разрешённые адреса).
- **Трафик за месяц** по каждому клиенту (сумма по всем серверам), история по месяцам.
- **DNS для клиентов** — задаётся в «Настройках».
- **Автопереход роутера (1.5.0)** — при сбое сторож перебирает все порты и все адреса сервера (второй IP, домены), затем TCP; на основной возвращается сам, если там снова есть рукопожатие.
- **Второй IP сервера** (`phobos-altip`) — если провайдер клиента режет основной адрес, роутер уходит на второй.
- **Несколько учёток на одном роутере**, **IPv6 для телефонов**, **ссылка для HydraRoute** (строго по формату автора Phobos).
- **Версии и обновления** — обновление и откат прямо из панели. - **Версии и обновления** — обновление и откат прямо из панели.
- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении. - **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
- **Telegram-бот** — управление клиентами командами прямо из чата: - **Telegram-бот** — управление клиентами командами прямо из чата:
@@ -40,92 +57,138 @@
- `/del <имя>` — удалить клиента - `/del <имя>` — удалить клиента
- `/list` — список клиентов - `/list` — список клиентов
- **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь. - **Кнопки** — постоянная клавиатура: ➕ Создать клиента · 📋 Список · 🗑 Удалить (инлайн-выбор) · 📊 Статус · ❓ Помощь.
(Работает только для чата из настроек `tg_chat_id`.) (Работает только для чата из настроек `tg_chat_id`.) Бот **необязателен**: хочешь — впиши токен от @BotFather и свой chat id в «Настройки», не хочешь — оставь пустым.
--- ---
## Скриншоты ## Скриншоты
| Сессии | Клиенты | Серверы | | Сессии (светлая) | Сессии (тёмная) |
|--------|---------|---------| |---|---|
| ![Сессии](docs/img/sessions.png) | ![Клиенты](docs/img/clients.png) | ![Серверы](docs/img/servers.png) | | ![Сессии](docs/img/sessions.png) | ![Сессии тёмная](docs/img/sessions-dark.png) |
| **Клиенты** | **Серверы: второй сервер, домен для клиентов** |
| ![Клиенты](docs/img/clients.png) | ![Серверы](docs/img/servers.png) |
> Если картинки не отображаются — положи свои PNG в `docs/img/` (`sessions.png`, `clients.png`, `servers.png`). | Телефон: клиенты | Телефон: тёмная | Телефон: серверы |
|---|---|---|
| ![](docs/img/mobile-clients.png) | ![](docs/img/mobile-clients-dark.png) | ![](docs/img/mobile-servers.png) |
Настройки (пароль, домен, каскад, DNS, сторож, обновления, WARP, бэкап): [светлая](docs/img/settings.png) · [тёмная](docs/img/settings-dark.png).
Android-приложения: [VPN-клиент](docs/img/android-vpn.png) · [раздельный трафик](docs/img/android-vpn-apps.png) · [админ](docs/img/android-admin.png).
---|---|
| ![Клиенты](docs/img/clients.png) | ![Клиенты тёмная](docs/img/clients-dark.png) |
| **Сессии** | **Серверы: замер скорости, второй сервер** |
| ![Сессии](docs/img/sessions.png) | ![Серверы](docs/img/servers.png) |
| Телефон: панель | Телефон: тёмная | 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)
--- ---
> **Откуда ставится (с 11.11.2026):** основной источник — GitHub [`adleibzon-rgb/PCA_Phobos`](https://github.com/adleibzon-rgb/PCA_Phobos), запасной — зеркало `git.bobyrev.com/PCA/PCA_Phobos` (обновлятор сам переходит на него, если GitHub недоступен). Старый аккаунт `andrey271192` заблокирован — ссылки на него не работают.
> Серверы на старых версиях (1.2.x–1.4.x) обновляются одной командой:
> ```bash
> bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/update.sh)
> ```
> Если GitHub с сервера не открывается — то же с зеркала: `bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/update.sh)`
## Быстрый старт (с чистого VPS, одной командой) ## Быстрый старт (с чистого 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://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/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://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/install.sh)
``` ```
| Переменная | По умолчанию | Описание | | Переменная | По умолчанию | Описание |
|-------------------|-----------------|----------| |-------------------|-----------------|----------|
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) | | `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
| `PANEL_PORT` | случайный | Порт веб-панели | | `PANEL_PORT` | случайный | Порт веб-панели |
| `SERVER_WG_IPV4_NETWORK` | `10.25.0.0/16` | Подсеть VPN-клиентов (например `10.25.1.0/24`). Без переменной установщик спросит в терминале. Сервер получает первый адрес (`10.25.1.1`). Задаётся только при установке |
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) | | `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
| `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 (без обфускации) |
| `CHANNEL` | `stable` | канал: `stable` (ветка main) или `beta` | | `PANEL_TLS_CERT` / `PANEL_TLS_KEY` | пусто | Включить HTTPS для панели через свой сертификат |
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их. После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
> **Канал:** по умолчанию ставится **стабильная** версия (ветка `main`). Бета — по желанию: Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
> ```bash DNS клиентов и свой AdGuard Home: [docs/DNS.md](docs/DNS.md). WARP — проверка и проблемы: [docs/WARP.md](docs/WARP.md).
> CHANNEL=beta bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
> ```
> Переключение после установки: `phobos-update beta` / `phobos-update main`.
### Вторичный сервер (для 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://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/server/secondary-setup.sh)
``` ```
Затем в панели → **Серверы** → добавь его в список. Сервер сам зарегистрируется в панели, клиенты досылаются на него сразу. Если сервер за NAT — добавь `SERVER_IP=<внешний_ip>`.
### Каскад (вход в РФ → выход за рубежом)
На входном VPS (чистый Debian/Ubuntu, Phobos не нужен):
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/server/phobos-cascade.sh) enable <IP_выходного_сервера> 2083,5443,993
```
Затем в панели выходного сервера → **Настройки → Адрес для клиентов и каскад** → впиши IP/домен входа. Роутеры переключатся сами (~15 с), телефонам — заново открыть ссылку/QR. Отключить: `phobos-cascade disable`.
### Сторонние «сторожа» по просьбам участников
- Защита от зондов РКН ([Loorrr293/Auto_IPtables](https://github.com/Loorrr293/Auto_IPtables)) — **встроена**: «Настройки → Защита сервера», включается кнопкой.
- «Прикрывашка» ([miha75vu-bit/Flashkeen](https://github.com/miha75vu-bit/Flashkeen)) — WARP на выход + Psiphon; интерактивная, ставится по SSH: `bash <(curl -fsSL https://raw.githubusercontent.com/miha75vu-bit/Flashkeen/main/wp)`. Перестраивает выход wg0 — не включай одновременно со встроенным WARP egress.
- ipidea-kimwolf-ban — репозиторий автора удалён (404), встроить нечего.
--- ---
## Клиенты для телефона ## Приложения для Android
В таблице **Клиенты** у каждого клиента есть кнопки: - **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.10/PCA-Phobos-VPN.apk) · исходники `android-client/`.
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.4/PCA-Phobos.apk) · исходники `android/`.
- 📱 **Android** — `phobos://`-ссылка + QR для приложения **PhobosWG** (с обфускацией). Скан QR → импорт. ## Подключение устройств
- 🍎 **iPhone / iOS** — обычный WireGuard-конфиг + QR для официального WireGuard (**без** обфускации). Требует открытого порта 51820 (`ALLOW_PLAIN_WG=1` при установке).
Клиенты → у клиента окно **«Подключение»** с вкладками:
- **Android** — `phobos://`-ссылка + QR для **PCA Phobos VPN** или **PhobosWG** (с обфускацией), файл `.conf`.
- **Windows** — ссылка `phobos://` и кнопка скачивания **PCA Phobos для Windows** (архив из закрытого релиза, нужен токен подписчика в «Настройках»).
- **Router** — команда установки на Keenetic/OpenWrt (Entware) и команда удаления.
- **HR** — ссылка для HydraRoute Neo строго по формату автора Phobos.
- 🍎 **iPhone** в stable — только обычный WireGuard (без обфускации, нужен порт 51820, `ALLOW_PLAIN_WG=1`). Приложения **PCA Phobos VPN для iPhone и macOS** (с обфускацией, автопереход) — в каналах **beta/dev** для подписчиков Boosty.
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей. > Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.
**Приложения:**
- **Android (PhobosWG):** автор пока не публикует APK официально. Положи `.apk` на сервер в `/opt/Phobos/www/app/PhobosWG.apk` — панель сама покажет кнопку «Скачать приложение PhobosWG» на странице Android-клиента (раздаётся по `http://<сервер>/app/PhobosWG.apk`).
- **iPhone (iOS):** официальный [WireGuard из App Store](https://apps.apple.com/app/wireguard/id1441195209) — кнопка есть на странице iOS-клиента.
--- ---
## Обновление и откат ## Обновление и откат
Проект версионируется (см. файл `VERSION` и git-теги `vX.Y.Z`). Проект версионируется (см. файл `VERSION` и git-теги `vX.Y.Z`).
**Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**. **Из панели:** Настройки → «Версия и обновления» → **Обновить** / **Откатить**. С 1.4.11 панель сама раз в час проверяет свой канал: вышла новая версия — сверху зелёная полоса «Доступна новая версия N» с кнопкой **Обновить** (в окне — что нового, бэкап, откат). Там же видно, если вышли новые версии приложений. В меню под «Настройками» в это время горит зелёный пункт **«Обновить до N»**.
> Панели до 1.4.11 этой полосы не показывают — один раз обнови вручную (`phobos-update`), дальше она появляется сама.
**Из консоли:** **Из консоли:**
```bash ```bash
phobos-update # обновить до последней версии phobos-update # обновить до последней версии (stable)
phobos-update beta|dev # каналы для подписчиков Boosty (токен сохраняется в /opt/phobos-panel/.gh_token)
phobos-update v1.1.0 # обновить до конкретной версии (git tag) phobos-update v1.1.0 # обновить до конкретной версии (git tag)
phobos-update --rollback # откатить на предыдущую версию phobos-update --rollback # откатить на предыдущую версию
phobos-update --version # показать установленную и доступную версию phobos-update --check # установленная версия и версии всех каналов
phobos-update --list # список бэкапов phobos-update --list # список бэкапов
``` ```
@@ -133,11 +196,53 @@ phobos-update --list # список бэкапов
--- ---
## Пароль панели — сменить или вспомнить
- **Сменить из панели:** «Настройки» → поле **«Пароль панели»** → новый пароль → «Сохранить». Пустое поле — пароль не меняется. Логин всегда `admin`.
- **Вспомнить:** `grep admin_pass /opt/phobos-panel/settings.json`
- **Сменить без входа (из консоли сервера):**
```bash
python3 - <<'EOF'
import json; p="/opt/phobos-panel/settings.json"; s=json.load(open(p))
s["admin_pass"]="НовыйПароль"; json.dump(s, open(p,"w"), ensure_ascii=False, indent=2)
EOF
systemctl restart phobos-panel
```
- Пароль по умолчанию (если не задавал `PANEL_PASS` при установке): `OcAdmin2026!` — смени сразу после установки.
- Порт панели: `cat /opt/phobos-panel/.port` · API key: `grep server_api_key /opt/phobos-panel/settings.json`.
---
## Удаление
**Сервер целиком** (панель, обфускатор, WireGuard `wg0`, nginx-сайт, сторож, правила iptables, каталоги Phobos):
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/uninstall.sh)
```
Спросит подтверждение — ответь `yes` (или `y` / `да`; до 1.4.6 работал только полный `yes`). Без вопросов: `PURGE=1 bash <(curl -fsSL …/uninstall.sh)`. Перед удалением сохрани бэкап из панели («Настройки» → «Бэкап»), если сервер понадобится снова. Клиенты на других серверах при удалении одного сервера продолжат работать (если есть резервный).
**Один клиент** — в панели «Клиенты» → 🗑 у клиента: ключ отзывается на всех серверах, роутер/телефон больше не подключится.
**С роутера Keenetic** (по SSH в Entware) — команда есть и в панели, внизу окна установки роутера («Удалить с роутера»):
```bash
/opt/etc/Phobos/phobos-uninstall.sh
```
Скрипт останавливает обфускатор, удаляет его конфиги и сам удаляет интерфейс WireGuard Phobos на роутере.
**Телефон / Windows:** удалить туннель в приложении и само приложение; в Windows — `uninstall.cmd` из папки программы.
---
## Возможные проблемы и решения ## Возможные проблемы и решения
| Симптом | Причина | Решение | | Симптом | Причина | Решение |
|---------|---------|---------| |---------|---------|---------|
| **Команда установки → 403 / не качается** | репозиторий приватный | Сделай репозиторий публичным, либо ставь с токеном: `GH_TOKEN=ghp_xxx bash <(curl -fsSL -H "Authorization: token ghp_xxx" .../install.sh)` | | **Команда установки → 403 / 404 / не качается** | старая ссылка (`andrey271192` заблокирован) или GitHub недоступен с сервера | Ссылки только `github.com/adleibzon-rgb/…`; если GitHub не открывается — ставь с зеркала `git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install.sh` |
| **Удаление: «Отменено»** | старая версия uninstall.sh понимала только `yes` | Набери `yes` полностью или `PURGE=1` |
| **Панель не открывается** | панель перезапускалась (обновление) / сервер недоступен | Подожди 5 сек, обнови страницу. Проверь: `systemctl status phobos-panel` | | **Панель не открывается** | панель перезапускалась (обновление) / сервер недоступен | Подожди 5 сек, обнови страницу. Проверь: `systemctl status phobos-panel` |
| **Клиент Offline, хотя работает** | роутер на резервном сервере | Норма — статус считается по handshake на **любом** сервере; обнови страницу | | **Клиент Offline, хотя работает** | роутер на резервном сервере | Норма — статус считается по handshake на **любом** сервере; обнови страницу |
| **«SSH timeout» при тесте роутера** | роутер за NAT, входящий SSH закрыт | Это нормально — управление идёт по pull-каналу через туннель, SSH не нужен | | **«SSH timeout» при тесте роутера** | роутер за NAT, входящий SSH закрыт | Это нормально — управление идёт по pull-каналу через туннель, SSH не нужен |
@@ -146,7 +251,11 @@ phobos-update --list # список бэкапов
| **iPhone не подключается по `phobos://`** | на iOS нет приложения-обфускатора | Используй 🍎-конфиг (обычный WireGuard) + открой 51820 (`ALLOW_PLAIN_WG=1`) | | **iPhone не подключается по `phobos://`** | на iOS нет приложения-обфускатора | Используй 🍎-конфиг (обычный WireGuard) + открой 51820 (`ALLOW_PLAIN_WG=1`) |
| **Смена сервера применяется не сразу** | роутер тянет конфиг по 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`.
@@ -187,3 +296,6 @@ PCA Phobos — это веб-панель и turnkey-инсталлятор по
- [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) — обфускация WireGuard-трафика (GPL-3.0), автор **ClusterM**. Поддержать: [Boosty](https://boosty.to/cluster) ❤️ - [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) — обфускация WireGuard-трафика (GPL-3.0), автор **ClusterM**. Поддержать: [Boosty](https://boosty.to/cluster) ❤️
Спасибо авторам за отличные инструменты. Спасибо авторам за отличные инструменты.
## Смена сервера у клиента
См. [docs/SERVER-SWITCH.md](docs/SERVER-SWITCH.md): какие приложения переключаются сами (роутер, iOS, Android, Windows) и что делать в остальных.

View File

@@ -1 +1 @@
1.2.4 1.5.0

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

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

View File

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

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

View File

@@ -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,809 @@
package com.pca.phobos.vpn;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ClipboardManager;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.net.VpnService;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import com.wireguard.android.backend.GoBackend;
import com.wireguard.android.backend.Statistics;
import com.wireguard.android.backend.Tunnel;
import com.wireguard.config.Config;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* PCA Phobos VPN — client: imports a phobos:// link from the panel, runs the
* bundled wg-obfuscator + userspace WireGuard, split tunnelling by apps.
* The app itself is always outside the tunnel so the obfuscator reaches the server directly.
*/
public class MainActivity extends Activity {
static final int LPORT = 13255;
static final String ACTION_TOGGLE = "com.pca.phobos.vpn.TOGGLE";
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
static GoBackend backend;
static Process obfuscator, relay;
static final int TCP_LPORT = 13256;
static volatile String transport = ""; // "UDP" | "TCP" | "Облако" while up
static WsRelay wsRelay;
static V6Relay v6Relay;
static final int V6_LPORT = 13257;
static final Object LOCK = new Object();
/** Panel subscription (# pca-sub): servers in the panel's order, IPv6 entries last; failover walks them round-robin. */
static org.json.JSONArray sub = new org.json.JSONArray();
static volatile int subIndex;
static volatile String curConf = ""; // profile pointed at the server in use
static volatile long watchGen; // bumped on connect/disconnect: stops the old watcher
static final long SILENCE_MS = 40000; // no server reply this long -> next server
static final Tunnel TUNNEL = new Tunnel() {
public String getName() { return "phobos"; }
public void onStateChange(Tunnel.State s) { }
};
private final ExecutorService bg = Executors.newSingleThreadExecutor();
private final Handler ui = new Handler(Looper.getMainLooper());
private SharedPreferences prefs;
private boolean dark;
private TextView status, traffic, server;
private Button power;
private long lastRx, lastTx, lastT;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
if (backend == null) backend = new GoBackend(getApplicationContext());
if (android.os.Build.VERSION.SDK_INT >= 33 && checkSelfPermission("android.permission.POST_NOTIFICATIONS") != PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{"android.permission.POST_NOTIFICATIONS"}, 8);
}
handleIntent(getIntent());
render();
}
@Override
protected void onNewIntent(Intent i) {
super.onNewIntent(i);
handleIntent(i);
render();
}
private void handleIntent(Intent i) {
if (i != null && ACTION_TOGGLE.equals(i.getAction())) {
setIntent(new Intent());
// tile: toggle and get out of the way, unless Android must ask for the VPN permission first
if (!isUp() && !prefs.getString("conf", "").isEmpty() && VpnService.prepare(this) == null) { bg.execute(this::up); finish(); return; }
if (isUp()) { bg.execute(this::down); finish(); return; }
toggle();
return;
}
if (i != null && i.getData() != null && "phobos".equals(i.getData().getScheme())) {
importLink(i.getDataString());
}
}
// ── colors / widgets ──────────────────────────────────────────────────
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
private GradientDrawable round(int color, int r) {
GradientDrawable g = new GradientDrawable();
g.setColor(color);
g.setCornerRadius(dp(r));
return g;
}
private Button button(String label, int color, View.OnClickListener l) {
Button b = new Button(this);
b.setText(label);
b.setAllCaps(false);
b.setTextColor(Color.WHITE);
b.setBackground(round(color, 10));
b.setOnClickListener(l);
return b;
}
private TextView tv(String s, int color, int size, boolean bold) {
TextView t = new TextView(this);
t.setText(s);
t.setTextColor(color);
t.setTextSize(size);
if (bold) t.setTypeface(Typeface.DEFAULT_BOLD);
return t;
}
private LinearLayout.LayoutParams mw(int top) {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
lp.topMargin = dp(top);
return lp;
}
// ── profile ───────────────────────────────────────────────────────────
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
private void importLink(String link) { importText(link, "Phobos"); }
/** A phobos:// link, or the text of a client .conf ([Interface] + [Peer] + [instance]). */
private void importText(String text, String name) {
try {
String s = text.replace("\uFEFF", "").trim(), conf = s;
if (s.startsWith("phobos://")) {
int hash = s.indexOf('#');
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
s = s.replaceFirst("^phobos://", "").trim();
conf = new String(Base64.decode(s, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING), StandardCharsets.UTF_8);
}
if (!conf.contains("[Interface]") || !conf.contains("[instance]"))
throw new IllegalArgumentException("нужен конфиг Phobos: секции [Interface], [Peer] и [instance]");
Config.parse(new BufferedReader(new StringReader(wgPart(conf, MODE_ALL, "", false)))); // validate
prefs.edit().putString("conf", conf).putString("name", name).apply();
toast("Профиль «" + name + "» импортирован");
} catch (Exception e) {
toast("Не удалось импортировать: " + e.getMessage());
}
}
/** Split payload into sections: header -> lines (drop "= none" placeholders). */
private static List<String> section(String conf, String header) {
List<String> out = new ArrayList<>();
boolean in = false;
for (String raw : conf.split("\n")) {
String ln = raw.trim();
if (ln.startsWith("[") && ln.endsWith("]")) { in = ln.equalsIgnoreCase(header); continue; }
if (!in || ln.isEmpty() || !ln.contains("=")) continue;
if (ln.substring(ln.indexOf('=') + 1).trim().equalsIgnoreCase("none")) continue;
out.add(ln);
}
return out;
}
private static String key(String line) { return line.substring(0, line.indexOf('=')).trim().toLowerCase(); }
private static String val(String line) { return line.substring(line.indexOf('=') + 1).trim(); }
String wgPart(String conf, String mode, String apps, boolean ruRoutes) {
StringBuilder sb = new StringBuilder("[Interface]\n");
for (String ln : section(conf, "[Interface]")) {
String k = key(ln);
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
if (k.equals("dns") && !myDns().isEmpty()) continue;
sb.append(ln).append('\n');
}
if (!myDns().isEmpty()) sb.append("DNS = ").append(myDns()).append('\n');
List<String> sel = new ArrayList<>();
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
sel.remove(getPackageNameSafe());
sb.append("IncludedApplications = ").append(String.join(", ", sel)).append('\n');
} else {
Set<String> ex = new LinkedHashSet<>();
ex.add(getPackageNameSafe()); // obfuscator must bypass the tunnel
if (MODE_EXCEPT.equals(mode)) ex.addAll(sel);
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
}
sb.append("\n[Peer]\n");
String ruBypass = ruRoutes && prefs.getBoolean("ru_direct", true) ? ruBypassAllowedIps() : null;
for (String ln : section(conf, "[Peer]")) {
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
if (key(ln).equals("allowedips") && ruBypass != null) ln = "AllowedIPs = " + ruBypass;
sb.append(ln).append('\n');
}
return sb.toString();
}
/** Internet minus Russian networks (assets/ru_nets.txt) and the user's own sites; resolves domains, so not on the UI thread. */
private String ruBypassAllowedIps() {
List<String> ru = new ArrayList<>();
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(
getAssets().open("ru_nets.txt"), StandardCharsets.UTF_8))) {
for (String ln; (ln = r.readLine()) != null; ) {
if (!ln.startsWith("#") && ln.contains("/")) for (String c : ln.split(",")) ru.add(c.trim());
}
} catch (Exception e) { log("ru_nets: " + e); }
return RuNets.allowed(ru, lines(prefs.getString("my_direct", "")), lines(prefs.getString("my_vpn", "")));
}
private static List<String> lines(String s) {
List<String> out = new ArrayList<>();
for (String l : s.split("[\\n,\\s]+")) if (!l.trim().isEmpty()) out.add(l.trim());
return out;
}
/** User's own DNS servers ("1.1.1.1, 8.8.8.8"); empty = the profile's. */
private String myDns() { return prefs.getString("dns", ""); }
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
private static final java.util.Set<String> OBF_KEYS = new java.util.HashSet<>(java.util.Arrays.asList(
"target", "key", "masking", "idle-timeout", "max-dummy", "static-bindings", "max-clients", "target-if", "allow-clean", "in-timeout", "resolve-interval"));
private String obfPart(String conf, String targetOverride) {
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
for (String ln : section(conf, "[instance]")) {
String k = key(ln);
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
// configs from other Phobos builds carry keys our wg-obfuscator v1.6 rejects (obfuscate-bytes…)
if (!OBF_KEYS.contains(k)) continue;
if (targetOverride != null && k.equals("target")) ln = "target = " + targetOverride;
sb.append(ln).append('\n');
}
sb.append("verbose = INFO\n");
return sb.toString();
}
/** "# pca-tcp = 443" written by the panel when the server has the TCP fallback. */
private static String tcpPort(String conf) {
for (String ln : conf.split("\n")) {
String t = ln.trim();
if (t.startsWith("#") && t.contains("pca-tcp") && t.contains("=")) return val(t);
}
return "";
}
/** wss:// of the user's Keenetic (KeenDNS cloud subdomain -> wsgw): set in the app, or "# pca-ws = wss://…" in the link. */
private String wsUrl(String conf) {
String u = prefs.getString("ws_url", "");
if (!u.isEmpty()) return u;
for (String ln : conf.split("\n")) {
String t = ln.trim();
if (t.startsWith("#") && t.contains("pca-ws") && t.contains("=")) return val(t);
}
return "";
}
/** "# pca-sub = http://panel/api/phone-sub/<client>?token=…" */
private static String subUrl(String conf) {
for (String ln : conf.split("\n")) {
String t = ln.trim();
if (t.startsWith("#") && t.contains("pca-sub") && t.contains("=")) return val(t);
}
return "";
}
/** Same profile pointed at another subscription server (address, obfuscator key, server WG key, TCP port). */
static String applying(String conf, org.json.JSONObject s) {
String host = s.optString("host"), sec = "";
if (host.contains(":")) host = "[" + host + "]";
StringBuilder sb = new StringBuilder();
for (String raw : conf.split("\n", -1)) {
String ln = raw.trim();
if (ln.startsWith("[") && ln.endsWith("]")) sec = ln.toLowerCase();
else if (ln.startsWith("#")) { if (ln.contains("pca-tcp")) continue; }
else if (ln.contains("=")) {
String k = key(ln);
if (sec.equals("[instance]") && k.equals("target")) { sb.append("target = ").append(host).append(':').append(s.optInt("port")).append('\n'); continue; }
if (sec.equals("[instance]") && k.equals("key")) { sb.append("key = ").append(s.optString("key")).append('\n'); continue; }
if (sec.equals("[peer]") && k.equals("publickey")) { sb.append("PublicKey = ").append(s.optString("wg_pub")).append('\n'); continue; }
}
sb.append(raw).append('\n');
}
if (s.optInt("tcp") != 0) sb.append("# pca-tcp = ").append(s.optInt("tcp")).append('\n');
return sb.toString();
}
/** Pull the server list from the panel (the app is outside the tunnel); cached when the panel is unreachable. */
private void refreshSub(String conf) {
String u = subUrl(conf);
if (u.isEmpty()) { sub = new org.json.JSONArray(); return; }
try {
java.net.HttpURLConnection c = (java.net.HttpURLConnection) new java.net.URL(u).openConnection();
c.setConnectTimeout(6000); c.setReadTimeout(6000);
try (java.io.InputStream in = c.getInputStream()) {
String body = new String(readAll(in), StandardCharsets.UTF_8);
org.json.JSONArray a = new org.json.JSONObject(body).getJSONArray("servers");
if (a.length() > 0) prefs.edit().putString("sub", a.toString()).apply();
}
} catch (Exception e) { log("sub: " + e); }
try { sub = new org.json.JSONArray(prefs.getString("sub", "[]")); } catch (Exception e) { sub = new org.json.JSONArray(); }
}
private static byte[] readAll(java.io.InputStream in) throws java.io.IOException {
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
for (int n; (n = in.read(buf)) > 0; ) o.write(buf, 0, n);
return o.toByteArray();
}
private String target(String conf) {
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
return "?";
}
// ── connect / disconnect ──────────────────────────────────────────────
private boolean isUp() { return tunnelUp(); }
static boolean tunnelUp() {
try { return backend != null && backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
}
/** Ongoing notification = shield in the status bar while the VPN is on (GoBackend's service has none). */
private void notifyState(boolean up) {
android.app.NotificationManager nm = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (nm == null) return;
if (!up) { nm.cancel(1); PhobosTile.refresh(this); return; }
if (android.os.Build.VERSION.SDK_INT >= 26) {
nm.createNotificationChannel(new android.app.NotificationChannel("vpn", "VPN", android.app.NotificationManager.IMPORTANCE_LOW));
}
android.app.PendingIntent open = android.app.PendingIntent.getActivity(this, 0,
new Intent(this, MainActivity.class), android.app.PendingIntent.FLAG_IMMUTABLE);
android.app.Notification.Builder b = android.os.Build.VERSION.SDK_INT >= 26
? new android.app.Notification.Builder(this, "vpn") : new android.app.Notification.Builder(this);
b.setSmallIcon(R.drawable.ic_vpn).setContentTitle("VPN")
.setContentText("Сервер: " + target(curConf.isEmpty() ? prefs.getString("conf", "") : curConf))
.setOngoing(true).setContentIntent(open);
try { nm.notify(1, b.build()); } catch (SecurityException ignored) { }
PhobosTile.refresh(this);
}
private void toggle() {
if (isUp()) { bg.execute(this::down); return; }
if (prefs.getString("conf", "").isEmpty()) { toast("Сначала импортируйте ссылку phobos:// из панели"); return; }
Intent prep = VpnService.prepare(this);
if (prep != null) startActivityForResult(prep, 7);
else bg.execute(this::up);
}
@Override
protected void onActivityResult(int req, int res, Intent data) {
if (req == 9) {
if (res != RESULT_OK || data == null || data.getData() == null) return;
String name = "Phobos";
try (android.database.Cursor c = getContentResolver().query(data.getData(), null, null, null, null)) {
int i = c == null ? -1 : c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME);
if (i >= 0 && c.moveToFirst()) name = c.getString(i).replaceFirst("\\.[^.]*$", "");
} catch (Exception ignored) { }
try (java.io.InputStream in = getContentResolver().openInputStream(data.getData())) {
importText(new String(readAll(in), StandardCharsets.UTF_8), name);
} catch (Exception e) { toast("Не удалось прочитать файл: " + e.getMessage()); }
applyIfUp();
render();
return;
}
if (req == 7 && res == RESULT_OK) bg.execute(this::up);
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
}
/** Start (or restart) obfuscator; via TCP / cloud it dials the local relay on TCP_LPORT. */
private void startTransport(String conf, String via) throws Exception {
stopObfuscator();
String lib = getApplicationInfo().nativeLibraryDir;
boolean viaTcp = "tcp".equals(via), viaWs = "cloud".equals(via);
if (viaWs) {
if (wsUrl(conf).isEmpty()) throw new IllegalStateException("не задан адрес роутера (wss://…) для канала «Облако»");
wsRelay = new WsRelay(TCP_LPORT, wsUrl(conf), this::log);
wsRelay.start();
}
String target = target(conf), host = target.replaceAll(":\\d+$", "").replaceAll("[\\[\\]]", "");
String override = viaTcp || viaWs ? "127.0.0.1:" + TCP_LPORT : null;
if (!viaTcp && !viaWs && host.contains(":")) {
v6Relay = new V6Relay(V6_LPORT, host, Integer.parseInt(target.replaceAll("^.*:", "")));
v6Relay.start();
override = "127.0.0.1:" + V6_LPORT;
}
if (viaTcp) {
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
}
File cfg = new File(getFilesDir(), "obf.conf");
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, override).getBytes(StandardCharsets.UTF_8)); }
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
Thread.sleep(600);
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
transport = viaWs ? "Облако" : viaTcp ? "TCP" : "UDP";
log("transport " + transport + " -> " + (viaWs ? wsUrl(conf) : target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : "")));
}
private void up() {
synchronized (LOCK) {
try {
String base = prefs.getString("conf", "");
refreshSub(base);
subIndex = 0;
connect(sub.length() > 0 ? applying(base, sub.getJSONObject(0)) : base);
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", servers " + Math.max(1, sub.length()));
notifyState(true);
post("Подключено (" + transport + ")");
if (sub.length() > 1 && !"cloud".equals(prefs.getString("transport", "auto"))) watchServer(base);
} catch (Throwable e) {
stopObfuscator();
log("up failed: " + e);
post("Ошибка: " + e.getMessage());
}
}
}
/** Obfuscator (+ relay) and WireGuard for one server; called again in place on failover. */
private void connect(String conf) throws Exception {
String mode = prefs.getString("transport", "auto");
boolean tcpAvail = !tcpPort(conf).isEmpty();
if ("tcp".equals(mode) && !tcpAvail && sub.length() == 0) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
startTransport(conf, "tcp".equals(mode) && !tcpAvail ? "udp" : "auto".equals(mode) ? "udp" : mode);
Config c = Config.parse(new BufferedReader(new StringReader(
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", ""), true))));
backend.setState(TUNNEL, Tunnel.State.UP, c);
curConf = conf;
if ("auto".equals(mode) && tcpAvail) watchUdp(conf);
}
/** Server answered nothing for SILENCE_MS -> next subscription server. Runs while the VPN service keeps the process alive. */
private void watchServer(String base) {
final long gen = ++watchGen;
new Thread(() -> {
long lastRx = -1, since = System.currentTimeMillis();
while (gen == watchGen) {
try { Thread.sleep(5000); } catch (InterruptedException e) { return; }
if (gen != watchGen || !isUp()) return;
long rx;
try { rx = backend.getStatistics(TUNNEL).totalRx(); } catch (Exception e) { continue; }
long now = System.currentTimeMillis();
if (rx != lastRx) { lastRx = rx; since = now; continue; }
if (now - since < SILENCE_MS) continue;
since = now;
synchronized (LOCK) {
if (gen != watchGen) return;
try {
subIndex = (subIndex + 1) % sub.length();
org.json.JSONObject s = sub.getJSONObject(subIndex);
log("server silent -> " + s.optString("host") + ":" + s.optInt("port"));
connect(applying(base, s));
notifyState(true);
post("Сервер не отвечал — перешёл на " + target(curConf));
} catch (Throwable e) { log("failover: " + e); }
}
}
}, "phobos-watch").start();
}
/** Auto: no bytes back over UDP within 15 s -> UDP is cut, move the obfuscator to TCP. */
private void watchUdp(String conf) {
new Thread(() -> {
try {
long rx0 = backend.getStatistics(TUNNEL).totalRx();
Thread.sleep(15000);
if (!isUp() || !"UDP".equals(transport) || !conf.equals(curConf)) return;
if (backend.getStatistics(TUNNEL).totalRx() > rx0) return;
log("UDP: no answer in 15 s -> switching to TCP");
startTransport(conf, "tcp");
post("UDP не отвечает — переключился на TCP");
} catch (Throwable e) { log("watchUdp: " + e); }
}).start();
}
private void down() {
watchGen++;
curConf = "";
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
stopObfuscator();
transport = "";
notifyState(false);
post("Отключено");
}
private static void stopObfuscator() {
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
if (relay != null) { relay.destroy(); relay = null; }
if (wsRelay != null) { wsRelay.stop(); wsRelay = null; }
if (v6Relay != null) { v6Relay.stop(); v6Relay = null; }
}
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
// ── UI ────────────────────────────────────────────────────────────────
private void render() {
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(bg());
LinearLayout h = new LinearLayout(this);
h.setBackgroundColor(0xFF1E1B4B);
h.setPadding(dp(16), dp(14), dp(16), dp(14));
h.addView(tv("🛡 PCA Phobos VPN", 0xFFC7D2FE, 18, true));
root.addView(h);
ScrollView sv = new ScrollView(this);
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(16), dp(16), dp(16), dp(24));
sv.addView(box);
String conf = prefs.getString("conf", "");
LinearLayout prof = new LinearLayout(this);
prof.setOrientation(LinearLayout.VERTICAL);
prof.setBackground(round(card(), 14));
prof.setPadding(dp(16), dp(14), dp(16), dp(14));
if (conf.isEmpty()) {
prof.addView(tv("Профиль не импортирован", text(), 16, true));
prof.addView(tv("В панели PCA Phobos откройте клиента → 📱 Android → нажмите ссылку phobos:// "
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
} else {
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
server = tv("", muted(), 14, false);
prof.addView(server);
}
box.addView(prof, mw(0));
power = new Button(this);
power.setAllCaps(false);
power.setTextColor(Color.WHITE);
power.setTextSize(20);
power.setTypeface(Typeface.DEFAULT_BOLD);
power.setOnClickListener(v -> toggle());
LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams(dp(180), dp(180));
plp.gravity = Gravity.CENTER_HORIZONTAL;
plp.topMargin = dp(24);
box.addView(power, plp);
status = tv("", text(), 16, true);
status.setGravity(Gravity.CENTER);
box.addView(status, mw(12));
traffic = tv("", muted(), 14, false);
traffic.setGravity(Gravity.CENTER);
box.addView(traffic, mw(2));
// split tunnelling
LinearLayout split = new LinearLayout(this);
split.setOrientation(LinearLayout.VERTICAL);
split.setBackground(round(card(), 14));
split.setPadding(dp(16), dp(14), dp(16), dp(14));
split.addView(tv("Раздельный трафик", text(), 16, true));
RadioGroup rg = new RadioGroup(this);
String mode = prefs.getString("mode", MODE_ALL);
String[][] modes = {{MODE_ALL, "Весь трафик через VPN"}, {MODE_ONLY, "Только выбранные приложения"},
{MODE_EXCEPT, "Все, кроме выбранных приложений"}};
for (String[] m : modes) {
RadioButton r = new RadioButton(this);
r.setText(m[1]);
r.setTextColor(text());
r.setId(View.generateViewId());
r.setChecked(m[0].equals(mode));
r.setOnClickListener(v -> { prefs.edit().putString("mode", m[0]).apply(); applyIfUp(); refresh(); });
rg.addView(r);
}
split.addView(rg);
android.widget.CheckBox ru = new android.widget.CheckBox(this);
ru.setText("Российские сайты — напрямую, без VPN");
ru.setTextColor(text());
ru.setChecked(prefs.getBoolean("ru_direct", true));
ru.setOnCheckedChangeListener((b, on) -> { prefs.edit().putBoolean("ru_direct", on).apply(); applyIfUp(); });
split.addView(ru, mw(4));
split.addView(tv("Канал до сервера", text(), 14, true), mw(8));
RadioGroup tg = new RadioGroup(this);
tg.setOrientation(RadioGroup.HORIZONTAL);
String tmode = prefs.getString("transport", "auto");
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}, {"cloud", "Облако"}}) {
RadioButton r = new RadioButton(this);
r.setText(m[1]);
r.setTextColor(text());
r.setId(View.generateViewId());
r.setChecked(m[0].equals(tmode));
r.setOnClickListener(v -> {
prefs.edit().putString("transport", m[0]).apply();
if ("cloud".equals(m[0])) wsDialog(); else applyIfUp();
});
tg.addView(r);
}
split.addView(tg);
int ns = lines(prefs.getString("my_direct", "")).size() + lines(prefs.getString("my_vpn", "")).size();
split.addView(button("Свои сайты (" + ns + ")", 0xFF475569, v -> sitesDialog()), mw(4));
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
split.addView(button("Свой DNS" + (myDns().isEmpty() ? "" : " (" + myDns() + ")"), 0xFF475569, v -> dnsDialog()), mw(8));
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
muted(), 12, false), mw(6));
box.addView(split, mw(20));
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
box.addView(button("Импорт из файла .conf", 0xFF4F46E5, v -> startActivityForResult(
new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"), 9)), mw(8));
box.addView(button("Проверить скорость", 0xFF0EA5E9, v -> speedtest()), mw(8));
box.addView(button("Журнал", 0xFF475569, v -> showLog()), mw(8));
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
refresh();
ui.removeCallbacksAndMessages(null);
ui.postDelayed(new Runnable() { public void run() { refresh(); ui.postDelayed(this, 2000); } }, 2000);
}
private void refresh() {
if (power == null) return;
boolean up = isUp();
power.setText(up ? "Отключить" : "Подключить");
String conf = prefs.getString("conf", "");
if (server != null && !conf.isEmpty()) server.setText("Сервер: " + target(up && !curConf.isEmpty() ? curConf : conf));
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
status.setText(up ? "🟢 VPN включён" + (transport.isEmpty() ? "" : " · " + transport) : "⚪ VPN выключен");
if (up) {
try {
Statistics st = backend.getStatistics(TUNNEL);
long rx = st.totalRx(), tx = st.totalTx(), now = System.currentTimeMillis();
String speed = "";
if (lastT > 0 && now > lastT) {
double s = (now - lastT) / 1000.0;
speed = String.format(" · ↓ %.1f / ↑ %.1f Мбит/с", (rx - lastRx) * 8 / s / 1e6, (tx - lastTx) * 8 / s / 1e6);
}
lastRx = rx; lastTx = tx; lastT = now;
traffic.setText(String.format("Получено %.1f МБ · Отправлено %.1f МБ", rx / 1048576.0, tx / 1048576.0) + speed);
} catch (Exception e) { traffic.setText(""); }
} else { traffic.setText(""); lastT = 0; }
}
private void applyIfUp() { if (isUp()) bg.execute(() -> { down(); up(); }); }
private void importDialog() {
final EditText e = new EditText(this);
e.setHint("phobos://…");
ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
if (cm != null && cm.getPrimaryClip() != null && cm.getPrimaryClip().getItemCount() > 0) {
CharSequence c = cm.getPrimaryClip().getItemAt(0).getText();
if (c != null && c.toString().trim().startsWith("phobos://")) e.setText(c);
}
new AlertDialog.Builder(this).setTitle("Ссылка из панели").setView(e)
.setPositiveButton("Импорт", (d, w) -> { importLink(e.getText().toString()); applyIfUp(); render(); })
.setNegativeButton("Отмена", null).show();
}
private void dnsDialog() {
final EditText e = new EditText(this);
e.setHint("1.1.1.1, 8.8.8.8");
e.setText(myDns());
new AlertDialog.Builder(this).setTitle("Свой DNS").setView(e)
.setMessage("Адреса DNS-серверов через запятую. Пусто — DNS из профиля.")
.setPositiveButton("Сохранить", (d, w) -> {
String v = String.join(", ", lines(e.getText().toString()));
if (!v.isEmpty() && !v.matches("[0-9a-fA-F:., ]+")) { toast("DNS — только IP-адреса, например 1.1.1.1"); return; }
prefs.edit().putString("dns", v).apply();
applyIfUp();
render();
})
.setNegativeButton("Отмена", null).show();
}
/** Channel "Облако": address of the user's Keenetic web app (KeenDNS cloud) with wsgw behind it. */
private void wsDialog() {
final EditText e = new EditText(this);
e.setHint("wss://phobos.имя.keenetic.pro/");
e.setText(wsUrl(prefs.getString("conf", "")));
new AlertDialog.Builder(this).setTitle("Адрес роутера (KeenDNS)").setView(e)
.setMessage("Канал через облако Keenetic: телефон → облако KeenDNS → ваш роутер → сервер.")
.setPositiveButton("Сохранить", (d, w) -> {
String u = e.getText().toString().trim();
if (!u.isEmpty() && !u.startsWith("wss://")) u = "wss://" + u.replaceFirst("^\\w+://", "");
prefs.edit().putString("ws_url", u).apply();
applyIfUp();
})
.setNegativeButton("Отмена", null).show();
}
/** User's own exceptions to "Russian sites directly": domains, IPs or networks, one per line. */
private void sitesDialog() {
LinearLayout l = new LinearLayout(this);
l.setOrientation(LinearLayout.VERTICAL);
l.setPadding(dp(16), dp(8), dp(16), 0);
l.addView(tv("Напрямую, без VPN (по одному на строку):", text(), 14, true));
final EditText direct = new EditText(this);
direct.setHint("mysite.ru\n1.2.3.4\n5.6.0.0/16");
direct.setText(prefs.getString("my_direct", ""));
direct.setMinLines(3);
l.addView(direct);
l.addView(tv("Всегда через VPN, даже если российский:", text(), 14, true), mw(8));
final EditText vpn = new EditText(this);
vpn.setHint("rutracker.org");
vpn.setText(prefs.getString("my_vpn", ""));
vpn.setMinLines(3);
l.addView(vpn);
l.addView(tv("Работает, когда включено «Российские сайты — напрямую». Сайт берётся по его текущим адресам при подключении.", muted(), 12, false), mw(6));
ScrollView sv = new ScrollView(this);
sv.addView(l);
new AlertDialog.Builder(this).setTitle("Свои сайты").setView(sv)
.setPositiveButton("Сохранить", (d, w) -> {
prefs.edit().putString("my_direct", direct.getText().toString().trim())
.putString("my_vpn", vpn.getText().toString().trim()).apply();
applyIfUp();
render();
})
.setNegativeButton("Отмена", null).show();
}
private void pickApps() {
PackageManager pm = getPackageManager();
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
List<ResolveInfo> ris = pm.queryIntentActivities(main, 0);
List<String[]> apps = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (ResolveInfo ri : ris) {
String p = ri.activityInfo.packageName;
if (p.equals(getPackageName()) || !seen.add(p)) continue;
apps.add(new String[]{String.valueOf(ri.loadLabel(pm)), p});
}
Collections.sort(apps, (a, b) -> a[0].compareToIgnoreCase(b[0]));
final Set<String> chosen = new LinkedHashSet<>(Arrays.asList(prefs.getString("apps", "").split(",")));
chosen.remove("");
String[] labels = new String[apps.size()];
boolean[] checked = new boolean[apps.size()];
for (int i = 0; i < apps.size(); i++) { labels[i] = apps.get(i)[0]; checked[i] = chosen.contains(apps.get(i)[1]); }
new AlertDialog.Builder(this).setTitle("Приложения")
.setMultiChoiceItems(labels, checked, (d, i, on) -> { if (on) chosen.add(apps.get(i)[1]); else chosen.remove(apps.get(i)[1]); })
.setPositiveButton("Готово", (d, w) -> { prefs.edit().putString("apps", String.join(",", chosen)).apply(); applyIfUp(); render(); })
.setNegativeButton("Отмена", null).show();
}
/** Real speed through the tunnel: the browser is inside the VPN (unless excluded by the split). */
private void speedtest() {
if (!isUp()) { toast("Сначала подключитесь"); return; }
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://speed.cloudflare.com/")));
}
private void log(String s) {
try (FileOutputStream o = new FileOutputStream(new File(getFilesDir(), "app.log"), true)) {
o.write((new java.util.Date() + " " + s + "\n").getBytes(StandardCharsets.UTF_8));
} catch (Exception ignored) { }
}
private static String tail(File f, int max) {
try {
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
try (java.io.FileInputStream in = new java.io.FileInputStream(f)) {
byte[] buf = new byte[8192];
for (int n; (n = in.read(buf)) > 0; ) o.write(buf, 0, n);
}
String t = new String(o.toByteArray(), StandardCharsets.UTF_8);
return t.length() > max ? t.substring(t.length() - max) : t;
} catch (Exception e) { return ""; }
}
private void showLog() {
String t = "— приложение —\n" + tail(new File(getFilesDir(), "app.log"), 3000)
+ "\n— обфускатор —\n" + tail(new File(getFilesDir(), "obf.log"), 3000)
+ "\n— TCP-канал —\n" + tail(new File(getFilesDir(), "tcp.log"), 1500)
+ "\nобфускатор запущен: " + (obfuscator != null && obfuscator.isAlive());
TextView v = tv(t, text(), 12, false);
v.setTextIsSelectable(true);
v.setPadding(dp(16), dp(8), dp(16), dp(8));
ScrollView sv = new ScrollView(this);
sv.addView(v);
new AlertDialog.Builder(this).setTitle("Журнал").setView(sv).setPositiveButton("OK", null).show();
}
private void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_LONG).show(); }
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,157 @@
package com.pca.phobos.vpn;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
/**
* Transport "Keenetic cloud": local UDP (obfuscator) <-> wss://<keendns-subdomain>/ -> wsgw on the router -> server UDP.
* One binary WS message = one obfuscated packet. Reconnects on drop; the tunnel itself is not restarted.
*/
final class WsRelay {
interface Log { void log(String s); }
private final int lport;
private final URI uri;
private final Log log;
private final SecureRandom rnd = new SecureRandom();
private volatile boolean stop;
private volatile SSLSocket ws;
private volatile OutputStream out;
private volatile SocketAddress peer;
private DatagramSocket udp;
WsRelay(int lport, String url, Log log) {
this.lport = lport;
this.uri = URI.create(url.trim());
this.log = log;
}
void start() throws IOException {
udp = new DatagramSocket(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), lport));
new Thread(this::upLoop, "ws-up").start();
new Thread(this::downLoop, "ws-down").start();
}
void stop() {
stop = true;
closeWs();
if (udp != null) udp.close();
}
private synchronized void closeWs() {
try { if (ws != null) ws.close(); } catch (IOException ignored) { }
ws = null;
out = null;
}
private synchronized OutputStream connect() throws IOException {
if (out != null) return out;
String host = uri.getHost();
int port = uri.getPort() > 0 ? uri.getPort() : 443;
SSLSocket s = (SSLSocket) SSLSocketFactory.getDefault().createSocket(host, port);
s.setTcpNoDelay(true);
s.setKeepAlive(true);
s.startHandshake();
byte[] k = new byte[16];
rnd.nextBytes(k);
String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "/" : uri.getRawPath();
String req = "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " + android.util.Base64.encodeToString(k, android.util.Base64.NO_WRAP) + "\r\n\r\n";
OutputStream o = s.getOutputStream();
o.write(req.getBytes(StandardCharsets.US_ASCII));
o.flush();
InputStream in = s.getInputStream();
StringBuilder head = new StringBuilder();
while (!head.toString().endsWith("\r\n\r\n")) {
int c = in.read();
if (c < 0 || head.length() > 8192) throw new IOException("ws: no handshake");
head.append((char) c);
}
if (!head.toString().startsWith("HTTP/1.1 101")) {
s.close();
throw new IOException("ws: " + head.toString().split("\r\n")[0]);
}
ws = s;
out = o;
log.log("ws: connected " + uri);
return o;
}
/** obfuscator -> server */
private void upLoop() {
byte[] buf = new byte[65535];
DatagramPacket p = new DatagramPacket(buf, buf.length);
while (!stop) {
try {
udp.receive(p);
peer = p.getSocketAddress();
OutputStream o = connect();
writeFrame(o, buf, p.getLength());
} catch (IOException e) {
if (stop) return;
log.log("ws up: " + e.getMessage());
closeWs();
sleep(1000);
}
}
}
/** server -> obfuscator */
private void downLoop() {
while (!stop) {
SSLSocket s = ws;
if (s == null) { sleep(200); continue; }
try {
DataInputStream in = new DataInputStream(s.getInputStream());
while (!stop) {
int b0 = in.readUnsignedByte(), b1 = in.readUnsignedByte();
long n = b1 & 0x7f;
if (n == 126) n = in.readUnsignedShort();
else if (n == 127) n = in.readLong();
if (n > 65535) throw new IOException("ws: frame too big");
byte[] p = new byte[(int) n];
in.readFully(p);
int op = b0 & 0x0f;
if (op == 8) throw new IOException("ws: closed by server");
if ((op == 2 || op == 0) && peer != null) udp.send(new DatagramPacket(p, p.length, peer));
}
} catch (IOException e) {
if (stop) return;
if (s == ws) { log.log("ws down: " + e.getMessage()); closeWs(); }
}
}
}
/** Client frames are masked (RFC 6455). */
private synchronized void writeFrame(OutputStream o, byte[] p, int n) throws IOException {
byte[] f = new byte[n + 14];
int i = 0;
f[i++] = (byte) 0x82;
if (n < 126) f[i++] = (byte) (0x80 | n);
else { f[i++] = (byte) (0x80 | 126); f[i++] = (byte) (n >> 8); f[i++] = (byte) n; }
byte[] m = new byte[4];
rnd.nextBytes(m);
System.arraycopy(m, 0, f, i, 4);
i += 4;
for (int j = 0; j < n; j++) f[i + j] = (byte) (p[j] ^ m[j & 3]);
o.write(f, 0, i + n);
o.flush();
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) { }
}
}

1
android/.gitignore vendored Normal file
View File

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

View File

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

4
android/README.md Normal file
View File

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

26
android/build.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

4376
app.py

File diff suppressed because it is too large Load Diff

View File

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

51
docs/DNS.md Normal file
View File

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

109
docs/PORTS.md Normal file
View File

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

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

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

29
docs/WARP.md Normal file
View File

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

160
docs/WARP_DEV_NOTES.md Normal file
View File

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

139
docs/cloud.md Normal file
View File

@@ -0,0 +1,139 @@
# Канал «Облако» — VPN через облако своего Keenetic (PCA Phobos 1.4.4)
![Схема канала «Облако»](img/cloud/skhema.png)
## Что это и зачем
Обычно телефон ходит к серверу Phobos напрямую по UDP (или по резервному TCP). Если оператор или сеть
режет сам сервер, а сайты Keenetic открываются, можно пойти **через свой домашний Keenetic**:
1. телефон открывает защищённый WebSocket `wss://phobos.<имя>.<домен>/` — для оператора это обычный HTTPS к облаку Keenetic;
2. облако KeenDNS (серверы Keenetic в РФ) по имени пересылает соединение в ваш роутер — белый IP роутеру не нужен;
3. на роутере шлюз **wsgw** разворачивает WebSocket обратно в UDP и шлёт на обфускатор сервера Phobos;
4. дальше всё как обычно: WireGuard → интернет с IP сервера.
Это **дополнение** к UDP/TCP, не замена. Шифрование то же (WireGuard), облако Keenetic видит только зашифрованный поток.
**Адрес вида `wss://phobos.vasya.keenetic.pro/`** = `wss://` (WebSocket через HTTPS, порт 443) +
`phobos` (поддомен веб-приложения на роутере) + `vasya.keenetic.pro` (KeenDNS-имя роутера).
Скорость в нашем тесте (Keenetic → сервер в Польше): ↓11 / ↑15 Мбит/с, задержка ~80 мс, потерь 0 %.
---
## Часть 1. Роутер Keenetic (один раз)
**Что нужно:** Keenetic с KeenDNS-именем (Интернет → Доменное имя), установленный Entware (OPKG) и SSH.
Команды ниже — в SSH роутера (`ssh root@192.168.1.1 -p 222` или как у вас настроено).
### Шаг 1. Веб-интерфейс на порт 80
Роутер выпускает сертификат Let's Encrypt для поддомена, только если его веб-интерфейс на порту 80.
```sh
ndmc -c 'ip http port 80'
```
Если раньше был другой порт (81, 8080…) — поправьте закладки и скрипты, которые туда ходили.
Снаружи порт 80 пробрасывать не нужно.
### Шаг 2. Шлюз wsgw
Узнать архитектуру:
```sh
uname -m # aarch64 → wsgw-aarch64; mips → wsgw-mipsel (или wsgw-mips, если не запустится)
```
Скачать прямо на роутер (замените `aarch64` на свою архитектуру):
```sh
curl -fsSL -o /opt/sbin/wsgw https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/wsgw/bin/wsgw-aarch64
curl -fsSL -o /opt/etc/init.d/S80wsgw https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/wsgw/router/S80wsgw
chmod +x /opt/sbin/wsgw /opt/etc/init.d/S80wsgw
```
Настройка — файл `/opt/etc/wsgw.conf`:
```sh
cat > /opt/etc/wsgw.conf <<'EOF'
WSGW_LISTEN=192.168.1.1:18090
WSGW_TARGET=147.45.126.83:2083
EOF
/opt/etc/init.d/S80wsgw start
```
- `WSGW_LISTEN` — LAN-адрес **вашего** роутера (Главная → Домашняя сеть) и порт 18090.
- `WSGW_TARGET` — адрес обфускатора сервера: строка `target = …` из ссылки клиента
(в панели клиента → 📱 → «показать конфиг»). Для сервера Польша — `147.45.126.83:2083`.
Проверка: `netstat -ltn | grep 18090` — должна быть строка LISTEN.
### Шаг 3. Поддомен и облачный режим KeenDNS
Подставьте своё имя: для `vasya.keenetic.pro` — `<имя>` = `vasya`, `<домен>` = `keenetic.pro`.
```sh
ndmc -c 'ip http proxy phobos'
ndmc -c 'ip http proxy phobos upstream http 192.168.1.1 18090'
ndmc -c 'ip http proxy phobos domain ndns'
ndmc -c 'ip http proxy phobos security-level public'
ndmc -c 'ndns book-name <имя> <домен> cloud'
ndmc -c 'ip http ssl acme get phobos.<имя>.<домен>'
ndmc -c 'system configuration save'
```
То же в веб-интерфейсе: **Сетевые правила → Переадресация → Веб-приложения** (или «Доменное имя → Веб-приложения»):
имя `phobos`, адрес `192.168.1.1`, порт `18090`, доступ **без авторизации**; режим доменного имени — **через облако**.
Сертификат выпускается за 10–60 секунд. Проверить:
```sh
ndmc -c 'ip http ssl acme list' | grep domain # в списке должен быть phobos.<имя>.<домен>
```
Если его нет, а `ndmc -c 'show acme' | grep get-pending` показывает `yes` — **перезагрузите роутер** и повторите `acme get`.
### Шаг 4. Проверка с любого компьютера
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://phobos.<имя>.<домен>/
```
`426` — всё работает (шлюз ответил «нужен WebSocket»). Ошибка TLS (`internal error`) — см. «Если не работает».
---
## Часть 2. Телефон
### Android (PCA Phobos VPN 1.4.4+)
1. Импортируйте ссылку `phobos://` как обычно.
2. «Канал до сервера» → **Облако**.
3. В окне «Адрес роутера (KeenDNS)» введите `wss://phobos.<имя>.<домен>/` → **Сохранить**.
4. **Подключить**. Статус: «🟢 VPN включён · Облако».
![Главный экран, канал «Облако»](img/cloud/android-1-glavnyj.png)
![Окно адреса роутера](img/cloud/android-2-adres.png)
### iPhone (PCA Phobos, TestFlight 1.0 (6) и новее)
1. Импортируйте ссылку `phobos://`.
2. «Канал до сервера» → сегмент **Облако**.
3. В появившемся поле — `wss://phobos.<имя>.<домен>/`.
4. **Подключить** → «VPN включён · Облако».
### Проверка
Откройте speed.cloudflare.com: «Your IP address» должен быть IP сервера Phobos (Польша — 147.45.126.83).
![Speedtest через канал «Облако»](img/cloud/android-3-skorost.png)
Адрес можно не вводить руками: если в ссылке клиента есть строка `# pca-ws = wss://…`, приложение возьмёт её само
(пока строку добавляет только администратор вручную).
---
## Если не работает
| Что видно | Причина | Что делать |
|---|---|---|
| `acme`: «available only when HTTP port is set to 80» | веб-интерфейс не на 80 | Шаг 1 |
| сертификата нет, `get-pending: yes` | задание ACME зависло | перезагрузить роутер, повторить `acme get` |
| curl: TLS `internal error` | у роутера нет сертификата **или** WAN:443 проброшен на другой компьютер (облако шлёт поддомены на WAN:443) | проверить `acme list`; убрать проброс 443 или настроить на том компьютере прокси поддомена на `роутер:18090` (например, Caddy: `phobos.<имя>.<домен> { reverse_proxy 192.168.1.1:18090 }`) |
| curl: `403` | поддомен не `public` / облачный режим выключен | `security-level public`, `ndns … cloud` |
| curl: `502` | шлюз не запущен | `/opt/etc/init.d/S80wsgw start`, проверить `wsgw.conf` |
| VPN «включён · Облако», но сайты не открываются | неверный `WSGW_TARGET` или профиль от другого сервера | `target` в ссылке клиента = `WSGW_TARGET` |
Журнал приложения (Android → «Журнал»): строки `ws: connected …` / `ws up: …` / `ws down: …`.
## Как выключить
```sh
/opt/etc/init.d/S80wsgw stop
ndmc -c 'no ip http proxy phobos'
ndmc -c 'ndns book-name <имя> <домен> direct'
ndmc -c 'system configuration save'
```
## Для разработчика
Код: `wsgw/` (шлюз, Go), `android-client/src/com/pca/phobos/vpn/WsRelay.java`,
`ios/Tunnel/WsLink.swift` + режим 2 в `pcarelay.c`. Протокол: одно бинарное WS-сообщение = один обфусцированный UDP-пакет.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
docs/img/clients.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

37
docs/img/cloud/skhema.svg Normal file
View File

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430" font-family="Helvetica, Arial, sans-serif">
<rect width="1200" height="430" fill="#ffffff"/>
<text x="600" y="38" text-anchor="middle" font-size="24" font-weight="bold" fill="#0f172a">Канал «Облако»: телефон → облако KeenDNS → свой Keenetic → сервер Phobos</text>
<defs><marker id="a" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#475569"/></marker></defs>
<g font-size="17">
<rect x="20" y="90" width="200" height="130" rx="14" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
<text x="120" y="125" text-anchor="middle" font-weight="bold" fill="#1e1b4b">Телефон</text>
<text x="120" y="152" text-anchor="middle" fill="#334155">PCA Phobos VPN</text>
<text x="120" y="176" text-anchor="middle" fill="#334155">канал «Облако»</text>
<text x="120" y="200" text-anchor="middle" fill="#334155">WireGuard+обфускатор</text>
<rect x="320" y="90" width="210" height="130" rx="14" fill="#f0f9ff" stroke="#0ea5e9" stroke-width="2"/>
<text x="425" y="125" text-anchor="middle" font-weight="bold" fill="#0c4a6e">Облако KeenDNS</text>
<text x="425" y="152" text-anchor="middle" fill="#334155">серверы Keenetic в РФ</text>
<text x="425" y="176" text-anchor="middle" fill="#334155">пересылает HTTPS</text>
<text x="425" y="200" text-anchor="middle" fill="#334155">по имени (SNI)</text>
<rect x="630" y="90" width="250" height="130" rx="14" fill="#f0fdf4" stroke="#16a34a" stroke-width="2"/>
<text x="755" y="125" text-anchor="middle" font-weight="bold" fill="#14532d">Ваш Keenetic</text>
<text x="755" y="152" text-anchor="middle" fill="#334155">поддомен phobos.* (TLS)</text>
<text x="755" y="176" text-anchor="middle" fill="#334155">→ шлюз wsgw :18090</text>
<text x="755" y="200" text-anchor="middle" fill="#334155">WebSocket → UDP</text>
<rect x="980" y="90" width="200" height="130" rx="14" fill="#fff7ed" stroke="#ea580c" stroke-width="2"/>
<text x="1080" y="125" text-anchor="middle" font-weight="bold" fill="#7c2d12">Сервер Phobos</text>
<text x="1080" y="152" text-anchor="middle" fill="#334155">обфускатор UDP</text>
<text x="1080" y="176" text-anchor="middle" fill="#334155">→ WireGuard</text>
<text x="1080" y="200" text-anchor="middle" fill="#334155">→ интернет</text>
</g>
<g stroke="#475569" stroke-width="3" marker-end="url(#a)"><line x1="222" y1="155" x2="316" y2="155"/><line x1="532" y1="155" x2="626" y2="155"/><line x1="882" y1="155" x2="976" y2="155"/></g>
<g font-size="15" fill="#475569" text-anchor="middle">
<text x="270" y="140">wss:// 443</text><text x="580" y="140">туннель</text><text x="580" y="178">Keenetic</text><text x="930" y="140">UDP</text></g>
<rect x="20" y="260" width="1160" height="150" rx="14" fill="#f8fafc" stroke="#cbd5e1"/>
<g font-size="17" fill="#0f172a">
<text x="44" y="295" font-weight="bold">Когда нужно</text>
<text x="44" y="322">• у оператора/в сети заблокирован сервер Phobos (UDP и TCP), а сайты Keenetic открываются;</text>
<text x="44" y="348">• у роутера нет белого IP — облако KeenDNS достаёт его всё равно.</text>
<text x="44" y="380" font-weight="bold">Проверено 31.10.2026: </text><text x="250" y="380">планшет → облако → домашний Keenetic → Польша, ↓11 / ↑15 Мбит/с, потерь 0 %.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

BIN
docs/img/servers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
docs/img/sessions.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

BIN
docs/img/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 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,36 +13,100 @@
# #
# 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
# TG_TOKEN / TG_CHAT Telegram alerts # TG_TOKEN / TG_CHAT Telegram alerts
# PCA_BRANCH main branch to pull PCA files from # PCA_BRANCH main branch to pull PCA files from
# SKIP_NGINX 0 1 = do not touch an existing nginx
# ============================================================ # ============================================================
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}"
# Канал: stable по умолчанию (ветка main). Бета — по желанию: CHANNEL=beta (или PCA_BRANCH=beta). # Уже есть wg0.conf (переустановка) — подсеть берём из него, иначе server.env разойдётся с интерфейсом
CHANNEL="${CHANNEL:-stable}" if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -f /etc/wireguard/wg0.conf ]; then
if [ -z "${PCA_BRANCH:-}" ]; then SERVER_WG_IPV4_NETWORK="$(python3 - <<'PY' 2>/dev/null
case "$CHANNEL" in beta) PCA_BRANCH="beta";; *) PCA_BRANCH="main";; esac import ipaddress, re
for ln in open("/etc/wireguard/wg0.conf"):
m = re.match(r"\s*Address\s*=\s*([^,\s]+)", ln)
if m:
print(ipaddress.ip_interface(m.group(1)).network); break
PY
)" || SERVER_WG_IPV4_NETWORK=""
fi fi
GH_TOKEN="${GH_TOKEN:-}" # set for private-repo installs; empty for public # Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
printf '\n!!! Это ВТОРОЙ сервер к уже работающей панели? Тогда прервите (Ctrl+C) и ставьте командой из панели\n «Серверы -> Как добавить второй сервер» (secondary-setup.sh), подсеть там по умолчанию.\n Своя подсеть нужна только для НЕЗАВИСИМОЙ панели (например 10.25.11.0/24, не пересекается с первой);\n смена сервера у клиентов между независимыми панелями не работает.\n\n' > /dev/tty
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
fi
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
try:
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
except ValueError:
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
# Считаем, что нужна подсеть этого адреса, и берём /24.
try:
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
except ValueError:
sys.exit("ERROR: bad subnet " + sys.argv[1])
if net.version != 4 or not net.is_private or net.prefixlen > 29:
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
print(net)
PY
)" || exit 1
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
CHANNEL="${CHANNEL:-stable}"
case "$CHANNEL" in
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
*) PCA_BRANCH="${PCA_BRANCH:-$CHANNEL}";;
esac
case "$PCA_BRANCH" in v[0-9]*) _kind=tag;; *) _kind=branch;; esac
PHOBOS_DIR="/opt/Phobos" 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}') # ponytail: SERVER_IP можно задать явно — для серверов с несколькими IP
SERVER_IP="${SERVER_IP:-$(curl -s -m8 https://api.ipify.org || hostname -I | awk '{print $1}')}"
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1) IFACE=$(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}"
ARCH=$(uname -m) ARCH=$(uname -m)
@@ -57,24 +121,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)..."
@@ -92,7 +184,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
@@ -152,6 +244,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
@@ -161,23 +255,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..."
@@ -199,6 +314,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
@@ -207,17 +327,23 @@ 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..."
# ponytail: SKIP_NGINX=1 — на сервере уже есть свой nginx; /init и /packages отдаёт панель
if [ "${SKIP_NGINX:-0}" = "1" ]; then echo " skipped (SKIP_NGINX=1)"; else
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
cat > /etc/nginx/sites-available/phobos <<'NGINX' cat > /etc/nginx/sites-available/phobos <<'NGINX'
server { server {
@@ -230,8 +356,9 @@ server {
} }
NGINX NGINX
ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos ln -sf /etc/nginx/sites-available/phobos /etc/nginx/sites-enabled/phobos
mkdir -p /opt/Phobos/www/app; chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages /opt/Phobos/www/app
nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed" nginx -t >/dev/null 2>&1 && systemctl enable nginx -q 2>/dev/null && systemctl restart nginx || echo " WARN: nginx config test failed"
fi
mkdir -p /opt/Phobos/www/app; chmod 755 /opt/Phobos/www /opt/Phobos/www/init /opt/Phobos/www/packages /opt/Phobos/www/app
# ── 9. router watchdog (auto reboot-recovery) ── # ── 9. router watchdog (auto reboot-recovery) ──
echo "[9/9] router watchdog..." echo "[9/9] router watchdog..."
@@ -244,10 +371,22 @@ 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
# при переустановке settings.json остаётся старый — в сводке показываем то, что реально в панели
_S="$PANEL_DIR/settings.json"
if [ -f "$_S" ]; then
API_KEY="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("server_api_key") or sys.argv[2])' "$_S" "$API_KEY" 2>/dev/null || echo "$API_KEY")"
PANEL_PASS="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("admin_pass") or sys.argv[2])' "$_S" "$PANEL_PASS" 2>/dev/null || echo "$PANEL_PASS")"
fi
echo "" echo ""
echo "============================================" 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 "$@"

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

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

View File

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

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)
@@ -99,7 +108,7 @@ action_add() {
[Interface] [Interface]
PrivateKey = $priv_key PrivateKey = $priv_key
Address = $addr_str Address = $addr_str
DNS = 1.1.1.1, 8.8.8.8 DNS = ${CLIENT_DNS:-1.1.1.1, 8.8.8.8}
MTU = 1420 MTU = 1420
[Peer] [Peer]
@@ -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/') || _tag="" # ponytail: GitHub API rate limit/недоступность не должны ронять сборку пакета — берём уже скачанные бинарники
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
for _arch in aarch64 mipsel mips armv7 x86_64; do
if [[ ! -f "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" ]] && [[ -n "$_tag" ]]; then
local _t; _t=$(mktemp -d)
if curl -fsSL -m30 "${_cb}/wg-obfuscator-${_tag}-${_am[$_arch]}.tar.gz" -o "$_t/o.tgz" 2>/dev/null; then
tar -xzf "$_t/o.tgz" -C "$_t" 2>/dev/null || true
local _b; _b=$(find "$_t" -name "wg-obfuscator" -type f | head -1)
[[ -n "$_b" ]] && cp "$_b" "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" && chmod +x "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" && log_info "+wg-obfuscator-$_arch (ClusterM ${_tag})"
fi
rm -rf "$_t"
fi
[[ -f "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" ]] && cp "$PHOBOS_DIR/bin/wg-obfuscator-$_arch" "$pkg_root/bin/"
done 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..."
ok=0
for url in \$urls; do
rm -f "\$dir/package.tar.gz"
if command -v curl >/dev/null; then if command -v curl >/dev/null; then
curl -L -s -o "\$dir/package.tar.gz" "\$url" curl -k -L -s -o "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
else else
wget -q -O "\$dir/package.tar.gz" "\$url" 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 fi
if [ ! -f "\$dir/package.tar.gz" ]; then echo "Download failed"; exit 1; 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

@@ -1,4 +1,5 @@
#!/bin/sh #!/bin/sh
export PATH=/opt/bin:/opt/sbin:/opt/usr/bin:$PATH # cron без /opt/bin -> busybox wget висел вечно (09.29)
# ============================================================ # ============================================================
# Phobos Config Pull — NAT-friendly management channel. # Phobos Config Pull — NAT-friendly management channel.
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY: # Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
@@ -21,21 +22,30 @@
# PANEL env or default below -> panel base URL # PANEL env or default below -> panel base URL
# ============================================================ # ============================================================
PHOBOS_DIR="/opt/etc/Phobos" PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf" # 02.11.2026: доп. учётки — PHOBOS_ACC=$PHOBOS_DIR/acc/<клиент> (см. phobos-health.sh)
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
CONF="$ACC_DIR/failover.conf"
HEALTH="$PHOBOS_DIR/phobos-health.sh" HEALTH="$PHOBOS_DIR/phobos-health.sh"
LOG="$PHOBOS_DIR/health.log" LOG="$ACC_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 "$ACC_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"
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home") PANEL="${PANEL:-$(cat "$ACC_DIR/panel_url" 2>/dev/null)}"
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null) PANEL_URLS="${PANEL_URLS:-$(cat "$ACC_DIR/panel_urls" 2>/dev/null)}"
TMP="/tmp/failover.conf.pull" [ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
LOCK="/tmp/phobos-pull.lock" # + public URLs the panel announced in failover.conf (second IP etc., 09.33)
_extra=$(grep "^PANEL_URLS_EXTRA=" "$CONF" 2>/dev/null | cut -d= -f2-)
for _u in $_extra; do case " $PANEL_URLS " in *" $_u "*) ;; *) PANEL_URLS="$PANEL_URLS $_u" ;; esac; done
CLIENT_ID=$(cat "$ACC_DIR/client_id" 2>/dev/null || echo "home")
TOKEN=$(cat "$ACC_DIR/pull_token" 2>/dev/null)
TMP="/tmp/failover.conf.pull${PHOBOS_ACC:+-$CLIENT_ID}"
LOCK="/tmp/phobos-pull${PHOBOS_ACC:+-$CLIENT_ID}.lock"
log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; } log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; }
@@ -49,12 +59,46 @@ 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 -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
}
# TCP channel for routers (1.5.0): the guard needs /opt/bin/udptcp when failover.conf has TCP_N.
ensure_udptcp() {
[ -x /opt/bin/udptcp ] && return 0
grep -q "^TCP_[0-9]*=" "$CONF" 2>/dev/null || return 0
case "$(uname -m)" in aarch64) a=aarch64 ;; mips*) a=mipsel ;; *) return 0 ;; esac
command -v curl >/dev/null || return 0
{ curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/udptcp/bin/udptcp-$a" 2>/dev/null \
|| curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/udptcp/bin/udptcp-$a" 2>/dev/null; } \
&& [ -s /opt/bin/udptcp.tmp ] && chmod 755 /opt/bin/udptcp.tmp && mv /opt/bin/udptcp.tmp /opt/bin/udptcp \
&& log "PULL: udptcp installed ($a)" || rm -f /opt/bin/udptcp.tmp
}
# One fetch + compare + apply pass. Returns 0 always (best-effort). # One fetch + compare + apply pass. Returns 0 always (best-effort).
do_pull() { do_pull() {
ensure_udptcp
# 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 +121,48 @@ 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
# только свой обфускатор: у других учёток свои failover.conf
own=""
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
[ "$(cat "$l" 2>/dev/null)" = "$CLIENT_ID" ] && own="${l%.link}.conf"
done
[ -z "$own" ] && [ -z "$PHOBOS_ACC" ] && own="$PHOBOS_DIR/wg-obfuscator*.conf"
for OBF in $own; do
[ -f "$OBF" ] || continue
cur=$(grep "^masking = " "$OBF" 2>/dev/null | sed 's/masking = //')
[ "$cur" != "$M" ] && { sed -i "s/^masking = .*/masking = $M/" "$OBF"; changed=1; }
if [ -n "$D" ]; then
cur=$(grep "^max-dummy = " "$OBF" 2>/dev/null | sed 's/max-dummy = //')
[ "$cur" != "$D" ] && { sed -i "s/^max-dummy = .*/max-dummy = $D/" "$OBF"; changed=1; }
fi
if [ -n "$I" ]; then
cur=$(grep "^idle-timeout = " "$OBF" 2>/dev/null | sed 's/idle-timeout = //')
[ "$cur" != "$I" ] && { sed -i "s/^idle-timeout = .*/idle-timeout = $I/" "$OBF"; changed=1; }
fi
done
[ "$changed" = 0 ] && return 0
log "PULL: obf params applied (masking=$M dummy=${D:-} idle=${I:-}), restart obfuscator"
if ls /opt/etc/init.d/S*obfuscator* >/dev/null 2>&1; then
for sc in /opt/etc/init.d/S*obfuscator*; do
for OBF in $own; do grep -q "/$(basename "$OBF")\"\$" "$sc" && [ -x "$sc" ] && "$sc" restart >/dev/null 2>&1; done
done
elif command -v systemctl >/dev/null 2>&1; then
systemctl restart 'wg-obfuscator*' >/dev/null 2>&1 || true
else
killall wg-obfuscator 2>/dev/null; sleep 1
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; do [ -f "$OBF" ] && wg-obfuscator --config "$OBF" >/dev/null 2>&1 & done
fi
} }
# Inner loop: cron fires this every 60s, but we poll ~4x per minute so a # 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,41 @@ EOF
return 1 return 1
fi fi
# Настраиваем статический маршрут в созданный интерфейс отдельным вызовом RCI
local route_target_net=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0
local route_mask="255.255.0.0"
# 02.11.2026: доп. учётка (на роутере уже стоит другая): та же /16 через второй
# интерфейс конфликтует с главной, а /32 до панели увёл бы pull главной в чужой
# туннель — маршрут не ставим, pull доп. учётки ходит по panel_urls.
local main_cid=$(cat /opt/etc/Phobos/client_id 2>/dev/null)
if [ -n "$main_cid" ] && [ "$main_cid" != "${CLIENT_NAME}" ]; then
log "Доп. учётка (главная — ${main_cid}): маршрут подсети туннеля не нужен"
log "Интерфейс ${interface_name} создан ✓"
return 0
fi
log "Настройка маршрута для туннеля (${route_target_net}/${route_mask}) -> ${interface_name}..."
local route_json=$(cat <<EOF
{
"network": "${route_target_net}",
"mask": "${route_mask}",
"interface": "${interface_name}",
"auto": true
}
EOF
)
local route_result=$(echo "${route_json}" | curl -s -X POST \
-H "Content-Type: application/json" \
-d @- \
"http://localhost:79/rci/ip/route/" 2>/dev/null)
if echo "${route_result}" | jq -e '.status[] | select(.status == "error")' >/dev/null 2>&1; then
local error_msg=$(echo "${route_result}" | jq -r '.status[] | select(.status == "error") | .message // "Unknown error"' 2>/dev/null)
error "RCI API отклонил настройку маршрута: ${error_msg}"
log "JSON запрос:"
log "${route_json}"
return 1
fi
log "Интерфейс ${interface_name} создан ✓" 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 -> публичный adleibzon-rgb/PCA_Phobos ветка main
# beta -> приватный adleibzon-rgb/PCA_Phobos-dev ветка beta
# dev -> приватный adleibzon-rgb/PCA_Phobos-dev ветка dev
#
# Поток фич — строго в одну сторону: dev -> beta -> stable
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
#
# Использование (GH_TOKEN в env):
# GH_TOKEN=ghp_xxx ./promote.sh status
# GH_TOKEN=ghp_xxx ./promote.sh dev2beta
# GH_TOKEN=ghp_xxx ./promote.sh beta2stable 1.3.0
# GH_TOKEN=ghp_xxx ./promote.sh forward # main->beta->dev (после хотфикса)
#
# Мёрж обычный (без -X). При конфликте — НЕ пушит, оставляет рабочую копию
# в $WORK для ручного разбора. Версии (VERSION) выставляются по каналу.
# ============================================================
set -e
GH_TOKEN="${GH_TOKEN:-}"
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
PUB="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos.git"
PRIV="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos-dev.git"
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
init_work() {
rm -rf "$WORK"; mkdir -p "$WORK"
git -C "$WORK" init -q
git -C "$WORK" config user.email "maintainer@pca"
git -C "$WORK" config user.name "PCA promote"
}
co_branch() { # $1=branch -> checkout local copy of remote branch
local br="$1" repo; repo=$(repo_for "$br")
git -C "$WORK" fetch -q "$repo" "$br"
git -C "$WORK" checkout -q -B "$br" FETCH_HEAD
}
ver_now() { tr -d ' \r\n' < "$WORK/VERSION" 2>/dev/null; }
do_merge() { # $1=incoming-ref-msg label ; merges FETCH_HEAD into current branch
if ! git -C "$WORK" merge --no-edit -m "$1" FETCH_HEAD; then
echo ""
echo "!!! КОНФЛИКТ. Не запушено. Разрули вручную:"
echo " cd $WORK && git status # правь файлы, git add, git commit"
echo " затем git push <repo> <branch>"
exit 1
fi
}
case "${1:-status}" in
status)
init_work
echo "== Версии каналов =="
for br in main beta dev; do
co_branch "$br" >/dev/null 2>&1 || { printf " %-7s (нет)\n" "$br"; continue; }
printf " %-7s %s (%s)\n" "$br" "$(ver_now)" "$(git -C "$WORK" rev-parse --short HEAD)"
done
echo "Поток: dev -> beta -> stable. После хотфикса в stable: ./promote.sh forward"
;;
dev2beta)
init_work
echo "== Промоут dev -> beta =="
co_branch beta
git -C "$WORK" fetch -q "$PRIV" dev
DEVVER=$(git -C "$WORK" show FETCH_HEAD:VERSION | tr -d ' \r\n')
do_merge "promote: merge dev into beta"
BETAVER="${DEVVER%-dev}-beta"
echo "$BETAVER" > "$WORK/VERSION"
git -C "$WORK" commit -q -am "promote: beta VERSION -> $BETAVER" || true
git -C "$WORK" push -q "$PRIV" beta:beta
git -C "$WORK" tag -f "v$BETAVER" >/dev/null; git -C "$WORK" push -fq "$PRIV" "v$BETAVER"
echo "OK -> beta = $BETAVER (тег v$BETAVER)"
;;
beta2stable)
STABLEVER="${2:-}"
[ -n "$STABLEVER" ] || { echo "Укажи версию: ./promote.sh beta2stable 1.3.0"; exit 1; }
init_work
echo "== Релиз beta -> stable (v$STABLEVER) =="
co_branch main
git -C "$WORK" fetch -q "$PRIV" beta
do_merge "release: merge beta into stable v$STABLEVER"
echo "$STABLEVER" > "$WORK/VERSION"
git -C "$WORK" commit -q -am "release: stable VERSION -> $STABLEVER" || true
git -C "$WORK" push -q "$PUB" main:main
git -C "$WORK" tag -f "v$STABLEVER" >/dev/null; git -C "$WORK" push -fq "$PUB" "v$STABLEVER"
echo "OK -> stable = $STABLEVER (тег v$STABLEVER). Дальше: ./promote.sh forward"
;;
forward)
init_work
echo "== Forward-port stable -> beta -> dev =="
# main -> beta
co_branch beta
BETAVER=$(ver_now)
git -C "$WORK" fetch -q "$PUB" main
do_merge "forward: merge stable into beta"
echo "$BETAVER" > "$WORK/VERSION"; git -C "$WORK" commit -q -am "forward: keep beta VERSION $BETAVER" || true
git -C "$WORK" push -q "$PRIV" beta:beta
echo " beta <- stable OK"
# beta -> dev
co_branch dev
DEVVER=$(ver_now)
git -C "$WORK" fetch -q "$PRIV" beta
do_merge "forward: merge beta into dev"
echo "$DEVVER" > "$WORK/VERSION"; git -C "$WORK" commit -q -am "forward: keep dev VERSION $DEVVER" || true
git -C "$WORK" push -q "$PRIV" dev:dev
echo " dev <- beta OK. Forward-port завершён."
;;
*)
echo "promote.sh — продвижение каналов PCA Phobos"
echo " status версии всех каналов"
echo " dev2beta dev -> beta (кандидат)"
echo " beta2stable <version> beta -> stable (релиз + тег)"
echo " forward stable -> beta -> dev (после хотфикса)"
;;
esac

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():
@@ -25,11 +69,16 @@ def health():
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5) out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
peer_keys = [] peer_keys = []
handshakes = {} handshakes = {}
transfer = {}
for line in out.strip().split("\n")[1:]: for line in out.strip().split("\n")[1:]:
parts = line.split("\t") parts = line.split("\t")
if len(parts) >= 4: if len(parts) >= 4:
pub = parts[0] pub = parts[0]
peer_keys.append(pub) peer_keys.append(pub)
try:
transfer[pub] = [int(parts[5]), int(parts[6])]
except Exception:
pass
try: try:
handshakes[pub] = int(parts[4]) handshakes[pub] = int(parts[4])
except Exception: except Exception:
@@ -39,18 +88,46 @@ def health():
peers = 0 peers = 0
peer_keys = [] peer_keys = []
handshakes = {} handshakes = {}
transfer = {}
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, "transfer": transfer, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
@app.route("/api/obf-health")
def obf_health():
"""Return 200 only when all local wg-obfuscator-* services are active."""
try:
listing = subprocess.check_output(
["systemctl", "list-units", "--type=service", "--no-legend",
"wg-obfuscator-*"], text=True, timeout=5)
names = [ln.split()[0] for ln in listing.splitlines() if ln.strip()]
except Exception:
names = []
if not names:
env = load_env()
ports = [p.strip() for p in env.get("OBFUSCATOR_PORTS", "2083,5443,993").split(",") if p.strip()]
names = [f"wg-obfuscator-{p}.service" for p in ports]
bad = []
for name in names:
status = subprocess.getoutput("systemctl is-active " + name).strip()
if status != "active":
bad.append(name + "=" + status)
if bad:
return ("obf-down: " + ",".join(bad), 503, {"Content-Type": "text/plain"})
return ("obf-ok " + str(len(names)), 200, {"Content-Type": "text/plain"})
@app.route("/api/peers", methods=["GET"]) @app.route("/api/peers", methods=["GET"])
def list_peers(): def list_peers():
@@ -67,11 +144,96 @@ 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
# --- Google geo (09.30): how Google sees the server's addresses ---------------
GOOGLE_CLOSED = {"Russia", "Belarus", "China", "Iran", "Hong Kong", "North Korea", "Cuba", "Syria"}
def _google_country(*args):
"""«Country version» at the bottom of policies.google.com/terms = the country Google assigns to the address."""
import re as _re
import subprocess as _sp
try:
html = _sp.run(["curl", *args, "-s", "-m", "15", "https://policies.google.com/terms?hl=en"],
capture_output=True, text=True, timeout=25).stdout
m = _re.search(r"Country version:\s*</a>\s*([A-Za-z][A-Za-z .'-]*?)\s*<", html)
return m.group(1) if m else None
except Exception:
return None
def google_geo():
"""v4/v6 = default route (what clients exit with); ips = every public IPv4 on the server."""
import re as _re
import subprocess as _sp
import time as _t
out = {"v4": _google_country("-4"), "v6": _google_country("-6"), "ips": {}, "ts": int(_t.time())}
try:
for line in _sp.run(["ip", "-4", "-o", "addr", "show", "scope", "global"], capture_output=True,
text=True, timeout=10).stdout.splitlines():
ip = line.split()[3].split("/")[0]
if not _re.match(r"^(10|127|172\.(1[6-9]|2\d|3[01])|192\.168|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7]))\.", ip):
out["ips"][ip] = _google_country("-4", "--interface", ip)
except Exception:
pass
return out
@app.route("/api/google-geo")
def api_google_geo():
if not check_api_key():
return jsonify({"error": "unauthorized"}), 401
return jsonify(google_geo())
@app.route("/api/limits", methods=["POST"])
def set_limits():
"""Per-client speed limits pushed by the main panel ("<tunnel_ip> <mbit>" lines)."""
if not check_api_key():
return jsonify({"error": "unauthorized"}), 401
text = (request.get_json(force=True, silent=True) or {}).get("limits", "")
Path("/opt/Phobos/server/limits.conf").write_text(text)
ensure_script(LIMIT_SCRIPT)
try:
r = subprocess.run([LIMIT_SCRIPT, "apply"], capture_output=True, text=True, timeout=30)
return jsonify({"status": "ok" if r.returncode == 0 else "error", "out": (r.stdout + r.stderr)[-500:]})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/peers/add", methods=["POST"]) @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 +249,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 +279,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

43
server/phobos-altip.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/sh
# PCA Phobos — второй/запасной IP сервера (09.33).
# wg-obfuscator и udptcp слушают 0.0.0.0, но отвечают с основного IP, а провайдер
# клиента может резать именно основной. DNAT «второй IP:порт → основной IP:порт»
# заставляет ответ уходить с того адреса, на который пришёл запрос.
# phobos-altip apply — правила для всех доп. IPv4 сервера (служба делает это при старте)
# phobos-altip status — что настроено
# phobos-altip enable — поставить службу phobos-altip
ENV=/opt/Phobos/server/server.env
[ -f "$ENV" ] || ENV=/opt/Phobos/server.env
get() { grep -E "^$1=" "$ENV" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"; }
MAIN=$(get SERVER_PUBLIC_IP_V4)
PORTS=$(get OBFUSCATOR_PORTS); [ -n "$PORTS" ] || PORTS=2083,5443,993
TCP=$(get TCP_PORT)
alts() { ip -4 -o addr show scope global | awk '{split($4,a,"/"); print a[1]}' | grep -vxF "$MAIN" | grep -v '^10\.\|^172\.\(1[6-9]\|2[0-9]\|3[01]\)\.\|^192\.168\.'; }
rule() { # $1 proto $2 alt $3 port
iptables -t nat -C PREROUTING -d "$2" -p "$1" --dport "$3" -j DNAT --to-destination "$MAIN:$3" 2>/dev/null \
|| iptables -t nat -I PREROUTING -d "$2" -p "$1" --dport "$3" -j DNAT --to-destination "$MAIN:$3"
}
case "$1" in
apply)
[ -n "$MAIN" ] || { echo "SERVER_PUBLIC_IP_V4 not set"; exit 1; }
for a in $(alts); do
for p in $(echo "$PORTS" | tr ',' ' '); do rule udp "$a" "$p"; done
[ -n "$TCP" ] && rule tcp "$a" "$TCP"
echo "alt $a -> $MAIN (udp $PORTS${TCP:+, tcp $TCP})"
done ;;
status) echo "main: $MAIN"; echo "alt: $(alts | tr '\n' ' ')"; iptables -t nat -S PREROUTING | grep -- "--to-destination $MAIN:" ;;
enable)
cat > /etc/systemd/system/phobos-altip.service <<UNIT
[Unit]
Description=PCA Phobos: second IP answers from itself (DNAT)
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh $(readlink -f "$0") apply
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload && systemctl enable --now phobos-altip ;;
*) echo "usage: $0 apply|status|enable"; exit 1 ;;
esac

93
server/phobos-backup.sh Executable file
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

@@ -6,11 +6,19 @@
# ============================================================ # ============================================================
PHOBOS_DIR="/opt/etc/Phobos" PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf" # 02.11.2026: несколько учёток на одном роутере. Файлы учётки (failover.conf, state,
STATE="$PHOBOS_DIR/state" # client_id, panel_*) — в PHOBOS_ACC (доп. учётки: $PHOBOS_DIR/acc/<клиент>),
LOG="$PHOBOS_DIR/health.log" # главная — прямо в $PHOBOS_DIR. Конфиги обфускаторов общие, свой ищем по *.link.
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
CONF="$ACC_DIR/failover.conf"
STATE="$ACC_DIR/state"
LOG="$ACC_DIR/health.log"
CID=$(cat "$ACC_DIR/client_id" 2>/dev/null)
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf" OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
LOCKFILE="/tmp/phobos-health.lock" for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
[ -n "$CID" ] && [ "$(cat "$l" 2>/dev/null)" = "$CID" ] && OBF_CONF="${l%.link}.conf" && break
done
LOCKFILE="/tmp/phobos-health${PHOBOS_ACC:+-$CID}.lock"
WG_IF="Wireguard3" WG_IF="Wireguard3"
MAX_LOG_LINES=200 MAX_LOG_LINES=200
@@ -25,7 +33,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 "$ACC_DIR/panel_scheme" 2>/dev/null)}"
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$ACC_DIR/panel_port" 2>/dev/null)}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
PRIMARY_PROBE_PATH="/api/obf-health" 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"
@@ -52,6 +63,16 @@ mkdir -p "$STATE"
# ── Detect WG interface ── # ── Detect WG interface ──
detect_wg_interface() { detect_wg_interface() {
# точное совпадение "Phobos-<клиент>"; доп. учётка не должна схватить чужой интерфейс
for i in 0 1 2 3 4 5 6 7 8 9; do
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
# HydraRoute Neo names the interface by the bare client id (09.33)
if [ -n "$CID" ] && echo "$desc" | grep -qE "(Phobos-|: )${CID}\$"; then
WG_IF="Wireguard${i}"
return 0
fi
done
[ -n "$PHOBOS_ACC" ] && return 1
for i in 0 1 2 3 4 5 6 7 8 9; do for i in 0 1 2 3 4 5 6 7 8 9; do
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1) desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
if echo "$desc" | grep -qi phobos; then if echo "$desc" | grep -qi phobos; then
@@ -62,6 +83,19 @@ detect_wg_interface() {
return 1 return 1
} }
# ── Restart own obfuscator (init script whose ARGS name our config) ──
restart_obf() {
sc=$(grep -l "/$(basename "$OBF_CONF")\"\$" /opt/etc/init.d/S*obfuscator* 2>/dev/null | head -1)
[ -z "$sc" ] && [ -f /opt/etc/init.d/S49wg-obfuscator ] && sc=/opt/etc/init.d/S49wg-obfuscator
if [ -n "$sc" ]; then
"$sc" restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
}
# ── Read failover config ── # ── Read failover config ──
if [ ! -f "$CONF" ]; then if [ ! -f "$CONF" ]; then
log "ERROR: no failover.conf" log "ERROR: no failover.conf"
@@ -77,6 +111,7 @@ while true; do
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)" eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)" eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)" eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
eval "SERVER_${idx}_TCP=$(grep "^TCP_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
SERVER_COUNT=$idx SERVER_COUNT=$idx
idx=$((idx + 1)) idx=$((idx + 1))
done done
@@ -91,12 +126,26 @@ CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0") CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0") FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0") PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
TCP_MODE=$(cat "$STATE/tcp_mode" 2>/dev/null || echo "0") # 0 = UDP, N = TCP via server N
CYCLES=$(cat "$STATE/udp_cycles" 2>/dev/null || echo "0") # full UDP rounds without success
UDPTCP_BIN=/opt/bin/udptcp
UDPTCP_LPORT=13256
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ── # ── Detect Phobos WG interface ──
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим (так сторож
# удалённой доп. учётки вписал пир Phobos в посторонний туннель).
detect_wg_interface || { detect_wg_interface || {
log "WARN: detect failed, using default $WG_IF" log "ERROR: own WG interface not found, skip"
exit 0
} }
# ── Panel tunnel IP must go through our tunnel (HydraRoute-made interface has no
# such route → pull went to WAN and hung, 09.33) ──
PT_IP=$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)
if [ -n "$PT_IP" ] && ip route get "$PT_IP" 2>/dev/null | grep -q "dev ppp\|dev eth\|dev wan"; then
ndmc -c "ip route $PT_IP 255.255.255.255 $WG_IF auto" >/dev/null 2>&1 && log "ROUTE: $PT_IP via $WG_IF"
fi
# ── Get handshake age via ndmc ── # ── Get handshake age via ndmc ──
# A peer switch can leave the removed peer's stale handshake listed in ndmc # A peer switch can leave the removed peer's stale handshake listed in ndmc
# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum # output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum
@@ -146,9 +195,13 @@ list_iface_keys() {
count_server_peers() { count_server_peers() {
present=$(list_iface_keys) present=$(list_iface_keys)
n=0 n=0
seen=" "
i=1 i=1
while [ "$i" -le "$SERVER_COUNT" ]; do while [ "$i" -le "$SERVER_COUNT" ]; do
eval "wk=\$SERVER_${i}_WGKEY" eval "wk=\$SERVER_${i}_WGKEY"
# one server may have several addresses = same key in several entries
case "$seen" in *" $wk "*) i=$((i + 1)); continue ;; esac
seen="$seen$wk "
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
n=$((n + 1)) n=$((n + 1))
fi fi
@@ -186,9 +239,18 @@ switch_wg_peer() {
# Add the target peer if it is not already present # Add the target peer if it is not already present
if ! echo "$present" | grep -q "^${new_key}$"; then if ! echo "$present" | grep -q "^${new_key}$"; then
log "WG PEER add: $new_key" log "WG PEER add: $new_key"
curl -s -X POST "http://localhost:79/rci/" \ # 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
-H "Content-Type: application/json" \ # берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
-d "{\"interface\":{\"${WG_IF}\":{\"wireguard\":{\"peer\":{\"key\":\"${new_key}\",\"comment\":\"Phobos VPS Server\",\"endpoint\":{\"address\":\"127.0.0.1:13255\"},\"keepalive-interval\":{\"interval\":25},\"allow-ips\":[{\"address\":\"0.0.0.0\",\"mask\":\"0.0.0.0\"},{\"address\":\"::\",\"mask\":\"0\"}]}}}}}" >/dev/null 2>&1 # Через ndmc по шагам: JSON-RCI с allow-ips "::"/0 молча не ставил allow-ips.
lport=$(grep 'source-lport' "$OBF_CONF" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
lport=${lport:-13255}
ndmc -c "interface $WG_IF wireguard peer $new_key" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key endpoint 127.0.0.1:$lport" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
fi fi
# Persist config # Persist config
@@ -212,6 +274,7 @@ switch_endpoint() {
fi fi
log "SWITCH → server $server_idx ($host:$port)" log "SWITCH → server $server_idx ($host:$port)"
stop_tcp
# 1. Switch WG peer key if different server # 1. Switch WG peer key if different server
switch_wg_peer "$wg_key" switch_wg_peer "$wg_key"
@@ -225,13 +288,7 @@ switch_endpoint() {
fi fi
# 3. Restart obfuscator # 3. Restart obfuscator
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then restart_obf
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
# 4. Save state (fail_count is managed by the caller, NOT reset here — # 4. Save state (fail_count is managed by the caller, NOT reset here —
# resetting on a port-hop would prevent escalation to server failover) # resetting on a port-hop would prevent escalation to server failover)
@@ -239,6 +296,45 @@ switch_endpoint() {
echo "$port_idx" > "$STATE/current_port_idx" echo "$port_idx" > "$STATE/current_port_idx"
} }
# ── TCP channel (09.33/1.5.0): obfuscated UDP inside TCP via local udptcp ──
stop_tcp() {
[ "$TCP_MODE" = "0" ] && ! pidof udptcp >/dev/null 2>&1 && return 0
killall udptcp 2>/dev/null
TCP_MODE=0
echo 0 > "$STATE/tcp_mode"
}
# start_tcp N — tunnel through server N's TCP port; 1 = started
start_tcp() {
n=$1
eval "host=\$SERVER_${n}_HOST"
eval "tport=\$SERVER_${n}_TCP"
eval "obf_key=\$SERVER_${n}_KEY"
eval "wg_key=\$SERVER_${n}_WGKEY"
[ -n "$tport" ] && [ -x "$UDPTCP_BIN" ] || return 1
killall udptcp 2>/dev/null
"$UDPTCP_BIN" -c "127.0.0.1:${UDPTCP_LPORT}" "${host}:${tport}" >/dev/null 2>&1 &
switch_wg_peer "$wg_key"
sed -i "s|^target = .*|target = 127.0.0.1:${UDPTCP_LPORT}|" "$OBF_CONF"
[ -n "$obf_key" ] && sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
restart_obf
TCP_MODE=$n
echo "$n" > "$STATE/tcp_mode"
echo "$n" > "$STATE/current_server"
log "TCP MODE → server $n (${host}:${tport} over TCP)"
return 0
}
# try_tcp_after N — first server after N (no wrap) that has a TCP port; 1 = none
try_tcp_after() {
i=$(( $1 + 1 ))
while [ "$i" -le "$SERVER_COUNT" ]; do
start_tcp "$i" && return 0
i=$((i + 1))
done
return 1
}
# ── Try next port on current server ── # ── Try next port on current server ──
try_next_port() { try_next_port() {
port_count=$(get_port_count "$CURRENT_SERVER") port_count=$(get_port_count "$CURRENT_SERVER")
@@ -253,7 +349,9 @@ try_next_port() {
# ── Try next server ── # ── Try next server ──
try_next_server() { try_next_server() {
next=$((CURRENT_SERVER + 1)) next=$((CURRENT_SERVER + 1))
[ "$next" -gt "$SERVER_COUNT" ] && next=1 if [ "$next" -gt "$SERVER_COUNT" ]; then
next=1
fi
[ "$next" -eq "$CURRENT_SERVER" ] && return 1 [ "$next" -eq "$CURRENT_SERVER" ] && return 1
log "FAILOVER → server $next" log "FAILOVER → server $next"
@@ -263,7 +361,7 @@ try_next_server() {
# ── Check if primary is back ── # ── Check if primary is back ──
check_primary() { check_primary() {
[ "$CURRENT_SERVER" -eq 1 ] && return [ "$CURRENT_SERVER" -eq 1 ] && [ "$TCP_MODE" = "0" ] && return
now=$(date +%s) now=$(date +%s)
elapsed=$((now - PRIMARY_CHECK_TS)) elapsed=$((now - PRIMARY_CHECK_TS))
@@ -274,13 +372,25 @@ 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
# HTTP alive does not mean UDP passes (провайдер режет UDP к IP, 09.33):
# пробуем, а если рукопожатия не будет — trial_check вернёт как было.
log "PRIMARY ($ok_url http=200) alive, TRIAL switch back"
echo "$CURRENT_SERVER $CURRENT_PORT_IDX $TCP_MODE $(date +%s)" > "$STATE/trial"
switch_endpoint 1 0 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
} }
@@ -318,6 +428,8 @@ heal_lan_routing() {
while read -r mac pol; do while read -r mac pol; do
[ -z "$mac" ] && continue [ -z "$mac" ] && continue
case "$mac" in \#*) continue;; esac case "$mac" in \#*) continue;; esac
# политику удалили — не воскрешаем привязку к ней
echo "$rc" | grep -qx "ip policy $pol" || continue
if ! echo "$rc" | grep -q "host $mac policy $pol"; then if ! echo "$rc" | grep -q "host $mac policy $pol"; then
ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1 ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1
log "HEAL: re-bound host $mac -> $pol" log "HEAL: re-bound host $mac -> $pol"
@@ -394,12 +506,31 @@ fi
AGE=$(get_handshake_age) AGE=$(get_handshake_age)
WAN=$(check_connectivity && echo "yes" || echo "no") WAN=$(check_connectivity && echo "yes" || echo "no")
# ── Trial of the primary: fresh handshake within ~2 min or go back ──
if [ -f "$STATE/trial" ]; then
read t_srv t_port t_tcp t_ts < "$STATE/trial"
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
log "TRIAL ok: primary works"
rm -f "$STATE/trial"; echo 0 > "$STATE/udp_cycles"
elif [ $(( $(date +%s) - ${t_ts:-0} )) -ge 110 ]; then
rm -f "$STATE/trial"
log "TRIAL failed: back to server $t_srv"
if [ "${t_tcp:-0}" != "0" ]; then start_tcp "$t_tcp" || switch_endpoint "$t_srv" "$t_port"
else switch_endpoint "$t_srv" "$t_port"; fi
echo 0 > "$STATE/fail_count"
exit 0
else
exit 0 # give the trial time
fi
fi
# Tunnel healthy = fresh handshake # Tunnel healthy = fresh handshake
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
if [ "$FAIL_COUNT" -gt 0 ]; then if [ "$FAIL_COUNT" -gt 0 ]; then
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)" log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
echo "0" > "$STATE/fail_count" echo "0" > "$STATE/fail_count"
fi fi
echo 0 > "$STATE/udp_cycles"
check_primary check_primary
exit 0 exit 0
fi fi
@@ -415,23 +546,30 @@ log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PO
# Stage 1 (fail 1): Restart obfuscator # Stage 1 (fail 1): Restart obfuscator
if [ "$FAIL_COUNT" -eq 1 ]; then if [ "$FAIL_COUNT" -eq 1 ]; then
log "ACTION: restart obfuscator" log "ACTION: restart obfuscator"
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then restart_obf
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
exit 0 exit 0
fi fi
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server # TCP mode went stale → back to the UDP round (next server)
if [ "$FAIL_COUNT" -eq 2 ]; then if [ "$TCP_MODE" != "0" ] && [ "$FAIL_COUNT" -ge 2 ]; then
log "TCP stale on server $TCP_MODE"
if try_tcp_after "$TCP_MODE"; then echo 0 > "$STATE/fail_count"; exit 0; fi
stop_tcp
switch_endpoint 1 0; echo 0 > "$STATE/fail_count"; exit 0
fi
# Stage 2 (fail 2+): hop through ALL ports of the SAME server (was: only one)
if [ "$FAIL_COUNT" -ge 2 ]; then
if try_next_port; then if try_next_port; then
exit 0 exit 0
fi fi
# only one port → fall through to server failover log "ports of server $CURRENT_SERVER exhausted, escalate to failover"
log "single port, escalate to failover" fi
# Whole UDP round failed (all servers × ports) → TCP, if any server offers it
if [ "$CURRENT_SERVER" -eq "$SERVER_COUNT" ]; then
CYCLES=$((CYCLES + 1)); echo "$CYCLES" > "$STATE/udp_cycles"
if try_tcp_after 0; then echo 0 > "$STATE/fail_count"; exit 0; fi
fi fi
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server # Stage 3 (fail 3+): Server failover. Reset fail_count so the new server

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

@@ -1,4 +1,5 @@
#!/bin/sh #!/bin/sh
export PATH=/opt/bin:/opt/sbin:/opt/usr/bin:$PATH # cron без /opt/bin -> busybox wget висел вечно (09.29)
# ============================================================ # ============================================================
# Phobos Config Pull — NAT-friendly management channel. # Phobos Config Pull — NAT-friendly management channel.
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY: # Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
@@ -21,21 +22,30 @@
# PANEL env or default below -> panel base URL # PANEL env or default below -> panel base URL
# ============================================================ # ============================================================
PHOBOS_DIR="/opt/etc/Phobos" PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf" # 02.11.2026: доп. учётки — PHOBOS_ACC=$PHOBOS_DIR/acc/<клиент> (см. phobos-health.sh)
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
CONF="$ACC_DIR/failover.conf"
HEALTH="$PHOBOS_DIR/phobos-health.sh" HEALTH="$PHOBOS_DIR/phobos-health.sh"
LOG="$PHOBOS_DIR/health.log" LOG="$ACC_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 "$ACC_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"
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home") PANEL="${PANEL:-$(cat "$ACC_DIR/panel_url" 2>/dev/null)}"
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null) PANEL_URLS="${PANEL_URLS:-$(cat "$ACC_DIR/panel_urls" 2>/dev/null)}"
TMP="/tmp/failover.conf.pull" [ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
LOCK="/tmp/phobos-pull.lock" # + public URLs the panel announced in failover.conf (second IP etc., 09.33)
_extra=$(grep "^PANEL_URLS_EXTRA=" "$CONF" 2>/dev/null | cut -d= -f2-)
for _u in $_extra; do case " $PANEL_URLS " in *" $_u "*) ;; *) PANEL_URLS="$PANEL_URLS $_u" ;; esac; done
CLIENT_ID=$(cat "$ACC_DIR/client_id" 2>/dev/null || echo "home")
TOKEN=$(cat "$ACC_DIR/pull_token" 2>/dev/null)
TMP="/tmp/failover.conf.pull${PHOBOS_ACC:+-$CLIENT_ID}"
LOCK="/tmp/phobos-pull${PHOBOS_ACC:+-$CLIENT_ID}.lock"
log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; } log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; }
@@ -49,12 +59,46 @@ 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 -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
}
# TCP channel for routers (1.5.0): the guard needs /opt/bin/udptcp when failover.conf has TCP_N.
ensure_udptcp() {
[ -x /opt/bin/udptcp ] && return 0
grep -q "^TCP_[0-9]*=" "$CONF" 2>/dev/null || return 0
case "$(uname -m)" in aarch64) a=aarch64 ;; mips*) a=mipsel ;; *) return 0 ;; esac
command -v curl >/dev/null || return 0
{ curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/udptcp/bin/udptcp-$a" 2>/dev/null \
|| curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/udptcp/bin/udptcp-$a" 2>/dev/null; } \
&& [ -s /opt/bin/udptcp.tmp ] && chmod 755 /opt/bin/udptcp.tmp && mv /opt/bin/udptcp.tmp /opt/bin/udptcp \
&& log "PULL: udptcp installed ($a)" || rm -f /opt/bin/udptcp.tmp
}
# One fetch + compare + apply pass. Returns 0 always (best-effort). # One fetch + compare + apply pass. Returns 0 always (best-effort).
do_pull() { do_pull() {
ensure_udptcp
# 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 +121,48 @@ 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
# только свой обфускатор: у других учёток свои failover.conf
own=""
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
[ "$(cat "$l" 2>/dev/null)" = "$CLIENT_ID" ] && own="${l%.link}.conf"
done
[ -z "$own" ] && [ -z "$PHOBOS_ACC" ] && own="$PHOBOS_DIR/wg-obfuscator*.conf"
for OBF in $own; do
[ -f "$OBF" ] || continue
cur=$(grep "^masking = " "$OBF" 2>/dev/null | sed 's/masking = //')
[ "$cur" != "$M" ] && { sed -i "s/^masking = .*/masking = $M/" "$OBF"; changed=1; }
if [ -n "$D" ]; then
cur=$(grep "^max-dummy = " "$OBF" 2>/dev/null | sed 's/max-dummy = //')
[ "$cur" != "$D" ] && { sed -i "s/^max-dummy = .*/max-dummy = $D/" "$OBF"; changed=1; }
fi
if [ -n "$I" ]; then
cur=$(grep "^idle-timeout = " "$OBF" 2>/dev/null | sed 's/idle-timeout = //')
[ "$cur" != "$I" ] && { sed -i "s/^idle-timeout = .*/idle-timeout = $I/" "$OBF"; changed=1; }
fi
done
[ "$changed" = 0 ] && return 0
log "PULL: obf params applied (masking=$M dummy=${D:-} idle=${I:-}), restart obfuscator"
if ls /opt/etc/init.d/S*obfuscator* >/dev/null 2>&1; then
for sc in /opt/etc/init.d/S*obfuscator*; do
for OBF in $own; do grep -q "/$(basename "$OBF")\"\$" "$sc" && [ -x "$sc" ] && "$sc" restart >/dev/null 2>&1; done
done
elif command -v systemctl >/dev/null 2>&1; then
systemctl restart 'wg-obfuscator*' >/dev/null 2>&1 || true
else
killall wg-obfuscator 2>/dev/null; sleep 1
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; do [ -f "$OBF" ] && wg-obfuscator --config "$OBF" >/dev/null 2>&1 & done
fi
} }
# Inner loop: cron fires this every 60s, but we poll ~4x per minute so a # Inner loop: cron fires this every 60s, but we poll ~4x per minute so a

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

54
server/phobos-v6relay.py Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""IPv6 -> local IPv4 UDP relay for the obfuscator (it speaks IPv4 only).
Usage: phobos-v6relay.py LISTEN_PORT [TARGET_PORT] [TARGET_HOST]
One upstream socket per IPv6 client, so the obfuscator sees a stable
client address for the whole session. Idle clients are dropped after 300 s.
"""
import selectors
import socket
import sys
import time
IDLE = 300
listen_port = int(sys.argv[1])
target = (sys.argv[3] if len(sys.argv) > 3 else "127.0.0.1",
int(sys.argv[2]) if len(sys.argv) > 2 else listen_port)
down = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
down.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
down.bind(("::", listen_port))
sel = selectors.DefaultSelector()
sel.register(down, selectors.EVENT_READ, None)
clients = {} # v6 addr -> [upstream socket, last seen]
while True:
for key, _ in sel.select(timeout=30):
if key.data is None:
data, addr = down.recvfrom(65535)
c = clients.get(addr)
if c is None:
up = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
up.connect(target)
sel.register(up, selectors.EVENT_READ, addr)
c = clients[addr] = [up, 0]
c[1] = time.monotonic()
try:
c[0].send(data)
except OSError:
pass
else:
addr = key.data
try:
down.sendto(key.fileobj.recv(65535), addr)
clients[addr][1] = time.monotonic()
except OSError:
pass
now = time.monotonic()
for addr, (up, seen) in list(clients.items()):
if now - seen > IDLE:
sel.unregister(up)
up.close()
del clients[addr]

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

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

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,50 @@ 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}"
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
fi
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
import ipaddress, sys
try:
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
except ValueError:
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
# Считаем, что нужна подсеть этого адреса, и берём /24.
try:
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
except ValueError:
sys.exit("ERROR: bad subnet " + sys.argv[1])
if net.version != 4 or not net.is_private or net.prefixlen > 29:
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
print(net)
PY
)" || exit 1
PHOBOS_DIR="/opt/Phobos" 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 +68,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 +144,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 +166,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 +186,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 +231,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 +297,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/adleibzon-rgb/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 +362,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 +438,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;
}

73
uninstall.sh Executable file
View File

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

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