Refactor RDP proxy handling and update related tests

This commit is contained in:
2026-05-17 20:38:35 +03:00
parent 8e9402580f
commit d551e57fd5
172 changed files with 22117 additions and 2509 deletions
+9 -5
View File
@@ -9,8 +9,10 @@ Implemented now:
while the device session is valid;
- load organization-scoped VPN client profile from `/clusters/{clusterID}/vpn/client-profile`;
- request Android VPN permission and create a `VpnService` TUN interface;
- relay TUN packets through the Control Plane HTTP packet relay to the active
`home-1` gateway lease.
- run as a normal fabric node with the `vpn-client` service role. The local
`VpnService` TUN is the IPv4 ingress for that node, and packet channels are
routed by the farm to an authorized `ipv4-egress` pool. HTTP batch fallback
and old VPN protocols are not part of the supported test path.
- user-facing HOME-first screen: connect/disconnect is primary, while backend,
cluster, organization, login, and password are kept in the settings dialog;
- saved connection settings in app preferences so repeat connects do not require
@@ -19,9 +21,11 @@ Implemented now:
device session is revoked or expires, the app asks for the password once and
then rotates the device keys/profile again.
This is still a lab runtime, not a production WireGuard/IPsec implementation.
The active Linux gateway node must be able to create `/dev/net/tun`, run `ip`,
`sysctl`, and `iptables`, and enable NAT for `10.77.0.0/24`.
This is still a lab runtime. The required target model is Android as a farm
node with the `vpn-client` role. The VPN service must attach to the mesh as
that node and route to an authorized IPv4 exit pool; there is no separate VPN
entry point. Exit configuration is always pool based, including pools that
currently contain only one node.
Build from this repository on Windows:
+6 -3
View File
@@ -22,7 +22,8 @@ android {
return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"")
}
def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "https://vpn.cin.su/api/v1"
def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "http://192.168.200.61:18080/api/v1"
def defaultFabricBootstrapPeers = project.findProperty("RAP_ANDROID_FABRIC_BOOTSTRAP_PEERS") ?: "quic://192.168.200.85:18080,quic://195.123.240.88:19131"
def defaultClusterId = project.findProperty("RAP_ANDROID_DEFAULT_CLUSTER_ID") ?: "cfc0743d-d960-49fb-9de8-96e063d5e4aa"
def defaultOrganizationId = project.findProperty("RAP_ANDROID_DEFAULT_ORGANIZATION_ID") ?: "125ff8b2-5ac1-4406-9bbb-ebbe18f7c7ed"
@@ -30,9 +31,10 @@ android {
applicationId "su.cin.rapvpn"
minSdk 26
targetSdk 35
versionCode 210
versionName "0.2.210"
versionCode 227
versionName "0.2.227"
buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\""
buildConfigField "String", "FABRIC_BOOTSTRAP_PEERS", "\"${normalizeGradleString(defaultFabricBootstrapPeers)}\""
buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\""
buildConfigField "String", "DEFAULT_ORGANIZATION_ID", "\"${normalizeGradleString(defaultOrganizationId)}\""
}
@@ -45,5 +47,6 @@ android {
}
dependencies {
implementation files("libs/rap-fabricvpn.aar")
implementation "com.squareup.okhttp3:okhttp:5.3.2"
}
Binary file not shown.
Binary file not shown.
@@ -10,12 +10,6 @@
android:label="RAP VPN"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".RdpActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="false"
android:screenOrientation="landscape" />
<activity
android:name=".MainActivity"
android:exported="true">
@@ -25,7 +25,7 @@ import java.util.Locale;
public class MainActivity extends Activity {
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
private static final String DEFAULT_BACKEND_URL = BuildConfig.DEFAULT_BACKEND_URL;
private static final String PUBLIC_FABRIC_BACKEND_URL = "https://vpn.cin.su/api/v1";
private static final String FABRIC_BOOTSTRAP_PEERS = BuildConfig.FABRIC_BOOTSTRAP_PEERS;
private static final String DEFAULT_CLUSTER_ID = BuildConfig.DEFAULT_CLUSTER_ID;
private static final String DEFAULT_ORGANIZATION_ID = BuildConfig.DEFAULT_ORGANIZATION_ID;
private static final String PREF_SELECTED_EXIT_NODE_ID = "selected_exit_node_id";
@@ -46,11 +46,9 @@ public class MainActivity extends Activity {
private EditText password;
private TextView status;
private TextView profileSummary;
private TextView serverDirectory;
private TextView runtimeStatus;
private String profileJson = "";
private String vpnConnectionId = "";
private JSONArray lastResources = new JSONArray();
private RapApiClient.AuthContext authContext = null;
private SharedPreferences prefs;
private SharedPreferences runtimePrefs;
@@ -68,7 +66,7 @@ public class MainActivity extends Activity {
int pad = dp(20);
root.setPadding(pad, pad, pad, pad);
backendUrl = field("Backend URL", preferredBackendUrl());
backendUrl = field("Fabric control bootstrap", preferredBackendUrl());
clusterId = field("Cluster ID", prefs.getString("cluster_id", DEFAULT_CLUSTER_ID));
organizationId = field("Organization ID", prefs.getString("organization_id", DEFAULT_ORGANIZATION_ID));
email = field("Email", prefs.getString("email", "m"));
@@ -93,12 +91,6 @@ public class MainActivity extends Activity {
profileSummary.setTextSize(14);
profileSummary.setText(summaryText());
serverDirectory = new TextView(this);
serverDirectory.setTextColor(0xffe8eef2);
serverDirectory.setTextSize(15);
serverDirectory.setPadding(0, dp(14), 0, dp(14));
serverDirectory.setText("");
status = new TextView(this);
status.setTextColor(0xffd8eadf);
status.setPadding(0, dp(14), 0, dp(14));
@@ -111,11 +103,11 @@ public class MainActivity extends Activity {
runtimeStatus.setText(runtimeStatusText());
Button load = new Button(this);
load.setText("Войти / обновить профиль");
load.setText("Войти / обновить пулы");
load.setOnClickListener(v -> loadProfile(false));
Button start = new Button(this);
start.setText("Включить HOME VPN");
start.setText("Подключить");
start.setOnClickListener(v -> prepareVpn());
Button stop = new Button(this);
@@ -156,17 +148,12 @@ public class MainActivity extends Activity {
});
Button settings = new Button(this);
settings.setText("Настройки");
settings.setText("Аккаунт");
settings.setOnClickListener(v -> showSettingsDialog());
Button servers = new Button(this);
servers.setText("Открыть удаленный сервер");
servers.setOnClickListener(v -> showServerPicker());
root.addView(title);
root.addView(profileSummary);
root.addView(load);
root.addView(servers);
root.addView(start);
root.addView(stop);
root.addView(settings);
@@ -204,25 +191,17 @@ public class MainActivity extends Activity {
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
authContext = authenticate(client);
String activeOrganizationId = resolveOrganizationId(client, authContext.userId);
String requestedExitNodeId = selectedExitNodeId();
profileJson = client.vpnClientProfile(
clusterId.getText().toString(),
activeOrganizationId,
authContext.userId,
requestedExitNodeId
""
);
vpnConnectionId = firstConnectionId(profileJson);
saveProfileState();
JSONObject resourcePayload = client.resources(activeOrganizationId, authContext.userId);
lastResources = resourcePayload.optJSONArray("resources");
if (lastResources == null) {
lastResources = new JSONArray();
}
String resourcesText = resourcesText(resourcePayload);
runOnUiThread(() -> {
profileSummary.setText(summaryText());
serverDirectory.setText(resourcesText);
status.setText(startAfterLoad ? "Профиль обновлен. Запускаю VPN..." : "Профиль и ключи устройства обновлены.");
status.setText(startAfterLoad ? "Список пулов обновлен. Подключаю..." : "Список доступных пулов обновлен.");
startDiagnosticChannel();
if (startAfterLoad) {
requestVpnPermission();
@@ -233,12 +212,12 @@ public class MainActivity extends Activity {
String message = friendlyError(ex);
boolean canUseSavedProfile = startAfterLoad && !profileJson.isEmpty() && !vpnConnectionId.isEmpty();
if (canUseSavedProfile) {
status.setText("Профиль сейчас не обновился: " + message + ". Запускаю VPN с сохраненным рабочим профилем.");
status.setText("Список пулов сейчас не обновился: " + message + ". Подключаюсь с сохраненным рабочим профилем.");
startDiagnosticChannel();
requestVpnPermission();
return;
}
status.setText("Ошибка профиля: " + message);
status.setText("Ошибка входа: " + message);
if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) {
clearSavedAuth(false);
showSettingsDialog();
@@ -250,7 +229,7 @@ public class MainActivity extends Activity {
private void prepareVpn() {
loadProfile(true);
status.setText("Обновляю сессию устройства и VPN-профиль...");
status.setText("Обновляю сессию устройства и доступные пулы...");
}
private void requestVpnPermission() {
@@ -281,7 +260,7 @@ public class MainActivity extends Activity {
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString());
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
startForegroundService(intent);
status.setText("VPN запускается. Версия " + APP_VERSION + ". Backend: " + backendUrl.getText() + ". Connection: " + vpnConnectionId + ". Ожидаю статус подключения.");
status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал.");
runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText());
runtimeStatus.postDelayed(() -> {
String state = runtimePrefs.getString("state", "");
@@ -493,7 +472,7 @@ public class MainActivity extends Activity {
if ("planned".equals(status)) {
String entry = fabricRoute.optString("selected_entry_node_id", "").trim();
String exit = fabricRoute.optString("selected_exit_node_id", "").trim();
if (!entry.isEmpty() && !exit.isEmpty()) {
if (!exit.isEmpty()) {
return id;
}
}
@@ -510,29 +489,6 @@ public class MainActivity extends Activity {
return connections.getJSONObject(0).getString("id");
}
private String resourcesText(JSONObject payload) throws Exception {
JSONArray resources = payload.optJSONArray("resources");
if (resources == null || resources.length() == 0) {
return "Серверы: доступных ресурсов нет.";
}
StringBuilder text = new StringBuilder("Серверы:\n");
int limit = Math.min(resources.length(), 6);
for (int i = 0; i < limit; i++) {
JSONObject resource = resources.getJSONObject(i);
text.append("")
.append(resource.optString("name", "server"))
.append(" ")
.append(resource.optString("protocol", "rdp"))
.append(" ")
.append(resource.optString("address", ""))
.append('\n');
}
if (resources.length() > limit) {
text.append("и еще ").append(resources.length() - limit).append("...");
}
return text.toString().trim();
}
private int dp(int value) {
return (int) (value * getResources().getDisplayMetrics().density);
}
@@ -542,23 +498,101 @@ public class MainActivity extends Activity {
String connectionId = vpnConnectionId == null || vpnConnectionId.isEmpty()
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
: vpnConnectionId;
String backendText = backendUrl == null ? "" : backendUrl.getText().toString().trim();
String clusterText = clusterId == null ? "" : clusterId.getText().toString().trim();
String organizationText = organizationId == null ? "" : organizationId.getText().toString().trim();
String exitNode = selectedExitNodeId();
String poolText = availablePoolsText();
String selectedPoolText = selectedPoolName();
String profileDNS = profileDNSServersText();
return "Версия: " + APP_VERSION
+ "\nКластер: " + (clusterText.isEmpty() ? "не задан" : clusterText)
+ "\nОрганизация: " + (organizationText.isEmpty() ? "не задана" : organizationText)
+ "\nТочка входа: автоматическая (из настроек кластера)"
+ "\nТочка выхода: " + (exitNode.isEmpty() ? "не выбрана (по умолчанию)" : exitNode)
+ "\nУзел Android: в ферме"
+ "\nBootstrap фермы: " + bootstrapPeerCount() + " узл."
+ "\nДоступные выходы: " + (poolText.isEmpty() ? "войдите для загрузки" : poolText)
+ "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "автоматически" : selectedPoolText)
+ "\nDNS выхода: " + (profileDNS.isEmpty() ? "будет получен из профиля" : profileDNS)
+ "\nBackend: " + (backendText.isEmpty() ? "не задан" : backendText)
+ "\nТрафик: " + (prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true) ? "весь через VPN" : "по профилю")
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
+ "\nConnection: " + (connectionId.isEmpty() ? "нет" : connectionId);
}
private int bootstrapPeerCount() {
if (FABRIC_BOOTSTRAP_PEERS == null || FABRIC_BOOTSTRAP_PEERS.trim().isEmpty()) {
return 0;
}
int count = 0;
for (String value : FABRIC_BOOTSTRAP_PEERS.split(",")) {
if (!value.trim().isEmpty()) {
count++;
}
}
return count;
}
private String availablePoolsText() {
if (profileJson == null || profileJson.trim().isEmpty()) {
return "";
}
try {
JSONObject root = new JSONObject(profileJson);
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
if (connections == null || connections.length() == 0) {
return "";
}
StringBuilder out = new StringBuilder();
for (int i = 0; i < connections.length(); i++) {
JSONObject connection = connections.optJSONObject(i);
if (connection == null) {
continue;
}
String name = connection.optString("exit_pool_name", "").trim();
if (name.isEmpty()) {
name = connection.optString("name", "").trim();
}
if (name.isEmpty()) {
continue;
}
if (out.length() > 0) {
out.append(", ");
}
out.append(name);
}
return out.toString();
} catch (Exception ignored) {
return "";
}
}
private String selectedPoolName() {
if (profileJson == null || profileJson.trim().isEmpty()) {
return "";
}
try {
JSONObject root = new JSONObject(profileJson);
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
if (connections == null || connections.length() == 0) {
return "";
}
String preferredConnection = vpnConnectionId == null || vpnConnectionId.isEmpty()
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
: vpnConnectionId;
for (int i = 0; i < connections.length(); i++) {
JSONObject connection = connections.optJSONObject(i);
if (connection == null) {
continue;
}
if (!preferredConnection.isEmpty() && !preferredConnection.equals(connection.optString("id", ""))) {
continue;
}
String name = connection.optString("exit_pool_name", "").trim();
if (name.isEmpty()) {
name = connection.optString("name", "").trim();
}
return name;
}
} catch (Exception ignored) {
}
return "";
}
private String profileDNSServersText() {
if (profileJson == null || profileJson.trim().isEmpty()) {
return runtimePrefs == null ? "" : runtimePrefs.getString("dns_servers", "");
@@ -665,17 +699,14 @@ public class MainActivity extends Activity {
|| "http://vpn.cin.su/api/v1".equals(lower)
|| "https://vpn.cin.su:443/api/v1".equals(lower)
|| "http://94.141.118.222:19191/api/v1".equals(lower)
|| "http://195.123.240.88:19131/api/v1".equals(lower)
|| "http://192.168.200.61:18080/api/v1".equals(lower)
|| "http://docker-test.cin.su:18080/api/v1".equals(lower)) {
return PUBLIC_FABRIC_BACKEND_URL;
|| "http://195.123.240.88:19131/api/v1".equals(lower)) {
return DEFAULT_BACKEND_URL;
}
return candidate;
}
private String selectedExitNodeId() {
String configured = prefs == null ? "" : prefs.getString(PREF_SELECTED_EXIT_NODE_ID, "");
return normalizeSelectedExitNodeId(configured);
return "";
}
private String normalizeSelectedExitNodeId(String value) {
@@ -824,16 +855,10 @@ public class MainActivity extends Activity {
form.setOrientation(LinearLayout.VERTICAL);
int pad = dp(12);
form.setPadding(pad, pad, pad, pad);
EditText backendDraft = field("Backend URL", backendUrl.getText().toString());
EditText clusterDraft = field("Cluster ID", clusterId.getText().toString());
EditText organizationDraft = field("Organization ID", organizationId.getText().toString());
EditText emailDraft = field("Email", email.getText().toString());
EditText passwordDraft = field("Password", password.getText().toString());
passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
passwordDraft.setHint("Password (не сохраняется)");
EditText selectedExitDraft = field(
"Точка выхода (Node ID, например ifcm)",
prefs.getString(PREF_SELECTED_EXIT_NODE_ID, ""));
CheckBox showPassword = new CheckBox(this);
showPassword.setText("Показать пароль");
showPassword.setTextColor(0xff111111);
@@ -847,30 +872,19 @@ public class MainActivity extends Activity {
forceFullTunnel.setText("Полный маршрут через VPN");
forceFullTunnel.setChecked(prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true));
forceFullTunnel.setTextColor(0xff111111);
form.addView(backendDraft);
form.addView(clusterDraft);
form.addView(organizationDraft);
form.addView(emailDraft);
form.addView(passwordDraft);
form.addView(selectedExitDraft);
form.addView(showPassword);
form.addView(forceFullTunnel);
new AlertDialog.Builder(this)
.setTitle("Настройки подключения")
.setTitle("Аккаунт VPN")
.setView(form)
.setPositiveButton("Сохранить", (dialog, which) -> {
backendUrl.setText(backendDraft.getText().toString());
clusterId.setText(clusterDraft.getText().toString());
organizationId.setText(organizationDraft.getText().toString());
email.setText(emailDraft.getText().toString());
password.setText(passwordDraft.getText().toString());
String normalizedExit = normalizeSelectedExitNodeId(selectedExitDraft.getText().toString());
prefs.edit()
.putString(PREF_SELECTED_EXIT_NODE_ID, normalizedExit)
.remove(PREF_SELECTED_EXIT_NODE_ID)
.apply();
if (!normalizedExit.equals(selectedExitDraft.getText().toString().trim())) {
status.setText("Точка выхода очищена: значение было не похоже на Node ID/alias.");
}
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, forceFullTunnel.isChecked()).apply();
saveSettings();
profileSummary.setText(summaryText());
@@ -898,60 +912,4 @@ public class MainActivity extends Activity {
return message;
}
private void showServerPicker() {
if (lastResources.length() == 0) {
loadProfile();
status.setText("Загружаю список серверов...");
return;
}
String[] labels = new String[lastResources.length()];
for (int i = 0; i < lastResources.length(); i++) {
JSONObject resource = lastResources.optJSONObject(i);
labels[i] = resource == null
? "server"
: resource.optString("name", "server") + " " + resource.optString("address", "");
}
new AlertDialog.Builder(this)
.setTitle("Удаленный сервер")
.setItems(labels, (dialog, which) -> startRemoteDesktop(which))
.show();
}
private void startRemoteDesktop(int index) {
JSONObject resource = lastResources.optJSONObject(index);
if (resource == null) {
return;
}
if (authContext == null || authContext.userId.isEmpty() || authContext.deviceId.isEmpty()) {
loadProfile();
status.setText("Профиль обновляется. Повторите открытие сервера.");
return;
}
status.setText("Открываю " + resource.optString("name", "сервер") + "...");
new Thread(() -> {
try {
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
JSONObject result = client.startSession(resource.getString("id"), authContext.userId, authContext.deviceId);
Intent intent = new Intent(this, RdpActivity.class);
intent.putExtra(RdpActivity.EXTRA_SESSION_RESULT, result.toString());
intent.putExtra(RdpActivity.EXTRA_GATEWAY_URL, gatewayUrl());
intent.putExtra(RdpActivity.EXTRA_RESOURCE_NAME, resource.optString("name", "Remote Desktop"));
runOnUiThread(() -> {
status.setText("Сессия создана.");
startActivity(intent);
});
} catch (Exception ex) {
runOnUiThread(() -> status.setText("Ошибка RDP: " + ex.getMessage()));
}
}).start();
}
private String gatewayUrl() {
String api = backendUrl.getText().toString().trim();
String gateway = api.replace("https://", "wss://").replace("http://", "ws://");
if (gateway.endsWith("/")) {
gateway = gateway.substring(0, gateway.length() - 1);
}
return gateway + "/gateway/ws";
}
}
@@ -54,7 +54,6 @@ public class RapDiagnosticService extends Service {
private static final String CHANNEL_ID = "rap-vpn-diagnostics";
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
private static final String DEFAULT_BACKEND_URL = BuildConfig.DEFAULT_BACKEND_URL;
private static final String PUBLIC_FABRIC_BACKEND_URL = "https://vpn.cin.su/api/v1";
private static final String INTERNAL_BACKEND_URL = "http://192.168.200.61:18080/api/v1";
private static final String DEFAULT_CLUSTER_ID = BuildConfig.DEFAULT_CLUSTER_ID;
private static final String DEFAULT_ORGANIZATION_ID = BuildConfig.DEFAULT_ORGANIZATION_ID;
@@ -427,6 +426,9 @@ public class RapDiagnosticService extends Service {
}
String type = payload.optString("type", "");
JSONObject params = payload.optJSONObject("payload");
if (params == null) {
params = payload.optJSONObject("params");
}
if (params == null) {
params = payload;
}
@@ -447,7 +449,7 @@ public class RapDiagnosticService extends Service {
result = runVPNHttpGet(params.optString("url", "http://192.168.200.61:18080/"), params.optInt("timeout_ms", 15000));
} else if ("vpn_page_probe".equals(type)) {
result = runVPNPageProbe(params);
} else if ("vpn_tcp_connect".equals(type) || "vpn_rdp_probe".equals(type)) {
} else if ("vpn_tcp_connect".equals(type)) {
result = runVPNTCPConnect(params.optString("host", "192.168.200.95"), params.optInt("port", 3389), params.optInt("timeout_ms", 7000));
} else if ("vpn_tcp_connect_default".equals(type)) {
result = runDefaultTCPConnect(params.optString("host", "192.168.200.95"), params.optInt("port", 3389), params.optInt("timeout_ms", 7000));
@@ -472,7 +474,7 @@ public class RapDiagnosticService extends Service {
} else if ("vpn_deep_test".equals(type)) {
result = runVPNDeepTest(client, clusterId, params);
} else if ("vpn_download_test".equals(type)) {
result = runVPNDownloadTest(params.optString("url", "http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json"));
result = runVPNDownloadTest(params.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json"));
} else if ("vpn_mixed_load_test".equals(type) || "vpn_parallel_http_get".equals(type)) {
result = runVPNMixedLoadTest(params);
} else if ("launch_telegram".equals(type)) {
@@ -590,6 +592,16 @@ public class RapDiagnosticService extends Service {
}
String relay = runtime.getString("packet_relay_active_base_url", "");
String state = runtime.getString("state", "");
boolean fabricMode = runtime.getBoolean("mesh_node_route_mode", false)
|| "fabric_mesh_node_route_v1".equals(runtime.getString("dataplane_selected_transport", ""));
if (fabricMode && ("fabric".equals(state)
|| "fabric_downlink".equals(state)
|| "uplink_sent".equals(state)
|| "uplink_read".equals(state)
|| "downlink".equals(state)
|| "downlink_idle".equals(state))) {
return true;
}
if (!relay.isEmpty() && ("running".equals(state)
|| "ready".equals(state)
|| "warming".equals(state)
@@ -602,7 +614,7 @@ public class RapDiagnosticService extends Service {
}
long sent = runtime.getLong("uplink_sent_total", 0);
long down = runtime.getLong("downlink_received_total", 0);
return !relay.isEmpty() && (sent > 0 || down > 0);
return (fabricMode || !relay.isEmpty()) && (sent > 0 || down > 0);
}
private static final class ControlEndpoint {
@@ -662,7 +674,6 @@ public class RapDiagnosticService extends Service {
return "vpn_http_get".equals(type)
|| "vpn_page_probe".equals(type)
|| "vpn_tcp_connect".equals(type)
|| "vpn_rdp_probe".equals(type)
|| "vpn_download_test".equals(type);
}
@@ -688,17 +699,17 @@ public class RapDiagnosticService extends Service {
if ("vpn_page_probe".equals(type)) {
return runVPNPageProbe(payload);
}
if ("vpn_tcp_connect".equals(type) || "vpn_rdp_probe".equals(type)) {
if ("vpn_tcp_connect".equals(type)) {
return runVPNTCPConnect(payload.optString("host", "192.168.200.95"), payload.optInt("port", 3389), payload.optInt("timeout_ms", 7000));
}
if ("vpn_download_test".equals(type)) {
if (recoveryAttempt) {
return runVPNDownloadTest(
payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json"),
payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json"),
RECOVERY_DOWNLOAD_CONNECT_TIMEOUT_MS,
RECOVERY_DOWNLOAD_READ_TIMEOUT_MS);
}
return runVPNDownloadTest(payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json"));
return runVPNDownloadTest(payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json"));
}
return "retry skipped: unsupported probe " + type;
}
@@ -711,7 +722,7 @@ public class RapDiagnosticService extends Service {
copy = new JSONObject();
}
try {
if ("vpn_tcp_connect".equals(type) || "vpn_rdp_probe".equals(type)) {
if ("vpn_tcp_connect".equals(type)) {
int requested = copy.optInt("timeout_ms", RECOVERY_TCP_TIMEOUT_MS);
copy.put("timeout_ms", Math.max(1000, Math.min(requested, RECOVERY_TCP_TIMEOUT_MS)));
} else if ("vpn_page_probe".equals(type)) {
@@ -758,6 +769,8 @@ public class RapDiagnosticService extends Service {
last = state + "/sender=" + senderState + "/age_ms=" + age;
boolean readyState = "downlink".equals(state)
|| "downlink_idle".equals(state)
|| "fabric".equals(state)
|| "fabric_downlink".equals(state)
|| "uplink_sent".equals(state)
|| "uplink_read".equals(state)
|| "runtime_recovery".equals(state);
@@ -900,9 +913,8 @@ public class RapDiagnosticService extends Service {
if (organizationId == null || organizationId.trim().isEmpty()) {
organizationId = DEFAULT_ORGANIZATION_ID;
}
String exitNodeId = prefs.getString(PREF_SELECTED_EXIT_NODE_ID, "");
RapApiClient client = new RapApiClient(backendUrl, this, true);
String profileJson = client.vpnClientProfile(clusterId, organizationId, userId, exitNodeId);
String profileJson = client.vpnClientProfile(clusterId, organizationId, userId, "");
JSONObject root = new JSONObject(profileJson);
JSONObject profile = root.getJSONObject("vpn_client_profile");
String connectionId = profile.getJSONArray("connections").getJSONObject(0).getString("id");
@@ -927,7 +939,6 @@ public class RapDiagnosticService extends Service {
String organizationId = params.optString("organization_id", DEFAULT_ORGANIZATION_ID).trim();
String userId = params.optString("user_id", "").trim();
String trustedDeviceId = params.optString("trusted_device_id", "").trim();
String selectedExitNodeId = params.optString("selected_exit_node_id", params.optString("exit_node_id", "")).trim();
String profileJson = params.optString("profile_json", "").trim();
JSONObject root;
if (profileJson.isEmpty()) {
@@ -962,13 +973,6 @@ public class RapDiagnosticService extends Service {
}
JSONObject connection = profile.getJSONArray("connections").getJSONObject(0);
String connectionId = params.optString("vpn_connection_id", connection.optString("id", "")).trim();
JSONObject config = connection.optJSONObject("client_config");
if (selectedExitNodeId.isEmpty() && config != null) {
JSONObject route = config.optJSONObject("vpn_fabric_route");
if (route != null) {
selectedExitNodeId = route.optString("selected_exit_node_id", "");
}
}
SharedPreferences.Editor editor = getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putString("backend_url", backendUrl)
.putString("cluster_id", clusterId)
@@ -979,9 +983,7 @@ public class RapDiagnosticService extends Service {
if (!trustedDeviceId.isEmpty()) {
editor.putString(PREF_DEVICE_ID, trustedDeviceId);
}
if (!selectedExitNodeId.isEmpty()) {
editor.putString(PREF_SELECTED_EXIT_NODE_ID, selectedExitNodeId);
}
editor.remove(PREF_SELECTED_EXIT_NODE_ID);
editor.apply();
Intent stopIntent = new Intent(this, RapVpnService.class);
stopIntent.setAction(RapVpnService.ACTION_STOP);
@@ -1095,10 +1097,8 @@ public class RapDiagnosticService extends Service {
|| "http://vpn.cin.su/api/v1".equals(lower)
|| "https://vpn.cin.su:443/api/v1".equals(lower)
|| "http://94.141.118.222:19191/api/v1".equals(lower)
|| "http://195.123.240.88:19131/api/v1".equals(lower)
|| "http://192.168.200.61:18080/api/v1".equals(lower)
|| "http://docker-test.cin.su:18080/api/v1".equals(lower)) {
return PUBLIC_FABRIC_BACKEND_URL;
|| "http://195.123.240.88:19131/api/v1".equals(lower)) {
return DEFAULT_BACKEND_URL;
}
return candidate;
}
@@ -1187,7 +1187,7 @@ public class RapDiagnosticService extends Service {
result.append(" | dns=").append(runVPNDNSLookup(host));
result.append(" | vpn_http=").append(runVPNHttpGet(url, 15000));
result.append(" | vpn_local_http=").append(runVPNHttpGet(localUrl, 15000));
result.append(" | download=").append(runVPNDownloadTest(payload.optString("download_url", "http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json")));
result.append(" | download=").append(runVPNDownloadTest(payload.optString("download_url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json")));
return compact(result.toString(), 2500);
}
@@ -1240,13 +1240,13 @@ public class RapDiagnosticService extends Service {
if (timeoutMs > 45000) {
timeoutMs = 45000;
}
String rdpHost = payload.optString("rdp_host", "192.168.200.95");
int rdpPort = payload.optInt("rdp_port", 3389);
String tcpHost = payload.optString("tcp_host", "192.168.200.95");
int tcpPort = payload.optInt("tcp_port", 443);
String[] defaults = new String[] {
"http://2ip.ru/",
"http://example.com/",
"http://neverssl.com/",
"http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json",
"http://192.168.200.61:18080/downloads/rap-android-vpn-build.json",
"http://192.168.200.61:18080/"
};
List<String> urls = new ArrayList<>();
@@ -1264,7 +1264,7 @@ public class RapDiagnosticService extends Service {
urls.add(url);
}
}
String rdpBefore = runVPNTCPConnect(rdpHost, rdpPort, Math.min(timeoutMs, 10000));
String tcpBefore = runVPNTCPConnect(tcpHost, tcpPort, Math.min(timeoutMs, 10000));
String[] results = new String[parallel];
Thread[] threads = new Thread[parallel];
final int requestTimeoutMs = timeoutMs;
@@ -1301,13 +1301,13 @@ public class RapDiagnosticService extends Service {
sample.append(item == null ? "timeout/no_result" : item);
}
}
String rdpAfter = runVPNTCPConnect(rdpHost, rdpPort, Math.min(timeoutMs, 10000));
String tcpAfter = runVPNTCPConnect(tcpHost, tcpPort, Math.min(timeoutMs, 10000));
return compact("vpn_mixed_load_test parallel=" + parallel
+ " ok=" + ok
+ " failed=" + failed
+ " elapsed_ms=" + elapsed
+ " rdp_before={" + rdpBefore + "}"
+ " rdp_after={" + rdpAfter + "}"
+ " tcp_before={" + tcpBefore + "}"
+ " tcp_after={" + tcpAfter + "}"
+ " sample={" + sample + "}", 2500);
}
@@ -1573,6 +1573,8 @@ public class RapDiagnosticService extends Service {
payload.put("packet_relay_candidate_urls", runtime.getString("packet_relay_candidate_urls", ""));
payload.put("dataplane_transport_candidate_count", runtime.getInt("dataplane_transport_candidate_count", 0));
payload.put("dataplane_entry_candidate_count", runtime.getInt("dataplane_entry_candidate_count", 0));
payload.put("dataplane_exit_candidate_count", runtime.getInt("dataplane_exit_candidate_count", 0));
payload.put("fabric_mesh_exit_endpoints", runtime.getString("fabric_mesh_exit_endpoints", ""));
return payload;
}
@@ -20,6 +20,10 @@ import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import su.cin.rapvpn.fabric.fabricvpn.Fabricvpn;
import su.cin.rapvpn.fabric.fabricvpn.Manager;
import su.cin.rapvpn.fabric.fabricvpn.SocketProtector;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.net.DatagramPacket;
@@ -79,7 +83,7 @@ public class RapVpnService extends VpnService {
private static final int RUNTIME_STATUS_INTERVAL_MS = 500;
private static final int RUNTIME_WATCHDOG_INTERVAL_MS = 2000;
private static final int RUNTIME_WATCHDOG_STALE_SYNACK_MS = 7000;
private static final int RUNTIME_WATCHDOG_RDP_STALE_SYNACK_MS = 4000;
private static final int RUNTIME_WATCHDOG_PRIORITY_STALE_SYNACK_MS = 4000;
private static final int RUNTIME_WATCHDOG_STALE_ROUNDS_BEFORE_RECOVERY = 3;
private static final int RUNTIME_WATCHDOG_STALE_SYNACKS_BEFORE_RECOVERY = 4;
private static final int RUNTIME_WATCHDOG_MAX_STALE_ROUNDS_BEFORE_RECOVERY = 6;
@@ -209,6 +213,9 @@ public class RapVpnService extends VpnService {
private volatile int activePacketRelayIndex;
private volatile VpnPacketWebSocketRelay packetWebSocketRelay;
private volatile FabricServiceChannel activeFabricServiceChannel = new FabricServiceChannel();
private volatile boolean activeMeshNodeRouteMode;
private volatile String activeFabricRuntimeConfigJson = "";
private volatile Manager fabricVpnManager;
private volatile String lastUplinkSendErrorMessage = "";
private final Object packetRelaySwitchLock = new Object();
private final Map<String, byte[]> clientSourceNat = new LinkedHashMap<String, byte[]>(4096, 0.75f, true) {
@@ -288,40 +295,18 @@ public class RapVpnService extends VpnService {
return START_NOT_STICKY;
}
persistStartConfig(profile, backendUrl, clusterId, vpnConnectionId);
List<String> packetRelayUrls = activePacketRelayUrlsByProfile == null || activePacketRelayUrlsByProfile.isEmpty()
? singletonUrl(activePacketRelayUrlByProfile)
: new ArrayList<>(activePacketRelayUrlsByProfile);
if (!activeFabricServiceChannel.enabled) {
shutdownReason = "fabric service channel lease required";
writeRuntimeStatus("error", "vpn not started: fabric service channel lease required", 0, 0, 0, 0);
if (!activeMeshNodeRouteMode) {
shutdownReason = "fabric mesh node route profile required";
writeRuntimeStatus("error", "vpn not started: fabric_mesh_node_route_v1 profile required; legacy relay dataplanes are disabled", 0, 0, 0, 0);
writeRuntimeDetail("legacy_dataplane_disabled", "Android VPN client accepts only the QUIC fabric mesh node route profile. HTTP relay, WebSocket relay and entry endpoint modes are disabled for runtime traffic.", "mesh", 0, 1, "legacy_dataplane_disabled", -1);
stopSelf();
return START_NOT_STICKY;
}
if (packetRelayUrls.isEmpty()) {
shutdownReason = "missing farm entry endpoint";
writeRuntimeStatus("error", "vpn not started: missing farm entry endpoint", 0, 0, 0, 0);
if (!startFabricMeshNodeRuntime(clusterId, vpnConnectionId, activeFabricRuntimeConfigJson)) {
stopSelf();
return START_NOT_STICKY;
}
startPacketRelay(backendUrl, packetRelayUrls, clusterId, vpnConnectionId);
if (!running) {
shutdownReason = "relay not running";
writeRuntimeStatus("error", "vpn not started: relay not running", 0, 0, 0, 0);
stopSelf();
return START_NOT_STICKY;
}
if (tunnel == null || backendUrl == null || backendUrl.isEmpty()
|| clusterId == null || clusterId.isEmpty()
|| vpnConnectionId == null || vpnConnectionId.isEmpty()) {
shutdownReason = "invalid runtime";
writeRuntimeStatus("error", "vpn not started: invalid runtime", 0, 0, 0, 0);
stopSelf();
return START_NOT_STICKY;
}
writeRuntimeStatus("running", "vpn service active " + vpnAddressIPv4, 0, 0, downlinkReceivedPackets.get(), 0);
startVPNReadinessWarmup(configuredDnsServers(), configuredDnsProbeDomains(), vpnConnectionId);
shutdownReason = "running";
return START_NOT_STICKY;
return START_STICKY;
}
private void ensureDiagnosticServiceRunning() {
@@ -467,6 +452,8 @@ public class RapVpnService extends VpnService {
activePacketRelayUrlByProfile = "";
activePacketRelayUrlsByProfile = new ArrayList<>();
activeFabricServiceChannel = new FabricServiceChannel();
activeMeshNodeRouteMode = false;
activeFabricRuntimeConfigJson = "";
VpnClientConfig config = parseClientConfig(profileJson, backendUrl);
SharedPreferences prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE);
boolean forceFullTunnel = prefs.getBoolean(MainActivity.PREF_FORCE_FULL_TUNNEL, true);
@@ -525,6 +512,8 @@ public class RapVpnService extends VpnService {
activePacketRelayUrlByProfile = config.packetRelayBaseUrl;
activePacketRelayUrlsByProfile = new ArrayList<>(config.packetRelayBaseUrls);
activeFabricServiceChannel = config.fabricServiceChannel;
activeMeshNodeRouteMode = config.meshNodeRouteMode;
activeFabricRuntimeConfigJson = config.fabricRuntimeConfigJson;
} catch (Exception e) {
Log.e(TAG, "vpn tunnel establish failed", e);
writeRuntimeStatus("error", "tunnel failed: " + e.getMessage(), 0, 0, 0, 0);
@@ -561,7 +550,10 @@ public class RapVpnService extends VpnService {
if ("planned".equals(status) && connection == null) {
String entry = candidateRoute.optString("selected_entry_node_id", "").trim();
String exit = candidateRoute.optString("selected_exit_node_id", "").trim();
if (!entry.isEmpty() && !exit.isEmpty()) {
boolean clientNodeEntry = candidateRoute.optBoolean("client_node_entry", false)
|| "client_node".equalsIgnoreCase(candidateRoute.optString("entry_selector", ""))
|| "client_node_to_exit_pool".equalsIgnoreCase(candidateRoute.optString("route_type", ""));
if ((!entry.isEmpty() || clientNodeEntry) && !exit.isEmpty()) {
connection = candidate;
selectedConnectionId = candidateId;
break;
@@ -581,6 +573,7 @@ public class RapVpnService extends VpnService {
return config;
}
JSONObject clientConfig = connection.optJSONObject("client_config");
JSONObject dataplaneSession = null;
if (clientConfig != null) {
String vpnAddress = clientConfig.optString("vpn_address", "");
if (!vpnAddress.isEmpty()) {
@@ -593,7 +586,7 @@ public class RapVpnService extends VpnService {
readStringArray(clientConfig.optJSONArray("dns_servers"), config.dnsServers, true);
readStringArray(clientConfig.optJSONArray("dns_probe_domains"), config.dnsProbeDomains, false);
readStringArray(clientConfig.optJSONArray("routes"), config.splitRoutes, false);
JSONObject dataplaneSession = clientConfig.optJSONObject("vpn_dataplane_session");
dataplaneSession = clientConfig.optJSONObject("vpn_dataplane_session");
if (dataplaneSession != null) {
config.dataplaneSessionStatus = dataplaneSession.optString("status", "");
config.dataplanePreferredTransport = dataplaneSession.optString("preferred_transport", "");
@@ -604,7 +597,17 @@ public class RapVpnService extends VpnService {
config.dataplaneTransportCandidateCount = transportCandidates == null ? 0 : transportCandidates.length();
JSONArray entryCandidates = dataplaneSession.optJSONArray("entry_candidates");
config.dataplaneEntryCandidateCount = entryCandidates == null ? 0 : entryCandidates.length();
JSONArray exitCandidates = dataplaneSession.optJSONArray("exit_candidates");
config.dataplaneExitCandidateCount = exitCandidates == null ? 0 : exitCandidates.length();
JSONObject routeBundle = dataplaneSession.optJSONObject("fabric_route_bundle");
JSONArray routeBundleEndpoints = routeBundle == null ? null : routeBundle.optJSONArray("endpoint_candidates");
config.fabricMeshExitEndpoints = summarizeFabricMeshEndpoints(routeBundleEndpoints == null ? exitCandidates : routeBundleEndpoints);
config.dataplaneSelectedTransport = selectDataplanePacketTransport(dataplaneSession);
config.meshNodeRouteMode = isMeshNodeRouteDataplane(dataplaneSession);
if (config.meshNodeRouteMode) {
config.dataplaneSelectedTransport = "fabric_mesh_node_route_v1";
config.configNotes.add("Fabric mesh node route: Android node connects to exit pool directly through mesh");
}
config.packetRelayBaseUrls.addAll(selectDataplanePacketRelayBaseUrls(dataplaneSession, backendUrl));
if (!config.packetRelayBaseUrls.isEmpty()) {
config.packetRelayBaseUrl = config.packetRelayBaseUrls.get(0);
@@ -656,12 +659,102 @@ public class RapVpnService extends VpnService {
selectedConnectionId = waitingConnectionId;
}
config.selectedConnectionId = selectedConnectionId;
if (config.meshNodeRouteMode && dataplaneSession != null) {
String clusterId = profile == null ? "" : profile.optString("cluster_id", "");
config.fabricRuntimeConfigJson = buildFabricRuntimeConfig(clusterId, selectedConnectionId, config.dataplaneExitNodeId, dataplaneSession);
}
} catch (Exception ignored) {
config.configNotes.add("Failed parsing profile: using defaults");
}
return config;
}
private String buildFabricRuntimeConfig(String clusterId, String vpnConnectionId, String exitNodeId, JSONObject dataplaneSession) {
try {
JSONObject out = new JSONObject();
String sessionCluster = dataplaneSession == null ? "" : dataplaneSession.optString("cluster_id", "");
String sessionId = dataplaneSession == null ? "" : dataplaneSession.optString("session_id", "");
String connectionId = vpnConnectionId == null ? "" : vpnConnectionId.trim();
out.put("cluster_id", firstNonEmpty(clusterId, sessionCluster));
out.put("local_node_id", "android-vpn-" + firstNonEmpty(connectionId, sessionId, String.valueOf(System.currentTimeMillis())));
out.put("exit_node_id", firstNonEmpty(exitNodeId, dataplaneSession == null ? "" : dataplaneSession.optString("exit_node_id", "")));
out.put("vpn_connection_id", firstNonEmpty(connectionId, sessionId));
out.put("stream_shards", 4);
JSONObject request = dataplaneSession == null ? null : dataplaneSession.optJSONObject("fabric_service_channel_request");
JSONObject routeBundle = dataplaneSession == null ? null : dataplaneSession.optJSONObject("fabric_route_bundle");
if (request != null) {
out.put("service_channel_request", request);
}
if (routeBundle != null) {
out.put("route_bundle", routeBundle);
}
JSONArray endpointCache = new JSONArray();
JSONArray candidates = routeBundle == null ? null : routeBundle.optJSONArray("endpoint_candidates");
if (candidates == null && routeBundle != null) {
candidates = routeBundle.optJSONArray("target_candidates");
}
if (candidates == null) {
candidates = dataplaneSession == null ? null : dataplaneSession.optJSONArray("exit_candidates");
}
if (candidates != null) {
for (int i = 0; i < candidates.length(); i++) {
JSONObject candidate = candidates.optJSONObject(i);
if (!isUsableFabricQUICCandidate(candidate)) {
continue;
}
JSONObject endpoint = new JSONObject();
endpoint.put("endpoint_id", candidate.optString("endpoint_id", candidate.optString("address", "")));
endpoint.put("node_id", candidate.optString("node_id", out.optString("exit_node_id", "")));
endpoint.put("transport", firstNonEmpty(candidate.optString("transport", ""), "direct_quic"));
endpoint.put("address", candidate.optString("address", ""));
endpoint.put("priority", candidate.optInt("priority", 1000 + i));
endpoint.put("peer_cert_sha256", firstNonEmpty(
candidate.optString("peer_cert_sha256", ""),
candidate.optString("tls_cert_sha256", ""),
nestedString(candidate, "metadata", "peer_cert_sha256"),
nestedString(candidate, "metadata", "tls_cert_sha256")));
endpoint.put("tls_cert_sha256", endpoint.optString("peer_cert_sha256", ""));
endpointCache.put(endpoint);
}
}
out.put("deprecated_runtime_endpoint_cache", endpointCache);
return out.toString();
} catch (Exception e) {
return "";
}
}
private boolean isUsableFabricQUICCandidate(JSONObject candidate) {
if (candidate == null) {
return false;
}
String address = candidate.optString("address", "").trim();
String transport = candidate.optString("transport", "").trim();
if (address.isEmpty()) {
return false;
}
return address.toLowerCase(Locale.US).startsWith("quic://")
|| "quic".equalsIgnoreCase(transport)
|| "direct_quic".equalsIgnoreCase(transport);
}
private String nestedString(JSONObject object, String parent, String child) {
JSONObject nested = object == null ? null : object.optJSONObject(parent);
return nested == null ? "" : nested.optString(child, "");
}
private String firstNonEmpty(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
if (value != null && !value.trim().isEmpty()) {
return value.trim();
}
}
return "";
}
private int parseMtu(int mtu) {
if (mtu <= 0) {
return DEFAULT_VPN_MTU;
@@ -691,10 +784,33 @@ public class RapVpnService extends VpnService {
}
private String selectDataplanePacketTransport(JSONObject dataplaneSession) {
if (isMeshNodeRouteDataplane(dataplaneSession)) {
return "fabric_mesh_node_route_v1";
}
JSONObject candidate = selectSafeEntryDirectHTTPCandidate(dataplaneSession);
return candidate == null ? "" : "entry_direct_http_v1";
}
private boolean isMeshNodeRouteDataplane(JSONObject dataplaneSession) {
if (dataplaneSession == null) {
return false;
}
if ("fabric_mesh_node_route_v1".equals(dataplaneSession.optString("preferred_transport", ""))) {
return true;
}
JSONArray transportCandidates = dataplaneSession.optJSONArray("transport_candidates");
if (transportCandidates == null) {
return false;
}
for (int i = 0; i < transportCandidates.length(); i++) {
JSONObject candidate = transportCandidates.optJSONObject(i);
if (candidate != null && "fabric_mesh_node_route_v1".equals(candidate.optString("type", ""))) {
return true;
}
}
return false;
}
private List<String> selectDataplanePacketRelayBaseUrls(JSONObject dataplaneSession, String backendUrl) {
List<String> urls = new ArrayList<>();
JSONObject candidate = selectSafeEntryDirectHTTPCandidate(dataplaneSession);
@@ -787,6 +903,37 @@ public class RapVpnService extends VpnService {
return null;
}
private String summarizeFabricMeshEndpoints(JSONArray candidates) {
if (candidates == null || candidates.length() == 0) {
return "";
}
List<String> endpoints = new ArrayList<>();
for (int i = 0; i < candidates.length(); i++) {
JSONObject candidate = candidates.optJSONObject(i);
if (candidate == null) {
continue;
}
String address = candidate.optString("address", "").trim();
if (address.isEmpty()) {
continue;
}
String transport = candidate.optString("transport", "").trim();
if (!address.toLowerCase().startsWith("quic://")
&& !"quic".equalsIgnoreCase(transport)
&& !"direct_quic".equalsIgnoreCase(transport)) {
continue;
}
String nodeId = candidate.optString("node_id", "").trim();
String endpointId = candidate.optString("endpoint_id", "").trim();
String label = nodeId.isEmpty() ? address : nodeId + "=" + address;
if (!endpointId.isEmpty()) {
label += "#" + endpointId;
}
endpoints.add(label);
}
return joinList(endpoints);
}
private String normalizeHTTPBaseUrl(String value) {
if (value == null) {
return "";
@@ -879,12 +1026,16 @@ public class RapVpnService extends VpnService {
.putString("dataplane_entry_node_id", config.dataplaneEntryNodeId)
.putString("dataplane_exit_node_id", config.dataplaneExitNodeId)
.putString("dataplane_selected_transport", config.dataplaneSelectedTransport)
.putBoolean("mesh_node_route_mode", config.meshNodeRouteMode)
.putString("fabric_runtime_config_json", config.fabricRuntimeConfigJson)
.putString("packet_relay_profile_base_url", config.packetRelayBaseUrl)
.putString("packet_relay_active_base_url", "")
.putString("packet_relay_base_url", config.packetRelayBaseUrl)
.putString("packet_relay_candidate_urls", joinList(config.packetRelayBaseUrls))
.putInt("dataplane_transport_candidate_count", config.dataplaneTransportCandidateCount)
.putInt("dataplane_entry_candidate_count", config.dataplaneEntryCandidateCount)
.putInt("dataplane_exit_candidate_count", config.dataplaneExitCandidateCount)
.putString("fabric_mesh_exit_endpoints", config.fabricMeshExitEndpoints)
.commit();
} catch (Exception ignored) {
}
@@ -1001,6 +1152,143 @@ public class RapVpnService extends VpnService {
return parts.length > 0 && !parts[0].isEmpty() ? parts[0] : "10.77.0.2";
}
private boolean startFabricMeshNodeRuntime(String clusterId, String vpnConnectionId, String fabricRuntimeConfigJson) {
if (tunnel == null || clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) {
writeRuntimeStatus("error", "fabric runtime not started: tunnel/cluster/connection missing", 0, 0, 0, 0);
return false;
}
if (fabricRuntimeConfigJson == null || fabricRuntimeConfigJson.trim().isEmpty()) {
writeRuntimeStatus("error", "fabric runtime not started: missing QUIC exit pool config", 0, 0, 0, 0);
return false;
}
try {
JSONObject cfg = new JSONObject(fabricRuntimeConfigJson);
JSONArray endpoints = cfg.optJSONArray("endpoints");
boolean hasRouteLease = hasFabricRouteLeaseCandidates(cfg.optJSONObject("route_bundle"));
JSONObject request = cfg.optJSONObject("service_channel_request");
if (request == null || request.optString("schema_version", "").isEmpty()) {
writeRuntimeStatus("error", "fabric runtime not started: missing service channel request", 0, 0, 0, 0);
return false;
}
if (!hasRouteLease && (endpoints == null || endpoints.length() == 0)) {
writeRuntimeStatus("error", "fabric runtime not started: no fabric route lease candidates", 0, 0, 0, 0);
return false;
}
} catch (Exception e) {
writeRuntimeStatus("error", "fabric runtime config invalid: " + e.getMessage(), 0, 0, 0, 0);
return false;
}
stopPacketRelay();
try {
Fabricvpn.touch();
Manager manager = Fabricvpn.newManager();
manager.setSocketProtector(new SocketProtector() {
@Override
public boolean protect(long fd) {
try {
return RapVpnService.this.protect((int) fd);
} catch (Exception e) {
writeRuntimeDetail("socket_protect_failed", e.getMessage(), "fabric", 0, 1, e.getClass().getSimpleName(), -1);
return false;
}
}
});
manager.start(fabricRuntimeConfigJson);
fabricVpnManager = manager;
} catch (Throwable e) {
fabricVpnManager = null;
writeRuntimeStatus("error", "fabric runtime start failed: " + e.getMessage(), 0, 0, 0, 1);
writeRuntimeDetail("start_failed", "QUIC fabric vpn runtime failed: " + e.getMessage(), "fabric", 0, 1, e.getClass().getSimpleName(), -1);
return false;
}
running = true;
long runtimeId = runtimeGeneration.incrementAndGet();
runtimeStartedAt = System.currentTimeMillis();
initializePacketRuntimeQueues();
configureBackendBypass("");
writeRuntimeStatus("fabric", "QUIC fabric VPN runtime connected " + vpnConnectionId, 0, 0, 0, 0);
writeRuntimeDetail("running", fabricSnapshot(), "fabric", 0, 0, "", -1);
uplinkThread = new Thread(() -> pumpTunToRelay(runtimeId, clusterId, vpnConnectionId), "rap-vpn-fabric-uplink");
uplinkSenderThreads = new Thread[uplinkWorkerCount];
for (int i = 0; i < uplinkWorkerCount; i++) {
final int workerIndex = i;
uplinkSenderThreads[i] = new Thread(() -> pumpUplinkQueueToRelay(runtimeId, workerIndex, clusterId, vpnConnectionId), "rap-vpn-fabric-uplink-sender-" + workerIndex);
}
downlinkThread = new Thread(() -> pumpFabricDownlinkToQueue(runtimeId), "rap-vpn-fabric-downlink-receiver");
downlinkWriterThread = new Thread(() -> pumpDownlinkQueueToTun(runtimeId), "rap-vpn-fabric-downlink-writer");
diagnosticWatchdogThread = new Thread(this::runDiagnosticServiceWatchdog, "rap-vpn-diagnostic-watchdog");
uplinkThread.start();
for (Thread senderThread : uplinkSenderThreads) {
senderThread.start();
}
downlinkThread.start();
downlinkWriterThread.start();
diagnosticWatchdogThread.start();
return true;
}
private boolean hasFabricRouteLeaseCandidates(JSONObject routeBundle) {
if (routeBundle == null) {
return false;
}
JSONObject lease = routeBundle.optJSONObject("route_lease");
if (lease == null) {
return false;
}
JSONObject primary = lease.optJSONObject("primary_path");
if (hasUsableFabricQUICCandidate(primary == null ? null : primary.optJSONArray("endpoint_candidates"))) {
return true;
}
JSONArray standby = lease.optJSONArray("warm_standby_paths");
if (standby == null) {
return false;
}
for (int i = 0; i < standby.length(); i++) {
JSONObject path = standby.optJSONObject(i);
if (hasUsableFabricQUICCandidate(path == null ? null : path.optJSONArray("endpoint_candidates"))) {
return true;
}
}
return false;
}
private boolean hasUsableFabricQUICCandidate(JSONArray candidates) {
if (candidates == null) {
return false;
}
for (int i = 0; i < candidates.length(); i++) {
if (isUsableFabricQUICCandidate(candidates.optJSONObject(i))) {
return true;
}
}
return false;
}
private void initializePacketRuntimeQueues() {
uplinkWorkerCount = Math.max(UPLINK_WORKER_MIN_COUNT, Math.min(UPLINK_WORKER_MAX_COUNT, Math.max(1, Runtime.getRuntime().availableProcessors())));
uplinkQueueOffersByWorker = createAtomicCounters(uplinkWorkerCount);
uplinkQueueDropsByWorker = createAtomicCounters(uplinkWorkerCount);
uplinkSenderPacketsByWorker = createAtomicCounters(uplinkWorkerCount);
uplinkSenderErrorsByWorker = createAtomicCounters(uplinkWorkerCount);
uplinkPriorityQueue = new ArrayBlockingQueue<>(PRIORITY_QUEUE_CAPACITY);
uplinkQueues = new ArrayBlockingQueue[uplinkWorkerCount];
for (int i = 0; i < uplinkWorkerCount; i++) {
uplinkQueues[i] = new ArrayBlockingQueue<>(UPLINK_QUEUE_CAPACITY);
}
downlinkFlowQueueCount = Math.max(1, Math.min(DOWNLINK_FLOW_QUEUE_MAX_COUNT, Math.max(1, Runtime.getRuntime().availableProcessors())));
downlinkQueueOffersByFlow = createAtomicCounters(downlinkFlowQueueCount);
downlinkQueueDropsByFlow = createAtomicCounters(downlinkFlowQueueCount);
downlinkWriterPacketsByFlow = createAtomicCounters(downlinkFlowQueueCount);
downlinkPriorityQueue = new ArrayBlockingQueue<>(PRIORITY_QUEUE_CAPACITY);
downlinkQueues = new ArrayBlockingQueue[downlinkFlowQueueCount];
int downlinkPerFlowCapacity = Math.max(512, DOWNLINK_QUEUE_CAPACITY / downlinkFlowQueueCount);
for (int i = 0; i < downlinkFlowQueueCount; i++) {
downlinkQueues[i] = new ArrayBlockingQueue<>(downlinkPerFlowCapacity);
}
}
private void startPacketRelay(String backendUrl, List<String> candidateUrls, String clusterId, String vpnConnectionId) {
if (tunnel == null || backendUrl == null || backendUrl.isEmpty() || clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) {
Log.e(TAG, "packet relay not started: tunnel=" + (tunnel != null)
@@ -1316,6 +1604,14 @@ public class RapVpnService extends VpnService {
if (relay != null) {
relay.close();
}
Manager manager = fabricVpnManager;
fabricVpnManager = null;
if (manager != null) {
try {
manager.stop();
} catch (Exception ignored) {
}
}
closeTunHandles();
interruptAndJoin(uplinkThread);
if (uplinkSenderThreads != null) {
@@ -1602,7 +1898,7 @@ public class RapVpnService extends VpnService {
}
long now = System.currentTimeMillis();
int stale = staleTCPHandshakeCount();
int rdpStale = staleRdpTCPHandshakeCount();
int priorityStale = stalePriorityTCPHandshakeCount();
long downlinkPackets = downlinkReceivedPackets.get();
long uplinkPackets = uplinkSentPackets.get();
boolean downlinkProgressed = downlinkPackets > lastRuntimeWatchdogDownlinkPackets;
@@ -1618,16 +1914,16 @@ public class RapVpnService extends VpnService {
}
if (downlinkProgressed) {
runtimeWatchdogStaleRounds = 0;
writeRuntimeDetail("watchdog_observed_downlink", "stale=" + stale + " rdp_stale=" + rdpStale + " downlink_progress=true uplink_progress=" + uplinkProgressed, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1);
writeRuntimeDetail("watchdog_observed_downlink", "stale=" + stale + " priority_stale=" + priorityStale + " downlink_progress=true uplink_progress=" + uplinkProgressed, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1);
continue;
}
runtimeWatchdogStaleRounds++;
if (rdpStale > 0 && now - lastRuntimeWatchdogRecoveryAt >= RUNTIME_WATCHDOG_RECOVERY_COOLDOWN_MS) {
if (priorityStale > 0 && now - lastRuntimeWatchdogRecoveryAt >= RUNTIME_WATCHDOG_RECOVERY_COOLDOWN_MS) {
runtimeWatchdogStaleRounds = 0;
tcpHandshakeStalls.addAndGet(rdpStale);
tcpHandshakeStalls.addAndGet(priorityStale);
runtimeWatchdogRecoveries.incrementAndGet();
lastRuntimeWatchdogRecoveryAt = now;
recoverPacketRelayRuntime(clusterId, vpnConnectionId, "rdp_tcp_handshake_stall stale=" + rdpStale);
recoverPacketRelayRuntime(clusterId, vpnConnectionId, "priority_tcp_handshake_stall stale=" + priorityStale);
continue;
}
boolean relayOpen = isPacketWebSocketRelayOpen();
@@ -1808,11 +2104,11 @@ public class RapVpnService extends VpnService {
return staleTCPHandshakeCount(RUNTIME_WATCHDOG_STALE_SYNACK_MS, false);
}
private int staleRdpTCPHandshakeCount() {
return staleTCPHandshakeCount(RUNTIME_WATCHDOG_RDP_STALE_SYNACK_MS, true);
private int stalePriorityTCPHandshakeCount() {
return staleTCPHandshakeCount(RUNTIME_WATCHDOG_PRIORITY_STALE_SYNACK_MS, true);
}
private int staleTCPHandshakeCount(int staleAfterMs, boolean rdpOnly) {
private int staleTCPHandshakeCount(int staleAfterMs, boolean priorityOnly) {
long now = System.currentTimeMillis();
int stale = 0;
synchronized (pendingTcpHandshakes) {
@@ -1829,7 +2125,7 @@ public class RapVpnService extends VpnService {
remove.add(entry.getKey());
continue;
}
if (rdpOnly && !entry.getKey().contains("|3389|")) {
if (priorityOnly && !isPriorityTCPFlowKey(entry.getKey())) {
continue;
}
if (age >= staleAfterMs) {
@@ -2309,8 +2605,11 @@ public class RapVpnService extends VpnService {
int ihl = (packet[0] & 0x0f) * 4;
int tcpHeaderLength = ((packet[ihl + 12] >> 4) & 0x0f) * 4;
int tcpPayloadLength = Math.max(0, ipTotalLength - ihl - tcpHeaderLength);
boolean rdp = flow.srcPort == 3389 || flow.dstPort == 3389;
return rdp || syn || fin || rst || (ack && tcpPayloadLength == 0) || (psh && tcpPayloadLength <= 96);
return syn || fin || rst || (ack && tcpPayloadLength == 0) || (psh && tcpPayloadLength <= 96);
}
private boolean isPriorityTCPFlowKey(String key) {
return key != null && !key.isEmpty();
}
private boolean clampIPv4TCPMSS(byte[] packet, int length, int maxMss) {
@@ -2619,6 +2918,10 @@ public class RapVpnService extends VpnService {
}
private boolean sendUplinkBatchWithRetry(String clusterId, String vpnConnectionId, List<byte[]> batch, int workerIndex) {
Manager manager = fabricVpnManager;
if (manager != null) {
return sendFabricUplinkBatch(manager, batch, workerIndex);
}
Exception lastError = null;
lastUplinkSendErrorMessage = "";
int relayAttempts = Math.max(1, activePacketRelayUrlsByProfile == null ? 1 : activePacketRelayUrlsByProfile.size());
@@ -2677,6 +2980,108 @@ public class RapVpnService extends VpnService {
return false;
}
private boolean sendFabricUplinkBatch(Manager manager, List<byte[]> batch, int workerIndex) {
if (manager == null || batch == null || batch.isEmpty()) {
return false;
}
try {
for (byte[] packet : batch) {
if (packet != null && packet.length > 0) {
manager.sendPacket(packet);
}
}
return true;
} catch (Throwable e) {
lastUplinkSendErrorMessage = compactException(e);
writeRuntimeDetail("fabric_send_failed", "fabric QUIC send failed worker=" + workerIndex + " error=" + e.getMessage(), "fabric", -1, 1, e.getClass().getSimpleName(), workerIndex);
return false;
}
}
private void pumpFabricDownlinkToQueue(long runtimeId) {
long fetchedPackets = 0;
long errors = 0;
while (isRuntimeActive(runtimeId)) {
Manager manager = fabricVpnManager;
if (manager == null) {
sleepQuietly(50);
continue;
}
try {
byte[] packet = manager.receivePacket(DOWNLINK_POLL_MS_MAX);
if (packet == null || packet.length == 0) {
if (fetchedPackets > 0) {
writeRuntimeDetail("idle", fabricSnapshot(), "fabric_downlink", fetchedPackets, errors, "");
}
continue;
}
if (!isIPv4Packet(packet)) {
recordDownlinkDrop(packet.length);
continue;
}
int length = effectiveIPv4Length(packet, packet.length);
if (length <= 0) {
errors++;
recordDownlinkDrop(packet.length);
writeRuntimeDetail("length_drop", packetSummary(packet, packet.length), "fabric_downlink", fetchedPackets, errors, "LENGTH");
continue;
}
boolean restoredClientNAT = restoreClientSourceNATDestination(packet, length);
if (!fastPathModeEnabled && !hasIPv4Destination(packet, length)) {
long mismatch = downlinkDestinationMismatchPackets.incrementAndGet();
if (mismatch <= ADDRESS_MISMATCH_TOLERANCE_PACKETS) {
recordDownlinkDrop(length);
writeRuntimeDetail("dest_drop", packetSummary(packet, length), "fabric_downlink", fetchedPackets, errors, "DEST_MISMATCH");
continue;
}
relaxedDownlinkDestinationValidation = true;
}
boolean transportChecksumWasValid = hasValidIPv4TransportChecksum(packet, length);
boolean normalized = normalizeIPv4PacketChecksums(packet, length);
if (normalized && (!transportChecksumWasValid || restoredClientNAT)) {
downlinkTransportChecksumRepairs.incrementAndGet();
}
if (!normalized) {
errors++;
recordDownlinkDrop(length);
writeRuntimeDetail("normalize_drop", packetSummary(packet, length), "fabric_downlink", fetchedPackets, errors, "CHECKSUM_NORMALIZE");
continue;
}
recordInboundTCPHandshake(packet, length);
if (offerDownlinkPacket(packet, length)) {
fetchedPackets++;
if (fetchedPackets <= 5 || fetchedPackets % 25 == 0) {
writeRuntimeStatus("fabric_downlink", "queued " + packetSummary(packet, length), 0, 0, downlinkReceivedPackets.get(), errors);
}
} else if (running) {
errors++;
recordDownlinkDrop(length);
writeRuntimeDetail("queue_drop", packetSummary(packet, length), "fabric_downlink", fetchedPackets, errors, "QUEUE_FULL");
}
} catch (Throwable e) {
if (!isRuntimeActive(runtimeId)) {
return;
}
errors++;
writeRuntimeStatus("degraded", "fabric downlink receive failed: " + e.getMessage(), 0, 0, fetchedPackets, errors);
writeRuntimeDetail("receive_failed", "fabric receive failed: " + e.getMessage(), "fabric_downlink", fetchedPackets, errors, e.getClass().getSimpleName());
sleepQuietly(100);
}
}
}
private String fabricSnapshot() {
Manager manager = fabricVpnManager;
if (manager == null) {
return "fabric runtime not connected";
}
try {
return manager.snapshotJSON();
} catch (Exception e) {
return "fabric snapshot failed: " + e.getMessage();
}
}
private String lastWebSocketRelayError() {
VpnPacketWebSocketRelay relay = packetWebSocketRelay;
if (relay == null) {
@@ -3407,7 +3812,7 @@ public class RapVpnService extends VpnService {
}
}
private String compactException(Exception e) {
private String compactException(Throwable e) {
if (e == null) {
return "";
}
@@ -3974,11 +4379,15 @@ public class RapVpnService extends VpnService {
String dataplaneEntryNodeId = "";
String dataplaneExitNodeId = "";
String dataplaneSelectedTransport = "";
boolean meshNodeRouteMode;
String fabricMeshExitEndpoints = "";
String fabricRuntimeConfigJson = "";
String packetRelayBaseUrl = "";
final List<String> packetRelayBaseUrls = new ArrayList<>();
FabricServiceChannel fabricServiceChannel = new FabricServiceChannel();
int dataplaneTransportCandidateCount = 0;
int dataplaneEntryCandidateCount = 0;
int dataplaneExitCandidateCount = 0;
final Set<String> configNotes = new LinkedHashSet<>();
final Set<String> dnsServers = new LinkedHashSet<>();
final Set<String> dnsProbeDomains = new LinkedHashSet<>();
@@ -1,209 +0,0 @@
package su.cin.rapvpn;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.util.Base64;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import org.json.JSONObject;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
public class RdpActivity extends Activity {
static final String EXTRA_SESSION_RESULT = "session_result";
static final String EXTRA_GATEWAY_URL = "gateway_url";
static final String EXTRA_RESOURCE_NAME = "resource_name";
private final OkHttpClient http = new OkHttpClient();
private ImageView desktop;
private TextView overlay;
private WebSocket webSocket;
private int desktopWidth = 1;
private int desktopHeight = 1;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
FrameLayout root = new FrameLayout(this);
root.setBackgroundColor(0xff05090c);
desktop = new ImageView(this);
desktop.setScaleType(ImageView.ScaleType.FIT_CENTER);
desktop.setBackgroundColor(0xff05090c);
desktop.setOnTouchListener((view, event) -> {
sendTouch(event);
return true;
});
overlay = new TextView(this);
overlay.setTextColor(0xffffffff);
overlay.setTextSize(14);
overlay.setBackgroundColor(0x66000000);
overlay.setPadding(14, 10, 14, 10);
overlay.setText("Подключение...");
root.addView(desktop, new FrameLayout.LayoutParams(-1, -1));
root.addView(overlay, new FrameLayout.LayoutParams(-2, -2));
setContentView(root);
connect();
}
@Override
protected void onDestroy() {
if (webSocket != null) {
webSocket.close(1000, "activity closed");
}
super.onDestroy();
}
private void connect() {
try {
JSONObject result = new JSONObject(getIntent().getStringExtra(EXTRA_SESSION_RESULT));
JSONObject token = result.getJSONObject("attach_token");
String attachToken = token.getString("token");
String gatewayUrl = getIntent().getStringExtra(EXTRA_GATEWAY_URL);
String url = gatewayUrl + "?attach_token=" + attachToken;
runOnUiThread(() -> overlay.setText(getIntent().getStringExtra(EXTRA_RESOURCE_NAME)));
Request request = new Request.Builder().url(url).build();
webSocket = http.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) {
runOnUiThread(() -> overlay.setText("Подключено"));
}
@Override
public void onMessage(WebSocket webSocket, String text) {
handleEnvelope(text);
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
runOnUiThread(() -> overlay.setText("Ошибка: " + t.getMessage()));
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
runOnUiThread(() -> overlay.setText("Отключено"));
}
});
} catch (Exception ex) {
overlay.setText("Ошибка запуска: " + ex.getMessage());
}
}
private void handleEnvelope(String text) {
try {
JSONObject envelope = new JSONObject(text);
String type = envelope.optString("type");
if ("session.state".equals(type)) {
JSONObject payload = envelope.optJSONObject("payload");
String state = payload == null ? "" : payload.optString("state", "");
if (!state.isEmpty() && !"active".equals(state)) {
runOnUiThread(() -> overlay.setText("Сессия: " + state));
}
return;
}
if (!"session.frame".equals(type)) {
return;
}
JSONObject payload = envelope.optJSONObject("payload");
if (payload == null) {
return;
}
String frameData = payload.optString("frame_data", "");
int width = payload.optInt("frame_width", payload.optInt("desktop_width", 0));
int height = payload.optInt("frame_height", payload.optInt("desktop_height", 0));
byte[] bytes = Base64.decode(frameData, Base64.DEFAULT);
Bitmap bitmap = decodeFrame(bytes, width, height, payload.optString("frame_format", ""));
if (bitmap != null) {
desktopWidth = Math.max(1, width);
desktopHeight = Math.max(1, height);
runOnUiThread(() -> {
desktop.setImageBitmap(bitmap);
overlay.setText("");
});
}
} catch (Exception ex) {
runOnUiThread(() -> overlay.setText("Кадр: " + ex.getMessage()));
}
}
private Bitmap decodeFrame(byte[] bytes, int width, int height, String format) {
Bitmap compressed = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (compressed != null) {
return compressed;
}
if (width <= 0 || height <= 0 || bytes.length < width * height * 4) {
return null;
}
int[] colors = new int[width * height];
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
for (int i = 0; i < colors.length; i++) {
int b = buffer.get() & 0xff;
int g = buffer.get() & 0xff;
int r = buffer.get() & 0xff;
int a = buffer.get() & 0xff;
colors[i] = (a << 24) | (r << 16) | (g << 8) | b;
}
return Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888);
}
private void sendTouch(MotionEvent event) {
if (webSocket == null || desktop.getWidth() <= 0 || desktop.getHeight() <= 0) {
return;
}
String action;
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
action = "down";
break;
case MotionEvent.ACTION_UP:
action = "up";
break;
case MotionEvent.ACTION_MOVE:
action = "move";
break;
default:
return;
}
double x = Math.max(0, Math.min(1, event.getX() / Math.max(1f, desktop.getWidth())));
double y = Math.max(0, Math.min(1, event.getY() / Math.max(1f, desktop.getHeight())));
try {
JSONObject payload = new JSONObject();
payload.put("correlation_id", UUID.randomUUID().toString());
payload.put("client_captured_at", java.time.Instant.now().toString());
payload.put("kind", "mouse");
payload.put("action", action);
payload.put("button", "left");
payload.put("normalized_x", x);
payload.put("normalized_y", y);
payload.put("surface_width", desktopWidth);
payload.put("surface_height", desktopHeight);
JSONObject envelope = new JSONObject();
envelope.put("type", "input");
envelope.put("payload", payload);
webSocket.send(envelope.toString().getBytes(StandardCharsets.UTF_8).length > 0 ? envelope.toString() : "{}");
} catch (Exception ignored) {
}
}
}