Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f4f334a5 | ||
|
|
ad839e4cad | ||
|
|
28986677eb | ||
|
|
af4c269438 | ||
|
|
528d799edc | ||
|
|
31978f22f0 | ||
|
|
c5e9e45141 | ||
|
|
51ec84db6c | ||
|
|
d030344b00 | ||
|
|
8dc9fda65b | ||
|
|
8bfc84f315 | ||
|
|
381e97a564 | ||
|
|
eefe3fc2af | ||
|
|
15011c2756 | ||
|
|
29790aaf4d | ||
|
|
884df15f42 | ||
|
|
eb2b2768cd | ||
|
|
147ef3a42d | ||
|
|
dde6403cea |
3
APPS
Normal file
3
APPS
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Android (PCA Phobos VPN)=1.4.6
|
||||||
|
iPhone (TestFlight)=1.4.5
|
||||||
|
Windows=1.4.5-beta
|
||||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,3 +1,10 @@
|
|||||||
|
## 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
|
# Changelog — PCA Phobos
|
||||||
|
|
||||||
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
|
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
|
||||||
@@ -5,6 +12,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.4.10 — 2026-11-03
|
||||||
|
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
|
||||||
|
|
||||||
## Как обновиться после фиксов
|
## Как обновиться после фиксов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -19,6 +29,31 @@ phobos-update beta # или: phobos-update dev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.4.8 — сторож роутера не трогает чужие туннели
|
||||||
|
- Исправлено: если сторож не находил интерфейс Phobos (удалён или переименован), он брал Wireguard3 по умолчанию и прописывал туда пир Phobos — ломался посторонний WireGuard-туннель. Теперь он просто пропускает проверку.
|
||||||
|
- Роутерам: обновить скрипты из панели или переустановить клиента; серверу — `phobos-update stable`.
|
||||||
|
|
||||||
|
## 1.4.7 — роутеры Keenetic сами переключаются между серверами
|
||||||
|
- Исправлено: после переключения на другой сервер туннель на роутере переставал работать — пир прописывался на порт обфускатора 13255, а он мог слушать другой. Порт теперь берётся из `wg-obfuscator.conf`.
|
||||||
|
- Исправлено: проверка и обновление конфига по расписанию на Keenetic не запускались — задачи писались в `/opt/etc/crontab`, который демон не читает. Теперь busybox `crond` и crontab пользователя root.
|
||||||
|
- Исправлено: `phobos-pull` навсегда зависал на `wget`, если туннель не работает (таймаут 8 с).
|
||||||
|
- Роутерам: переустановить клиента или обновить скрипты из панели; серверу — `phobos-update stable`.
|
||||||
|
|
||||||
|
## 1.4.6 — роутеры снова получают смену адреса
|
||||||
|
- Исправлено: роутер получал 403 при pull конфига, если клиент создан через API/бота (`/api/client/ensure`) — смена адреса/сервера до него не доходила. Теперь принимается и общий ключ из пакета, и личный токен клиента. Роутеры переустанавливать не нужно — достаточно обновить панель.
|
||||||
|
|
||||||
|
## 1.4.5 — подсеть при установке, beta/dev по токену
|
||||||
|
|
||||||
|
- **feat:** beta и dev теперь берутся из приватного репозитория на Gitea (GitHub заблокирован): `phobos-update beta|dev` с токеном подписчика — в панели «Настройки → Версия и обновления → токен подписчика» или `GH_TOKEN=… phobos-update beta`. Без токена updater сообщает, что канал закрыт, и ничего не трогает.
|
||||||
|
- **fix:** при переустановке на сервер с готовым `wg0.conf` подсеть берётся из него.
|
||||||
|
|
||||||
|
- **feat:** подсеть VPN-клиентов задаётся при установке: `install.sh` и `secondary-setup.sh` спрашивают её в терминале (Enter = `10.25.0.0/16`) или берут `SERVER_WG_IPV4_NETWORK=10.25.1.0/24`. Неверная или публичная сеть — установка останавливается с ошибкой.
|
||||||
|
|
||||||
|
## 1.4.4 — канал «Облако» (через KeenDNS своего Keenetic)
|
||||||
|
- **Канал «Облако»** в Android PCA Phobos VPN 1.4.4 и iPhone (TestFlight 1.0 (6)): телефон → облако KeenDNS → ваш Keenetic → сервер. Помогает, когда сам сервер заблокирован, а сайты Keenetic открываются; белый IP роутеру не нужен.
|
||||||
|
- **wsgw** — шлюз WebSocket → UDP для Keenetic (Entware: aarch64, mipsel, mips) и Linux. Инструкция с картинками — [docs/cloud.md](docs/cloud.md).
|
||||||
|
- Панель: ссылки на приложения ведут на v1.4.4. Остальной сервер не менялся.
|
||||||
|
|
||||||
## 1.4.2 — настройка обфускатора сервера
|
## 1.4.2 — настройка обфускатора сервера
|
||||||
|
|
||||||
- **feat:** в панели «Настройки» → «Обфускатор сервера»: masking (AUTO/STUN/NONE), max-dummy, idle — записываются во все порты сервера, обфускатор перезапускается (клиенты переподключаются за секунды).
|
- **feat:** в панели «Настройки» → «Обфускатор сервера»: masking (AUTO/STUN/NONE), max-dummy, idle — записываются во все порты сервера, обфускатор перезапускается (клиенты переподключаются за секунды).
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
|
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
|
||||||
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
|
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
|
||||||
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
|
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
|
||||||
|
- **Канал «Облако»** — если сервер заблокирован целиком: телефон идёт через облако KeenDNS своего Keenetic. [Инструкция](docs/cloud.md).
|
||||||
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
|
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
|
||||||
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
|
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
|
||||||
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
|
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
|
||||||
@@ -98,6 +99,7 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install
|
|||||||
|-------------------|-----------------|----------|
|
|-------------------|-----------------|----------|
|
||||||
| `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-уведомления |
|
||||||
@@ -107,6 +109,7 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install
|
|||||||
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
||||||
|
|
||||||
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
|
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
|
||||||
|
DNS клиентов и свой AdGuard Home: [docs/DNS.md](docs/DNS.md). WARP — проверка и проблемы: [docs/WARP.md](docs/WARP.md).
|
||||||
|
|
||||||
### Вторичный сервер (для failover / балансировки)
|
### Вторичный сервер (для failover / балансировки)
|
||||||
|
|
||||||
@@ -137,8 +140,8 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/
|
|||||||
|
|
||||||
## Приложения для Android
|
## Приложения для Android
|
||||||
|
|
||||||
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.2/PCA-Phobos-VPN.apk) · исходники `android-client/`.
|
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.4/PCA-Phobos-VPN.apk) · исходники `android-client/`.
|
||||||
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.2/PCA-Phobos.apk) · исходники `android/`.
|
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.4/PCA-Phobos.apk) · исходники `android/`.
|
||||||
|
|
||||||
## Клиенты для телефона
|
## Клиенты для телефона
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.pca.phobos.vpn"
|
package="com.pca.phobos.vpn"
|
||||||
android:versionCode="143"
|
android:versionCode="144"
|
||||||
android:versionName="1.4.3">
|
android:versionName="1.4.4">
|
||||||
|
|
||||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ public class MainActivity extends Activity {
|
|||||||
static GoBackend backend;
|
static GoBackend backend;
|
||||||
static Process obfuscator, relay;
|
static Process obfuscator, relay;
|
||||||
static final int TCP_LPORT = 13256;
|
static final int TCP_LPORT = 13256;
|
||||||
static volatile String transport = ""; // "UDP" | "TCP" while up
|
static volatile String transport = ""; // "UDP" | "TCP" | "Облако" while up
|
||||||
|
static WsRelay wsRelay;
|
||||||
static final Tunnel TUNNEL = new Tunnel() {
|
static final Tunnel TUNNEL = new Tunnel() {
|
||||||
public String getName() { return "phobos"; }
|
public String getName() { return "phobos"; }
|
||||||
public void onStateChange(Tunnel.State s) { }
|
public void onStateChange(Tunnel.State s) { }
|
||||||
@@ -220,14 +221,14 @@ public class MainActivity extends Activity {
|
|||||||
private static final java.util.Set<String> OBF_KEYS = new java.util.HashSet<>(java.util.Arrays.asList(
|
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"));
|
"target", "key", "masking", "idle-timeout", "max-dummy", "static-bindings", "max-clients", "target-if", "allow-clean", "in-timeout", "resolve-interval"));
|
||||||
|
|
||||||
private String obfPart(String conf, boolean viaTcp) {
|
private String obfPart(String conf, boolean viaRelay) {
|
||||||
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
|
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
|
||||||
for (String ln : section(conf, "[instance]")) {
|
for (String ln : section(conf, "[instance]")) {
|
||||||
String k = key(ln);
|
String k = key(ln);
|
||||||
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
|
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…)
|
// configs from other Phobos builds carry keys our wg-obfuscator v1.6 rejects (obfuscate-bytes…)
|
||||||
if (!OBF_KEYS.contains(k)) continue;
|
if (!OBF_KEYS.contains(k)) continue;
|
||||||
if (viaTcp && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
|
if (viaRelay && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
|
||||||
sb.append(ln).append('\n');
|
sb.append(ln).append('\n');
|
||||||
}
|
}
|
||||||
sb.append("verbose = INFO\n");
|
sb.append("verbose = INFO\n");
|
||||||
@@ -243,6 +244,17 @@ public class MainActivity extends Activity {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** wss:// of the user's Keenetic (KeenDNS cloud subdomain -> wsgw): set in the app, or "# pca-ws = wss://…" in the link. */
|
||||||
|
private String wsUrl(String conf) {
|
||||||
|
String u = prefs.getString("ws_url", "");
|
||||||
|
if (!u.isEmpty()) return u;
|
||||||
|
for (String ln : conf.split("\n")) {
|
||||||
|
String t = ln.trim();
|
||||||
|
if (t.startsWith("#") && t.contains("pca-ws") && t.contains("=")) return val(t);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
private String target(String conf) {
|
private String target(String conf) {
|
||||||
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
||||||
return "?";
|
return "?";
|
||||||
@@ -267,24 +279,30 @@ public class MainActivity extends Activity {
|
|||||||
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start (or restart) obfuscator; via TCP it dials the local udptcp relay. */
|
/** Start (or restart) obfuscator; via TCP / cloud it dials the local relay on TCP_LPORT. */
|
||||||
private void startTransport(String conf, boolean viaTcp) throws Exception {
|
private void startTransport(String conf, String via) throws Exception {
|
||||||
stopObfuscator();
|
stopObfuscator();
|
||||||
String lib = getApplicationInfo().nativeLibraryDir;
|
String lib = getApplicationInfo().nativeLibraryDir;
|
||||||
|
boolean viaTcp = "tcp".equals(via), viaWs = "cloud".equals(via);
|
||||||
|
if (viaWs) {
|
||||||
|
if (wsUrl(conf).isEmpty()) throw new IllegalStateException("не задан адрес роутера (wss://…) для канала «Облако»");
|
||||||
|
wsRelay = new WsRelay(TCP_LPORT, wsUrl(conf), this::log);
|
||||||
|
wsRelay.start();
|
||||||
|
}
|
||||||
if (viaTcp) {
|
if (viaTcp) {
|
||||||
String host = target(conf).replaceAll(":\\d+$", "");
|
String host = target(conf).replaceAll(":\\d+$", "");
|
||||||
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
|
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();
|
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
|
||||||
}
|
}
|
||||||
File cfg = new File(getFilesDir(), "obf.conf");
|
File cfg = new File(getFilesDir(), "obf.conf");
|
||||||
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp).getBytes(StandardCharsets.UTF_8)); }
|
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp || viaWs).getBytes(StandardCharsets.UTF_8)); }
|
||||||
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
|
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
|
||||||
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
|
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
|
||||||
Thread.sleep(600);
|
Thread.sleep(600);
|
||||||
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
||||||
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
|
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
|
||||||
transport = viaTcp ? "TCP" : "UDP";
|
transport = viaWs ? "Облако" : viaTcp ? "TCP" : "UDP";
|
||||||
log("transport " + transport + " -> " + target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : ""));
|
log("transport " + transport + " -> " + (viaWs ? wsUrl(conf) : target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : "")));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void up() {
|
private void up() {
|
||||||
@@ -293,7 +311,7 @@ public class MainActivity extends Activity {
|
|||||||
String mode = prefs.getString("transport", "auto");
|
String mode = prefs.getString("transport", "auto");
|
||||||
boolean tcpAvail = !tcpPort(conf).isEmpty();
|
boolean tcpAvail = !tcpPort(conf).isEmpty();
|
||||||
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
|
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
|
||||||
startTransport(conf, "tcp".equals(mode));
|
startTransport(conf, "auto".equals(mode) ? "udp" : mode);
|
||||||
Config c = Config.parse(new BufferedReader(new StringReader(
|
Config c = Config.parse(new BufferedReader(new StringReader(
|
||||||
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
|
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
|
||||||
backend.setState(TUNNEL, Tunnel.State.UP, c);
|
backend.setState(TUNNEL, Tunnel.State.UP, c);
|
||||||
@@ -315,7 +333,7 @@ public class MainActivity extends Activity {
|
|||||||
if (!isUp() || !"UDP".equals(transport)) return;
|
if (!isUp() || !"UDP".equals(transport)) return;
|
||||||
if (backend.getStatistics(TUNNEL).totalRx() > 0) return;
|
if (backend.getStatistics(TUNNEL).totalRx() > 0) return;
|
||||||
log("UDP: no answer in 15 s -> switching to TCP");
|
log("UDP: no answer in 15 s -> switching to TCP");
|
||||||
startTransport(conf, true);
|
startTransport(conf, "tcp");
|
||||||
post("UDP не отвечает — переключился на TCP");
|
post("UDP не отвечает — переключился на TCP");
|
||||||
} catch (Throwable e) { log("watchUdp: " + e); }
|
} catch (Throwable e) { log("watchUdp: " + e); }
|
||||||
}).start();
|
}).start();
|
||||||
@@ -331,6 +349,7 @@ public class MainActivity extends Activity {
|
|||||||
private static void stopObfuscator() {
|
private static void stopObfuscator() {
|
||||||
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
|
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
|
||||||
if (relay != null) { relay.destroy(); relay = null; }
|
if (relay != null) { relay.destroy(); relay = null; }
|
||||||
|
if (wsRelay != null) { wsRelay.stop(); wsRelay = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
|
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
|
||||||
@@ -416,13 +435,16 @@ public class MainActivity extends Activity {
|
|||||||
RadioGroup tg = new RadioGroup(this);
|
RadioGroup tg = new RadioGroup(this);
|
||||||
tg.setOrientation(RadioGroup.HORIZONTAL);
|
tg.setOrientation(RadioGroup.HORIZONTAL);
|
||||||
String tmode = prefs.getString("transport", "auto");
|
String tmode = prefs.getString("transport", "auto");
|
||||||
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}}) {
|
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}, {"cloud", "Облако"}}) {
|
||||||
RadioButton r = new RadioButton(this);
|
RadioButton r = new RadioButton(this);
|
||||||
r.setText(m[1]);
|
r.setText(m[1]);
|
||||||
r.setTextColor(text());
|
r.setTextColor(text());
|
||||||
r.setId(View.generateViewId());
|
r.setId(View.generateViewId());
|
||||||
r.setChecked(m[0].equals(tmode));
|
r.setChecked(m[0].equals(tmode));
|
||||||
r.setOnClickListener(v -> { prefs.edit().putString("transport", m[0]).apply(); applyIfUp(); });
|
r.setOnClickListener(v -> {
|
||||||
|
prefs.edit().putString("transport", m[0]).apply();
|
||||||
|
if ("cloud".equals(m[0])) wsDialog(); else applyIfUp();
|
||||||
|
});
|
||||||
tg.addView(r);
|
tg.addView(r);
|
||||||
}
|
}
|
||||||
split.addView(tg);
|
split.addView(tg);
|
||||||
@@ -478,6 +500,22 @@ public class MainActivity extends Activity {
|
|||||||
.setNegativeButton("Отмена", null).show();
|
.setNegativeButton("Отмена", null).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Channel "Облако": address of the user's Keenetic web app (KeenDNS cloud) with wsgw behind it. */
|
||||||
|
private void wsDialog() {
|
||||||
|
final EditText e = new EditText(this);
|
||||||
|
e.setHint("wss://phobos.имя.keenetic.pro/");
|
||||||
|
e.setText(wsUrl(prefs.getString("conf", "")));
|
||||||
|
new AlertDialog.Builder(this).setTitle("Адрес роутера (KeenDNS)").setView(e)
|
||||||
|
.setMessage("Канал через облако Keenetic: телефон → облако KeenDNS → ваш роутер → сервер.")
|
||||||
|
.setPositiveButton("Сохранить", (d, w) -> {
|
||||||
|
String u = e.getText().toString().trim();
|
||||||
|
if (!u.isEmpty() && !u.startsWith("wss://")) u = "wss://" + u.replaceFirst("^\\w+://", "");
|
||||||
|
prefs.edit().putString("ws_url", u).apply();
|
||||||
|
applyIfUp();
|
||||||
|
})
|
||||||
|
.setNegativeButton("Отмена", null).show();
|
||||||
|
}
|
||||||
|
|
||||||
private void pickApps() {
|
private void pickApps() {
|
||||||
PackageManager pm = getPackageManager();
|
PackageManager pm = getPackageManager();
|
||||||
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
||||||
|
|||||||
157
android-client/src/com/pca/phobos/vpn/WsRelay.java
Normal file
157
android-client/src/com/pca/phobos/vpn/WsRelay.java
Normal 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
421
app.py
421
app.py
@@ -36,8 +36,8 @@ SECRET_FILE = f"{PANEL_DIR}/.secret_key"
|
|||||||
WG_CONFIG = "/etc/wireguard/wg0.conf"
|
WG_CONFIG = "/etc/wireguard/wg0.conf"
|
||||||
# Distribution: Gitea first (GitHub account is unavailable since 23.10.2026), GitHub fallback
|
# Distribution: Gitea first (GitHub account is unavailable since 23.10.2026), GitHub fallback
|
||||||
PCA_GIT = "https://git.bobyrev.com/PCA/PCA_Phobos"
|
PCA_GIT = "https://git.bobyrev.com/PCA/PCA_Phobos"
|
||||||
PCA_GH_RAW = "https://raw.githubusercontent.com/andrey271192/PCA_Phobos"
|
PCA_GH_RAW = "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos"
|
||||||
APK_TAG = "v1.4.2"
|
APK_TAG = "v1.4.4"
|
||||||
WARP_DIR = f"{PHOBOS_DIR}/server/warp"
|
WARP_DIR = f"{PHOBOS_DIR}/server/warp"
|
||||||
WARP_CUSTOM_PROFILE_FILE = f"{WARP_DIR}/custom-profile.conf"
|
WARP_CUSTOM_PROFILE_FILE = f"{WARP_DIR}/custom-profile.conf"
|
||||||
WARP_LICENSE_FILE = f"{WARP_DIR}/license.key"
|
WARP_LICENSE_FILE = f"{WARP_DIR}/license.key"
|
||||||
@@ -802,6 +802,7 @@ _last_fanout = 0
|
|||||||
_last_peer_sync = 0
|
_last_peer_sync = 0
|
||||||
server_stats_cache = {}
|
server_stats_cache = {}
|
||||||
server_handshakes_cache = {}
|
server_handshakes_cache = {}
|
||||||
|
server_transfer_cache = {} # server ip -> {pub: [rx, tx]} (from secondary /api/health)
|
||||||
|
|
||||||
|
|
||||||
def count_client_peers():
|
def count_client_peers():
|
||||||
@@ -889,6 +890,7 @@ def get_remote_stats(server):
|
|||||||
raise Exception("no response")
|
raise Exception("no response")
|
||||||
# cache handshakes so page renders never block on remote HTTP
|
# cache handshakes so page renders never block on remote HTTP
|
||||||
server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {}
|
server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {}
|
||||||
|
server_transfer_cache[server.get("ip", "")] = data.get("transfer", {}) or {}
|
||||||
# Filter peers: only count known client public keys
|
# Filter peers: only count known client public keys
|
||||||
clients = get_clients()
|
clients = get_clients()
|
||||||
client_pubs = {c.get("public_key", "") for c in clients}
|
client_pubs = {c.get("public_key", "") for c in clients}
|
||||||
@@ -1069,15 +1071,15 @@ UPDATE_BIN = "/usr/local/bin/phobos-update"
|
|||||||
def _update_repo_for_ref(ref):
|
def _update_repo_for_ref(ref):
|
||||||
ref = (ref or "").strip()
|
ref = (ref or "").strip()
|
||||||
if ref in ("beta", "dev"):
|
if ref in ("beta", "dev"):
|
||||||
return "andrey271192/PCA_Phobos-dev", ref
|
return "adleibzon-rgb/PCA_Phobos-dev", ref
|
||||||
if ref in ("stable", "main", "") or ref.startswith("v"):
|
if ref in ("stable", "main", "") or ref.startswith("v"):
|
||||||
return "andrey271192/PCA_Phobos", "main" if ref in ("stable", "") else ref
|
return "adleibzon-rgb/PCA_Phobos", "main" if ref in ("stable", "") else ref
|
||||||
version = panel_version()
|
version = panel_version()
|
||||||
if "beta" in version:
|
if "beta" in version:
|
||||||
return "andrey271192/PCA_Phobos-dev", "beta"
|
return "adleibzon-rgb/PCA_Phobos-dev", "beta"
|
||||||
if "dev" in version:
|
if "dev" in version:
|
||||||
return "andrey271192/PCA_Phobos-dev", "dev"
|
return "adleibzon-rgb/PCA_Phobos-dev", "dev"
|
||||||
return "andrey271192/PCA_Phobos", "main"
|
return "adleibzon-rgb/PCA_Phobos", "main"
|
||||||
|
|
||||||
|
|
||||||
def _current_update_ref(arg):
|
def _current_update_ref(arg):
|
||||||
@@ -1196,6 +1198,34 @@ def client_endpoint(ip):
|
|||||||
|
|
||||||
|
|
||||||
_HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$")
|
_HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$")
|
||||||
|
DEFAULT_CLIENT_DNS = "1.1.1.1, 8.8.8.8"
|
||||||
|
|
||||||
|
|
||||||
|
def client_dns():
|
||||||
|
return _load_server_env().get("CLIENT_DNS", "").strip() or DEFAULT_CLIENT_DNS
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_client_dns(wg):
|
||||||
|
"""Issued .conf files keep the DNS they were born with; serve the current one."""
|
||||||
|
return re.sub(r"(?mi)^DNS\s*=.*$", "DNS = " + client_dns(), wg)
|
||||||
|
|
||||||
|
|
||||||
|
def save_client_dns(form):
|
||||||
|
raw = (form.get("client_dns", "") or "").replace(";", ",")
|
||||||
|
items = [x.strip() for x in raw.split(",") if x.strip()]
|
||||||
|
for x in items:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(x)
|
||||||
|
except ValueError:
|
||||||
|
return False, tr("Неверный DNS: ", "Bad DNS: ") + x
|
||||||
|
try:
|
||||||
|
lines = [ln for ln in open(SERVER_ENV).read().splitlines() if not ln.startswith("CLIENT_DNS=")]
|
||||||
|
if items and ", ".join(items) != DEFAULT_CLIENT_DNS:
|
||||||
|
lines.append("CLIENT_DNS=" + ",".join(items)) # no spaces: server.env is also `source`d
|
||||||
|
Path(SERVER_ENV).write_text("\n".join(lines) + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"server.env: {e}"
|
||||||
|
return True, "DNS = " + client_dns()
|
||||||
|
|
||||||
|
|
||||||
def save_client_endpoints(form):
|
def save_client_endpoints(form):
|
||||||
@@ -1249,6 +1279,93 @@ def run_speedtest(ip):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
|
||||||
|
_google_geo_busy = set()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_google_geo(ip):
|
||||||
|
"""Background refresh of google_geo for one server (main: locally, secondary: mini-API)."""
|
||||||
|
if ip in _google_geo_busy:
|
||||||
|
return
|
||||||
|
_google_geo_busy.add(ip)
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
if not ip or ip == SERVER_IP:
|
||||||
|
res = google_geo()
|
||||||
|
else:
|
||||||
|
srv = next((x for x in load_servers() if x.get("ip") == ip), None)
|
||||||
|
res = secondary_api_request(srv, "/api/google-geo", None, timeout=120) if srv else None
|
||||||
|
if res and "v4" in res:
|
||||||
|
s = load_settings()
|
||||||
|
s.setdefault("google_geo", {})[ip or SERVER_IP] = res
|
||||||
|
save_settings(s)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_google_geo_busy.discard(ip)
|
||||||
|
import threading as _th
|
||||||
|
_th.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def google_geo_html(ip):
|
||||||
|
"""Card lines: «Google видит: IPv4 X · IPv6 Y» (+ other IPv4), red + warning if Google marks it closed."""
|
||||||
|
import time as _t
|
||||||
|
g = load_settings().get("google_geo", {}).get(ip) or {}
|
||||||
|
if not g or _t.time() - g.get("ts", 0) > 3 * 3600:
|
||||||
|
refresh_google_geo(ip)
|
||||||
|
if not g:
|
||||||
|
return f'{tr("Google видит", "Google sees")}: <span>{tr("проверяется…", "checking…")}</span>'
|
||||||
|
def b(v):
|
||||||
|
col = "#e66" if v in GOOGLE_CLOSED else "var(--text)"
|
||||||
|
return f'<b style="color:{col}">{v or "—"}</b>'
|
||||||
|
others = " · ".join(f'{k} {b(v)}' for k, v in sorted((g.get("ips") or {}).items()) if k != ip)
|
||||||
|
ago = int((_t.time() - g.get("ts", 0)) / 60)
|
||||||
|
html = (f'{tr("Google видит", "Google sees")}: IPv4 {b(g.get("v4"))} · IPv6 {b(g.get("v6"))} '
|
||||||
|
f'<span style="opacity:.7">({ago} {tr("мин назад", "min ago")})</span>')
|
||||||
|
if others:
|
||||||
|
html += f'<br><span style="opacity:.8">{tr("другие IPv4", "other IPv4")}: {others}</span>'
|
||||||
|
bad = [v for v in (g.get("v4"), g.get("v6")) if v in GOOGLE_CLOSED]
|
||||||
|
if bad:
|
||||||
|
html += (f'<br><span style="color:#e66">{tr("Google считает адрес", "Google treats the address as")} «{", ".join(bad)}» — '
|
||||||
|
f'{tr("Gemini и часть сервисов Google у клиентов не откроются", "Gemini and some Google services will not open for clients")}</span>')
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
LIMITS_FILE = f"{PHOBOS_DIR}/server/limits.conf"
|
LIMITS_FILE = f"{PHOBOS_DIR}/server/limits.conf"
|
||||||
|
|
||||||
|
|
||||||
@@ -1400,7 +1517,7 @@ def build_phone_config(cid, mode="android"):
|
|||||||
instpath = os.path.join(cdir, "wg-obfuscator.conf")
|
instpath = os.path.join(cdir, "wg-obfuscator.conf")
|
||||||
if not os.path.exists(wgpath):
|
if not os.path.exists(wgpath):
|
||||||
return None, None
|
return None, None
|
||||||
wg = open(wgpath).read().rstrip()
|
wg = _apply_client_dns(open(wgpath).read().rstrip())
|
||||||
if mode == "ios":
|
if mode == "ios":
|
||||||
server_ip = SERVER_IP
|
server_ip = SERVER_IP
|
||||||
try:
|
try:
|
||||||
@@ -1434,7 +1551,8 @@ def build_phone_config(cid, mode="android"):
|
|||||||
inst += "\n# pca-tcp = " + tcp_port
|
inst += "\n# pca-tcp = " + tcp_port
|
||||||
conf = wg + "\n\n" + inst + "\n"
|
conf = wg + "\n\n" + inst + "\n"
|
||||||
payload = _prepare_phoboswg_payload(conf)
|
payload = _prepare_phoboswg_payload(conf)
|
||||||
b64 = base64.urlsafe_b64encode(payload.encode()).decode()
|
# spec 2.1: base64url WITHOUT padding (HydraRoute rejects a trailing "=")
|
||||||
|
b64 = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
|
||||||
link = "phobos://" + b64 + "#" + urllib.parse.quote(cid or "none")
|
link = "phobos://" + b64 + "#" + urllib.parse.quote(cid or "none")
|
||||||
return payload, link
|
return payload, link
|
||||||
|
|
||||||
@@ -1751,6 +1869,71 @@ def finish_legacy_update():
|
|||||||
finish_legacy_update()
|
finish_legacy_update()
|
||||||
threading.Thread(target=rate_sampler, daemon=True).start()
|
threading.Thread(target=rate_sampler, daemon=True).start()
|
||||||
|
|
||||||
|
# ---- Monthly traffic per client, summed over all servers; resets on the 1st ----
|
||||||
|
# WireGuard counters live per server and drop to 0 on wg restart/reboot, so we add deltas.
|
||||||
|
TRAFFIC_FILE = f"{PANEL_DIR}/traffic.json"
|
||||||
|
_traffic_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _traffic_load():
|
||||||
|
try:
|
||||||
|
with open(TRAFFIC_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _local_transfer():
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
for line in subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5).strip().split("\n")[1:]:
|
||||||
|
p = line.split("\t")
|
||||||
|
if len(p) >= 7:
|
||||||
|
out[p[0]] = [int(p[5]), int(p[6])]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def traffic_sample():
|
||||||
|
month = datetime.now().strftime("%Y-%m")
|
||||||
|
with _traffic_lock:
|
||||||
|
d = _traffic_load()
|
||||||
|
if d.get("month") != month:
|
||||||
|
if d.get("usage"):
|
||||||
|
d.setdefault("history", {})[d.get("month", "?")] = d["usage"]
|
||||||
|
d["month"], d["usage"] = month, {}
|
||||||
|
prev, usage = d.setdefault("prev", {}), d.setdefault("usage", {})
|
||||||
|
pub2cid = {c.get("public_key"): c.get("client_id") for c in get_clients() if c.get("public_key")}
|
||||||
|
srcs = {SERVER_IP: _local_transfer()}
|
||||||
|
srcs.update(server_transfer_cache)
|
||||||
|
for ip, counters in srcs.items():
|
||||||
|
for pub, (rx, tx) in counters.items():
|
||||||
|
key, old, cid = ip + "|" + pub, prev.get(ip + "|" + pub), pub2cid.get(pub)
|
||||||
|
prev[key] = [rx, tx]
|
||||||
|
if old is None or not cid: # first sight = baseline only
|
||||||
|
continue
|
||||||
|
u = usage.setdefault(cid, [0, 0])
|
||||||
|
u[0] += rx - old[0] if rx >= old[0] else rx # counter went down = wg restarted
|
||||||
|
u[1] += tx - old[1] if tx >= old[1] else tx
|
||||||
|
tmp = TRAFFIC_FILE + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(d, f)
|
||||||
|
os.replace(tmp, TRAFFIC_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def traffic_sampler():
|
||||||
|
time.sleep(20)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
traffic_sample()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=traffic_sampler, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
PAGE = """<!DOCTYPE html>
|
PAGE = """<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
@@ -1849,7 +2032,7 @@ textarea::placeholder{color:var(--muted)}
|
|||||||
</div>
|
</div>
|
||||||
CONTENT
|
CONTENT
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<b style="color:var(--accent)">PCA Phobos Panel</b> — панель управления © <a href="https://git.bobyrev.com/PCA/PCA_Phobos" target="_blank" rel="noopener">andrey271192</a><br>
|
<b style="color:var(--accent)">PCA Phobos Panel</b> — панель управления © <a href="https://git.bobyrev.com/PCA/PCA_Phobos" target="_blank" rel="noopener">adleibzon-rgb</a><br>
|
||||||
Поддержать проект: <a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener">💖 Boosty</a> · <a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener">💳 Ozon Bank</a> · <a href="https://t.me/PCAdministration" target="_blank" rel="noopener">✉️ Telegram</a>
|
Поддержать проект: <a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener">💖 Boosty</a> · <a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener">💳 Ozon Bank</a> · <a href="https://t.me/PCAdministration" target="_blank" rel="noopener">✉️ Telegram</a>
|
||||||
<div style="margin-top:8px;font-size:.88em;color:var(--muted);line-height:1.7;text-align:left;max-width:660px;margin:8px auto 0">
|
<div style="margin-top:8px;font-size:.88em;color:var(--muted);line-height:1.7;text-align:left;max-width:660px;margin:8px auto 0">
|
||||||
<b>Создано на основе открытых проектов:</b><br>
|
<b>Создано на основе открытых проектов:</b><br>
|
||||||
@@ -1864,8 +2047,141 @@ function showHelp(el){var b=el.nextElementSibling; if(b&&b.classList.contains('h
|
|||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── «Доступна новая версия»: проверка канала раз в час + баннер с описанием ──
|
||||||
|
_upd_cache = {"ts": 0, "info": None, "busy": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_of(version):
|
||||||
|
v = (version or "").lower()
|
||||||
|
return "dev" if "dev" in v else ("beta" if "beta" in v else "stable")
|
||||||
|
|
||||||
|
|
||||||
|
def _ver_key(v):
|
||||||
|
"""1.4.10 > 1.4.9; 1.5.0-beta.2 > 1.5.0-beta.1; release > pre-release of the same number."""
|
||||||
|
v = (v or "").strip().lstrip("v")
|
||||||
|
base, _, pre = v.partition("-")
|
||||||
|
nums = [int(x) if x.isdigit() else 0 for x in base.split(".")[:3]]
|
||||||
|
nums += [0] * (3 - len(nums))
|
||||||
|
pre_n = [int(x) for x in re.findall(r"\d+", pre)] or [0]
|
||||||
|
return (nums, 0 if pre else 1, pre_n)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_channel_file(channel, name):
|
||||||
|
repo = "PCA_Phobos" if channel == "stable" else "PCA_Phobos-dev"
|
||||||
|
branch = {"stable": "main"}.get(channel, channel)
|
||||||
|
cmd = ["curl", "-fsSL", "-m", "10", f"https://git.bobyrev.com/PCA/{repo}/raw/branch/{branch}/{name}?cb={int(time.time())}"]
|
||||||
|
try:
|
||||||
|
tok = Path(GH_TOKEN_FILE).read_text().strip()
|
||||||
|
if tok and repo != "PCA_Phobos":
|
||||||
|
cmd[1:1] = ["-H", "Authorization: token " + tok]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if channel == "stable": # public: GitHub first, Gitea fallback (09.33)
|
||||||
|
g = subprocess.run(["curl", "-fsSL", "-m", "10", f"https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/{name}?cb={int(time.time())}"],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
if g.returncode == 0 and g.stdout:
|
||||||
|
return g.stdout
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
return r.stdout if r.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _changelog_since(text, cur):
|
||||||
|
"""Sections '## X.Y.Z …' of CHANGELOG newer than the installed version."""
|
||||||
|
out, take = [], False
|
||||||
|
for ln in text.split("\n"):
|
||||||
|
m = re.match(r"^## +v?(\d+\.\d+\.\d+\S*)", ln)
|
||||||
|
if m:
|
||||||
|
take = _ver_key(m.group(1)) > _ver_key(cur)
|
||||||
|
elif ln.startswith("## "):
|
||||||
|
take = False
|
||||||
|
if take:
|
||||||
|
out.append(ln)
|
||||||
|
return "\n".join(out).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _apps_parse(text):
|
||||||
|
out = {}
|
||||||
|
for ln in (text or "").splitlines():
|
||||||
|
if "=" in ln and not ln.lstrip().startswith("#"):
|
||||||
|
k, v = ln.split("=", 1)
|
||||||
|
out[k.strip()] = v.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_update_info():
|
||||||
|
try:
|
||||||
|
cur = panel_version()
|
||||||
|
ch = _channel_of(cur)
|
||||||
|
latest = _fetch_channel_file(ch, "VERSION").strip()
|
||||||
|
info = None
|
||||||
|
try:
|
||||||
|
local_apps = _apps_parse(open(os.path.join(PANEL_DIR, "APPS")).read())
|
||||||
|
except Exception:
|
||||||
|
local_apps = {}
|
||||||
|
remote_apps = _apps_parse(_fetch_channel_file(ch, "APPS"))
|
||||||
|
apps = [(k, local_apps.get(k, ""), v) for k, v in remote_apps.items()
|
||||||
|
if not local_apps.get(k) or _ver_key(v) > _ver_key(local_apps.get(k))]
|
||||||
|
newer = bool(latest and re.match(r"^v?\d+\.\d+", latest) and _ver_key(latest) > _ver_key(cur))
|
||||||
|
if newer or apps:
|
||||||
|
info = {"channel": ch, "cur": cur, "latest": latest if newer else cur, "apps": apps,
|
||||||
|
"notes": _changelog_since(_fetch_channel_file(ch, "CHANGELOG.md"), cur)[:6000] if newer else ""}
|
||||||
|
_upd_cache["info"] = info
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_upd_cache["ts"] = time.time()
|
||||||
|
_upd_cache["busy"] = False
|
||||||
|
|
||||||
|
|
||||||
|
def update_info():
|
||||||
|
if not _upd_cache["busy"] and time.time() - _upd_cache["ts"] > 3600:
|
||||||
|
_upd_cache["busy"] = True
|
||||||
|
threading.Thread(target=_refresh_update_info, daemon=True).start()
|
||||||
|
return _upd_cache["info"]
|
||||||
|
|
||||||
|
|
||||||
|
def update_banner():
|
||||||
|
try:
|
||||||
|
if not session.get("auth"):
|
||||||
|
return ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
i = update_info()
|
||||||
|
if not i:
|
||||||
|
return ""
|
||||||
|
e = html_lib.escape
|
||||||
|
notes = e(i["notes"] or tr("Описание изменений — в CHANGELOG.md репозитория.", "See CHANGELOG.md in the repository."))
|
||||||
|
apps = i.get("apps") or []
|
||||||
|
apps_line = (" · " + tr("новые приложения", "new apps") + ": " + ", ".join(e(f"{k} {v}") for k, _o, v in apps)) if apps else ""
|
||||||
|
apps_html = ("<p style=\"font-size:.88em\"><b>" + tr("Новые версии приложений", "New app versions") + ":</b><br>"
|
||||||
|
+ "<br>".join(e(f"{k}: {o or '—'} → {v}") for k, o, v in apps)
|
||||||
|
+ "<br>" + tr("После обновления панели новые ссылки на скачивание — в окне «Подключение» у клиента; разошлите их пользователям.", "After the panel update the new download links are in the client's Connection window; send them to your users.")
|
||||||
|
+ "</p>") if apps else ""
|
||||||
|
return f'''<div id="upd-bar" style="background:#16a34a;color:#fff;padding:8px 14px;text-align:center;font-size:.92em">
|
||||||
|
{tr("Доступна новая версия","New version available")} <b>{e(i["latest"])}</b> ({e(i["channel"])}) · {tr("у вас","you have")} {e(i["cur"])}{apps_line}
|
||||||
|
<button class="btn btn-sm" style="background:#fff;color:#16a34a;margin-left:10px" onclick="document.getElementById('upd-modal').style.display='flex'">{tr("Обновить","Update")}</button>
|
||||||
|
</div>
|
||||||
|
<div id="upd-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9999;align-items:center;justify-content:center;padding:16px">
|
||||||
|
<div class="card" style="max-width:640px;width:100%;max-height:85vh;overflow:auto;margin:0">
|
||||||
|
<h2>{tr("Обновление до","Update to")} {e(i["latest"])} ({e(i["channel"])})</h2>
|
||||||
|
<p style="color:var(--muted);font-size:.88em">{tr("Что нового","What's new")}:</p>
|
||||||
|
<pre style="white-space:pre-wrap;font-size:.85em;background:var(--bg);padding:10px;border-radius:8px">{notes}</pre>
|
||||||
|
{apps_html}
|
||||||
|
<p style="font-size:.88em"><b>{tr("Перед обновлением","Before updating")}:</b> {tr("панель сама делает бэкап PCA-слоя; ключи WireGuard, server.env и клиенты не трогаются. Свой полный бэкап (рекомендуем):","the panel backs up the PCA layer itself; WireGuard keys, server.env and clients are not touched. Your own full backup (recommended):")}<br>
|
||||||
|
<code>tar czf /root/phobos-backup-$(date +%F).tgz /opt/Phobos /opt/phobos-panel</code></p>
|
||||||
|
<p style="font-size:.88em"><b>{tr("Если что-то сломалось","If something breaks")}:</b> {tr("Настройки → «Откатить на предыдущую» или в консоли","Settings → Rollback, or in the console")} <code>phobos-update --rollback</code>.
|
||||||
|
{tr("Обновлены secondary-серверы? Обновите и их той же кнопкой в их панели.","Have secondary servers? Update them the same way in their panel.")}</p>
|
||||||
|
<form method="post" action="/settings" style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">
|
||||||
|
<input type="hidden" name="action" value="update"><input type="hidden" name="target" value="{e(i["channel"])}">
|
||||||
|
<button type="button" class="btn btn-sm" style="background:#475569" onclick="document.getElementById('upd-modal').style.display='none'">{tr("Отмена","Cancel")}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{tr("Обновить сейчас","Update now")}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>'''
|
||||||
|
|
||||||
|
|
||||||
def render(content):
|
def render(content):
|
||||||
return PAGE.replace("CONTENT", content)
|
return PAGE.replace("CONTENT", update_banner() + content)
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@@ -2294,6 +2610,27 @@ def _terminal_button(url, title):
|
|||||||
f'style="background:#334155" title="{_html_attr(title)}">🖥️</a>')
|
f'style="background:#334155" title="{_html_attr(title)}">🖥️</a>')
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_bytes(n):
|
||||||
|
for u in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||||
|
if n < 1024 or u == "TiB":
|
||||||
|
return f"{n:.2f} {u}" if u != "B" else f"{int(n)} B"
|
||||||
|
n /= 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _traffic_cell(u):
|
||||||
|
if not u:
|
||||||
|
return "—"
|
||||||
|
return f"↓ {_fmt_bytes(u[1])} · ↑ {_fmt_bytes(u[0])}" # server tx = client download
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/traffic")
|
||||||
|
@auth_required
|
||||||
|
def api_traffic():
|
||||||
|
"""Monthly traffic per client: current month + history ({client: [rx, tx]}, server-side bytes)."""
|
||||||
|
d = _traffic_load()
|
||||||
|
return jsonify({"month": d.get("month"), "usage": d.get("usage", {}), "history": d.get("history", {})})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/clients", methods=["GET", "POST"])
|
@app.route("/clients", methods=["GET", "POST"])
|
||||||
@auth_required
|
@auth_required
|
||||||
def clients_page():
|
def clients_page():
|
||||||
@@ -2601,6 +2938,7 @@ def clients_page():
|
|||||||
<button class="btn btn-sm" style="background:#475569" type="submit">{tr("Имя","Name")}</button>
|
<button class="btn btn-sm" style="background:#475569" type="submit">{tr("Имя","Name")}</button>
|
||||||
</form>
|
</form>
|
||||||
"""
|
"""
|
||||||
|
_tusage = _traffic_load().get("usage", {}) if "_tusage" not in dir() else _tusage
|
||||||
lim = str(limits.get(cid, "") or "")
|
lim = str(limits.get(cid, "") or "")
|
||||||
rate = client_rates.get(pub)
|
rate = client_rates.get(pub)
|
||||||
speed = (f'<span class="cspeed" title="{tr("Скорость сейчас","Current speed")}">↓{rate[0]:.1f} ↑{rate[1]:.1f} {tr("Мбит/с","Mbit/s")}</span>' if rate else "")
|
speed = (f'<span class="cspeed" title="{tr("Скорость сейчас","Current speed")}">↓{rate[0]:.1f} ↑{rate[1]:.1f} {tr("Мбит/с","Mbit/s")}</span>' if rate else "")
|
||||||
@@ -2613,7 +2951,7 @@ def clients_page():
|
|||||||
<button class="btn btn-primary btn-sm" type="submit">{set_lbl}</button>
|
<button class="btn btn-primary btn-sm" type="submit">{set_lbl}</button>
|
||||||
</form>"""
|
</form>"""
|
||||||
rows += f"""<div class="ccard">
|
rows += f"""<div class="ccard">
|
||||||
<div class="ctop"><span class="cname">{client_cell}</span> <span class="cip">{ip}</span> {status} {expiry_badge} {f'<span class="badge badge-lock">≤{_html_attr(lim)} Mbit</span>' if lim and lim != "0" else ""} {speed}</div>
|
<div class="ctop"><span class="cname">{client_cell}</span> <span class="cip">{ip}</span> {status} {expiry_badge} <span class="cspeed" title="{tr('Трафик за месяц (суммарно по всем серверам)','Traffic this month (all servers)')}">{_traffic_cell(_tusage.get(cid))}</span> {f'<span class="badge badge-lock">≤{_html_attr(lim)} Mbit</span>' if lim and lim != "0" else ""} {speed}</div>
|
||||||
<div class="cctl">{server_cell}{expiry_cell}{limit_cell}{rename_box}</div>
|
<div class="cctl">{server_cell}{expiry_cell}{limit_cell}{rename_box}</div>
|
||||||
<div class="cact">{actions_cell}</div>
|
<div class="cact">{actions_cell}</div>
|
||||||
</div>"""
|
</div>"""
|
||||||
@@ -2716,6 +3054,7 @@ def settings_page():
|
|||||||
_out = tr("WARP настройки сохранены.", "WARP settings saved.")
|
_out = tr("WARP настройки сохранены.", "WARP settings saved.")
|
||||||
elif _act == "update":
|
elif _act == "update":
|
||||||
_out = run_update(request.form.get("target", "").strip() or "main")
|
_out = run_update(request.form.get("target", "").strip() or "main")
|
||||||
|
_upd_cache["ts"] = 0
|
||||||
elif _act == "check":
|
elif _act == "check":
|
||||||
_out = run_update("--check")
|
_out = run_update("--check")
|
||||||
elif _act == "warp_enable":
|
elif _act == "warp_enable":
|
||||||
@@ -2753,6 +3092,8 @@ def settings_page():
|
|||||||
ok, _out = run_server_script("phobos-domain.sh", "remove")
|
ok, _out = run_server_script("phobos-domain.sh", "remove")
|
||||||
elif _act == "endpoints_save":
|
elif _act == "endpoints_save":
|
||||||
ok, _out = save_client_endpoints(request.form)
|
ok, _out = save_client_endpoints(request.form)
|
||||||
|
elif _act == "dns_save":
|
||||||
|
ok, _out = save_client_dns(request.form)
|
||||||
else:
|
else:
|
||||||
ok, _out = run_server_script("phobos-guard.sh", _act.split("_", 1)[1], timeout=300)
|
ok, _out = run_server_script("phobos-guard.sh", _act.split("_", 1)[1], timeout=300)
|
||||||
cls = "alert-ok" if ok else "alert-error"
|
cls = "alert-ok" if ok else "alert-error"
|
||||||
@@ -2861,6 +3202,15 @@ def settings_page():
|
|||||||
<p style="font-size:.8em;color:var(--muted);margin-top:6px">{tr("Отключить: phobos-cascade disable · статус: phobos-cascade status. iOS/обычный WireGuard (порт 51820) через каскад не идёт.","Disable: phobos-cascade disable · status: phobos-cascade status. iOS/plain WireGuard (port 51820) does not go through the cascade.")}</p>
|
<p style="font-size:.8em;color:var(--muted);margin-top:6px">{tr("Отключить: phobos-cascade disable · статус: phobos-cascade status. iOS/обычный WireGuard (порт 51820) через каскад не идёт.","Disable: phobos-cascade disable · status: phobos-cascade status. iOS/plain WireGuard (port 51820) does not go through the cascade.")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>{tr("DNS для клиентов","Client DNS")} {hlp("DNS-серверы в конфиге клиента (строка DNS = в [Interface]). Пусто = 1.1.1.1, 8.8.8.8. Новые клиенты получают сразу; уже выданным — заново открыть ссылку/QR. Роутеры Keenetic раздают в сеть свой DNS. Если у клиента включён firewall (🛡) — добавьте эти адреса в разрешённые.", "DNS servers in the client config (DNS = line in [Interface]). Empty = 1.1.1.1, 8.8.8.8. New clients get it at once; existing ones must reopen the link/QR. Keenetic routers hand out their own DNS to the LAN. If a client has the firewall (🛡) on, add these addresses to its allow list.")}</h2>
|
||||||
|
<form method="post" class="form-row">
|
||||||
|
<input type="hidden" name="action" value="dns_save">
|
||||||
|
<input name="client_dns" value="{_html_attr(client_dns())}" placeholder="1.1.1.1, 8.8.8.8" style="width:260px;padding:5px 8px">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">{tr("Сохранить","Save")}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{tr("Защита сервера (сторож)","Server protection (guard)")} {hlp("Блокирует подсети зондов и сканеров РКН до того, как они дойдут до сервера (nftables). Список IP — из проекта Loorrr293/Auto_IPtables, обновляется раз в сутки. Частные сети (LAN, туннель) никогда не блокируются.", "Drops RKN probe/scanner subnets before they reach the server (nftables). IP list from Loorrr293/Auto_IPtables, refreshed daily. Private networks (LAN, tunnel) are never blocked.")}</h2>
|
<h2>{tr("Защита сервера (сторож)","Server protection (guard)")} {hlp("Блокирует подсети зондов и сканеров РКН до того, как они дойдут до сервера (nftables). Список IP — из проекта Loorrr293/Auto_IPtables, обновляется раз в сутки. Частные сети (LAN, туннель) никогда не блокируются.", "Drops RKN probe/scanner subnets before they reach the server (nftables). IP list from Loorrr293/Auto_IPtables, refreshed daily. Private networks (LAN, tunnel) are never blocked.")}</h2>
|
||||||
<p>{tr("Статус","Status")}: <b style="color:{'#16a34a' if guard.get("enabled") else '#f59e0b'}">{tr("включён","enabled") if guard.get("enabled") else tr("выключен","disabled")}</b>
|
<p>{tr("Статус","Status")}: <b style="color:{'#16a34a' if guard.get("enabled") else '#f59e0b'}">{tr("включён","enabled") if guard.get("enabled") else tr("выключен","disabled")}</b>
|
||||||
@@ -2991,6 +3341,7 @@ def settings_page():
|
|||||||
<tr><td>{tr("WARP+ key","WARP+ key")}</td><td>{tr("сохранён","saved") if warp_license_saved else tr("не задан","not set")}</td></tr>
|
<tr><td>{tr("WARP+ key","WARP+ key")}</td><td>{tr("сохранён","saved") if warp_license_saved else tr("не задан","not set")}</td></tr>
|
||||||
<tr><td>{tr("Custom profile","Custom profile")}</td><td>{tr("сохранён","saved") if custom_profile_saved else tr("не задан","not set")}</td></tr>
|
<tr><td>{tr("Custom profile","Custom profile")}</td><td>{tr("сохранён","saved") if custom_profile_saved else tr("не задан","not set")}</td></tr>
|
||||||
<tr><td>{tr("Egress IPv4","Egress IPv4")}</td><td>{warp_egress or '—'}</td></tr>
|
<tr><td>{tr("Egress IPv4","Egress IPv4")}</td><td>{warp_egress or '—'}</td></tr>
|
||||||
|
<tr><td>Handshake</td><td>{(str(warp.get("handshake_age")) + tr(" с назад", "s ago")) if warp.get("handshake_age", -1) >= 0 else tr("нет — UDP до Cloudflare не проходит", "none — UDP to Cloudflare blocked")}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<form method="post" style="margin-top:10px">
|
<form method="post" style="margin-top:10px">
|
||||||
<input type="hidden" name="action" value="warp_save">
|
<input type="hidden" name="action" value="warp_save">
|
||||||
@@ -3376,13 +3727,12 @@ def download_apk():
|
|||||||
@auth_required
|
@auth_required
|
||||||
def client_conf_download(cid, mode):
|
def client_conf_download(cid, mode):
|
||||||
m = "ios" if mode == "ios" else "android"
|
m = "ios" if mode == "ios" else "android"
|
||||||
if m == "android":
|
|
||||||
return ("Android/PhobosWG uses phobos:// QR/link import; .conf import is only valid for iOS/plain WireGuard.", 400)
|
|
||||||
conf, _link = build_phone_config(cid, m)
|
conf, _link = build_phone_config(cid, m)
|
||||||
if conf is None:
|
if conf is None:
|
||||||
return ("client not found", 404)
|
return ("client not found", 404)
|
||||||
if m == "android":
|
if m == "android":
|
||||||
conf = _pad_conf_with_none(conf)
|
# raw .conf carries no "= none" placeholders (spec 2.3: padding is for the link only)
|
||||||
|
conf = "\n".join(ln for ln in conf.split("\n") if ln.partition("=")[2].strip().lower() != "none")
|
||||||
return (conf, 200, {
|
return (conf, 200, {
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
"Content-Disposition": 'attachment; filename="%s-%s.conf"' % (cid, m),
|
"Content-Disposition": 'attachment; filename="%s-%s.conf"' % (cid, m),
|
||||||
@@ -3423,7 +3773,9 @@ def client_phone(cid, mode):
|
|||||||
f'<p style="font-size:.8em;color:var(--muted)">{tr("Поставь любое из двух приложений и открой ссылку ниже на телефоне — профиль импортируется сам.","Install either app and open the link below on the phone — the profile imports itself.")}</p>')
|
f'<p style="font-size:.8em;color:var(--muted)">{tr("Поставь любое из двух приложений и открой ссылку ниже на телефоне — профиль импортируется сам.","Install either app and open the link below on the phone — the profile imports itself.")}</p>')
|
||||||
extra = (client_btn + apk_btn +
|
extra = (client_btn + apk_btn +
|
||||||
f'<p>{tr("phobos:// ссылка — импорт в PCA Phobos VPN или PhobosWG:", "phobos:// link — import into PCA Phobos VPN or PhobosWG:")} <a href="{link}">{tr("открыть", "open")}</a></p>'
|
f'<p>{tr("phobos:// ссылка — импорт в PCA Phobos VPN или PhobosWG:", "phobos:// link — import into PCA Phobos VPN or PhobosWG:")} <a href="{link}">{tr("открыть", "open")}</a></p>'
|
||||||
f'<textarea readonly onclick="this.select()" style="width:100%;height:90px;font-size:.75em">{link}</textarea>')
|
f'<textarea readonly onclick="this.select()" style="width:100%;height:90px;font-size:.75em">{link}</textarea>'
|
||||||
|
f'<p style="font-size:.85em"><a href="/client/{cid}/conf/android">⬇ {tr("Файл .conf — для «Импорт из файла» в PCA Phobos VPN", ".conf file — for “Import from file” in PCA Phobos VPN")}</a>. '
|
||||||
|
f'{tr("Ссылка выше подходит и для HydraRoute (формат phobos:// автора Phobos).", "The link above also works in HydraRoute (upstream phobos:// format).")}</p>')
|
||||||
else:
|
else:
|
||||||
title = tr("iPhone / iOS WireGuard (без обфускации)", "iPhone / iOS WireGuard (plain, no obfuscation)")
|
title = tr("iPhone / iOS WireGuard (без обфускации)", "iPhone / iOS WireGuard (plain, no obfuscation)")
|
||||||
extra = (f'<p><a href="https://apps.apple.com/app/wireguard/id1441195209" target="_blank" rel="noopener" class="btn btn-primary" style="background:#0ea5e9">📥 {tr("WireGuard в App Store","WireGuard on the App Store")}</a> <a href="/client/{cid}/conf/ios" class="btn" style="background:#7c3aed">⬇ {tr("Скачать .conf","Download .conf")}</a></p>'
|
extra = (f'<p><a href="https://apps.apple.com/app/wireguard/id1441195209" target="_blank" rel="noopener" class="btn btn-primary" style="background:#0ea5e9">📥 {tr("WireGuard в App Store","WireGuard on the App Store")}</a> <a href="/client/{cid}/conf/ios" class="btn" style="background:#7c3aed">⬇ {tr("Скачать .conf","Download .conf")}</a></p>'
|
||||||
@@ -3449,8 +3801,9 @@ def router_config(client_id):
|
|||||||
st = load_settings()
|
st = load_settings()
|
||||||
token = request.args.get("token", "")
|
token = request.args.get("token", "")
|
||||||
acc = st.get("router_access", {}).get(client_id, {})
|
acc = st.get("router_access", {}).get(client_id, {})
|
||||||
expected = acc.get("pull_token", "") or st.get("server_api_key", "")
|
# packages ship server_api_key as pull_token (phobos-client.sh), so accept both
|
||||||
if not expected or token != expected:
|
expected = {t for t in (acc.get("pull_token", ""), st.get("server_api_key", "")) if t}
|
||||||
|
if not token or token not in expected:
|
||||||
return ("forbidden", 403)
|
return ("forbidden", 403)
|
||||||
conf = generate_failover_conf_for_client(client_id)
|
conf = generate_failover_conf_for_client(client_id)
|
||||||
return (conf, 200, {"Content-Type": "text/plain; charset=utf-8"})
|
return (conf, 200, {"Content-Type": "text/plain; charset=utf-8"})
|
||||||
@@ -4388,6 +4741,7 @@ def servers_page():
|
|||||||
Peers: <b style="color:var(--text)">{peers_val}</b> Assigned: <b style="color:var(--text)">{assigned}</b> Ports: {ports}<br>
|
Peers: <b style="color:var(--text)">{peers_val}</b> Assigned: <b style="color:var(--text)">{assigned}</b> Ports: {ports}<br>
|
||||||
<span title="Канал / Channel (rx+tx)">↕ <b style="color:var(--text)">{mbps_disp}</b></span><br>
|
<span title="Канал / Channel (rx+tx)">↕ <b style="color:var(--text)">{mbps_disp}</b></span><br>
|
||||||
{speed_line}
|
{speed_line}
|
||||||
|
<br>{google_geo_html(ip)}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" style="margin-top:8px" onsubmit="this.querySelector('button').textContent='⏳ ~20 s…'">
|
<form method="post" style="margin-top:8px" onsubmit="this.querySelector('button').textContent='⏳ ~20 s…'">
|
||||||
<input type="hidden" name="action" value="speedtest"><input type="hidden" name="ip" value="{ip}">
|
<input type="hidden" name="action" value="speedtest"><input type="hidden" name="ip" value="{ip}">
|
||||||
@@ -4478,6 +4832,26 @@ def servers_page():
|
|||||||
{nav('servers')}
|
{nav('servers')}
|
||||||
{msg}
|
{msg}
|
||||||
|
|
||||||
|
<div class="card" style="border:2px solid var(--accent)">
|
||||||
|
<h2 style="font-size:1.25em">➕ {tr("Как добавить второй сервер", "How to add a second server")}</h2>
|
||||||
|
<p style="font-size:1em;font-weight:600;margin:6px 0">{tr("Сервер управления один — эта панель. Второй VPS ставится как «дополнительный» (secondary) одной командой.", "There is one management server — this panel. The second VPS is installed as a secondary with one command.")}</p>
|
||||||
|
<ol style="margin:8px 0 8px 20px;line-height:1.6;font-size:.95em">
|
||||||
|
<li>{tr("Возьмите чистый VPS (Ubuntu/Debian). Если на нём уже стоял Phobos целиком — сначала удалите его (uninstall.sh).", "Take a clean VPS (Ubuntu/Debian). If a full Phobos was installed there, uninstall it first (uninstall.sh).")}</li>
|
||||||
|
<li>{tr("Зайдите на него по SSH под root и выполните команду ниже.", "SSH to it as root and run the command below.")}</li>
|
||||||
|
<li><b>{tr("Подсеть уже подставлена в команду (SERVER_WG_IPV4_NETWORK) — она такая же, как у этой панели, вопроса не будет.", "The subnet is already in the command (SERVER_WG_IPV4_NETWORK) — it is the same as on this panel, no prompt.")}</b> {tr("Не меняйте её и не вводите другую!", "Do not change it or enter another one!")}</li>
|
||||||
|
<li>{tr("Сервер сам появится в списке ниже, клиенты скопируются на него автоматически. Кнопка «Добавить сервер» не нужна.", "The server appears in the list below by itself and clients are copied to it automatically. The “Add Server” button is not needed.")}</li>
|
||||||
|
<li>{tr("Назначить клиенту сервер: Клиенты → «Редактировать» → «Сервер». Наши приложения (роутер, iOS, Android, Windows 1.4.5+) переключатся сами; другие клиенты — только повторным импортом ссылки.", "Assign a server to a client: Clients → Edit → Server. Our apps (router, iOS, Android, Windows 1.4.5+) switch by themselves; other clients need the link re-imported.")}</li>
|
||||||
|
</ol>
|
||||||
|
<div style="display:flex;gap:8px;align-items:flex-start">
|
||||||
|
<code id="secCmd" style="background:var(--bg);padding:8px 12px;border-radius:6px;display:block;font-size:.82em;word-break:break-all;flex:1">SERVER_WG_IPV4_NETWORK={server_wg_network()} MAIN_PROTO={panel_scheme()} MAIN_SERVER={SERVER_IP} MAIN_PORT={panel_port()} MAIN_API_KEY={api_key} bash <(curl -kfsSL {panel_base_url()}/server/secondary-setup.sh)</code>
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" onclick="var x=document.createElement('textarea');x.value=document.getElementById('secCmd').textContent;document.body.appendChild(x);x.select();document.execCommand('copy');document.body.removeChild(x);this.textContent='{tr("Скопировано","Copied")}'">📋 {tr("Копировать","Copy")}</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:rgba(245,158,11,.15);border:1px solid #f59e0b;font-size:.9em;line-height:1.5">
|
||||||
|
<b>⚠ {tr("Не ставьте второй сервер полным install.sh!", "Do not install the second server with the full install.sh!")}</b><br>
|
||||||
|
{tr("Полный install.sh создаёт ОТДЕЛЬНУЮ панель со своими клиентами. Подсеть там придётся указывать свою (например 10.25.11.0/24, иначе у клиентов будут одинаковые IP и второй туннель на роутере не поднимется), а смена сервера у клиента между двумя панелями НЕ работает — это разные клиенты. Нужна смена и назначение серверов — ставьте только командой выше, подсеть по умолчанию (Enter).", "The full install.sh creates a SEPARATE panel with its own clients. You would have to enter your own subnet there (e.g. 10.25.11.0/24, otherwise clients get identical IPs and the second router tunnel will not come up), and switching a client's server between two panels does NOT work — they are different clients. If you need server assignment and switching, use only the command above with the default subnet (Enter).")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{tr("Панель серверов", "Server Dashboard")} {hlp("Живая статистика всех VPN-серверов. Peers = активные WG-подключения. Assigned = клиенты, у которых этот сервер основной. Фоновый монитор опрашивает каждые " + str(s.get('monitor_interval',30)) + " сек и шлёт оповещения в Telegram при падении/восстановлении сервера.", "Live stats for all VPN servers. Peers = active WG connections. Assigned = clients whose primary is this server. Background monitor polls every " + str(s.get('monitor_interval',30)) + "s and sends Telegram alerts on server up/down.")}</h2>
|
<h2>{tr("Панель серверов", "Server Dashboard")} {hlp("Живая статистика всех VPN-серверов. Peers = активные WG-подключения. Assigned = клиенты, у которых этот сервер основной. Фоновый монитор опрашивает каждые " + str(s.get('monitor_interval',30)) + " сек и шлёт оповещения в Telegram при падении/восстановлении сервера.", "Live stats for all VPN servers. Peers = active WG connections. Assigned = clients whose primary is this server. Background monitor polls every " + str(s.get('monitor_interval',30)) + "s and sends Telegram alerts on server up/down.")}</h2>
|
||||||
<div style="display:flex;gap:12px;flex-wrap:wrap">{stats_cards}</div>
|
<div style="display:flex;gap:12px;flex-wrap:wrap">{stats_cards}</div>
|
||||||
@@ -4497,6 +4871,7 @@ def servers_page():
|
|||||||
<button class="btn btn-primary" type="submit" onclick="return confirm('{tr("Отправить конфиг на все роутеры?","Push config to all routers?")}')">Push All</button></form>
|
<button class="btn btn-primary" type="submit" onclick="return confirm('{tr("Отправить конфиг на все роутеры?","Push config to all routers?")}')">Push All</button></form>
|
||||||
{hlp("Принудительно отправить конфиг на роутеры с прямым SSH. Роутеры за NAT это игнорируют и сами подтягивают конфиг за ~15 сек — обычно эта кнопка не нужна.", "Force-push config to routers reachable by SSH. NAT routers ignore it and auto-pull within ~15s — you usually do not need this.")}
|
{hlp("Принудительно отправить конфиг на роутеры с прямым SSH. Роутеры за NAT это игнорируют и сами подтягивают конфиг за ~15 сек — обычно эта кнопка не нужна.", "Force-push config to routers reachable by SSH. NAT routers ignore it and auto-pull within ~15s — you usually do not need this.")}
|
||||||
</div>
|
</div>
|
||||||
|
<details style="margin-top:16px"><summary style="cursor:pointer;color:var(--muted)">{tr("Сервер уже поставлен, но не появился в списке? Добавить вручную", "Server installed but not in the list? Add manually")}</summary>
|
||||||
<form method="post" class="form-row" style="margin-top:16px;flex-wrap:wrap">
|
<form method="post" class="form-row" style="margin-top:16px;flex-wrap:wrap">
|
||||||
<input type="hidden" name="action" value="add">
|
<input type="hidden" name="action" value="add">
|
||||||
<input type="text" name="ip" placeholder="{tr('IP сервера','Server IP')}" required>
|
<input type="text" name="ip" placeholder="{tr('IP сервера','Server IP')}" required>
|
||||||
@@ -4507,6 +4882,7 @@ def servers_page():
|
|||||||
<button class="btn btn-primary" type="submit">{tr("Добавить сервер","Add Server")}</button>
|
<button class="btn btn-primary" type="submit">{tr("Добавить сервер","Add Server")}</button>
|
||||||
{hlp("Добавить новый VPN-сервер в пул резервирования. Если secondary ставился командой ниже, API key можно оставить пустым — будет использован главный ключ панели. 8444 — это порт mini-API, не ключ.", "Add a new VPN server to the failover pool. If the secondary was installed with the command below, API key may be left blank — the panel main key will be used. 8444 is the mini-API port, not the key.")}
|
{hlp("Добавить новый VPN-сервер в пул резервирования. Если secondary ставился командой ниже, API key можно оставить пустым — будет использован главный ключ панели. 8444 — это порт mini-API, не ключ.", "Add a new VPN server to the failover pool. If the secondary was installed with the command below, API key may be left blank — the panel main key will be used. 8444 is the mini-API port, not the key.")}
|
||||||
</form>
|
</form>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -4526,7 +4902,7 @@ def servers_page():
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Server SSH Access</h2>
|
<h2>{tr("SSH-доступ к серверам","Server SSH Access")}</h2>
|
||||||
<p style="color:var(--muted);font-size:.85em;margin-bottom:12px">SSH credentials for secondary servers. Used by panel to add/remove WG peers when switching client servers. Main server uses local commands.</p>
|
<p style="color:var(--muted);font-size:.85em;margin-bottom:12px">SSH credentials for secondary servers. Used by panel to add/remove WG peers when switching client servers. Main server uses local commands.</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="action" value="save_server_access">
|
<input type="hidden" name="action" value="save_server_access">
|
||||||
@@ -4545,7 +4921,7 @@ def servers_page():
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>API Key</h2>
|
<h2>{tr("API-ключ","API Key")}</h2>
|
||||||
<form method="post" class="form-row">
|
<form method="post" class="form-row">
|
||||||
<input type="hidden" name="action" value="set_api_key">
|
<input type="hidden" name="action" value="set_api_key">
|
||||||
<input type="text" name="server_api_key" value="{api_key}" placeholder="API key" style="width:300px">
|
<input type="text" name="server_api_key" value="{api_key}" placeholder="API key" style="width:300px">
|
||||||
@@ -4553,11 +4929,6 @@ def servers_page():
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Deploy Secondary Server</h2>
|
|
||||||
<p style="color:var(--muted);font-size:.85em;margin-bottom:8px">One command deploys WG + obfuscator + mini-API on a new VPS. Auto-registers in panel. Run via SSH on new VPS:</p>
|
|
||||||
<code style="background:var(--bg);padding:8px 12px;border-radius:6px;display:block;font-size:.82em;word-break:break-all">MAIN_PROTO={panel_scheme()} MAIN_SERVER={SERVER_IP} MAIN_PORT={panel_port()} MAIN_API_KEY={api_key} bash <(curl -kfsSL {panel_base_url()}/server/secondary-setup.sh)</code>
|
|
||||||
</div>
|
|
||||||
</div>"""
|
</div>"""
|
||||||
return render(html)
|
return render(html)
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
#!/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"
|
CONF="$PHOBOS_DIR/failover.conf"
|
||||||
STATE="$PHOBOS_DIR/state"
|
STATE="$PHOBOS_DIR/state"
|
||||||
LOG="$PHOBOS_DIR/health.log"
|
LOG="$PHOBOS_DIR/health.log"
|
||||||
WG_CONF="$PHOBOS_DIR/wg0.conf"
|
|
||||||
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
||||||
LOCKFILE="/tmp/phobos-health.lock"
|
LOCKFILE="/tmp/phobos-health.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 "$PHOBOS_DIR/panel_scheme" 2>/dev/null)}"
|
||||||
|
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
|
||||||
|
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$PHOBOS_DIR/panel_port" 2>/dev/null)}"
|
||||||
|
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
|
||||||
|
PRIMARY_PROBE_PATH="/api/obf-health"
|
||||||
|
# Connectivity check targets (logged for context only)
|
||||||
|
CHECK_HOST_1="8.8.8.8"
|
||||||
|
CHECK_HOST_2="1.1.1.1"
|
||||||
|
|
||||||
log() {
|
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 +51,35 @@ 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() {
|
||||||
|
for i in 0 1 2 3 4 5 6 7 8 9; do
|
||||||
|
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
|
||||||
|
if echo "$desc" | grep -qi phobos; then
|
||||||
|
WG_IF="Wireguard${i}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# ── Read failover config ──
|
# ── 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-)"
|
||||||
SERVER_COUNT=$idx
|
SERVER_COUNT=$idx
|
||||||
idx=$((idx + 1))
|
idx=$((idx + 1))
|
||||||
done
|
done
|
||||||
@@ -74,22 +89,45 @@ 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
|
|
||||||
fi
|
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
||||||
now=$(date +%s)
|
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим туннелем.
|
||||||
age=$((now - hs))
|
detect_wg_interface || {
|
||||||
echo "$age"
|
log "ERROR: own WG interface not found, skip"
|
||||||
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Get port by index from comma-separated list ──
|
# ── Get handshake age via ndmc ──
|
||||||
|
# A peer switch can leave the removed peer's stale handshake listed in ndmc
|
||||||
|
# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum
|
||||||
|
# positive, sane) handshake across all peers — that is the live tunnel.
|
||||||
|
get_handshake_age() {
|
||||||
|
hs=$(ndmc -c "show interface $WG_IF" 2>/dev/null \
|
||||||
|
| grep "last-handshake" \
|
||||||
|
| awk '{v=$2} v>0 && v<86400 {print v}' \
|
||||||
|
| sort -n | head -1)
|
||||||
|
if [ -z "$hs" ]; then
|
||||||
|
echo "9999"
|
||||||
|
else
|
||||||
|
echo "$hs"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check real connectivity ──
|
||||||
|
check_connectivity() {
|
||||||
|
ping -c 1 -W 3 "$CHECK_HOST_1" >/dev/null 2>&1 && return 0
|
||||||
|
ping -c 1 -W 3 "$CHECK_HOST_2" >/dev/null 2>&1 && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Get port by index ──
|
||||||
get_port() {
|
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 +138,78 @@ 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
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
|
eval "wk=\$SERVER_${i}_WGKEY"
|
||||||
|
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
|
||||||
|
n=$((n + 1))
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
echo "$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Switch WG peer so EXACTLY ONE server peer (new_key) remains ──
|
||||||
|
# Failover adds a new peer; the RCI "remove" is unreliable on Keenetic and
|
||||||
|
# leaves dead peers behind. Multiple peers each with allow-ips 0.0.0.0/0 make
|
||||||
|
# egress routing ambiguous. We hard-purge every OTHER known server key via the
|
||||||
|
# ndmc CLI (reliable) — never the interface's own key — then ensure new_key.
|
||||||
|
switch_wg_peer() {
|
||||||
|
new_key=$1
|
||||||
|
|
||||||
|
if [ -z "$new_key" ]; then
|
||||||
|
log "WARN: no WGKEY for target server, skip peer switch"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
present=$(list_iface_keys)
|
||||||
|
|
||||||
|
# Purge any OTHER known server key that is attached
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
|
eval "wk=\$SERVER_${i}_WGKEY"
|
||||||
|
if [ -n "$wk" ] && [ "$wk" != "$new_key" ] && echo "$present" | grep -q "^${wk}$"; then
|
||||||
|
log "WG PEER purge: $wk"
|
||||||
|
ndmc -c "interface $WG_IF no wireguard peer $wk" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Add the target peer if it is not already present
|
||||||
|
if ! echo "$present" | grep -q "^${new_key}$"; then
|
||||||
|
log "WG PEER add: $new_key"
|
||||||
|
# 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
|
||||||
|
# берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
|
||||||
|
# Через ndmc по шагам: JSON-RCI с allow-ips "::"/0 молча не ставил allow-ips.
|
||||||
|
lport=$(grep 'source-lport' "$PHOBOS_DIR/wg-obfuscator.conf" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
|
||||||
|
lport=${lport:-13255}
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key endpoint 127.0.0.1:$lport" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
|
||||||
|
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
|
||||||
|
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
|
||||||
|
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Persist config
|
||||||
|
ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# ── Switch to specific server:port ──
|
# ── Switch to specific server:port ──
|
||||||
switch_endpoint() {
|
switch_endpoint() {
|
||||||
server_idx=$1
|
server_idx=$1
|
||||||
@@ -107,6 +217,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
|
||||||
@@ -116,7 +227,10 @@ switch_endpoint() {
|
|||||||
|
|
||||||
log "SWITCH → server $server_idx ($host:$port)"
|
log "SWITCH → server $server_idx ($host:$port)"
|
||||||
|
|
||||||
# Update obfuscator config target
|
# 1. Switch WG peer key if different server
|
||||||
|
switch_wg_peer "$wg_key"
|
||||||
|
|
||||||
|
# 2. Update obfuscator config
|
||||||
if [ -f "$OBF_CONF" ]; then
|
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 +238,26 @@ switch_endpoint() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Restart obfuscator
|
# 3. Restart obfuscator
|
||||||
killall wg-obfuscator 2>/dev/null
|
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||||
sleep 1
|
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
else
|
||||||
|
killall wg-obfuscator 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
|
fi
|
||||||
|
|
||||||
# Save state
|
# 4. Save state (fail_count is managed by the caller, NOT reset here —
|
||||||
|
# resetting on a port-hop would prevent escalation to server failover)
|
||||||
echo "$server_idx" > "$STATE/current_server"
|
echo "$server_idx" > "$STATE/current_server"
|
||||||
echo "$port_idx" > "$STATE/current_port_idx"
|
echo "$port_idx" > "$STATE/current_port_idx"
|
||||||
echo "0" > "$STATE/restart_count"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 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"
|
||||||
@@ -152,96 +267,203 @@ try_next_port() {
|
|||||||
# ── Try next server ──
|
# ── Try next server ──
|
||||||
try_next_server() {
|
try_next_server() {
|
||||||
next=$((CURRENT_SERVER + 1))
|
next=$((CURRENT_SERVER + 1))
|
||||||
if [ "$next" -gt "$SERVER_COUNT" ]; then
|
[ "$next" -gt "$SERVER_COUNT" ] && next=1
|
||||||
next=1
|
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
|
||||||
fi
|
|
||||||
|
|
||||||
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 ] && 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
|
||||||
|
log "PRIMARY ($ok_url http=200) alive, switching back"
|
||||||
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
|
||||||
|
|
||||||
|
# 1) WG interface must be public
|
||||||
|
sl=$(echo "$rc" | awk -v ifc="interface $WG_IF" '$0~ifc{f=1} f&&/security-level/{print $2; exit}')
|
||||||
|
if [ -n "$sl" ] && [ "$sl" != "public" ]; then
|
||||||
|
ndmc -c "interface $WG_IF security-level public" >/dev/null 2>&1
|
||||||
|
ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
log "HEAL: $WG_IF security-level -> public"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) learn live bindings (append-only) into lan-hosts
|
||||||
|
touch "$LANHOSTS"
|
||||||
|
echo "$rc" | grep -oE "host [0-9a-f:]+ policy [A-Za-z0-9_]+" | while read -r _h mac _p pol; do
|
||||||
|
grep -q "^$mac " "$LANHOSTS" 2>/dev/null || echo "$mac $pol" >> "$LANHOSTS"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3) re-apply any learned binding that is missing live
|
||||||
|
changed=0
|
||||||
|
while read -r mac pol; do
|
||||||
|
[ -z "$mac" ] && continue
|
||||||
|
case "$mac" in \#*) continue;; esac
|
||||||
|
if ! echo "$rc" | grep -q "host $mac policy $pol"; then
|
||||||
|
ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1
|
||||||
|
log "HEAL: re-bound host $mac -> $pol"
|
||||||
|
changed=1
|
||||||
|
fi
|
||||||
|
done < "$LANHOSTS"
|
||||||
|
[ "$changed" = 1 ] && ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Explicit apply hook (used by phobos-pull.sh after a config change).
|
||||||
|
# `phobos-health.sh apply-server N` re-points the tunnel to SERVER_N
|
||||||
|
# immediately, skipping the failure-escalation logic. Reuses
|
||||||
|
# switch_endpoint so the WG-peer/obfuscator/state changes stay identical
|
||||||
|
# to a normal failover. Resets fail_count so the new server starts clean.
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
if [ "$1" = "apply-server" ]; then
|
||||||
|
idx="${2:-1}"
|
||||||
|
eval "ahost=\$SERVER_${idx}_HOST"
|
||||||
|
if [ -z "$ahost" ]; then
|
||||||
|
log "APPLY: server $idx not in conf, ignore"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "APPLY: pull requested server $idx ($ahost)"
|
||||||
|
switch_endpoint "$idx" "0"
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
echo "$idx" > "$STATE/current_server"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Pre-check: fix desync (obfuscator targeting wrong server)
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
if [ -f "$OBF_CONF" ]; then
|
||||||
|
cur_target=$(grep "^target = " "$OBF_CONF" 2>/dev/null | sed 's/target = //')
|
||||||
|
eval "expected_host=\$SERVER_${CURRENT_SERVER}_HOST"
|
||||||
|
if [ -n "$cur_target" ] && [ -n "$expected_host" ]; then
|
||||||
|
echo "$cur_target" | grep -q "$expected_host" || {
|
||||||
|
log "DESYNC: obf=$cur_target state=server${CURRENT_SERVER}($expected_host). Resync."
|
||||||
|
switch_endpoint "$CURRENT_SERVER" "$CURRENT_PORT_IDX"
|
||||||
|
sleep 5
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── LAN routing self-heal (gated ~5 min; a reboot drops host->policy bindings) ──
|
||||||
|
HEAL_TS=$(cat "$STATE/heal_ts" 2>/dev/null || echo 0)
|
||||||
|
now_heal=$(date +%s)
|
||||||
|
if [ $((now_heal - HEAL_TS)) -ge 300 ]; then
|
||||||
|
echo "$now_heal" > "$STATE/heal_ts"
|
||||||
|
heal_lan_routing
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Peer hygiene: keep exactly ONE WG peer = current server's key ──
|
||||||
|
# Failovers can leave dead/duplicate peers; multiple 0.0.0.0/0 peers cause
|
||||||
|
# ambiguous egress routing. Self-heal here every run (cheap when already clean).
|
||||||
|
eval "cur_wgkey=\$SERVER_${CURRENT_SERVER}_WGKEY"
|
||||||
|
if [ -n "$cur_wgkey" ]; then
|
||||||
|
peer_n=$(count_server_peers)
|
||||||
|
if [ "$peer_n" -gt 1 ]; then
|
||||||
|
log "HYGIENE: $peer_n server peers present, purging to server${CURRENT_SERVER}"
|
||||||
|
switch_wg_peer "$cur_wgkey"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Main logic
|
||||||
|
# Tunnel health = WireGuard handshake age (authoritative: a fresh
|
||||||
|
# handshake only happens through the full obfuscator→server→WG path).
|
||||||
|
# WAN ping is logged for context ONLY — it does NOT gate decisions,
|
||||||
|
# because the router's default route is not the tunnel, so WAN can be
|
||||||
|
# up while the tunnel is dead (and a WAN blip must not cause failover).
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
AGE=$(get_handshake_age)
|
||||||
|
WAN=$(check_connectivity && echo "yes" || echo "no")
|
||||||
|
|
||||||
|
# Tunnel healthy = fresh handshake
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
||||||
# All good, reset counters
|
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||||
if [ "$RESTART_COUNT" -gt 0 ]; then
|
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
|
||||||
log "OK: connection restored (age=${AGE}s)"
|
echo "0" > "$STATE/fail_count"
|
||||||
echo "0" > "$STATE/restart_count"
|
|
||||||
fi
|
fi
|
||||||
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"
|
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||||
log "ACTION: restart obfuscator (attempt $RESTART_COUNT)"
|
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
|
||||||
killall wg-obfuscator 2>/dev/null
|
else
|
||||||
sleep 1
|
killall wg-obfuscator 2>/dev/null
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
sleep 1
|
||||||
exit 0
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_RESTART_WG" ]; then
|
|
||||||
# Restart WireGuard + obfuscator
|
|
||||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
|
||||||
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
|
||||||
log "ACTION: restart WG + obfuscator (attempt $RESTART_COUNT)"
|
|
||||||
wg-quick down wg0 2>/dev/null
|
|
||||||
killall wg-obfuscator 2>/dev/null
|
|
||||||
sleep 2
|
|
||||||
wg-quick up wg0 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_FAILOVER" ]; then
|
|
||||||
# Try next port
|
|
||||||
log "ACTION: try port hop"
|
|
||||||
if ! try_next_port; then
|
|
||||||
log "ACTION: all ports exhausted, try next server"
|
|
||||||
try_next_server
|
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Full failover
|
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server
|
||||||
log "ACTION: failover (handshake ${AGE}s)"
|
if [ "$FAIL_COUNT" -eq 2 ]; then
|
||||||
if ! try_next_server; then
|
if try_next_port; then
|
||||||
# Only one server, cycle ports
|
exit 0
|
||||||
try_next_port
|
fi
|
||||||
|
# only one port → fall through to server failover
|
||||||
|
log "single port, escalate to failover"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server
|
||||||
|
# gets its own restart→port-hop→failover cycle next ticks.
|
||||||
|
log "ACTION: server failover (fail $FAIL_COUNT)"
|
||||||
|
if try_next_server; then
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
else
|
||||||
|
# Only one server — cycle back to port 0 and restart escalation
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
switch_endpoint "$CURRENT_SERVER" "0"
|
||||||
fi
|
fi
|
||||||
|
|||||||
51
docs/DNS.md
Normal file
51
docs/DNS.md
Normal 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.
|
||||||
29
docs/WARP.md
Normal file
29
docs/WARP.md
Normal 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).
|
||||||
139
docs/cloud.md
Normal file
139
docs/cloud.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Канал «Облако» — VPN через облако своего Keenetic (PCA Phobos 1.4.4)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Что это и зачем
|
||||||
|
Обычно телефон ходит к серверу 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 включён · Облако».
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Адрес можно не вводить руками: если в ссылке клиента есть строка `# 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/cloud/android-1-glavnyj.png
Normal file
BIN
docs/img/cloud/android-1-glavnyj.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/img/cloud/android-2-adres.png
Normal file
BIN
docs/img/cloud/android-2-adres.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/img/cloud/android-3-skorost.png
Normal file
BIN
docs/img/cloud/android-3-skorost.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/img/cloud/skhema.png
Normal file
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
37
docs/img/cloud/skhema.svg
Normal 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 |
44
install.sh
44
install.sh
@@ -29,11 +29,46 @@ 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}"
|
||||||
|
# Уже есть wg0.conf (переустановка) — подсеть берём из него, иначе server.env разойдётся с интерфейсом
|
||||||
|
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -f /etc/wireguard/wg0.conf ]; then
|
||||||
|
SERVER_WG_IPV4_NETWORK="$(python3 - <<'PY' 2>/dev/null
|
||||||
|
import ipaddress, re
|
||||||
|
for ln in open("/etc/wireguard/wg0.conf"):
|
||||||
|
m = re.match(r"\s*Address\s*=\s*([^,\s]+)", ln)
|
||||||
|
if m:
|
||||||
|
print(ipaddress.ip_interface(m.group(1)).network); break
|
||||||
|
PY
|
||||||
|
)" || SERVER_WG_IPV4_NETWORK=""
|
||||||
|
fi
|
||||||
|
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
||||||
|
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
|
||||||
|
printf '\n!!! Это ВТОРОЙ сервер к уже работающей панели? Тогда прервите (Ctrl+C) и ставьте командой из панели\n «Серверы -> Как добавить второй сервер» (secondary-setup.sh), подсеть там по умолчанию.\n Своя подсеть нужна только для НЕЗАВИСИМОЙ панели (например 10.25.11.0/24, не пересекается с первой);\n смена сервера у клиентов между независимыми панелями не работает.\n\n' > /dev/tty
|
||||||
|
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
|
||||||
|
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
||||||
|
fi
|
||||||
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
SERVER_WG_IPV4_NETWORK="${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 — запасной.
|
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
|
||||||
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
|
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
|
||||||
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
||||||
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
|
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
|
||||||
CHANNEL="${CHANNEL:-stable}"
|
CHANNEL="${CHANNEL:-stable}"
|
||||||
case "$CHANNEL" in
|
case "$CHANNEL" in
|
||||||
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
|
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
|
||||||
@@ -336,6 +371,13 @@ sleep 2
|
|||||||
mkdir -p "$PANEL_DIR"
|
mkdir -p "$PANEL_DIR"
|
||||||
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
|
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"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct ContentView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
profileCard
|
profileCard
|
||||||
|
if vpn.servers.count > 1 { serversCard }
|
||||||
powerButton
|
powerButton
|
||||||
statusLine
|
statusLine
|
||||||
optionsCard
|
optionsCard
|
||||||
@@ -57,6 +58,30 @@ struct ContentView: View {
|
|||||||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var serversCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Серверы").font(.subheadline.bold()).padding(.bottom, 6)
|
||||||
|
ForEach(vpn.servers, id: \.self) { s in
|
||||||
|
let n = s["name"] ?? ""
|
||||||
|
HStack {
|
||||||
|
Image(systemName: n == vpn.name ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(n == vpn.name ? .green : .secondary)
|
||||||
|
Text(n)
|
||||||
|
Spacer()
|
||||||
|
if n != vpn.name {
|
||||||
|
Button(role: .destructive) { vpn.remove(n) } label: { Image(systemName: "trash") }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { vpn.select(n) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
private var powerButton: some View {
|
private var powerButton: some View {
|
||||||
Button(action: vpn.toggle) {
|
Button(action: vpn.toggle) {
|
||||||
Text(vpn.isOn ? "Отключить" : "Подключить")
|
Text(vpn.isOn ? "Отключить" : "Подключить")
|
||||||
@@ -85,9 +110,16 @@ struct ContentView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Канал до сервера").font(.subheadline.bold())
|
Text("Канал до сервера").font(.subheadline.bold())
|
||||||
Picker("Канал", selection: $vpn.transportMode) {
|
Picker("Канал", selection: $vpn.transportMode) {
|
||||||
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp")
|
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp"); Text("Облако").tag("cloud")
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
if vpn.transportMode == "cloud" {
|
||||||
|
TextField("wss://phobos.имя.keenetic.pro/", text: $vpn.wsURL)
|
||||||
|
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
|
||||||
|
.textFieldStyle(.roundedBorder).onSubmit { vpn.wsURL = vpn.wsURL.trimmingCharacters(in: .whitespaces) }
|
||||||
|
Text("Облако: телефон → облако KeenDNS → ваш Keenetic → сервер. Работает, когда до сервера напрямую не достучаться.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Text("Авто: если UDP режут, через 15 секунд переходит на TCP. Изменения применяются сразу.")
|
Text("Авто: если UDP режут, через 15 секунд переходит на TCP. Изменения применяются сразу.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -30,9 +30,13 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
|
<key>UIRequiresFullScreen</key>
|
||||||
|
<true/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ final class VPNController: ObservableObject {
|
|||||||
@Published var conf = UserDefaults.standard.string(forKey: "conf") ?? ""
|
@Published var conf = UserDefaults.standard.string(forKey: "conf") ?? ""
|
||||||
@Published var ruDirect = UserDefaults.standard.object(forKey: "ruDirect") as? Bool ?? true { didSet { saveOptions() } }
|
@Published var ruDirect = UserDefaults.standard.object(forKey: "ruDirect") as? Bool ?? true { didSet { saveOptions() } }
|
||||||
@Published var transportMode = UserDefaults.standard.string(forKey: "transport") ?? "auto" { didSet { saveOptions() } }
|
@Published var transportMode = UserDefaults.standard.string(forKey: "transport") ?? "auto" { didSet { saveOptions() } }
|
||||||
|
/// Channel «Облако»: wss:// of the user's Keenetic (empty -> "# pca-ws" from the link).
|
||||||
|
@Published var wsURL = UserDefaults.standard.string(forKey: "wsURL") ?? "" { didSet { saveOptions() } }
|
||||||
|
/// Imported servers: [["name": …, "conf": …]]; name/conf above mirror the selected one.
|
||||||
|
@Published var servers = UserDefaults.standard.array(forKey: "servers") as? [[String: String]] ?? []
|
||||||
@Published var status: NEVPNStatus = .invalid
|
@Published var status: NEVPNStatus = .invalid
|
||||||
@Published var transport = ""
|
@Published var transport = ""
|
||||||
@Published var message = ""
|
@Published var message = ""
|
||||||
@@ -29,6 +33,7 @@ final class VPNController: ObservableObject {
|
|||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
if servers.isEmpty && !conf.isEmpty { servers = [["name": name, "conf": conf]] } // pre-list single profile
|
||||||
Task { await load() }
|
Task { await load() }
|
||||||
NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
||||||
Task { @MainActor in self?.status = self?.manager?.connection.status ?? .invalid }
|
Task { @MainActor in self?.status = self?.manager?.connection.status ?? .invalid }
|
||||||
@@ -49,17 +54,40 @@ final class VPNController: ObservableObject {
|
|||||||
|
|
||||||
func importLink(_ link: String) {
|
func importLink(_ link: String) {
|
||||||
guard let parsed = PhobosProfile.fromLink(link) else { message = "Это не ссылка phobos:// из панели"; return }
|
guard let parsed = PhobosProfile.fromLink(link) else { message = "Это не ссылка phobos:// из панели"; return }
|
||||||
conf = parsed.profile.raw
|
servers.removeAll { $0["name"] == parsed.name } // same name = updated link
|
||||||
name = parsed.name
|
servers.append(["name": parsed.name, "conf": parsed.profile.raw])
|
||||||
|
select(parsed.name)
|
||||||
|
message = "Профиль «\(name)» импортирован"
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ server: String) {
|
||||||
|
guard let s = servers.first(where: { $0["name"] == server }) else { return }
|
||||||
|
name = server
|
||||||
|
conf = s["conf"] ?? ""
|
||||||
|
persist()
|
||||||
|
Task { await save(reconnect: isOn) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ server: String) {
|
||||||
|
servers.removeAll { $0["name"] == server }
|
||||||
|
if server == name {
|
||||||
|
if let next = servers.first?["name"] { select(next); return }
|
||||||
|
if isOn { manager?.connection.stopVPNTunnel() }
|
||||||
|
name = ""; conf = ""
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
UserDefaults.standard.set(servers, forKey: "servers")
|
||||||
UserDefaults.standard.set(conf, forKey: "conf")
|
UserDefaults.standard.set(conf, forKey: "conf")
|
||||||
UserDefaults.standard.set(name, forKey: "name")
|
UserDefaults.standard.set(name, forKey: "name")
|
||||||
message = "Профиль «\(name)» импортирован"
|
|
||||||
Task { await save(reconnect: isOn) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveOptions() {
|
private func saveOptions() {
|
||||||
UserDefaults.standard.set(ruDirect, forKey: "ruDirect")
|
UserDefaults.standard.set(ruDirect, forKey: "ruDirect")
|
||||||
UserDefaults.standard.set(transportMode, forKey: "transport")
|
UserDefaults.standard.set(transportMode, forKey: "transport")
|
||||||
|
UserDefaults.standard.set(wsURL, forKey: "wsURL")
|
||||||
Task { await save(reconnect: isOn) }
|
Task { await save(reconnect: isOn) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +98,7 @@ final class VPNController: ObservableObject {
|
|||||||
let proto = NETunnelProviderProtocol()
|
let proto = NETunnelProviderProtocol()
|
||||||
proto.providerBundleIdentifier = "com.bobyrev.pcaphobos.tunnel"
|
proto.providerBundleIdentifier = "com.bobyrev.pcaphobos.tunnel"
|
||||||
proto.serverAddress = profile.targetHost ?? "Phobos"
|
proto.serverAddress = profile.targetHost ?? "Phobos"
|
||||||
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode]
|
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode, "wsURL": wsURL]
|
||||||
m.protocolConfiguration = proto
|
m.protocolConfiguration = proto
|
||||||
m.localizedDescription = "PCA Phobos"
|
m.localizedDescription = "PCA Phobos"
|
||||||
m.isEnabled = true
|
m.isEnabled = true
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
38870650A7C2B1F0927A1230 /* wg-obfuscator.c in Sources */ = {isa = PBXBuildFile; fileRef = 1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
38870650A7C2B1F0927A1230 /* wg-obfuscator.c in Sources */ = {isa = PBXBuildFile; fileRef = 1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||||
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */; };
|
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */; };
|
||||||
56F35C68C6A387E499703CFF /* masking.c in Sources */ = {isa = PBXBuildFile; fileRef = CCE5C98CD905BDE16A8D83D4 /* masking.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
56F35C68C6A387E499703CFF /* masking.c in Sources */ = {isa = PBXBuildFile; fileRef = CCE5C98CD905BDE16A8D83D4 /* masking.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||||
|
599057D9048B5FF1EA0E6000 /* WsLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC83AB79BE0E361F4EE90875 /* WsLink.swift */; };
|
||||||
5F5D376513C0B27C45A3E7B6 /* ru_bypass.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2572D9A6F822B038D0652619 /* ru_bypass.txt */; };
|
5F5D376513C0B27C45A3E7B6 /* ru_bypass.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2572D9A6F822B038D0652619 /* ru_bypass.txt */; };
|
||||||
78B74F81E0042D9AD94B7A6B /* config.c in Sources */ = {isa = PBXBuildFile; fileRef = 0E5C1673464AAC3EF81DD2FE /* config.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
78B74F81E0042D9AD94B7A6B /* config.c in Sources */ = {isa = PBXBuildFile; fileRef = 0E5C1673464AAC3EF81DD2FE /* config.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||||
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */; };
|
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */; };
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PCAPhobosApp.swift; sourceTree = "<group>"; };
|
B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PCAPhobosApp.swift; sourceTree = "<group>"; };
|
||||||
CA6480536DA633DB94B6930F /* WireGuardKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WireGuardKit; path = WireGuardKit; sourceTree = SOURCE_ROOT; };
|
CA6480536DA633DB94B6930F /* WireGuardKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WireGuardKit; path = WireGuardKit; sourceTree = SOURCE_ROOT; };
|
||||||
CCE5C98CD905BDE16A8D83D4 /* masking.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = masking.c; sourceTree = "<group>"; };
|
CCE5C98CD905BDE16A8D83D4 /* masking.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = masking.c; sourceTree = "<group>"; };
|
||||||
|
EC83AB79BE0E361F4EE90875 /* WsLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WsLink.swift; sourceTree = "<group>"; };
|
||||||
EF9115CA0D5EC52C8679A12A /* obfuscation.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = obfuscation.c; sourceTree = "<group>"; };
|
EF9115CA0D5EC52C8679A12A /* obfuscation.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = obfuscation.c; sourceTree = "<group>"; };
|
||||||
FF14952A4263FF0E004A3F04 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
FF14952A4263FF0E004A3F04 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -103,6 +105,7 @@
|
|||||||
2572D9A6F822B038D0652619 /* ru_bypass.txt */,
|
2572D9A6F822B038D0652619 /* ru_bypass.txt */,
|
||||||
7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */,
|
7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */,
|
||||||
2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */,
|
2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */,
|
||||||
|
EC83AB79BE0E361F4EE90875 /* WsLink.swift */,
|
||||||
);
|
);
|
||||||
path = Tunnel;
|
path = Tunnel;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -214,11 +217,11 @@
|
|||||||
LastUpgradeCheck = 1430;
|
LastUpgradeCheck = 1430;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
0FD066BC2C51A1E092914FE8 = {
|
0FD066BC2C51A1E092914FE8 = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = Q5FKU39TUA;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
E1F7E69029753CF102C6CE2E = {
|
E1F7E69029753CF102C6CE2E = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = Q5FKU39TUA;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -274,6 +277,7 @@
|
|||||||
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */,
|
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */,
|
||||||
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */,
|
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */,
|
||||||
11F2A6A8D281AB36B45D4603 /* TunnelConfiguration+WgQuickConfig.swift in Sources */,
|
11F2A6A8D281AB36B45D4603 /* TunnelConfiguration+WgQuickConfig.swift in Sources */,
|
||||||
|
599057D9048B5FF1EA0E6000 /* WsLink.swift in Sources */,
|
||||||
78B74F81E0042D9AD94B7A6B /* config.c in Sources */,
|
78B74F81E0042D9AD94B7A6B /* config.c in Sources */,
|
||||||
1C05D29ACA95548374AD6FD9 /* logging.c in Sources */,
|
1C05D29ACA95548374AD6FD9 /* logging.c in Sources */,
|
||||||
56F35C68C6A387E499703CFF /* masking.c in Sources */,
|
56F35C68C6A387E499703CFF /* masking.c in Sources */,
|
||||||
@@ -385,9 +389,9 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 141;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = Q5FKU39TUA;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -399,7 +403,7 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -445,9 +449,9 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 141;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = Q5FKU39TUA;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -465,7 +469,7 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
# PCA Phobos VPN — iPhone
|
# PCA Phobos VPN — iPhone
|
||||||
|
|
||||||
То же, что Android-клиент: импорт `phobos://` из панели, обфускация (wg-obfuscator внутри
|
То же, что Android-клиент: импорт `phobos://` из панели, обфускация (wg-obfuscator внутри
|
||||||
расширения), **российские сайты напрямую**, канал **Авто/UDP/TCP** (при блокировке UDP — сам на TCP).
|
расширения), **российские сайты напрямую**, канал **Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP; «Облако» — через KeenDNS своего Keenetic, см. `../wsgw/README.md`).
|
||||||
Выбор отдельных приложений на iOS невозможен (Apple даёт это только через MDM) — делим по адресам.
|
Выбор отдельных приложений на iOS невозможен (Apple даёт это только через MDM) — делим по адресам.
|
||||||
|
|
||||||
Устройство:
|
Устройство:
|
||||||
- `App/` — SwiftUI-приложение (профиль, кнопка, настройки), `Shared/PhobosProfile.swift` — разбор ссылки.
|
- `App/` — SwiftUI-приложение (профиль, кнопка, настройки), `Shared/PhobosProfile.swift` — разбор ссылки.
|
||||||
- `Tunnel/` — Packet Tunnel: WireGuardKit → 127.0.0.1:13255 wg-obfuscator (`-Dmain=wgobf_main`, один
|
- `Tunnel/` — Packet Tunnel: WireGuardKit → 127.0.0.1:13255 wg-obfuscator (`-Dmain=wgobf_main`, один
|
||||||
экземпляр: iOS запрещает fork) → 127.0.0.1:13258 `pcarelay.c` → сервер по UDP или TCP (формат udptcp).
|
экземпляр: iOS запрещает fork) → 127.0.0.1:13258 `pcarelay.c` → сервер по UDP или TCP (формат udptcp),
|
||||||
|
либо «Облако»: пакеты из pcarelay в `WsLink.swift` (URLSessionWebSocketTask) → wss://поддомен роутера → wsgw → сервер.
|
||||||
- `WireGuardKit/` — wireguard-apple (MIT), `WireGuardGo/<sdk>/libwg-go.a` — собран `make` (iphoneos) и
|
- `WireGuardKit/` — wireguard-apple (MIT), `WireGuardGo/<sdk>/libwg-go.a` — собран `make` (iphoneos) и
|
||||||
`go build -buildmode c-archive` с `-target arm64-apple-ios15.0-simulator` (симулятор).
|
`go build -buildmode c-archive` с `-target arm64-apple-ios15.0-simulator` (симулятор).
|
||||||
|
|
||||||
Сборка: `xcodegen generate` → `PCAPhobos.xcodeproj`. Проверка без телефона: `Tests/ProfileCheck`.
|
Сборка: `xcodegen generate` → `PCAPhobos.xcodeproj`. Проверка без телефона: `Tests/ProfileCheck`, режим «Облако» релея — `Tests/RelayCheck/main.c`.
|
||||||
|
|
||||||
## Установка на iPhone (нужен ПЛАТНЫЙ Apple Developer, $99/год)
|
## Установка на iPhone (нужен ПЛАТНЫЙ Apple Developer, $99/год)
|
||||||
Network Extension (packet-tunnel-provider) Apple выдаёт только платным аккаунтам — с бесплатным
|
Network Extension (packet-tunnel-provider) Apple выдаёт только платным аккаунтам — с бесплатным
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ struct PhobosProfile {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "# pca-ws = wss://…" — the user's Keenetic (KeenDNS cloud subdomain -> wsgw), channel «Облако».
|
||||||
|
var wsURL: String? {
|
||||||
|
for rawLine in raw.split(separator: "\n") {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
if line.hasPrefix("#"), line.contains("pca-ws"), let eq = line.firstIndex(of: "=") {
|
||||||
|
return line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func obfuscatorConfig(listen: Int, target: String) -> String {
|
func obfuscatorConfig(listen: Int, target: String) -> String {
|
||||||
var out = "[instance]\nsource-if = 127.0.0.1\nsource-lport = \(listen)\ntarget = \(target)\n"
|
var out = "[instance]\nsource-if = 127.0.0.1\nsource-lport = \(listen)\ntarget = \(target)\n"
|
||||||
for item in lines("[instance]") where !["source-if", "source-lport", "target", "verbose"].contains(item.key) {
|
for item in lines("[instance]") where !["source-if", "source-lport", "target", "verbose"].contains(item.key) {
|
||||||
@@ -70,7 +81,13 @@ struct PhobosProfile {
|
|||||||
|
|
||||||
func wireGuardConfig(endpoint: String, allowedIPs: String?) -> String {
|
func wireGuardConfig(endpoint: String, allowedIPs: String?) -> String {
|
||||||
var out = "[Interface]\n"
|
var out = "[Interface]\n"
|
||||||
for item in lines("[Interface]") where !item.key.hasSuffix("applications") { out += item.line + "\n" }
|
for item in lines("[Interface]") where !item.key.hasSuffix("applications") && item.key != "mtu" { out += item.line + "\n" }
|
||||||
|
// ponytail: obfuscator masking adds bytes; the panel's default 1420 stalls on mobile networks,
|
||||||
|
// so 1420/missing -> 1280, any other MTU in the link wins (set per client in the panel)
|
||||||
|
let linkMTU = lines("[Interface]").first { $0.key == "mtu" }
|
||||||
|
.flatMap { Int($0.line.split(separator: "=").last?.trimmingCharacters(in: .whitespaces) ?? "") }
|
||||||
|
let mtu = (linkMTU == nil || linkMTU == 1420) ? 1280 : linkMTU!
|
||||||
|
out += "MTU = \(mtu)\n"
|
||||||
out += "\n[Peer]\n"
|
out += "\n[Peer]\n"
|
||||||
for item in lines("[Peer]") {
|
for item in lines("[Peer]") {
|
||||||
switch item.key {
|
switch item.key {
|
||||||
|
|||||||
@@ -35,4 +35,7 @@ let obf = p.obfuscatorConfig(listen: 13255, target: "127.0.0.1:13258")
|
|||||||
precondition(obf.contains("target = 127.0.0.1:13258") && obf.contains("key = testkey") && !obf.contains("203.0.113.10"))
|
precondition(obf.contains("target = 127.0.0.1:13258") && obf.contains("key = testkey") && !obf.contains("203.0.113.10"))
|
||||||
let wg = p.wireGuardConfig(endpoint: "127.0.0.1:13255", allowedIPs: "1.0.0.0/8")
|
let wg = p.wireGuardConfig(endpoint: "127.0.0.1:13255", allowedIPs: "1.0.0.0/8")
|
||||||
precondition(wg.contains("Endpoint = 127.0.0.1:13255") && wg.contains("AllowedIPs = 1.0.0.0/8") && !wg.contains("= none"))
|
precondition(wg.contains("Endpoint = 127.0.0.1:13255") && wg.contains("AllowedIPs = 1.0.0.0/8") && !wg.contains("= none"))
|
||||||
|
precondition(wg.contains("MTU = 1280") && !wg.contains("1420")) // panel default -> 1280
|
||||||
|
let custom = PhobosProfile.fromLink("phobos://" + Data(conf.replacingOccurrences(of: "MTU = 1420", with: "MTU = 1300").utf8).base64EncodedString())!.0
|
||||||
|
precondition(custom.wireGuardConfig(endpoint: "e", allowedIPs: nil).contains("MTU = 1300")) // link MTU wins
|
||||||
print("profile_check ok")
|
print("profile_check ok")
|
||||||
|
|||||||
22
ios/Tests/RelayCheck/main.c
Normal file
22
ios/Tests/RelayCheck/main.c
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/* Mode «Облако» of pcarelay without a phone: cc -I Tunnel -o /tmp/rc Tests/RelayCheck/main.c Tunnel/pcarelay.c && /tmp/rc */
|
||||||
|
#include "pcarelay.h"
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
int wgobf_main(int c, char **v) { return 0; }
|
||||||
|
static void out(const unsigned char *p, int n) { unsigned char r[64]; memcpy(r, p, n); r[0] = 'R'; pcarelay_ws_in(r, n); }
|
||||||
|
int main(void) {
|
||||||
|
pcarelay_start(13299, "127.0.0.1", 9, 0);
|
||||||
|
pcarelay_use_ws(out);
|
||||||
|
usleep(200000);
|
||||||
|
assert(pcarelay_is_ws() && !pcarelay_is_tcp());
|
||||||
|
int s = socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons(13299), .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
|
||||||
|
struct timeval tv = {2, 0}; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
|
||||||
|
sendto(s, "ping", 4, 0, (struct sockaddr *)&a, sizeof a);
|
||||||
|
char b[16] = {0}; int n = recv(s, b, sizeof b, 0);
|
||||||
|
assert(n == 4 && !memcmp(b, "Ring", 4) && pcarelay_rx_bytes() == 4);
|
||||||
|
puts("pcarelay ws: ok");
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>XPC!</string>
|
<string>XPC!</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import WireGuardKit
|
|||||||
|
|
||||||
/// PCA Phobos packet tunnel:
|
/// PCA Phobos packet tunnel:
|
||||||
/// WireGuard (userspace) -> 127.0.0.1:13255 wg-obfuscator -> 127.0.0.1:13258 pcarelay -> server (UDP or TCP).
|
/// WireGuard (userspace) -> 127.0.0.1:13255 wg-obfuscator -> 127.0.0.1:13258 pcarelay -> server (UDP or TCP).
|
||||||
/// providerConfiguration: "conf" (phobos:// payload), "ruDirect" (Bool), "transport" ("auto"|"udp"|"tcp").
|
/// providerConfiguration: "conf" (phobos:// payload), "ruDirect" (Bool), "transport" ("auto"|"udp"|"tcp"|"cloud"), "wsURL".
|
||||||
|
/// "cloud": pcarelay hands packets to WsLink -> wss://<Keenetic KeenDNS subdomain>/ -> wsgw on the router -> server UDP.
|
||||||
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
private static let wgPort = 13255, relayPort = 13258
|
private static let wgPort = 13255, relayPort = 13258
|
||||||
private static var obfuscatorStarted = false // one wg-obfuscator per process (it forks for more)
|
private static var obfuscatorStarted = false // one wg-obfuscator per process (it forks for more)
|
||||||
@@ -31,7 +32,17 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pcarelay_use_tcp(transport == "tcp" ? 1 : 0)
|
let wsURL = (pc["wsURL"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? profile.wsURL
|
||||||
|
if transport == "cloud" {
|
||||||
|
guard let s = wsURL, let url = URL(string: s) else {
|
||||||
|
completionHandler(NSError(domain: "PCAPhobos", code: 4, userInfo: [NSLocalizedDescriptionKey: "не задан адрес роутера (wss://…) для канала «Облако»"]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WsLink.shared.start(url)
|
||||||
|
pcarelay_use_ws { p, n in WsLink.shared.send(p, n) }
|
||||||
|
} else {
|
||||||
|
pcarelay_use_tcp(transport == "tcp" ? 1 : 0)
|
||||||
|
}
|
||||||
if !Self.obfuscatorStarted {
|
if !Self.obfuscatorStarted {
|
||||||
Self.obfuscatorStarted = true
|
Self.obfuscatorStarted = true
|
||||||
pcarelay_start(Int32(Self.relayPort), host, Int32(udpPort), Int32(tcpPort))
|
pcarelay_start(Int32(Self.relayPort), host, Int32(udpPort), Int32(tcpPort))
|
||||||
@@ -44,8 +55,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowed = ruDirect ? Self.ruBypassAllowedIPs() : nil
|
// full tunnel; Russian networks (all sizes, incl. CDN /24s) are excluded routes when ruDirect
|
||||||
let wgText = profile.wireGuardConfig(endpoint: "127.0.0.1:\(Self.wgPort)", allowedIPs: allowed)
|
PhobosRoutes.excludedIPv4 = ruDirect ? Self.ruNetworks() : []
|
||||||
|
let wgText = profile.wireGuardConfig(endpoint: "127.0.0.1:\(Self.wgPort)", allowedIPs: nil)
|
||||||
let tunnelConfig: TunnelConfiguration
|
let tunnelConfig: TunnelConfiguration
|
||||||
do {
|
do {
|
||||||
tunnelConfig = try TunnelConfiguration(fromWgQuickConfig: wgText, called: "phobos")
|
tunnelConfig = try TunnelConfiguration(fromWgQuickConfig: wgText, called: "phobos")
|
||||||
@@ -79,14 +91,17 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
|
|
||||||
/// App asks for status: {"transport": "UDP"|"TCP", "rx": bytes}
|
/// App asks for status: {"transport": "UDP"|"TCP", "rx": bytes}
|
||||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||||
let status: [String: Any] = ["transport": pcarelay_is_tcp() != 0 ? "TCP" : "UDP",
|
let status: [String: Any] = ["transport": pcarelay_is_ws() != 0 ? "Облако" : pcarelay_is_tcp() != 0 ? "TCP" : "UDP",
|
||||||
"rx": pcarelay_rx_bytes()]
|
"rx": pcarelay_rx_bytes()]
|
||||||
completionHandler?(try? JSONSerialization.data(withJSONObject: status))
|
completionHandler?(try? JSONSerialization.data(withJSONObject: status))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func ruBypassAllowedIPs() -> String? {
|
/// ru_bypass.txt: Russian IPv4 networks (comma-separated). IPv6 stays fully in the tunnel,
|
||||||
|
/// otherwise blocked sites leak over carrier IPv6.
|
||||||
|
private static func ruNetworks() -> [String] {
|
||||||
guard let url = Bundle.main.url(forResource: "ru_bypass", withExtension: "txt"),
|
guard let url = Bundle.main.url(forResource: "ru_bypass", withExtension: "txt"),
|
||||||
let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
|
let text = try? String(contentsOf: url, encoding: .utf8),
|
||||||
return text.split(separator: "\n").first { !$0.hasPrefix("#") && $0.contains("/") }.map(String.init)
|
let line = text.split(separator: "\n").first(where: { !$0.hasPrefix("#") && $0.contains("/") }) else { return [] }
|
||||||
|
return line.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
ios/Tunnel/WsLink.swift
Normal file
59
ios/Tunnel/WsLink.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Channel «Облако»: one binary WebSocket message = one obfuscated packet, both ways.
|
||||||
|
/// pcarelay (C) calls send() for every packet from the obfuscator; received messages go back via pcarelay_ws_in().
|
||||||
|
final class WsLink {
|
||||||
|
static let shared = WsLink()
|
||||||
|
|
||||||
|
private let q = DispatchQueue(label: "pca.ws")
|
||||||
|
private let session = URLSession(configuration: .ephemeral)
|
||||||
|
private var url: URL?
|
||||||
|
private var task: URLSessionWebSocketTask?
|
||||||
|
|
||||||
|
func start(_ url: URL) {
|
||||||
|
q.async { self.url = url; self.connect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ p: UnsafePointer<UInt8>?, _ n: Int32) {
|
||||||
|
guard let p, n > 0 else { return }
|
||||||
|
let data = Data(bytes: p, count: Int(n))
|
||||||
|
q.async {
|
||||||
|
if self.task == nil { self.connect() }
|
||||||
|
guard let t = self.task else { return }
|
||||||
|
t.send(.data(data)) { [weak self] err in
|
||||||
|
if let err { self?.q.async { if self?.task === t { self?.drop("send: \(err.localizedDescription)") } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect() {
|
||||||
|
guard let url, task == nil else { return }
|
||||||
|
let t = session.webSocketTask(with: url)
|
||||||
|
t.maximumMessageSize = 1 << 20
|
||||||
|
task = t
|
||||||
|
t.resume()
|
||||||
|
NSLog("PCAPhobos ws: connect %@", url.absoluteString)
|
||||||
|
receive(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receive(_ t: URLSessionWebSocketTask) {
|
||||||
|
t.receive { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .success(.data(let d)):
|
||||||
|
d.withUnsafeBytes { pcarelay_ws_in($0.bindMemory(to: UInt8.self).baseAddress, Int32(d.count)) }
|
||||||
|
self?.receive(t)
|
||||||
|
case .success:
|
||||||
|
self?.receive(t)
|
||||||
|
case .failure(let err):
|
||||||
|
self?.q.async { if self?.task === t { self?.drop("recv: \(err.localizedDescription)") } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Next packet from the obfuscator reconnects (WireGuard keepalives make that quick).
|
||||||
|
private func drop(_ why: String) {
|
||||||
|
NSLog("PCAPhobos ws: %@", why)
|
||||||
|
task?.cancel(with: .goingAway, reason: nil)
|
||||||
|
task = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* wg-obfuscator (one instance, iOS forbids fork) sends to 127.0.0.1:<local>;
|
* wg-obfuscator (one instance, iOS forbids fork) sends to 127.0.0.1:<local>;
|
||||||
* we forward either over UDP to the server's obfuscator port or over TCP to
|
* we forward either over UDP to the server's obfuscator port or over TCP to
|
||||||
* the server's udptcp (frames: 2-byte length + packet). Switch with pcarelay_use_tcp().
|
* the server's udptcp (frames: 2-byte length + packet). Switch with pcarelay_use_tcp().
|
||||||
|
* Mode 2 ("Облако"): packets go to a Swift callback (WebSocket to the user's Keenetic), answers come back via pcarelay_ws_in().
|
||||||
*/
|
*/
|
||||||
#include "pcarelay.h"
|
#include "pcarelay.h"
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
@@ -18,7 +19,12 @@
|
|||||||
#include <time.h>
|
#include <time.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
static atomic_int use_tcp = 0;
|
static atomic_int use_tcp = 0; /* 0 udp, 1 tcp, 2 ws */
|
||||||
|
static pcarelay_ws_out_fn ws_out;
|
||||||
|
static int g_local = -1;
|
||||||
|
static pthread_mutex_t peer_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
static struct sockaddr_storage g_peer;
|
||||||
|
static socklen_t g_pl;
|
||||||
static atomic_ullong rx_bytes = 0;
|
static atomic_ullong rx_bytes = 0;
|
||||||
static char g_host[256], g_udp_port[8], g_tcp_port[8];
|
static char g_host[256], g_udp_port[8], g_tcp_port[8];
|
||||||
static int g_local_port;
|
static int g_local_port;
|
||||||
@@ -59,15 +65,17 @@ static void *loop(void *arg) {
|
|||||||
la.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
la.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
int local = socket(AF_INET, SOCK_DGRAM, 0);
|
int local = socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
if (local < 0 || bind(local, (struct sockaddr *)&la, sizeof la) < 0) return NULL;
|
if (local < 0 || bind(local, (struct sockaddr *)&la, sizeof la) < 0) return NULL;
|
||||||
|
g_local = local;
|
||||||
int udp = -1, tcp = -1;
|
int udp = -1, tcp = -1;
|
||||||
struct sockaddr_storage peer; socklen_t pl = 0;
|
struct sockaddr_storage peer; socklen_t pl = 0;
|
||||||
unsigned char buf[65537];
|
unsigned char buf[65537];
|
||||||
time_t last_dial = 0;
|
time_t last_dial = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
int want_tcp = atomic_load(&use_tcp);
|
int mode = atomic_load(&use_tcp), want_tcp = mode == 1;
|
||||||
|
if (mode == 2) { if (udp >= 0) { close(udp); udp = -1; } if (tcp >= 0) { close(tcp); tcp = -1; } }
|
||||||
if (!want_tcp && tcp >= 0) { close(tcp); tcp = -1; }
|
if (!want_tcp && tcp >= 0) { close(tcp); tcp = -1; }
|
||||||
if (want_tcp && udp >= 0) { close(udp); udp = -1; }
|
if (want_tcp && udp >= 0) { close(udp); udp = -1; }
|
||||||
if (!want_tcp && udp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); udp = dial(SOCK_DGRAM, g_udp_port); }
|
if (mode == 0 && udp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); udp = dial(SOCK_DGRAM, g_udp_port); }
|
||||||
int up = want_tcp ? tcp : udp;
|
int up = want_tcp ? tcp : udp;
|
||||||
struct pollfd p[2] = {{local, POLLIN, 0}, {up, POLLIN, 0}};
|
struct pollfd p[2] = {{local, POLLIN, 0}, {up, POLLIN, 0}};
|
||||||
poll(p, up >= 0 ? 2 : 1, 1000);
|
poll(p, up >= 0 ? 2 : 1, 1000);
|
||||||
@@ -75,7 +83,10 @@ static void *loop(void *arg) {
|
|||||||
pl = sizeof peer;
|
pl = sizeof peer;
|
||||||
ssize_t n = recvfrom(local, buf, 65535, 0, (struct sockaddr *)&peer, &pl);
|
ssize_t n = recvfrom(local, buf, 65535, 0, (struct sockaddr *)&peer, &pl);
|
||||||
if (n <= 0) continue;
|
if (n <= 0) continue;
|
||||||
if (want_tcp) {
|
pthread_mutex_lock(&peer_mu); g_peer = peer; g_pl = pl; pthread_mutex_unlock(&peer_mu);
|
||||||
|
if (mode == 2) {
|
||||||
|
if (ws_out) ws_out(buf, (int)n);
|
||||||
|
} else if (want_tcp) {
|
||||||
if (tcp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); tcp = dial(SOCK_STREAM, g_tcp_port); }
|
if (tcp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); tcp = dial(SOCK_STREAM, g_tcp_port); }
|
||||||
if (tcp >= 0) {
|
if (tcp >= 0) {
|
||||||
unsigned char h[2] = {(unsigned char)(n >> 8), (unsigned char)n};
|
unsigned char h[2] = {(unsigned char)(n >> 8), (unsigned char)n};
|
||||||
@@ -114,5 +125,17 @@ int pcarelay_start(int local_port, const char *host, int udp_port, int tcp_port)
|
|||||||
}
|
}
|
||||||
|
|
||||||
void pcarelay_use_tcp(int on) { atomic_store(&use_tcp, on ? 1 : 0); }
|
void pcarelay_use_tcp(int on) { atomic_store(&use_tcp, on ? 1 : 0); }
|
||||||
int pcarelay_is_tcp(void) { return atomic_load(&use_tcp); }
|
int pcarelay_is_tcp(void) { return atomic_load(&use_tcp) == 1; }
|
||||||
|
|
||||||
|
void pcarelay_use_ws(pcarelay_ws_out_fn out) { ws_out = out; atomic_store(&use_tcp, 2); }
|
||||||
|
int pcarelay_is_ws(void) { return atomic_load(&use_tcp) == 2; }
|
||||||
|
|
||||||
|
void pcarelay_ws_in(const unsigned char *p, int n) {
|
||||||
|
if (g_local < 0 || n <= 0) return;
|
||||||
|
atomic_fetch_add(&rx_bytes, (unsigned long long)n);
|
||||||
|
pthread_mutex_lock(&peer_mu);
|
||||||
|
struct sockaddr_storage peer = g_peer; socklen_t pl = g_pl;
|
||||||
|
pthread_mutex_unlock(&peer_mu);
|
||||||
|
if (pl) sendto(g_local, p, (size_t)n, 0, (struct sockaddr *)&peer, pl);
|
||||||
|
}
|
||||||
unsigned long long pcarelay_rx_bytes(void) { return atomic_load(&rx_bytes); }
|
unsigned long long pcarelay_rx_bytes(void) { return atomic_load(&rx_bytes); }
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ int pcarelay_is_tcp(void);
|
|||||||
/* Bytes received back from the server on any path (auto-detects a dead UDP). */
|
/* Bytes received back from the server on any path (auto-detects a dead UDP). */
|
||||||
unsigned long long pcarelay_rx_bytes(void);
|
unsigned long long pcarelay_rx_bytes(void);
|
||||||
|
|
||||||
|
/* Mode "Облако": each packet from the obfuscator goes to out() (Swift sends it over WebSocket);
|
||||||
|
packets from the WebSocket are handed back with pcarelay_ws_in(). */
|
||||||
|
typedef void (*pcarelay_ws_out_fn)(const unsigned char *p, int n);
|
||||||
|
void pcarelay_use_ws(pcarelay_ws_out_fn out);
|
||||||
|
int pcarelay_is_ws(void);
|
||||||
|
void pcarelay_ws_in(const unsigned char *p, int n);
|
||||||
|
|
||||||
/* wg-obfuscator compiled in with -Dmain=wgobf_main (one instance per process). */
|
/* wg-obfuscator compiled in with -Dmain=wgobf_main (one instance per process). */
|
||||||
int wgobf_main(int argc, char **argv);
|
int wgobf_main(int argc, char **argv);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12,6 +12,11 @@ import WireGuardKitC
|
|||||||
/// A type alias for `Result` type that holds a tuple with source and resolved endpoint.
|
/// A type alias for `Result` type that holds a tuple with source and resolved endpoint.
|
||||||
typealias EndpointResolutionResult = Result<(Endpoint, Endpoint), DNSResolutionError>
|
typealias EndpointResolutionResult = Result<(Endpoint, Endpoint), DNSResolutionError>
|
||||||
|
|
||||||
|
/// PCA Phobos: IPv4 CIDRs that must not go into the tunnel (set before adapter.start).
|
||||||
|
public enum PhobosRoutes {
|
||||||
|
public static var excludedIPv4: [String] = []
|
||||||
|
}
|
||||||
|
|
||||||
class PacketTunnelSettingsGenerator {
|
class PacketTunnelSettingsGenerator {
|
||||||
let tunnelConfiguration: TunnelConfiguration
|
let tunnelConfiguration: TunnelConfiguration
|
||||||
let resolvedEndpoints: [Endpoint?]
|
let resolvedEndpoints: [Endpoint?]
|
||||||
@@ -118,6 +123,11 @@ class PacketTunnelSettingsGenerator {
|
|||||||
|
|
||||||
let ipv4Settings = NEIPv4Settings(addresses: ipv4Addresses.map { $0.destinationAddress }, subnetMasks: ipv4Addresses.map { $0.destinationSubnetMask })
|
let ipv4Settings = NEIPv4Settings(addresses: ipv4Addresses.map { $0.destinationAddress }, subnetMasks: ipv4Addresses.map { $0.destinationSubnetMask })
|
||||||
ipv4Settings.includedRoutes = ipv4IncludedRoutes
|
ipv4Settings.includedRoutes = ipv4IncludedRoutes
|
||||||
|
// PCA Phobos: "Russian sites direct" = these networks bypass the tunnel
|
||||||
|
ipv4Settings.excludedRoutes = PhobosRoutes.excludedIPv4.compactMap { cidr in
|
||||||
|
guard let range = IPAddressRange(from: cidr) else { return nil }
|
||||||
|
return NEIPv4Route(destinationAddress: "\(range.address)", subnetMask: "\(range.subnetMask())")
|
||||||
|
}
|
||||||
networkSettings.ipv4Settings = ipv4Settings
|
networkSettings.ipv4Settings = ipv4Settings
|
||||||
|
|
||||||
let ipv6Settings = NEIPv6Settings(addresses: ipv6Addresses.map { $0.destinationAddress }, networkPrefixLengths: ipv6Addresses.map { $0.destinationNetworkPrefixLength })
|
let ipv6Settings = NEIPv6Settings(addresses: ipv6Addresses.map { $0.destinationAddress }, networkPrefixLengths: ipv6Addresses.map { $0.destinationNetworkPrefixLength })
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ packages:
|
|||||||
path: WireGuardKit
|
path: WireGuardKit
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
MARKETING_VERSION: "1.4.1"
|
MARKETING_VERSION: "1.0"
|
||||||
CURRENT_PROJECT_VERSION: "141"
|
CURRENT_PROJECT_VERSION: "6"
|
||||||
DEVELOPMENT_TEAM: ""
|
DEVELOPMENT_TEAM: Q5FKU39TUA
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
targets:
|
targets:
|
||||||
PCAPhobos:
|
PCAPhobos:
|
||||||
@@ -33,8 +33,12 @@ targets:
|
|||||||
path: App/Info.plist
|
path: App/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: PCA Phobos
|
CFBundleDisplayName: PCA Phobos
|
||||||
|
ITSAppUsesNonExemptEncryption: false
|
||||||
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
||||||
|
UIRequiresFullScreen: true
|
||||||
CFBundleURLTypes:
|
CFBundleURLTypes:
|
||||||
- CFBundleURLName: com.bobyrev.pcaphobos.link
|
- CFBundleURLName: com.bobyrev.pcaphobos.link
|
||||||
CFBundleURLSchemes: [phobos]
|
CFBundleURLSchemes: [phobos]
|
||||||
@@ -50,6 +54,7 @@ targets:
|
|||||||
- Tunnel/TunnelConfiguration+WgQuickConfig.swift
|
- Tunnel/TunnelConfiguration+WgQuickConfig.swift
|
||||||
- Tunnel/String+ArrayConversion.swift
|
- Tunnel/String+ArrayConversion.swift
|
||||||
- Tunnel/pcarelay.c
|
- Tunnel/pcarelay.c
|
||||||
|
- Tunnel/WsLink.swift
|
||||||
- Shared
|
- Shared
|
||||||
- path: Tunnel/ru_bypass.txt
|
- path: Tunnel/ru_bypass.txt
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
@@ -66,6 +71,8 @@ targets:
|
|||||||
path: Tunnel/Info.plist
|
path: Tunnel/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: PhobosTunnel
|
CFBundleDisplayName: PhobosTunnel
|
||||||
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
NSExtension:
|
NSExtension:
|
||||||
NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel
|
NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel
|
||||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).PacketTunnelProvider
|
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).PacketTunnelProvider
|
||||||
|
|||||||
@@ -456,27 +456,21 @@ deploy_health_monitor() {
|
|||||||
[ -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 — туда писать бесполезно
|
||||||
|
sed -i "/phobos-health\|phobos-pull/d" /opt/etc/crontab 2>/dev/null || true
|
||||||
|
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; 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 | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ PY
|
|||||||
[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]
|
||||||
@@ -220,7 +220,7 @@ action_package() {
|
|||||||
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
|
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
|
||||||
|
|
||||||
# Fetch missing router-arch binaries from ClusterM on-demand
|
# Fetch missing router-arch binaries from ClusterM on-demand
|
||||||
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
|
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') || _tag="" # ponytail: GitHub API rate limit/недоступность не должны ронять сборку пакета — берём уже скачанные бинарники
|
||||||
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
|
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")
|
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
|
for _arch in aarch64 mipsel mips armv7 x86_64; do
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ fetch_config() {
|
|||||||
if command -v curl >/dev/null; then
|
if command -v curl >/dev/null; then
|
||||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
fi
|
fi
|
||||||
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
||||||
|
|||||||
10
promote.sh
10
promote.sh
@@ -3,9 +3,9 @@
|
|||||||
# PCA Phobos — promote / sync каналов (maintainer-инструмент)
|
# PCA Phobos — promote / sync каналов (maintainer-инструмент)
|
||||||
#
|
#
|
||||||
# Каналы и репозитории:
|
# Каналы и репозитории:
|
||||||
# stable -> публичный andrey271192/PCA_Phobos ветка main
|
# stable -> публичный adleibzon-rgb/PCA_Phobos ветка main
|
||||||
# beta -> приватный andrey271192/PCA_Phobos-dev ветка beta
|
# beta -> приватный adleibzon-rgb/PCA_Phobos-dev ветка beta
|
||||||
# dev -> приватный andrey271192/PCA_Phobos-dev ветка dev
|
# dev -> приватный adleibzon-rgb/PCA_Phobos-dev ветка dev
|
||||||
#
|
#
|
||||||
# Поток фич — строго в одну сторону: dev -> beta -> stable
|
# Поток фич — строго в одну сторону: dev -> beta -> stable
|
||||||
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
|
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
set -e
|
set -e
|
||||||
GH_TOKEN="${GH_TOKEN:-}"
|
GH_TOKEN="${GH_TOKEN:-}"
|
||||||
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
|
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
|
||||||
PUB="https://andrey271192:${GH_TOKEN}@github.com/andrey271192/PCA_Phobos.git"
|
PUB="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos.git"
|
||||||
PRIV="https://andrey271192:${GH_TOKEN}@github.com/andrey271192/PCA_Phobos-dev.git"
|
PRIV="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos-dev.git"
|
||||||
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
|
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
|
||||||
|
|
||||||
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
|
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
|
||||||
|
|||||||
@@ -69,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:
|
||||||
@@ -83,6 +88,7 @@ def health():
|
|||||||
peers = 0
|
peers = 0
|
||||||
peer_keys = []
|
peer_keys = []
|
||||||
handshakes = {}
|
handshakes = {}
|
||||||
|
transfer = {}
|
||||||
cpu = cpu_percent()
|
cpu = cpu_percent()
|
||||||
try:
|
try:
|
||||||
parts = Path("/proc/meminfo").read_text().splitlines()
|
parts = Path("/proc/meminfo").read_text().splitlines()
|
||||||
@@ -96,7 +102,7 @@ def health():
|
|||||||
mem_str = f"{used}/{total}MB"
|
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, "net": net_bytes()})
|
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")
|
@app.route("/api/obf-health")
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ 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")
|
||||||
|
|
||||||
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
||||||
|
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим туннелем.
|
||||||
detect_wg_interface || {
|
detect_wg_interface || {
|
||||||
log "WARN: detect failed, using default $WG_IF"
|
log "ERROR: own WG interface not found, skip"
|
||||||
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Get handshake age via ndmc ──
|
# ── Get handshake age via ndmc ──
|
||||||
@@ -189,9 +191,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' "$PHOBOS_DIR/wg-obfuscator.conf" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
|
||||||
|
lport=${lport:-13255}
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key endpoint 127.0.0.1:$lport" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
|
||||||
|
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
|
||||||
|
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
|
||||||
|
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Persist config
|
# Persist config
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ fetch_config() {
|
|||||||
if command -v curl >/dev/null; then
|
if command -v curl >/dev/null; then
|
||||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
fi
|
fi
|
||||||
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ post_up = (
|
|||||||
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
|
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
|
||||||
f"ip route replace {subnet} dev wg0 table {table}; "
|
f"ip route replace {subnet} dev wg0 table {table}; "
|
||||||
f"ip route replace default dev %i 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 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 -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; "
|
"iptables -I FORWARD 1 -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; "
|
||||||
@@ -203,6 +205,7 @@ post_up = (
|
|||||||
post_down = (
|
post_down = (
|
||||||
f"ip route del {subnet} dev wg0 table {table} 2>/dev/null || true; "
|
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; "
|
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 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; "
|
"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"
|
f"iptables -t nat -D POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || true"
|
||||||
@@ -234,6 +237,7 @@ clear_egress() {
|
|||||||
systemctl disable --now "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
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 "$(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
|
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 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 -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
|
iptables -t nat -D POSTROUTING -s "$(wg_subnet)" -o "$WARP_IF" -j MASQUERADE 2>/dev/null || true
|
||||||
@@ -356,6 +360,35 @@ relay_enabled() {
|
|||||||
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
|
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() {
|
enable_egress() {
|
||||||
local profile_source profile_path existing_clients
|
local profile_source profile_path existing_clients
|
||||||
load_env
|
load_env
|
||||||
@@ -368,6 +401,7 @@ enable_egress() {
|
|||||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||||
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
||||||
systemctl restart "wg-quick@${WARP_IF}"
|
systemctl restart "wg-quick@${WARP_IF}"
|
||||||
|
pick_warp_endpoint
|
||||||
clear_legacy_subnet_rule
|
clear_legacy_subnet_rule
|
||||||
reapply_client_rules "$existing_clients"
|
reapply_client_rules "$existing_clients"
|
||||||
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
|
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
|
||||||
@@ -436,7 +470,7 @@ client_disable() {
|
|||||||
status_json() {
|
status_json() {
|
||||||
load_env
|
load_env
|
||||||
load_state
|
load_state
|
||||||
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on
|
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on handshake=-1
|
||||||
subnet="$(wg_subnet)"
|
subnet="$(wg_subnet)"
|
||||||
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
|
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
|
||||||
mode="${STATE_MODE:-}"
|
mode="${STATE_MODE:-}"
|
||||||
@@ -453,6 +487,7 @@ status_json() {
|
|||||||
endpoint="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" 2>/dev/null || 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)"
|
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)"
|
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 )"
|
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -469,9 +504,9 @@ status_json() {
|
|||||||
enabled=true
|
enabled=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - "$enabled" "$configured" "$service" "$subnet" "$endpoint" "$pubkey" "$egress" "$mode" "$profile_source" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$( [ -s "$CUSTOM_PROFILE" ] && echo true || echo false )" "$relay_on" "$( [ -s "$LICENSE_FILE" ] && echo true || echo false )" "${STATE_CLIENTS:-}" <<'PY'
|
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
|
import json, sys
|
||||||
enabled, configured, service, subnet, endpoint, pubkey, egress, mode, profile_source, relay_ports, relay_target, relay_target_ip, custom_saved, relay_on, license_saved, clients_csv = sys.argv[1:]
|
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":
|
if relay_on == "true" and service != "active":
|
||||||
service = "relay"
|
service = "relay"
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
@@ -484,6 +519,7 @@ print(json.dumps({
|
|||||||
"endpoint": endpoint,
|
"endpoint": endpoint,
|
||||||
"public_key": pubkey,
|
"public_key": pubkey,
|
||||||
"egress_ipv4": egress,
|
"egress_ipv4": egress,
|
||||||
|
"handshake_age": int(handshake),
|
||||||
"relay_enabled": relay_on == "true",
|
"relay_enabled": relay_on == "true",
|
||||||
"relay_ports": relay_ports,
|
"relay_ports": relay_ports,
|
||||||
"relay_target": relay_target,
|
"relay_target": relay_target,
|
||||||
|
|||||||
@@ -18,7 +18,30 @@ MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
|
|||||||
MAIN_PROTO="${MAIN_PROTO:-http}"
|
MAIN_PROTO="${MAIN_PROTO:-http}"
|
||||||
MAIN_PORT="${MAIN_PORT:-8443}"
|
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="${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'
|
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
import ipaddress, sys
|
import ipaddress, sys
|
||||||
@@ -293,7 +316,7 @@ RAW="${PCA_RAW_BASE:-https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/${PCA_BRA
|
|||||||
PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/server/api.py"
|
PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/server/api.py"
|
||||||
curl -kfsSL "$PANEL_BOOTSTRAP" -o "$PHOBOS_DIR/server/api.py" \
|
curl -kfsSL "$PANEL_BOOTSTRAP" -o "$PHOBOS_DIR/server/api.py" \
|
||||||
|| curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" \
|
|| curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" \
|
||||||
|| curl -fsSL "https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
|| curl -fsSL "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/${PCA_BRANCH}/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
||||||
[ -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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# PCA Phobos — полное удаление сервера (primary / panel node)
|
# PCA Phobos — полное удаление сервера (primary / panel node)
|
||||||
#
|
#
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/uninstall.sh)
|
# bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/uninstall.sh)
|
||||||
#
|
#
|
||||||
# Останавливает и удаляет: панель, обфускатор-сервисы, WireGuard wg0,
|
# Останавливает и удаляет: панель, обфускатор-сервисы, WireGuard wg0,
|
||||||
# nginx-сайт, watchdog-cron, бинарники, iptables-правила, каталоги Phobos.
|
# nginx-сайт, watchdog-cron, бинарники, iptables-правила, каталоги Phobos.
|
||||||
@@ -21,8 +21,11 @@ echo " watchdog, бинарники, $PHOBOS_DIR, $PANEL_DIR"
|
|||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
if [ "${PURGE:-0}" != "1" ]; then
|
if [ "${PURGE:-0}" != "1" ]; then
|
||||||
printf "Точно удалить ВСЁ? [yes/NO]: "
|
printf "Точно удалить ВСЁ? [yes/NO]: "
|
||||||
read ans
|
read -r ans </dev/tty 2>/dev/null || read -r ans
|
||||||
[ "$ans" = "yes" ] || { echo "Отменено."; exit 0; }
|
# 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
|
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=$(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)
|
||||||
|
|||||||
25
update.sh
25
update.sh
@@ -19,7 +19,7 @@ set -e
|
|||||||
PHOBOS_DIR=/opt/Phobos
|
PHOBOS_DIR=/opt/Phobos
|
||||||
PANEL_DIR=/opt/phobos-panel
|
PANEL_DIR=/opt/phobos-panel
|
||||||
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
||||||
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
|
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
|
||||||
BACKUPS="$PHOBOS_DIR/updates"
|
BACKUPS="$PHOBOS_DIR/updates"
|
||||||
VERFILE="$PANEL_DIR/.version"
|
VERFILE="$PANEL_DIR/.version"
|
||||||
CUR=$(cat "$VERFILE" 2>/dev/null || echo "unknown")
|
CUR=$(cat "$VERFILE" 2>/dev/null || echo "unknown")
|
||||||
@@ -42,11 +42,20 @@ restart_panel_service() {
|
|||||||
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
|
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
|
||||||
is_private() { return 1; }
|
is_private() { return 1; }
|
||||||
|
|
||||||
|
PCA_GIT_DEV="${PCA_GIT_DEV:-https://git.bobyrev.com/PCA/PCA_Phobos-dev}"
|
||||||
|
|
||||||
fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallback
|
fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallback
|
||||||
local ref="$1" rp="$2" out="$3" kind=branch
|
local ref="$1" rp="$2" out="$3" kind=branch
|
||||||
case "$ref" in v[0-9]*) kind=tag;; esac
|
case "$ref" in v[0-9]*) kind=tag;; esac
|
||||||
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
# beta/dev live in the private Gitea repo PCA_Phobos-dev: read-only Gitea token in $PANEL_DIR/.gh_token or GH_TOKEN
|
||||||
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out"
|
case "$ref" in beta|dev)
|
||||||
|
if [ -n "$GH_TOKEN" ]; then
|
||||||
|
curl -fsSL -m25 -H "Authorization: token $GH_TOKEN" "$PCA_GIT_DEV/raw/branch/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
||||||
|
fi;;
|
||||||
|
esac
|
||||||
|
# GitHub first (Gitea sits on one home IP and is cut for some providers, 09.33), Gitea fallback
|
||||||
|
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
||||||
|
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out"
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch_repo_text() { # $1=ref $2=repo_path
|
fetch_repo_text() { # $1=ref $2=repo_path
|
||||||
@@ -61,6 +70,7 @@ fetch_repo_text() { # $1=ref $2=repo_path
|
|||||||
managed_files() {
|
managed_files() {
|
||||||
cat <<LIST
|
cat <<LIST
|
||||||
$PANEL_DIR/app.py|app.py
|
$PANEL_DIR/app.py|app.py
|
||||||
|
$PANEL_DIR/APPS|APPS
|
||||||
$PHOBOS_DIR/repo/server/scripts/lib-core.sh|overlay/lib-core.sh
|
$PHOBOS_DIR/repo/server/scripts/lib-core.sh|overlay/lib-core.sh
|
||||||
$PHOBOS_DIR/repo/server/scripts/phobos-client.sh|overlay/phobos-client.sh
|
$PHOBOS_DIR/repo/server/scripts/phobos-client.sh|overlay/phobos-client.sh
|
||||||
$PHOBOS_DIR/repo/client/templates/install-router.sh.template|overlay/install-router.sh.template
|
$PHOBOS_DIR/repo/client/templates/install-router.sh.template|overlay/install-router.sh.template
|
||||||
@@ -241,6 +251,15 @@ case "${1:-}" in
|
|||||||
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
|
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
case "$ref" in beta|dev)
|
||||||
|
if [ -z "$GH_TOKEN" ]; then
|
||||||
|
echo "Каналы beta и dev — по токену подписчика (read-only токен Gitea)."
|
||||||
|
echo "Вставь токен в панели: Настройки → Версия и обновления → «токен подписчика», или:"
|
||||||
|
echo " GH_TOKEN=ваш_токен phobos-update $ref"
|
||||||
|
echo "Стабильная (без токена): phobos-update stable"
|
||||||
|
exit 1
|
||||||
|
fi;;
|
||||||
|
esac
|
||||||
if ! ref_exists "$ref"; then
|
if ! ref_exists "$ref"; then
|
||||||
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
|
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
|
||||||
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
|
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
|
||||||
|
|||||||
21
wsgw/README.md
Normal file
21
wsgw/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# wsgw — шлюз WebSocket → UDP (транспорт «Облако» / Keenetic-облако)
|
||||||
|
|
||||||
|
Телефон без доступа к серверу → облако KeenDNS (HTTPS по SNI) → свой Keenetic → `wsgw` → UDP обфускатора сервера.
|
||||||
|
Одно бинарное WS-сообщение = один обфусцированный пакет; на каждое WS-соединение — свой UDP-сокет к `-t`.
|
||||||
|
|
||||||
|
Роутер (Entware):
|
||||||
|
```sh
|
||||||
|
cp bin/wsgw-aarch64 /opt/sbin/wsgw # mipsel/mips — по uname -m
|
||||||
|
cp router/S80wsgw /opt/etc/init.d/ && chmod +x /opt/sbin/wsgw /opt/etc/init.d/S80wsgw
|
||||||
|
printf 'WSGW_LISTEN=192.168.1.1:18090\nWSGW_TARGET=<сервер>:<порт обфускатора>\n' > /opt/etc/wsgw.conf
|
||||||
|
/opt/etc/init.d/S80wsgw start
|
||||||
|
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'
|
||||||
|
```
|
||||||
|
Требования: `ip http port 80` (иначе ACME не выпустит сертификат), WAN:443 не проброшен на другой хост
|
||||||
|
(облако шлёт TLS поддоменов на WAN:443). Если `acme get` висит в `get-pending` — перезагрузка роутера и команда ещё раз.
|
||||||
|
|
||||||
|
Клиент Android 1.4.4: канал «Облако», адрес `wss://phobos.<имя>.<домен>/` (или `# pca-ws = wss://…` в ссылке).
|
||||||
|
Сборка: `./build.sh` (Go, статические linux arm64/mipsle/mips/amd64).
|
||||||
BIN
wsgw/bin/wsgw-aarch64
Executable file
BIN
wsgw/bin/wsgw-aarch64
Executable file
Binary file not shown.
BIN
wsgw/bin/wsgw-mips
Executable file
BIN
wsgw/bin/wsgw-mips
Executable file
Binary file not shown.
BIN
wsgw/bin/wsgw-mipsel
Executable file
BIN
wsgw/bin/wsgw-mipsel
Executable file
Binary file not shown.
BIN
wsgw/bin/wsgw-x86_64
Executable file
BIN
wsgw/bin/wsgw-x86_64
Executable file
Binary file not shown.
7
wsgw/build.sh
Executable file
7
wsgw/build.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Static builds for Keenetic (Entware) and Linux servers.
|
||||||
|
cd "$(dirname "$0")"; mkdir -p bin
|
||||||
|
for a in arm64:aarch64 mipsle:mipsel mips:mips amd64:x86_64; do
|
||||||
|
GOOS=linux GOARCH=${a%%:*} GOMIPS=softfloat CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/wsgw-${a##*:} . || exit 1
|
||||||
|
done
|
||||||
|
ls -l bin
|
||||||
3
wsgw/go.mod
Normal file
3
wsgw/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module wsgw
|
||||||
|
|
||||||
|
go 1.22
|
||||||
147
wsgw/main.go
Normal file
147
wsgw/main.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// wsgw: WebSocket -> UDP gateway for the PCA Phobos "Keenetic cloud" transport.
|
||||||
|
// Keenetic `ip http proxy` (KeenDNS cloud, TLS on the router) forwards wss://<sub>/ here as plain ws.
|
||||||
|
// One binary WS message = one obfuscated UDP packet; each WS connection gets its own UDP socket to -t.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"flag"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listen := flag.String("l", "0.0.0.0:18090", "listen addr (http)")
|
||||||
|
target := flag.String("t", "", "UDP target host:port (server obfuscator)")
|
||||||
|
flag.Parse()
|
||||||
|
if *target == "" {
|
||||||
|
log.Fatal("-t host:port required")
|
||||||
|
}
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
||||||
|
http.Error(w, "ok", http.StatusUpgradeRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, rw, err := h.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := sha1.Sum([]byte(r.Header.Get("Sec-WebSocket-Key") + wsGUID))
|
||||||
|
rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " +
|
||||||
|
base64.StdEncoding.EncodeToString(sum[:]) + "\r\n\r\n")
|
||||||
|
rw.Flush()
|
||||||
|
go serve(conn, rw.Reader, *target)
|
||||||
|
})
|
||||||
|
log.Printf("wsgw %s -> udp %s", *listen, *target)
|
||||||
|
log.Fatal(http.ListenAndServe(*listen, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(c net.Conn, br *bufio.Reader, target string) {
|
||||||
|
defer c.Close()
|
||||||
|
u, err := net.Dial("udp", target)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("udp %s: %v", target, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer u.Close()
|
||||||
|
go func() { // udp -> ws
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
u.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
||||||
|
n, err := u.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
c.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if writeFrame(c, 2, buf[:n]) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for { // ws -> udp
|
||||||
|
op, p, err := readFrame(br)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch op {
|
||||||
|
case 2, 0:
|
||||||
|
u.Write(p)
|
||||||
|
case 8:
|
||||||
|
return
|
||||||
|
case 9:
|
||||||
|
writeFrame(c, 10, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrame reads one client frame (masked). Fragmented messages are not used by our clients.
|
||||||
|
func readFrame(r *bufio.Reader) (byte, []byte, error) {
|
||||||
|
var h [2]byte
|
||||||
|
if _, err := io.ReadFull(r, h[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
n := uint64(h[1] & 0x7f)
|
||||||
|
switch n {
|
||||||
|
case 126:
|
||||||
|
var e [2]byte
|
||||||
|
if _, err := io.ReadFull(r, e[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
n = uint64(binary.BigEndian.Uint16(e[:]))
|
||||||
|
case 127:
|
||||||
|
var e [8]byte
|
||||||
|
if _, err := io.ReadFull(r, e[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
n = binary.BigEndian.Uint64(e[:])
|
||||||
|
}
|
||||||
|
if n > 65535 {
|
||||||
|
return 0, nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
var mask [4]byte
|
||||||
|
masked := h[1]&0x80 != 0
|
||||||
|
if masked {
|
||||||
|
if _, err := io.ReadFull(r, mask[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(r, p); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
if masked {
|
||||||
|
for i := range p {
|
||||||
|
p[i] ^= mask[i&3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h[0] & 0x0f, p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFrame(w io.Writer, op byte, p []byte) error {
|
||||||
|
hdr := []byte{0x80 | op, 0}
|
||||||
|
switch n := len(p); {
|
||||||
|
case n < 126:
|
||||||
|
hdr[1] = byte(n)
|
||||||
|
case n < 65536:
|
||||||
|
hdr[1] = 126
|
||||||
|
hdr = binary.BigEndian.AppendUint16(hdr, uint16(n))
|
||||||
|
default:
|
||||||
|
hdr[1] = 127
|
||||||
|
hdr = binary.BigEndian.AppendUint64(hdr, uint64(n))
|
||||||
|
}
|
||||||
|
_, err := w.Write(append(hdr, p...))
|
||||||
|
return err
|
||||||
|
}
|
||||||
10
wsgw/router/S80wsgw
Normal file
10
wsgw/router/S80wsgw
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# PCA Phobos: WebSocket -> UDP gateway (transport "Keenetic cloud"). Config: /opt/etc/wsgw.conf
|
||||||
|
[ -f /opt/etc/wsgw.conf ] && . /opt/etc/wsgw.conf
|
||||||
|
ENABLED=yes
|
||||||
|
PROCS=wsgw
|
||||||
|
ARGS="-l ${WSGW_LISTEN:-127.0.0.1:18090} -t $WSGW_TARGET"
|
||||||
|
PREARGS=""
|
||||||
|
DESC=$PROCS
|
||||||
|
PATH=/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
. /opt/etc/init.d/rc.func
|
||||||
Reference in New Issue
Block a user