This commit is contained in:
2026-05-18 21:33:39 +03:00
parent 5096155d83
commit 469fa0e860
94 changed files with 8761 additions and 8003 deletions
+26 -8
View File
@@ -1,18 +1,25 @@
# RAP Android VPN
This is the Android client for the experimental RAP VPN service.
This is the Android mobile node build with the `vpn-client` service enabled.
Implemented now:
- login through `/auth/login`;
- trusted-device reconnect through `/auth/refresh` without retyping the password
while the device session is valid;
- load organization-scoped VPN client profile from `/clusters/{clusterID}/vpn/client-profile`;
- installation as a first-class fabric node with an embedded QUIC bootstrap
seed set. The seed set is not a backend selector: it contains every known
public or local entry candidate that may help the node join the fabric from
its current network.
- runtime launch uses a persisted `fabric_bootstrap_config`, not a backend API
URL. The Android node starts by attaching to the fabric through bootstrap
peers and then discovers/uses services through fabric rules.
- login and trusted-device refresh through the QUIC fabric control channel;
- load organization-scoped VPN client profile through the fabric control channel;
- request Android VPN permission and create a `VpnService` TUN interface;
- 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.
routed by the farm to an authorized `ipv4-egress` pool. The supported
dataplane is the QUIC fabric runtime only. HTTP batch forwarding, WebSocket
packet relay, direct backend packet relay, and old VPN protocols are removed
from the runtime 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
@@ -20,12 +27,23 @@ Implemented now:
- encrypted refresh-token storage through Android Keystore. If the trusted
device session is revoked or expires, the app asks for the password once and
then rotates the device keys/profile again.
- no separate diagnostic foreground service: runtime status is reported by the
node/VPN service itself, so the Android build does not keep a parallel legacy
control process alive.
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.
currently contain only one node. A phone installed in a closed network may join
through local seed nodes from that network; it does not need direct Internet
access if a nearby fabric node can route onward.
Current code contract:
- Android control bootstrap field: `fabric_bootstrap_config`
- Android runtime dataplane: QUIC `Fabricvpn` runtime only
- Android runtime status keys: `fabric_transport_*`
Build from this repository on Windows:
+8 -4
View File
@@ -22,8 +22,12 @@ android {
return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"")
}
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 defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: ""
// This is a node bootstrap seed set, not an API/backend selector. The
// Android app installs as a fabric node and tries every QUIC endpoint that
// may be reachable from its current network: public nodes, LAN nodes, or a
// closed-site neighbor that can route onward through the fabric.
def defaultFabricBootstrapPeers = project.findProperty("RAP_ANDROID_FABRIC_BOOTSTRAP_PEERS") ?: "quic://94.141.118.222:19199#sha256=49892029a27db9c394a41bc4cb917d9cceb1f86219417c351764d2ed9d6bc683,quic://94.141.118.222:19191#sha256=72e51f1631b32c3a7d1e8732fe3325e0395a897a5aa31db645888c142e4ae401,quic://192.168.200.61:19134#sha256=72e51f1631b32c3a7d1e8732fe3325e0395a897a5aa31db645888c142e4ae401,quic://192.168.200.61:19132#sha256=8d28b75144d25d29e3b8f8022b6165258ce3cb0e227a2d9d97996839abb89c2a,quic://192.168.200.61:19133#sha256=a71b07e55b810f57b01696c485b765b336983e963238163085824bf04022ecaa,quic://192.168.200.85:18080#sha256=49892029a27db9c394a41bc4cb917d9cceb1f86219417c351764d2ed9d6bc683,quic://192.168.200.85:18081#sha256=2a3be67e6345943a36cfa1197a5879c2b112c81adc019fd1ee9d7dffbf188b57,quic://192.168.200.85:18082#sha256=a318c1a756ff43595635961768dfd1677afa7e2cbf945d724c107ff82426378a"
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"
@@ -31,8 +35,8 @@ android {
applicationId "su.cin.rapvpn"
minSdk 26
targetSdk 35
versionCode 227
versionName "0.2.227"
versionCode 239
versionName "0.2.239"
buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\""
buildConfigField "String", "FABRIC_BOOTSTRAP_PEERS", "\"${normalizeGradleString(defaultFabricBootstrapPeers)}\""
buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\""
Binary file not shown.
Binary file not shown.
@@ -42,15 +42,6 @@
android:value="vpn" />
</service>
<service
android:name=".RapDiagnosticService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn-diagnostics" />
</service>
<receiver
android:name=".RapAutostartReceiver"
android:exported="false">
@@ -1,140 +0,0 @@
package su.cin.rapvpn;
import android.util.Base64;
import org.json.JSONObject;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import okhttp3.Request;
final class FabricServiceChannel {
final boolean enabled;
final String channelId;
final String token;
final String pathTemplate;
final String webSocketPathTemplate;
final String authorityPayloadHeader;
final String authoritySignatureHeader;
final String serviceClass;
final String channelClass;
FabricServiceChannel() {
this(false, "", "", "", "", "", "", "", "");
}
private FabricServiceChannel(
boolean enabled,
String channelId,
String token,
String pathTemplate,
String webSocketPathTemplate,
String authorityPayloadHeader,
String authoritySignatureHeader,
String serviceClass,
String channelClass) {
this.enabled = enabled;
this.channelId = safe(channelId);
this.token = safe(token);
this.pathTemplate = safe(pathTemplate);
this.webSocketPathTemplate = safe(webSocketPathTemplate);
this.authorityPayloadHeader = safe(authorityPayloadHeader);
this.authoritySignatureHeader = safe(authoritySignatureHeader);
this.serviceClass = safe(serviceClass);
this.channelClass = safe(channelClass);
}
static FabricServiceChannel fromLease(JSONObject lease) {
if (lease == null) {
return new FabricServiceChannel();
}
JSONObject tokenObject = lease.optJSONObject("token");
JSONObject entryHttp = lease.optJSONObject("entry_http");
String channelId = lease.optString("channel_id", "");
String token = tokenObject == null ? "" : tokenObject.optString("token", "");
String pathTemplate = entryHttp == null ? "" : entryHttp.optString("path_template", "");
String wsTemplate = entryHttp == null ? "" : entryHttp.optString("websocket_path_template", "");
String serviceClass = lease.optString("service_class", "vpn_packets");
String channelClass = "vpn_packet";
JSONObject authoritySignature = lease.optJSONObject("authority_signature");
JSONObject authorityPayload = lease.optJSONObject("authority_payload");
String payloadHeader = authorityPayload == null ? "" : encodeHeader(authorityPayload.toString());
String signatureHeader = authoritySignature == null ? "" : encodeHeader(authoritySignature.toString());
boolean enabled = !channelId.isEmpty() && token.startsWith("rap_fsc_") && !pathTemplate.isEmpty();
return new FabricServiceChannel(enabled, channelId, token, pathTemplate, wsTemplate, payloadHeader, signatureHeader, serviceClass, channelClass);
}
String packetPath(String clusterId, String vpnConnectionId, boolean webSocket) {
return packetPathForBase("", clusterId, vpnConnectionId, webSocket);
}
String packetPathForBase(String baseUrl, String clusterId, String vpnConnectionId, boolean webSocket) {
String template = webSocket && !webSocketPathTemplate.isEmpty() ? webSocketPathTemplate : pathTemplate;
if (!enabled || template.isEmpty()) {
return "";
}
String path = template
.replace("{cluster_id}", safe(clusterId))
.replace("{clusterID}", safe(clusterId))
.replace("{channel_id}", channelId)
.replace("{channelID}", channelId)
.replace("{resource_id}", safe(vpnConnectionId))
.replace("{resourceID}", safe(vpnConnectionId))
.replace("{vpn_connection_id}", safe(vpnConnectionId))
.replace("{vpnConnectionID}", safe(vpnConnectionId));
path = path.startsWith("/") ? path : "/" + path;
String basePath = "";
try {
URI uri = URI.create(baseUrl == null ? "" : baseUrl);
basePath = uri.getRawPath() == null ? "" : trimRight(uri.getRawPath());
} catch (Exception ignored) {
}
if (basePath.endsWith("/api/v1") && path.startsWith("/api/v1/")) {
path = path.substring("/api/v1".length());
}
return path;
}
Request.Builder applyHeaders(Request.Builder builder) {
if (!enabled || builder == null) {
return builder;
}
builder.header("X-RAP-Service-Channel-Token", token);
builder.header("X-RAP-Fabric-Channel-ID", channelId);
if (!serviceClass.isEmpty()) {
builder.header("X-RAP-Service-Class", serviceClass);
}
if (!channelClass.isEmpty()) {
builder.header("X-RAP-Channel-Class", channelClass);
}
if (!authorityPayloadHeader.isEmpty()) {
builder.header("X-RAP-Service-Channel-Authority-Payload", authorityPayloadHeader);
}
if (!authoritySignatureHeader.isEmpty()) {
builder.header("X-RAP-Service-Channel-Authority-Signature", authoritySignatureHeader);
}
return builder;
}
private static String encodeHeader(String value) {
if (value == null || value.isEmpty()) {
return "";
}
return Base64.encodeToString(value.getBytes(StandardCharsets.UTF_8), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
}
private static String safe(String value) {
return value == null ? "" : value.trim();
}
private static String trimRight(String value) {
if (value == null) {
return "";
}
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
}
@@ -24,14 +24,13 @@ 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 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";
private static final int VPN_PREPARE_REQUEST = 42;
private static final String PREFS = "rap-vpn";
private static final String PREF_DEVICE_FINGERPRINT = "device_fingerprint";
private static final String PREF_FABRIC_NODE_ID = "fabric_node_id";
private static final String PREF_REFRESH_TOKEN = "refresh_token";
private static final String PREF_REFRESH_EXPIRES_AT = "refresh_expires_at";
private static final String PREF_USER_ID = "user_id";
@@ -39,7 +38,6 @@ public class MainActivity extends Activity {
private static final String PREF_PROFILE_JSON = "profile_json";
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
static final String PREF_FORCE_FULL_TUNNEL = "force_full_tunnel";
private EditText backendUrl;
private EditText clusterId;
private EditText organizationId;
private EditText email;
@@ -66,7 +64,6 @@ public class MainActivity extends Activity {
int pad = dp(20);
root.setPadding(pad, pad, pad, pad);
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"));
@@ -102,10 +99,6 @@ public class MainActivity extends Activity {
runtimeStatus.setPadding(0, 0, 0, dp(10));
runtimeStatus.setText(runtimeStatusText());
Button load = new Button(this);
load.setText("Войти / обновить пулы");
load.setOnClickListener(v -> loadProfile(false));
Button start = new Button(this);
start.setText("Подключить");
start.setOnClickListener(v -> prepareVpn());
@@ -148,12 +141,11 @@ public class MainActivity extends Activity {
});
Button settings = new Button(this);
settings.setText("Аккаунт");
settings.setText("Настройка");
settings.setOnClickListener(v -> showSettingsDialog());
root.addView(title);
root.addView(profileSummary);
root.addView(load);
root.addView(start);
root.addView(stop);
root.addView(settings);
@@ -161,9 +153,7 @@ public class MainActivity extends Activity {
root.addView(runtimeStatus);
setContentView(root);
scheduleRuntimeStatusRefresh();
if (authContext != null && !authContext.deviceId.isEmpty()) {
startDiagnosticChannel();
}
registerCandidateNodeAsync(false);
}
@Override
@@ -179,62 +169,38 @@ public class MainActivity extends Activity {
return input;
}
private void loadProfile() {
loadProfile(false);
}
private void loadProfile(boolean startAfterLoad) {
status.setText("Загрузка...");
saveSettings();
private void prepareVpn() {
if (!hasSelectedPool()) {
status.setText("Сначала выберите выходной пул.");
showSettingsDialog();
return;
}
status.setText("Проверяю доступ к выбранному пулу...");
new Thread(() -> {
try {
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
authContext = authenticate(client);
String activeOrganizationId = resolveOrganizationId(client, authContext.userId);
profileJson = client.vpnClientProfile(
clusterId.getText().toString(),
activeOrganizationId,
authContext.userId,
""
);
vpnConnectionId = firstConnectionId(profileJson);
saveProfileState();
refreshSavedProfileForCurrentUser();
if (!hasSelectedPool()) {
throw new IllegalStateException("Выбранный пул больше не доступен.");
}
runOnUiThread(() -> {
profileSummary.setText(summaryText());
status.setText(startAfterLoad ? "Список пулов обновлен. Подключаю..." : "Список доступных пулов обновлен.");
startDiagnosticChannel();
if (startAfterLoad) {
requestVpnPermission();
}
status.setText("Доступ подтвержден. Подключаюсь к выбранному пулу.");
requestVpnPermission();
});
} catch (Exception ex) {
runOnUiThread(() -> {
String message = friendlyError(ex);
boolean canUseSavedProfile = startAfterLoad && !profileJson.isEmpty() && !vpnConnectionId.isEmpty();
if (canUseSavedProfile) {
status.setText("Список пулов сейчас не обновился: " + message + ". Подключаюсь с сохраненным рабочим профилем.");
startDiagnosticChannel();
requestVpnPermission();
return;
}
status.setText("Ошибка входа: " + message);
if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) {
clearSavedAuth(false);
showSettingsDialog();
}
status.setText("Нужна настройка: " + message);
showSettingsDialog();
});
}
}).start();
}
private void prepareVpn() {
loadProfile(true);
status.setText("Обновляю сессию устройства и доступные пулы...");
}
private void requestVpnPermission() {
if (profileJson.isEmpty()) {
status.setText("VPN-профиль не загружен.");
if (!hasSelectedPool()) {
status.setText("Выходной пул не выбран или больше не доступен.");
showSettingsDialog();
return;
}
Intent prepare = VpnService.prepare(this);
@@ -254,32 +220,37 @@ public class MainActivity extends Activity {
}
private void startVpn() {
Intent intent = new Intent(this, RapVpnService.class);
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson);
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, backendUrl.getText().toString());
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString());
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
startForegroundService(intent);
status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал.");
runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText());
runtimeStatus.postDelayed(() -> {
String state = runtimePrefs.getString("state", "");
boolean runtimeActive = isVpnRuntimeActive();
if (!isSystemVpnActive()) {
if (runtimeActive) {
status.setText("VPN runtime активен, рабочий канал поднят. Android еще обновляет системный статус.");
} else if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) {
status.setText("VPN не включился: " + runtimePrefs.getString("message", "Android остановил VPN-сервис") + ".");
} else if ("starting".equals(state) || "tunnel".equals(state) || "relay_selected".equals(state) || "relay".equals(state) || "relay_reset".equals(state)) {
status.setText("VPN запускается. Android еще применяет туннель, ожидаю рабочий канал.");
try {
Intent intent = new Intent(this, RapVpnService.class);
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson);
intent.putExtra(RapVpnService.EXTRA_FABRIC_BOOTSTRAP_CONFIG, fabricControlConfig());
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString());
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
startForegroundService(intent);
status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал.");
runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText());
runtimeStatus.postDelayed(() -> {
String state = runtimePrefs.getString("state", "");
boolean runtimeActive = isVpnRuntimeActive();
if (!isSystemVpnActive()) {
if (runtimeActive) {
status.setText("VPN runtime активен, рабочий канал поднят. Android еще обновляет системный статус.");
} else if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) {
status.setText("VPN не включился: " + runtimePrefs.getString("message", "Android остановил VPN-сервис") + ".");
} else if ("starting".equals(state) || "tunnel".equals(state) || isTransportWarmupState(state)) {
status.setText("VPN запускается. Android еще применяет туннель, ожидаю рабочий канал.");
} else {
status.setText("VPN еще не активен в Android. Проверьте системный запрос разрешения VPN.");
}
} else {
status.setText("VPN еще не активен в Android. Проверьте системный запрос разрешения VPN.");
status.setText("VPN включен Android. Версия " + APP_VERSION + ".");
}
} else {
status.setText("VPN включен Android. Версия " + APP_VERSION + ".");
}
runtimeStatus.setText(runtimeStatusText());
}, 2500);
runtimeStatus.setText(runtimeStatusText());
}, 2500);
} catch (Exception e) {
status.setText("VPN не запущен: bootstrap-конфиг фабрики недоступен.");
runtimeStatus.setText("Ошибка запуска: " + e.getMessage());
}
}
private void scheduleRuntimeStatusRefresh() {
@@ -335,9 +306,9 @@ public class MainActivity extends Activity {
boolean osVpnActive = isSystemVpnActive();
String routes = runtimePrefs.getString("routes", "");
String dnsServers = runtimePrefs.getString("dns_servers", "");
String profileRelayUrl = runtimePrefs.getString("packet_relay_profile_base_url", "");
String activeRelayUrl = runtimePrefs.getString("packet_relay_active_base_url", "");
String relayCandidates = runtimePrefs.getString("packet_relay_candidate_urls", "");
String profileTransportEndpoint = runtimePrefs.getString("fabric_transport_profile_endpoint", "");
String activeTransportEndpoint = runtimePrefs.getString("fabric_transport_active_endpoint", "");
String transportCandidates = runtimePrefs.getString("fabric_transport_candidate_endpoints", "");
boolean forceFullTunnelRuntime = false;
boolean fastPathEnabled = false;
try {
@@ -350,11 +321,14 @@ public class MainActivity extends Activity {
}
boolean staleState = updatedAt > 0 && (System.currentTimeMillis() - updatedAt) > 12_000;
boolean runtimeActive = isVpnRuntimeActive();
if (!osVpnActive && !runtimeActive && ("running".equals(state) || "tunnel".equals(state) || "relay".equals(state) || "relay_reset".equals(state))) {
if (!osVpnActive && !runtimeActive && ("running".equals(state) || "tunnel".equals(state) || isTransportWarmupState(state))) {
state = "stale_no_os_vpn";
message = "Сервис говорит об активном состоянии, но Android VPN-интерфейс не активен. Проверьте разрешения/ручной запуск.";
staleState = false;
}
String transportEndpoint = activeTransportEndpoint.isEmpty() ? "-" : activeTransportEndpoint;
String transportTargets = transportCandidates.isEmpty() ? "-" : transportCandidates;
String profileTarget = profileTransportEndpoint.isEmpty() ? "-" : profileTransportEndpoint;
return "Диагностика: " + state
+ "\n" + message
+ "\nOS VPN: " + (osVpnActive ? "активен" : (runtimeActive ? "runtime активен" : "неактивен"))
@@ -369,9 +343,9 @@ public class MainActivity extends Activity {
+ " / down " + String.format(Locale.US, "%.1f", downlinkPps)
+ "\nDNS выхода: " + (dnsServers.isEmpty() ? "-" : dnsServers)
+ "\nroutes: " + (routes.isEmpty() ? "-" : routes)
+ "\nrelay active: " + (activeRelayUrl.isEmpty() ? "-" : activeRelayUrl)
+ "\nrelay profile: " + (profileRelayUrl.isEmpty() ? "-" : profileRelayUrl)
+ "\nrelay candidates: " + (relayCandidates.isEmpty() ? "-" : relayCandidates)
+ "\ntransport endpoint: " + transportEndpoint
+ "\nprofile target: " + profileTarget
+ "\ntransport candidates: " + transportTargets
+ "\nforced_full_tunnel: " + (forceFullTunnelRuntime ? "да" : "нет")
+ "\nfast_path_mode: " + (fastPathEnabled ? "включен" : "выключен")
+ "\nbytes read/sent/down: " + readBytes + "/" + sentBytes + "/" + downBytes
@@ -389,13 +363,6 @@ public class MainActivity extends Activity {
+ "\nобновлено: " + age;
}
private void startDiagnosticChannel() {
if (authContext == null || authContext.deviceId.isEmpty()) {
return;
}
RapDiagnosticService.start(this);
}
private boolean isSystemVpnActive() {
try {
ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
@@ -426,20 +393,31 @@ public class MainActivity extends Activity {
if (updatedAt <= 0 || (System.currentTimeMillis() - updatedAt) > 15_000) {
return false;
}
String relay = runtimePrefs.getString("packet_relay_active_base_url", "");
String activeTransportEndpoint = runtimePrefs.getString("fabric_transport_active_endpoint", "");
long read = runtimePrefs.getLong("uplink_read_total", 0);
long sent = runtimePrefs.getLong("uplink_sent_total", 0);
long down = runtimePrefs.getLong("downlink_received_total", 0);
return !relay.isEmpty() && ("running".equals(state)
|| "relay".equals(state)
|| "relay_reset".equals(state)
return !activeTransportEndpoint.isEmpty() && ("running".equals(state)
|| "fabric_transport".equals(state)
|| "fabric_transport_reset".equals(state)
|| "downlink".equals(state)
|| "downlink_idle".equals(state)
|| "uplink_sent".equals(state)
|| read > 0 || sent > 0 || down > 0);
}
private boolean isTransportWarmupState(String state) {
return "fabric_transport_selected".equals(state)
|| "fabric_transport".equals(state)
|| "fabric_transport_reset".equals(state)
|| "fabric_transport_switch".equals(state);
}
private String firstConnectionId(String profile) throws Exception {
String selected = prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, "").trim();
if (!selected.isEmpty() && profileContainsConnection(profile, selected)) {
return selected;
}
JSONObject root = new JSONObject(profile);
JSONObject vpnProfile = root.getJSONObject("vpn_client_profile");
JSONArray connections = vpnProfile.getJSONArray("connections");
@@ -489,6 +467,36 @@ public class MainActivity extends Activity {
return connections.getJSONObject(0).getString("id");
}
private boolean hasSelectedPool() {
return profileJson != null
&& !profileJson.trim().isEmpty()
&& vpnConnectionId != null
&& !vpnConnectionId.trim().isEmpty()
&& profileContainsConnection(profileJson, vpnConnectionId.trim());
}
private boolean profileContainsConnection(String profile, String connectionId) {
if (profile == null || profile.trim().isEmpty() || connectionId == null || connectionId.trim().isEmpty()) {
return false;
}
try {
JSONObject root = new JSONObject(profile);
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
if (connections == null) {
return false;
}
for (int i = 0; i < connections.length(); i++) {
JSONObject connection = connections.optJSONObject(i);
if (connection != null && connectionId.trim().equals(connection.optString("id", ""))) {
return true;
}
}
} catch (Exception ignored) {
}
return false;
}
private int dp(int value) {
return (int) (value * getResources().getDisplayMetrics().density);
}
@@ -504,8 +512,8 @@ public class MainActivity extends Activity {
return "Версия: " + APP_VERSION
+ "\nУзел Android: в ферме"
+ "\nBootstrap фермы: " + bootstrapPeerCount() + " узл."
+ "\nДоступные выходы: " + (poolText.isEmpty() ? "войдите для загрузки" : poolText)
+ "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "автоматически" : selectedPoolText)
+ "\nДоступные выходы: " + (poolText.isEmpty() ? "не загружены" : poolText)
+ "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "не выбран" : selectedPoolText)
+ "\nDNS выхода: " + (profileDNS.isEmpty() ? "будет получен из профиля" : profileDNS)
+ "\nТрафик: " + (prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true) ? "весь через VPN" : "по профилю")
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
@@ -647,20 +655,7 @@ public class MainActivity extends Activity {
return out.toString();
}
private String preferredBackendUrl() {
String saved = prefs.getString("backend_url", DEFAULT_BACKEND_URL);
String normalized = normalizeBackendUrl(saved);
if (!normalized.equals(saved == null ? "" : saved.trim())) {
prefs.edit().putString("backend_url", normalized).apply();
}
return normalized;
}
private void saveSettings() {
String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString());
if (!normalizedBackend.equals(backendUrl.getText().toString().trim())) {
backendUrl.setText(normalizedBackend);
}
normalizeAndPersistDefaults();
if (clusterId.getText().toString().trim().isEmpty()) {
clusterId.setText(DEFAULT_CLUSTER_ID);
@@ -669,7 +664,6 @@ public class MainActivity extends Activity {
organizationId.setText(DEFAULT_ORGANIZATION_ID);
}
prefs.edit()
.putString("backend_url", normalizedBackend)
.putString("cluster_id", clusterId.getText().toString())
.putString("organization_id", organizationId.getText().toString())
.putString("email", email.getText().toString())
@@ -677,10 +671,6 @@ public class MainActivity extends Activity {
}
private void normalizeAndPersistDefaults() {
String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString());
if (normalizedBackend.isEmpty()) {
backendUrl.setText(DEFAULT_BACKEND_URL);
}
if (clusterId.getText().toString().trim().isEmpty()) {
clusterId.setText(DEFAULT_CLUSTER_ID);
}
@@ -689,38 +679,48 @@ public class MainActivity extends Activity {
}
}
private String normalizeBackendUrl(String value) {
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
if (candidate.isEmpty()) {
return DEFAULT_BACKEND_URL;
private String fabricControlConfig() throws Exception {
JSONArray endpoints = new JSONArray();
for (String peer : FABRIC_BOOTSTRAP_PEERS.split(",")) {
String raw = peer == null ? "" : peer.trim();
String address = raw;
String certSHA256 = "";
int fragmentIndex = raw.indexOf('#');
if (fragmentIndex >= 0) {
address = raw.substring(0, fragmentIndex).trim();
String fragment = raw.substring(fragmentIndex + 1).trim();
if (fragment.startsWith("sha256=")) {
certSHA256 = fragment.substring("sha256=".length()).trim();
}
}
if (address.isEmpty()) {
continue;
}
JSONObject endpoint = new JSONObject();
endpoint.put("endpoint_id", address);
endpoint.put("address", address);
endpoint.put("transport", "direct_quic");
if (certSHA256.matches("^[0-9a-fA-F]{64}$")) {
endpoint.put("peer_cert_sha256", certSHA256.toLowerCase(Locale.US));
}
endpoints.put(endpoint);
}
String lower = candidate.toLowerCase(Locale.US);
if ("http://vpn.cin.su:19191/api/v1".equals(lower)
|| "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)) {
return DEFAULT_BACKEND_URL;
if (endpoints.length() == 0) {
throw new IllegalStateException("В клиенте нет bootstrap-узлов фермы.");
}
return candidate;
}
private String selectedExitNodeId() {
return "";
}
private String normalizeSelectedExitNodeId(String value) {
String candidate = value == null ? "" : value.trim();
if (candidate.isEmpty()) {
return "";
}
if (candidate.matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) {
return candidate;
}
if (candidate.matches("^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$")) {
return candidate;
}
return "";
JSONObject service = new JSONObject();
service.put("schema_version", "rap.fabric_service_channel_request.v1");
service.put("channel_id", "android-control");
service.put("service_class", "identity_runtime");
service.put("source_role", "vpn-client");
JSONObject cfg = new JSONObject();
cfg.put("cluster_id", DEFAULT_CLUSTER_ID);
cfg.put("local_node_id", fabricNodeId());
cfg.put("vpn_connection_id", "fabric-control");
cfg.put("stream_shards", 1);
cfg.put("service_channel_request", service);
cfg.put("endpoints", endpoints);
return cfg.toString();
}
private RapApiClient.AuthContext authenticate(RapApiClient client) throws Exception {
@@ -743,6 +743,44 @@ public class MainActivity extends Activity {
return loggedIn;
}
private RapApiClient.AuthContext authenticateWithPassword(RapApiClient client, String emailValue, String passwordValue) throws Exception {
if (passwordValue == null || passwordValue.trim().isEmpty()) {
throw new IllegalStateException("Введите пароль для идентификации устройства и выбора пула.");
}
RapApiClient.AuthContext loggedIn = client.login(emailValue.trim(), passwordValue.trim(), deviceFingerprint());
saveAuthContext(loggedIn);
return loggedIn;
}
private void refreshSavedProfileForCurrentUser() throws Exception {
String userId = prefs.getString(PREF_USER_ID, "");
if (userId == null || userId.trim().isEmpty()) {
throw new IllegalStateException("Устройство еще не привязано к пользователю.");
}
RapApiClient client = new RapApiClient(fabricControlConfig(), this);
String refreshToken = savedRefreshToken();
if (!refreshToken.isEmpty()) {
authContext = client.refresh(refreshToken);
saveAuthContext(authContext);
userId = authContext.userId;
}
String activeOrganizationId = resolveOrganizationId(client, userId);
String refreshedProfile = client.vpnClientProfile(
clusterId.getText().toString(),
activeOrganizationId,
userId,
""
);
if (!profileContainsConnection(refreshedProfile, vpnConnectionId)) {
profileJson = refreshedProfile;
vpnConnectionId = "";
saveProfileState();
throw new IllegalStateException("Администратор закрыл доступ к выбранному пулу или пул удален.");
}
profileJson = refreshedProfile;
saveProfileState();
}
private String resolveOrganizationId(RapApiClient client, String userId) throws Exception {
JSONObject payload = client.organizations(userId);
JSONArray organizations = payload.optJSONArray("organizations");
@@ -850,6 +888,89 @@ public class MainActivity extends Activity {
return generated;
}
private String fabricNodeId() {
String existing = prefs.getString(PREF_FABRIC_NODE_ID, "");
if (existing != null && existing.matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) {
return existing.toLowerCase(Locale.US);
}
String generated = java.util.UUID.randomUUID().toString();
prefs.edit().putString(PREF_FABRIC_NODE_ID, generated).apply();
return generated;
}
private void registerCandidateNodeAsync(boolean showStatus) {
new Thread(() -> {
try {
RapApiClient client = new RapApiClient(fabricControlConfig(), this);
String nodeId = registerCandidateNode(client);
sendCandidateHeartbeat(client, nodeId);
if (showStatus) {
runOnUiThread(() -> status.setText("Узел телефона виден ферме как кандидат: " + nodeId));
}
} catch (Exception ex) {
if (showStatus) {
runOnUiThread(() -> status.setText("Узел телефона пока не зарегистрирован в ферме: " + friendlyError(ex)));
}
}
}, "rap-fabric-candidate-register").start();
}
private String registerCandidateNode(RapApiClient client) throws Exception {
String nodeId = fabricNodeId();
JSONObject metadata = new JSONObject();
metadata.put("source", "android_vpn_client");
metadata.put("candidate_access", true);
metadata.put("fabric_transport", "quic");
metadata.put("connectivity_mode", "outbound_only");
metadata.put("app_version", APP_VERSION);
metadata.put("device_fingerprint", deviceFingerprint());
JSONObject payload = new JSONObject();
payload.put("cluster_id", clusterId.getText().toString().trim().isEmpty() ? DEFAULT_CLUSTER_ID : clusterId.getText().toString().trim());
payload.put("node_key", "android-vpn:" + deviceFingerprint());
payload.put("name", "android-vpn-" + deviceFingerprint().replace("android-", "").substring(0, Math.min(8, deviceFingerprint().replace("android-", "").length())));
payload.put("ownership_type", "customer_managed");
payload.put("owner_organization_id", organizationId.getText().toString().trim().isEmpty() ? DEFAULT_ORGANIZATION_ID : organizationId.getText().toString().trim());
payload.put("reported_version", APP_VERSION);
payload.put("metadata", metadata);
JSONObject response = client.registerFabricNode(payload);
String registeredNodeId = response.optString("node_id", nodeId).trim();
if (!registeredNodeId.isEmpty()) {
prefs.edit().putString(PREF_FABRIC_NODE_ID, registeredNodeId).apply();
return registeredNodeId;
}
return nodeId;
}
private void sendCandidateHeartbeat(RapApiClient client, String nodeId) throws Exception {
JSONObject capabilities = new JSONObject();
capabilities.put("fabric_quic_node", true);
capabilities.put("android_vpn_client", true);
capabilities.put("candidate_access", true);
capabilities.put("vpn_client", true);
JSONObject serviceStates = new JSONObject();
serviceStates.put("vpn-client", new JSONObject()
.put("state", isSystemVpnActive() ? "running" : "candidate")
.put("runtime", "android_vpnservice")
.put("transport", "fabric_quic_route"));
JSONObject metadata = new JSONObject();
metadata.put("source", "android_vpn_client");
metadata.put("candidate", true);
metadata.put("passive", true);
metadata.put("app_version", APP_VERSION);
metadata.put("mesh_endpoint_report", new JSONObject()
.put("schema_version", "rap.mesh_endpoint_report.v1")
.put("transport", "quic")
.put("connectivity_mode", "outbound_only")
.put("endpoint_candidates", new JSONArray()));
JSONObject payload = new JSONObject();
payload.put("health_status", "healthy");
payload.put("reported_version", APP_VERSION);
payload.put("capabilities", capabilities);
payload.put("service_states", serviceStates);
payload.put("metadata", metadata);
client.sendFabricNodeHeartbeat(clusterId.getText().toString().trim().isEmpty() ? DEFAULT_CLUSTER_ID : clusterId.getText().toString().trim(), nodeId, payload);
}
private void showSettingsDialog() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
@@ -877,17 +998,15 @@ public class MainActivity extends Activity {
form.addView(showPassword);
form.addView(forceFullTunnel);
new AlertDialog.Builder(this)
.setTitle("Аккаунт VPN")
.setTitle("Настройка VPN")
.setView(form)
.setPositiveButton("Сохранить", (dialog, which) -> {
.setPositiveButton("Войти и выбрать выход", (dialog, which) -> {
email.setText(emailDraft.getText().toString());
password.setText(passwordDraft.getText().toString());
prefs.edit()
.remove(PREF_SELECTED_EXIT_NODE_ID)
.apply();
String passwordValue = passwordDraft.getText().toString();
password.setText("");
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, forceFullTunnel.isChecked()).apply();
saveSettings();
profileSummary.setText(summaryText());
loginAndChoosePool(emailDraft.getText().toString(), passwordValue);
})
.setNeutralButton("Забыть устройство", (dialog, which) -> {
clearSavedAuth(true);
@@ -897,6 +1016,72 @@ public class MainActivity extends Activity {
.show();
}
private void loginAndChoosePool(String emailValue, String passwordValue) {
status.setText("Идентифицирую устройство и загружаю доступные выходы...");
new Thread(() -> {
try {
RapApiClient client = new RapApiClient(fabricControlConfig(), this);
authContext = authenticateWithPassword(client, emailValue, passwordValue);
String activeOrganizationId = resolveOrganizationId(client, authContext.userId);
String loadedProfile = client.vpnClientProfile(
clusterId.getText().toString(),
activeOrganizationId,
authContext.userId,
""
);
runOnUiThread(() -> showPoolChoiceDialog(loadedProfile));
} catch (Exception ex) {
runOnUiThread(() -> {
status.setText("Ошибка настройки: " + friendlyError(ex));
if (friendlyError(ex).contains("пароль")) {
clearSavedAuth(false);
}
});
}
}).start();
}
private void showPoolChoiceDialog(String loadedProfile) {
try {
JSONObject root = new JSONObject(loadedProfile);
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
if (connections == null || connections.length() == 0) {
throw new IllegalStateException("Для пользователя нет доступных выходных пулов.");
}
String[] labels = new String[connections.length()];
String[] ids = new String[connections.length()];
int selectedIndex = 0;
for (int i = 0; i < connections.length(); i++) {
JSONObject connection = connections.getJSONObject(i);
ids[i] = connection.optString("id", "");
String name = connection.optString("exit_pool_name", "").trim();
if (name.isEmpty()) {
name = connection.optString("name", "").trim();
}
labels[i] = name.isEmpty() ? "Выход " + (i + 1) : name;
if (!vpnConnectionId.isEmpty() && vpnConnectionId.equals(ids[i])) {
selectedIndex = i;
}
}
int initialSelection = selectedIndex;
new AlertDialog.Builder(this)
.setTitle("Выходной пул")
.setSingleChoiceItems(labels, initialSelection, (dialog, which) -> {
profileJson = loadedProfile;
vpnConnectionId = ids[which];
saveProfileState();
profileSummary.setText(summaryText());
status.setText("Выбран выходной пул: " + labels[which]);
dialog.dismiss();
})
.setNegativeButton("Отмена", null)
.show();
} catch (Exception ex) {
status.setText("Ошибка выбора пула: " + friendlyError(ex));
}
}
private String friendlyError(Exception ex) {
String message = ex.getMessage();
if (message == null || message.trim().isEmpty()) {
@@ -4,7 +4,6 @@ import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.VpnService;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
@@ -16,35 +15,28 @@ import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import su.cin.rapvpn.fabric.fabricvpn.Fabricvpn;
import su.cin.rapvpn.fabric.fabricvpn.Manager;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import javax.net.SocketFactory;
final class RapApiClient {
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream");
private static final int MAX_PACKET_BATCH_PACKETS = 512;
private static final int MAX_PACKET_BATCH_BYTES = 512 * 1024;
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
private static final int MAX_BATCH_HEADER_BYTES = 4;
private final String baseUrl;
private final OkHttpClient httpClient;
private final String networkMode;
private final FabricServiceChannel fabricServiceChannel;
private final Manager fabricControlManager;
RapApiClient(String baseUrl) {
this(baseUrl, (Context) null);
@@ -52,7 +44,7 @@ final class RapApiClient {
RapApiClient(String baseUrl, Context context) {
this.baseUrl = trimRight(baseUrl);
this.fabricServiceChannel = new FabricServiceChannel();
this.fabricControlManager = startFabricControlManager(baseUrl);
OkHttpClient.Builder builder = new OkHttpClient.Builder();
// Regular app and diagnostic requests should use Android's default
// routing. Some devices reject binding app sockets to a specific
@@ -74,7 +66,7 @@ final class RapApiClient {
RapApiClient(String baseUrl, Context context, boolean preferUnderlyingNetwork) {
this.baseUrl = trimRight(baseUrl);
this.fabricServiceChannel = new FabricServiceChannel();
this.fabricControlManager = startFabricControlManager(baseUrl);
OkHttpClient.Builder builder = new OkHttpClient.Builder();
String mode = context == null ? "default_network" : "default_network_context";
if (preferUnderlyingNetwork && context != null) {
@@ -99,74 +91,27 @@ final class RapApiClient {
this.httpClient = builder.build();
}
RapApiClient(String baseUrl, VpnService vpnService) {
this(baseUrl, vpnService, new FabricServiceChannel());
}
RapApiClient(String baseUrl, VpnService vpnService, FabricServiceChannel fabricServiceChannel) {
this.baseUrl = trimRight(baseUrl);
this.fabricServiceChannel = fabricServiceChannel == null ? new FabricServiceChannel() : fabricServiceChannel;
OkHttpClient.Builder builder = new OkHttpClient.Builder();
if (vpnService != null) {
builder.socketFactory(new ProtectedSocketFactory(vpnService));
builder.dns(new BackendPinnedDns(baseUrl));
this.networkMode = "protected_socket";
} else {
this.networkMode = "default_network";
}
builder.connectTimeout(3, TimeUnit.SECONDS);
builder.writeTimeout(8, TimeUnit.SECONDS);
builder.readTimeout(8, TimeUnit.SECONDS);
builder.callTimeout(10, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(false);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);
dispatcher.setMaxRequestsPerHost(32);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
}
RapApiClient(String baseUrl, Network network) {
this.baseUrl = trimRight(baseUrl);
this.fabricServiceChannel = new FabricServiceChannel();
OkHttpClient.Builder builder = new OkHttpClient.Builder();
if (network != null) {
builder.socketFactory(network.getSocketFactory());
builder.dns(hostname -> {
InetAddress[] addresses = network.getAllByName(hostname);
if (addresses == null || addresses.length == 0) {
throw new UnknownHostException(hostname);
}
List<InetAddress> out = new ArrayList<>();
Collections.addAll(out, addresses);
return out;
});
this.networkMode = "vpn_network";
} else {
builder.dns(new BackendPinnedDns(baseUrl));
this.networkMode = "default_network";
}
builder.connectTimeout(5, TimeUnit.SECONDS);
builder.writeTimeout(12, TimeUnit.SECONDS);
builder.readTimeout(12, TimeUnit.SECONDS);
builder.callTimeout(15, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(true);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);
dispatcher.setMaxRequestsPerHost(32);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
}
String networkMode() {
return networkMode;
}
private Manager startFabricControlManager(String config) {
String value = config == null ? "" : config.trim();
if (!value.startsWith("{")) {
return null;
}
try {
Fabricvpn.touch();
Manager manager = Fabricvpn.newManager();
manager.start(value);
return manager;
} catch (Exception e) {
String detail = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
throw new IllegalStateException("Не удалось подключиться к ферме через QUIC bootstrap. Последняя ошибка: " + detail, e);
}
}
static final class BackendPinnedDns implements Dns {
private static final String VPN_PUBLIC_HOST = "vpn.cin.su";
private static final String VPN_PUBLIC_IPV4 = "94.141.118.222";
private final String backendHost;
BackendPinnedDns(String baseUrl) {
@@ -180,10 +125,6 @@ final class RapApiClient {
@Override
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
String host = hostname == null ? "" : hostname.trim().toLowerCase();
if (!backendHost.isEmpty() && host.equals(backendHost) && VPN_PUBLIC_HOST.equals(host)) {
return Collections.singletonList(InetAddress.getByName(VPN_PUBLIC_IPV4));
}
return Dns.SYSTEM.lookup(hostname);
}
}
@@ -243,103 +184,26 @@ final class RapApiClient {
return get(path);
}
JSONObject startSession(String resourceId, String userId, String deviceId) throws Exception {
JSONObject body = new JSONObject();
body.put("resource_id", resourceId);
body.put("user_id", userId);
body.put("device_id", deviceId);
return post("/sessions/", body);
JSONObject registerFabricNode(JSONObject payload) throws Exception {
return post("/node-agents/register", payload);
}
JSONObject reportVPNDiagnosticStatus(String clusterId, String deviceId, JSONObject payload) throws Exception {
return post("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/status", payload);
}
JSONObject nextVPNDiagnosticCommand(String clusterId, String deviceId, int timeoutMs) throws Exception {
byte[] payload = getBytes("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/commands?timeout_ms=" + timeoutMs);
if (payload.length == 0) {
return null;
}
return new JSONObject(new String(payload, StandardCharsets.UTF_8));
}
JSONObject vpnPacketStats(String clusterId, String vpnConnectionId) throws Exception {
return get("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/stats");
}
JSONObject resetVPNPacketQueues(String clusterId, String vpnConnectionId) throws Exception {
return post("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/reset", new JSONObject());
}
void sendClientPacket(String clusterId, String vpnConnectionId, byte[] packet, int length) throws Exception {
postBytes(clientPacketPath(clusterId, vpnConnectionId, ""), packet, length);
}
void sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) throws Exception {
if (packets == null || packets.isEmpty()) {
return;
}
List<List<byte[]>> chunks = chunkPacketsForBatch(packets);
if (chunks.isEmpty()) {
return;
}
for (List<byte[]> chunk : chunks) {
postBytes(clientPacketPath(clusterId, vpnConnectionId, "?batch=true"), encodePacketBatch(chunk));
}
}
byte[] receiveClientPacket(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
try {
return getBytes(clientPacketPath(clusterId, vpnConnectionId, "?timeout_ms=" + timeoutMs));
} catch (InterruptedIOException e) {
return new byte[0];
} catch (IOException e) {
if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) {
return new byte[0];
}
throw e;
} catch (IllegalStateException e) {
String message = e.getMessage();
if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) {
return new byte[0];
}
throw e;
}
}
List<byte[]> receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
byte[] payload;
try {
payload = getBytes(clientPacketPath(clusterId, vpnConnectionId, "?batch=true&timeout_ms=" + timeoutMs));
if (payload == null || payload.length == 0) {
return new ArrayList<>();
}
if (!isLikelyPacketBatch(payload)) {
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
}
return decodePacketBatch(payload);
} catch (InterruptedIOException e) {
return new ArrayList<>();
} catch (IOException e) {
if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) {
return new ArrayList<>();
}
throw e;
} catch (IllegalStateException e) {
String message = e.getMessage();
if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) {
return new ArrayList<>();
}
throw e;
}
JSONObject sendFabricNodeHeartbeat(String clusterId, String nodeId, JSONObject payload) throws Exception {
return post("/clusters/" + clusterId + "/nodes/" + nodeId + "/heartbeats", payload);
}
private JSONObject get(String path) throws Exception {
if (fabricControlManager != null) {
return fabricControlJSON("GET", path, null);
}
Request request = new Request.Builder().url(baseUrl + path).get().build();
return read(request);
}
private JSONObject post(String path, JSONObject body) throws Exception {
if (fabricControlManager != null) {
return fabricControlJSON("POST", path, body);
}
Request request = new Request.Builder()
.url(baseUrl + path)
.post(RequestBody.create(body.toString().getBytes(StandardCharsets.UTF_8), JSON))
@@ -347,39 +211,60 @@ final class RapApiClient {
return read(request);
}
private byte[] getBytes(String path) throws Exception {
Request.Builder builder = new Request.Builder().url(baseUrl + path).get();
applyFabricHeadersIfNeeded(builder, path);
Request request = builder.build();
try (Response response = httpClient.newCall(request).execute()) {
if (response.code() == 204) {
return new byte[0];
}
if (!response.isSuccessful()) {
throw new IllegalStateException(describeHttpFailure(response));
}
ResponseBody body = response.body();
return body == null ? new byte[0] : body.bytes();
private JSONObject fabricControlJSON(String method, String path, JSONObject body) throws Exception {
byte[] payload = fabricControlBodyBytes(method, path, body);
if (payload.length == 0) {
return new JSONObject();
}
return new JSONObject(new String(payload, StandardCharsets.UTF_8));
}
private void postBytes(String path, byte[] packet, int length) throws Exception {
byte[] bodyBytes = new byte[length];
System.arraycopy(packet, 0, bodyBytes, 0, length);
postBytes(path, bodyBytes);
private byte[] fabricControlBodyBytes(String method, String path, JSONObject body) throws Exception {
JSONObject request = new JSONObject();
request.put("method", method);
request.put("path", path);
if (body != null) {
request.put("body", body);
}
String raw;
try {
raw = fabricControlManager.controlRequest(request.toString());
} catch (Exception e) {
throw new IllegalStateException("Ферма сейчас не смогла выполнить контрольный запрос. Попробуйте еще раз.", e);
}
JSONObject wrapper = raw == null || raw.trim().isEmpty() ? new JSONObject() : new JSONObject(raw);
int statusCode = wrapper.optInt("status_code", 200);
Object bodyValue = wrapper.opt("body");
String bodyText = jsonBodyText(bodyValue);
if (statusCode < 200 || statusCode >= 300) {
if (statusCode == 401 && bodyText.contains("auth.invalid_credentials")) {
throw new IllegalStateException("Неверный логин или пароль.");
}
if (statusCode == 401 && bodyText.contains("auth.invalid_refresh_token")) {
throw new IllegalStateException("Сессия устройства истекла. Введите пароль один раз.");
}
throw new IllegalStateException("fabric control HTTP " + statusCode + ": " + compactText(bodyText, 240));
}
return bodyText.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private void postBytes(String path, byte[] bodyBytes) throws Exception {
Request.Builder builder = new Request.Builder()
.url(baseUrl + path)
.post(RequestBody.create(bodyBytes, OCTET_STREAM));
applyFabricHeadersIfNeeded(builder, path);
Request request = builder.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IllegalStateException(describeHttpFailure(response));
}
private String jsonBodyText(Object bodyValue) {
if (bodyValue == null || JSONObject.NULL.equals(bodyValue)) {
return "";
}
if (bodyValue instanceof JSONObject || bodyValue instanceof JSONArray) {
return bodyValue.toString();
}
String text = String.valueOf(bodyValue);
return text == null ? "" : text;
}
private String compactText(String text, int limit) {
String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' ').trim();
if (value.length() > limit) {
return value.substring(0, limit);
}
return value;
}
private String describeHttpFailure(Response response) {
@@ -401,45 +286,6 @@ final class RapApiClient {
return message.toString();
}
private String clientPacketPath(String clusterId, String vpnConnectionId, String suffix) throws IOException {
String path = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, false);
if (path.isEmpty()) {
throw new IOException("fabric service channel lease required for VPN packet dataplane");
}
return path + (suffix == null ? "" : suffix);
}
private void applyFabricHeadersIfNeeded(Request.Builder builder, String path) {
if (path != null && path.contains("/fabric/service-channels/")) {
fabricServiceChannel.applyHeaders(builder);
}
}
private byte[] encodePacketBatch(List<byte[]> packets) {
int total = 0;
for (byte[] packet : packets) {
if (packet != null && packet.length > 0) {
total += 4 + packet.length;
}
}
byte[] out = new byte[total];
int offset = 0;
for (byte[] packet : packets) {
if (packet == null || packet.length == 0) {
continue;
}
int length = packet.length;
out[offset] = (byte) ((length >> 24) & 0xff);
out[offset + 1] = (byte) ((length >> 16) & 0xff);
out[offset + 2] = (byte) ((length >> 8) & 0xff);
out[offset + 3] = (byte) (length & 0xff);
offset += 4;
System.arraycopy(packet, 0, out, offset, length);
offset += length;
}
return out;
}
private JSONObject read(Request request) throws Exception {
try (Response response = httpClient.newCall(request).execute()) {
ResponseBody body = response.body();
@@ -457,93 +303,6 @@ final class RapApiClient {
}
}
private List<byte[]> decodePacketBatch(byte[] payload) {
List<byte[]> packets = new ArrayList<>();
int offset = 0;
while (payload != null && offset + 4 <= payload.length) {
int length = ((payload[offset] & 0xff) << 24)
| ((payload[offset + 1] & 0xff) << 16)
| ((payload[offset + 2] & 0xff) << 8)
| (payload[offset + 3] & 0xff);
offset += 4;
if (length <= 0 || offset + length > payload.length) {
break;
}
byte[] packet = new byte[length];
System.arraycopy(payload, offset, packet, 0, length);
packets.add(packet);
offset += length;
}
return packets;
}
private List<List<byte[]>> chunkPacketsForBatch(List<byte[]> packets) {
List<List<byte[]>> chunks = new ArrayList<>();
List<byte[]> current = new ArrayList<>();
int currentBytes = 0;
boolean hasData = false;
for (byte[] packet : packets) {
if (packet == null || packet.length == 0) {
continue;
}
if (packet.length > MAX_SINGLE_PACKET_BYTES) {
continue;
}
hasData = true;
int projected = currentBytes + MAX_BATCH_HEADER_BYTES + packet.length;
if (!current.isEmpty() && (current.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES)) {
chunks.add(current);
current = new ArrayList<>();
currentBytes = 0;
}
current.add(packet);
currentBytes = projected;
}
if (!hasData) {
return chunks;
}
if (!current.isEmpty()) {
chunks.add(current);
}
return chunks;
}
private boolean isLikelyPacketBatch(byte[] payload) {
if (payload == null || payload.length < MAX_BATCH_HEADER_BYTES) {
return false;
}
int offset = 0;
int consumed = 0;
while (offset + MAX_BATCH_HEADER_BYTES <= payload.length) {
int length = ((payload[offset] & 0xff) << 24)
| ((payload[offset + 1] & 0xff) << 16)
| ((payload[offset + 2] & 0xff) << 8)
| (payload[offset + 3] & 0xff);
offset += MAX_BATCH_HEADER_BYTES;
if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES) {
return false;
}
if (offset + length > payload.length) {
return false;
}
offset += length;
consumed++;
if (consumed > MAX_PACKET_BATCH_PACKETS) {
return false;
}
}
return offset == payload.length && consumed > 0;
}
private List<byte[]> receiveSinglePacketAsBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
byte[] payload = receiveClientPacket(clusterId, vpnConnectionId, timeoutMs);
if (payload == null || payload.length == 0) {
return new ArrayList<>();
}
return new ArrayList<>(Collections.singletonList(payload));
}
private AuthContext parseAuthContext(JSONObject response) throws Exception {
JSONObject user = response.getJSONObject("user");
String userId = user.optString("id", "");
@@ -570,65 +329,6 @@ final class RapApiClient {
return value;
}
static final class ProtectedSocketFactory extends SocketFactory {
private final SocketFactory delegate = SocketFactory.getDefault();
private final VpnService vpnService;
ProtectedSocketFactory(VpnService vpnService) {
this.vpnService = vpnService;
}
@Override
public Socket createSocket() throws IOException {
Socket socket = delegate.createSocket();
socket.bind(null);
return protect(socket);
}
@Override
public Socket createSocket(String host, int port) throws IOException {
Socket socket = createSocket();
socket.connect(new InetSocketAddress(host, port));
return socket;
}
@Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
Socket socket = delegate.createSocket();
socket.bind(new InetSocketAddress(localHost, localPort));
protect(socket);
socket.connect(new InetSocketAddress(host, port));
return socket;
}
@Override
public Socket createSocket(InetAddress host, int port) throws IOException {
Socket socket = createSocket();
socket.connect(new InetSocketAddress(host, port));
return socket;
}
@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
Socket socket = delegate.createSocket();
socket.bind(new InetSocketAddress(localAddress, localPort));
protect(socket);
socket.connect(new InetSocketAddress(address, port));
return socket;
}
private Socket protect(Socket socket) throws IOException {
if (!vpnService.protect(socket)) {
try {
socket.close();
} catch (IOException ignored) {
}
throw new IOException("protect control-plane socket failed");
}
return socket;
}
}
static final class AuthContext {
final String userId;
final String deviceId;
@@ -10,7 +10,6 @@ import android.os.Build;
public final class RapAutostartReceiver extends BroadcastReceiver {
private static final String PREFS = "rap-vpn";
private static final String PREF_PROFILE_JSON = "profile_json";
private static final String PREF_BACKEND_URL = "backend_url";
private static final String PREF_CLUSTER_ID = "cluster_id";
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
private static final String PREF_MANUAL_STOPPED = "manual_stopped";
@@ -25,21 +24,18 @@ public final class RapAutostartReceiver extends BroadcastReceiver {
&& !Intent.ACTION_BOOT_COMPLETED.equals(action)) {
return;
}
RapDiagnosticService.start(context);
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
if (prefs.getBoolean(PREF_MANUAL_STOPPED, false)) {
return;
}
if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) {
// Diagnostic service owns post-upgrade VPN restart. Starting both services from
// MY_PACKAGE_REPLACED can race foreground-service startup and leave diagnostics stale.
// After package replacement we wait for an explicit user action or runtime resume.
return;
}
String profile = prefs.getString(PREF_PROFILE_JSON, "");
String backendUrl = prefs.getString(PREF_BACKEND_URL, "");
String clusterId = prefs.getString(PREF_CLUSTER_ID, "");
String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
if (profile.isEmpty() || backendUrl.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
if (profile.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
return;
}
if (VpnService.prepare(context) != null) {
@@ -47,7 +43,6 @@ public final class RapAutostartReceiver extends BroadcastReceiver {
}
Intent service = new Intent(context, RapVpnService.class);
service.putExtra("profile_json", profile);
service.putExtra("backend_url", backendUrl);
service.putExtra("cluster_id", clusterId);
service.putExtra("vpn_connection_id", vpnConnectionId);
if (Build.VERSION.SDK_INT >= 26) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -54,7 +54,7 @@ public class TestTrafficActivity extends Activity {
setContentView(layout);
String url = getIntent().getStringExtra(EXTRA_URL);
if (url == null || url.isEmpty()) {
url = "http://192.168.200.61:18080/";
url = "http://example.com/";
}
target = url;
assetErrorCount = 0;
@@ -11,7 +11,7 @@ import java.nio.charset.StandardCharsets;
public class TestVpnActivity extends Activity {
public static final String EXTRA_PROFILE_JSON = "profile_json";
public static final String EXTRA_PROFILE_BASE64 = "profile_base64";
public static final String EXTRA_BACKEND_URL = "backend_url";
public static final String EXTRA_FABRIC_BOOTSTRAP_CONFIG = "fabric_bootstrap_config";
public static final String EXTRA_CLUSTER_ID = "cluster_id";
public static final String EXTRA_VPN_CONNECTION_ID = "vpn_connection_id";
private static final int VPN_PREPARE_REQUEST = 77;
@@ -44,7 +44,10 @@ public class TestVpnActivity extends Activity {
private Intent buildServiceIntent(Intent source) {
Intent intent = new Intent(this, RapVpnService.class);
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson(source));
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, source.getStringExtra(EXTRA_BACKEND_URL));
String fabricBootstrapConfig = source.getStringExtra(EXTRA_FABRIC_BOOTSTRAP_CONFIG);
if (fabricBootstrapConfig != null && !fabricBootstrapConfig.isEmpty()) {
intent.putExtra(RapVpnService.EXTRA_FABRIC_BOOTSTRAP_CONFIG, fabricBootstrapConfig);
}
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, source.getStringExtra(EXTRA_CLUSTER_ID));
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, source.getStringExtra(EXTRA_VPN_CONNECTION_ID));
return intent;
@@ -1,393 +0,0 @@
package su.cin.rapvpn;
import android.net.VpnService;
import android.util.Log;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import okhttp3.ConnectionPool;
import okhttp3.Dispatcher;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okio.ByteString;
final class VpnPacketWebSocketRelay {
private static final String TAG = "RapVpnWebSocketRelay";
private static final int MAX_PACKET_BATCH_PACKETS = 512;
private static final int MAX_PACKET_BATCH_BYTES = 1024 * 1024;
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
private static final long CONNECTING_STALE_MS = 8000;
private static final long OPEN_WAIT_MS = 3500;
private static final int PRIORITY_GRACE_MS = 2;
private final String baseUrl;
private final VpnService vpnService;
private final OkHttpClient httpClient;
private final FabricServiceChannel fabricServiceChannel;
private final BlockingQueue<List<byte[]>> priorityIncoming = new ArrayBlockingQueue<>(512);
private final BlockingQueue<List<byte[]>> incoming = new ArrayBlockingQueue<>(2048);
private final Object lock = new Object();
private WebSocket webSocket;
private String connectedClusterId = "";
private String connectedVpnConnectionId = "";
private volatile boolean open;
private volatile boolean connecting;
private volatile long connectingSinceMs;
private volatile long reconnectAfterMs;
private volatile String lastError = "";
VpnPacketWebSocketRelay(String baseUrl, VpnService vpnService) {
this(baseUrl, vpnService, new FabricServiceChannel());
}
VpnPacketWebSocketRelay(String baseUrl, VpnService vpnService, FabricServiceChannel fabricServiceChannel) {
this.baseUrl = trimRight(baseUrl);
this.vpnService = vpnService;
this.fabricServiceChannel = fabricServiceChannel == null ? new FabricServiceChannel() : fabricServiceChannel;
OkHttpClient.Builder builder = new OkHttpClient.Builder();
if (vpnService != null) {
builder.socketFactory(new RapApiClient.ProtectedSocketFactory(vpnService));
}
builder.dns(new RapApiClient.BackendPinnedDns(baseUrl));
builder.connectTimeout(5, TimeUnit.SECONDS);
builder.writeTimeout(10, TimeUnit.SECONDS);
builder.readTimeout(0, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(true);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(16);
dispatcher.setMaxRequestsPerHost(8);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(8, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
}
String baseUrl() {
return baseUrl;
}
boolean isOpen() {
return open;
}
String lastError() {
return lastError == null ? "" : lastError;
}
void connect(String clusterId, String vpnConnectionId) {
if (clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
synchronized (lock) {
if (open && clusterId.equals(connectedClusterId) && vpnConnectionId.equals(connectedVpnConnectionId)) {
return;
}
if (connecting && clusterId.equals(connectedClusterId) && vpnConnectionId.equals(connectedVpnConnectionId)) {
if (now - connectingSinceMs < CONNECTING_STALE_MS) {
return;
}
lastError = "stale websocket connect";
closeLocked();
}
if (now < reconnectAfterMs) {
return;
}
closeLocked();
String wsUrl = webSocketUrl(clusterId, vpnConnectionId);
if (wsUrl.isEmpty()) {
lastError = "invalid websocket url";
reconnectAfterMs = now + 5000;
return;
}
connectedClusterId = clusterId;
connectedVpnConnectionId = vpnConnectionId;
connecting = true;
connectingSinceMs = now;
Request.Builder requestBuilder = new Request.Builder().url(wsUrl);
this.fabricServiceChannel.applyHeaders(requestBuilder);
Request request = requestBuilder.build();
lastError = "connecting";
webSocket = httpClient.newWebSocket(request, new Listener());
}
}
boolean sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) {
packets = cleanPacketBatch(packets);
if (packets.isEmpty()) {
return true;
}
connect(clusterId, vpnConnectionId);
if (!awaitOpen(OPEN_WAIT_MS)) {
return false;
}
WebSocket socket = webSocket;
if (socket == null) {
lastError = "websocket missing after open";
return false;
}
byte[] payload = encodePacketBatch(packets);
if (payload.length == 0) {
return true;
}
boolean queued = socket.send(ByteString.of(payload));
if (!queued) {
lastError = "websocket send queue rejected batch";
synchronized (lock) {
if (socket == webSocket) {
reconnectAfterMs = 0;
closeLocked();
}
}
}
return queued;
}
List<byte[]> receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws InterruptedException {
connect(clusterId, vpnConnectionId);
awaitOpen(Math.min(OPEN_WAIT_MS, Math.max(1, timeoutMs)));
int waitMs = Math.max(1, timeoutMs);
List<byte[]> packets = priorityIncoming.poll();
if (packets != null) {
return packets;
}
packets = priorityIncoming.poll(Math.min(PRIORITY_GRACE_MS, waitMs), TimeUnit.MILLISECONDS);
if (packets != null) {
return packets;
}
packets = incoming.poll();
if (packets != null) {
return packets;
}
packets = priorityIncoming.poll();
if (packets != null) {
return packets;
}
packets = incoming.poll(Math.max(1, waitMs - PRIORITY_GRACE_MS), TimeUnit.MILLISECONDS);
return packets == null ? new ArrayList<>() : packets;
}
void close() {
synchronized (lock) {
closeLocked();
}
}
private void closeLocked() {
open = false;
connecting = false;
connectingSinceMs = 0;
priorityIncoming.clear();
incoming.clear();
if (webSocket != null) {
try {
webSocket.close(1000, "relay switch");
} catch (Exception ignored) {
}
}
webSocket = null;
}
private boolean awaitOpen(long timeoutMs) {
long deadline = System.currentTimeMillis() + Math.max(1, timeoutMs);
synchronized (lock) {
while (!open && connecting) {
long waitMs = deadline - System.currentTimeMillis();
if (waitMs <= 0) {
break;
}
try {
lock.wait(waitMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
lastError = "interrupted waiting for websocket open";
return false;
}
}
if (!open && "connecting".equals(lastError)) {
lastError = "connecting_timeout";
}
return open;
}
}
private String webSocketUrl(String clusterId, String vpnConnectionId) {
try {
URI uri = URI.create(baseUrl);
String scheme = "https".equalsIgnoreCase(uri.getScheme()) ? "wss" : "ws";
String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "" : trimRight(uri.getRawPath());
String fabricPath = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, true);
if (!fabricPath.isEmpty()) {
path += fabricPath;
} else {
path += "/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets/ws";
}
URI ws = new URI(scheme, uri.getRawUserInfo(), uri.getHost(), uri.getPort(), path, null, null);
return ws.toString();
} catch (Exception e) {
lastError = e.getClass().getSimpleName() + ": " + e.getMessage();
return "";
}
}
private final class Listener extends WebSocketListener {
@Override
public void onOpen(WebSocket webSocket, Response response) {
synchronized (lock) {
open = true;
connecting = false;
reconnectAfterMs = 0;
lastError = "";
lock.notifyAll();
}
Log.i(TAG, "vpn packet websocket opened " + baseUrl);
}
@Override
public void onMessage(WebSocket webSocket, ByteString bytes) {
List<byte[]> packets = decodePacketBatch(bytes.toByteArray());
if (packets.isEmpty()) {
return;
}
offerIncomingPacketBatch(packets);
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
synchronized (lock) {
open = false;
connecting = false;
reconnectAfterMs = System.currentTimeMillis() + 1000;
lastError = "closed " + code + " " + reason;
lock.notifyAll();
}
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
String responseStatus = "";
if (response != null) {
responseStatus = " status=" + response.code();
}
synchronized (lock) {
open = false;
connecting = false;
reconnectAfterMs = System.currentTimeMillis() + 3000;
lastError = (t == null ? "websocket failure" : t.getClass().getSimpleName() + ": " + t.getMessage()) + responseStatus;
lock.notifyAll();
}
Log.w(TAG, "vpn packet websocket failed " + baseUrl + ": " + lastError);
}
}
private static List<byte[]> cleanPacketBatch(List<byte[]> packets) {
List<byte[]> cleaned = new ArrayList<>();
int bytes = 0;
if (packets == null) {
return cleaned;
}
for (byte[] packet : packets) {
if (packet == null || packet.length <= 0 || packet.length > MAX_SINGLE_PACKET_BYTES) {
continue;
}
int projected = bytes + 4 + packet.length;
if (cleaned.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES) {
break;
}
cleaned.add(packet);
bytes = projected;
}
return cleaned;
}
private static byte[] encodePacketBatch(List<byte[]> packets) {
packets = cleanPacketBatch(packets);
int total = 0;
for (byte[] packet : packets) {
total += 4 + packet.length;
}
byte[] out = new byte[total];
int offset = 0;
for (byte[] packet : packets) {
int length = packet.length;
out[offset] = (byte) ((length >> 24) & 0xff);
out[offset + 1] = (byte) ((length >> 16) & 0xff);
out[offset + 2] = (byte) ((length >> 8) & 0xff);
out[offset + 3] = (byte) (length & 0xff);
offset += 4;
System.arraycopy(packet, 0, out, offset, length);
offset += length;
}
return out;
}
private static List<byte[]> decodePacketBatch(byte[] payload) {
List<byte[]> packets = new ArrayList<>();
int offset = 0;
while (payload != null && offset + 4 <= payload.length && packets.size() < MAX_PACKET_BATCH_PACKETS) {
int length = ((payload[offset] & 0xff) << 24)
| ((payload[offset + 1] & 0xff) << 16)
| ((payload[offset + 2] & 0xff) << 8)
| (payload[offset + 3] & 0xff);
offset += 4;
if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES || offset + length > payload.length) {
break;
}
byte[] packet = new byte[length];
System.arraycopy(payload, offset, packet, 0, length);
packets.add(packet);
offset += length;
}
return packets;
}
private void offerIncomingPacketBatch(List<byte[]> packets) {
BlockingQueue<List<byte[]>> target = containsTCPControlPacket(packets) ? priorityIncoming : incoming;
if (!target.offer(packets)) {
target.poll();
target.offer(packets);
}
}
private static boolean containsTCPControlPacket(List<byte[]> packets) {
if (packets == null) {
return false;
}
for (byte[] packet : packets) {
if (isTCPControlPacket(packet)) {
return true;
}
}
return false;
}
private static boolean isTCPControlPacket(byte[] packet) {
if (packet == null || packet.length < 20 || (packet[0] >> 4) != 4) {
return false;
}
int ihl = (packet[0] & 0x0f) * 4;
if (ihl < 20 || packet.length < ihl + 20 || packet[9] != 6) {
return false;
}
int flags = packet[ihl + 13] & 0xff;
return (flags & 0x17) != 0;
}
private static String trimRight(String value) {
if (value == null) {
return "";
}
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
}