release: 1.4.4 — channel «Облако» (WebSocket via KeenDNS cloud to own Keenetic): wsgw gateway, Android 1.4.4, iOS, docs/cloud.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Бобырев Андрей
2026-10-31 05:16:31 +03:00
parent d795bc6f70
commit dde6403cea
38 changed files with 855 additions and 58 deletions

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="143"
android:versionName="1.4.3">
android:versionCode="144"
android:versionName="1.4.4">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
<uses-permission android:name="android.permission.INTERNET" />

View File

@@ -61,7 +61,8 @@ public class MainActivity extends Activity {
static GoBackend backend;
static Process obfuscator, relay;
static final int TCP_LPORT = 13256;
static volatile String transport = ""; // "UDP" | "TCP" while up
static volatile String transport = ""; // "UDP" | "TCP" | "Облако" while up
static WsRelay wsRelay;
static final Tunnel TUNNEL = new Tunnel() {
public String getName() { return "phobos"; }
public void onStateChange(Tunnel.State s) { }
@@ -220,14 +221,14 @@ public class MainActivity extends Activity {
private static final java.util.Set<String> OBF_KEYS = new java.util.HashSet<>(java.util.Arrays.asList(
"target", "key", "masking", "idle-timeout", "max-dummy", "static-bindings", "max-clients", "target-if", "allow-clean", "in-timeout", "resolve-interval"));
private String obfPart(String conf, boolean viaTcp) {
private String obfPart(String conf, boolean viaRelay) {
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
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 (viaTcp && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
if (viaRelay && k.equals("target")) ln = "target = 127.0.0.1:" + TCP_LPORT;
sb.append(ln).append('\n');
}
sb.append("verbose = INFO\n");
@@ -243,6 +244,17 @@ public class MainActivity extends Activity {
return "";
}
/** wss:// of the user's Keenetic (KeenDNS cloud subdomain -> wsgw): set in the app, or "# pca-ws = wss://…" in the link. */
private String wsUrl(String conf) {
String u = prefs.getString("ws_url", "");
if (!u.isEmpty()) return u;
for (String ln : conf.split("\n")) {
String t = ln.trim();
if (t.startsWith("#") && t.contains("pca-ws") && t.contains("=")) return val(t);
}
return "";
}
private String target(String conf) {
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
return "?";
@@ -267,24 +279,30 @@ public class MainActivity extends Activity {
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
}
/** Start (or restart) obfuscator; via TCP it dials the local udptcp relay. */
private void startTransport(String conf, boolean viaTcp) throws Exception {
/** Start (or restart) obfuscator; via TCP / cloud it dials the local relay on TCP_LPORT. */
private void startTransport(String conf, String via) throws Exception {
stopObfuscator();
String lib = getApplicationInfo().nativeLibraryDir;
boolean viaTcp = "tcp".equals(via), viaWs = "cloud".equals(via);
if (viaWs) {
if (wsUrl(conf).isEmpty()) throw new IllegalStateException("не задан адрес роутера (wss://…) для канала «Облако»");
wsRelay = new WsRelay(TCP_LPORT, wsUrl(conf), this::log);
wsRelay.start();
}
if (viaTcp) {
String host = target(conf).replaceAll(":\\d+$", "");
relay = new ProcessBuilder(lib + "/libudptcp.so", "-c", "127.0.0.1:" + TCP_LPORT, host + ":" + tcpPort(conf))
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "tcp.log")).start();
}
File cfg = new File(getFilesDir(), "obf.conf");
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp).getBytes(StandardCharsets.UTF_8)); }
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf, viaTcp || viaWs).getBytes(StandardCharsets.UTF_8)); }
obfuscator = new ProcessBuilder(lib + "/libwgobfuscator.so", "-c", cfg.getAbsolutePath())
.redirectErrorStream(true).redirectOutput(new File(getFilesDir(), "obf.log")).start();
Thread.sleep(600);
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
if (relay != null && !relay.isAlive()) throw new IllegalStateException("TCP-канал не запустился — см. «Журнал»");
transport = viaTcp ? "TCP" : "UDP";
log("transport " + transport + " -> " + target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : ""));
transport = viaWs ? "Облако" : viaTcp ? "TCP" : "UDP";
log("transport " + transport + " -> " + (viaWs ? wsUrl(conf) : target(conf) + (viaTcp ? " tcp:" + tcpPort(conf) : "")));
}
private void up() {
@@ -293,7 +311,7 @@ public class MainActivity extends Activity {
String mode = prefs.getString("transport", "auto");
boolean tcpAvail = !tcpPort(conf).isEmpty();
if ("tcp".equals(mode) && !tcpAvail) throw new IllegalStateException("на сервере нет TCP-канала — обновите панель и заново импортируйте ссылку");
startTransport(conf, "tcp".equals(mode));
startTransport(conf, "auto".equals(mode) ? "udp" : mode);
Config c = Config.parse(new BufferedReader(new StringReader(
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
backend.setState(TUNNEL, Tunnel.State.UP, c);
@@ -315,7 +333,7 @@ public class MainActivity extends Activity {
if (!isUp() || !"UDP".equals(transport)) return;
if (backend.getStatistics(TUNNEL).totalRx() > 0) return;
log("UDP: no answer in 15 s -> switching to TCP");
startTransport(conf, true);
startTransport(conf, "tcp");
post("UDP не отвечает — переключился на TCP");
} catch (Throwable e) { log("watchUdp: " + e); }
}).start();
@@ -331,6 +349,7 @@ public class MainActivity extends Activity {
private static void stopObfuscator() {
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
if (relay != null) { relay.destroy(); relay = null; }
if (wsRelay != null) { wsRelay.stop(); wsRelay = null; }
}
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
@@ -416,13 +435,16 @@ public class MainActivity extends Activity {
RadioGroup tg = new RadioGroup(this);
tg.setOrientation(RadioGroup.HORIZONTAL);
String tmode = prefs.getString("transport", "auto");
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}}) {
for (String[] m : new String[][]{{"auto", "Авто"}, {"udp", "UDP"}, {"tcp", "TCP"}, {"cloud", "Облако"}}) {
RadioButton r = new RadioButton(this);
r.setText(m[1]);
r.setTextColor(text());
r.setId(View.generateViewId());
r.setChecked(m[0].equals(tmode));
r.setOnClickListener(v -> { prefs.edit().putString("transport", m[0]).apply(); applyIfUp(); });
r.setOnClickListener(v -> {
prefs.edit().putString("transport", m[0]).apply();
if ("cloud".equals(m[0])) wsDialog(); else applyIfUp();
});
tg.addView(r);
}
split.addView(tg);
@@ -478,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);

View File

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