Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc9fda65b | ||
|
|
8bfc84f315 | ||
|
|
381e97a564 | ||
|
|
eefe3fc2af | ||
|
|
15011c2756 | ||
|
|
29790aaf4d | ||
|
|
884df15f42 | ||
|
|
eb2b2768cd | ||
|
|
147ef3a42d | ||
|
|
dde6403cea | ||
|
|
d795bc6f70 | ||
|
|
c139debd8d | ||
|
|
01632034b0 | ||
|
|
071c1dc7f1 | ||
|
|
dff6af2c8e | ||
|
|
071d3695e6 |
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
windows/build/
|
||||
|
||||
33
CHANGELOG.md
@@ -19,6 +19,39 @@ phobos-update beta # или: phobos-update dev
|
||||
|
||||
---
|
||||
|
||||
## 1.4.7 — роутеры Keenetic сами переключаются между серверами
|
||||
- Исправлено: после переключения на другой сервер туннель на роутере переставал работать — пир прописывался на порт обфускатора 13255, а он мог слушать другой. Порт теперь берётся из `wg-obfuscator.conf`.
|
||||
- Исправлено: проверка и обновление конфига по расписанию на Keenetic не запускались — задачи писались в `/opt/etc/crontab`, который демон не читает. Теперь busybox `crond` и crontab пользователя root.
|
||||
- Исправлено: `phobos-pull` навсегда зависал на `wget`, если туннель не работает (таймаут 8 с).
|
||||
- Роутерам: переустановить клиента или обновить скрипты из панели; серверу — `phobos-update stable`.
|
||||
|
||||
## 1.4.6 — роутеры снова получают смену адреса
|
||||
- Исправлено: роутер получал 403 при pull конфига, если клиент создан через API/бота (`/api/client/ensure`) — смена адреса/сервера до него не доходила. Теперь принимается и общий ключ из пакета, и личный токен клиента. Роутеры переустанавливать не нужно — достаточно обновить панель.
|
||||
|
||||
## 1.4.5 — подсеть при установке, beta/dev по токену
|
||||
|
||||
- **feat:** beta и dev теперь берутся из приватного репозитория на Gitea (GitHub заблокирован): `phobos-update beta|dev` с токеном подписчика — в панели «Настройки → Версия и обновления → токен подписчика» или `GH_TOKEN=… phobos-update beta`. Без токена updater сообщает, что канал закрыт, и ничего не трогает.
|
||||
- **fix:** при переустановке на сервер с готовым `wg0.conf` подсеть берётся из него.
|
||||
|
||||
- **feat:** подсеть VPN-клиентов задаётся при установке: `install.sh` и `secondary-setup.sh` спрашивают её в терминале (Enter = `10.25.0.0/16`) или берут `SERVER_WG_IPV4_NETWORK=10.25.1.0/24`. Неверная или публичная сеть — установка останавливается с ошибкой.
|
||||
|
||||
## 1.4.4 — канал «Облако» (через KeenDNS своего Keenetic)
|
||||
- **Канал «Облако»** в Android PCA Phobos VPN 1.4.4 и iPhone (TestFlight 1.0 (6)): телефон → облако KeenDNS → ваш Keenetic → сервер. Помогает, когда сам сервер заблокирован, а сайты Keenetic открываются; белый IP роутеру не нужен.
|
||||
- **wsgw** — шлюз WebSocket → UDP для Keenetic (Entware: aarch64, mipsel, mips) и Linux. Инструкция с картинками — [docs/cloud.md](docs/cloud.md).
|
||||
- Панель: ссылки на приложения ведут на v1.4.4. Остальной сервер не менялся.
|
||||
|
||||
## 1.4.2 — настройка обфускатора сервера
|
||||
|
||||
- **feat:** в панели «Настройки» → «Обфускатор сервера»: masking (AUTO/STUN/NONE), max-dummy, idle — записываются во все порты сервера, обфускатор перезапускается (клиенты переподключаются за секунды).
|
||||
- **fix (Android PCA Phobos VPN 1.4.3):** конфиги других сборок Phobos с лишними ключами (`obfuscate-bytes` и т. п.) больше не роняют обфускатор («обфускатор завершился, код 1»): передаются только ключи, известные wg-obfuscator v1.6.
|
||||
|
||||
## 1.4.1 — резервный TCP-канал, «РФ напрямую»
|
||||
|
||||
- **feat:** резервный канал TCP: если провайдер режет UDP, клиент сам переходит на TCP (порт 443, иначе 8443/2096/4433). Сервер: служба `phobos-tcp` (`udptcp`), ставится и включается автоматически при установке и обновлении. Проверено реальной блокировкой UDP: переключение за ~15 с, без блокировки остаётся UDP.
|
||||
- **feat (Android 1.4.2):** «Российские сайты — напрямую, без VPN» (по умолчанию), выбор канала Авто/UDP/TCP, журнал TCP-канала.
|
||||
- **fix:** установка и обновления с git.bobyrev.com (GitHub — запасной), каскад раздаёт панель.
|
||||
- Ограничения: TCP-резерв пока только на основном сервере (не на вторых) и только в приложении PCA Phobos VPN (роутеры и PhobosWG — по-прежнему UDP с перебором портов).
|
||||
|
||||
## 1.4.0 — единая версия (stable = beta = dev)
|
||||
|
||||
Три канала сведены в одну версию: всё из dev (WARP, ttyd, бэкап, маршрут Keenetic, параметры обфускатора)
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
- **Замер скорости** — сервера (реальная скачка/отдача, кнопка ⚡ на «Серверах»), клиента (живая скорость в карточке + страница `http://10.25.0.1:<порт>/speedtest` на устройстве через VPN).
|
||||
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
|
||||
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
|
||||
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
|
||||
- **Канал «Облако»** — если сервер заблокирован целиком: телефон идёт через облако KeenDNS своего Keenetic. [Инструкция](docs/cloud.md).
|
||||
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
|
||||
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
|
||||
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
|
||||
@@ -97,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-уведомления |
|
||||
@@ -136,8 +139,8 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/server/
|
||||
|
||||
## Приложения для Android
|
||||
|
||||
- **PCA Phobos VPN** — клиент: включает VPN одной кнопкой, импорт `phobos://` из панели, встроенный обфускатор, **раздельный трафик** (весь / только выбранные приложения / все кроме выбранных), журнал, проверка скорости. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.0/PCA-Phobos-VPN.apk) · исходники `android-client/`.
|
||||
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.0/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="140"
|
||||
android:versionName="1.4.0">
|
||||
android:versionCode="144"
|
||||
android:versionName="1.4.4">
|
||||
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
2
android-client/assets/ru_bypass.txt
Normal file
@@ -14,10 +14,11 @@ rm -rf build && mkdir -p build/res build/classes build/dex build/aar build/apk/l
|
||||
unzip -q "$AAR" -d build/aar
|
||||
LIBS="build/aar/classes.jar:$(ls libs/*.jar | tr '\n' ':')"
|
||||
"$BT/aapt2" compile --dir res -o build/res/
|
||||
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml \
|
||||
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml -A assets \
|
||||
--java build/gen build/res/*.flat --min-sdk-version 24 --target-sdk-version 35
|
||||
javac -source 11 -target 11 -nowarn -classpath "$JAR:$LIBS" -d build/classes \
|
||||
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath\|^Note:" || true
|
||||
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath\|^Note:\|^warning: \[options\]\|^1 warning" || true
|
||||
[ -f build/classes/com/pca/phobos/vpn/MainActivity.class ] || { echo "javac failed"; exit 1; }
|
||||
"$BT/d8" --release --lib "$JAR" --min-api 24 --output build/dex \
|
||||
$(find build/classes -name '*.class') build/aar/classes.jar libs/*.jar
|
||||
cp build/base.apk build/unsigned.apk
|
||||
@@ -25,7 +26,7 @@ cp build/base.apk build/unsigned.apk
|
||||
for abi in arm64-v8a armeabi-v7a; do
|
||||
mkdir -p "build/apk/lib/$abi"
|
||||
cp build/aar/jni/$abi/*.so "build/apk/lib/$abi/"
|
||||
cp "jni/$abi/libwgobfuscator.so" "build/apk/lib/$abi/"
|
||||
cp "jni/$abi/libwgobfuscator.so" "jni/$abi/libudptcp.so" "build/apk/lib/$abi/"
|
||||
done
|
||||
(cd build/apk && zip -qr ../unsigned.apk lib)
|
||||
"$BT/zipalign" -f 4 build/unsigned.apk build/aligned.apk
|
||||
|
||||
BIN
android-client/jni/arm64-v8a/libudptcp.so
Executable file
BIN
android-client/jni/armeabi-v7a/libudptcp.so
Executable file
@@ -59,7 +59,10 @@ public class MainActivity extends Activity {
|
||||
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
|
||||
|
||||
static GoBackend backend;
|
||||
static Process obfuscator;
|
||||
static Process obfuscator, relay;
|
||||
static final int TCP_LPORT = 13256;
|
||||
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) { }
|
||||
@@ -190,26 +193,68 @@ public class MainActivity extends Activity {
|
||||
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
|
||||
}
|
||||
sb.append("\n[Peer]\n");
|
||||
String ruBypass = prefs != null && prefs.getBoolean("ru_direct", true) ? ruBypassAllowedIps() : null;
|
||||
for (String ln : section(conf, "[Peer]")) {
|
||||
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
|
||||
if (key(ln).equals("allowedips") && ruBypass != null) ln = "AllowedIPs = " + ruBypass;
|
||||
sb.append(ln).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String ruCache;
|
||||
|
||||
/** Internet minus Russian networks (assets/ru_bypass.txt, generated from ipdeny). */
|
||||
private String ruBypassAllowedIps() {
|
||||
if (ruCache != null) return ruCache;
|
||||
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(
|
||||
getAssets().open("ru_bypass.txt"), StandardCharsets.UTF_8))) {
|
||||
for (String ln; (ln = r.readLine()) != null; ) {
|
||||
if (!ln.startsWith("#") && ln.contains("/")) { ruCache = ln.trim(); break; }
|
||||
}
|
||||
} catch (Exception e) { log("ru_bypass: " + e); }
|
||||
return ruCache;
|
||||
}
|
||||
|
||||
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
|
||||
|
||||
private String obfPart(String conf) {
|
||||
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;
|
||||
// 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");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** "# pca-tcp = 443" written by the panel when the server has the TCP fallback. */
|
||||
private static String tcpPort(String conf) {
|
||||
for (String ln : conf.split("\n")) {
|
||||
String t = ln.trim();
|
||||
if (t.startsWith("#") && t.contains("pca-tcp") && t.contains("=")) return val(t);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** wss:// of the user's Keenetic (KeenDNS cloud subdomain -> wsgw): set in the app, or "# pca-ws = wss://…" in the link. */
|
||||
private String wsUrl(String conf) {
|
||||
String u = prefs.getString("ws_url", "");
|
||||
if (!u.isEmpty()) return u;
|
||||
for (String ln : conf.split("\n")) {
|
||||
String t = ln.trim();
|
||||
if (t.startsWith("#") && t.contains("pca-ws") && t.contains("=")) return val(t);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String target(String conf) {
|
||||
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
||||
return "?";
|
||||
@@ -234,24 +279,45 @@ public class MainActivity extends Activity {
|
||||
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
||||
}
|
||||
|
||||
/** Start (or restart) obfuscator; via TCP / cloud it dials the local relay on TCP_LPORT. */
|
||||
private void startTransport(String conf, String via) throws Exception {
|
||||
stopObfuscator();
|
||||
String lib = getApplicationInfo().nativeLibraryDir;
|
||||
boolean viaTcp = "tcp".equals(via), viaWs = "cloud".equals(via);
|
||||
if (viaWs) {
|
||||
if (wsUrl(conf).isEmpty()) throw new IllegalStateException("не задан адрес роутера (wss://…) для канала «Облако»");
|
||||
wsRelay = new WsRelay(TCP_LPORT, wsUrl(conf), this::log);
|
||||
wsRelay.start();
|
||||
}
|
||||
if (viaTcp) {
|
||||
String host = target(conf).replaceAll(":\\d+$", "");
|
||||
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
|
||||
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
|
||||
}
|
||||
File cfg = new File(getFilesDir(), "obf.conf");
|
||||
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp || viaWs).getBytes(StandardCharsets.UTF_8)); }
|
||||
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
|
||||
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
|
||||
Thread.sleep(600);
|
||||
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
||||
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
|
||||
transport = viaWs ? "Облако" : viaTcp ? "TCP" : "UDP";
|
||||
log("transport " + transport + " -> " + (viaWs ? wsUrl(conf) : target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : "")));
|
||||
}
|
||||
|
||||
private void up() {
|
||||
try {
|
||||
String conf = prefs.getString("conf", "");
|
||||
File cfg = new File(getFilesDir(), "obf.conf");
|
||||
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf).getBytes(StandardCharsets.UTF_8)); }
|
||||
stopObfuscator();
|
||||
String bin = getApplicationInfo().nativeLibraryDir + "/libwgobfuscator.so";
|
||||
obfuscator = new ProcessBuilder(bin, "-c", cfg.getAbsolutePath())
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(new File(getFilesDir(), "obf.log"))
|
||||
.start();
|
||||
Thread.sleep(600);
|
||||
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
||||
String mode = prefs.getString("transport", "auto");
|
||||
boolean tcpAvail = !tcpPort(conf).isEmpty();
|
||||
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
|
||||
startTransport(conf, "auto".equals(mode) ? "udp" : mode);
|
||||
Config c = Config.parse(new BufferedReader(new StringReader(
|
||||
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
|
||||
backend.setState(TUNNEL, Tunnel.State.UP, c);
|
||||
log("up ok, target " + target(conf) + ", mode " + prefs.getString("mode", MODE_ALL));
|
||||
post("Подключено");
|
||||
log("up ok, mode " + prefs.getString("mode", MODE_ALL) + ", transport " + mode);
|
||||
post("Подключено (" + transport + ")");
|
||||
if ("auto".equals(mode) && tcpAvail) watchUdp(conf);
|
||||
} catch (Throwable e) {
|
||||
stopObfuscator();
|
||||
log("up failed: " + e);
|
||||
@@ -259,14 +325,31 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto: no bytes back over UDP within 15 s -> UDP is cut, move the obfuscator to TCP. */
|
||||
private void watchUdp(String conf) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(15000);
|
||||
if (!isUp() || !"UDP".equals(transport)) return;
|
||||
if (backend.getStatistics(TUNNEL).totalRx() > 0) return;
|
||||
log("UDP: no answer in 15 s -> switching to TCP");
|
||||
startTransport(conf, "tcp");
|
||||
post("UDP не отвечает — переключился на TCP");
|
||||
} catch (Throwable e) { log("watchUdp: " + e); }
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void down() {
|
||||
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
|
||||
stopObfuscator();
|
||||
transport = "";
|
||||
post("Отключено");
|
||||
}
|
||||
|
||||
private static void stopObfuscator() {
|
||||
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
|
||||
if (relay != null) { relay.destroy(); relay = null; }
|
||||
if (wsRelay != null) { wsRelay.stop(); wsRelay = null; }
|
||||
}
|
||||
|
||||
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
|
||||
@@ -342,6 +425,29 @@ public class MainActivity extends Activity {
|
||||
rg.addView(r);
|
||||
}
|
||||
split.addView(rg);
|
||||
android.widget.CheckBox ru = new android.widget.CheckBox(this);
|
||||
ru.setText("Российские сайты — напрямую, без VPN");
|
||||
ru.setTextColor(text());
|
||||
ru.setChecked(prefs.getBoolean("ru_direct", true));
|
||||
ru.setOnCheckedChangeListener((b, on) -> { prefs.edit().putBoolean("ru_direct", on).apply(); applyIfUp(); });
|
||||
split.addView(ru, mw(4));
|
||||
split.addView(tv("Канал до сервера", text(), 14, true), mw(8));
|
||||
RadioGroup tg = new RadioGroup(this);
|
||||
tg.setOrientation(RadioGroup.HORIZONTAL);
|
||||
String tmode = prefs.getString("transport", "auto");
|
||||
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}, {"cloud", "Облако"}}) {
|
||||
RadioButton r = new RadioButton(this);
|
||||
r.setText(m[1]);
|
||||
r.setTextColor(text());
|
||||
r.setId(View.generateViewId());
|
||||
r.setChecked(m[0].equals(tmode));
|
||||
r.setOnClickListener(v -> {
|
||||
prefs.edit().putString("transport", m[0]).apply();
|
||||
if ("cloud".equals(m[0])) wsDialog(); else applyIfUp();
|
||||
});
|
||||
tg.addView(r);
|
||||
}
|
||||
split.addView(tg);
|
||||
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
|
||||
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
|
||||
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
|
||||
@@ -363,7 +469,7 @@ public class MainActivity extends Activity {
|
||||
boolean up = isUp();
|
||||
power.setText(up ? "Отключить" : "Подключить");
|
||||
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
|
||||
status.setText(up ? "🟢 VPN включён" : "⚪ VPN выключен");
|
||||
status.setText(up ? "🟢 VPN включён" + (transport.isEmpty() ? "" : " · " + transport) : "⚪ VPN выключен");
|
||||
if (up) {
|
||||
try {
|
||||
Statistics st = backend.getStatistics(TUNNEL);
|
||||
@@ -394,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);
|
||||
@@ -431,8 +553,12 @@ public class MainActivity extends Activity {
|
||||
|
||||
private static String tail(File f, int max) {
|
||||
try {
|
||||
byte[] b = java.nio.file.Files.readAllBytes(f.toPath());
|
||||
String t = new String(b, StandardCharsets.UTF_8);
|
||||
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.FileInputStream in = new java.io.FileInputStream(f)) {
|
||||
byte[] buf = new byte[8192];
|
||||
for (int n; (n = in.read(buf)) > 0; ) o.write(buf, 0, n);
|
||||
}
|
||||
String t = new String(o.toByteArray(), StandardCharsets.UTF_8);
|
||||
return t.length() > max ? t.substring(t.length() - max) : t;
|
||||
} catch (Exception e) { return ""; }
|
||||
}
|
||||
@@ -440,6 +566,7 @@ public class MainActivity extends Activity {
|
||||
private void showLog() {
|
||||
String t = "— приложение —\n" + tail(new File(getFilesDir(), "app.log"), 3000)
|
||||
+ "\n— обфускатор —\n" + tail(new File(getFilesDir(), "obf.log"), 3000)
|
||||
+ "\n— TCP-канал —\n" + tail(new File(getFilesDir(), "tcp.log"), 1500)
|
||||
+ "\nобфускатор запущен: " + (obfuscator != null && obfuscator.isAlive());
|
||||
TextView v = tv(t, text(), 12, false);
|
||||
v.setTextIsSelectable(true);
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
75
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.0"
|
||||
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 = {}
|
||||
@@ -1390,6 +1428,10 @@ def build_phone_config(cid, mode="android"):
|
||||
("target = " + _swap_host(ln.split("=", 1)[1].strip(), client_endpoint))
|
||||
if ln.strip().startswith("target") and "=" in ln else ln
|
||||
for ln in inst.split("\n"))
|
||||
tcp_port = _load_server_env().get("TCP_PORT", "")
|
||||
if tcp_port and "pca-tcp" not in inst:
|
||||
# comment: PhobosWG/wg-obfuscator ignore it, PCA Phobos VPN uses it for the TCP fallback
|
||||
inst += "\n# pca-tcp = " + tcp_port
|
||||
conf = wg + "\n\n" + inst + "\n"
|
||||
payload = _prepare_phoboswg_payload(conf)
|
||||
b64 = base64.urlsafe_b64encode(payload.encode()).decode()
|
||||
@@ -2737,6 +2779,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:
|
||||
@@ -2754,6 +2807,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()))
|
||||
@@ -2894,6 +2948,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>
|
||||
@@ -3381,8 +3449,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"})
|
||||
|
||||
@@ -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,43 @@ 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) ──
|
||||
detect_wg_interface || {
|
||||
log "WARN: detect failed, using default $WG_IF"
|
||||
}
|
||||
|
||||
# ── 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 +136,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 +215,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 +225,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 +236,26 @@ switch_endpoint() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restart obfuscator
|
||||
# 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 +265,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)"
|
||||
# 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" &
|
||||
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
|
||||
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 |
32
install.sh
@@ -29,7 +29,34 @@ 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 'Подсеть 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=False)
|
||||
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}"
|
||||
@@ -243,6 +270,8 @@ fetch server/phobos-cascade.sh "$PHOBOS_DIR/server/phobos-cascade.s
|
||||
fetch server/phobos-domain.sh "$PHOBOS_DIR/server/phobos-domain.sh" && chmod +x "$PHOBOS_DIR/server/phobos-domain.sh" && ln -sf "$PHOBOS_DIR/server/phobos-domain.sh" /usr/local/bin/phobos-domain
|
||||
fetch server/phobos-limit.sh "$PHOBOS_DIR/server/phobos-limit.sh" && chmod +x "$PHOBOS_DIR/server/phobos-limit.sh"
|
||||
fetch server/phobos-speedtest.sh "$PHOBOS_DIR/server/phobos-speedtest.sh" && chmod +x "$PHOBOS_DIR/server/phobos-speedtest.sh"
|
||||
fetch server/phobos-tcp.sh "$PHOBOS_DIR/server/phobos-tcp.sh" && chmod +x "$PHOBOS_DIR/server/phobos-tcp.sh" && ln -sf "$PHOBOS_DIR/server/phobos-tcp.sh" /usr/local/bin/phobos-tcp
|
||||
fetch "udptcp/bin/udptcp-$OBF_ARCH" "$PHOBOS_DIR/bin/udptcp" && chmod +x "$PHOBOS_DIR/bin/udptcp"
|
||||
fetch server/phobos-health.sh "$PHOBOS_DIR/server/phobos-health.sh" && chmod +x "$PHOBOS_DIR/server/phobos-health.sh"
|
||||
fetch server/phobos-self-check.sh "$PHOBOS_DIR/server/phobos-self-check.sh" && chmod +x "$PHOBOS_DIR/server/phobos-self-check.sh" && ln -sf "$PHOBOS_DIR/server/phobos-self-check.sh" /usr/local/bin/phobos-self-check
|
||||
fetch server/phobos-pull.sh "$PHOBOS_DIR/server/phobos-pull.sh" && chmod +x "$PHOBOS_DIR/server/phobos-pull.sh"
|
||||
@@ -298,6 +327,9 @@ WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload; systemctl enable phobos-panel -q; systemctl restart phobos-panel
|
||||
|
||||
# TCP fallback for clients whose UDP is cut (port 443/8443/... picked automatically)
|
||||
"$PHOBOS_DIR/server/phobos-tcp.sh" enable || echo " WARN: TCP fallback not enabled"
|
||||
|
||||
# ── 8. nginx (serve /init + /packages over plain HTTP for routers) ──
|
||||
echo "[8/9] nginx..."
|
||||
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
||||
|
||||
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,20 @@ 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
|
||||
$I stop >/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
|
||||
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
|
||||
(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).
|
||||
|
||||
8664
server/data/ru-aggregated.zone
Normal file
@@ -189,9 +189,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).
|
||||
|
||||
65
server/phobos-tcp.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# PCA Phobos — TCP fallback: clients whose UDP is cut reach the obfuscator over TCP.
|
||||
# udptcp -s 0.0.0.0:<TCP_PORT> -> 127.0.0.1:<first obfuscator port> (UDP).
|
||||
# phobos-tcp.sh enable [port] pick a free port (443, 8443, 2096) unless given; idempotent
|
||||
# phobos-tcp.sh disable | status
|
||||
set -uo pipefail
|
||||
PHOBOS_DIR=/opt/Phobos
|
||||
ENV="$PHOBOS_DIR/server/server.env"
|
||||
BIN="$PHOBOS_DIR/bin/udptcp"
|
||||
|
||||
env_get() { grep -E "^$1=" "$ENV" 2>/dev/null | tail -1 | cut -d= -f2-; }
|
||||
env_set() {
|
||||
grep -v -E "^$1=" "$ENV" > "$ENV.tmp" 2>/dev/null || true
|
||||
echo "$1=$2" >> "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
|
||||
}
|
||||
port_busy() { ss -lntH "sport = :$1" 2>/dev/null | grep -q .; }
|
||||
|
||||
enable() {
|
||||
[ -x "$BIN" ] || { echo "udptcp binary missing: $BIN (phobos-update)" >&2; exit 1; }
|
||||
local port="${1:-$(env_get TCP_PORT)}" obf
|
||||
obf=$(env_get OBFUSCATOR_PORTS | cut -d, -f1); obf="${obf:-2083}"
|
||||
# keep our own port if the service already holds it
|
||||
if [ -z "$port" ] || { port_busy "$port" && ! systemctl is-active -q phobos-tcp; }; then
|
||||
port=""
|
||||
for p in 443 8443 2096 4433; do port_busy "$p" || { port=$p; break; }; done
|
||||
fi
|
||||
[ -n "$port" ] || { echo "no free TCP port (443/8443/2096/4433)" >&2; exit 1; }
|
||||
env_set TCP_PORT "$port"
|
||||
cat > /etc/systemd/system/phobos-tcp.service <<EOF
|
||||
[Unit]
|
||||
Description=PCA Phobos TCP fallback (udptcp -> obfuscator :$obf)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
ExecStart=$BIN -s 0.0.0.0:$port 127.0.0.1:$obf
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable -q phobos-tcp
|
||||
systemctl restart phobos-tcp
|
||||
sleep 1
|
||||
status
|
||||
}
|
||||
|
||||
disable() {
|
||||
systemctl disable --now phobos-tcp >/dev/null 2>&1 || true
|
||||
rm -f /etc/systemd/system/phobos-tcp.service
|
||||
systemctl daemon-reload
|
||||
echo "tcp fallback: disabled"
|
||||
}
|
||||
|
||||
status() {
|
||||
printf '{"enabled": %s, "port": "%s"}\n' \
|
||||
"$(systemctl is-active -q phobos-tcp && echo true || echo false)" "$(env_get TCP_PORT)"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
enable) enable "${2:-}" ;;
|
||||
disable) disable ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 enable [port]|disable|status"; exit 2 ;;
|
||||
esac
|
||||
@@ -18,7 +18,23 @@ 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=False)
|
||||
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
|
||||
|
||||
6
udptcp/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# udptcp — резервный TCP-канал PCA Phobos
|
||||
|
||||
UDP-пакеты (уже обфусцированные) внутри TCP: кадр = 2 байта длины + пакет.
|
||||
- сервер: `udptcp -s 0.0.0.0:443 127.0.0.1:2083` (служба `phobos-tcp`, порт в server.env `TCP_PORT`);
|
||||
- клиент: `udptcp -c 127.0.0.1:13256 <сервер>:443`, обфускатор клиента шлёт на 127.0.0.1:13256.
|
||||
Сборка: `gcc -O2 -static -pthread -o bin/udptcp-x86_64 udptcp.c` (+ aarch64-linux-gnu-gcc), Android — NDK (см. android-client/README.md).
|
||||
BIN
udptcp/bin/udptcp-aarch64
Executable file
BIN
udptcp/bin/udptcp-x86_64
Executable file
175
udptcp/udptcp.c
Normal file
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* udptcp — PCA Phobos fallback transport: UDP datagrams carried over TCP.
|
||||
* Frames on the TCP stream: 2-byte big-endian length + payload.
|
||||
*
|
||||
* server: udptcp -s <listen_ip:port> <udp_target_ip:port>
|
||||
* each TCP connection gets its own UDP socket to the target
|
||||
* (the local wg-obfuscator), so the obfuscator sees one client per conn.
|
||||
* client: udptcp -c <local_udp_ip:port> <tcp_server_host:port>
|
||||
* local UDP peer (client wg-obfuscator) <-> one TCP connection, reconnects.
|
||||
*
|
||||
* Plain C, no deps: builds for Linux servers, Android (NDK) and iOS.
|
||||
*/
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MAXPKT 65535
|
||||
|
||||
static int parse_addr(const char *s, struct sockaddr_storage *ss, socklen_t *len, int socktype) {
|
||||
char host[256];
|
||||
const char *colon = strrchr(s, ':');
|
||||
if (!colon || colon == s || (size_t)(colon - s) >= sizeof host) return -1;
|
||||
memcpy(host, s, colon - s);
|
||||
host[colon - s] = 0;
|
||||
struct addrinfo hints = {0}, *res;
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = socktype;
|
||||
if (getaddrinfo(host, colon + 1, &hints, &res) != 0) return -1;
|
||||
memcpy(ss, res->ai_addr, res->ai_addrlen);
|
||||
*len = res->ai_addrlen;
|
||||
freeaddrinfo(res);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int read_full(int fd, unsigned char *buf, size_t n) {
|
||||
size_t got = 0;
|
||||
while (got < n) {
|
||||
ssize_t r = read(fd, buf + got, n - got);
|
||||
if (r <= 0) return -1;
|
||||
got += (size_t)r;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int write_frame(int fd, const unsigned char *p, size_t n) {
|
||||
unsigned char hdr[2] = {(unsigned char)(n >> 8), (unsigned char)(n & 0xff)};
|
||||
if (write(fd, hdr, 2) != 2) return -1;
|
||||
size_t sent = 0;
|
||||
while (sent < n) {
|
||||
ssize_t w = write(fd, p + sent, n - sent);
|
||||
if (w <= 0) return -1;
|
||||
sent += (size_t)w;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void tune_tcp(int fd) {
|
||||
int one = 1;
|
||||
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
|
||||
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof one);
|
||||
}
|
||||
|
||||
/* ── server ─────────────────────────────────────────────────────────────── */
|
||||
static struct sockaddr_storage g_target;
|
||||
static socklen_t g_target_len;
|
||||
|
||||
static void *serve_conn(void *arg) {
|
||||
int tcp = (int)(long)arg;
|
||||
unsigned char buf[MAXPKT + 2];
|
||||
int udp = socket(g_target.ss_family, SOCK_DGRAM, 0);
|
||||
if (udp < 0 || connect(udp, (struct sockaddr *)&g_target, g_target_len) < 0) goto out;
|
||||
tune_tcp(tcp);
|
||||
struct pollfd p[2] = {{tcp, POLLIN, 0}, {udp, POLLIN, 0}};
|
||||
for (;;) {
|
||||
if (poll(p, 2, 300000) <= 0) break; /* 5 min idle -> drop */
|
||||
if (p[0].revents & (POLLIN | POLLHUP | POLLERR)) {
|
||||
unsigned char h[2];
|
||||
if (read_full(tcp, h, 2) < 0) break;
|
||||
size_t n = ((size_t)h[0] << 8) | h[1];
|
||||
if (n == 0 || read_full(tcp, buf, n) < 0) break;
|
||||
send(udp, buf, n, 0);
|
||||
}
|
||||
if (p[1].revents & POLLIN) {
|
||||
ssize_t n = recv(udp, buf, MAXPKT, 0);
|
||||
if (n > 0 && write_frame(tcp, buf, (size_t)n) < 0) break;
|
||||
}
|
||||
}
|
||||
out:
|
||||
if (udp >= 0) close(udp);
|
||||
close(tcp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int run_server(const char *listen_s, const char *target_s) {
|
||||
struct sockaddr_storage la; socklen_t ll;
|
||||
if (parse_addr(listen_s, &la, &ll, SOCK_STREAM) || parse_addr(target_s, &g_target, &g_target_len, SOCK_DGRAM)) {
|
||||
fprintf(stderr, "bad address\n"); return 2;
|
||||
}
|
||||
int ls = socket(la.ss_family, SOCK_STREAM, 0), one = 1;
|
||||
setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
||||
if (bind(ls, (struct sockaddr *)&la, ll) < 0 || listen(ls, 128) < 0) { perror("listen"); return 1; }
|
||||
fprintf(stderr, "udptcp server %s -> udp %s\n", listen_s, target_s);
|
||||
for (;;) {
|
||||
int c = accept(ls, NULL, NULL);
|
||||
if (c < 0) { if (errno == EINTR) continue; perror("accept"); sleep(1); continue; }
|
||||
pthread_t t;
|
||||
if (pthread_create(&t, NULL, serve_conn, (void *)(long)c) == 0) pthread_detach(t);
|
||||
else close(c);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── client ─────────────────────────────────────────────────────────────── */
|
||||
static int connect_tcp(const char *server_s) {
|
||||
struct sockaddr_storage sa; socklen_t sl;
|
||||
if (parse_addr(server_s, &sa, &sl, SOCK_STREAM)) return -1; /* re-resolve each time */
|
||||
int s = socket(sa.ss_family, SOCK_STREAM, 0);
|
||||
if (s < 0) return -1;
|
||||
if (connect(s, (struct sockaddr *)&sa, sl) < 0) { close(s); return -1; }
|
||||
tune_tcp(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
static int run_client(const char *local_s, const char *server_s) {
|
||||
struct sockaddr_storage la, peer; socklen_t ll, pl = 0;
|
||||
if (parse_addr(local_s, &la, &ll, SOCK_DGRAM)) { fprintf(stderr, "bad address\n"); return 2; }
|
||||
int us = socket(la.ss_family, SOCK_DGRAM, 0);
|
||||
if (bind(us, (struct sockaddr *)&la, ll) < 0) { perror("bind"); return 1; }
|
||||
fprintf(stderr, "udptcp client udp %s -> tcp %s\n", local_s, server_s);
|
||||
unsigned char buf[MAXPKT + 2];
|
||||
int tcp = -1;
|
||||
time_t last_try = 0;
|
||||
for (;;) {
|
||||
struct pollfd p[2] = {{us, POLLIN, 0}, {tcp, POLLIN, 0}};
|
||||
poll(p, tcp >= 0 ? 2 : 1, 1000);
|
||||
if (p[0].revents & POLLIN) {
|
||||
pl = sizeof peer;
|
||||
ssize_t n = recvfrom(us, buf, MAXPKT, 0, (struct sockaddr *)&peer, &pl);
|
||||
if (n > 0) {
|
||||
if (tcp < 0 && time(NULL) != last_try) {
|
||||
last_try = time(NULL);
|
||||
tcp = connect_tcp(server_s);
|
||||
fprintf(stderr, tcp >= 0 ? "connected\n" : "connect failed\n");
|
||||
}
|
||||
if (tcp >= 0 && write_frame(tcp, buf, (size_t)n) < 0) { close(tcp); tcp = -1; }
|
||||
}
|
||||
}
|
||||
if (tcp >= 0 && (p[1].revents & (POLLIN | POLLHUP | POLLERR))) {
|
||||
unsigned char h[2];
|
||||
if (read_full(tcp, h, 2) < 0) { close(tcp); tcp = -1; continue; }
|
||||
size_t n = ((size_t)h[0] << 8) | h[1];
|
||||
if (n == 0 || read_full(tcp, buf, n) < 0) { close(tcp); tcp = -1; continue; }
|
||||
if (pl) sendto(us, buf, n, 0, (struct sockaddr *)&peer, pl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
if (argc == 4 && !strcmp(argv[1], "-s")) return run_server(argv[2], argv[3]);
|
||||
if (argc == 4 && !strcmp(argv[1], "-c")) return run_client(argv[2], argv[3]);
|
||||
fprintf(stderr, "usage: udptcp -s <listen ip:port> <udp target ip:port>\n"
|
||||
" udptcp -c <local udp ip:port> <tcp server host:port>\n");
|
||||
return 2;
|
||||
}
|
||||
33
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"
|
||||
}
|
||||
@@ -86,10 +94,25 @@ $PHOBOS_DIR/server/phobos-cascade.sh|server/phobos-cascade.sh
|
||||
$PHOBOS_DIR/server/phobos-domain.sh|server/phobos-domain.sh
|
||||
$PHOBOS_DIR/server/phobos-limit.sh|server/phobos-limit.sh
|
||||
$PHOBOS_DIR/server/phobos-speedtest.sh|server/phobos-speedtest.sh
|
||||
$PHOBOS_DIR/server/phobos-tcp.sh|server/phobos-tcp.sh
|
||||
$PHOBOS_DIR/server/update.sh|update.sh
|
||||
LIST
|
||||
}
|
||||
|
||||
ensure_tcp_fallback() { # $1=ref ; primary only (secondaries have no panel)
|
||||
[ -f "$PANEL_DIR/app.py" ] || return 0
|
||||
local arch tmp
|
||||
arch=$(uname -m); case "$arch" in amd64) arch=x86_64;; arm64) arch=aarch64;; esac
|
||||
tmp=$(mktemp)
|
||||
if fetch_repo_file "$1" "udptcp/bin/udptcp-$arch" "$tmp" && [ -s "$tmp" ]; then
|
||||
install -m755 "$tmp" "$PHOBOS_DIR/bin/udptcp"
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
[ -x "$PHOBOS_DIR/server/phobos-tcp.sh" ] && ln -sf "$PHOBOS_DIR/server/phobos-tcp.sh" /usr/local/bin/phobos-tcp
|
||||
[ -x "$PHOBOS_DIR/bin/udptcp" ] && "$PHOBOS_DIR/server/phobos-tcp.sh" enable >/dev/null 2>&1 \
|
||||
&& echo "TCP fallback: port $(grep '^TCP_PORT=' "$PHOBOS_DIR/server/server.env" | cut -d= -f2)" || true
|
||||
}
|
||||
|
||||
run_self_check() {
|
||||
if [ -x "$PHOBOS_DIR/server/phobos-self-check.sh" ]; then
|
||||
ln -sf "$PHOBOS_DIR/server/phobos-self-check.sh" /usr/local/bin/phobos-self-check 2>/dev/null || true
|
||||
@@ -226,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)"
|
||||
@@ -251,6 +283,7 @@ case "${1:-}" in
|
||||
apply_ref "$ref"
|
||||
ensure_runtime_deps
|
||||
run_self_check
|
||||
ensure_tcp_fallback "$ref"
|
||||
refresh_router_packages
|
||||
echo "$target" > "$VERFILE"
|
||||
restart_panel_service
|
||||
|
||||
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).
|
||||