Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad839e4cad | ||
|
|
28986677eb | ||
|
|
af4c269438 | ||
|
|
528d799edc | ||
|
|
31978f22f0 | ||
|
|
c5e9e45141 | ||
|
|
51ec84db6c | ||
|
|
d030344b00 | ||
|
|
8dc9fda65b | ||
|
|
8bfc84f315 | ||
|
|
381e97a564 | ||
|
|
eefe3fc2af | ||
|
|
15011c2756 | ||
|
|
29790aaf4d | ||
|
|
884df15f42 | ||
|
|
eb2b2768cd | ||
|
|
147ef3a42d | ||
|
|
dde6403cea | ||
|
|
d795bc6f70 | ||
|
|
c139debd8d | ||
|
|
01632034b0 | ||
|
|
071c1dc7f1 |
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
windows/build/
|
||||
|
||||
37
CHANGELOG.md
@@ -1,3 +1,7 @@
|
||||
## 1.4.9 — 2026-11-03
|
||||
- Команда secondary сама подставляет подсеть главной; install.sh в сводке показывает реальные ключ API и пароль.
|
||||
- Панель «Серверы»: сверху блок «Как добавить второй сервер» (по-русски, команда secondary, предупреждение про независимые панели и подсети); ручное добавление свёрнуто. install.sh: предупреждение перед вопросом о подсети; подсеть `10.25.11.0/16` → `10.25.11.0/24` вместо молчаливого `10.25.0.0/16`.
|
||||
|
||||
# Changelog — PCA Phobos
|
||||
|
||||
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
|
||||
@@ -5,6 +9,9 @@
|
||||
|
||||
---
|
||||
|
||||
## 1.4.10 — 2026-11-03
|
||||
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
|
||||
|
||||
## Как обновиться после фиксов
|
||||
|
||||
```bash
|
||||
@@ -19,6 +26,36 @@ 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 — настройка обфускатора сервера
|
||||
|
||||
- **feat:** в панели «Настройки» → «Обфускатор сервера»: masking (AUTO/STUN/NONE), max-dummy, idle — записываются во все порты сервера, обфускатор перезапускается (клиенты переподключаются за секунды).
|
||||
- **fix (Android PCA Phobos VPN 1.4.3):** конфиги других сборок Phobos с лишними ключами (`obfuscate-bytes` и т. п.) больше не роняют обфускатор («обфускатор завершился, код 1»): передаются только ключи, известные wg-obfuscator v1.6.
|
||||
|
||||
## 1.4.1 — резервный TCP-канал, «РФ напрямую»
|
||||
|
||||
- **feat:** резервный канал TCP: если провайдер режет UDP, клиент сам переходит на TCP (порт 443, иначе 8443/2096/4433). Сервер: служба `phobos-tcp` (`udptcp`), ставится и включается автоматически при установке и обновлении. Проверено реальной блокировкой UDP: переключение за ~15 с, без блокировки остаётся UDP.
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
|
||||
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
|
||||
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
|
||||
- **Канал «Облако»** — если сервер заблокирован целиком: телефон идёт через облако KeenDNS своего Keenetic. [Инструкция](docs/cloud.md).
|
||||
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
|
||||
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
|
||||
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (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_PORT` | случайный | Порт веб-панели |
|
||||
| `SERVER_WG_IPV4_NETWORK` | `10.25.0.0/16` | Подсеть VPN-клиентов (например `10.25.1.0/24`). Без переменной установщик спросит в терминале. Сервер получает первый адрес (`10.25.1.1`). Задаётся только при установке |
|
||||
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
|
||||
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
|
||||
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
|
||||
@@ -137,8 +139,8 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/
|
||||
|
||||
## Приложения для Android
|
||||
|
||||
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), **российские сайты напрямую** (по умолчанию), **канал Авто/UDP/TCP** (при блокировке UDP — сам на TCP), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.1/PCA-Phobos-VPN.apk) · исходники `android-client/`.
|
||||
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.1/PCA-Phobos.apk) · исходники `android/`.
|
||||
- **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.4/PCA-Phobos.apk) · исходники `android/`.
|
||||
|
||||
## Клиенты для телефона
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.pca.phobos.vpn"
|
||||
android:versionCode="142"
|
||||
android:versionName="1.4.2">
|
||||
android:versionCode="144"
|
||||
android:versionName="1.4.4">
|
||||
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
@@ -61,7 +61,8 @@ public class MainActivity extends Activity {
|
||||
static GoBackend backend;
|
||||
static Process obfuscator, relay;
|
||||
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() {
|
||||
public String getName() { return "phobos"; }
|
||||
public void onStateChange(Tunnel.State s) { }
|
||||
@@ -217,12 +218,17 @@ public class MainActivity extends Activity {
|
||||
|
||||
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
|
||||
|
||||
private String obfPart(String conf, boolean viaTcp) {
|
||||
private static final java.util.Set<String> OBF_KEYS = new java.util.HashSet<>(java.util.Arrays.asList(
|
||||
"target", "key", "masking", "idle-timeout", "max-dummy", "static-bindings", "max-clients", "target-if", "allow-clean", "in-timeout", "resolve-interval"));
|
||||
|
||||
private String obfPart(String conf, boolean viaRelay) {
|
||||
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
|
||||
for (String ln : section(conf, "[instance]")) {
|
||||
String k = key(ln);
|
||||
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
|
||||
if (viaTcp && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
|
||||
// configs from other Phobos builds carry keys our wg-obfuscator v1.6 rejects (obfuscate-bytes…)
|
||||
if (!OBF_KEYS.contains(k)) continue;
|
||||
if (viaRelay && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
|
||||
sb.append(ln).append('\n');
|
||||
}
|
||||
sb.append("verbose = INFO\n");
|
||||
@@ -238,6 +244,17 @@ public class MainActivity extends Activity {
|
||||
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) {
|
||||
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
||||
return "?";
|
||||
@@ -262,24 +279,30 @@ public class MainActivity extends Activity {
|
||||
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
||||
}
|
||||
|
||||
/** Start (or restart) obfuscator; via TCP it dials the local udptcp relay. */
|
||||
private void startTransport(String conf, boolean viaTcp) throws Exception {
|
||||
/** Start (or restart) obfuscator; via TCP / cloud it dials the local relay on TCP_LPORT. */
|
||||
private void startTransport(String conf, String via) throws Exception {
|
||||
stopObfuscator();
|
||||
String lib = getApplicationInfo().nativeLibraryDir;
|
||||
boolean viaTcp = "tcp".equals(via), viaWs = "cloud".equals(via);
|
||||
if (viaWs) {
|
||||
if (wsUrl(conf).isEmpty()) throw new IllegalStateException("не задан адрес роутера (wss://…) для канала «Облако»");
|
||||
wsRelay = new WsRelay(TCP_LPORT, wsUrl(conf), this::log);
|
||||
wsRelay.start();
|
||||
}
|
||||
if (viaTcp) {
|
||||
String host = target(conf).replaceAll(":\\d+$", "");
|
||||
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
|
||||
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
|
||||
}
|
||||
File cfg = new File(getFilesDir(), "obf.conf");
|
||||
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp).getBytes(StandardCharsets.UTF_8)); }
|
||||
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())
|
||||
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
|
||||
Thread.sleep(600);
|
||||
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
||||
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
|
||||
transport = viaTcp ? "TCP" : "UDP";
|
||||
log("transport " + transport + " -> " + target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : ""));
|
||||
transport = viaWs ? "Облако" : viaTcp ? "TCP" : "UDP";
|
||||
log("transport " + transport + " -> " + (viaWs ? wsUrl(conf) : target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : "")));
|
||||
}
|
||||
|
||||
private void up() {
|
||||
@@ -288,7 +311,7 @@ public class MainActivity extends Activity {
|
||||
String mode = prefs.getString("transport", "auto");
|
||||
boolean tcpAvail = !tcpPort(conf).isEmpty();
|
||||
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
|
||||
startTransport(conf, "tcp".equals(mode));
|
||||
startTransport(conf, "auto".equals(mode) ? "udp" : mode);
|
||||
Config c = Config.parse(new BufferedReader(new StringReader(
|
||||
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
|
||||
backend.setState(TUNNEL, Tunnel.State.UP, c);
|
||||
@@ -310,7 +333,7 @@ public class MainActivity extends Activity {
|
||||
if (!isUp() || !"UDP".equals(transport)) return;
|
||||
if (backend.getStatistics(TUNNEL).totalRx() > 0) return;
|
||||
log("UDP: no answer in 15 s -> switching to TCP");
|
||||
startTransport(conf, true);
|
||||
startTransport(conf, "tcp");
|
||||
post("UDP не отвечает — переключился на TCP");
|
||||
} catch (Throwable e) { log("watchUdp: " + e); }
|
||||
}).start();
|
||||
@@ -326,6 +349,7 @@ public class MainActivity extends Activity {
|
||||
private static void stopObfuscator() {
|
||||
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
|
||||
if (relay != null) { relay.destroy(); relay = null; }
|
||||
if (wsRelay != null) { wsRelay.stop(); wsRelay = null; }
|
||||
}
|
||||
|
||||
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
|
||||
@@ -411,13 +435,16 @@ public class MainActivity extends Activity {
|
||||
RadioGroup tg = new RadioGroup(this);
|
||||
tg.setOrientation(RadioGroup.HORIZONTAL);
|
||||
String tmode = prefs.getString("transport", "auto");
|
||||
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}}) {
|
||||
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}, {"cloud", "Облако"}}) {
|
||||
RadioButton r = new RadioButton(this);
|
||||
r.setText(m[1]);
|
||||
r.setTextColor(text());
|
||||
r.setId(View.generateViewId());
|
||||
r.setChecked(m[0].equals(tmode));
|
||||
r.setOnClickListener(v -> { prefs.edit().putString("transport", m[0]).apply(); applyIfUp(); });
|
||||
r.setOnClickListener(v -> {
|
||||
prefs.edit().putString("transport", m[0]).apply();
|
||||
if ("cloud".equals(m[0])) wsDialog(); else applyIfUp();
|
||||
});
|
||||
tg.addView(r);
|
||||
}
|
||||
split.addView(tg);
|
||||
@@ -473,6 +500,22 @@ public class MainActivity extends Activity {
|
||||
.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() {
|
||||
PackageManager pm = getPackageManager();
|
||||
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
193
app.py
@@ -37,7 +37,7 @@ WG_CONFIG = "/etc/wireguard/wg0.conf"
|
||||
# Distribution: Gitea first (GitHub account is unavailable since 23.10.2026), GitHub fallback
|
||||
PCA_GIT = "https://git.bobyrev.com/PCA/PCA_Phobos"
|
||||
PCA_GH_RAW = "https://raw.githubusercontent.com/andrey271192/PCA_Phobos"
|
||||
APK_TAG = "v1.4.1"
|
||||
APK_TAG = "v1.4.4"
|
||||
WARP_DIR = f"{PHOBOS_DIR}/server/warp"
|
||||
WARP_CUSTOM_PROFILE_FILE = f"{WARP_DIR}/custom-profile.conf"
|
||||
WARP_LICENSE_FILE = f"{WARP_DIR}/license.key"
|
||||
@@ -102,6 +102,44 @@ def save_settings(s):
|
||||
json.dump(s, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
SRV_OBF_KEYS = ("masking", "max-dummy", "idle-timeout")
|
||||
|
||||
|
||||
def _srv_obf_confs():
|
||||
import glob
|
||||
return sorted(glob.glob(f"{PHOBOS_DIR}/server/wg-obfuscator-*.conf"))
|
||||
|
||||
|
||||
def read_server_obf():
|
||||
"""Current server wg-obfuscator params (from the first instance config)."""
|
||||
vals = {"masking": "AUTO", "max-dummy": "50", "idle-timeout": "300"}
|
||||
confs = _srv_obf_confs()
|
||||
if confs:
|
||||
for ln in Path(confs[0]).read_text().splitlines():
|
||||
k, _, v = ln.partition("=")
|
||||
if k.strip() in SRV_OBF_KEYS:
|
||||
vals[k.strip()] = v.strip()
|
||||
return vals
|
||||
|
||||
|
||||
def write_server_obf(vals):
|
||||
"""Rewrite masking/max-dummy/idle-timeout in every server instance and restart them."""
|
||||
done = []
|
||||
for f in _srv_obf_confs():
|
||||
lines, seen = [], set()
|
||||
for ln in Path(f).read_text().splitlines():
|
||||
k = ln.partition("=")[0].strip()
|
||||
if k in vals:
|
||||
ln = f"{k} = {vals[k]}"; seen.add(k)
|
||||
lines.append(ln)
|
||||
lines += [f"{k} = {v}" for k, v in vals.items() if k not in seen]
|
||||
Path(f).write_text("\n".join(lines) + "\n")
|
||||
port = re.search(r"wg-obfuscator-(\d+)\.conf$", f).group(1)
|
||||
subprocess.run(["systemctl", "restart", f"wg-obfuscator-{port}"], timeout=30)
|
||||
done.append(port)
|
||||
return done
|
||||
|
||||
|
||||
def _load_server_env():
|
||||
"""Load server.env as dict for subprocess env."""
|
||||
env = {}
|
||||
@@ -764,6 +802,7 @@ _last_fanout = 0
|
||||
_last_peer_sync = 0
|
||||
server_stats_cache = {}
|
||||
server_handshakes_cache = {}
|
||||
server_transfer_cache = {} # server ip -> {pub: [rx, tx]} (from secondary /api/health)
|
||||
|
||||
|
||||
def count_client_peers():
|
||||
@@ -851,6 +890,7 @@ def get_remote_stats(server):
|
||||
raise Exception("no response")
|
||||
# cache handshakes so page renders never block on remote HTTP
|
||||
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
|
||||
clients = get_clients()
|
||||
client_pubs = {c.get("public_key", "") for c in clients}
|
||||
@@ -1713,6 +1753,71 @@ def finish_legacy_update():
|
||||
finish_legacy_update()
|
||||
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>
|
||||
<html lang="ru">
|
||||
@@ -2256,6 +2361,27 @@ def _terminal_button(url, title):
|
||||
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"])
|
||||
@auth_required
|
||||
def clients_page():
|
||||
@@ -2563,6 +2689,7 @@ def clients_page():
|
||||
<button class="btn btn-sm" style="background:#475569" type="submit">{tr("Имя","Name")}</button>
|
||||
</form>
|
||||
"""
|
||||
_tusage = _traffic_load().get("usage", {}) if "_tusage" not in dir() else _tusage
|
||||
lim = str(limits.get(cid, "") or "")
|
||||
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 "")
|
||||
@@ -2575,7 +2702,7 @@ def clients_page():
|
||||
<button class="btn btn-primary btn-sm" type="submit">{set_lbl}</button>
|
||||
</form>"""
|
||||
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="cact">{actions_cell}</div>
|
||||
</div>"""
|
||||
@@ -2741,6 +2868,17 @@ def settings_page():
|
||||
except Exception:
|
||||
pass
|
||||
msg = f'<div class="alert alert-ok">{tr("Параметры обфускатора сохранены и отправлены на роутеры", "Obfuscator params saved and pushed to routers")} ({pushed})</div>'
|
||||
elif request.method == "POST" and request.form.get("action") == "save_srv_obf":
|
||||
masking = request.form.get("srv_masking", "AUTO").strip().upper()
|
||||
if masking not in ("AUTO", "STUN", "NONE"):
|
||||
masking = "AUTO"
|
||||
try:
|
||||
dummy = str(min(255, max(0, int(request.form.get("srv_max_dummy", "50")))))
|
||||
idle = str(min(3600, max(10, int(request.form.get("srv_idle", "300")))))
|
||||
except ValueError:
|
||||
dummy, idle = "50", "300"
|
||||
ports = write_server_obf({"masking": masking, "max-dummy": dummy, "idle-timeout": idle})
|
||||
msg = f'<div class="alert alert-ok">{tr("Серверный обфускатор перезапущен с новыми параметрами, порты", "Server obfuscator restarted with new params, ports")}: {", ".join(ports) or "—"}</div>'
|
||||
elif request.method == "POST":
|
||||
new_pass = request.form.get("admin_pass", "").strip()
|
||||
if new_pass:
|
||||
@@ -2758,6 +2896,7 @@ def settings_page():
|
||||
custom_warp_profile = read_custom_warp_profile()
|
||||
warp_license_key = read_warp_license_key()
|
||||
warp = warp_status()
|
||||
srv_obf = read_server_obf()
|
||||
warp_service = warp.get("service", "unknown")
|
||||
warp_state = tr("включён", "enabled") if warp.get("enabled") else tr("выключен", "disabled")
|
||||
warp_subnet = _html_attr(warp.get("subnet", server_wg_network()))
|
||||
@@ -2898,6 +3037,20 @@ def settings_page():
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{tr("Обфускатор сервера","Server obfuscator")} {hlp("Параметры wg-obfuscator на ЭТОМ сервере (все порты). masking: AUTO принимает любой режим клиента — менять, только если знаете зачем; STUN/NONE — клиенты должны работать в том же режиме. max-dummy — мусорные байты в ответах, idle — таймаут простоя (сек). Сохранение перезапускает обфускатор: клиенты переподключатся за пару секунд.", "wg-obfuscator params on THIS server (all ports). masking: AUTO accepts any client mode — change only if you know why; with STUN/NONE clients must use the same mode. max-dummy — junk bytes in replies, idle — idle timeout (s). Saving restarts the obfuscator: clients reconnect within seconds.")}</h2>
|
||||
<form method="post" class="form-row" style="flex-wrap:wrap;gap:10px;align-items:center" onsubmit="return confirm('{tr("Перезапустить обфускатор сервера? Клиенты переподключатся за пару секунд.","Restart server obfuscator? Clients will reconnect within seconds.")}')">
|
||||
<input type="hidden" name="action" value="save_srv_obf">
|
||||
<label>masking
|
||||
<select name="srv_masking" style="background:var(--bg);border:1px solid var(--border);color:var(--text);padding:5px;border-radius:6px">
|
||||
{"".join(f'<option value="{m}"{" selected" if srv_obf["masking"].upper()==m else ""}>{m}</option>' for m in ("AUTO","STUN","NONE"))}
|
||||
</select></label>
|
||||
<label>max-dummy <input type="number" name="srv_max_dummy" value="{_html_attr(srv_obf['max-dummy'])}" min="0" max="255" style="width:70px;padding:5px"></label>
|
||||
<label>idle (s) <input type="number" name="srv_idle" value="{_html_attr(srv_obf['idle-timeout'])}" min="10" max="3600" style="width:80px;padding:5px"></label>
|
||||
<button class="btn btn-primary btn-sm" type="submit">{tr("Сохранить и перезапустить","Save & restart")}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{tr("Информация о сервере","Server info")}</h2>
|
||||
<table>
|
||||
@@ -3385,8 +3538,9 @@ def router_config(client_id):
|
||||
st = load_settings()
|
||||
token = request.args.get("token", "")
|
||||
acc = st.get("router_access", {}).get(client_id, {})
|
||||
expected = acc.get("pull_token", "") or st.get("server_api_key", "")
|
||||
if not expected or token != expected:
|
||||
# packages ship server_api_key as pull_token (phobos-client.sh), so accept both
|
||||
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)
|
||||
conf = generate_failover_conf_for_client(client_id)
|
||||
return (conf, 200, {"Content-Type": "text/plain; charset=utf-8"})
|
||||
@@ -4414,6 +4568,26 @@ def servers_page():
|
||||
{nav('servers')}
|
||||
{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">
|
||||
<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>
|
||||
@@ -4433,6 +4607,7 @@ def servers_page():
|
||||
<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.")}
|
||||
</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">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<input type="text" name="ip" placeholder="{tr('IP сервера','Server IP')}" required>
|
||||
@@ -4443,6 +4618,7 @@ def servers_page():
|
||||
<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.")}
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -4462,7 +4638,7 @@ def servers_page():
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="save_server_access">
|
||||
@@ -4481,7 +4657,7 @@ def servers_page():
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>API Key</h2>
|
||||
<h2>{tr("API-ключ","API Key")}</h2>
|
||||
<form method="post" class="form-row">
|
||||
<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">
|
||||
@@ -4489,11 +4665,6 @@ def servers_page():
|
||||
</form>
|
||||
</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>"""
|
||||
return render(html)
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
#!/bin/sh
|
||||
# ============================================================
|
||||
# Phobos Health Monitor — Auto-heal + Failover for routers
|
||||
# Runs via cron every 60 seconds on Keenetic/Entware
|
||||
#
|
||||
# Install: crontab -e → */1 * * * * /opt/etc/Phobos/phobos-health.sh
|
||||
# Phobos Health Monitor v2 — Keenetic Edition
|
||||
# Uses ndmc/RCI instead of wg-tools. Connectivity-based failover.
|
||||
# Runs via cron every 60 seconds
|
||||
# ============================================================
|
||||
|
||||
PHOBOS_DIR="/opt/etc/Phobos"
|
||||
CONF="$PHOBOS_DIR/failover.conf"
|
||||
STATE="$PHOBOS_DIR/state"
|
||||
LOG="$PHOBOS_DIR/health.log"
|
||||
WG_CONF="$PHOBOS_DIR/wg0.conf"
|
||||
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
||||
LOCKFILE="/tmp/phobos-health.lock"
|
||||
WG_IF="Wireguard3"
|
||||
|
||||
MAX_LOG_LINES=200
|
||||
HANDSHAKE_WARN=180
|
||||
HANDSHAKE_RESTART_OBF=300
|
||||
HANDSHAKE_RESTART_WG=600
|
||||
HANDSHAKE_FAILOVER=900
|
||||
# Thresholds (seconds)
|
||||
HANDSHAKE_WARN=150
|
||||
HANDSHAKE_PORT_HOP=300
|
||||
HANDSHAKE_SERVER_SWITCH=600
|
||||
PRIMARY_CHECK_INTERVAL=300
|
||||
# Primary-alive probe: servers block ICMP and busybox `nc -z` is unreliable,
|
||||
# so reachability is tested with curl against the panel's obfuscator-health
|
||||
# endpoint on the primary VPS. It returns http 200 ONLY when all
|
||||
# wg-obfuscator-* services are active — the bare panel port stays up even
|
||||
# when the tunnel path is dead, so we must NOT switch back on panel liveness
|
||||
# alone (that caused premature switchback to a dead primary).
|
||||
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$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() {
|
||||
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
|
||||
# Trim log
|
||||
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
|
||||
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
||||
fi
|
||||
@@ -38,33 +51,35 @@ fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
trap 'rm -f "$LOCKFILE"' EXIT
|
||||
|
||||
# Create state dir
|
||||
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 ──
|
||||
if [ ! -f "$CONF" ]; then
|
||||
log "ERROR: no failover.conf"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse servers: SERVER_1=ip:port1,port2,port3
|
||||
# Parse current state
|
||||
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
|
||||
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
|
||||
RESTART_COUNT=$(cat "$STATE/restart_count" 2>/dev/null || echo "0")
|
||||
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
||||
|
||||
# Load server list
|
||||
SERVER_COUNT=0
|
||||
idx=1
|
||||
while true; do
|
||||
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||
if [ -z "$val" ]; then
|
||||
break
|
||||
fi
|
||||
[ -z "$val" ] && break
|
||||
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
|
||||
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
|
||||
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||
eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||
SERVER_COUNT=$idx
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
@@ -74,22 +89,45 @@ if [ "$SERVER_COUNT" -eq 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Get current handshake age ──
|
||||
get_handshake_age() {
|
||||
hs=$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}')
|
||||
if [ -z "$hs" ] || [ "$hs" = "0" ]; then
|
||||
echo "9999"
|
||||
return
|
||||
fi
|
||||
now=$(date +%s)
|
||||
age=$((now - hs))
|
||||
echo "$age"
|
||||
# Read state
|
||||
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
|
||||
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
|
||||
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
|
||||
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
||||
|
||||
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
||||
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим туннелем.
|
||||
detect_wg_interface || {
|
||||
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() {
|
||||
server_idx=$1
|
||||
port_idx=$2
|
||||
server_idx=$1; port_idx=$2
|
||||
eval "ports=\$SERVER_${server_idx}_PORTS"
|
||||
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
|
||||
}
|
||||
@@ -100,6 +138,78 @@ get_port_count() {
|
||||
echo "$ports" | tr ',' '\n' | wc -l
|
||||
}
|
||||
|
||||
# ── Keys currently on the interface (includes the interface's OWN key) ──
|
||||
# WG public keys are 43 base64 chars + '='. The interface's own public-key is
|
||||
# also matched here — callers must only act on KNOWN server WGKEYs so the
|
||||
# local key is never touched.
|
||||
list_iface_keys() {
|
||||
ndmc -c "show interface $WG_IF" 2>/dev/null \
|
||||
| grep -oE '[A-Za-z0-9+/]{43}=' | sort -u
|
||||
}
|
||||
|
||||
# Count how many KNOWN server WGKEYs are currently attached as peers.
|
||||
count_server_peers() {
|
||||
present=$(list_iface_keys)
|
||||
n=0
|
||||
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_endpoint() {
|
||||
server_idx=$1
|
||||
@@ -107,6 +217,7 @@ switch_endpoint() {
|
||||
|
||||
eval "host=\$SERVER_${server_idx}_HOST"
|
||||
eval "obf_key=\$SERVER_${server_idx}_KEY"
|
||||
eval "wg_key=\$SERVER_${server_idx}_WGKEY"
|
||||
port=$(get_port "$server_idx" "$port_idx")
|
||||
|
||||
if [ -z "$host" ] || [ -z "$port" ]; then
|
||||
@@ -116,7 +227,10 @@ switch_endpoint() {
|
||||
|
||||
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
|
||||
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
|
||||
if [ -n "$obf_key" ]; then
|
||||
@@ -124,25 +238,26 @@ switch_endpoint() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restart obfuscator
|
||||
killall wg-obfuscator 2>/dev/null
|
||||
sleep 1
|
||||
wg-obfuscator --config "$OBF_CONF" &
|
||||
# 3. Restart obfuscator
|
||||
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
|
||||
else
|
||||
killall wg-obfuscator 2>/dev/null
|
||||
sleep 1
|
||||
wg-obfuscator --config "$OBF_CONF" &
|
||||
fi
|
||||
|
||||
# Save state
|
||||
# 4. Save state (fail_count is managed by the caller, NOT reset here —
|
||||
# resetting on a port-hop would prevent escalation to server failover)
|
||||
echo "$server_idx" > "$STATE/current_server"
|
||||
echo "$port_idx" > "$STATE/current_port_idx"
|
||||
echo "0" > "$STATE/restart_count"
|
||||
}
|
||||
|
||||
# ── Try next port on current server ──
|
||||
try_next_port() {
|
||||
port_count=$(get_port_count "$CURRENT_SERVER")
|
||||
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
|
||||
|
||||
if [ "$next_idx" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
[ "$next_idx" -eq 0 ] && return 1
|
||||
|
||||
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
|
||||
switch_endpoint "$CURRENT_SERVER" "$next_idx"
|
||||
@@ -152,96 +267,203 @@ try_next_port() {
|
||||
# ── Try next server ──
|
||||
try_next_server() {
|
||||
next=$((CURRENT_SERVER + 1))
|
||||
if [ "$next" -gt "$SERVER_COUNT" ]; then
|
||||
next=1
|
||||
fi
|
||||
|
||||
if [ "$next" -eq "$CURRENT_SERVER" ]; then
|
||||
log "WARN: only one server, can't failover"
|
||||
return 1
|
||||
fi
|
||||
[ "$next" -gt "$SERVER_COUNT" ] && next=1
|
||||
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
|
||||
|
||||
log "FAILOVER → server $next"
|
||||
switch_endpoint "$next" "0"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Check if primary is back (every 5 min when on secondary) ──
|
||||
# ── Check if primary is back ──
|
||||
check_primary() {
|
||||
if [ "$CURRENT_SERVER" -eq 1 ]; then
|
||||
return
|
||||
fi
|
||||
[ "$CURRENT_SERVER" -eq 1 ] && return
|
||||
|
||||
now=$(date +%s)
|
||||
elapsed=$((now - PRIMARY_CHECK_TS))
|
||||
if [ "$elapsed" -lt 300 ]; then
|
||||
return
|
||||
fi
|
||||
[ "$elapsed" -lt "$PRIMARY_CHECK_INTERVAL" ] && return
|
||||
echo "$now" > "$STATE/primary_check_ts"
|
||||
|
||||
eval "host=\$SERVER_1_HOST"
|
||||
port=$(get_port 1 0)
|
||||
|
||||
# Quick UDP probe — send a byte, see if port responds
|
||||
if ping -c 1 -W 2 "$host" >/dev/null 2>&1; then
|
||||
log "PRIMARY alive, switching back"
|
||||
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
|
||||
# obfuscator path is actually up. Anything else (000 no-response, 503
|
||||
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
|
||||
ok_url=""
|
||||
last_code=""
|
||||
for url in "${PRIMARY_PROBE_SCHEME}://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" "http://${host}:8444${PRIMARY_PROBE_PATH}"; do
|
||||
code=$(curl -k -s -m 4 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
|
||||
last_code="$code"
|
||||
if [ "$code" = "200" ]; then
|
||||
ok_url="$url"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$ok_url" ]; then
|
||||
log "PRIMARY ($ok_url http=200) alive, switching back"
|
||||
switch_endpoint 1 0
|
||||
echo "0" > "$STATE/fail_count"
|
||||
else
|
||||
log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main logic ──
|
||||
AGE=$(get_handshake_age)
|
||||
# ── LAN routing self-heal ──────────────────────
|
||||
# A router REBOOT silently drops the per-device `ip hotspot host <mac> policy`
|
||||
# binding and can reset the WG interface security-level, so LAN clients leak to
|
||||
# WAN (no VPN). This:
|
||||
# 1) ensures WG_IF security-level = public (needed for masquerade),
|
||||
# 2) LEARNS current host->policy bindings into state/lan-hosts (append-only),
|
||||
# 3) RE-APPLIES any learned binding that is currently missing.
|
||||
# Append-only learning means a reboot (which clears live bindings) never erases
|
||||
# the record, so the next run restores them. ndmc show running-config is heavy,
|
||||
# so the caller gates this to run only every few minutes.
|
||||
heal_lan_routing() {
|
||||
LANHOSTS="$STATE/lan-hosts"
|
||||
rc=$(ndmc -c "show running-config" 2>/dev/null)
|
||||
[ -z "$rc" ] && return 0
|
||||
|
||||
# 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
|
||||
# All good, reset counters
|
||||
if [ "$RESTART_COUNT" -gt 0 ]; then
|
||||
log "OK: connection restored (age=${AGE}s)"
|
||||
echo "0" > "$STATE/restart_count"
|
||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
|
||||
echo "0" > "$STATE/fail_count"
|
||||
fi
|
||||
check_primary
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "STALE handshake: ${AGE}s (server=$CURRENT_SERVER port_idx=$CURRENT_PORT_IDX restarts=$RESTART_COUNT)"
|
||||
# Stale handshake = tunnel down → escalate.
|
||||
# fail_count is MONOTONIC: it climbs across stages and is only reset by the
|
||||
# OK branch (real recovery) or after a successful failover to a NEW server
|
||||
# (so the new server gets a fresh restart→port-hop→failover cycle).
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo "$FAIL_COUNT" > "$STATE/fail_count"
|
||||
log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PORT_IDX fails=$FAIL_COUNT"
|
||||
|
||||
if [ "$AGE" -lt "$HANDSHAKE_RESTART_OBF" ]; then
|
||||
# Restart obfuscator only
|
||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
||||
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
||||
log "ACTION: restart obfuscator (attempt $RESTART_COUNT)"
|
||||
killall wg-obfuscator 2>/dev/null
|
||||
sleep 1
|
||||
wg-obfuscator --config "$OBF_CONF" &
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$AGE" -lt "$HANDSHAKE_RESTART_WG" ]; then
|
||||
# Restart WireGuard + obfuscator
|
||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
||||
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
||||
log "ACTION: restart WG + obfuscator (attempt $RESTART_COUNT)"
|
||||
wg-quick down wg0 2>/dev/null
|
||||
killall wg-obfuscator 2>/dev/null
|
||||
sleep 2
|
||||
wg-quick up wg0 2>/dev/null
|
||||
sleep 1
|
||||
wg-obfuscator --config "$OBF_CONF" &
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$AGE" -lt "$HANDSHAKE_FAILOVER" ]; then
|
||||
# Try next port
|
||||
log "ACTION: try port hop"
|
||||
if ! try_next_port; then
|
||||
log "ACTION: all ports exhausted, try next server"
|
||||
try_next_server
|
||||
# Stage 1 (fail 1): Restart obfuscator
|
||||
if [ "$FAIL_COUNT" -eq 1 ]; then
|
||||
log "ACTION: restart obfuscator"
|
||||
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
|
||||
else
|
||||
killall wg-obfuscator 2>/dev/null
|
||||
sleep 1
|
||||
wg-obfuscator --config "$OBF_CONF" &
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Full failover
|
||||
log "ACTION: failover (handshake ${AGE}s)"
|
||||
if ! try_next_server; then
|
||||
# Only one server, cycle ports
|
||||
try_next_port
|
||||
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server
|
||||
if [ "$FAIL_COUNT" -eq 2 ]; then
|
||||
if try_next_port; then
|
||||
exit 0
|
||||
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
|
||||
|
||||
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
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/img/cloud/android-2-adres.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/img/cloud/android-3-skorost.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/img/cloud/skhema.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
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 |
BIN
docs/img/ios-app.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
42
install.sh
@@ -29,7 +29,42 @@ PANEL_SCHEME="${PANEL_SCHEME:-http}"
|
||||
TG_TOKEN="${TG_TOKEN:-}"
|
||||
TG_CHAT="${TG_CHAT:-}"
|
||||
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
||||
# Уже есть wg0.conf (переустановка) — подсеть берём из него, иначе server.env разойдётся с интерфейсом
|
||||
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -f /etc/wireguard/wg0.conf ]; then
|
||||
SERVER_WG_IPV4_NETWORK="$(python3 - <<'PY' 2>/dev/null
|
||||
import ipaddress, re
|
||||
for ln in open("/etc/wireguard/wg0.conf"):
|
||||
m = re.match(r"\s*Address\s*=\s*([^,\s]+)", ln)
|
||||
if m:
|
||||
print(ipaddress.ip_interface(m.group(1)).network); break
|
||||
PY
|
||||
)" || SERVER_WG_IPV4_NETWORK=""
|
||||
fi
|
||||
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
||||
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
|
||||
printf '\n!!! Это ВТОРОЙ сервер к уже работающей панели? Тогда прервите (Ctrl+C) и ставьте командой из панели\n «Серверы -> Как добавить второй сервер» (secondary-setup.sh), подсеть там по умолчанию.\n Своя подсеть нужна только для НЕЗАВИСИМОЙ панели (например 10.25.11.0/24, не пересекается с первой);\n смена сервера у клиентов между независимыми панелями не работает.\n\n' > /dev/tty
|
||||
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
|
||||
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
||||
fi
|
||||
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||
import ipaddress, sys
|
||||
try:
|
||||
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
|
||||
except ValueError:
|
||||
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
|
||||
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
|
||||
# Считаем, что нужна подсеть этого адреса, и берём /24.
|
||||
try:
|
||||
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
|
||||
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
|
||||
except ValueError:
|
||||
sys.exit("ERROR: bad subnet " + sys.argv[1])
|
||||
if net.version != 4 or not net.is_private or net.prefixlen > 29:
|
||||
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
|
||||
print(net)
|
||||
PY
|
||||
)" || exit 1
|
||||
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
|
||||
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
|
||||
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
||||
@@ -336,6 +371,13 @@ sleep 2
|
||||
mkdir -p "$PANEL_DIR"
|
||||
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
|
||||
|
||||
# при переустановке settings.json остаётся старый — в сводке показываем то, что реально в панели
|
||||
_S="$PANEL_DIR/settings.json"
|
||||
if [ -f "$_S" ]; then
|
||||
API_KEY="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("server_api_key") or sys.argv[2])' "$_S" "$API_KEY" 2>/dev/null || echo "$API_KEY")"
|
||||
PANEL_PASS="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("admin_pass") or sys.argv[2])' "$_S" "$PANEL_PASS" 2>/dev/null || echo "$PANEL_PASS")"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Installation complete"
|
||||
|
||||
3
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
build/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
1
ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1 @@
|
||||
{"images":[{"filename":"icon-1024.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}
|
||||
BIN
ios/App/Assets.xcassets/AppIcon.appiconset/icon-1024.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
ios/App/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1 @@
|
||||
{"info":{"author":"xcode","version":1}}
|
||||
129
ios/App/ContentView.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import NetworkExtension
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var vpn: VPNController
|
||||
@State private var pasteText = ""
|
||||
@State private var showImport = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 18) {
|
||||
profileCard
|
||||
if vpn.servers.count > 1 { serversCard }
|
||||
powerButton
|
||||
statusLine
|
||||
optionsCard
|
||||
Button { showImport = true } label: {
|
||||
Label("Импорт ссылки phobos://", systemImage: "square.and.arrow.down")
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent).tint(.indigo)
|
||||
Link(destination: URL(string: "https://speed.cloudflare.com/")!) {
|
||||
Label("Проверить скорость", systemImage: "speedometer").frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
if !vpn.message.isEmpty {
|
||||
Text(vpn.message).font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("PCA Phobos VPN")
|
||||
.alert("Ссылка из панели", isPresented: $showImport) {
|
||||
TextField("phobos://…", text: $pasteText)
|
||||
Button("Импорт") { vpn.importLink(pasteText); pasteText = "" }
|
||||
Button("Из буфера") { vpn.importLink(UIPasteboard.general.string ?? "") }
|
||||
Button("Отмена", role: .cancel) {}
|
||||
} message: {
|
||||
Text("В панели: клиент → 📱 → ссылка phobos://. Можно просто открыть ссылку на iPhone — профиль импортируется сам.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var profileCard: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let p = vpn.profile {
|
||||
Text(vpn.name).font(.title3.bold())
|
||||
Text("Сервер: \(p.target ?? "?")").font(.subheadline).foregroundStyle(.secondary)
|
||||
if let tcp = p.tcpPort { Text("Резервный TCP: порт \(tcp)").font(.caption).foregroundStyle(.secondary) }
|
||||
} else {
|
||||
Text("Профиль не импортирован").font(.headline)
|
||||
Text("Откройте на iPhone ссылку phobos:// из панели PCA Phobos или вставьте её кнопкой ниже.")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var serversCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Серверы").font(.subheadline.bold()).padding(.bottom, 6)
|
||||
ForEach(vpn.servers, id: \.self) { s in
|
||||
let n = s["name"] ?? ""
|
||||
HStack {
|
||||
Image(systemName: n == vpn.name ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(n == vpn.name ? .green : .secondary)
|
||||
Text(n)
|
||||
Spacer()
|
||||
if n != vpn.name {
|
||||
Button(role: .destructive) { vpn.remove(n) } label: { Image(systemName: "trash") }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { vpn.select(n) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var powerButton: some View {
|
||||
Button(action: vpn.toggle) {
|
||||
Text(vpn.isOn ? "Отключить" : "Подключить")
|
||||
.font(.title2.bold()).foregroundStyle(.white)
|
||||
.frame(width: 180, height: 180)
|
||||
.background(Circle().fill(vpn.isOn ? Color.green : Color.indigo))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var statusLine: some View {
|
||||
let text: String = {
|
||||
switch vpn.status {
|
||||
case .connected: return "🟢 VPN включён" + (vpn.transport.isEmpty ? "" : " · \(vpn.transport)")
|
||||
case .connecting, .reasserting: return "🟡 Подключение…"
|
||||
case .disconnecting: return "Отключение…"
|
||||
default: return "⚪ VPN выключен"
|
||||
}
|
||||
}()
|
||||
return Text(text).font(.headline)
|
||||
}
|
||||
|
||||
private var optionsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Российские сайты — напрямую, без VPN", isOn: $vpn.ruDirect)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Канал до сервера").font(.subheadline.bold())
|
||||
Picker("Канал", selection: $vpn.transportMode) {
|
||||
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp"); Text("Облако").tag("cloud")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
if vpn.transportMode == "cloud" {
|
||||
TextField("wss://phobos.имя.keenetic.pro/", text: $vpn.wsURL)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
|
||||
.textFieldStyle(.roundedBorder).onSubmit { vpn.wsURL = vpn.wsURL.trimmingCharacters(in: .whitespaces) }
|
||||
Text("Облако: телефон → облако KeenDNS → ваш Keenetic → сервер. Работает, когда до сервера напрямую не достучаться.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Авто: если UDP режут, через 15 секунд переходит на TCP. Изменения применяются сразу.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
45
ios/App/Info.plist
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PCA Phobos</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.bobyrev.pcaphobos.link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>phobos</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
10
ios/App/PCAPhobos.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
131
ios/App/PCAPhobosApp.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import NetworkExtension
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PCAPhobosApp: App {
|
||||
@StateObject private var vpn = VPNController()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(vpn)
|
||||
.onOpenURL { vpn.importLink($0.absoluteString) } // phobos:// from the panel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the NETunnelProviderManager: profile, options, connect/disconnect, live status.
|
||||
@MainActor
|
||||
final class VPNController: ObservableObject {
|
||||
@Published var name = UserDefaults.standard.string(forKey: "name") ?? ""
|
||||
@Published var conf = UserDefaults.standard.string(forKey: "conf") ?? ""
|
||||
@Published var ruDirect = UserDefaults.standard.object(forKey: "ruDirect") as? Bool ?? true { didSet { saveOptions() } }
|
||||
@Published var transportMode = UserDefaults.standard.string(forKey: "transport") ?? "auto" { didSet { saveOptions() } }
|
||||
/// Channel «Облако»: wss:// of the user's Keenetic (empty -> "# pca-ws" from the link).
|
||||
@Published var wsURL = UserDefaults.standard.string(forKey: "wsURL") ?? "" { didSet { saveOptions() } }
|
||||
/// Imported servers: [["name": …, "conf": …]]; name/conf above mirror the selected one.
|
||||
@Published var servers = UserDefaults.standard.array(forKey: "servers") as? [[String: String]] ?? []
|
||||
@Published var status: NEVPNStatus = .invalid
|
||||
@Published var transport = ""
|
||||
@Published var message = ""
|
||||
|
||||
private var manager: NETunnelProviderManager?
|
||||
private var timer: Timer?
|
||||
|
||||
init() {
|
||||
if servers.isEmpty && !conf.isEmpty { servers = [["name": name, "conf": conf]] } // pre-list single profile
|
||||
Task { await load() }
|
||||
NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
||||
Task { @MainActor in self?.status = self?.manager?.connection.status ?? .invalid }
|
||||
}
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.pollTransport() }
|
||||
}
|
||||
}
|
||||
|
||||
var profile: PhobosProfile? { conf.isEmpty ? nil : PhobosProfile(conf) }
|
||||
var isOn: Bool { status == .connected || status == .connecting || status == .reasserting }
|
||||
|
||||
func load() async {
|
||||
let all = (try? await NETunnelProviderManager.loadAllFromPreferences()) ?? []
|
||||
manager = all.first
|
||||
status = manager?.connection.status ?? .invalid
|
||||
}
|
||||
|
||||
func importLink(_ link: String) {
|
||||
guard let parsed = PhobosProfile.fromLink(link) else { message = "Это не ссылка phobos:// из панели"; return }
|
||||
servers.removeAll { $0["name"] == parsed.name } // same name = updated link
|
||||
servers.append(["name": parsed.name, "conf": parsed.profile.raw])
|
||||
select(parsed.name)
|
||||
message = "Профиль «\(name)» импортирован"
|
||||
}
|
||||
|
||||
func select(_ server: String) {
|
||||
guard let s = servers.first(where: { $0["name"] == server }) else { return }
|
||||
name = server
|
||||
conf = s["conf"] ?? ""
|
||||
persist()
|
||||
Task { await save(reconnect: isOn) }
|
||||
}
|
||||
|
||||
func remove(_ server: String) {
|
||||
servers.removeAll { $0["name"] == server }
|
||||
if server == name {
|
||||
if let next = servers.first?["name"] { select(next); return }
|
||||
if isOn { manager?.connection.stopVPNTunnel() }
|
||||
name = ""; conf = ""
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
UserDefaults.standard.set(servers, forKey: "servers")
|
||||
UserDefaults.standard.set(conf, forKey: "conf")
|
||||
UserDefaults.standard.set(name, forKey: "name")
|
||||
}
|
||||
|
||||
private func saveOptions() {
|
||||
UserDefaults.standard.set(ruDirect, forKey: "ruDirect")
|
||||
UserDefaults.standard.set(transportMode, forKey: "transport")
|
||||
UserDefaults.standard.set(wsURL, forKey: "wsURL")
|
||||
Task { await save(reconnect: isOn) }
|
||||
}
|
||||
|
||||
/// Write the VPN configuration to iOS settings (asks the user once).
|
||||
func save(reconnect: Bool = false) async {
|
||||
guard let profile else { return }
|
||||
let m = manager ?? NETunnelProviderManager()
|
||||
let proto = NETunnelProviderProtocol()
|
||||
proto.providerBundleIdentifier = "com.bobyrev.pcaphobos.tunnel"
|
||||
proto.serverAddress = profile.targetHost ?? "Phobos"
|
||||
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode, "wsURL": wsURL]
|
||||
m.protocolConfiguration = proto
|
||||
m.localizedDescription = "PCA Phobos"
|
||||
m.isEnabled = true
|
||||
do {
|
||||
try await m.saveToPreferences()
|
||||
try await m.loadFromPreferences()
|
||||
manager = m
|
||||
if reconnect { m.connection.stopVPNTunnel(); try await Task.sleep(nanoseconds: 1_000_000_000); try m.connection.startVPNTunnel() }
|
||||
} catch {
|
||||
message = "Не удалось сохранить VPN: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
Task {
|
||||
if isOn { manager?.connection.stopVPNTunnel(); return }
|
||||
guard profile != nil else { message = "Сначала импортируйте ссылку phobos:// из панели"; return }
|
||||
await save()
|
||||
do { try manager?.connection.startVPNTunnel() } catch { message = "Ошибка: \(error.localizedDescription)" }
|
||||
}
|
||||
}
|
||||
|
||||
private func pollTransport() {
|
||||
guard status == .connected, let session = manager?.connection as? NETunnelProviderSession else { transport = ""; return }
|
||||
try? session.sendProviderMessage(Data()) { [weak self] data in
|
||||
guard let data, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||||
Task { @MainActor in self?.transport = obj["transport"] as? String ?? "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
576
ios/PCAPhobos.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,576 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
008DA398D56DFBDB16D97CE1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF14952A4263FF0E004A3F04 /* ContentView.swift */; };
|
||||
08C4842C3185E04B2154C971 /* WireGuardKit in Frameworks */ = {isa = PBXBuildFile; productRef = FA829997FAB330F6EFA104C9 /* WireGuardKit */; };
|
||||
11F2A6A8D281AB36B45D4603 /* TunnelConfiguration+WgQuickConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */; };
|
||||
1C05D29ACA95548374AD6FD9 /* logging.c in Sources */ = {isa = PBXBuildFile; fileRef = 9FC04650634F03E6DB09AB6B /* logging.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||
38870650A7C2B1F0927A1230 /* wg-obfuscator.c in Sources */ = {isa = PBXBuildFile; fileRef = 1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */; };
|
||||
56F35C68C6A387E499703CFF /* masking.c in Sources */ = {isa = PBXBuildFile; fileRef = CCE5C98CD905BDE16A8D83D4 /* masking.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||
599057D9048B5FF1EA0E6000 /* WsLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC83AB79BE0E361F4EE90875 /* WsLink.swift */; };
|
||||
5F5D376513C0B27C45A3E7B6 /* ru_bypass.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2572D9A6F822B038D0652619 /* ru_bypass.txt */; };
|
||||
78B74F81E0042D9AD94B7A6B /* config.c in Sources */ = {isa = PBXBuildFile; fileRef = 0E5C1673464AAC3EF81DD2FE /* config.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */; };
|
||||
99F5DB52833E3B5505EEA727 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E36BA501FDCB2034F09415A /* PacketTunnelProvider.swift */; };
|
||||
B3BB8BE3409D63FEB59B5449 /* obfuscation.c in Sources */ = {isa = PBXBuildFile; fileRef = EF9115CA0D5EC52C8679A12A /* obfuscation.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||
C3C41D3C419E8D17F20A38B5 /* PCAPhobosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */; };
|
||||
C6116E8B9CDFEE363BCBD2D6 /* PhobosTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A81F96BF4131FDBB816623BE /* PhobosTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
CBC338B91B5FCE1E207B36AC /* PhobosProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */; };
|
||||
D8CCC1E6CC8164EDD4E34D25 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6359B8954E69147DA50EDB46 /* Assets.xcassets */; };
|
||||
DF6D62C526E908CA5CDDFCAE /* pcarelay.c in Sources */ = {isa = PBXBuildFile; fileRef = 211950F8776D3D14D7B9C49A /* pcarelay.c */; };
|
||||
E88105F9325A93041D617213 /* masking_stun.c in Sources */ = {isa = PBXBuildFile; fileRef = 05DD44245829A52D32B42AF8 /* masking_stun.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
B9875EF5DEB6362919B43F22 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = E59D1112D2B128DF6FE7A1E9 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0FD066BC2C51A1E092914FE8;
|
||||
remoteInfo = PhobosTunnel;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9FC25420ED5EF973CE140584 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
C6116E8B9CDFEE363BCBD2D6 /* PhobosTunnel.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
027878C5E0654DB27FAE88A3 /* PCAPhobos.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = PCAPhobos.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
05DD44245829A52D32B42AF8 /* masking_stun.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = masking_stun.c; sourceTree = "<group>"; };
|
||||
0E5C1673464AAC3EF81DD2FE /* config.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = config.c; sourceTree = "<group>"; };
|
||||
1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = "wg-obfuscator.c"; sourceTree = "<group>"; };
|
||||
211950F8776D3D14D7B9C49A /* pcarelay.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = pcarelay.c; sourceTree = "<group>"; };
|
||||
2572D9A6F822B038D0652619 /* ru_bypass.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = ru_bypass.txt; sourceTree = "<group>"; };
|
||||
2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TunnelConfiguration+WgQuickConfig.swift"; sourceTree = "<group>"; };
|
||||
6359B8954E69147DA50EDB46 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
722DE8EFC35A88BC68637FB4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+ArrayConversion.swift"; sourceTree = "<group>"; };
|
||||
7E66B3AD4BB22D2AB5676E10 /* PCAPhobos.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PCAPhobos.entitlements; sourceTree = "<group>"; };
|
||||
8E36BA501FDCB2034F09415A /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
|
||||
98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhobosProfile.swift; sourceTree = "<group>"; };
|
||||
9FC04650634F03E6DB09AB6B /* logging.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = logging.c; sourceTree = "<group>"; };
|
||||
A81F96BF4131FDBB816623BE /* PhobosTunnel.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = PhobosTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PCAPhobosApp.swift; sourceTree = "<group>"; };
|
||||
CA6480536DA633DB94B6930F /* WireGuardKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WireGuardKit; path = WireGuardKit; sourceTree = SOURCE_ROOT; };
|
||||
CCE5C98CD905BDE16A8D83D4 /* masking.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = masking.c; sourceTree = "<group>"; };
|
||||
EC83AB79BE0E361F4EE90875 /* WsLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WsLink.swift; sourceTree = "<group>"; };
|
||||
EF9115CA0D5EC52C8679A12A /* obfuscation.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = obfuscation.c; sourceTree = "<group>"; };
|
||||
FF14952A4263FF0E004A3F04 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
D366EA9C7C0EC88CAE5EA47E /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
08C4842C3185E04B2154C971 /* WireGuardKit in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1EBBB94A186F7080FE3609C8 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
027878C5E0654DB27FAE88A3 /* PCAPhobos.app */,
|
||||
A81F96BF4131FDBB816623BE /* PhobosTunnel.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2500D202DC9E96E7BBCE29B1 /* Tunnel */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8E36BA501FDCB2034F09415A /* PacketTunnelProvider.swift */,
|
||||
211950F8776D3D14D7B9C49A /* pcarelay.c */,
|
||||
2572D9A6F822B038D0652619 /* ru_bypass.txt */,
|
||||
7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */,
|
||||
2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */,
|
||||
EC83AB79BE0E361F4EE90875 /* WsLink.swift */,
|
||||
);
|
||||
path = Tunnel;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
527268DF962A8B7EC120C536 /* obfuscator */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0E5C1673464AAC3EF81DD2FE /* config.c */,
|
||||
9FC04650634F03E6DB09AB6B /* logging.c */,
|
||||
05DD44245829A52D32B42AF8 /* masking_stun.c */,
|
||||
CCE5C98CD905BDE16A8D83D4 /* masking.c */,
|
||||
EF9115CA0D5EC52C8679A12A /* obfuscation.c */,
|
||||
1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */,
|
||||
);
|
||||
name = obfuscator;
|
||||
path = Tunnel/obfuscator;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D4CB33FA621F53C8F82ED115 /* Packages */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CA6480536DA633DB94B6930F /* WireGuardKit */,
|
||||
);
|
||||
name = Packages;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DD1FD95C0B2DE48623872799 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6359B8954E69147DA50EDB46 /* Assets.xcassets */,
|
||||
FF14952A4263FF0E004A3F04 /* ContentView.swift */,
|
||||
722DE8EFC35A88BC68637FB4 /* Info.plist */,
|
||||
7E66B3AD4BB22D2AB5676E10 /* PCAPhobos.entitlements */,
|
||||
B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E31449B836D353F74991D905 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DD1FD95C0B2DE48623872799 /* App */,
|
||||
527268DF962A8B7EC120C536 /* obfuscator */,
|
||||
D4CB33FA621F53C8F82ED115 /* Packages */,
|
||||
F6FC482145D1EFAD5A15F63B /* Shared */,
|
||||
2500D202DC9E96E7BBCE29B1 /* Tunnel */,
|
||||
1EBBB94A186F7080FE3609C8 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F6FC482145D1EFAD5A15F63B /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
0FD066BC2C51A1E092914FE8 /* PhobosTunnel */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 329F6A5A13B95B1525F94841 /* Build configuration list for PBXNativeTarget "PhobosTunnel" */;
|
||||
buildPhases = (
|
||||
173BA4C39EE769A34EBB19CB /* Sources */,
|
||||
A5352C1E8C48FFB2CCEC38F3 /* Resources */,
|
||||
D366EA9C7C0EC88CAE5EA47E /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = PhobosTunnel;
|
||||
packageProductDependencies = (
|
||||
FA829997FAB330F6EFA104C9 /* WireGuardKit */,
|
||||
);
|
||||
productName = PhobosTunnel;
|
||||
productReference = A81F96BF4131FDBB816623BE /* PhobosTunnel.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
E1F7E69029753CF102C6CE2E /* PCAPhobos */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 61EF055D39FD21A29FA039D5 /* Build configuration list for PBXNativeTarget "PCAPhobos" */;
|
||||
buildPhases = (
|
||||
F83DEE91B85335727CA6B39E /* Sources */,
|
||||
D18596DD6D613F6BC9647644 /* Resources */,
|
||||
9FC25420ED5EF973CE140584 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0B334C04E5AD898D8736FFAA /* PBXTargetDependency */,
|
||||
);
|
||||
name = PCAPhobos;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PCAPhobos;
|
||||
productReference = 027878C5E0654DB27FAE88A3 /* PCAPhobos.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
E59D1112D2B128DF6FE7A1E9 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
0FD066BC2C51A1E092914FE8 = {
|
||||
DevelopmentTeam = Q5FKU39TUA;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
E1F7E69029753CF102C6CE2E = {
|
||||
DevelopmentTeam = Q5FKU39TUA;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F73680B923D76C65D03B442C /* Build configuration list for PBXProject "PCAPhobos" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = E31449B836D353F74991D905;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
9F8EC62EA6724F5FA6B8BAE0 /* XCLocalSwiftPackageReference "WireGuardKit" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 1EBBB94A186F7080FE3609C8 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
E1F7E69029753CF102C6CE2E /* PCAPhobos */,
|
||||
0FD066BC2C51A1E092914FE8 /* PhobosTunnel */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A5352C1E8C48FFB2CCEC38F3 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5F5D376513C0B27C45A3E7B6 /* ru_bypass.txt in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D18596DD6D613F6BC9647644 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D8CCC1E6CC8164EDD4E34D25 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
173BA4C39EE769A34EBB19CB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
99F5DB52833E3B5505EEA727 /* PacketTunnelProvider.swift in Sources */,
|
||||
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */,
|
||||
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */,
|
||||
11F2A6A8D281AB36B45D4603 /* TunnelConfiguration+WgQuickConfig.swift in Sources */,
|
||||
599057D9048B5FF1EA0E6000 /* WsLink.swift in Sources */,
|
||||
78B74F81E0042D9AD94B7A6B /* config.c in Sources */,
|
||||
1C05D29ACA95548374AD6FD9 /* logging.c in Sources */,
|
||||
56F35C68C6A387E499703CFF /* masking.c in Sources */,
|
||||
E88105F9325A93041D617213 /* masking_stun.c in Sources */,
|
||||
B3BB8BE3409D63FEB59B5449 /* obfuscation.c in Sources */,
|
||||
DF6D62C526E908CA5CDDFCAE /* pcarelay.c in Sources */,
|
||||
38870650A7C2B1F0927A1230 /* wg-obfuscator.c in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F83DEE91B85335727CA6B39E /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
008DA398D56DFBDB16D97CE1 /* ContentView.swift in Sources */,
|
||||
C3C41D3C419E8D17F20A38B5 /* PCAPhobosApp.swift in Sources */,
|
||||
CBC338B91B5FCE1E207B36AC /* PhobosProfile.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
0B334C04E5AD898D8736FFAA /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0FD066BC2C51A1E092914FE8 /* PhobosTunnel */;
|
||||
targetProxy = B9875EF5DEB6362919B43F22 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
35473CF1EA2AAAC350F346D0 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/PCAPhobos.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.bobyrev.pcaphobos;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
62739003B63EE4EE3B3FF1FB /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Tunnel/PhobosTunnel.entitlements;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(PROJECT_DIR)/Tunnel",
|
||||
"$(PROJECT_DIR)/Tunnel/obfuscator",
|
||||
);
|
||||
INFOPLIST_FILE = Tunnel/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||
"$(PROJECT_DIR)/WireGuardGo/iphoneos",
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = (
|
||||
"$(PROJECT_DIR)/WireGuardGo/iphonesimulator",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.bobyrev.pcaphobos.tunnel;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Tunnel/Tunnel-Bridging-Header.h";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
7ABA8220043DEF78154F9F38 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = Q5FKU39TUA;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B5D2F9B70DD37D3A57737EED /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = Q5FKU39TUA;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D0AEA32C3C7BFB88918AB206 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Tunnel/PhobosTunnel.entitlements;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(PROJECT_DIR)/Tunnel",
|
||||
"$(PROJECT_DIR)/Tunnel/obfuscator",
|
||||
);
|
||||
INFOPLIST_FILE = Tunnel/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||
"$(PROJECT_DIR)/WireGuardGo/iphoneos",
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = (
|
||||
"$(PROJECT_DIR)/WireGuardGo/iphonesimulator",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.bobyrev.pcaphobos.tunnel;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Tunnel/Tunnel-Bridging-Header.h";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D42BF07983FD43EBB6D84762 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/PCAPhobos.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.bobyrev.pcaphobos;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
329F6A5A13B95B1525F94841 /* Build configuration list for PBXNativeTarget "PhobosTunnel" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D0AEA32C3C7BFB88918AB206 /* Debug */,
|
||||
62739003B63EE4EE3B3FF1FB /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
61EF055D39FD21A29FA039D5 /* Build configuration list for PBXNativeTarget "PCAPhobos" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D42BF07983FD43EBB6D84762 /* Debug */,
|
||||
35473CF1EA2AAAC350F346D0 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
F73680B923D76C65D03B442C /* Build configuration list for PBXProject "PCAPhobos" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B5D2F9B70DD37D3A57737EED /* Debug */,
|
||||
7ABA8220043DEF78154F9F38 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
9F8EC62EA6724F5FA6B8BAE0 /* XCLocalSwiftPackageReference "WireGuardKit" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = WireGuardKit;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
FA829997FAB330F6EFA104C9 /* WireGuardKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = WireGuardKit;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = E59D1112D2B128DF6FE7A1E9 /* Project object */;
|
||||
}
|
||||
7
ios/PCAPhobos.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
27
ios/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# PCA Phobos VPN — iPhone
|
||||
|
||||
То же, что Android-клиент: импорт `phobos://` из панели, обфускация (wg-obfuscator внутри
|
||||
расширения), **российские сайты напрямую**, канал **Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP; «Облако» — через KeenDNS своего Keenetic, см. `../wsgw/README.md`).
|
||||
Выбор отдельных приложений на iOS невозможен (Apple даёт это только через MDM) — делим по адресам.
|
||||
|
||||
Устройство:
|
||||
- `App/` — SwiftUI-приложение (профиль, кнопка, настройки), `Shared/PhobosProfile.swift` — разбор ссылки.
|
||||
- `Tunnel/` — Packet Tunnel: WireGuardKit → 127.0.0.1:13255 wg-obfuscator (`-Dmain=wgobf_main`, один
|
||||
экземпляр: iOS запрещает fork) → 127.0.0.1:13258 `pcarelay.c` → сервер по UDP или TCP (формат udptcp),
|
||||
либо «Облако»: пакеты из pcarelay в `WsLink.swift` (URLSessionWebSocketTask) → wss://поддомен роутера → wsgw → сервер.
|
||||
- `WireGuardKit/` — wireguard-apple (MIT), `WireGuardGo/<sdk>/libwg-go.a` — собран `make` (iphoneos) и
|
||||
`go build -buildmode c-archive` с `-target arm64-apple-ios15.0-simulator` (симулятор).
|
||||
|
||||
Сборка: `xcodegen generate` → `PCAPhobos.xcodeproj`. Проверка без телефона: `Tests/ProfileCheck`, режим «Облако» релея — `Tests/RelayCheck/main.c`.
|
||||
|
||||
## Установка на iPhone (нужен ПЛАТНЫЙ Apple Developer, $99/год)
|
||||
Network Extension (packet-tunnel-provider) Apple выдаёт только платным аккаунтам — с бесплатным
|
||||
Xcode не подпишет даже одно приложение. Когда аккаунт есть:
|
||||
1. В `project.yml` вписать `DEVELOPMENT_TEAM: <Team ID>`, `xcodegen generate`.
|
||||
2. Xcode → Signing: включить capability Network Extensions (Packet Tunnel) для обоих таргетов.
|
||||
3. iPhone по кабелю → Run, либо Product → Archive → TestFlight (тестировщики по e-mail, 90 дней).
|
||||
4. В панели клиента → 📱 → открыть ссылку `phobos://` на iPhone → «Открыть в PCA Phobos» → Подключить
|
||||
→ разрешить добавление VPN.
|
||||
|
||||
Проверено 23.10.2026: сборка под iphoneos и симулятор, UI и импорт реальной ссылки в симуляторе.
|
||||
Сам туннель в симуляторе iOS не работает — проверяется только на телефоне.
|
||||
101
ios/Shared/PhobosProfile.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
/// Parsed phobos:// payload from the PCA Phobos panel:
|
||||
/// [Interface] + [Peer] (WireGuard) + [instance] (wg-obfuscator) + "# pca-tcp = <port>".
|
||||
struct PhobosProfile {
|
||||
let raw: String
|
||||
|
||||
init(_ raw: String) { self.raw = raw }
|
||||
|
||||
/// phobos://<urlsafe base64>#name
|
||||
static func fromLink(_ link: String) -> (profile: PhobosProfile, name: String)? {
|
||||
var s = link.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard s.hasPrefix("phobos://") else { return nil }
|
||||
s.removeFirst("phobos://".count)
|
||||
var name = "Phobos"
|
||||
if let hash = s.firstIndex(of: "#") {
|
||||
name = String(s[s.index(after: hash)...]).removingPercentEncoding ?? name
|
||||
s = String(s[..<hash])
|
||||
}
|
||||
var b64 = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
while b64.count % 4 != 0 { b64 += "=" }
|
||||
guard let data = Data(base64Encoded: b64), let text = String(data: data, encoding: .utf8),
|
||||
text.contains("[Interface]"), text.contains("[instance]") else { return nil }
|
||||
return (PhobosProfile(text), name)
|
||||
}
|
||||
|
||||
/// Lines of a section, without "= none" placeholders and comments.
|
||||
func lines(_ header: String) -> [(key: String, line: String)] {
|
||||
var inSection = false
|
||||
var out: [(String, String)] = []
|
||||
for rawLine in raw.split(separator: "\n") {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
if line.hasPrefix("[") && line.hasSuffix("]") { inSection = line.lowercased() == header.lowercased(); continue }
|
||||
guard inSection, !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue }
|
||||
let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
|
||||
if value.lowercased() == "none" { continue }
|
||||
out.append((line[..<eq].trimmingCharacters(in: .whitespaces).lowercased(), line))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func value(_ header: String, _ key: String) -> String? {
|
||||
lines(header).first { $0.key == key }.map { line in
|
||||
line.line[line.line.index(after: line.line.firstIndex(of: "=")!)...].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
var target: String? { value("[instance]", "target") }
|
||||
var targetHost: String? { target.map { String($0[..<($0.lastIndex(of: ":") ?? $0.endIndex)]) } }
|
||||
var targetPort: Int? { target.flatMap { $0.split(separator: ":").last.flatMap { Int($0) } } }
|
||||
|
||||
/// "# pca-tcp = 443" written by the panel when the server has the TCP fallback.
|
||||
var tcpPort: Int? {
|
||||
for rawLine in raw.split(separator: "\n") {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
if line.hasPrefix("#"), line.contains("pca-tcp"), let eq = line.firstIndex(of: "=") {
|
||||
return Int(line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// "# pca-ws = wss://…" — the user's Keenetic (KeenDNS cloud subdomain -> wsgw), channel «Облако».
|
||||
var wsURL: String? {
|
||||
for rawLine in raw.split(separator: "\n") {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
if line.hasPrefix("#"), line.contains("pca-ws"), let eq = line.firstIndex(of: "=") {
|
||||
return line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func obfuscatorConfig(listen: Int, target: String) -> String {
|
||||
var out = "[instance]\nsource-if = 127.0.0.1\nsource-lport = \(listen)\ntarget = \(target)\n"
|
||||
for item in lines("[instance]") where !["source-if", "source-lport", "target", "verbose"].contains(item.key) {
|
||||
out += item.line + "\n"
|
||||
}
|
||||
return out + "verbose = INFO\n"
|
||||
}
|
||||
|
||||
func wireGuardConfig(endpoint: String, allowedIPs: String?) -> String {
|
||||
var out = "[Interface]\n"
|
||||
for item in lines("[Interface]") where !item.key.hasSuffix("applications") && item.key != "mtu" { out += item.line + "\n" }
|
||||
// ponytail: obfuscator masking adds bytes; the panel's default 1420 stalls on mobile networks,
|
||||
// so 1420/missing -> 1280, any other MTU in the link wins (set per client in the panel)
|
||||
let linkMTU = lines("[Interface]").first { $0.key == "mtu" }
|
||||
.flatMap { Int($0.line.split(separator: "=").last?.trimmingCharacters(in: .whitespaces) ?? "") }
|
||||
let mtu = (linkMTU == nil || linkMTU == 1420) ? 1280 : linkMTU!
|
||||
out += "MTU = \(mtu)\n"
|
||||
out += "\n[Peer]\n"
|
||||
for item in lines("[Peer]") {
|
||||
switch item.key {
|
||||
case "endpoint": out += "Endpoint = \(endpoint)\n"
|
||||
case "allowedips" where allowedIPs != nil: out += "AllowedIPs = \(allowedIPs!)\n"
|
||||
default: out += item.line + "\n"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
41
ios/Tests/ProfileCheck/main.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
// Self-check of phobos:// parsing (no device needed):
|
||||
// cd ios/Tests/ProfileCheck && xcrun swiftc -o /tmp/pc ../../Shared/PhobosProfile.swift main.swift && /tmp/pc
|
||||
import Foundation
|
||||
let conf = """
|
||||
[Interface]
|
||||
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
Address = 10.25.0.9/32
|
||||
DNS = 1.1.1.1
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
|
||||
PresharedKey = none
|
||||
Endpoint = 127.0.0.1:51820
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
PersistentKeepalive = 25
|
||||
|
||||
[instance]
|
||||
source-if = 127.0.0.1
|
||||
source-lport = 51820
|
||||
target = 203.0.113.10:2083
|
||||
key = testkey
|
||||
masking = STUN
|
||||
verbose = INFO
|
||||
idle-timeout = 300
|
||||
max-dummy = 4
|
||||
# pca-tcp = 443
|
||||
"""
|
||||
let link = "phobos://" + Data(conf.utf8).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "") + "#demo"
|
||||
let (p, name) = PhobosProfile.fromLink(link)!
|
||||
precondition(name == "demo" && p.targetHost == "203.0.113.10" && p.targetPort == 2083 && p.tcpPort == 443)
|
||||
let obf = p.obfuscatorConfig(listen: 13255, target: "127.0.0.1:13258")
|
||||
precondition(obf.contains("target = 127.0.0.1:13258") && obf.contains("key = testkey") && !obf.contains("203.0.113.10"))
|
||||
let wg = p.wireGuardConfig(endpoint: "127.0.0.1:13255", allowedIPs: "1.0.0.0/8")
|
||||
precondition(wg.contains("Endpoint = 127.0.0.1:13255") && wg.contains("AllowedIPs = 1.0.0.0/8") && !wg.contains("= none"))
|
||||
precondition(wg.contains("MTU = 1280") && !wg.contains("1420")) // panel default -> 1280
|
||||
let custom = PhobosProfile.fromLink("phobos://" + Data(conf.replacingOccurrences(of: "MTU = 1420", with: "MTU = 1300").utf8).base64EncodedString())!.0
|
||||
precondition(custom.wireGuardConfig(endpoint: "e", allowedIPs: nil).contains("MTU = 1300")) // link MTU wins
|
||||
print("profile_check ok")
|
||||
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");
|
||||
}
|
||||
31
ios/Tunnel/Info.plist
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PhobosTunnel</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.networkextension.packet-tunnel</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
107
ios/Tunnel/PacketTunnelProvider.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
import WireGuardKit
|
||||
|
||||
/// PCA Phobos packet tunnel:
|
||||
/// WireGuard (userspace) -> 127.0.0.1:13255 wg-obfuscator -> 127.0.0.1:13258 pcarelay -> server (UDP or TCP).
|
||||
/// providerConfiguration: "conf" (phobos:// payload), "ruDirect" (Bool), "transport" ("auto"|"udp"|"tcp"|"cloud"), "wsURL".
|
||||
/// "cloud": pcarelay hands packets to WsLink -> wss://<Keenetic KeenDNS subdomain>/ -> wsgw on the router -> server UDP.
|
||||
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
private static let wgPort = 13255, relayPort = 13258
|
||||
private static var obfuscatorStarted = false // one wg-obfuscator per process (it forks for more)
|
||||
|
||||
private lazy var adapter = WireGuardAdapter(with: self) { _, message in NSLog("wg: %@", message) }
|
||||
|
||||
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||
let pc = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration ?? [:]
|
||||
guard let conf = pc["conf"] as? String, !conf.isEmpty else {
|
||||
completionHandler(NSError(domain: "PCAPhobos", code: 1, userInfo: [NSLocalizedDescriptionKey: "нет профиля"]))
|
||||
return
|
||||
}
|
||||
let ruDirect = pc["ruDirect"] as? Bool ?? true
|
||||
let transport = pc["transport"] as? String ?? "auto"
|
||||
let profile = PhobosProfile(conf)
|
||||
|
||||
guard let host = profile.targetHost, let udpPort = profile.targetPort else {
|
||||
completionHandler(NSError(domain: "PCAPhobos", code: 2, userInfo: [NSLocalizedDescriptionKey: "в профиле нет адреса сервера"]))
|
||||
return
|
||||
}
|
||||
let tcpPort = profile.tcpPort ?? 0
|
||||
if transport == "tcp" && tcpPort == 0 {
|
||||
completionHandler(NSError(domain: "PCAPhobos", code: 3, userInfo: [NSLocalizedDescriptionKey: "на сервере нет TCP-канала"]))
|
||||
return
|
||||
}
|
||||
|
||||
let wsURL = (pc["wsURL"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? profile.wsURL
|
||||
if transport == "cloud" {
|
||||
guard let s = wsURL, let url = URL(string: s) else {
|
||||
completionHandler(NSError(domain: "PCAPhobos", code: 4, userInfo: [NSLocalizedDescriptionKey: "не задан адрес роутера (wss://…) для канала «Облако»"]))
|
||||
return
|
||||
}
|
||||
WsLink.shared.start(url)
|
||||
pcarelay_use_ws { p, n in WsLink.shared.send(p, n) }
|
||||
} else {
|
||||
pcarelay_use_tcp(transport == "tcp" ? 1 : 0)
|
||||
}
|
||||
if !Self.obfuscatorStarted {
|
||||
Self.obfuscatorStarted = true
|
||||
pcarelay_start(Int32(Self.relayPort), host, Int32(udpPort), Int32(tcpPort))
|
||||
let path = NSTemporaryDirectory() + "obf.conf"
|
||||
try? profile.obfuscatorConfig(listen: Self.wgPort, target: "127.0.0.1:\(Self.relayPort)")
|
||||
.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
Thread.detachNewThread {
|
||||
var args: [UnsafeMutablePointer<CChar>?] = ["wg-obfuscator", "-c", path].map { strdup($0) }
|
||||
_ = wgobf_main(Int32(args.count), &args)
|
||||
}
|
||||
}
|
||||
|
||||
// full tunnel; Russian networks (all sizes, incl. CDN /24s) are excluded routes when ruDirect
|
||||
PhobosRoutes.excludedIPv4 = ruDirect ? Self.ruNetworks() : []
|
||||
let wgText = profile.wireGuardConfig(endpoint: "127.0.0.1:\(Self.wgPort)", allowedIPs: nil)
|
||||
let tunnelConfig: TunnelConfiguration
|
||||
do {
|
||||
tunnelConfig = try TunnelConfiguration(fromWgQuickConfig: wgText, called: "phobos")
|
||||
} catch {
|
||||
completionHandler(error)
|
||||
return
|
||||
}
|
||||
adapter.start(tunnelConfiguration: tunnelConfig) { [weak self] error in
|
||||
completionHandler(error)
|
||||
if error == nil && transport == "auto" && tcpPort != 0 { self?.watchUdp() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto: nothing came back over UDP within 15 s -> UDP is cut, move the relay to TCP.
|
||||
private func watchUdp() {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 15) {
|
||||
if pcarelay_is_tcp() == 0 && pcarelay_rx_bytes() == 0 {
|
||||
NSLog("PCAPhobos: no UDP answer in 15 s -> TCP")
|
||||
pcarelay_use_tcp(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||
adapter.stop { _ in
|
||||
completionHandler()
|
||||
// the obfuscator thread cannot be stopped cleanly; a fresh process starts next time
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// App asks for status: {"transport": "UDP"|"TCP", "rx": bytes}
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||
let status: [String: Any] = ["transport": pcarelay_is_ws() != 0 ? "Облако" : pcarelay_is_tcp() != 0 ? "TCP" : "UDP",
|
||||
"rx": pcarelay_rx_bytes()]
|
||||
completionHandler?(try? JSONSerialization.data(withJSONObject: status))
|
||||
}
|
||||
|
||||
/// ru_bypass.txt: Russian IPv4 networks (comma-separated). IPv6 stays fully in the tunnel,
|
||||
/// otherwise blocked sites leak over carrier IPv6.
|
||||
private static func ruNetworks() -> [String] {
|
||||
guard let url = Bundle.main.url(forResource: "ru_bypass", withExtension: "txt"),
|
||||
let text = try? String(contentsOf: url, encoding: .utf8),
|
||||
let line = text.split(separator: "\n").first(where: { !$0.hasPrefix("#") && $0.contains("/") }) else { return [] }
|
||||
return line.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
}
|
||||
}
|
||||
10
ios/Tunnel/PhobosTunnel.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
32
ios/Tunnel/String+ArrayConversion.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
|
||||
func splitToArray(separator: Character = ",", trimmingCharacters: CharacterSet? = nil) -> [String] {
|
||||
return split(separator: separator)
|
||||
.map {
|
||||
if let charSet = trimmingCharacters {
|
||||
return $0.trimmingCharacters(in: charSet)
|
||||
} else {
|
||||
return String($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Optional where Wrapped == String {
|
||||
|
||||
func splitToArray(separator: Character = ",", trimmingCharacters: CharacterSet? = nil) -> [String] {
|
||||
switch self {
|
||||
case .none:
|
||||
return []
|
||||
case .some(let wrapped):
|
||||
return wrapped.splitToArray(separator: separator, trimmingCharacters: trimmingCharacters)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
1
ios/Tunnel/Tunnel-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
||||
#include "pcarelay.h"
|
||||
253
ios/Tunnel/TunnelConfiguration+WgQuickConfig.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import WireGuardKit
|
||||
|
||||
extension TunnelConfiguration {
|
||||
|
||||
enum ParserState {
|
||||
case inInterfaceSection
|
||||
case inPeerSection
|
||||
case notInASection
|
||||
}
|
||||
|
||||
enum ParseError: Error {
|
||||
case invalidLine(String.SubSequence)
|
||||
case noInterface
|
||||
case multipleInterfaces
|
||||
case interfaceHasNoPrivateKey
|
||||
case interfaceHasInvalidPrivateKey(String)
|
||||
case interfaceHasInvalidListenPort(String)
|
||||
case interfaceHasInvalidAddress(String)
|
||||
case interfaceHasInvalidDNS(String)
|
||||
case interfaceHasInvalidMTU(String)
|
||||
case interfaceHasUnrecognizedKey(String)
|
||||
case peerHasNoPublicKey
|
||||
case peerHasInvalidPublicKey(String)
|
||||
case peerHasInvalidPreSharedKey(String)
|
||||
case peerHasInvalidAllowedIP(String)
|
||||
case peerHasInvalidEndpoint(String)
|
||||
case peerHasInvalidPersistentKeepAlive(String)
|
||||
case peerHasInvalidTransferBytes(String)
|
||||
case peerHasInvalidLastHandshakeTime(String)
|
||||
case peerHasUnrecognizedKey(String)
|
||||
case multiplePeersWithSamePublicKey
|
||||
case multipleEntriesForKey(String)
|
||||
}
|
||||
|
||||
convenience init(fromWgQuickConfig wgQuickConfig: String, called name: String? = nil) throws {
|
||||
var interfaceConfiguration: InterfaceConfiguration?
|
||||
var peerConfigurations = [PeerConfiguration]()
|
||||
|
||||
let lines = wgQuickConfig.split { $0.isNewline }
|
||||
|
||||
var parserState = ParserState.notInASection
|
||||
var attributes = [String: String]()
|
||||
|
||||
for (lineIndex, line) in lines.enumerated() {
|
||||
var trimmedLine: String
|
||||
if let commentRange = line.range(of: "#") {
|
||||
trimmedLine = String(line[..<commentRange.lowerBound])
|
||||
} else {
|
||||
trimmedLine = String(line)
|
||||
}
|
||||
|
||||
trimmedLine = trimmedLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercasedLine = trimmedLine.lowercased()
|
||||
|
||||
if !trimmedLine.isEmpty {
|
||||
if let equalsIndex = trimmedLine.firstIndex(of: "=") {
|
||||
// Line contains an attribute
|
||||
let keyWithCase = trimmedLine[..<equalsIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let key = keyWithCase.lowercased()
|
||||
let value = trimmedLine[trimmedLine.index(equalsIndex, offsetBy: 1)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let keysWithMultipleEntriesAllowed: Set<String> = ["address", "allowedips", "dns"]
|
||||
if let presentValue = attributes[key] {
|
||||
if keysWithMultipleEntriesAllowed.contains(key) {
|
||||
attributes[key] = presentValue + "," + value
|
||||
} else {
|
||||
throw ParseError.multipleEntriesForKey(keyWithCase)
|
||||
}
|
||||
} else {
|
||||
attributes[key] = value
|
||||
}
|
||||
let interfaceSectionKeys: Set<String> = ["privatekey", "listenport", "address", "dns", "mtu"]
|
||||
let peerSectionKeys: Set<String> = ["publickey", "presharedkey", "allowedips", "endpoint", "persistentkeepalive"]
|
||||
if parserState == .inInterfaceSection {
|
||||
guard interfaceSectionKeys.contains(key) else {
|
||||
throw ParseError.interfaceHasUnrecognizedKey(keyWithCase)
|
||||
}
|
||||
} else if parserState == .inPeerSection {
|
||||
guard peerSectionKeys.contains(key) else {
|
||||
throw ParseError.peerHasUnrecognizedKey(keyWithCase)
|
||||
}
|
||||
}
|
||||
} else if lowercasedLine != "[interface]" && lowercasedLine != "[peer]" {
|
||||
throw ParseError.invalidLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
let isLastLine = lineIndex == lines.count - 1
|
||||
|
||||
if isLastLine || lowercasedLine == "[interface]" || lowercasedLine == "[peer]" {
|
||||
// Previous section has ended; process the attributes collected so far
|
||||
if parserState == .inInterfaceSection {
|
||||
let interface = try TunnelConfiguration.collate(interfaceAttributes: attributes)
|
||||
guard interfaceConfiguration == nil else { throw ParseError.multipleInterfaces }
|
||||
interfaceConfiguration = interface
|
||||
} else if parserState == .inPeerSection {
|
||||
let peer = try TunnelConfiguration.collate(peerAttributes: attributes)
|
||||
peerConfigurations.append(peer)
|
||||
}
|
||||
}
|
||||
|
||||
if lowercasedLine == "[interface]" {
|
||||
parserState = .inInterfaceSection
|
||||
attributes.removeAll()
|
||||
} else if lowercasedLine == "[peer]" {
|
||||
parserState = .inPeerSection
|
||||
attributes.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray)
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
throw ParseError.multiplePeersWithSamePublicKey
|
||||
}
|
||||
|
||||
if let interfaceConfiguration = interfaceConfiguration {
|
||||
self.init(name: name, interface: interfaceConfiguration, peers: peerConfigurations)
|
||||
} else {
|
||||
throw ParseError.noInterface
|
||||
}
|
||||
}
|
||||
|
||||
func asWgQuickConfig() -> String {
|
||||
var output = "[Interface]\n"
|
||||
output.append("PrivateKey = \(interface.privateKey.base64Key)\n")
|
||||
if let listenPort = interface.listenPort {
|
||||
output.append("ListenPort = \(listenPort)\n")
|
||||
}
|
||||
if !interface.addresses.isEmpty {
|
||||
let addressString = interface.addresses.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
output.append("Address = \(addressString)\n")
|
||||
}
|
||||
if !interface.dns.isEmpty || !interface.dnsSearch.isEmpty {
|
||||
var dnsLine = interface.dns.map { $0.stringRepresentation }
|
||||
dnsLine.append(contentsOf: interface.dnsSearch)
|
||||
let dnsString = dnsLine.joined(separator: ", ")
|
||||
output.append("DNS = \(dnsString)\n")
|
||||
}
|
||||
if let mtu = interface.mtu {
|
||||
output.append("MTU = \(mtu)\n")
|
||||
}
|
||||
|
||||
for peer in peers {
|
||||
output.append("\n[Peer]\n")
|
||||
output.append("PublicKey = \(peer.publicKey.base64Key)\n")
|
||||
if let preSharedKey = peer.preSharedKey?.base64Key {
|
||||
output.append("PresharedKey = \(preSharedKey)\n")
|
||||
}
|
||||
if !peer.allowedIPs.isEmpty {
|
||||
let allowedIPsString = peer.allowedIPs.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
output.append("AllowedIPs = \(allowedIPsString)\n")
|
||||
}
|
||||
if let endpoint = peer.endpoint {
|
||||
output.append("Endpoint = \(endpoint.stringRepresentation)\n")
|
||||
}
|
||||
if let persistentKeepAlive = peer.persistentKeepAlive {
|
||||
output.append("PersistentKeepalive = \(persistentKeepAlive)\n")
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
private static func collate(interfaceAttributes attributes: [String: String]) throws -> InterfaceConfiguration {
|
||||
guard let privateKeyString = attributes["privatekey"] else {
|
||||
throw ParseError.interfaceHasNoPrivateKey
|
||||
}
|
||||
guard let privateKey = PrivateKey(base64Key: privateKeyString) else {
|
||||
throw ParseError.interfaceHasInvalidPrivateKey(privateKeyString)
|
||||
}
|
||||
var interface = InterfaceConfiguration(privateKey: privateKey)
|
||||
if let listenPortString = attributes["listenport"] {
|
||||
guard let listenPort = UInt16(listenPortString) else {
|
||||
throw ParseError.interfaceHasInvalidListenPort(listenPortString)
|
||||
}
|
||||
interface.listenPort = listenPort
|
||||
}
|
||||
if let addressesString = attributes["address"] {
|
||||
var addresses = [IPAddressRange]()
|
||||
for addressString in addressesString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
guard let address = IPAddressRange(from: addressString) else {
|
||||
throw ParseError.interfaceHasInvalidAddress(addressString)
|
||||
}
|
||||
addresses.append(address)
|
||||
}
|
||||
interface.addresses = addresses
|
||||
}
|
||||
if let dnsString = attributes["dns"] {
|
||||
var dnsServers = [DNSServer]()
|
||||
var dnsSearch = [String]()
|
||||
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
if let dnsServer = DNSServer(from: dnsServerString) {
|
||||
dnsServers.append(dnsServer)
|
||||
} else {
|
||||
dnsSearch.append(dnsServerString)
|
||||
}
|
||||
}
|
||||
interface.dns = dnsServers
|
||||
interface.dnsSearch = dnsSearch
|
||||
}
|
||||
if let mtuString = attributes["mtu"] {
|
||||
guard let mtu = UInt16(mtuString) else {
|
||||
throw ParseError.interfaceHasInvalidMTU(mtuString)
|
||||
}
|
||||
interface.mtu = mtu
|
||||
}
|
||||
return interface
|
||||
}
|
||||
|
||||
private static func collate(peerAttributes attributes: [String: String]) throws -> PeerConfiguration {
|
||||
guard let publicKeyString = attributes["publickey"] else {
|
||||
throw ParseError.peerHasNoPublicKey
|
||||
}
|
||||
guard let publicKey = PublicKey(base64Key: publicKeyString) else {
|
||||
throw ParseError.peerHasInvalidPublicKey(publicKeyString)
|
||||
}
|
||||
var peer = PeerConfiguration(publicKey: publicKey)
|
||||
if let preSharedKeyString = attributes["presharedkey"] {
|
||||
guard let preSharedKey = PreSharedKey(base64Key: preSharedKeyString) else {
|
||||
throw ParseError.peerHasInvalidPreSharedKey(preSharedKeyString)
|
||||
}
|
||||
peer.preSharedKey = preSharedKey
|
||||
}
|
||||
if let allowedIPsString = attributes["allowedips"] {
|
||||
var allowedIPs = [IPAddressRange]()
|
||||
for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
guard let allowedIP = IPAddressRange(from: allowedIPString) else {
|
||||
throw ParseError.peerHasInvalidAllowedIP(allowedIPString)
|
||||
}
|
||||
allowedIPs.append(allowedIP)
|
||||
}
|
||||
peer.allowedIPs = allowedIPs
|
||||
}
|
||||
if let endpointString = attributes["endpoint"] {
|
||||
guard let endpoint = Endpoint(from: endpointString) else {
|
||||
throw ParseError.peerHasInvalidEndpoint(endpointString)
|
||||
}
|
||||
peer.endpoint = endpoint
|
||||
}
|
||||
if let persistentKeepAliveString = attributes["persistentkeepalive"] {
|
||||
guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else {
|
||||
throw ParseError.peerHasInvalidPersistentKeepAlive(persistentKeepAliveString)
|
||||
}
|
||||
peer.persistentKeepAlive = persistentKeepAlive
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
535
ios/Tunnel/obfuscator/config.c
Normal file
@@ -0,0 +1,535 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <ctype.h>
|
||||
#include "config.h"
|
||||
#include "wg-obfuscator.h"
|
||||
#include "mini_argp.h"
|
||||
#include "masking.h"
|
||||
|
||||
// Executable name
|
||||
static const char *arg0;
|
||||
|
||||
/* The options we understand. */
|
||||
static const mini_argp_opt options[] = {
|
||||
{ "help", '?', 0 },
|
||||
{ "config", 'c', 1 },
|
||||
{ "source-if", 'i', 1 },
|
||||
{ "source", 's', 1 },
|
||||
{ "source-lport", 'p', 1 },
|
||||
{ "target-if", 'o', 1 },
|
||||
{ "target", 't', 1 },
|
||||
{ "target-lport", 'r', 1 },
|
||||
{ "key", 'k', 1 },
|
||||
{ "masking", 'a', 1 },
|
||||
{ "static-bindings", 'b', 1 },
|
||||
{ "max-clients" , 'm', 1 },
|
||||
{ "idle-timeout", 'l', 1 },
|
||||
{ "in-timeout", 'n', 1 },
|
||||
{ "max-dummy", 'd', 1 },
|
||||
{ "fwmark", 'f', 1 },
|
||||
{ "allow-clean", 'e', 0 },
|
||||
{ "verbose", 'v', 1 },
|
||||
{ "log-file", 'L', 1 },
|
||||
{ "log-timestamps", 'T', 1 },
|
||||
{ "resolve-interval", 'R', 1 },
|
||||
{ 0 }
|
||||
};
|
||||
|
||||
static void show_usage(void)
|
||||
{
|
||||
printf("Usage: %s [options]\n%s", arg0,
|
||||
" -?, --help Give this help list\n"
|
||||
"\n"
|
||||
"Main settings:\n"
|
||||
" -c, --config=<config_file> Read configuration from file\n"
|
||||
" (can be used instead of the rest arguments)\n"
|
||||
" -i, --source-if=<ip> Source interface to listen on\n"
|
||||
" (optional, default - 0.0.0.0, e.g. all)\n"
|
||||
" -p, --source-lport=<port> Source port to listen\n"
|
||||
" -t, --target=<ip>:<port> Target IP and port\n"
|
||||
" -k, --key=<key> Obfuscation key \n"
|
||||
" (required, must be 1-255 characters long)\n"
|
||||
" -a, --masking=<type> Masking type (optional, default - AUTO)\n"
|
||||
" Supported values: STUN, AUTO, NONE\n"
|
||||
" -b, --static-bindings=<ip>:<port>:<port>,...\n"
|
||||
" Comma-separated static bindings for two-way mode\n"
|
||||
" as <client_ip>:<client_port>:<forward_port>\n"
|
||||
" You can also repeat this option.\n"
|
||||
" -f, --fwmark=<mark> Firewall mark to set on all packets\n"
|
||||
" (optional, default - 0, e.g. disabled)\n"
|
||||
" -v, --verbose=<level> Verbosity level (optional, default - INFO)\n"
|
||||
" ERRORS (critical errors only)\n"
|
||||
" WARNINGS (important messages)\n"
|
||||
" 2 - INFO (informational messages: status messages,\n"
|
||||
" connection established, etc.)\n"
|
||||
" 3 - DEBUG (detailed debug messages)\n"
|
||||
" 4 - TRACE (very detailed debug messages, including\n"
|
||||
" packet dumps)\n"
|
||||
" -L, --log-file=<path> Write the log to this file instead of stderr\n"
|
||||
" (optional, the file is reopened on SIGHUP)\n"
|
||||
" -T, --log-timestamps=<val> Prefix log lines with a timestamp\n"
|
||||
" (optional, default - AUTO, e.g. only when\n"
|
||||
" writing to a log file)\n"
|
||||
" Supported values: AUTO, TRUE, FALSE\n"
|
||||
"\n"
|
||||
"Additional options:\n"
|
||||
" -m, --max-clients=<number> Maximum number of clients (default: 1024)\n"
|
||||
" -l, --idle-timeout=<sec> Idle timeout in seconds (default: 300)\n"
|
||||
" -n, --in-timeout=<sec> Incoming timeout in seconds (default: 0 - disabled)\n"
|
||||
" -d, --max-dummy=<bytes> Maximum length of dummy bytes for data packets\n"
|
||||
" (default: 4)\n"
|
||||
" -e, --allow-clean For servers, allow non-obfuscated incoming connections\n"
|
||||
" -R, --resolve-interval=<sec>\n"
|
||||
" Re-resolve target and static-binding hostnames\n"
|
||||
" every N seconds (default: 0 - disabled).\n"
|
||||
" SIGHUP always triggers a refresh.\n"
|
||||
" If non-zero, a failed resolve at startup is retried\n"
|
||||
" instead of exiting.\n");
|
||||
}
|
||||
|
||||
static int parse_opt(const char *lname, char sname, const char *val, void *ctx);
|
||||
|
||||
/**
|
||||
* @brief Resets the configuration structure to its default values.
|
||||
*
|
||||
* This function clears the configuration structure by setting all fields to zero
|
||||
* and resetting the verbosity level to the default value.
|
||||
*
|
||||
* @param config Pointer to the obfuscator_config structure to be reset.
|
||||
*/
|
||||
static void reset_config(obfuscator_config_t *config)
|
||||
{
|
||||
if (config->static_bindings) {
|
||||
free(config->static_bindings);
|
||||
}
|
||||
memset(config, 0, sizeof(*config));
|
||||
config->max_clients = MAX_CLIENTS_DEFAULT;
|
||||
config->idle_timeout = IDLE_TIMEOUT_DEFAULT;
|
||||
config->in_timeout = IN_TIMEOUT_DEFAULT;
|
||||
config->max_dummy_length_data = MAX_DUMMY_LENGTH_DATA_DEFAULT;
|
||||
config->log_timestamps = -1; // auto
|
||||
verbose = LL_DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given string represents a valid integer.
|
||||
*
|
||||
* @param str Pointer to the null-terminated string to check.
|
||||
* @return Non-zero value if the string is a valid integer, 0 otherwise.
|
||||
*/
|
||||
static uint8_t is_integer(const char *str)
|
||||
{
|
||||
if (!str || !*str) {
|
||||
return 0; // Empty string is not an integer
|
||||
}
|
||||
while (*str) {
|
||||
if (!isdigit((unsigned char)*str)) {
|
||||
return 0; // Non-digit character found
|
||||
}
|
||||
str++;
|
||||
}
|
||||
return 1; // All characters are digits
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a boolean value from a string.
|
||||
*
|
||||
* @param str Pointer to the null-terminated string to parse.
|
||||
* @return 1 for true values ("true", "yes", "on", "1"),
|
||||
* 0 for false values ("false", "no", "off", "0"),
|
||||
* -1 if the string is not a valid boolean value.
|
||||
*/
|
||||
static int parse_bool(const char *str)
|
||||
{
|
||||
char lower[16];
|
||||
if (!str || !*str || strlen(str) >= sizeof(lower)) {
|
||||
return -1;
|
||||
}
|
||||
strcpy(lower, str);
|
||||
for (char *p = lower; *p; ++p) *p = tolower((unsigned char)*p);
|
||||
if (!strcmp(lower, "true") || !strcmp(lower, "yes") || !strcmp(lower, "on") || !strcmp(lower, "1")) {
|
||||
return 1;
|
||||
}
|
||||
if (!strcmp(lower, "false") || !strcmp(lower, "no") || !strcmp(lower, "off") || !strcmp(lower, "0")) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Reads and processes the configuration file.
|
||||
*
|
||||
* This function opens the specified configuration file and parses its contents
|
||||
* to initialize or update the application's configuration settings.
|
||||
*
|
||||
* @param filename The path to the configuration file to be read.
|
||||
* @param config Pointer to the obfuscator_config structure where the parsed settings will be stored.
|
||||
*/
|
||||
static void read_config_file(const char *filename, obfuscator_config_t *config)
|
||||
{
|
||||
// Read configuration from the file
|
||||
uint8_t first_section = 1; // Flag to indicate if this is the first section being processed
|
||||
char line[10 * 1024]; // Buffer to hold each line from the config file
|
||||
|
||||
FILE *config_file = fopen(filename, "r");
|
||||
if (config_file == NULL) {
|
||||
perror("Can't open config file");
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
|
||||
while (fgets(line, sizeof(line), config_file)) {
|
||||
// Remove trailing newlines, carriage returns, spaces and tabs
|
||||
while (strlen(line) && (line[strlen(line) - 1] == '\n' || line[strlen(line) - 1] == '\r'
|
||||
|| line[strlen(line) - 1] == ' ' || line[strlen(line) - 1] == '\t')) {
|
||||
line[strlen(line) - 1] = 0;
|
||||
}
|
||||
// Remove leading spaces and tabs
|
||||
while (strlen(line) && (line[0] == ' ' || line[0] == '\t')) {
|
||||
memmove(line, line + 1, strlen(line));
|
||||
}
|
||||
// Ignore comments
|
||||
char *comment_index = strstr(line, "#");
|
||||
if (comment_index != NULL) {
|
||||
*comment_index = 0;
|
||||
}
|
||||
// Skip empty lines or with spaces only
|
||||
size_t line_len = strlen(line);
|
||||
if (strspn(line, " \t\r\n") == line_len) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// It can be new section
|
||||
if (line_len > 0 && line[0] == '[' && line[line_len - 1] == ']') {
|
||||
if (!first_section) {
|
||||
// new config, need to fork the process
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
log(LL_ERROR, "Can't fork a new instance - %s (%d)", strerror(errno), errno);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
if (pid == 0) {
|
||||
// Close in the child process
|
||||
fclose(config_file);
|
||||
// Stop config file processing for this instance
|
||||
return;
|
||||
}
|
||||
// Remember the instance to be able to forward signals to it
|
||||
register_child_instance(pid);
|
||||
}
|
||||
size_t len = strlen(line) - 2;
|
||||
if (len > sizeof(section_name) - 1) {
|
||||
len = sizeof(section_name) - 1;
|
||||
}
|
||||
strncpy(section_name, line + 1, len);
|
||||
section_name[len] = 0;
|
||||
|
||||
// Reset all the parameters
|
||||
reset_config(config);
|
||||
|
||||
first_section = 0; // We have processed the first section
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key-value pairs
|
||||
char *key = strtok(line, "=");
|
||||
key = trim(key);
|
||||
while (strlen(key) && (key[strlen(key) - 1] == ' ' || key[strlen(key) - 1] == '\t' || key[strlen(key) - 1] == '\r' || key[strlen(key) - 1] == '\n')) {
|
||||
key[strlen(key) - 1] = 0;
|
||||
}
|
||||
char *value = strtok(NULL, "=");
|
||||
if (value == NULL) {
|
||||
log(LL_ERROR, "Invalid configuration line: %s", line);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
value = trim(value);
|
||||
if (!*value) {
|
||||
log(LL_ERROR, "Invalid configuration line: %s", line);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
const mini_argp_opt *o = margp_find(options, key, 0);
|
||||
if (o == NULL) {
|
||||
log(LL_ERROR, "Unknown configuration key: %s", key);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
if (!o->has_arg) {
|
||||
// Flag options are represented as booleans in the config file
|
||||
int b = parse_bool(value);
|
||||
if (b < 0) {
|
||||
log(LL_ERROR, "Configuration key '%s' accepts only boolean values (true/yes/on/1 or false/no/off/0)", key);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
if (!b) {
|
||||
continue;
|
||||
}
|
||||
value = NULL;
|
||||
}
|
||||
parse_opt(o->long_name, o->short_name, value, config);
|
||||
}
|
||||
fclose(config_file);
|
||||
}
|
||||
|
||||
/* Parse a single option. */
|
||||
static int parse_opt(const char *lname, char sname, const char *val, void *ctx)
|
||||
{
|
||||
obfuscator_config_t *config = (obfuscator_config_t *)ctx;
|
||||
char val_lower[16];
|
||||
|
||||
switch (sname)
|
||||
{
|
||||
case '?':
|
||||
// Show usage and exit
|
||||
show_usage();
|
||||
exit(EXIT_SUCCESS);
|
||||
case 'c':
|
||||
read_config_file(val, config);
|
||||
break;
|
||||
case 'i':
|
||||
strncpy(config->client_interface, val, sizeof(config->client_interface) - 1);
|
||||
config->client_interface[sizeof(config->client_interface) - 1] = 0; // Ensure null-termination
|
||||
config->client_interface_set = 1;
|
||||
break;
|
||||
case 'p':
|
||||
if (!is_integer(val)) {
|
||||
log(LL_ERROR, "Invalid source port: %s (must be an integer)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->listen_port = atoi(val);
|
||||
if (config->listen_port <= 0 || config->listen_port > 65535) {
|
||||
log(LL_ERROR, "Invalid listen port: %s (must be between 1 and 65535)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->listen_port_set = 1;
|
||||
break;
|
||||
case 't':
|
||||
strncpy(config->forward_host_port, val, sizeof(config->forward_host_port) - 1);
|
||||
config->forward_host_port[sizeof(config->forward_host_port) - 1] = 0; // Ensure null-termination
|
||||
config->forward_host_port_set = 1;
|
||||
break;
|
||||
case 'b':
|
||||
{
|
||||
char *new_bindings;
|
||||
if (!config->static_bindings) {
|
||||
new_bindings = strdup(val);
|
||||
} else {
|
||||
size_t old_len = strlen(config->static_bindings);
|
||||
new_bindings = realloc(config->static_bindings, old_len + strlen(val) + 2);
|
||||
if (new_bindings) {
|
||||
new_bindings[old_len] = ',';
|
||||
strcpy(new_bindings + old_len + 1, val);
|
||||
}
|
||||
}
|
||||
if (!new_bindings) {
|
||||
log(LL_ERROR, "Out of memory while parsing static-bindings");
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->static_bindings = new_bindings;
|
||||
config->static_bindings_set = 1;
|
||||
}
|
||||
break;
|
||||
case 'k':
|
||||
strncpy(config->xor_key, val, sizeof(config->xor_key));
|
||||
config->xor_key[sizeof(config->xor_key) - 1] = 0; // Ensure null-termination
|
||||
if (strlen(config->xor_key) == 0) {
|
||||
log(LL_ERROR, "XOR key cannot be empty");
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->xor_key_set = 1;
|
||||
break;
|
||||
case 'm':
|
||||
if (!is_integer(val)) {
|
||||
log(LL_ERROR, "Invalid maximum number of clients: %s (must be an integer)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->max_clients = atoi(val);
|
||||
if (config->max_clients <= 0) {
|
||||
log(LL_ERROR, "Invalid maximum number of clients: %s (must be greater than 0)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
break;
|
||||
case 'l':
|
||||
if (!is_integer(val)) {
|
||||
log(LL_ERROR, "Invalid idle timeout: %s (must be an integer)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->idle_timeout = atol(val);
|
||||
if (config->idle_timeout <= 0) {
|
||||
log(LL_ERROR, "Invalid idle timeout: %s (must be greater than 0)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->idle_timeout *= 1000; // Convert to milliseconds
|
||||
break;
|
||||
case 'n':
|
||||
if (!is_integer(val)) {
|
||||
log(LL_ERROR, "Invalid incoming timeout: %s (must be an integer)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->in_timeout = atol(val);
|
||||
if (config->in_timeout <= 0) {
|
||||
log(LL_ERROR, "Invalid incoming timeout: %s (must be greater than 0)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->in_timeout *= 1000; // Convert to milliseconds
|
||||
break;
|
||||
case 'd':
|
||||
if (!is_integer(val)) {
|
||||
log(LL_ERROR, "Invalid maximum dummy length for data packets: %s (must be an integer)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->max_dummy_length_data = atoi(val);
|
||||
if (config->max_dummy_length_data < 0 || config->max_dummy_length_data > MAX_DUMMY_LENGTH_TOTAL) {
|
||||
log(LL_ERROR, "Invalid maximum dummy length for data packets: %s (must be between 0 and %d)", val, MAX_DUMMY_LENGTH_TOTAL);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
break;
|
||||
case 'e':
|
||||
config->allow_clean = 1;
|
||||
break;
|
||||
case 'f':
|
||||
// parse string with decimal and hexadecimal support
|
||||
#ifdef __linux__
|
||||
{
|
||||
long int v = strtol(val, NULL, 0);
|
||||
if (v <= 0 || v > UINT16_MAX) {
|
||||
log(LL_ERROR, "Invalid firewall mark: %s", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->fwmark = (uint16_t)v;
|
||||
}
|
||||
#else
|
||||
log(LL_WARN, "Firewall mark is not supported on this platform");
|
||||
#endif
|
||||
break;
|
||||
case 'a':
|
||||
{
|
||||
strncpy(val_lower, val, sizeof(val_lower) - 1);
|
||||
val_lower[sizeof(val_lower) - 1] = 0;
|
||||
for (char *p = val_lower; *p; ++p) *p = tolower((unsigned char)*p);
|
||||
if (strcmp(val_lower, "none") == 0) {
|
||||
config->masking_handler = NULL;
|
||||
config->masking_handler_set = 1;
|
||||
break;
|
||||
}
|
||||
if (strcmp(val_lower, "auto") == 0) {
|
||||
config->masking_handler = NULL;
|
||||
config->masking_handler_set = 0;
|
||||
break;
|
||||
}
|
||||
masking_handler_t *handler = get_masking_handler_by_name(val_lower);
|
||||
if (handler == NULL) {
|
||||
log(LL_ERROR, "Unknown masking type: %s", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->masking_handler = handler;
|
||||
config->masking_handler_set = 1;
|
||||
}
|
||||
break;
|
||||
case 'v':
|
||||
strncpy(val_lower, val, sizeof(val_lower) - 1);
|
||||
val_lower[sizeof(val_lower) - 1] = 0;
|
||||
for (char *p = val_lower; *p; ++p) *p = tolower((unsigned char)*p);
|
||||
if (strcmp(val_lower, "error") == 0) {
|
||||
verbose = LL_ERROR;
|
||||
} else if (strcmp(val_lower, "warn") == 0) {
|
||||
verbose = LL_WARN;
|
||||
} else if (strcmp(val_lower, "info") == 0) {
|
||||
verbose = LL_INFO;
|
||||
} else if (strcmp(val_lower, "debug") == 0) {
|
||||
verbose = LL_DEBUG;
|
||||
} else if (strcmp(val_lower, "trace") == 0) {
|
||||
verbose = LL_TRACE;
|
||||
} else {
|
||||
// check if it's a number
|
||||
if (is_integer(val)) {
|
||||
verbose = atoi(val);
|
||||
if (verbose < 0 || verbose > 4) {
|
||||
log(LL_ERROR, "Invalid verbosity level: %s (must be one of 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE')", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
} else {
|
||||
log(LL_ERROR, "Invalid verbosity level: %s (must be one of 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE')", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'L':
|
||||
strncpy(config->log_file, val, sizeof(config->log_file) - 1);
|
||||
config->log_file[sizeof(config->log_file) - 1] = 0; // Ensure null-termination
|
||||
if (strlen(config->log_file) == 0) {
|
||||
log(LL_ERROR, "Log file path cannot be empty");
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->log_file_set = 1;
|
||||
break;
|
||||
case 'T':
|
||||
{
|
||||
strncpy(val_lower, val, sizeof(val_lower) - 1);
|
||||
val_lower[sizeof(val_lower) - 1] = 0;
|
||||
for (char *p = val_lower; *p; ++p) *p = tolower((unsigned char)*p);
|
||||
if (strcmp(val_lower, "auto") == 0) {
|
||||
config->log_timestamps = -1;
|
||||
break;
|
||||
}
|
||||
int b = parse_bool(val_lower);
|
||||
if (b < 0) {
|
||||
log(LL_ERROR, "Invalid log timestamps mode: %s (must be one of 'AUTO', 'TRUE', 'FALSE')", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->log_timestamps = b;
|
||||
}
|
||||
break;
|
||||
case 'R':
|
||||
if (!is_integer(val)) {
|
||||
log(LL_ERROR, "Invalid resolve interval: %s (must be an integer)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->resolve_interval = atol(val);
|
||||
if (config->resolve_interval < 0) {
|
||||
log(LL_ERROR, "Invalid resolve interval: %s (must be 0 or greater)", val);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
config->resolve_interval *= 1000; // Convert to milliseconds
|
||||
break;
|
||||
default:
|
||||
// should never happen
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int parse_config(int argc, char **argv, obfuscator_config_t *config)
|
||||
{
|
||||
/* Parse command line arguments */
|
||||
reset_config(config);
|
||||
arg0 = argv[0]; // Save the executable name
|
||||
if (argc == 1) {
|
||||
fprintf(stderr, "No arguments provided, use \"%s --help\" command for usage information\n", argv[0]);
|
||||
return -1;
|
||||
}
|
||||
if (mini_argp_parse(argc, argv, options, config, parse_opt) != 0) {
|
||||
fprintf(stderr, "Failed to parse command line arguments\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Removes leading and trailing whitespace characters from the input string.
|
||||
*
|
||||
* This function modifies the input string in place by trimming any whitespace
|
||||
* characters (such as spaces, tabs, or newlines) from both the beginning and end.
|
||||
*
|
||||
* @param s Pointer to the null-terminated string to be trimmed.
|
||||
* @return Pointer to the trimmed string.
|
||||
*/
|
||||
char *trim(char *s) {
|
||||
char *end;
|
||||
// Trim leading spaces, tabs, carriage returns and newlines
|
||||
while (*s && (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n')) s++;
|
||||
if (!*s) return s;
|
||||
// Trim trailing spaces, tabs, carriage returns and newlines
|
||||
end = s + strlen(s) - 1;
|
||||
while (end > s && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) *end-- = 0;
|
||||
return s;
|
||||
}
|
||||
9
ios/Tunnel/obfuscator/config.h
Normal file
@@ -0,0 +1,9 @@
|
||||
#ifndef _CONFIG_H_
|
||||
#define _CONFIG_H_
|
||||
|
||||
#include "wg-obfuscator.h"
|
||||
|
||||
char *trim(char *s);
|
||||
int parse_config(int argc, char **argv, obfuscator_config_t *config);
|
||||
|
||||
#endif // _CONFIG_H_
|
||||
258
ios/Tunnel/obfuscator/logging.c
Normal file
@@ -0,0 +1,258 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdarg.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include "wg-obfuscator.h"
|
||||
|
||||
#ifndef O_BINARY
|
||||
#define O_BINARY 0
|
||||
#endif
|
||||
|
||||
#ifndef STDERR_FILENO
|
||||
#define STDERR_FILENO 2
|
||||
#endif
|
||||
|
||||
// Log files may contain client addresses, so they are not world-readable
|
||||
#define LOG_FILE_MODE 0640
|
||||
|
||||
// File descriptor of the log file, -1 when logging to stderr
|
||||
static int log_fd = -1;
|
||||
// Path of the log file, kept to be able to reopen it on SIGHUP
|
||||
static char *log_path = NULL;
|
||||
// 1 if every line must be prefixed with a timestamp
|
||||
static int log_timestamps = 0;
|
||||
|
||||
/**
|
||||
* @brief Returns the single character representing a logging level.
|
||||
*/
|
||||
static char level_char(int level)
|
||||
{
|
||||
switch (level) {
|
||||
case LL_ERROR: return 'E';
|
||||
case LL_WARN: return 'W';
|
||||
case LL_INFO: return 'I';
|
||||
case LL_DEBUG: return 'D';
|
||||
case LL_TRACE: return 'T';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Writes the whole buffer to the descriptor, retrying on partial writes.
|
||||
*
|
||||
* @param fd Descriptor to write to.
|
||||
* @param buf Data to write.
|
||||
* @param len Number of bytes to write.
|
||||
*/
|
||||
static void write_all(int fd, const char *buf, size_t len)
|
||||
{
|
||||
while (len) {
|
||||
ssize_t written = write(fd, buf, len);
|
||||
if (written <= 0) {
|
||||
if (written < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
// Nothing sensible can be done about a broken log, drop the rest of the line
|
||||
return;
|
||||
}
|
||||
buf += written;
|
||||
len -= (size_t)written;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Appends formatted text to the buffer without ever overflowing it.
|
||||
*
|
||||
* @param buf Buffer to append to.
|
||||
* @param size Usable size of the buffer, including the terminating zero.
|
||||
* @param len Current length of the text in the buffer.
|
||||
* @param fmt Format string.
|
||||
* @param args Arguments for the format string.
|
||||
* @return New length of the text in the buffer.
|
||||
*/
|
||||
static size_t append_vfmt(char *buf, size_t size, size_t len, const char *fmt, va_list args)
|
||||
{
|
||||
int n;
|
||||
|
||||
if (len + 1 >= size) {
|
||||
return len;
|
||||
}
|
||||
n = vsnprintf(buf + len, size - len, fmt, args);
|
||||
if (n < 0) {
|
||||
return len;
|
||||
}
|
||||
if ((size_t)n >= size - len) {
|
||||
return size - 1; // truncated
|
||||
}
|
||||
return len + (size_t)n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Appends formatted text to the buffer without ever overflowing it.
|
||||
*
|
||||
* @param buf Buffer to append to.
|
||||
* @param size Usable size of the buffer, including the terminating zero.
|
||||
* @param len Current length of the text in the buffer.
|
||||
* @param fmt Format string.
|
||||
* @return New length of the text in the buffer.
|
||||
*/
|
||||
static size_t append_fmt(char *buf, size_t size, size_t len, const char *fmt, ...)
|
||||
{
|
||||
va_list args;
|
||||
|
||||
va_start(args, fmt);
|
||||
len = append_vfmt(buf, size, len, fmt, args);
|
||||
va_end(args);
|
||||
|
||||
return len;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Writes the common line prefix (timestamp and instance name) to the buffer.
|
||||
*
|
||||
* @param buf Buffer to write to.
|
||||
* @param size Usable size of the buffer, including the terminating zero.
|
||||
* @param level Logging level of the line.
|
||||
* @return Length of the prefix.
|
||||
*/
|
||||
static size_t log_prefix(char *buf, size_t size, int level)
|
||||
{
|
||||
size_t len = 0;
|
||||
|
||||
if (log_timestamps) {
|
||||
struct timespec ts;
|
||||
struct tm *tm;
|
||||
if (clock_gettime(CLOCK_REALTIME, &ts) == 0 && (tm = localtime(&ts.tv_sec)) != NULL) {
|
||||
len = strftime(buf, size, "%Y-%m-%d %H:%M:%S", tm);
|
||||
len = append_fmt(buf, size, len, ".%03ld ", (long)(ts.tv_nsec / 1000000));
|
||||
}
|
||||
}
|
||||
|
||||
return append_fmt(buf, size, len, "[%s][%c] ", section_name, level_char(level));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Terminates the line and writes it out with a single write() call.
|
||||
*
|
||||
* A single write() to a descriptor opened with O_APPEND keeps lines of different
|
||||
* instances from interleaving when they share the same log file.
|
||||
*
|
||||
* @param buf Buffer holding the line, must have one spare byte for the newline.
|
||||
* @param len Length of the line.
|
||||
*/
|
||||
static void log_emit(char *buf, size_t len)
|
||||
{
|
||||
buf[len++] = '\n';
|
||||
write_all(log_fd >= 0 ? log_fd : STDERR_FILENO, buf, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Writes a single log line.
|
||||
*
|
||||
* This is the implementation behind the log() macro, which is the one that
|
||||
* checks the verbosity level, so the line written here is unconditional.
|
||||
*
|
||||
* @param level Logging level of the line.
|
||||
* @param fmt Format string.
|
||||
*/
|
||||
void log_printf(int level, const char *fmt, ...)
|
||||
{
|
||||
char buf[LOG_LINE_MAX];
|
||||
va_list args;
|
||||
size_t len = log_prefix(buf, sizeof(buf) - 1, level);
|
||||
|
||||
va_start(args, fmt);
|
||||
len = append_vfmt(buf, sizeof(buf) - 1, len, fmt, args);
|
||||
va_end(args);
|
||||
|
||||
log_emit(buf, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Writes a hexadecimal dump of a buffer, one or more prefixed lines.
|
||||
*
|
||||
* @param level Logging level of the dump.
|
||||
* @param prefix Text to put right after the line prefix, e.g. a direction marker.
|
||||
* @param data Data to dump.
|
||||
* @param length Number of bytes to dump.
|
||||
*/
|
||||
void log_hexdump(int level, const char *prefix, const uint8_t *data, int length)
|
||||
{
|
||||
char buf[LOG_LINE_MAX];
|
||||
int i = 0;
|
||||
|
||||
if (verbose < level) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Long dumps are split into several lines, each one with its own prefix
|
||||
do {
|
||||
size_t len = log_prefix(buf, sizeof(buf) - 1, level);
|
||||
len = append_fmt(buf, sizeof(buf) - 1, len, "%s", prefix);
|
||||
while ((i < length) && (len + 3 < sizeof(buf) - 1)) {
|
||||
len = append_fmt(buf, sizeof(buf) - 1, len, "%02X ", data[i++]);
|
||||
}
|
||||
log_emit(buf, len);
|
||||
} while (i < length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Initializes logging.
|
||||
*
|
||||
* @param path Path of the log file or NULL to keep logging to stderr.
|
||||
* @param timestamps_mode 1 to force timestamps on, 0 to force them off,
|
||||
* negative to enable them only when logging to a file.
|
||||
*/
|
||||
void log_init(const char *path, int8_t timestamps_mode)
|
||||
{
|
||||
log_timestamps = (timestamps_mode >= 0) ? (timestamps_mode != 0) : (path != NULL);
|
||||
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
log_path = strdup(path);
|
||||
if (!log_path) {
|
||||
fprintf(stderr, "Out of memory while initializing the log file\n");
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
|
||||
log_fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_BINARY, LOG_FILE_MODE);
|
||||
if (log_fd < 0) {
|
||||
// Logging into nowhere is worse than not starting at all
|
||||
fprintf(stderr, "Can't open log file '%s' - %s (%d)\n", path, strerror(errno), errno);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
|
||||
// Make every log file start with the version, no matter the verbosity level
|
||||
log_printf(LL_INFO, "Starting %s", version_string());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Reopens the log file, to be called after the log has been rotated.
|
||||
*/
|
||||
void log_reopen(void)
|
||||
{
|
||||
int fd;
|
||||
|
||||
if (!log_path) {
|
||||
return;
|
||||
}
|
||||
|
||||
fd = open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_BINARY, LOG_FILE_MODE);
|
||||
if (fd < 0) {
|
||||
// Keep the old descriptor, losing the log completely would be worse
|
||||
fprintf(stderr, "Can't reopen log file '%s' - %s (%d)\n", log_path, strerror(errno), errno);
|
||||
return;
|
||||
}
|
||||
if (log_fd >= 0) {
|
||||
close(log_fd);
|
||||
}
|
||||
log_fd = fd;
|
||||
|
||||
log_printf(LL_INFO, "Log file reopened, %s", version_string());
|
||||
}
|
||||
170
ios/Tunnel/obfuscator/masking.c
Normal file
@@ -0,0 +1,170 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <ctype.h>
|
||||
#include "wg-obfuscator.h"
|
||||
#include "masking.h"
|
||||
#include "masking_handlers.h"
|
||||
|
||||
static _Thread_local struct {
|
||||
int listen_sock;
|
||||
struct sockaddr_in *sender_addr;
|
||||
int server_sock;
|
||||
} g_send_ctx;
|
||||
|
||||
static ssize_t send_to_client_cb(uint8_t *buffer, int length) {
|
||||
return sendto(g_send_ctx.listen_sock, buffer, length, 0, (struct sockaddr *)g_send_ctx.sender_addr, sizeof(*g_send_ctx.sender_addr));
|
||||
}
|
||||
|
||||
static ssize_t send_to_server_cb(uint8_t *buffer, int length) {
|
||||
return send(g_send_ctx.server_sock, buffer, length, 0);
|
||||
}
|
||||
|
||||
masking_handler_t * get_masking_handler_by_name(const char *name) {
|
||||
if (!name) return NULL;
|
||||
|
||||
for (int i = 0; masking_handlers[i]; ++i) {
|
||||
char handler_name_lower[MASKING_HANDLER_NAME_LEN];
|
||||
strncpy(handler_name_lower, masking_handlers[i]->name, sizeof(handler_name_lower) - 1);
|
||||
handler_name_lower[sizeof(handler_name_lower) - 1] = 0;
|
||||
for (char *p = handler_name_lower; *p; ++p) *p = tolower((unsigned char)*p);
|
||||
|
||||
if (strcmp(name, handler_name_lower) == 0) {
|
||||
return masking_handlers[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void masking_on_handshake_req_from_client(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *client_addr,
|
||||
struct sockaddr_in *server_addr) {
|
||||
if (!client->masking_handler || !client->masking_handler->on_handshake_req) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_send_ctx.listen_sock = listen_sock;
|
||||
g_send_ctx.sender_addr = &client->client_addr;
|
||||
g_send_ctx.server_sock = client->server_sock;
|
||||
client->masking_handler->on_handshake_req(config, client, DIR_CLIENT_TO_SERVER, client_addr, server_addr, send_to_client_cb, send_to_server_cb);
|
||||
}
|
||||
|
||||
void masking_on_handshake_req_from_server(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *client_addr,
|
||||
struct sockaddr_in *server_addr) {
|
||||
if (!client->masking_handler || !client->masking_handler->on_handshake_req) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_send_ctx.listen_sock = listen_sock;
|
||||
g_send_ctx.sender_addr = &client->client_addr;
|
||||
g_send_ctx.server_sock = client->server_sock;
|
||||
client->masking_handler->on_handshake_req(config, client, DIR_SERVER_TO_CLIENT, server_addr, client_addr, send_to_server_cb, send_to_client_cb);
|
||||
}
|
||||
|
||||
int masking_unwrap_from_client(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client, // can be NULL!
|
||||
int listen_sock,
|
||||
struct sockaddr_in *client_addr,
|
||||
struct sockaddr_in *server_addr,
|
||||
masking_handler_t **masking_handler_out) {
|
||||
g_send_ctx.listen_sock = listen_sock;
|
||||
g_send_ctx.sender_addr = client_addr;
|
||||
g_send_ctx.server_sock = client ? client->server_sock : 0;
|
||||
|
||||
if (!client && !config->masking_handler_set) {
|
||||
// Brueteforce detection of masking type if no client entry and no default masking handler
|
||||
for (int i = 0; masking_handlers[i]; ++i) {
|
||||
// A handler may move *buffer_ptr while unwrapping. If it does not match
|
||||
// (returns < 0), restore the pointer so the next handler sees the original data.
|
||||
uint8_t *saved_buffer = *buffer_ptr;
|
||||
int r = masking_handlers[i]->on_data_unwrap(buffer_ptr, length, config, NULL, DIR_CLIENT_TO_SERVER, client_addr, server_addr, send_to_client_cb, send_to_server_cb);
|
||||
if (r < 0) {
|
||||
*buffer_ptr = saved_buffer;
|
||||
continue;
|
||||
}
|
||||
// Found a matching masking handler
|
||||
log(LL_TRACE, "Autodetected masking handler for packet from %s:%d: %s", inet_ntoa(client_addr->sin_addr), ntohs(client_addr->sin_port), masking_handlers[i]->name);
|
||||
if (masking_handler_out) {
|
||||
*masking_handler_out = masking_handlers[i];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// No matching masking handler found
|
||||
if (masking_handler_out) {
|
||||
*masking_handler_out = NULL;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
masking_handler_t *handler = client ? client->masking_handler : config->masking_handler;
|
||||
if (!handler || !handler->on_data_unwrap) {
|
||||
return length; // no masking handler, nothing to do
|
||||
}
|
||||
|
||||
return handler->on_data_unwrap(buffer_ptr, length, config, client, DIR_CLIENT_TO_SERVER, client_addr, server_addr, send_to_client_cb, client ? send_to_server_cb : NULL);
|
||||
}
|
||||
|
||||
int masking_unwrap_from_server(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *server_addr) {
|
||||
if (!client->masking_handler || !client->masking_handler->on_data_unwrap) {
|
||||
return length; // no masking handler, nothing to do
|
||||
}
|
||||
|
||||
g_send_ctx.listen_sock = listen_sock;
|
||||
g_send_ctx.sender_addr = &client->client_addr;
|
||||
g_send_ctx.server_sock = client->server_sock;
|
||||
return client->masking_handler->on_data_unwrap(buffer_ptr, length, config, client, DIR_SERVER_TO_CLIENT, server_addr, &client->client_addr, send_to_server_cb, send_to_client_cb);
|
||||
}
|
||||
|
||||
int masking_data_wrap_to_client(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *server_addr) {
|
||||
if (!client->masking_handler || !client->masking_handler->on_data_wrap) {
|
||||
return length; // no masking handler, nothing to do
|
||||
}
|
||||
|
||||
g_send_ctx.listen_sock = listen_sock;
|
||||
g_send_ctx.sender_addr = &client->client_addr;
|
||||
g_send_ctx.server_sock = client->server_sock;
|
||||
return client->masking_handler->on_data_wrap(buffer_ptr, length, config, client, DIR_SERVER_TO_CLIENT, server_addr, &client->client_addr, send_to_server_cb, send_to_client_cb);
|
||||
}
|
||||
|
||||
int masking_data_wrap_to_server(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *server_addr) {
|
||||
if (!client->masking_handler || !client->masking_handler->on_data_wrap) {
|
||||
return length; // no masking handler, nothing to do
|
||||
}
|
||||
|
||||
g_send_ctx.listen_sock = listen_sock;
|
||||
g_send_ctx.sender_addr = &client->client_addr;
|
||||
g_send_ctx.server_sock = client->server_sock;
|
||||
return client->masking_handler->on_data_wrap(buffer_ptr, length, config, client, DIR_CLIENT_TO_SERVER, &client->client_addr, server_addr, send_to_client_cb, send_to_server_cb);
|
||||
}
|
||||
|
||||
void masking_on_timer(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *server_addr) {
|
||||
if (!client->masking_handler || !client->masking_handler->on_timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_send_ctx.listen_sock = listen_sock;
|
||||
g_send_ctx.sender_addr = &client->client_addr;
|
||||
g_send_ctx.server_sock = client->server_sock;
|
||||
client->masking_handler->on_timer(config, client, &client->client_addr, server_addr, send_to_client_cb, send_to_server_cb);
|
||||
}
|
||||
91
ios/Tunnel/obfuscator/masking.h
Normal file
@@ -0,0 +1,91 @@
|
||||
#ifndef _MASKING_H_
|
||||
#define _MASKING_H_
|
||||
|
||||
#include <stdint.h>
|
||||
#include <netinet/in.h>
|
||||
#include "wg-obfuscator.h"
|
||||
|
||||
#define MASKING_HANDLER_NAME_LEN 32
|
||||
|
||||
typedef ssize_t (*send_data_callback_t)(uint8_t *buffer, int length);
|
||||
|
||||
typedef void (*masking_event_handler_t)(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
direction_t direction,
|
||||
const struct sockaddr_in *src_addr,
|
||||
const struct sockaddr_in *dest_addr,
|
||||
send_data_callback_t send_back_callback,
|
||||
send_data_callback_t send_forward_callback);
|
||||
|
||||
typedef int (*masking_data_handler_t)(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
direction_t direction,
|
||||
const struct sockaddr_in *src_addr,
|
||||
const struct sockaddr_in *dest_addr,
|
||||
send_data_callback_t send_back_callback,
|
||||
send_data_callback_t send_forward_callback);
|
||||
|
||||
typedef void (*masking_timer_handler_t)(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
const struct sockaddr_in *client_addr,
|
||||
const struct sockaddr_in *server_addr,
|
||||
send_data_callback_t send_to_client_callback,
|
||||
send_data_callback_t send_to_server_callback);
|
||||
|
||||
struct masking_handler {
|
||||
char name[MASKING_HANDLER_NAME_LEN];
|
||||
masking_event_handler_t on_handshake_req;
|
||||
masking_data_handler_t on_data_wrap;
|
||||
masking_data_handler_t on_data_unwrap;
|
||||
masking_timer_handler_t on_timer;
|
||||
uint32_t timer_interval_s;
|
||||
};
|
||||
typedef struct masking_handler masking_handler_t;
|
||||
|
||||
masking_handler_t * get_masking_handler_by_name(const char *name);
|
||||
|
||||
void masking_on_handshake_req_from_client(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *client_addr,
|
||||
struct sockaddr_in *server_addr);
|
||||
|
||||
void masking_on_handshake_req_from_server(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *client_addr,
|
||||
struct sockaddr_in *server_addr);
|
||||
|
||||
int masking_data_wrap_to_client(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *server_addr);
|
||||
|
||||
int masking_data_wrap_to_server(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *server_addr);
|
||||
|
||||
int masking_unwrap_from_client(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *client_addr,
|
||||
struct sockaddr_in *server_addr,
|
||||
masking_handler_t **masking_handler_out);
|
||||
|
||||
int masking_unwrap_from_server(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *server_addr);
|
||||
|
||||
void masking_on_timer(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
int listen_sock,
|
||||
struct sockaddr_in *server_addr);
|
||||
|
||||
#endif // _MASKING_H_
|
||||
14
ios/Tunnel/obfuscator/masking_handlers.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#ifndef _MASKING_HANDLERS_H_
|
||||
#define _MASKING_HANDLERS_H_
|
||||
|
||||
#include "masking.h"
|
||||
|
||||
/* List of available masking handlers */
|
||||
#include "masking_stun.h"
|
||||
|
||||
static masking_handler_t * const masking_handlers[] = {
|
||||
&stun_masking_handler,
|
||||
NULL
|
||||
};
|
||||
|
||||
#endif
|
||||
298
ios/Tunnel/obfuscator/masking_stun.c
Normal file
@@ -0,0 +1,298 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <assert.h>
|
||||
#include "wg-obfuscator.h"
|
||||
#include "masking.h"
|
||||
#include "masking_stun.h"
|
||||
|
||||
// Bytes prepended in front of the payload when wrapping data in a STUN Data Indication
|
||||
// (20-byte STUN header + 4-byte attribute header). Must fit into PREBUFFER_SIZE so that
|
||||
// wrapping can be done by shifting the buffer pointer instead of moving the payload.
|
||||
#define STUN_WRAP_OVERHEAD 24
|
||||
_Static_assert(STUN_WRAP_OVERHEAD <= PREBUFFER_SIZE,
|
||||
"PREBUFFER_SIZE is too small to hold the STUN wrap header");
|
||||
|
||||
static void rand_bytes(uint8_t* p, size_t n){ for(size_t i=0;i<n;i++) p[i]=rand()&0xFF; }
|
||||
|
||||
int stun_check_magic(const uint8_t *buf, size_t len) {
|
||||
if (!buf || len < 8) return 0;
|
||||
return !memcmp(buf + 4, COOKIE_BE, 4);
|
||||
}
|
||||
|
||||
uint16_t stun_peek_type(const uint8_t *buf){
|
||||
return (buf[0] << 8) | buf[1];
|
||||
}
|
||||
|
||||
static int stun_write_header(uint8_t *b, uint16_t type, uint16_t mlen, const uint8_t txid[12]) {
|
||||
b[0] = type >> 8;
|
||||
b[1] = type & 0xFF;
|
||||
b[2] = mlen >> 8;
|
||||
b[3] = mlen & 0xFF;
|
||||
memcpy(b + 4, COOKIE_BE, 4);
|
||||
memcpy(b + 8, txid, 12);
|
||||
return 20;
|
||||
}
|
||||
|
||||
static int stun_attr_xor_mapped_addr(uint8_t *b, const struct sockaddr_in *src) {
|
||||
// type,len
|
||||
b[0] = STUN_ATTR_XORMAPPED >> 8;
|
||||
b[1] = STUN_ATTR_XORMAPPED & 0xFF;
|
||||
b[2] = 0;
|
||||
b[3] = 8; // len=8 (family + port + addr)
|
||||
b[4] = 0;
|
||||
b[5] = 0x01; // family IPv4
|
||||
memcpy(b + 6, &src->sin_port, 2); // network-order bytes
|
||||
((uint8_t*)b)[6] ^= COOKIE_BE[0];
|
||||
((uint8_t*)b)[7] ^= COOKIE_BE[1];
|
||||
memcpy(b + 8, &src->sin_addr.s_addr, 4); // network-order bytes
|
||||
((uint8_t*)b)[8] ^= COOKIE_BE[0];
|
||||
((uint8_t*)b)[9] ^= COOKIE_BE[1];
|
||||
((uint8_t*)b)[10] ^= COOKIE_BE[2];
|
||||
((uint8_t*)b)[11] ^= COOKIE_BE[3];
|
||||
return 12; // 4 hdr + 8 val
|
||||
}
|
||||
|
||||
/*
|
||||
static int stun_attr_software(uint8_t *b, const char *s) {
|
||||
uint16_t n = (uint16_t)strlen(s);
|
||||
uint16_t pad = (4 - (n & 3)) & 3;
|
||||
if (b) {
|
||||
b[0] = STUN_ATTR_SOFTWARE >> 8;
|
||||
b[1] = STUN_ATTR_SOFTWARE&0xFF;
|
||||
b[2] = n>>8;
|
||||
b[3] = n&0xFF;
|
||||
memcpy(b+4,s,n);
|
||||
if (pad) memset(b+4+n,0,pad);
|
||||
}
|
||||
return 4 + n + pad;
|
||||
}
|
||||
*/
|
||||
|
||||
static uint32_t crc32(const uint8_t *p, size_t n) {
|
||||
uint32_t crc = ~0u;
|
||||
for(size_t i = 0; i < n; i++){
|
||||
crc ^= p[i];
|
||||
for(int k = 0; k < 8; k++)
|
||||
crc = (crc >> 1) ^ (0xEDB88320u & (-(int)(crc & 1)));
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
static int stun_attr_fingerprint(uint8_t *pkt, size_t cur_len) {
|
||||
uint8_t *b = pkt + cur_len;
|
||||
b[0] = STUN_ATTR_FINGERPR >> 8;
|
||||
b[1] = STUN_ATTR_FINGERPR & 0xFF;
|
||||
b[2] = 0;
|
||||
b[3] = 4;
|
||||
uint32_t fp = htonl(crc32(pkt, cur_len) ^ 0x5354554eu);
|
||||
memcpy(b + 4, &fp, 4);
|
||||
return 8; // 4 hdr + 4 val
|
||||
}
|
||||
|
||||
static int stun_build_binding_request(uint8_t *out) {
|
||||
uint8_t txid[12];
|
||||
rand_bytes(txid, 12);
|
||||
stun_write_header(out, STUN_BINDING_REQ, 0, txid);
|
||||
size_t mlen = 0;
|
||||
// optional SOFTWARE
|
||||
//mlen += stun_attr_software(out+20+mlen, "wgo/1.0");
|
||||
mlen += stun_attr_fingerprint(out, 20 + mlen);
|
||||
out[2] = (mlen >> 8) & 0xFF;
|
||||
out[3] = mlen & 0xFF;
|
||||
return (int)(20 + mlen);
|
||||
}
|
||||
|
||||
static int stun_build_binding_success(uint8_t *out,
|
||||
const uint8_t txid[12],
|
||||
const struct sockaddr_in *src) {
|
||||
if (BUFFER_SIZE < 20 + 12 + 8) return -1; // header + XOR-MAPPED-ADDRESS + fingerprint
|
||||
stun_write_header(out, STUN_BINDING_RESP, 0, (uint8_t*)txid);
|
||||
size_t mlen = 0;
|
||||
mlen += stun_attr_xor_mapped_addr(out + 20 + mlen, src);
|
||||
// optional SOFTWARE
|
||||
//mlen += stun_attr_software(out+20+mlen, "wgo/1.0");
|
||||
mlen += stun_attr_fingerprint(out, 20 + mlen);
|
||||
out[2] = (mlen >> 8) & 0xFF;
|
||||
out[3] = mlen & 0xFF;
|
||||
return (int)(20 + mlen);
|
||||
}
|
||||
|
||||
static int stun_wrap(uint8_t **buf_ptr, size_t data_len) {
|
||||
size_t mlen = 0;
|
||||
|
||||
// The caller guarantees the payload already fits into the main buffer (it was
|
||||
// received/encoded within BUFFER_SIZE), so the wrapped packet cannot overflow
|
||||
// the tail of full_buffer. The attribute length field is also 16-bit only.
|
||||
// Note: BUFFER_SIZE (65535) equals the maximum value of a 16-bit unsigned
|
||||
// integer (0xFFFF), so this single check covers both the buffer limit and
|
||||
// the STUN attribute length field limit.
|
||||
if (data_len > BUFFER_SIZE) {
|
||||
log(LL_WARN, "Can't wrap data in STUN, data too large (%zu bytes)", data_len);
|
||||
return -ENOMEM;
|
||||
}
|
||||
|
||||
// Header is prepended into the prebuffer (checked at compile time via _Static_assert).
|
||||
*buf_ptr -= STUN_WRAP_OVERHEAD;
|
||||
uint8_t *buf = *buf_ptr;
|
||||
|
||||
uint8_t txid[12];
|
||||
rand_bytes(txid,12);
|
||||
mlen += stun_write_header(buf, STUN_TYPE_DATA_IND, 0, txid);
|
||||
|
||||
buf[mlen] = STUN_ATTR_DATA >> 8;
|
||||
buf[mlen + 1] = STUN_ATTR_DATA & 0xFF;
|
||||
buf[mlen + 2] = data_len >> 8;
|
||||
buf[mlen + 3] = data_len & 0xFF;
|
||||
|
||||
return STUN_WRAP_OVERHEAD + data_len;
|
||||
}
|
||||
|
||||
static int stun_unwrap(uint8_t **buf_ptr, size_t len) {
|
||||
uint8_t *buf = *buf_ptr;
|
||||
if (len < 20 + 4) return -1; // header + attr
|
||||
|
||||
uint16_t msg_type = (buf[0] << 8) | buf[1];
|
||||
if (msg_type != STUN_TYPE_DATA_IND) return -1;
|
||||
|
||||
uint16_t msg_len = (buf[2] << 8) | buf[3];
|
||||
if (msg_len + 20 > len) return -1;
|
||||
|
||||
uint16_t attr_type = (buf[20] << 8) | buf[21];
|
||||
if (attr_type != STUN_ATTR_DATA) return -1;
|
||||
|
||||
uint16_t data_len = (buf[22] << 8) | buf[23];
|
||||
if (data_len + 24 > len) return -1;
|
||||
|
||||
// Move the buffer pointer to avoid moving the data
|
||||
*buf_ptr += 24;
|
||||
|
||||
return data_len;
|
||||
}
|
||||
|
||||
static void stun_on_handshake_req(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
direction_t direction,
|
||||
const struct sockaddr_in *src_addr,
|
||||
const struct sockaddr_in *dest_addr,
|
||||
send_data_callback_t send_back_callback,
|
||||
send_data_callback_t send_forward_callback) {
|
||||
uint8_t buffer[128];
|
||||
int len = stun_build_binding_request(buffer);
|
||||
if (len < 0) return;
|
||||
|
||||
int sent = send_forward_callback(buffer, len);
|
||||
if (sent < 0) {
|
||||
serror("Can't send STUN binding request to %s:%d", inet_ntoa(dest_addr->sin_addr), ntohs(dest_addr->sin_port));
|
||||
} else if (sent != len) {
|
||||
log(LL_WARN, "Partial send of STUN binding request to %s:%d (%d of %d bytes)", inet_ntoa(dest_addr->sin_addr), ntohs(dest_addr->sin_port), sent, len);
|
||||
} else {
|
||||
log(LL_TRACE, "Sent STUN binding request (%d bytes) to %s:%d", len, inet_ntoa(dest_addr->sin_addr), ntohs(dest_addr->sin_port));
|
||||
}
|
||||
}
|
||||
|
||||
static int stun_on_data_unwrap(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
direction_t direction,
|
||||
const struct sockaddr_in *src_addr,
|
||||
const struct sockaddr_in *dest_addr,
|
||||
send_data_callback_t send_back_callback,
|
||||
send_data_callback_t send_forward_callback) {
|
||||
if (!stun_check_magic(*buffer_ptr, length)) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
uint16_t stun_type = stun_peek_type(*buffer_ptr);
|
||||
|
||||
switch (stun_type) {
|
||||
case STUN_BINDING_REQ: {
|
||||
// Received STUN Binding Request from client, send Binding Success Response
|
||||
log(LL_TRACE, "Received STUN Binding Request from %s:%d", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
|
||||
uint8_t txid[12];
|
||||
memcpy(txid, (*buffer_ptr) + 8, 12);
|
||||
int resp_len = stun_build_binding_success(*buffer_ptr, txid, src_addr);
|
||||
if (resp_len > 0) {
|
||||
int sent = send_back_callback(*buffer_ptr, resp_len);
|
||||
if (sent < 0) {
|
||||
serror("sendto STUN response to %s:%d", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
|
||||
} else if (sent != resp_len) {
|
||||
log(LL_WARN, "Partial send of STUN Binding Success Response to %s:%d (%d of %d bytes)", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port), sent, resp_len);
|
||||
} else {
|
||||
log(LL_TRACE, "Sent STUN Binding Success Response (%d bytes) to %s:%d", resp_len, inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
|
||||
}
|
||||
} else {
|
||||
log(LL_ERROR, "Failed to build STUN Binding Success Response");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case STUN_BINDING_RESP:
|
||||
log(LL_TRACE, "Received STUN Binding Success Response from %s:%d, ignoring", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
|
||||
return 0;
|
||||
case STUN_TYPE_DATA_IND:
|
||||
length = stun_unwrap(buffer_ptr, length);
|
||||
if (length < 0) {
|
||||
log(LL_DEBUG, "Failed to unwrap STUN Data Indication from %s:%d", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
|
||||
return length;
|
||||
}
|
||||
log(LL_TRACE, "Unwrapped STUN Data Indication from %s:%d (%d bytes)", inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port), length);
|
||||
return length;
|
||||
default:
|
||||
log(LL_DEBUG, "Received unknown STUN type %04X from %s:%d, ignoring", stun_type, inet_ntoa(src_addr->sin_addr), ntohs(src_addr->sin_port));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static int stun_on_data_wrap(uint8_t **buffer_ptr, int length,
|
||||
obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
direction_t direction,
|
||||
const struct sockaddr_in *src_addr,
|
||||
const struct sockaddr_in *dest_addr,
|
||||
send_data_callback_t send_back_callback,
|
||||
send_data_callback_t send_forward_callback) {
|
||||
return stun_wrap(buffer_ptr, length);
|
||||
}
|
||||
|
||||
static void stun_on_timer(obfuscator_config_t *config,
|
||||
client_entry_t *client,
|
||||
const struct sockaddr_in *client_addr,
|
||||
const struct sockaddr_in *server_addr,
|
||||
send_data_callback_t send_to_client_callback,
|
||||
send_data_callback_t send_to_server_callback) {
|
||||
uint8_t buffer[128];
|
||||
int len = stun_build_binding_request(buffer);
|
||||
if (len < 0) return;
|
||||
|
||||
if (client->client_obfuscated) {
|
||||
int sent = send_to_client_callback(buffer, len);
|
||||
if (sent < 0) {
|
||||
serror("STUN binding request to client");
|
||||
} else if (sent != len) {
|
||||
log(LL_WARN, "Partial send of STUN binding request to client (%d of %d bytes)", sent, len);
|
||||
} else {
|
||||
log(LL_TRACE, "Sent STUN binding request (%d bytes) to %s:%d", len, inet_ntoa(client_addr->sin_addr), ntohs(client_addr->sin_port));
|
||||
}
|
||||
}
|
||||
if (client->server_obfuscated) {
|
||||
int sent = send_to_server_callback(buffer, len);
|
||||
if (sent < 0) {
|
||||
serror("STUN binding request to server");
|
||||
} else if (sent != len) {
|
||||
log(LL_WARN, "Partial send of STUN binding request to server (%d of %d bytes)", sent, len);
|
||||
} else {
|
||||
log(LL_TRACE, "Sent STUN binding request (%d bytes) to %s:%d", len, inet_ntoa(server_addr->sin_addr), ntohs(server_addr->sin_port));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
masking_handler_t stun_masking_handler = {
|
||||
.name = "STUN",
|
||||
.on_handshake_req = stun_on_handshake_req,
|
||||
.on_data_wrap = stun_on_data_wrap,
|
||||
.on_data_unwrap = stun_on_data_unwrap,
|
||||
.on_timer = stun_on_timer,
|
||||
.timer_interval_s = 10, // 10 seconds
|
||||
};
|
||||
19
ios/Tunnel/obfuscator/masking_stun.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#ifndef _MASKING_STUN_H_
|
||||
#define _MASKING_STUN_H_
|
||||
|
||||
#include <stdint.h>
|
||||
#include <netinet/in.h>
|
||||
#include "wg-obfuscator.h"
|
||||
|
||||
static const uint8_t COOKIE_BE[4] = {0x21,0x12,0xA4,0x42};
|
||||
#define STUN_TYPE_DATA_IND 0x0115
|
||||
#define STUN_BINDING_REQ 0x0001
|
||||
#define STUN_BINDING_RESP 0x0101
|
||||
#define STUN_ATTR_XORMAPPED 0x0020
|
||||
#define STUN_ATTR_SOFTWARE 0x8022
|
||||
#define STUN_ATTR_FINGERPR 0x8028
|
||||
#define STUN_ATTR_DATA 0x0013
|
||||
|
||||
extern masking_handler_t stun_masking_handler;
|
||||
|
||||
#endif // _MASKING_STUN_H_
|
||||
82
ios/Tunnel/obfuscator/mini_argp.h
Normal file
@@ -0,0 +1,82 @@
|
||||
/* mini_argp.h
|
||||
* Tiny callback-based CLI parser */
|
||||
|
||||
#ifndef _MINI_ARGP_H_
|
||||
#define _MINI_ARGP_H_
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef int (*mini_argp_cb)(const char *lname, char sname,
|
||||
const char *val, void *ctx);
|
||||
|
||||
typedef struct {
|
||||
const char *long_name; /* "--target" or NULL */
|
||||
char short_name; /* 't' or 0 */
|
||||
int has_arg; /* 0 flag, 1 needs val */
|
||||
mini_argp_cb cb; /* your handler */
|
||||
} mini_argp_opt;
|
||||
|
||||
/* internal: find desc by name */
|
||||
static const mini_argp_opt *
|
||||
margp_find(const mini_argp_opt *o, char *lname, char sname)
|
||||
{
|
||||
for (; o->long_name || o->short_name; o++)
|
||||
if ((lname && o->long_name && !strcmp(lname,o->long_name)) ||
|
||||
(sname && o->short_name==sname))
|
||||
return o;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int mini_argp_parse(int argc, char **argv,
|
||||
const mini_argp_opt *opts,
|
||||
void *ctx,
|
||||
mini_argp_cb cb)
|
||||
{
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
char *arg = argv[i];
|
||||
|
||||
/* long option: --foo or --foo=bar */
|
||||
if (!strncmp(arg, "--", 2)) {
|
||||
char *name = arg + 2, *val = NULL;
|
||||
if ((val = strchr(name, '='))) { *val = 0; ++val; }
|
||||
|
||||
const mini_argp_opt *o = margp_find(opts, name, 0);
|
||||
if (!o) { fprintf(stderr,"unknown --%s\n", name); return -1; }
|
||||
if (o->has_arg && !val) {
|
||||
if (++i == argc) { fprintf(stderr,"--%s needs value\n",name);return -1; }
|
||||
val = argv[i];
|
||||
}
|
||||
if (!o->has_arg) val = NULL;
|
||||
mini_argp_cb to_call = o->cb ? o->cb : cb;
|
||||
if (to_call(o->long_name, o->short_name, val, ctx)) return -1;
|
||||
}
|
||||
|
||||
/* short(s): -a, -fVAL, -xzvf */
|
||||
else if (arg[0]=='-' && arg[1]) {
|
||||
for (size_t pos=1; arg[pos]; ++pos) {
|
||||
char c = arg[pos];
|
||||
const mini_argp_opt *o = margp_find(opts,NULL,c);
|
||||
if (!o){ fprintf(stderr,"unknown -%c\n",c); return -1;}
|
||||
const char *val = NULL;
|
||||
|
||||
if (o->has_arg) {
|
||||
if (arg[pos+1]) val = &arg[pos+1]; /* -fVAL */
|
||||
else if (++i < argc) val = argv[i]; /* -f VAL */
|
||||
else { fprintf(stderr,"-%c needs value\n",c); return -1;}
|
||||
/* Stop further char-scanning: the rest of the arg is the value */
|
||||
pos = strlen(arg)-1;
|
||||
}
|
||||
mini_argp_cb to_call = o->cb ? o->cb : cb;
|
||||
if (to_call(o->long_name, o->short_name, val, ctx)) return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* positional not allowed */
|
||||
else {
|
||||
fprintf(stderr,"unexpected arg: %s\n",arg); return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif/* _MINI_ARGP_H_ */
|
||||
130
ios/Tunnel/obfuscator/obfuscation.c
Normal file
@@ -0,0 +1,130 @@
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "wg-obfuscator.h"
|
||||
#include "obfuscation.h"
|
||||
|
||||
static uint8_t crc_a[256]; // step(crc, 0): contribution of the current CRC state
|
||||
static uint8_t crc_b[256]; // step(0, inbyte): contribution of the input byte
|
||||
static uint8_t tables_ready = 0;
|
||||
|
||||
typedef struct {
|
||||
uint8_t *data; // cached keystream bytes, NULL until first allocation
|
||||
int valid; // number of valid bytes in data
|
||||
int cap; // allocated capacity of data
|
||||
uint8_t crc; // CRC state right after the 'valid' bytes
|
||||
} keystream_row_t;
|
||||
|
||||
static keystream_row_t rows[256];
|
||||
|
||||
// Single step of the original bit-by-bit CRC8 update, kept only to build the tables.
|
||||
static uint8_t crc8_step(uint8_t crc, uint8_t inbyte) {
|
||||
for (uint8_t j = 0; j < 8; j++) {
|
||||
uint8_t mix = (crc ^ inbyte) & 0x01;
|
||||
crc >>= 1;
|
||||
if (mix) {
|
||||
crc ^= 0x8C;
|
||||
}
|
||||
inbyte >>= 1;
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
static void ensure_tables(void) {
|
||||
if (tables_ready) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < 256; i++) {
|
||||
crc_a[i] = crc8_step((uint8_t)i, 0);
|
||||
crc_b[i] = crc8_step(0, (uint8_t)i);
|
||||
}
|
||||
tables_ready = 1;
|
||||
}
|
||||
|
||||
// Ensure the given row holds at least 'need' valid keystream bytes (capped at
|
||||
// KEYSTREAM_ROW_MAX). On allocation failure the row is left as large as it could
|
||||
// grow; the caller then computes the missing tail on the fly.
|
||||
static void grow_row(keystream_row_t *row, int cls, int need, const char *key, int key_length) {
|
||||
if (need > KEYSTREAM_ROW_MAX) {
|
||||
need = KEYSTREAM_ROW_MAX;
|
||||
}
|
||||
if (row->valid >= need) {
|
||||
return;
|
||||
}
|
||||
if (row->cap < need) {
|
||||
int newcap = (need + 255) & ~255; // round up to a 256-byte chunk
|
||||
if (newcap > KEYSTREAM_ROW_MAX) {
|
||||
newcap = KEYSTREAM_ROW_MAX;
|
||||
}
|
||||
uint8_t *p = realloc(row->data, newcap);
|
||||
if (!p) {
|
||||
return; // keep whatever we already have; tail handled by caller
|
||||
}
|
||||
row->data = p;
|
||||
row->cap = newcap;
|
||||
}
|
||||
|
||||
uint8_t crc = row->crc;
|
||||
int ki = row->valid % key_length;
|
||||
for (int i = row->valid; i < need; i++) {
|
||||
uint8_t inbyte = (uint8_t)(key[ki] + cls + key_length);
|
||||
crc = crc_a[crc] ^ crc_b[inbyte];
|
||||
row->data[i] = crc;
|
||||
if (++ki == key_length) {
|
||||
ki = 0;
|
||||
}
|
||||
}
|
||||
row->valid = need;
|
||||
row->crc = crc;
|
||||
}
|
||||
|
||||
// XOR 'n' keystream bytes into dst, 8 bytes at a time. memcpy avoids both
|
||||
// strict-aliasing violations and unaligned-access assumptions.
|
||||
static void xor_block(uint8_t *dst, const uint8_t *ks, int n) {
|
||||
int i = 0;
|
||||
for (; i + 8 <= n; i += 8) {
|
||||
uint64_t a, b;
|
||||
memcpy(&a, dst + i, 8);
|
||||
memcpy(&b, ks + i, 8);
|
||||
a ^= b;
|
||||
memcpy(dst + i, &a, 8);
|
||||
}
|
||||
for (; i < n; i++) {
|
||||
dst[i] ^= ks[i];
|
||||
}
|
||||
}
|
||||
|
||||
void xor_data(uint8_t *buffer, int length, char *key, int key_length) {
|
||||
if (length <= 0) {
|
||||
return;
|
||||
}
|
||||
ensure_tables();
|
||||
|
||||
int cls = length & 0xFF;
|
||||
keystream_row_t *row = &rows[cls];
|
||||
|
||||
int cached = (length <= KEYSTREAM_ROW_MAX) ? length : KEYSTREAM_ROW_MAX;
|
||||
if (row->valid < cached) {
|
||||
grow_row(row, cls, cached, key, key_length);
|
||||
}
|
||||
|
||||
int n = (length < row->valid) ? length : row->valid;
|
||||
if (n > 0) {
|
||||
xor_block(buffer, row->data, n);
|
||||
}
|
||||
|
||||
// Tail beyond the cache limit (or beyond what we could allocate): compute on
|
||||
// the fly, continuing the CRC chain from the last cached state.
|
||||
if (n < length) {
|
||||
uint8_t crc = row->crc;
|
||||
int ki = n % key_length;
|
||||
for (int i = n; i < length; i++) {
|
||||
uint8_t inbyte = (uint8_t)(key[ki] + cls + key_length);
|
||||
crc = crc_a[crc] ^ crc_b[inbyte];
|
||||
buffer[i] ^= crc;
|
||||
if (++ki == key_length) {
|
||||
ki = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
ios/Tunnel/obfuscator/obfuscation.h
Normal file
@@ -0,0 +1,139 @@
|
||||
#ifndef _OBFUSCATION_H_
|
||||
#define _OBFUSCATION_H_
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Current obfuscation version
|
||||
#define OBFUSCATION_VERSION 1
|
||||
|
||||
// Maximum length (in bytes) of a single cached keystream row.
|
||||
// The keystream depends only on (key, length mod 256), so it is cached in 256
|
||||
// lazily-grown rows. Packets longer than this limit are still handled correctly:
|
||||
// the part beyond the limit is computed on the fly. Worst-case cache memory is
|
||||
// 256 * KEYSTREAM_ROW_MAX bytes. 2048 covers a typical MTU plus masking overhead.
|
||||
#define KEYSTREAM_ROW_MAX 2048
|
||||
|
||||
// WireGuard packet types
|
||||
#define WG_TYPE_HANDSHAKE 0x01
|
||||
#define WG_TYPE_HANDSHAKE_RESP 0x02
|
||||
#define WG_TYPE_COOKIE 0x03
|
||||
#define WG_TYPE_DATA 0x04
|
||||
|
||||
#define WG_TYPE(data) ((uint32_t)(data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24)))
|
||||
#ifndef MIN
|
||||
#define MIN(a, b) ((a) < (b) ? (a) : (b))
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Checks if the given data is obfuscated.
|
||||
*
|
||||
* @param data Pointer to the data buffer to check.
|
||||
* @return uint8_t Returns a non-zero value if the data is obfuscated, 0 otherwise.
|
||||
*/
|
||||
static inline uint8_t is_obfuscated(uint8_t *data) {
|
||||
uint32_t packet_type = WG_TYPE(data);
|
||||
return !(packet_type >= 1 && packet_type <= 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief XORs the data in the given buffer with the key-derived keystream.
|
||||
*
|
||||
* The keystream is a per-byte CRC8 value derived from the key, the byte position
|
||||
* and the packet length; it depends only on (key, length mod 256), so it is
|
||||
* cached (see obfuscation.c). The on-the-wire result is byte-for-byte identical
|
||||
* to the original bit-by-bit implementation.
|
||||
*
|
||||
* @param buffer Pointer to the data buffer to be XORed.
|
||||
* @param length Length of the data buffer in bytes.
|
||||
* @param key Pointer to the key used for XOR operation.
|
||||
* @param key_length Length of the key in bytes.
|
||||
*/
|
||||
void xor_data(uint8_t *buffer, int length, char *key, int key_length);
|
||||
|
||||
/**
|
||||
* @brief Encodes the given buffer using the specified key and version.
|
||||
*
|
||||
* This function applies an encoding algorithm to the input buffer using the provided key and version.
|
||||
* WARNING: buffer must be at least 4 bytes long and aligned to 4 bytes.
|
||||
*
|
||||
* @param buffer Pointer to the data buffer to encode.
|
||||
* @param length Length of the data buffer in bytes.
|
||||
* @param key Pointer to the key used for encoding.
|
||||
* @param key_length Length of the key in bytes.
|
||||
* @param version Encoding version to use.
|
||||
* @param max_dummy_length_data Maximum length of dummy data for data packets.
|
||||
* @return 0 on success, or a negative value on error.
|
||||
*/
|
||||
static inline int encode(uint8_t *buffer, int length, char *key, int key_length, uint8_t version, int max_dummy_length_data) {
|
||||
if (version >= 1) {
|
||||
uint32_t packet_type = WG_TYPE(buffer);
|
||||
// Add some randomness to the packet
|
||||
uint8_t rnd = 1 + (rand() % 255);
|
||||
buffer[0] ^= rnd; // Xor the first byte to a random value
|
||||
buffer[1] = rnd; // Set the second byte to a random value
|
||||
// Add dummy data to the packet
|
||||
if (length < MAX_DUMMY_LENGTH_TOTAL) {
|
||||
uint16_t dummy_length = 0;
|
||||
uint16_t max_dummy_length = MAX_DUMMY_LENGTH_TOTAL - length;
|
||||
switch (packet_type) {
|
||||
case WG_TYPE_HANDSHAKE:
|
||||
case WG_TYPE_HANDSHAKE_RESP:
|
||||
// length to MAX_DUMMY_LENGTH_HANDSHAKE
|
||||
dummy_length = rand() % MIN(max_dummy_length, MAX_DUMMY_LENGTH_HANDSHAKE);
|
||||
break;
|
||||
case WG_TYPE_COOKIE:
|
||||
case WG_TYPE_DATA:
|
||||
// length to MAX_DUMMY_LENGTH_HANDSHAKE
|
||||
if (max_dummy_length_data) {
|
||||
dummy_length = rand() % MIN(max_dummy_length, max_dummy_length_data);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
//assert(0);
|
||||
break;
|
||||
}
|
||||
buffer[2] = dummy_length & 0xFF; // Set the dummy length in the packet
|
||||
buffer[3] = dummy_length >> 8; // Set the dummy length in
|
||||
if (dummy_length > 0) {
|
||||
int i = length;
|
||||
length += dummy_length;
|
||||
for (; i < length; ++i) {
|
||||
buffer[i] = 0xFF; // Fill with FFs, random data is not needed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xor_data(buffer, length, key, key_length);
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the given buffer using the provided key.
|
||||
*
|
||||
* WARNING: buffer must be at least 4 bytes long and aligned to 4 bytes.
|
||||
*
|
||||
* @param buffer Pointer to the input buffer to decode.
|
||||
* @param length Length of the input buffer.
|
||||
* @param key Pointer to the key used for decoding.
|
||||
* @param key_length Length of the key.
|
||||
* @param version_out Pointer to a variable where the decoded version will be stored.
|
||||
* @return Length of the decoded data (smaller than or equal to the input length).
|
||||
*/
|
||||
static inline int decode(uint8_t *buffer, int length, char *key, int key_length, uint8_t *version_out) {
|
||||
xor_data(buffer, length, key, key_length);
|
||||
|
||||
if (!is_obfuscated(buffer)) {
|
||||
// Looks like an old version
|
||||
*version_out = 0;
|
||||
return length;
|
||||
}
|
||||
|
||||
buffer[0] ^= buffer[1]; // Restore the first byte by XORing it with the second byte
|
||||
length -= (uint16_t)(buffer[2] | (buffer[3] << 8)); // Remove dummy data length from the packet
|
||||
buffer[1] = buffer[2] = buffer[3] = 0; // Reset the dummy length field to 0
|
||||
return length;
|
||||
}
|
||||
|
||||
#endif // _OBFUSCATION_H_
|
||||
1137
ios/Tunnel/obfuscator/uthash.h
Normal file
1320
ios/Tunnel/obfuscator/wg-obfuscator.c
Normal file
135
ios/Tunnel/obfuscator/wg-obfuscator.h
Normal file
@@ -0,0 +1,135 @@
|
||||
#ifndef _WG_OBFUSCATOR_H_
|
||||
#define _WG_OBFUSCATOR_H_
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
#include "uthash.h"
|
||||
|
||||
// on Linux, use epoll for better performance
|
||||
#ifdef __linux__
|
||||
#define USE_EPOLL
|
||||
#endif
|
||||
|
||||
#ifdef USE_EPOLL
|
||||
#include <sys/epoll.h>
|
||||
#define MAX_EVENTS 1024
|
||||
#else
|
||||
#include <poll.h>
|
||||
#endif
|
||||
|
||||
#define WG_OBFUSCATOR_VERSION "1.6"
|
||||
#define WG_OBFUSCATOR_GIT_REPO "https://github.com/ClusterM/wg-obfuscator"
|
||||
|
||||
#define LL_DEFAULT LL_INFO // default logging level
|
||||
|
||||
// Main parameters
|
||||
// TODO: make these configurable via command line arguments or config file
|
||||
#define BUFFER_SIZE 65535 // size of the buffer for receiving data from the clients and server
|
||||
#define PREBUFFER_SIZE 1024 // size of the additional buffer size before the main buffer
|
||||
#define POLL_TIMEOUT 5000 // in milliseconds
|
||||
#define HANDSHAKE_TIMEOUT 5000 // in milliseconds
|
||||
#define ITERATE_INTERVAL 1000 // in milliseconds
|
||||
#define MAX_DUMMY_LENGTH_TOTAL 1024 // maximum length of a packet after dummy data extension
|
||||
#define MAX_DUMMY_LENGTH_HANDSHAKE 512 // maximum length of dummy data for handshake packets
|
||||
|
||||
#define MAX_CLIENTS_DEFAULT 1024 // maximum number of clients
|
||||
#define IDLE_TIMEOUT_DEFAULT 300000 // in milliseconds
|
||||
#define IN_TIMEOUT_DEFAULT 0 // in milliseconds
|
||||
#define MAX_DUMMY_LENGTH_DATA_DEFAULT 4 // maximum length of dummy data for data packets
|
||||
|
||||
// Default instance name
|
||||
#define DEFAULT_INSTANCE_NAME "main"
|
||||
|
||||
// Logging levels
|
||||
#define LL_ERROR 0
|
||||
#define LL_WARN 1
|
||||
#define LL_INFO 2
|
||||
#define LL_DEBUG 3
|
||||
#define LL_TRACE 4
|
||||
|
||||
// Maximum length of a single log line, longer hex dumps are split into several lines
|
||||
#define LOG_LINE_MAX 16384
|
||||
|
||||
#define log(level, fmt, ...) do { if (verbose >= (level)) log_printf((level), fmt, ##__VA_ARGS__); } while (0)
|
||||
#define serror_level(level, fmt, ...) log(level, fmt " - %s (%d)", ##__VA_ARGS__, strerror(errno), errno)
|
||||
#define serror(fmt, ...) serror_level(LL_ERROR, fmt, ##__VA_ARGS__)
|
||||
|
||||
// Direction of... something
|
||||
typedef enum {
|
||||
DIR_CLIENT_TO_SERVER = 0,
|
||||
DIR_SERVER_TO_CLIENT = 1,
|
||||
} direction_t;
|
||||
|
||||
struct masking_handler; // forward declaration
|
||||
typedef struct masking_handler masking_handler_t;
|
||||
|
||||
// Structure to hold obfuscator configuration
|
||||
typedef struct {
|
||||
int listen_port; // Listening port for the obfuscator
|
||||
char forward_host_port[256]; // Host and port to forward the data to
|
||||
char xor_key[256]; // Key for obfuscation
|
||||
char client_interface[256]; // Client interface as a string
|
||||
char *static_bindings; // Static bindings as a string
|
||||
int max_clients; // Maximum number of clients
|
||||
long idle_timeout; // Idle timeout in milliseconds
|
||||
long in_timeout; // Incoming timeout in milliseconds
|
||||
int max_dummy_length_data; // Maximum length of dummy data for data packets
|
||||
uint32_t fwmark; // Firewall mark
|
||||
masking_handler_t *masking_handler; // Masking handler to use
|
||||
uint8_t allow_clean; // 1 if non-obfuscated (clean) clients are allowed, 0 otherwise
|
||||
char log_file[512]; // Path of the log file
|
||||
int8_t log_timestamps; // 1 to force timestamps on, 0 to force them off, -1 for auto
|
||||
long resolve_interval; // Hostname re-resolve interval in milliseconds, 0 to disable periodic refresh
|
||||
|
||||
uint8_t log_file_set; // 1 if the log file is set, 0 otherwise
|
||||
uint8_t listen_port_set; // 1 if the listen port is set, 0 otherwise
|
||||
uint8_t forward_host_port_set; // 1 if the forward host and port are set, 0 otherwise
|
||||
uint8_t xor_key_set; // 1 if the XOR key is set, 0 otherwise
|
||||
uint8_t client_interface_set; // 1 if the client interface is set, 0 otherwise
|
||||
uint8_t static_bindings_set; // 1 if the static bindings are set, 0 otherwise
|
||||
uint8_t masking_handler_set; // 1 if the masking handler is set, 0 otherwise
|
||||
} obfuscator_config_t;
|
||||
|
||||
// Structure to hold client connection information
|
||||
typedef struct {
|
||||
struct sockaddr_in client_addr; // client address and port (key for the hash table)
|
||||
struct sockaddr_in our_addr; // our address and port on the server connection
|
||||
long last_activity_time; // last time we received data from/to this client
|
||||
long last_incoming_time; // last time we received data from the remote server
|
||||
long last_handshake_request_time; // last time we received a handshake request from/to this client
|
||||
long last_handshake_time; // last time we received a handshake response from/to this client
|
||||
long last_masking_timer_time; // last time we called the masking timer handler for this client
|
||||
int server_sock; // socket for the connection to the server
|
||||
uint8_t version; // obfuscation version
|
||||
masking_handler_t *masking_handler; // masking handler in use
|
||||
uint8_t handshaked : 1; // 1 if the handshake is complete, 0 otherwise
|
||||
uint8_t handshake_direction : 1; // 1 if the handshake is from client to server, 0 if from server to client
|
||||
uint8_t client_obfuscated : 1; // 1 if the client is obfuscated, 0 otherwise
|
||||
uint8_t server_obfuscated : 1; // 1 if the server is obfuscated, 0 otherwise
|
||||
uint8_t client_clean : 1; // 1 if the client speaks plain (non-obfuscated) WireGuard, traffic is passed through as is (allow-clean mode)
|
||||
uint8_t is_static : 1; // 1 if this is a static binding entry, 0 otherwise
|
||||
char bind_host[256]; // Original hostname of a static binding, empty if the address is a literal or the entry is dynamic
|
||||
UT_hash_handle hh;
|
||||
} client_entry_t;
|
||||
|
||||
// Verbosity level
|
||||
extern int verbose;
|
||||
// Section name (for multiple instances)
|
||||
extern char section_name[256];
|
||||
|
||||
const char *version_string(void);
|
||||
void print_version(void);
|
||||
void register_child_instance(pid_t pid);
|
||||
|
||||
void log_init(const char *path, int8_t timestamps_mode);
|
||||
void log_reopen(void);
|
||||
#ifdef __GNUC__
|
||||
void log_printf(int level, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
#else
|
||||
void log_printf(int level, const char *fmt, ...);
|
||||
#endif
|
||||
void log_hexdump(int level, const char *prefix, const uint8_t *data, int length);
|
||||
|
||||
#endif
|
||||
141
ios/Tunnel/pcarelay.c
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* pcarelay — in-process transport switch for the iOS packet tunnel.
|
||||
* wg-obfuscator (one instance, iOS forbids fork) sends to 127.0.0.1:<local>;
|
||||
* we forward either over UDP to the server's obfuscator port or over TCP to
|
||||
* the server's udptcp (frames: 2-byte length + packet). Switch with pcarelay_use_tcp().
|
||||
* Mode 2 ("Облако"): packets go to a Swift callback (WebSocket to the user's Keenetic), answers come back via pcarelay_ws_in().
|
||||
*/
|
||||
#include "pcarelay.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static atomic_int use_tcp = 0; /* 0 udp, 1 tcp, 2 ws */
|
||||
static pcarelay_ws_out_fn ws_out;
|
||||
static int g_local = -1;
|
||||
static pthread_mutex_t peer_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||
static struct sockaddr_storage g_peer;
|
||||
static socklen_t g_pl;
|
||||
static atomic_ullong rx_bytes = 0;
|
||||
static char g_host[256], g_udp_port[8], g_tcp_port[8];
|
||||
static int g_local_port;
|
||||
|
||||
static int dial(int socktype, const char *port) {
|
||||
struct addrinfo hints = {0}, *res, *r;
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = socktype;
|
||||
if (getaddrinfo(g_host, port, &hints, &res) != 0) return -1;
|
||||
int s = -1;
|
||||
for (r = res; r; r = r->ai_next) {
|
||||
s = socket(r->ai_family, r->ai_socktype, 0);
|
||||
if (s < 0) continue;
|
||||
if (connect(s, r->ai_addr, r->ai_addrlen) == 0) break;
|
||||
close(s); s = -1;
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
if (s >= 0 && socktype == SOCK_STREAM) {
|
||||
int one = 1;
|
||||
setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
|
||||
setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof one);
|
||||
setsockopt(s, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof one);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static int read_full(int fd, unsigned char *b, size_t n) {
|
||||
size_t got = 0;
|
||||
while (got < n) { ssize_t r = read(fd, b + got, n - got); if (r <= 0) return -1; got += (size_t)r; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void *loop(void *arg) {
|
||||
(void)arg;
|
||||
struct sockaddr_in la = {0};
|
||||
la.sin_family = AF_INET;
|
||||
la.sin_port = htons((uint16_t)g_local_port);
|
||||
la.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
int local = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (local < 0 || bind(local, (struct sockaddr *)&la, sizeof la) < 0) return NULL;
|
||||
g_local = local;
|
||||
int udp = -1, tcp = -1;
|
||||
struct sockaddr_storage peer; socklen_t pl = 0;
|
||||
unsigned char buf[65537];
|
||||
time_t last_dial = 0;
|
||||
for (;;) {
|
||||
int mode = atomic_load(&use_tcp), want_tcp = mode == 1;
|
||||
if (mode == 2) { if (udp >= 0) { close(udp); udp = -1; } if (tcp >= 0) { close(tcp); tcp = -1; } }
|
||||
if (!want_tcp && tcp >= 0) { close(tcp); tcp = -1; }
|
||||
if (want_tcp && udp >= 0) { close(udp); udp = -1; }
|
||||
if (mode == 0 && udp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); udp = dial(SOCK_DGRAM, g_udp_port); }
|
||||
int up = want_tcp ? tcp : udp;
|
||||
struct pollfd p[2] = {{local, POLLIN, 0}, {up, POLLIN, 0}};
|
||||
poll(p, up >= 0 ? 2 : 1, 1000);
|
||||
if (p[0].revents & POLLIN) {
|
||||
pl = sizeof peer;
|
||||
ssize_t n = recvfrom(local, buf, 65535, 0, (struct sockaddr *)&peer, &pl);
|
||||
if (n <= 0) continue;
|
||||
pthread_mutex_lock(&peer_mu); g_peer = peer; g_pl = pl; pthread_mutex_unlock(&peer_mu);
|
||||
if (mode == 2) {
|
||||
if (ws_out) ws_out(buf, (int)n);
|
||||
} else if (want_tcp) {
|
||||
if (tcp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); tcp = dial(SOCK_STREAM, g_tcp_port); }
|
||||
if (tcp >= 0) {
|
||||
unsigned char h[2] = {(unsigned char)(n >> 8), (unsigned char)n};
|
||||
if (send(tcp, h, 2, 0) != 2 || send(tcp, buf, (size_t)n, 0) != n) { close(tcp); tcp = -1; }
|
||||
}
|
||||
} else if (udp >= 0) {
|
||||
send(udp, buf, (size_t)n, 0);
|
||||
}
|
||||
}
|
||||
if (up >= 0 && (p[1].revents & (POLLIN | POLLHUP | POLLERR))) {
|
||||
ssize_t n;
|
||||
if (want_tcp) {
|
||||
unsigned char h[2];
|
||||
if (read_full(tcp, h, 2) < 0) { close(tcp); tcp = -1; continue; }
|
||||
n = (h[0] << 8) | h[1];
|
||||
if (n == 0 || read_full(tcp, buf, (size_t)n) < 0) { close(tcp); tcp = -1; continue; }
|
||||
} else {
|
||||
n = recv(udp, buf, 65535, 0);
|
||||
if (n <= 0) { close(udp); udp = -1; continue; }
|
||||
}
|
||||
atomic_fetch_add(&rx_bytes, (unsigned long long)n);
|
||||
if (pl) sendto(local, buf, (size_t)n, 0, (struct sockaddr *)&peer, pl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int pcarelay_start(int local_port, const char *host, int udp_port, int tcp_port) {
|
||||
g_local_port = local_port;
|
||||
snprintf(g_host, sizeof g_host, "%s", host);
|
||||
snprintf(g_udp_port, sizeof g_udp_port, "%d", udp_port);
|
||||
snprintf(g_tcp_port, sizeof g_tcp_port, "%d", tcp_port);
|
||||
pthread_t t;
|
||||
if (pthread_create(&t, NULL, loop, NULL) != 0) return -1;
|
||||
pthread_detach(t);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void pcarelay_use_tcp(int on) { atomic_store(&use_tcp, on ? 1 : 0); }
|
||||
int pcarelay_is_tcp(void) { return atomic_load(&use_tcp) == 1; }
|
||||
|
||||
void pcarelay_use_ws(pcarelay_ws_out_fn out) { ws_out = out; atomic_store(&use_tcp, 2); }
|
||||
int pcarelay_is_ws(void) { return atomic_load(&use_tcp) == 2; }
|
||||
|
||||
void pcarelay_ws_in(const unsigned char *p, int n) {
|
||||
if (g_local < 0 || n <= 0) return;
|
||||
atomic_fetch_add(&rx_bytes, (unsigned long long)n);
|
||||
pthread_mutex_lock(&peer_mu);
|
||||
struct sockaddr_storage peer = g_peer; socklen_t pl = g_pl;
|
||||
pthread_mutex_unlock(&peer_mu);
|
||||
if (pl) sendto(g_local, p, (size_t)n, 0, (struct sockaddr *)&peer, pl);
|
||||
}
|
||||
unsigned long long pcarelay_rx_bytes(void) { return atomic_load(&rx_bytes); }
|
||||
21
ios/Tunnel/pcarelay.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#ifndef PCARELAY_H
|
||||
#define PCARELAY_H
|
||||
|
||||
/* Start the transport switch thread: obfuscator -> 127.0.0.1:local_port -> host via UDP or TCP. */
|
||||
int pcarelay_start(int local_port, const char *host, int udp_port, int tcp_port);
|
||||
void pcarelay_use_tcp(int on);
|
||||
int pcarelay_is_tcp(void);
|
||||
/* Bytes received back from the server on any path (auto-detects a dead UDP). */
|
||||
unsigned long long pcarelay_rx_bytes(void);
|
||||
|
||||
/* Mode "Облако": each packet from the obfuscator goes to out() (Swift sends it over WebSocket);
|
||||
packets from the WebSocket are handed back with pcarelay_ws_in(). */
|
||||
typedef void (*pcarelay_ws_out_fn)(const unsigned char *p, int n);
|
||||
void pcarelay_use_ws(pcarelay_ws_out_fn out);
|
||||
int pcarelay_is_ws(void);
|
||||
void pcarelay_ws_in(const unsigned char *p, int n);
|
||||
|
||||
/* wg-obfuscator compiled in with -Dmain=wgobf_main (one instance per process). */
|
||||
int wgobf_main(int argc, char **argv);
|
||||
|
||||
#endif
|
||||
2
ios/Tunnel/ru_bypass.txt
Normal file
BIN
ios/WireGuardGo/iphoneos/libwg-go.a
Normal file
BIN
ios/WireGuardGo/iphonesimulator/libwg-go.a
Normal file
19
ios/WireGuardKit/COPYING
Normal file
@@ -0,0 +1,19 @@
|
||||
Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
15
ios/WireGuardKit/Package.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
// swift-tools-version:5.5
|
||||
// Vendored WireGuardKit (github.com/WireGuard/wireguard-apple, MIT). libwg-go.a is
|
||||
// prebuilt in ../WireGuardGo/<sdk>/ — the app target adds it to LIBRARY_SEARCH_PATHS.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "WireGuardKit",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [.library(name: "WireGuardKit", targets: ["WireGuardKit"])],
|
||||
targets: [
|
||||
.target(name: "WireGuardKit", dependencies: ["WireGuardKitGo", "WireGuardKitC"]),
|
||||
.target(name: "WireGuardKitC", publicHeadersPath: "."),
|
||||
.target(name: "WireGuardKitGo", publicHeadersPath: ".", linkerSettings: [.linkedLibrary("wg-go")]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Array {
|
||||
|
||||
/// Returns an array containing the results of mapping the given closure over the sequence’s
|
||||
/// elements concurrently.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - queue: The queue for performing concurrent computations.
|
||||
/// If the given queue is serial, the values are mapped in a serial fashion.
|
||||
/// Pass `nil` to perform computations on the current queue.
|
||||
/// - transform: the block to perform concurrent computations over the given element.
|
||||
/// - Returns: an array of concurrently computed values.
|
||||
func concurrentMap<U>(queue: DispatchQueue?, _ transform: (Element) -> U) -> [U] {
|
||||
var result = [U?](repeating: nil, count: self.count)
|
||||
let resultQueue = DispatchQueue(label: "ConcurrentMapQueue")
|
||||
|
||||
let execute = queue?.sync ?? { $0() }
|
||||
|
||||
execute {
|
||||
DispatchQueue.concurrentPerform(iterations: self.count) { index in
|
||||
let value = transform(self[index])
|
||||
resultQueue.sync {
|
||||
result[index] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.map { $0! }
|
||||
}
|
||||
}
|
||||
153
ios/WireGuardKit/Sources/WireGuardKit/DNSResolver.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Network
|
||||
import Foundation
|
||||
|
||||
enum DNSResolver {}
|
||||
|
||||
extension DNSResolver {
|
||||
|
||||
/// Concurrent queue used for DNS resolutions
|
||||
private static let resolverQueue = DispatchQueue(label: "DNSResolverQueue", qos: .default, attributes: .concurrent)
|
||||
|
||||
static func resolveSync(endpoints: [Endpoint?]) -> [Result<Endpoint, DNSResolutionError>?] {
|
||||
let isAllEndpointsAlreadyResolved = endpoints.allSatisfy { maybeEndpoint -> Bool in
|
||||
return maybeEndpoint?.hasHostAsIPAddress() ?? true
|
||||
}
|
||||
|
||||
if isAllEndpointsAlreadyResolved {
|
||||
return endpoints.map { endpoint in
|
||||
return endpoint.map { .success($0) }
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints.concurrentMap(queue: resolverQueue) { endpoint -> Result<Endpoint, DNSResolutionError>? in
|
||||
guard let endpoint = endpoint else { return nil }
|
||||
|
||||
if endpoint.hasHostAsIPAddress() {
|
||||
return .success(endpoint)
|
||||
} else {
|
||||
return Result { try DNSResolver.resolveSync(endpoint: endpoint) }
|
||||
.mapError { error -> DNSResolutionError in
|
||||
// swiftlint:disable:next force_cast
|
||||
return error as! DNSResolutionError
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveSync(endpoint: Endpoint) throws -> Endpoint {
|
||||
guard case .name(let name, _) = endpoint.host else {
|
||||
return endpoint
|
||||
}
|
||||
|
||||
var hints = addrinfo()
|
||||
hints.ai_flags = AI_ALL // We set this to ALL so that we get v4 addresses even on DNS64 networks
|
||||
hints.ai_family = AF_UNSPEC
|
||||
hints.ai_socktype = SOCK_DGRAM
|
||||
hints.ai_protocol = IPPROTO_UDP
|
||||
|
||||
var resultPointer: UnsafeMutablePointer<addrinfo>?
|
||||
defer {
|
||||
resultPointer.flatMap { freeaddrinfo($0) }
|
||||
}
|
||||
|
||||
let errorCode = getaddrinfo(name, "\(endpoint.port)", &hints, &resultPointer)
|
||||
if errorCode != 0 {
|
||||
throw DNSResolutionError(errorCode: errorCode, address: name)
|
||||
}
|
||||
|
||||
var ipv4Address: IPv4Address?
|
||||
var ipv6Address: IPv6Address?
|
||||
|
||||
var next: UnsafeMutablePointer<addrinfo>? = resultPointer
|
||||
let iterator = AnyIterator { () -> addrinfo? in
|
||||
let result = next?.pointee
|
||||
next = result?.ai_next
|
||||
return result
|
||||
}
|
||||
|
||||
for addrInfo in iterator {
|
||||
if let maybeIpv4Address = IPv4Address(addrInfo: addrInfo) {
|
||||
ipv4Address = maybeIpv4Address
|
||||
break // If we found an IPv4 address, we can stop
|
||||
} else if let maybeIpv6Address = IPv6Address(addrInfo: addrInfo) {
|
||||
ipv6Address = maybeIpv6Address
|
||||
continue // If we already have an IPv6 address, we can skip this one
|
||||
}
|
||||
}
|
||||
|
||||
// We prefer an IPv4 address over an IPv6 address
|
||||
if let ipv4Address = ipv4Address {
|
||||
return Endpoint(host: .ipv4(ipv4Address), port: endpoint.port)
|
||||
} else if let ipv6Address = ipv6Address {
|
||||
return Endpoint(host: .ipv6(ipv6Address), port: endpoint.port)
|
||||
} else {
|
||||
// Must never happen
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoint {
|
||||
func withReresolvedIP() throws -> Endpoint {
|
||||
#if os(iOS)
|
||||
let hostname: String
|
||||
switch host {
|
||||
case .name(let name, _):
|
||||
hostname = name
|
||||
case .ipv4(let address):
|
||||
hostname = "\(address)"
|
||||
case .ipv6(let address):
|
||||
hostname = "\(address)"
|
||||
@unknown default:
|
||||
fatalError()
|
||||
}
|
||||
|
||||
var hints = addrinfo()
|
||||
hints.ai_family = AF_UNSPEC
|
||||
hints.ai_socktype = SOCK_DGRAM
|
||||
hints.ai_protocol = IPPROTO_UDP
|
||||
hints.ai_flags = 0 // We set this to zero so that we actually resolve this using DNS64
|
||||
|
||||
var result: UnsafeMutablePointer<addrinfo>?
|
||||
defer {
|
||||
result.flatMap { freeaddrinfo($0) }
|
||||
}
|
||||
|
||||
let errorCode = getaddrinfo(hostname, "\(self.port)", &hints, &result)
|
||||
if errorCode != 0 {
|
||||
throw DNSResolutionError(errorCode: errorCode, address: hostname)
|
||||
}
|
||||
|
||||
let addrInfo = result!.pointee
|
||||
if let ipv4Address = IPv4Address(addrInfo: addrInfo) {
|
||||
return Endpoint(host: .ipv4(ipv4Address), port: port)
|
||||
} else if let ipv6Address = IPv6Address(addrInfo: addrInfo) {
|
||||
return Endpoint(host: .ipv6(ipv6Address), port: port)
|
||||
} else {
|
||||
fatalError()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return self
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// An error type describing DNS resolution error
|
||||
public struct DNSResolutionError: LocalizedError {
|
||||
public let errorCode: Int32
|
||||
public let address: String
|
||||
|
||||
init(errorCode: Int32, address: String) {
|
||||
self.errorCode = errorCode
|
||||
self.address = address
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
return String(cString: gai_strerror(errorCode))
|
||||
}
|
||||
}
|
||||
35
ios/WireGuardKit/Sources/WireGuardKit/DNSServer.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public struct DNSServer {
|
||||
public let address: IPAddress
|
||||
|
||||
public init(address: IPAddress) {
|
||||
self.address = address
|
||||
}
|
||||
}
|
||||
|
||||
extension DNSServer: Equatable {
|
||||
public static func == (lhs: DNSServer, rhs: DNSServer) -> Bool {
|
||||
return lhs.address.rawValue == rhs.address.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
extension DNSServer {
|
||||
public var stringRepresentation: String {
|
||||
return "\(address)"
|
||||
}
|
||||
|
||||
public init?(from addressString: String) {
|
||||
if let addr = IPv4Address(addressString) {
|
||||
address = addr
|
||||
} else if let addr = IPv6Address(addressString) {
|
||||
address = addr
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
101
ios/WireGuardKit/Sources/WireGuardKit/Endpoint.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public struct Endpoint {
|
||||
public let host: NWEndpoint.Host
|
||||
public let port: NWEndpoint.Port
|
||||
|
||||
public init(host: NWEndpoint.Host, port: NWEndpoint.Port) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoint: Equatable {
|
||||
public static func == (lhs: Endpoint, rhs: Endpoint) -> Bool {
|
||||
return lhs.host == rhs.host && lhs.port == rhs.port
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoint: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(host)
|
||||
hasher.combine(port)
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoint {
|
||||
public var stringRepresentation: String {
|
||||
switch host {
|
||||
case .name(let hostname, _):
|
||||
return "\(hostname):\(port)"
|
||||
case .ipv4(let address):
|
||||
return "\(address):\(port)"
|
||||
case .ipv6(let address):
|
||||
return "[\(address)]:\(port)"
|
||||
@unknown default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
|
||||
public init?(from string: String) {
|
||||
// Separation of host and port is based on 'parse_endpoint' function in
|
||||
// https://git.zx2c4.com/wireguard-tools/tree/src/config.c
|
||||
guard !string.isEmpty else { return nil }
|
||||
let startOfPort: String.Index
|
||||
let hostString: String
|
||||
if string.first! == "[" {
|
||||
// Look for IPv6-style endpoint, like [::1]:80
|
||||
let startOfHost = string.index(after: string.startIndex)
|
||||
guard let endOfHost = string.dropFirst().firstIndex(of: "]") else { return nil }
|
||||
let afterEndOfHost = string.index(after: endOfHost)
|
||||
if afterEndOfHost == string.endIndex { return nil }
|
||||
guard string[afterEndOfHost] == ":" else { return nil }
|
||||
startOfPort = string.index(after: afterEndOfHost)
|
||||
hostString = String(string[startOfHost ..< endOfHost])
|
||||
} else {
|
||||
// Look for an IPv4-style endpoint, like 127.0.0.1:80
|
||||
guard let endOfHost = string.firstIndex(of: ":") else { return nil }
|
||||
startOfPort = string.index(after: endOfHost)
|
||||
hostString = String(string[string.startIndex ..< endOfHost])
|
||||
}
|
||||
guard let endpointPort = NWEndpoint.Port(String(string[startOfPort ..< string.endIndex])) else { return nil }
|
||||
let invalidCharacterIndex = hostString.unicodeScalars.firstIndex { char in
|
||||
return !CharacterSet.urlHostAllowed.contains(char)
|
||||
}
|
||||
guard invalidCharacterIndex == nil else { return nil }
|
||||
host = NWEndpoint.Host(hostString)
|
||||
port = endpointPort
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoint {
|
||||
public func hasHostAsIPAddress() -> Bool {
|
||||
switch host {
|
||||
case .name:
|
||||
return false
|
||||
case .ipv4:
|
||||
return true
|
||||
case .ipv6:
|
||||
return true
|
||||
@unknown default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
|
||||
public func hostname() -> String? {
|
||||
switch host {
|
||||
case .name(let hostname, _):
|
||||
return hostname
|
||||
case .ipv4:
|
||||
return nil
|
||||
case .ipv6:
|
||||
return nil
|
||||
@unknown default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
extension IPv4Address {
|
||||
init?(addrInfo: addrinfo) {
|
||||
guard addrInfo.ai_family == AF_INET else { return nil }
|
||||
|
||||
let addressData = addrInfo.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { ptr -> Data in
|
||||
return Data(bytes: &ptr.pointee.sin_addr, count: MemoryLayout<in_addr>.size)
|
||||
}
|
||||
|
||||
self.init(addressData)
|
||||
}
|
||||
}
|
||||
|
||||
extension IPv6Address {
|
||||
init?(addrInfo: addrinfo) {
|
||||
guard addrInfo.ai_family == AF_INET6 else { return nil }
|
||||
|
||||
let addressData = addrInfo.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { ptr -> Data in
|
||||
return Data(bytes: &ptr.pointee.sin6_addr, count: MemoryLayout<in6_addr>.size)
|
||||
}
|
||||
|
||||
self.init(addressData)
|
||||
}
|
||||
}
|
||||
115
ios/WireGuardKit/Sources/WireGuardKit/IPAddressRange.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public struct IPAddressRange {
|
||||
public let address: IPAddress
|
||||
public let networkPrefixLength: UInt8
|
||||
|
||||
init(address: IPAddress, networkPrefixLength: UInt8) {
|
||||
self.address = address
|
||||
self.networkPrefixLength = networkPrefixLength
|
||||
}
|
||||
}
|
||||
|
||||
extension IPAddressRange: Equatable {
|
||||
public static func == (lhs: IPAddressRange, rhs: IPAddressRange) -> Bool {
|
||||
return lhs.address.rawValue == rhs.address.rawValue && lhs.networkPrefixLength == rhs.networkPrefixLength
|
||||
}
|
||||
}
|
||||
|
||||
extension IPAddressRange: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(address.rawValue)
|
||||
hasher.combine(networkPrefixLength)
|
||||
}
|
||||
}
|
||||
|
||||
extension IPAddressRange {
|
||||
public var stringRepresentation: String {
|
||||
return "\(address)/\(networkPrefixLength)"
|
||||
}
|
||||
|
||||
public init?(from string: String) {
|
||||
guard let parsed = IPAddressRange.parseAddressString(string) else { return nil }
|
||||
address = parsed.0
|
||||
networkPrefixLength = parsed.1
|
||||
}
|
||||
|
||||
private static func parseAddressString(_ string: String) -> (IPAddress, UInt8)? {
|
||||
let endOfIPAddress = string.lastIndex(of: "/") ?? string.endIndex
|
||||
let addressString = String(string[string.startIndex ..< endOfIPAddress])
|
||||
let address: IPAddress
|
||||
if let addr = IPv4Address(addressString) {
|
||||
address = addr
|
||||
} else if let addr = IPv6Address(addressString) {
|
||||
address = addr
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let maxNetworkPrefixLength: UInt8 = address is IPv4Address ? 32 : 128
|
||||
var networkPrefixLength: UInt8
|
||||
if endOfIPAddress < string.endIndex { // "/" was located
|
||||
let indexOfNetworkPrefixLength = string.index(after: endOfIPAddress)
|
||||
guard indexOfNetworkPrefixLength < string.endIndex else { return nil }
|
||||
let networkPrefixLengthSubstring = string[indexOfNetworkPrefixLength ..< string.endIndex]
|
||||
guard let npl = UInt8(networkPrefixLengthSubstring) else { return nil }
|
||||
networkPrefixLength = min(npl, maxNetworkPrefixLength)
|
||||
} else {
|
||||
networkPrefixLength = maxNetworkPrefixLength
|
||||
}
|
||||
|
||||
return (address, networkPrefixLength)
|
||||
}
|
||||
|
||||
public func subnetMask() -> IPAddress {
|
||||
if address is IPv4Address {
|
||||
let mask = networkPrefixLength > 0 ? ~UInt32(0) << (32 - networkPrefixLength) : UInt32(0)
|
||||
let bytes = Data([
|
||||
UInt8(truncatingIfNeeded: mask >> 24),
|
||||
UInt8(truncatingIfNeeded: mask >> 16),
|
||||
UInt8(truncatingIfNeeded: mask >> 8),
|
||||
UInt8(truncatingIfNeeded: mask >> 0)
|
||||
])
|
||||
return IPv4Address(bytes)!
|
||||
}
|
||||
if address is IPv6Address {
|
||||
var bytes = Data(repeating: 0, count: 16)
|
||||
for i in 0..<Int(networkPrefixLength/8) {
|
||||
bytes[i] = 0xff
|
||||
}
|
||||
let nibble = networkPrefixLength % 32
|
||||
if nibble != 0 {
|
||||
let mask = ~UInt32(0) << (32 - nibble)
|
||||
let i = Int(networkPrefixLength / 32 * 4)
|
||||
bytes[i + 0] = UInt8(truncatingIfNeeded: mask >> 24)
|
||||
bytes[i + 1] = UInt8(truncatingIfNeeded: mask >> 16)
|
||||
bytes[i + 2] = UInt8(truncatingIfNeeded: mask >> 8)
|
||||
bytes[i + 3] = UInt8(truncatingIfNeeded: mask >> 0)
|
||||
}
|
||||
return IPv6Address(bytes)!
|
||||
}
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public func maskedAddress() -> IPAddress {
|
||||
let subnet = subnetMask().rawValue
|
||||
var masked = Data(address.rawValue)
|
||||
if subnet.count != masked.count {
|
||||
fatalError()
|
||||
}
|
||||
for i in 0..<subnet.count {
|
||||
masked[i] &= subnet[i]
|
||||
}
|
||||
if subnet.count == 4 {
|
||||
return IPv4Address(masked)!
|
||||
}
|
||||
if subnet.count == 16 {
|
||||
return IPv6Address(masked)!
|
||||
}
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public struct InterfaceConfiguration {
|
||||
public var privateKey: PrivateKey
|
||||
public var addresses = [IPAddressRange]()
|
||||
public var listenPort: UInt16?
|
||||
public var mtu: UInt16?
|
||||
public var dns = [DNSServer]()
|
||||
public var dnsSearch = [String]()
|
||||
|
||||
public init(privateKey: PrivateKey) {
|
||||
self.privateKey = privateKey
|
||||
}
|
||||
}
|
||||
|
||||
extension InterfaceConfiguration: Equatable {
|
||||
public static func == (lhs: InterfaceConfiguration, rhs: InterfaceConfiguration) -> Bool {
|
||||
let lhsAddresses = lhs.addresses.filter { $0.address is IPv4Address } + lhs.addresses.filter { $0.address is IPv6Address }
|
||||
let rhsAddresses = rhs.addresses.filter { $0.address is IPv4Address } + rhs.addresses.filter { $0.address is IPv6Address }
|
||||
|
||||
return lhs.privateKey == rhs.privateKey &&
|
||||
lhsAddresses == rhsAddresses &&
|
||||
lhs.listenPort == rhs.listenPort &&
|
||||
lhs.mtu == rhs.mtu &&
|
||||
lhs.dns == rhs.dns &&
|
||||
lhs.dnsSearch == rhs.dnsSearch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import NetworkExtension
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
import WireGuardKitC
|
||||
#endif
|
||||
|
||||
/// A type alias for `Result` type that holds a tuple with source and resolved endpoint.
|
||||
typealias EndpointResolutionResult = Result<(Endpoint, Endpoint), DNSResolutionError>
|
||||
|
||||
/// PCA Phobos: IPv4 CIDRs that must not go into the tunnel (set before adapter.start).
|
||||
public enum PhobosRoutes {
|
||||
public static var excludedIPv4: [String] = []
|
||||
}
|
||||
|
||||
class PacketTunnelSettingsGenerator {
|
||||
let tunnelConfiguration: TunnelConfiguration
|
||||
let resolvedEndpoints: [Endpoint?]
|
||||
|
||||
init(tunnelConfiguration: TunnelConfiguration, resolvedEndpoints: [Endpoint?]) {
|
||||
self.tunnelConfiguration = tunnelConfiguration
|
||||
self.resolvedEndpoints = resolvedEndpoints
|
||||
}
|
||||
|
||||
func endpointUapiConfiguration() -> (String, [EndpointResolutionResult?]) {
|
||||
var resolutionResults = [EndpointResolutionResult?]()
|
||||
var wgSettings = ""
|
||||
|
||||
assert(tunnelConfiguration.peers.count == resolvedEndpoints.count)
|
||||
for (peer, resolvedEndpoint) in zip(self.tunnelConfiguration.peers, self.resolvedEndpoints) {
|
||||
wgSettings.append("public_key=\(peer.publicKey.hexKey)\n")
|
||||
|
||||
let result = resolvedEndpoint.map(Self.reresolveEndpoint)
|
||||
if case .success((_, let resolvedEndpoint)) = result {
|
||||
if case .name = resolvedEndpoint.host { assert(false, "Endpoint is not resolved") }
|
||||
wgSettings.append("endpoint=\(resolvedEndpoint.stringRepresentation)\n")
|
||||
}
|
||||
resolutionResults.append(result)
|
||||
}
|
||||
|
||||
return (wgSettings, resolutionResults)
|
||||
}
|
||||
|
||||
func uapiConfiguration() -> (String, [EndpointResolutionResult?]) {
|
||||
var resolutionResults = [EndpointResolutionResult?]()
|
||||
var wgSettings = ""
|
||||
wgSettings.append("private_key=\(tunnelConfiguration.interface.privateKey.hexKey)\n")
|
||||
if let listenPort = tunnelConfiguration.interface.listenPort {
|
||||
wgSettings.append("listen_port=\(listenPort)\n")
|
||||
}
|
||||
if !tunnelConfiguration.peers.isEmpty {
|
||||
wgSettings.append("replace_peers=true\n")
|
||||
}
|
||||
assert(tunnelConfiguration.peers.count == resolvedEndpoints.count)
|
||||
for (peer, resolvedEndpoint) in zip(self.tunnelConfiguration.peers, self.resolvedEndpoints) {
|
||||
wgSettings.append("public_key=\(peer.publicKey.hexKey)\n")
|
||||
if let preSharedKey = peer.preSharedKey?.hexKey {
|
||||
wgSettings.append("preshared_key=\(preSharedKey)\n")
|
||||
}
|
||||
|
||||
let result = resolvedEndpoint.map(Self.reresolveEndpoint)
|
||||
if case .success((_, let resolvedEndpoint)) = result {
|
||||
if case .name = resolvedEndpoint.host { assert(false, "Endpoint is not resolved") }
|
||||
wgSettings.append("endpoint=\(resolvedEndpoint.stringRepresentation)\n")
|
||||
}
|
||||
resolutionResults.append(result)
|
||||
|
||||
let persistentKeepAlive = peer.persistentKeepAlive ?? 0
|
||||
wgSettings.append("persistent_keepalive_interval=\(persistentKeepAlive)\n")
|
||||
if !peer.allowedIPs.isEmpty {
|
||||
wgSettings.append("replace_allowed_ips=true\n")
|
||||
peer.allowedIPs.forEach { wgSettings.append("allowed_ip=\($0.stringRepresentation)\n") }
|
||||
}
|
||||
}
|
||||
return (wgSettings, resolutionResults)
|
||||
}
|
||||
|
||||
func generateNetworkSettings() -> NEPacketTunnelNetworkSettings {
|
||||
/* iOS requires a tunnel endpoint, whereas in WireGuard it's valid for
|
||||
* a tunnel to have no endpoint, or for there to be many endpoints, in
|
||||
* which case, displaying a single one in settings doesn't really
|
||||
* make sense. So, we fill it in with this placeholder, which is not
|
||||
* a valid IP address that will actually route over the Internet.
|
||||
*/
|
||||
let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
|
||||
|
||||
if !tunnelConfiguration.interface.dnsSearch.isEmpty || !tunnelConfiguration.interface.dns.isEmpty {
|
||||
let dnsServerStrings = tunnelConfiguration.interface.dns.map { $0.stringRepresentation }
|
||||
let dnsSettings = NEDNSSettings(servers: dnsServerStrings)
|
||||
dnsSettings.searchDomains = tunnelConfiguration.interface.dnsSearch
|
||||
if !tunnelConfiguration.interface.dns.isEmpty {
|
||||
dnsSettings.matchDomains = [""] // All DNS queries must first go through the tunnel's DNS
|
||||
}
|
||||
networkSettings.dnsSettings = dnsSettings
|
||||
}
|
||||
|
||||
let mtu = tunnelConfiguration.interface.mtu ?? 0
|
||||
|
||||
/* 0 means automatic MTU. In theory, we should just do
|
||||
* `networkSettings.tunnelOverheadBytes = 80` but in
|
||||
* practice there are too many broken networks out there.
|
||||
* Instead set it to 1280. Boohoo. Maybe someday we'll
|
||||
* add a nob, maybe, or iOS will do probing for us.
|
||||
*/
|
||||
if mtu == 0 {
|
||||
#if os(iOS)
|
||||
networkSettings.mtu = NSNumber(value: 1280)
|
||||
#elseif os(macOS)
|
||||
networkSettings.tunnelOverheadBytes = 80
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
} else {
|
||||
networkSettings.mtu = NSNumber(value: mtu)
|
||||
}
|
||||
|
||||
let (ipv4Addresses, ipv6Addresses) = addresses()
|
||||
let (ipv4IncludedRoutes, ipv6IncludedRoutes) = includedRoutes()
|
||||
|
||||
let ipv4Settings = NEIPv4Settings(addresses: ipv4Addresses.map { $0.destinationAddress }, subnetMasks: ipv4Addresses.map { $0.destinationSubnetMask })
|
||||
ipv4Settings.includedRoutes = ipv4IncludedRoutes
|
||||
// PCA Phobos: "Russian sites direct" = these networks bypass the tunnel
|
||||
ipv4Settings.excludedRoutes = PhobosRoutes.excludedIPv4.compactMap { cidr in
|
||||
guard let range = IPAddressRange(from: cidr) else { return nil }
|
||||
return NEIPv4Route(destinationAddress: "\(range.address)", subnetMask: "\(range.subnetMask())")
|
||||
}
|
||||
networkSettings.ipv4Settings = ipv4Settings
|
||||
|
||||
let ipv6Settings = NEIPv6Settings(addresses: ipv6Addresses.map { $0.destinationAddress }, networkPrefixLengths: ipv6Addresses.map { $0.destinationNetworkPrefixLength })
|
||||
ipv6Settings.includedRoutes = ipv6IncludedRoutes
|
||||
networkSettings.ipv6Settings = ipv6Settings
|
||||
|
||||
return networkSettings
|
||||
}
|
||||
|
||||
private func addresses() -> ([NEIPv4Route], [NEIPv6Route]) {
|
||||
var ipv4Routes = [NEIPv4Route]()
|
||||
var ipv6Routes = [NEIPv6Route]()
|
||||
for addressRange in tunnelConfiguration.interface.addresses {
|
||||
if addressRange.address is IPv4Address {
|
||||
ipv4Routes.append(NEIPv4Route(destinationAddress: "\(addressRange.address)", subnetMask: "\(addressRange.subnetMask())"))
|
||||
} else if addressRange.address is IPv6Address {
|
||||
/* Big fat ugly hack for broken iOS networking stack: the smallest prefix that will have
|
||||
* any effect on iOS is a /120, so we clamp everything above to /120. This is potentially
|
||||
* very bad, if various network parameters were actually relying on that subnet being
|
||||
* intentionally small. TODO: talk about this with upstream iOS devs.
|
||||
*/
|
||||
ipv6Routes.append(NEIPv6Route(destinationAddress: "\(addressRange.address)", networkPrefixLength: NSNumber(value: min(120, addressRange.networkPrefixLength))))
|
||||
}
|
||||
}
|
||||
return (ipv4Routes, ipv6Routes)
|
||||
}
|
||||
|
||||
private func includedRoutes() -> ([NEIPv4Route], [NEIPv6Route]) {
|
||||
var ipv4IncludedRoutes = [NEIPv4Route]()
|
||||
var ipv6IncludedRoutes = [NEIPv6Route]()
|
||||
|
||||
for addressRange in tunnelConfiguration.interface.addresses {
|
||||
if addressRange.address is IPv4Address {
|
||||
let route = NEIPv4Route(destinationAddress: "\(addressRange.maskedAddress())", subnetMask: "\(addressRange.subnetMask())")
|
||||
route.gatewayAddress = "\(addressRange.address)"
|
||||
ipv4IncludedRoutes.append(route)
|
||||
} else if addressRange.address is IPv6Address {
|
||||
let route = NEIPv6Route(destinationAddress: "\(addressRange.maskedAddress())", networkPrefixLength: NSNumber(value: addressRange.networkPrefixLength))
|
||||
route.gatewayAddress = "\(addressRange.address)"
|
||||
ipv6IncludedRoutes.append(route)
|
||||
}
|
||||
}
|
||||
|
||||
for peer in tunnelConfiguration.peers {
|
||||
for addressRange in peer.allowedIPs {
|
||||
if addressRange.address is IPv4Address {
|
||||
ipv4IncludedRoutes.append(NEIPv4Route(destinationAddress: "\(addressRange.address)", subnetMask: "\(addressRange.subnetMask())"))
|
||||
} else if addressRange.address is IPv6Address {
|
||||
ipv6IncludedRoutes.append(NEIPv6Route(destinationAddress: "\(addressRange.address)", networkPrefixLength: NSNumber(value: addressRange.networkPrefixLength)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return (ipv4IncludedRoutes, ipv6IncludedRoutes)
|
||||
}
|
||||
|
||||
private class func reresolveEndpoint(endpoint: Endpoint) -> EndpointResolutionResult {
|
||||
return Result { (endpoint, try endpoint.withReresolvedIP()) }
|
||||
.mapError { error -> DNSResolutionError in
|
||||
// swiftlint:disable:next force_cast
|
||||
return error as! DNSResolutionError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct PeerConfiguration {
|
||||
public var publicKey: PublicKey
|
||||
public var preSharedKey: PreSharedKey?
|
||||
public var allowedIPs = [IPAddressRange]()
|
||||
public var endpoint: Endpoint?
|
||||
public var persistentKeepAlive: UInt16?
|
||||
public var rxBytes: UInt64?
|
||||
public var txBytes: UInt64?
|
||||
public var lastHandshakeTime: Date?
|
||||
|
||||
public init(publicKey: PublicKey) {
|
||||
self.publicKey = publicKey
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerConfiguration: Equatable {
|
||||
public static func == (lhs: PeerConfiguration, rhs: PeerConfiguration) -> Bool {
|
||||
return lhs.publicKey == rhs.publicKey &&
|
||||
lhs.preSharedKey == rhs.preSharedKey &&
|
||||
Set(lhs.allowedIPs) == Set(rhs.allowedIPs) &&
|
||||
lhs.endpoint == rhs.endpoint &&
|
||||
lhs.persistentKeepAlive == rhs.persistentKeepAlive
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerConfiguration: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(publicKey)
|
||||
hasher.combine(preSharedKey)
|
||||
hasher.combine(Set(allowedIPs))
|
||||
hasher.combine(endpoint)
|
||||
hasher.combine(persistentKeepAlive)
|
||||
|
||||
}
|
||||
}
|
||||
114
ios/WireGuardKit/Sources/WireGuardKit/PrivateKey.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
import WireGuardKitC
|
||||
#endif
|
||||
|
||||
/// The class describing a private key used by WireGuard.
|
||||
public class PrivateKey: BaseKey {
|
||||
/// Derived public key
|
||||
public var publicKey: PublicKey {
|
||||
return rawValue.withUnsafeBytes { (privateKeyBufferPointer: UnsafeRawBufferPointer) -> PublicKey in
|
||||
var publicKeyData = Data(repeating: 0, count: Int(WG_KEY_LEN))
|
||||
let privateKeyBytes = privateKeyBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
|
||||
publicKeyData.withUnsafeMutableBytes { (publicKeyBufferPointer: UnsafeMutableRawBufferPointer) in
|
||||
let publicKeyBytes = publicKeyBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
curve25519_derive_public_key(publicKeyBytes, privateKeyBytes)
|
||||
}
|
||||
|
||||
return PublicKey(rawValue: publicKeyData)!
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize new private key
|
||||
convenience public init() {
|
||||
var privateKeyData = Data(repeating: 0, count: Int(WG_KEY_LEN))
|
||||
privateKeyData.withUnsafeMutableBytes { (rawBufferPointer: UnsafeMutableRawBufferPointer) in
|
||||
let privateKeyBytes = rawBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
curve25519_generate_private_key(privateKeyBytes)
|
||||
}
|
||||
self.init(rawValue: privateKeyData)!
|
||||
}
|
||||
}
|
||||
|
||||
/// The class describing a public key used by WireGuard.
|
||||
public class PublicKey: BaseKey {}
|
||||
|
||||
/// The class describing a pre-shared key used by WireGuard.
|
||||
public class PreSharedKey: BaseKey {}
|
||||
|
||||
/// The base key implementation. Should not be used directly.
|
||||
public class BaseKey: RawRepresentable, Equatable, Hashable {
|
||||
/// Raw key representation
|
||||
public let rawValue: Data
|
||||
|
||||
/// Hex encoded representation
|
||||
public var hexKey: String {
|
||||
return rawValue.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> String in
|
||||
let inBytes = rawBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
var outBytes = [CChar](repeating: 0, count: Int(WG_KEY_LEN_HEX))
|
||||
key_to_hex(&outBytes, inBytes)
|
||||
return String(cString: outBytes, encoding: .ascii)!
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64 encoded representation
|
||||
public var base64Key: String {
|
||||
return rawValue.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> String in
|
||||
let inBytes = rawBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
var outBytes = [CChar](repeating: 0, count: Int(WG_KEY_LEN_BASE64))
|
||||
key_to_base64(&outBytes, inBytes)
|
||||
return String(cString: outBytes, encoding: .ascii)!
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the key with existing raw representation
|
||||
required public init?(rawValue: Data) {
|
||||
if rawValue.count == WG_KEY_LEN {
|
||||
self.rawValue = rawValue
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the key with hex representation
|
||||
public convenience init?(hexKey: String) {
|
||||
var bytes = Data(repeating: 0, count: Int(WG_KEY_LEN))
|
||||
let success = bytes.withUnsafeMutableBytes { (bufferPointer: UnsafeMutableRawBufferPointer) -> Bool in
|
||||
return key_from_hex(bufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self), hexKey)
|
||||
}
|
||||
if success {
|
||||
self.init(rawValue: bytes)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the key with base64 representation
|
||||
public convenience init?(base64Key: String) {
|
||||
var bytes = Data(repeating: 0, count: Int(WG_KEY_LEN))
|
||||
let success = bytes.withUnsafeMutableBytes { (bufferPointer: UnsafeMutableRawBufferPointer) -> Bool in
|
||||
return key_from_base64(bufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self), base64Key)
|
||||
}
|
||||
if success {
|
||||
self.init(rawValue: bytes)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func == (lhs: BaseKey, rhs: BaseKey) -> Bool {
|
||||
return lhs.rawValue.withUnsafeBytes { (lhsBytes: UnsafeRawBufferPointer) -> Bool in
|
||||
return rhs.rawValue.withUnsafeBytes { (rhsBytes: UnsafeRawBufferPointer) -> Bool in
|
||||
return key_eq(
|
||||
lhsBytes.baseAddress!.assumingMemoryBound(to: UInt8.self),
|
||||
rhsBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class TunnelConfiguration {
|
||||
public var name: String?
|
||||
public var interface: InterfaceConfiguration
|
||||
public let peers: [PeerConfiguration]
|
||||
|
||||
public init(name: String?, interface: InterfaceConfiguration, peers: [PeerConfiguration]) {
|
||||
self.interface = interface
|
||||
self.peers = peers
|
||||
self.name = name
|
||||
|
||||
let peerPublicKeysArray = peers.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray)
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
fatalError("Two or more peers cannot have the same public key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelConfiguration: Equatable {
|
||||
public static func == (lhs: TunnelConfiguration, rhs: TunnelConfiguration) -> Bool {
|
||||
return lhs.name == rhs.name &&
|
||||
lhs.interface == rhs.interface &&
|
||||
Set(lhs.peers) == Set(rhs.peers)
|
||||
}
|
||||
}
|
||||
487
ios/WireGuardKit/Sources/WireGuardKit/WireGuardAdapter.swift
Normal file
@@ -0,0 +1,487 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
import WireGuardKitGo
|
||||
import WireGuardKitC
|
||||
#endif
|
||||
|
||||
public enum WireGuardAdapterError: Error {
|
||||
/// Failure to locate tunnel file descriptor.
|
||||
case cannotLocateTunnelFileDescriptor
|
||||
|
||||
/// Failure to perform an operation in such state.
|
||||
case invalidState
|
||||
|
||||
/// Failure to resolve endpoints.
|
||||
case dnsResolution([DNSResolutionError])
|
||||
|
||||
/// Failure to set network settings.
|
||||
case setNetworkSettings(Error)
|
||||
|
||||
/// Failure to start WireGuard backend.
|
||||
case startWireGuardBackend(Int32)
|
||||
}
|
||||
|
||||
/// Enum representing internal state of the `WireGuardAdapter`
|
||||
private enum State {
|
||||
/// The tunnel is stopped
|
||||
case stopped
|
||||
|
||||
/// The tunnel is up and running
|
||||
case started(_ handle: Int32, _ settingsGenerator: PacketTunnelSettingsGenerator)
|
||||
|
||||
/// The tunnel is temporarily shutdown due to device going offline
|
||||
case temporaryShutdown(_ settingsGenerator: PacketTunnelSettingsGenerator)
|
||||
}
|
||||
|
||||
public class WireGuardAdapter {
|
||||
public typealias LogHandler = (WireGuardLogLevel, String) -> Void
|
||||
|
||||
/// Network routes monitor.
|
||||
private var networkMonitor: NWPathMonitor?
|
||||
|
||||
/// Packet tunnel provider.
|
||||
private weak var packetTunnelProvider: NEPacketTunnelProvider?
|
||||
|
||||
/// Log handler closure.
|
||||
private let logHandler: LogHandler
|
||||
|
||||
/// Private queue used to synchronize access to `WireGuardAdapter` members.
|
||||
private let workQueue = DispatchQueue(label: "WireGuardAdapterWorkQueue")
|
||||
|
||||
/// Adapter state.
|
||||
private var state: State = .stopped
|
||||
|
||||
/// Tunnel device file descriptor.
|
||||
private var tunnelFileDescriptor: Int32? {
|
||||
var ctlInfo = ctl_info()
|
||||
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
|
||||
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
|
||||
_ = strcpy($0, "com.apple.net.utun_control")
|
||||
}
|
||||
}
|
||||
for fd: Int32 in 0...1024 {
|
||||
var addr = sockaddr_ctl()
|
||||
var ret: Int32 = -1
|
||||
var len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||
withUnsafeMutablePointer(to: &addr) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
ret = getpeername(fd, $0, &len)
|
||||
}
|
||||
}
|
||||
if ret != 0 || addr.sc_family != AF_SYSTEM {
|
||||
continue
|
||||
}
|
||||
if ctlInfo.ctl_id == 0 {
|
||||
ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
|
||||
if ret != 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if addr.sc_id == ctlInfo.ctl_id {
|
||||
return fd
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns a WireGuard version.
|
||||
class var backendVersion: String {
|
||||
guard let ver = wgVersion() else { return "unknown" }
|
||||
let str = String(cString: ver)
|
||||
free(UnsafeMutableRawPointer(mutating: ver))
|
||||
return str
|
||||
}
|
||||
|
||||
/// Returns the tunnel device interface name, or nil on error.
|
||||
/// - Returns: String.
|
||||
public var interfaceName: String? {
|
||||
guard let tunnelFileDescriptor = self.tunnelFileDescriptor else { return nil }
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: Int(IFNAMSIZ))
|
||||
|
||||
return buffer.withUnsafeMutableBufferPointer { mutableBufferPointer in
|
||||
guard let baseAddress = mutableBufferPointer.baseAddress else { return nil }
|
||||
|
||||
var ifnameSize = socklen_t(IFNAMSIZ)
|
||||
let result = getsockopt(
|
||||
tunnelFileDescriptor,
|
||||
2 /* SYSPROTO_CONTROL */,
|
||||
2 /* UTUN_OPT_IFNAME */,
|
||||
baseAddress,
|
||||
&ifnameSize)
|
||||
|
||||
if result == 0 {
|
||||
return String(cString: baseAddress)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Designated initializer.
|
||||
/// - Parameter packetTunnelProvider: an instance of `NEPacketTunnelProvider`. Internally stored
|
||||
/// as a weak reference.
|
||||
/// - Parameter logHandler: a log handler closure.
|
||||
public init(with packetTunnelProvider: NEPacketTunnelProvider, logHandler: @escaping LogHandler) {
|
||||
self.packetTunnelProvider = packetTunnelProvider
|
||||
self.logHandler = logHandler
|
||||
|
||||
setupLogHandler()
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Force remove logger to make sure that no further calls to the instance of this class
|
||||
// can happen after deallocation.
|
||||
wgSetLogger(nil, nil)
|
||||
|
||||
// Cancel network monitor
|
||||
networkMonitor?.cancel()
|
||||
|
||||
// Shutdown the tunnel
|
||||
if case .started(let handle, _) = self.state {
|
||||
wgTurnOff(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public methods
|
||||
|
||||
/// Returns a runtime configuration from WireGuard.
|
||||
/// - Parameter completionHandler: completion handler.
|
||||
public func getRuntimeConfiguration(completionHandler: @escaping (String?) -> Void) {
|
||||
workQueue.async {
|
||||
guard case .started(let handle, _) = self.state else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let settings = wgGetConfig(handle) {
|
||||
completionHandler(String(cString: settings))
|
||||
free(settings)
|
||||
} else {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the tunnel tunnel.
|
||||
/// - Parameters:
|
||||
/// - tunnelConfiguration: tunnel configuration.
|
||||
/// - completionHandler: completion handler.
|
||||
public func start(tunnelConfiguration: TunnelConfiguration, completionHandler: @escaping (WireGuardAdapterError?) -> Void) {
|
||||
workQueue.async {
|
||||
guard case .stopped = self.state else {
|
||||
completionHandler(.invalidState)
|
||||
return
|
||||
}
|
||||
|
||||
let networkMonitor = NWPathMonitor()
|
||||
networkMonitor.pathUpdateHandler = { [weak self] path in
|
||||
self?.didReceivePathUpdate(path: path)
|
||||
}
|
||||
networkMonitor.start(queue: self.workQueue)
|
||||
|
||||
do {
|
||||
let settingsGenerator = try self.makeSettingsGenerator(with: tunnelConfiguration)
|
||||
try self.setNetworkSettings(settingsGenerator.generateNetworkSettings())
|
||||
|
||||
let (wgConfig, resolutionResults) = settingsGenerator.uapiConfiguration()
|
||||
self.logEndpointResolutionResults(resolutionResults)
|
||||
|
||||
self.state = .started(
|
||||
try self.startWireGuardBackend(wgConfig: wgConfig),
|
||||
settingsGenerator
|
||||
)
|
||||
self.networkMonitor = networkMonitor
|
||||
completionHandler(nil)
|
||||
} catch let error as WireGuardAdapterError {
|
||||
networkMonitor.cancel()
|
||||
completionHandler(error)
|
||||
} catch {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the tunnel.
|
||||
/// - Parameter completionHandler: completion handler.
|
||||
public func stop(completionHandler: @escaping (WireGuardAdapterError?) -> Void) {
|
||||
workQueue.async {
|
||||
switch self.state {
|
||||
case .started(let handle, _):
|
||||
wgTurnOff(handle)
|
||||
|
||||
case .temporaryShutdown:
|
||||
break
|
||||
|
||||
case .stopped:
|
||||
completionHandler(.invalidState)
|
||||
return
|
||||
}
|
||||
|
||||
self.networkMonitor?.cancel()
|
||||
self.networkMonitor = nil
|
||||
|
||||
self.state = .stopped
|
||||
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update runtime configuration.
|
||||
/// - Parameters:
|
||||
/// - tunnelConfiguration: tunnel configuration.
|
||||
/// - completionHandler: completion handler.
|
||||
public func update(tunnelConfiguration: TunnelConfiguration, completionHandler: @escaping (WireGuardAdapterError?) -> Void) {
|
||||
workQueue.async {
|
||||
if case .stopped = self.state {
|
||||
completionHandler(.invalidState)
|
||||
return
|
||||
}
|
||||
|
||||
// Tell the system that the tunnel is going to reconnect using new WireGuard
|
||||
// configuration.
|
||||
// This will broadcast the `NEVPNStatusDidChange` notification to the GUI process.
|
||||
self.packetTunnelProvider?.reasserting = true
|
||||
defer {
|
||||
self.packetTunnelProvider?.reasserting = false
|
||||
}
|
||||
|
||||
do {
|
||||
let settingsGenerator = try self.makeSettingsGenerator(with: tunnelConfiguration)
|
||||
try self.setNetworkSettings(settingsGenerator.generateNetworkSettings())
|
||||
|
||||
switch self.state {
|
||||
case .started(let handle, _):
|
||||
let (wgConfig, resolutionResults) = settingsGenerator.uapiConfiguration()
|
||||
self.logEndpointResolutionResults(resolutionResults)
|
||||
|
||||
wgSetConfig(handle, wgConfig)
|
||||
#if os(iOS)
|
||||
wgDisableSomeRoamingForBrokenMobileSemantics(handle)
|
||||
#endif
|
||||
|
||||
self.state = .started(handle, settingsGenerator)
|
||||
|
||||
case .temporaryShutdown:
|
||||
self.state = .temporaryShutdown(settingsGenerator)
|
||||
|
||||
case .stopped:
|
||||
fatalError()
|
||||
}
|
||||
|
||||
completionHandler(nil)
|
||||
} catch let error as WireGuardAdapterError {
|
||||
completionHandler(error)
|
||||
} catch {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private methods
|
||||
|
||||
/// Setup WireGuard log handler.
|
||||
private func setupLogHandler() {
|
||||
let context = Unmanaged.passUnretained(self).toOpaque()
|
||||
wgSetLogger(context) { context, logLevel, message in
|
||||
guard let context = context, let message = message else { return }
|
||||
|
||||
let unretainedSelf = Unmanaged<WireGuardAdapter>.fromOpaque(context)
|
||||
.takeUnretainedValue()
|
||||
|
||||
let swiftString = String(cString: message).trimmingCharacters(in: .newlines)
|
||||
let tunnelLogLevel = WireGuardLogLevel(rawValue: logLevel) ?? .verbose
|
||||
|
||||
unretainedSelf.logHandler(tunnelLogLevel, swiftString)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set network tunnel configuration.
|
||||
/// This method ensures that the call to `setTunnelNetworkSettings` does not time out, as in
|
||||
/// certain scenarios the completion handler given to it may not be invoked by the system.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - networkSettings: an instance of type `NEPacketTunnelNetworkSettings`.
|
||||
/// - Throws: an error of type `WireGuardAdapterError`.
|
||||
/// - Returns: `PacketTunnelSettingsGenerator`.
|
||||
private func setNetworkSettings(_ networkSettings: NEPacketTunnelNetworkSettings) throws {
|
||||
var systemError: Error?
|
||||
let condition = NSCondition()
|
||||
|
||||
// Activate the condition
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
|
||||
self.packetTunnelProvider?.setTunnelNetworkSettings(networkSettings) { error in
|
||||
systemError = error
|
||||
condition.signal()
|
||||
}
|
||||
|
||||
// Packet tunnel's `setTunnelNetworkSettings` times out in certain
|
||||
// scenarios & never calls the given callback.
|
||||
let setTunnelNetworkSettingsTimeout: TimeInterval = 5 // seconds
|
||||
|
||||
if condition.wait(until: Date().addingTimeInterval(setTunnelNetworkSettingsTimeout)) {
|
||||
if let systemError = systemError {
|
||||
throw WireGuardAdapterError.setNetworkSettings(systemError)
|
||||
}
|
||||
} else {
|
||||
self.logHandler(.error, "setTunnelNetworkSettings timed out after 5 seconds; proceeding anyway")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve peers of the given tunnel configuration.
|
||||
/// - Parameter tunnelConfiguration: tunnel configuration.
|
||||
/// - Throws: an error of type `WireGuardAdapterError`.
|
||||
/// - Returns: The list of resolved endpoints.
|
||||
private func resolvePeers(for tunnelConfiguration: TunnelConfiguration) throws -> [Endpoint?] {
|
||||
let endpoints = tunnelConfiguration.peers.map { $0.endpoint }
|
||||
let resolutionResults = DNSResolver.resolveSync(endpoints: endpoints)
|
||||
let resolutionErrors = resolutionResults.compactMap { result -> DNSResolutionError? in
|
||||
if case .failure(let error) = result {
|
||||
return error
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
assert(endpoints.count == resolutionResults.count)
|
||||
guard resolutionErrors.isEmpty else {
|
||||
throw WireGuardAdapterError.dnsResolution(resolutionErrors)
|
||||
}
|
||||
|
||||
let resolvedEndpoints = resolutionResults.map { result -> Endpoint? in
|
||||
// swiftlint:disable:next force_try
|
||||
return try! result?.get()
|
||||
}
|
||||
|
||||
return resolvedEndpoints
|
||||
}
|
||||
|
||||
/// Start WireGuard backend.
|
||||
/// - Parameter wgConfig: WireGuard configuration
|
||||
/// - Throws: an error of type `WireGuardAdapterError`
|
||||
/// - Returns: tunnel handle
|
||||
private func startWireGuardBackend(wgConfig: String) throws -> Int32 {
|
||||
guard let tunnelFileDescriptor = self.tunnelFileDescriptor else {
|
||||
throw WireGuardAdapterError.cannotLocateTunnelFileDescriptor
|
||||
}
|
||||
|
||||
let handle = wgTurnOn(wgConfig, tunnelFileDescriptor)
|
||||
if handle < 0 {
|
||||
throw WireGuardAdapterError.startWireGuardBackend(handle)
|
||||
}
|
||||
#if os(iOS)
|
||||
wgDisableSomeRoamingForBrokenMobileSemantics(handle)
|
||||
#endif
|
||||
return handle
|
||||
}
|
||||
|
||||
/// Resolves the hostnames in the given tunnel configuration and return settings generator.
|
||||
/// - Parameter tunnelConfiguration: an instance of type `TunnelConfiguration`.
|
||||
/// - Throws: an error of type `WireGuardAdapterError`.
|
||||
/// - Returns: an instance of type `PacketTunnelSettingsGenerator`.
|
||||
private func makeSettingsGenerator(with tunnelConfiguration: TunnelConfiguration) throws -> PacketTunnelSettingsGenerator {
|
||||
return PacketTunnelSettingsGenerator(
|
||||
tunnelConfiguration: tunnelConfiguration,
|
||||
resolvedEndpoints: try self.resolvePeers(for: tunnelConfiguration)
|
||||
)
|
||||
}
|
||||
|
||||
/// Log DNS resolution results.
|
||||
/// - Parameter resolutionErrors: an array of type `[DNSResolutionError]`.
|
||||
private func logEndpointResolutionResults(_ resolutionResults: [EndpointResolutionResult?]) {
|
||||
for case .some(let result) in resolutionResults {
|
||||
switch result {
|
||||
case .success((let sourceEndpoint, let resolvedEndpoint)):
|
||||
if sourceEndpoint.host == resolvedEndpoint.host {
|
||||
self.logHandler(.verbose, "DNS64: mapped \(sourceEndpoint.host) to itself.")
|
||||
} else {
|
||||
self.logHandler(.verbose, "DNS64: mapped \(sourceEndpoint.host) to \(resolvedEndpoint.host)")
|
||||
}
|
||||
case .failure(let resolutionError):
|
||||
self.logHandler(.error, "Failed to resolve endpoint \(resolutionError.address): \(resolutionError.errorDescription ?? "(nil)")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper method used by network path monitor.
|
||||
/// - Parameter path: new network path
|
||||
private func didReceivePathUpdate(path: Network.NWPath) {
|
||||
self.logHandler(.verbose, "Network change detected with \(path.status) route and interface order \(path.availableInterfaces)")
|
||||
|
||||
#if os(macOS)
|
||||
if case .started(let handle, _) = self.state {
|
||||
wgBumpSockets(handle)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
switch self.state {
|
||||
case .started(let handle, let settingsGenerator):
|
||||
if path.status.isSatisfiable {
|
||||
let (wgConfig, resolutionResults) = settingsGenerator.endpointUapiConfiguration()
|
||||
self.logEndpointResolutionResults(resolutionResults)
|
||||
|
||||
wgSetConfig(handle, wgConfig)
|
||||
wgDisableSomeRoamingForBrokenMobileSemantics(handle)
|
||||
wgBumpSockets(handle)
|
||||
} else {
|
||||
self.logHandler(.verbose, "Connectivity offline, pausing backend.")
|
||||
|
||||
self.state = .temporaryShutdown(settingsGenerator)
|
||||
wgTurnOff(handle)
|
||||
}
|
||||
|
||||
case .temporaryShutdown(let settingsGenerator):
|
||||
guard path.status.isSatisfiable else { return }
|
||||
|
||||
self.logHandler(.verbose, "Connectivity online, resuming backend.")
|
||||
|
||||
do {
|
||||
try self.setNetworkSettings(settingsGenerator.generateNetworkSettings())
|
||||
|
||||
let (wgConfig, resolutionResults) = settingsGenerator.uapiConfiguration()
|
||||
self.logEndpointResolutionResults(resolutionResults)
|
||||
|
||||
self.state = .started(
|
||||
try self.startWireGuardBackend(wgConfig: wgConfig),
|
||||
settingsGenerator
|
||||
)
|
||||
} catch {
|
||||
self.logHandler(.error, "Failed to restart backend: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
case .stopped:
|
||||
// no-op
|
||||
break
|
||||
}
|
||||
#else
|
||||
#error("Unsupported")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A enum describing WireGuard log levels defined in `api-apple.go`.
|
||||
public enum WireGuardLogLevel: Int32 {
|
||||
case verbose = 0
|
||||
case error = 1
|
||||
}
|
||||
|
||||
private extension Network.NWPath.Status {
|
||||
/// Returns `true` if the path is potentially satisfiable.
|
||||
var isSatisfiable: Bool {
|
||||
switch self {
|
||||
case .requiresConnection, .satisfied:
|
||||
return true
|
||||
case .unsatisfied:
|
||||
return false
|
||||
@unknown default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
21
ios/WireGuardKit/Sources/WireGuardKitC/WireGuardKitC.h
Normal file
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
#include <sys/types.h>
|
||||
#include "key.h"
|
||||
#include "x25519.h"
|
||||
|
||||
/* From <sys/kern_control.h> */
|
||||
#define CTLIOCGINFO 0xc0644e03UL
|
||||
struct ctl_info {
|
||||
u_int32_t ctl_id;
|
||||
char ctl_name[96];
|
||||
};
|
||||
struct sockaddr_ctl {
|
||||
u_char sc_len;
|
||||
u_char sc_family;
|
||||
u_int16_t ss_sysaddr;
|
||||
u_int32_t sc_id;
|
||||
u_int32_t sc_unit;
|
||||
u_int32_t sc_reserved[5];
|
||||
};
|
||||
124
ios/WireGuardKit/Sources/WireGuardKitC/key.c
Normal file
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
/*
|
||||
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
*
|
||||
* This is a specialized constant-time base64/hex implementation that resists side-channel attacks.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include "key.h"
|
||||
|
||||
static inline void encode_base64(char dest[static 4], const uint8_t src[static 3])
|
||||
{
|
||||
const uint8_t input[] = { (src[0] >> 2) & 63, ((src[0] << 4) | (src[1] >> 4)) & 63, ((src[1] << 2) | (src[2] >> 6)) & 63, src[2] & 63 };
|
||||
|
||||
for (unsigned int i = 0; i < 4; ++i)
|
||||
dest[i] = input[i] + 'A'
|
||||
+ (((25 - input[i]) >> 8) & 6)
|
||||
- (((51 - input[i]) >> 8) & 75)
|
||||
- (((61 - input[i]) >> 8) & 15)
|
||||
+ (((62 - input[i]) >> 8) & 3);
|
||||
|
||||
}
|
||||
|
||||
void key_to_base64(char base64[static WG_KEY_LEN_BASE64], const uint8_t key[static WG_KEY_LEN])
|
||||
{
|
||||
unsigned int i;
|
||||
|
||||
for (i = 0; i < WG_KEY_LEN / 3; ++i)
|
||||
encode_base64(&base64[i * 4], &key[i * 3]);
|
||||
encode_base64(&base64[i * 4], (const uint8_t[]){ key[i * 3 + 0], key[i * 3 + 1], 0 });
|
||||
base64[WG_KEY_LEN_BASE64 - 2] = '=';
|
||||
base64[WG_KEY_LEN_BASE64 - 1] = '\0';
|
||||
}
|
||||
|
||||
static inline int decode_base64(const char src[static 4])
|
||||
{
|
||||
int val = 0;
|
||||
|
||||
for (unsigned int i = 0; i < 4; ++i)
|
||||
val |= (-1
|
||||
+ ((((('A' - 1) - src[i]) & (src[i] - ('Z' + 1))) >> 8) & (src[i] - 64))
|
||||
+ ((((('a' - 1) - src[i]) & (src[i] - ('z' + 1))) >> 8) & (src[i] - 70))
|
||||
+ ((((('0' - 1) - src[i]) & (src[i] - ('9' + 1))) >> 8) & (src[i] + 5))
|
||||
+ ((((('+' - 1) - src[i]) & (src[i] - ('+' + 1))) >> 8) & 63)
|
||||
+ ((((('/' - 1) - src[i]) & (src[i] - ('/' + 1))) >> 8) & 64)
|
||||
) << (18 - 6 * i);
|
||||
return val;
|
||||
}
|
||||
|
||||
bool key_from_base64(uint8_t key[static WG_KEY_LEN], const char *base64)
|
||||
{
|
||||
unsigned int i;
|
||||
volatile uint8_t ret = 0;
|
||||
int val;
|
||||
|
||||
if (strlen(base64) != WG_KEY_LEN_BASE64 - 1 || base64[WG_KEY_LEN_BASE64 - 2] != '=')
|
||||
return false;
|
||||
|
||||
for (i = 0; i < WG_KEY_LEN / 3; ++i) {
|
||||
val = decode_base64(&base64[i * 4]);
|
||||
ret |= (uint32_t)val >> 31;
|
||||
key[i * 3 + 0] = (val >> 16) & 0xff;
|
||||
key[i * 3 + 1] = (val >> 8) & 0xff;
|
||||
key[i * 3 + 2] = val & 0xff;
|
||||
}
|
||||
val = decode_base64((const char[]){ base64[i * 4 + 0], base64[i * 4 + 1], base64[i * 4 + 2], 'A' });
|
||||
ret |= ((uint32_t)val >> 31) | (val & 0xff);
|
||||
key[i * 3 + 0] = (val >> 16) & 0xff;
|
||||
key[i * 3 + 1] = (val >> 8) & 0xff;
|
||||
|
||||
return 1 & ((ret - 1) >> 8);
|
||||
}
|
||||
|
||||
void key_to_hex(char hex[static WG_KEY_LEN_HEX], const uint8_t key[static WG_KEY_LEN])
|
||||
{
|
||||
unsigned int i;
|
||||
|
||||
for (i = 0; i < WG_KEY_LEN; ++i) {
|
||||
hex[i * 2] = 87U + (key[i] >> 4) + ((((key[i] >> 4) - 10U) >> 8) & ~38U);
|
||||
hex[i * 2 + 1] = 87U + (key[i] & 0xf) + ((((key[i] & 0xf) - 10U) >> 8) & ~38U);
|
||||
}
|
||||
hex[i * 2] = '\0';
|
||||
}
|
||||
|
||||
bool key_from_hex(uint8_t key[static WG_KEY_LEN], const char *hex)
|
||||
{
|
||||
uint8_t c, c_acc, c_alpha0, c_alpha, c_num0, c_num, c_val;
|
||||
volatile uint8_t ret = 0;
|
||||
|
||||
if (strlen(hex) != WG_KEY_LEN_HEX - 1)
|
||||
return false;
|
||||
|
||||
for (unsigned int i = 0; i < WG_KEY_LEN_HEX - 1; i += 2) {
|
||||
c = (uint8_t)hex[i];
|
||||
c_num = c ^ 48U;
|
||||
c_num0 = (c_num - 10U) >> 8;
|
||||
c_alpha = (c & ~32U) - 55U;
|
||||
c_alpha0 = ((c_alpha - 10U) ^ (c_alpha - 16U)) >> 8;
|
||||
ret |= ((c_num0 | c_alpha0) - 1) >> 8;
|
||||
c_val = (c_num0 & c_num) | (c_alpha0 & c_alpha);
|
||||
c_acc = c_val * 16U;
|
||||
|
||||
c = (uint8_t)hex[i + 1];
|
||||
c_num = c ^ 48U;
|
||||
c_num0 = (c_num - 10U) >> 8;
|
||||
c_alpha = (c & ~32U) - 55U;
|
||||
c_alpha0 = ((c_alpha - 10U) ^ (c_alpha - 16U)) >> 8;
|
||||
ret |= ((c_num0 | c_alpha0) - 1) >> 8;
|
||||
c_val = (c_num0 & c_num) | (c_alpha0 & c_alpha);
|
||||
key[i / 2] = c_acc | c_val;
|
||||
}
|
||||
|
||||
return 1 & ((ret - 1) >> 8);
|
||||
}
|
||||
|
||||
bool key_eq(const uint8_t key1[static WG_KEY_LEN], const uint8_t key2[static WG_KEY_LEN])
|
||||
{
|
||||
volatile uint8_t acc = 0;
|
||||
for (unsigned int i = 0; i < WG_KEY_LEN; ++i) {
|
||||
acc |= key1[i] ^ key2[i];
|
||||
asm volatile("" : "=r"(acc) : "0"(acc));
|
||||
}
|
||||
return 1 & ((acc - 1) >> 8);
|
||||
}
|
||||
24
ios/WireGuardKit/Sources/WireGuardKitC/key.h
Normal file
@@ -0,0 +1,24 @@
|
||||
/* SPDX-License-Identifier: MIT */
|
||||
/*
|
||||
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef KEY_H
|
||||
#define KEY_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define WG_KEY_LEN (32)
|
||||
#define WG_KEY_LEN_BASE64 (45)
|
||||
#define WG_KEY_LEN_HEX (65)
|
||||
|
||||
void key_to_base64(char base64[static WG_KEY_LEN_BASE64], const uint8_t key[static WG_KEY_LEN]);
|
||||
bool key_from_base64(uint8_t key[static WG_KEY_LEN], const char *base64);
|
||||
|
||||
void key_to_hex(char hex[static WG_KEY_LEN_HEX], const uint8_t key[static WG_KEY_LEN]);
|
||||
bool key_from_hex(uint8_t key[static WG_KEY_LEN], const char *hex);
|
||||
|
||||
bool key_eq(const uint8_t key1[static WG_KEY_LEN], const uint8_t key2[static WG_KEY_LEN]);
|
||||
|
||||
#endif
|
||||
4
ios/WireGuardKit/Sources/WireGuardKitC/module.modulemap
Normal file
@@ -0,0 +1,4 @@
|
||||
module WireGuardKitC {
|
||||
umbrella header "WireGuardKitC.h"
|
||||
export *
|
||||
}
|
||||
178
ios/WireGuardKit/Sources/WireGuardKitC/x25519.c
Normal file
@@ -0,0 +1,178 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
*
|
||||
* Curve25519 ECDH functions, based on TweetNaCl but cleaned up.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <assert.h>
|
||||
#include <CommonCrypto/CommonRandom.h>
|
||||
|
||||
#include "x25519.h"
|
||||
|
||||
typedef int64_t fe[16];
|
||||
|
||||
static inline void carry(fe o)
|
||||
{
|
||||
int i;
|
||||
|
||||
for (i = 0; i < 16; ++i) {
|
||||
o[(i + 1) % 16] += (i == 15 ? 38 : 1) * (o[i] >> 16);
|
||||
o[i] &= 0xffff;
|
||||
}
|
||||
}
|
||||
|
||||
static inline void cswap(fe p, fe q, int b)
|
||||
{
|
||||
int i;
|
||||
int64_t t, c = ~(b - 1);
|
||||
|
||||
for (i = 0; i < 16; ++i) {
|
||||
t = c & (p[i] ^ q[i]);
|
||||
p[i] ^= t;
|
||||
q[i] ^= t;
|
||||
}
|
||||
}
|
||||
|
||||
static inline void pack(uint8_t *o, const fe n)
|
||||
{
|
||||
int i, j, b;
|
||||
fe m, t;
|
||||
|
||||
memcpy(t, n, sizeof(t));
|
||||
carry(t);
|
||||
carry(t);
|
||||
carry(t);
|
||||
for (j = 0; j < 2; ++j) {
|
||||
m[0] = t[0] - 0xffed;
|
||||
for (i = 1; i < 15; ++i) {
|
||||
m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
|
||||
m[i - 1] &= 0xffff;
|
||||
}
|
||||
m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1);
|
||||
b = (m[15] >> 16) & 1;
|
||||
m[14] &= 0xffff;
|
||||
cswap(t, m, 1 - b);
|
||||
}
|
||||
for (i = 0; i < 16; ++i) {
|
||||
o[2 * i] = t[i] & 0xff;
|
||||
o[2 * i + 1] = t[i] >> 8;
|
||||
}
|
||||
}
|
||||
|
||||
static inline void unpack(fe o, const uint8_t *n)
|
||||
{
|
||||
int i;
|
||||
|
||||
for (i = 0; i < 16; ++i)
|
||||
o[i] = n[2 * i] + ((int64_t)n[2 * i + 1] << 8);
|
||||
o[15] &= 0x7fff;
|
||||
}
|
||||
|
||||
static inline void add(fe o, const fe a, const fe b)
|
||||
{
|
||||
int i;
|
||||
|
||||
for (i = 0; i < 16; ++i)
|
||||
o[i] = a[i] + b[i];
|
||||
}
|
||||
|
||||
static inline void subtract(fe o, const fe a, const fe b)
|
||||
{
|
||||
int i;
|
||||
|
||||
for (i = 0; i < 16; ++i)
|
||||
o[i] = a[i] - b[i];
|
||||
}
|
||||
|
||||
static inline void multmod(fe o, const fe a, const fe b)
|
||||
{
|
||||
int i, j;
|
||||
int64_t t[31] = { 0 };
|
||||
|
||||
for (i = 0; i < 16; ++i) {
|
||||
for (j = 0; j < 16; ++j)
|
||||
t[i + j] += a[i] * b[j];
|
||||
}
|
||||
for (i = 0; i < 15; ++i)
|
||||
t[i] += 38 * t[i + 16];
|
||||
memcpy(o, t, sizeof(fe));
|
||||
carry(o);
|
||||
carry(o);
|
||||
}
|
||||
|
||||
static inline void invert(fe o, const fe i)
|
||||
{
|
||||
fe c;
|
||||
int a;
|
||||
|
||||
memcpy(c, i, sizeof(c));
|
||||
for (a = 253; a >= 0; --a) {
|
||||
multmod(c, c, c);
|
||||
if (a != 2 && a != 4)
|
||||
multmod(c, c, i);
|
||||
}
|
||||
memcpy(o, c, sizeof(fe));
|
||||
}
|
||||
|
||||
static void curve25519_shared_secret(uint8_t shared_secret[32], const uint8_t private_key[32], const uint8_t public_key[32])
|
||||
{
|
||||
static const fe a24 = { 0xdb41, 1 };
|
||||
uint8_t z[32];
|
||||
int64_t r;
|
||||
int i;
|
||||
fe a = { 1 }, b, c = { 0 }, d = { 1 }, e, f, x;
|
||||
|
||||
memcpy(z, private_key, sizeof(z));
|
||||
|
||||
z[31] = (z[31] & 127) | 64;
|
||||
z[0] &= 248;
|
||||
|
||||
unpack(x, public_key);
|
||||
memcpy(b, x, sizeof(b));
|
||||
|
||||
for (i = 254; i >= 0; --i) {
|
||||
r = (z[i >> 3] >> (i & 7)) & 1;
|
||||
cswap(a, b, (int)r);
|
||||
cswap(c, d, (int)r);
|
||||
add(e, a, c);
|
||||
subtract(a, a, c);
|
||||
add(c, b, d);
|
||||
subtract(b, b, d);
|
||||
multmod(d, e, e);
|
||||
multmod(f, a, a);
|
||||
multmod(a, c, a);
|
||||
multmod(c, b, e);
|
||||
add(e, a, c);
|
||||
subtract(a, a, c);
|
||||
multmod(b, a, a);
|
||||
subtract(c, d, f);
|
||||
multmod(a, c, a24);
|
||||
add(a, a, d);
|
||||
multmod(c, c, a);
|
||||
multmod(a, d, f);
|
||||
multmod(d, b, x);
|
||||
multmod(b, e, e);
|
||||
cswap(a, b, (int)r);
|
||||
cswap(c, d, (int)r);
|
||||
}
|
||||
invert(c, c);
|
||||
multmod(a, a, c);
|
||||
pack(shared_secret, a);
|
||||
}
|
||||
|
||||
void curve25519_derive_public_key(uint8_t public_key[32], const uint8_t private_key[32])
|
||||
{
|
||||
static const uint8_t basepoint[32] = { 9 };
|
||||
|
||||
curve25519_shared_secret(public_key, private_key, basepoint);
|
||||
}
|
||||
|
||||
void curve25519_generate_private_key(uint8_t private_key[32])
|
||||
{
|
||||
assert(CCRandomGenerateBytes(private_key, 32) == kCCSuccess);
|
||||
private_key[31] = (private_key[31] & 127) | 64;
|
||||
private_key[0] &= 248;
|
||||
}
|
||||
7
ios/WireGuardKit/Sources/WireGuardKitC/x25519.h
Normal file
@@ -0,0 +1,7 @@
|
||||
#ifndef X25519_H
|
||||
#define X25519_H
|
||||
|
||||
void curve25519_derive_public_key(unsigned char public_key[32], const unsigned char private_key[32]);
|
||||
void curve25519_generate_private_key(unsigned char private_key[32]);
|
||||
|
||||
#endif
|
||||
1
ios/WireGuardKit/Sources/WireGuardKitGo/dummy.c
Normal file
@@ -0,0 +1 @@
|
||||
// Empty
|
||||
5
ios/WireGuardKit/Sources/WireGuardKitGo/module.modulemap
Normal file
@@ -0,0 +1,5 @@
|
||||
module WireGuardKitGo {
|
||||
umbrella header "wireguard.h"
|
||||
link "wg-go"
|
||||
export *
|
||||
}
|
||||
23
ios/WireGuardKit/Sources/WireGuardKitGo/wireguard.h
Normal file
@@ -0,0 +1,23 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef WIREGUARD_H
|
||||
#define WIREGUARD_H
|
||||
|
||||
#include <sys/types.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef void(*logger_fn_t)(void *context, int level, const char *msg);
|
||||
extern void wgSetLogger(void *context, logger_fn_t logger_fn);
|
||||
extern int wgTurnOn(const char *settings, int32_t tun_fd);
|
||||
extern void wgTurnOff(int handle);
|
||||
extern int64_t wgSetConfig(int handle, const char *settings);
|
||||
extern char *wgGetConfig(int handle);
|
||||
extern void wgBumpSockets(int handle);
|
||||
extern void wgDisableSomeRoamingForBrokenMobileSemantics(int handle);
|
||||
extern const char *wgVersion();
|
||||
|
||||
#endif
|
||||
86
ios/project.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
# xcodegen spec: `xcodegen generate` -> PCAPhobos.xcodeproj
|
||||
# Signing: set DEVELOPMENT_TEAM (paid Apple Developer account — Network Extension needs it).
|
||||
name: PCAPhobos
|
||||
options:
|
||||
bundleIdPrefix: com.bobyrev
|
||||
deploymentTarget:
|
||||
iOS: "16.0"
|
||||
packages:
|
||||
WireGuardKit:
|
||||
path: WireGuardKit
|
||||
settings:
|
||||
base:
|
||||
MARKETING_VERSION: "1.0"
|
||||
CURRENT_PROJECT_VERSION: "6"
|
||||
DEVELOPMENT_TEAM: Q5FKU39TUA
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
targets:
|
||||
PCAPhobos:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- App
|
||||
- Shared
|
||||
resources:
|
||||
- App/Assets.xcassets
|
||||
dependencies:
|
||||
- target: PhobosTunnel
|
||||
entitlements:
|
||||
path: App/PCAPhobos.entitlements
|
||||
properties:
|
||||
com.apple.developer.networking.networkextension: [packet-tunnel-provider]
|
||||
info:
|
||||
path: App/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: PCA Phobos
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
UILaunchScreen: {}
|
||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
||||
UIRequiresFullScreen: true
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: com.bobyrev.pcaphobos.link
|
||||
CFBundleURLSchemes: [phobos]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.bobyrev.pcaphobos
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
PhobosTunnel:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
sources:
|
||||
- Tunnel/PacketTunnelProvider.swift
|
||||
- Tunnel/TunnelConfiguration+WgQuickConfig.swift
|
||||
- Tunnel/String+ArrayConversion.swift
|
||||
- Tunnel/pcarelay.c
|
||||
- Tunnel/WsLink.swift
|
||||
- Shared
|
||||
- path: Tunnel/ru_bypass.txt
|
||||
buildPhase: resources
|
||||
- path: Tunnel/obfuscator
|
||||
compilerFlags: ["-Dmain=wgobf_main", "-w"]
|
||||
excludes: ["*.h"]
|
||||
dependencies:
|
||||
- package: WireGuardKit
|
||||
entitlements:
|
||||
path: Tunnel/PhobosTunnel.entitlements
|
||||
properties:
|
||||
com.apple.developer.networking.networkextension: [packet-tunnel-provider]
|
||||
info:
|
||||
path: Tunnel/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: PhobosTunnel
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).PacketTunnelProvider
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.bobyrev.pcaphobos.tunnel
|
||||
SWIFT_OBJC_BRIDGING_HEADER: Tunnel/Tunnel-Bridging-Header.h
|
||||
HEADER_SEARCH_PATHS: ["$(PROJECT_DIR)/Tunnel", "$(PROJECT_DIR)/Tunnel/obfuscator"]
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": ["$(PROJECT_DIR)/WireGuardGo/iphoneos"]
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": ["$(PROJECT_DIR)/WireGuardGo/iphonesimulator"]
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64
|
||||
@@ -456,27 +456,21 @@ deploy_health_monitor() {
|
||||
[ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line=""
|
||||
|
||||
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
|
||||
# Install cron if not present (Entware)
|
||||
if ! command -v crontab >/dev/null 2>&1 && command -v opkg >/dev/null 2>&1; then
|
||||
log " Установка cron через opkg..."
|
||||
opkg update >/dev/null 2>&1
|
||||
opkg install cron >/dev/null 2>&1
|
||||
/opt/etc/init.d/S10cron start >/dev/null 2>&1 || true
|
||||
fi
|
||||
local cron_file="/opt/etc/crontab"
|
||||
# Create crontab file if missing
|
||||
if [ ! -f "$cron_file" ]; then
|
||||
mkdir -p /opt/etc
|
||||
touch "$cron_file"
|
||||
fi
|
||||
if ! grep -q "phobos-health" "$cron_file" 2>/dev/null; then
|
||||
echo "$cron_line" >> "$cron_file"
|
||||
log " Cron (health) добавлен в $cron_file"
|
||||
fi
|
||||
if [ -n "$pull_line" ] && ! grep -q "phobos-pull" "$cron_file" 2>/dev/null; then
|
||||
echo "$pull_line" >> "$cron_file"
|
||||
log " Cron (pull) добавлен в $cron_file"
|
||||
# Демон — busybox crond (есть в Entware как /opt/sbin/crond). Entware-пакет cron
|
||||
# (vixie, PROCS=cron) на Keenetic задачи не выполнял — заменяем (01.11.2026)
|
||||
local I=/opt/etc/init.d/S10cron
|
||||
if [ ! -x $I ] || grep -q "^PROCS=cron$" $I; then
|
||||
[ -x $I ] && $I stop </dev/null >/dev/null 2>&1
|
||||
printf '#!/bin/sh\n\nENABLED=yes\nPROCS=crond\nARGS="-c /opt/var/spool/cron/crontabs -b"\nPREARGS=""\nDESC=$PROCS\nPATH=/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n. /opt/etc/init.d/rc.func\n' > $I
|
||||
chmod +x $I
|
||||
fi
|
||||
[ -x /opt/sbin/crond ] || command -v crond >/dev/null 2>&1 || log " ВНИМАНИЕ: crond не найден — health/pull по расписанию работать не будут (opkg install cron или busybox с crond)"
|
||||
mkdir -p /opt/var/spool/cron/crontabs
|
||||
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
|
||||
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
|
||||
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
|
||||
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 -
|
||||
|
||||
@@ -220,7 +220,7 @@ action_package() {
|
||||
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
|
||||
|
||||
# Fetch missing router-arch binaries from ClusterM on-demand
|
||||
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
|
||||
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') || _tag="" # ponytail: GitHub API rate limit/недоступность не должны ронять сборку пакета — берём уже скачанные бинарники
|
||||
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
|
||||
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
|
||||
for _arch in aarch64 mipsel mips armv7 x86_64; do
|
||||
|
||||
@@ -71,7 +71,7 @@ fetch_config() {
|
||||
if command -v curl >/dev/null; then
|
||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||
fi
|
||||
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
||||
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).
|
||||
|
||||
@@ -69,11 +69,16 @@ def health():
|
||||
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
||||
peer_keys = []
|
||||
handshakes = {}
|
||||
transfer = {}
|
||||
for line in out.strip().split("\n")[1:]:
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 4:
|
||||
pub = parts[0]
|
||||
peer_keys.append(pub)
|
||||
try:
|
||||
transfer[pub] = [int(parts[5]), int(parts[6])]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
handshakes[pub] = int(parts[4])
|
||||
except Exception:
|
||||
@@ -83,6 +88,7 @@ def health():
|
||||
peers = 0
|
||||
peer_keys = []
|
||||
handshakes = {}
|
||||
transfer = {}
|
||||
cpu = cpu_percent()
|
||||
try:
|
||||
parts = Path("/proc/meminfo").read_text().splitlines()
|
||||
@@ -96,7 +102,7 @@ def health():
|
||||
mem_str = f"{used}/{total}MB"
|
||||
except Exception:
|
||||
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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
||||
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим туннелем.
|
||||
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 ──
|
||||
@@ -189,9 +191,18 @@ switch_wg_peer() {
|
||||
# Add the target peer if it is not already present
|
||||
if ! echo "$present" | grep -q "^${new_key}$"; then
|
||||
log "WG PEER add: $new_key"
|
||||
curl -s -X POST "http://localhost:79/rci/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-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
|
||||
# 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
|
||||
|
||||
@@ -71,7 +71,7 @@ fetch_config() {
|
||||
if command -v curl >/dev/null; then
|
||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||
fi
|
||||
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
||||
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).
|
||||
|
||||
@@ -18,7 +18,30 @@ MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
|
||||
MAIN_PROTO="${MAIN_PROTO:-http}"
|
||||
MAIN_PORT="${MAIN_PORT:-8443}"
|
||||
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
||||
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
||||
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
|
||||
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
|
||||
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
||||
fi
|
||||
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||
import ipaddress, sys
|
||||
try:
|
||||
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
|
||||
except ValueError:
|
||||
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
|
||||
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
|
||||
# Считаем, что нужна подсеть этого адреса, и берём /24.
|
||||
try:
|
||||
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
|
||||
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
|
||||
except ValueError:
|
||||
sys.exit("ERROR: bad subnet " + sys.argv[1])
|
||||
if net.version != 4 or not net.is_private or net.prefixlen > 29:
|
||||
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
|
||||
print(net)
|
||||
PY
|
||||
)" || exit 1
|
||||
PHOBOS_DIR="/opt/Phobos"
|
||||
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||
import ipaddress, sys
|
||||
|
||||
@@ -21,8 +21,10 @@ echo " watchdog, бинарники, $PHOBOS_DIR, $PANEL_DIR"
|
||||
echo "=================================================="
|
||||
if [ "${PURGE:-0}" != "1" ]; then
|
||||
printf "Точно удалить ВСЁ? [yes/NO]: "
|
||||
read ans
|
||||
[ "$ans" = "yes" ] || { echo "Отменено."; exit 0; }
|
||||
read -r ans </dev/tty 2>/dev/null || read -r ans
|
||||
# tolerate CR from Windows/phone terminals, spaces, case, "да"
|
||||
ans=$(printf '%s' "$ans" | tr -d '\r[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
case "$ans" in yes|y|да|д) ;; *) echo "Отменено (нужно ввести yes)."; exit 0 ;; esac
|
||||
fi
|
||||
|
||||
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
||||
|
||||
17
update.sh
@@ -42,9 +42,17 @@ restart_panel_service() {
|
||||
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
|
||||
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
|
||||
local ref="$1" rp="$2" out="$3" kind=branch
|
||||
case "$ref" in v[0-9]*) kind=tag;; esac
|
||||
# beta/dev live in the private Gitea repo PCA_Phobos-dev: read-only Gitea token in $PANEL_DIR/.gh_token or GH_TOKEN
|
||||
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
|
||||
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
||||
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out"
|
||||
}
|
||||
@@ -241,6 +249,15 @@ case "${1:-}" in
|
||||
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
|
||||
exit 1
|
||||
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
|
||||
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
|
||||
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
|
||||
|
||||
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-mips
Executable file
BIN
wsgw/bin/wsgw-mipsel
Executable file
BIN
wsgw/bin/wsgw-x86_64
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
@@ -0,0 +1,3 @@
|
||||
module wsgw
|
||||
|
||||
go 1.22
|
||||
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
@@ -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
|
||||