2 Commits

Author SHA1 Message Date
Бобырев Андрей
dff6af2c8e release: 1.4.1 — TCP fallback, Russian sites direct in the Android client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:27:23 +03:00
Бобырев Андрей
071d3695e6 android-client 1.4.1: Russian sites direct by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:03:02 +03:00
18 changed files with 9056 additions and 26 deletions

View File

@@ -19,6 +19,13 @@ phobos-update beta # или: phobos-update dev
---
## 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, параметры обфускатора)

View File

@@ -38,6 +38,7 @@
- **Замер скорости** — сервера (реальная скачка/отдача, кнопка ⚡ на «Серверах»), клиента (живая скорость в карточке + страница `http://10.25.0.1:<порт>/speedtest` на устройстве через VPN).
- **Домен панели** — `https://ваш-домен/` с бесплатным сертификатом Let's Encrypt; старый адрес `http://IP:порт` продолжает работать.
- **Адрес для клиентов и каскад** — клиенты подключаются по домену или через входной сервер (например, в РФ) → ваш сервер; роутеры получают адрес сами.
- **Резервный TCP-канал** — если провайдер режет UDP, клиент сам переходит на TCP (порт 443), обфускация сохраняется.
- **Сторож** — блокировка зондов и сканеров РКН (nftables, список обновляется раз в сутки).
- **WARP / WARP+** — выпуск клиентов в интернет через Cloudflare WARP (всем или выборочно), relay-режим.
- **Бэкап и восстановление** сервера из панели; **терминал роутера в браузере** (ttyd/RCI) за NAT.
@@ -136,8 +137,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.1/PCA-Phobos-VPN.apk) · исходники `android-client/`.
- **PCA Phobos** (админ) — список ваших панелей, открываются в приложении. [Скачать APK](https://git.bobyrev.com/PCA/PCA_Phobos/releases/download/v1.4.1/PCA-Phobos.apk) · исходники `android/`.
## Клиенты для телефона

View File

@@ -1 +1 @@
1.4.0
1.4.1

View File

@@ -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="142"
android:versionName="1.4.2">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
<uses-permission android:name="android.permission.INTERNET" />

File diff suppressed because one or more lines are too long

View 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

Binary file not shown.

Binary file not shown.

View File

@@ -59,7 +59,9 @@ 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 final Tunnel TUNNEL = new Tunnel() {
public String getName() { return "phobos"; }
public void onStateChange(Tunnel.State s) { }
@@ -190,26 +192,52 @@ 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 String obfPart(String conf, boolean viaTcp) {
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
for (String ln : section(conf, "[instance]")) {
String k = key(ln);
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
if (viaTcp && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
sb.append(ln).append('\n');
}
sb.append("verbose = INFO\n");
return sb.toString();
}
/** "# pca-tcp = 443" written by the panel when the server has the TCP fallback. */
private static String tcpPort(String conf) {
for (String ln : conf.split("\n")) {
String t = ln.trim();
if (t.startsWith("#") && t.contains("pca-tcp") && t.contains("=")) return val(t);
}
return "";
}
private String target(String conf) {
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
return "?";
@@ -234,24 +262,39 @@ public class MainActivity extends Activity {
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
}
/** Start (or restart) obfuscator; via TCP it dials the local udptcp relay. */
private void startTransport(String conf, boolean viaTcp) throws Exception {
stopObfuscator();
String lib = getApplicationInfo().nativeLibraryDir;
if (viaTcp) {
String host = target(conf).replaceAll(":\\d+$", "");
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
}
File cfg = new File(getFilesDir(), "obf.conf");
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp).getBytes(StandardCharsets.UTF_8)); }
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
Thread.sleep(600);
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
transport = viaTcp ? "TCP" : "UDP";
log("transport " + transport + " -> " + target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : ""));
}
private void up() {
try {
String conf = prefs.getString("conf", "");
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, "tcp".equals(mode));
Config c = Config.parse(new BufferedReader(new StringReader(
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
backend.setState(TUNNEL, Tunnel.State.UP, c);
log("up ok, 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 +302,30 @@ 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, true);
post("UDP не отвечает — переключился на TCP");
} catch (Throwable e) { log("watchUdp: " + e); }
}).start();
}
private void down() {
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
stopObfuscator();
transport = "";
post("Отключено");
}
private static void stopObfuscator() {
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
if (relay != null) { relay.destroy(); relay = null; }
}
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
@@ -342,6 +401,26 @@ 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"}}) {
RadioButton r = new RadioButton(this);
r.setText(m[1]);
r.setTextColor(text());
r.setId(View.generateViewId());
r.setChecked(m[0].equals(tmode));
r.setOnClickListener(v -> { prefs.edit().putString("transport", m[0]).apply(); applyIfUp(); });
tg.addView(r);
}
split.addView(tg);
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
@@ -363,7 +442,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);
@@ -431,8 +510,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 +523,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);

6
app.py
View File

@@ -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.1"
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"
@@ -1390,6 +1390,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()

View File

@@ -243,6 +243,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 +300,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

File diff suppressed because it is too large Load Diff

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

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

6
udptcp/README.md Normal file
View File

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

BIN
udptcp/bin/udptcp-aarch64 Executable file

Binary file not shown.

BIN
udptcp/bin/udptcp-x86_64 Executable file

Binary file not shown.

175
udptcp/udptcp.c Normal file
View File

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

View File

@@ -86,10 +86,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
@@ -251,6 +266,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