This commit is contained in:
2026-05-14 23:30:34 +03:00
parent 26cb65e936
commit 04c46042d9
239 changed files with 34102 additions and 438 deletions
+3 -3
View File
@@ -22,7 +22,7 @@ android {
return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"")
}
def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "http://vpn.cin.su:19191/api/v1"
def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "https://vpn.cin.su/api/v1"
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,8 +30,8 @@ android {
applicationId "su.cin.rapvpn"
minSdk 26
targetSdk 35
versionCode 159
versionName "0.2.159"
versionCode 182
versionName "0.2.182"
buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\""
buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\""
buildConfigField "String", "DEFAULT_ORGANIZATION_ID", "\"${normalizeGradleString(defaultOrganizationId)}\""
@@ -25,6 +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 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";
@@ -659,6 +660,16 @@ public class MainActivity extends Activity {
if (candidate.isEmpty()) {
return DEFAULT_BACKEND_URL;
}
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)
|| "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;
}
return candidate;
}
@@ -356,7 +356,7 @@ final class RapApiClient {
return new byte[0];
}
if (!response.isSuccessful()) {
throw new IllegalStateException("HTTP " + response.code());
throw new IllegalStateException(describeHttpFailure(response));
}
ResponseBody body = response.body();
return body == null ? new byte[0] : body.bytes();
@@ -377,15 +377,34 @@ final class RapApiClient {
Request request = builder.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IllegalStateException("HTTP " + response.code());
throw new IllegalStateException(describeHttpFailure(response));
}
}
}
private String clientPacketPath(String clusterId, String vpnConnectionId, String suffix) {
private String describeHttpFailure(Response response) {
StringBuilder message = new StringBuilder("HTTP ").append(response.code());
ResponseBody body = response.body();
if (body != null) {
try {
String text = body.string();
if (text != null && !text.trim().isEmpty()) {
text = text.replace('\n', ' ').replace('\r', ' ').trim();
if (text.length() > 240) {
text = text.substring(0, 240);
}
message.append(": ").append(text);
}
} catch (Exception ignored) {
}
}
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()) {
path = "/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets";
throw new IOException("fabric service channel lease required for VPN packet dataplane");
}
return path + (suffix == null ? "" : suffix);
}
@@ -18,6 +18,7 @@ import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.provider.Settings;
import android.widget.Toast;
import org.json.JSONObject;
@@ -38,6 +39,7 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -51,6 +53,7 @@ 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;
@@ -60,14 +63,22 @@ public class RapDiagnosticService extends Service {
private static final String PREF_REFRESH_TOKEN = "refresh_token";
private static final String PREF_USER_ID = "user_id";
private static final String PREF_DEVICE_ID = "device_id";
private static final String PREF_DIAGNOSTIC_DEVICE_ID = "diagnostic_device_id";
private static final String PREF_PROFILE_JSON = "profile_json";
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
private static final long COMMAND_STALE_MS = 45000;
private static final long COMMAND_ORPHAN_MS = 60000;
private static final long POLL_FORCE_MS = 45000;
private volatile boolean running;
private Thread worker;
private Thread supervisor;
private String serviceState = "";
private String lastCommandType = "";
private String lastCommandResult = "";
private String lastCommandPollResult = "";
private String lastReceivedCommandID = "";
private String lastReceivedCommandType = "";
private long lastReceivedCommandAt = 0;
private long lastCommandAt = 0;
private long lastHeartbeatAt = 0;
private long lastCommandPollAt = 0;
@@ -84,14 +95,14 @@ public class RapDiagnosticService extends Service {
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
running = false;
if (worker != null) {
worker.interrupt();
}
if (supervisor != null) {
supervisor.interrupt();
}
stopForeground(true);
stopSelfResult(startId);
if (worker != null) {
worker.interrupt();
}
if (supervisor != null) {
supervisor.interrupt();
}
stopForeground(true);
stopSelfResult(startId);
return START_NOT_STICKY;
}
if (intent != null && ACTION_RESTART.equals(intent.getAction())) {
@@ -129,14 +140,24 @@ public class RapDiagnosticService extends Service {
}
}
static void restart(android.content.Context context) {
Intent intent = new Intent(context, RapDiagnosticService.class);
intent.setAction(ACTION_RESTART);
if (Build.VERSION.SDK_INT >= 26) {
context.startForegroundService(intent);
} else {
context.startService(intent);
}
}
private void startWorker() {
if (worker != null && worker.isAlive()) {
long age = System.currentTimeMillis() - lastWorkerProgressAt;
if (age > 45000) {
restartWorker();
} else {
startSupervisor();
return;
startSupervisor();
return;
}
}
running = true;
@@ -211,8 +232,12 @@ public class RapDiagnosticService extends Service {
if (clusterId == null || clusterId.trim().isEmpty()) {
clusterId = DEFAULT_CLUSTER_ID;
}
String deviceId = prefs.getString(PREF_DEVICE_ID, "");
String deviceId = diagnosticDeviceId(prefs);
if (backendUrl.isEmpty() || clusterId.isEmpty() || deviceId.isEmpty()) {
serviceState = "waiting for config backend=" + !backendUrl.isEmpty()
+ " cluster=" + !clusterId.isEmpty()
+ " device=" + !deviceId.isEmpty();
writeLocalDiagnosticHeartbeat();
Thread.sleep(3000);
continue;
}
@@ -259,12 +284,33 @@ public class RapDiagnosticService extends Service {
commandPollStartedAt = 0;
serviceState = "diagnostic poll watchdog released stale poll";
}
if (commandPollInProgress.get() && commandPollStartedAt == 0 && lastCommandPollAt > 0 && now - lastCommandPollAt > 45000) {
commandPollInProgress.set(false);
serviceState = "diagnostic poll watchdog released orphan poll flag age_ms=" + (now - lastCommandPollAt);
}
if (!commandPollInProgress.get() && !commandInProgress.get() && lastCommandPollAt > 0 && now - lastCommandPollAt > POLL_FORCE_MS) {
serviceState = "diagnostic poll watchdog forcing command poll age_ms=" + (now - lastCommandPollAt);
lastWorkerProgressAt = now;
}
long commandAge = commandInProgress.get() && commandStartedAt > 0 ? now - commandStartedAt : 0;
if (commandAge > 120000) {
if (commandAge > COMMAND_STALE_MS) {
commandInProgress.set(false);
commandStartedAt = 0;
lastCommandType = lastReceivedCommandType.isEmpty() ? "command_timeout" : lastReceivedCommandType;
lastCommandResult = "command watchdog timed out age_ms=" + commandAge
+ " id=" + lastReceivedCommandID
+ " type=" + lastReceivedCommandType;
lastCommandAt = now;
serviceState = "diagnostic command watchdog released stale command age_ms=" + commandAge;
}
if (commandInProgress.get() && commandStartedAt == 0 && lastCommandAt > 0 && now - lastCommandAt > COMMAND_ORPHAN_MS) {
commandInProgress.set(false);
serviceState = "diagnostic command watchdog released orphan command flag age_ms=" + (now - lastCommandAt);
}
if (commandInProgress.get() && commandStartedAt == 0 && lastCommandPollAt > 0 && now - lastCommandPollAt > COMMAND_ORPHAN_MS) {
commandInProgress.set(false);
serviceState = "diagnostic command watchdog released poll-stalled command flag age_ms=" + (now - lastCommandPollAt);
}
}
private void startHeartbeatWorker(String backendUrl, String clusterId, String deviceId, SharedPreferences prefs) {
@@ -308,9 +354,14 @@ public class RapDiagnosticService extends Service {
JSONObject commandEnvelope = nextCommandWithFallback(backendUrl, clusterId, deviceId);
lastWorkerProgressAt = System.currentTimeMillis();
if (commandEnvelope != null) {
lastCommandPollResult = describeCommandEnvelope(commandEnvelope);
rememberReceivedCommand(commandEnvelope);
startCommandWorker(backendUrl, clusterId, deviceId, commandEnvelope);
} else {
lastCommandPollResult = "no_content";
}
} catch (Exception e) {
lastCommandPollResult = "error: " + e.getClass().getSimpleName();
serviceState = "command poll error: " + e.getMessage();
lastWorkerProgressAt = System.currentTimeMillis();
} finally {
@@ -323,11 +374,15 @@ public class RapDiagnosticService extends Service {
private void startCommandWorker(String backendUrl, String clusterId, String deviceId, JSONObject commandEnvelope) {
if (!commandInProgress.compareAndSet(false, true)) {
lastCommandPollResult = "worker_busy " + describeCommandEnvelope(commandEnvelope);
return;
}
Thread commandWorker = new Thread(() -> {
try {
commandStartedAt = System.currentTimeMillis();
lastCommandType = lastReceivedCommandType.isEmpty() ? "command_running" : lastReceivedCommandType;
lastCommandResult = "running id=" + lastReceivedCommandID + " type=" + lastReceivedCommandType;
lastCommandAt = commandStartedAt;
lastWorkerProgressAt = System.currentTimeMillis();
RapApiClient commandClient = controlClient(backendUrl);
controlNetworkMode = commandClient.networkMode();
@@ -363,6 +418,9 @@ public class RapDiagnosticService extends Service {
params = payload;
}
String result;
lastCommandType = type;
lastCommandResult = "running id=" + (command == null ? "" : command.optString("id", "")) + " type=" + type;
lastCommandAt = System.currentTimeMillis();
if ("start_vpn".equals(type)) {
result = startVPNFromSavedProfile();
} else if ("stop_vpn".equals(type)) {
@@ -411,6 +469,8 @@ public class RapDiagnosticService extends Service {
result = "remote_assist_end accepted";
} else if ("full_vpn_test".equals(type)) {
result = runFullVPNTest(client, clusterId, params);
} else if ("install_profile".equals(type) || "apply_profile".equals(type)) {
result = installProfileFromCommand(params);
} else if ("refresh_profile".equals(type)) {
result = refreshProfile();
} else {
@@ -418,9 +478,15 @@ public class RapDiagnosticService extends Service {
}
if (isRecoverableVPNProbe(type) && looksLikeVPNStall(result)) {
String firstResult = result;
String recovery = controlledRestartVPNRuntime(client, clusterId);
Thread.sleep(4000);
result = firstResult + " | recovery=" + recovery + " | retry=" + runVPNProbeCommand(type, params);
Thread.sleep(1500);
String fastRetry = runVPNProbeCommand(type, params);
if (!looksLikeVPNStall(fastRetry)) {
result = firstResult + " | fast_retry=" + fastRetry;
} else {
String recovery = controlledRestartVPNRuntime(client, clusterId);
Thread.sleep(4000);
result = firstResult + " | fast_retry=" + fastRetry + " | recovery=" + recovery + " | retry=" + runVPNProbeCommand(type, params);
}
}
lastCommandType = type;
lastCommandResult = result;
@@ -623,7 +689,9 @@ public class RapDiagnosticService extends Service {
return "restart failed: queue reset failed: " + e.getMessage();
}
Thread.sleep(300);
return startVPNFromSavedProfile();
String refresh = refreshProfile();
String start = startVPNFromSavedProfile();
return start + " profile_refresh=" + refresh;
} catch (Exception e) {
return "restart failed: " + e.getClass().getSimpleName() + ": " + e.getMessage();
}
@@ -642,6 +710,10 @@ public class RapDiagnosticService extends Service {
}
serviceState = "upgrade restart " + lastVersion + " -> " + APP_VERSION;
try {
String refresh = refreshProfile();
if (refresh.startsWith("refresh_profile failed")) {
lastCommandResult = "vpn runtime profile refresh before upgrade restart failed: " + refresh;
}
try {
client.resetVPNPacketQueues(clusterId, connectionId);
} catch (Exception ignored) {
@@ -650,7 +722,7 @@ public class RapDiagnosticService extends Service {
startVPNFromSavedProfile();
prefs.edit().putString("vpn_runtime_app_version", APP_VERSION).apply();
lastCommandType = "auto_upgrade_restart";
lastCommandResult = "vpn runtime reinitialized after app upgrade " + lastVersion + " -> " + APP_VERSION;
lastCommandResult = "vpn runtime reinitialized after app upgrade " + lastVersion + " -> " + APP_VERSION + " profile_refresh=" + refresh;
lastCommandAt = System.currentTimeMillis();
} catch (Exception e) {
lastCommandType = "auto_upgrade_restart";
@@ -664,44 +736,135 @@ public class RapDiagnosticService extends Service {
try {
String refreshToken = new SecureTokenStore(this).get(PREF_REFRESH_TOKEN);
if (refreshToken.isEmpty()) {
return "refresh_profile skipped: refresh token missing";
String savedUserId = prefs.getString(PREF_USER_ID, "");
if (savedUserId == null || savedUserId.trim().isEmpty()) {
return "refresh_profile skipped: refresh token and saved user missing";
}
return refreshProfileForUser(prefs, savedUserId.trim(), null);
}
RapApiClient client = new RapApiClient(normalizeBackendUrl(prefs.getString("backend_url", "")), this, true);
RapApiClient.AuthContext auth = client.refresh(refreshToken);
String organizationId = prefs.getString("organization_id", DEFAULT_ORGANIZATION_ID);
String clusterId = prefs.getString("cluster_id", DEFAULT_CLUSTER_ID);
if (clusterId == null || clusterId.trim().isEmpty()) {
clusterId = DEFAULT_CLUSTER_ID;
}
if (organizationId == null || organizationId.trim().isEmpty()) {
organizationId = DEFAULT_ORGANIZATION_ID;
}
String exitNodeId = prefs.getString(PREF_SELECTED_EXIT_NODE_ID, "");
String profileJson = client.vpnClientProfile(clusterId, organizationId, auth.userId, exitNodeId);
JSONObject root = new JSONObject(profileJson);
JSONObject profile = root.getJSONObject("vpn_client_profile");
String connectionId = profile.getJSONArray("connections").getJSONObject(0).getString("id");
prefs.edit()
.putString(PREF_USER_ID, auth.userId)
.putString(PREF_DEVICE_ID, auth.deviceId)
.putString(PREF_PROFILE_JSON, profileJson)
.putString(PREF_VPN_CONNECTION_ID, connectionId)
.apply();
new SecureTokenStore(this).put(PREF_REFRESH_TOKEN, auth.refreshToken);
return "refresh_profile ok " + connectionId;
return refreshProfileForUser(prefs, auth.userId, auth.deviceId);
} catch (Exception e) {
return "refresh_profile failed: " + e.getMessage();
}
}
private String refreshProfileForUser(SharedPreferences prefs, String userId, String trustedDeviceId) throws Exception {
String backendUrl = normalizeBackendUrl(prefs.getString("backend_url", DEFAULT_BACKEND_URL));
String organizationId = prefs.getString("organization_id", DEFAULT_ORGANIZATION_ID);
String clusterId = prefs.getString("cluster_id", DEFAULT_CLUSTER_ID);
if (clusterId == null || clusterId.trim().isEmpty()) {
clusterId = DEFAULT_CLUSTER_ID;
}
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);
JSONObject root = new JSONObject(profileJson);
JSONObject profile = root.getJSONObject("vpn_client_profile");
String connectionId = profile.getJSONArray("connections").getJSONObject(0).getString("id");
SharedPreferences.Editor editor = prefs.edit()
.putString("backend_url", backendUrl)
.putString("cluster_id", clusterId)
.putString("organization_id", organizationId)
.putString(PREF_USER_ID, userId)
.putString(PREF_PROFILE_JSON, profileJson)
.putString(PREF_VPN_CONNECTION_ID, connectionId);
if (trustedDeviceId != null && !trustedDeviceId.trim().isEmpty()) {
editor.putString(PREF_DEVICE_ID, trustedDeviceId.trim());
}
editor.apply();
return "refresh_profile ok " + connectionId;
}
private String installProfileFromCommand(JSONObject params) {
try {
String backendUrl = normalizeBackendUrl(params.optString("backend_url", DEFAULT_BACKEND_URL));
String clusterId = params.optString("cluster_id", DEFAULT_CLUSTER_ID).trim();
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()) {
JSONObject profile = params.optJSONObject("vpn_client_profile");
if (profile == null) {
profile = params.optJSONObject("profile");
}
if (profile == null) {
return "install_profile skipped: profile missing";
}
root = new JSONObject();
root.put("vpn_client_profile", profile);
profileJson = root.toString();
} else {
root = new JSONObject(profileJson);
if (!root.has("vpn_client_profile")) {
JSONObject wrapped = new JSONObject();
wrapped.put("vpn_client_profile", root);
root = wrapped;
profileJson = wrapped.toString();
}
}
JSONObject profile = root.getJSONObject("vpn_client_profile");
if (clusterId.isEmpty()) {
clusterId = profile.optString("cluster_id", DEFAULT_CLUSTER_ID);
}
if (organizationId.isEmpty()) {
organizationId = profile.optString("organization_id", DEFAULT_ORGANIZATION_ID);
}
if (userId.isEmpty()) {
userId = profile.optString("user_id", "");
}
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)
.putString("organization_id", organizationId)
.putString(PREF_USER_ID, userId)
.putString(PREF_PROFILE_JSON, profileJson)
.putString(PREF_VPN_CONNECTION_ID, connectionId);
if (!trustedDeviceId.isEmpty()) {
editor.putString(PREF_DEVICE_ID, trustedDeviceId);
}
if (!selectedExitNodeId.isEmpty()) {
editor.putString(PREF_SELECTED_EXIT_NODE_ID, selectedExitNodeId);
}
editor.apply();
Intent stopIntent = new Intent(this, RapVpnService.class);
stopIntent.setAction(RapVpnService.ACTION_STOP);
startService(stopIntent);
Thread.sleep(300);
return "install_profile ok " + connectionId + " | " + startVPNFromSavedProfile();
} catch (Exception e) {
return "install_profile failed: " + e.getClass().getSimpleName() + ": " + e.getMessage();
}
}
private JSONObject statusPayload(String event) throws Exception {
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
String deviceId = diagnosticDeviceId(prefs);
JSONObject payload = new JSONObject();
payload.put("event", event);
payload.put("app_version", APP_VERSION);
payload.put("service", "diagnostic");
payload.put("user_id", prefs.getString(PREF_USER_ID, ""));
payload.put("device_id", prefs.getString(PREF_DEVICE_ID, ""));
payload.put("device_id", deviceId);
payload.put("trusted_device_id", prefs.getString(PREF_DEVICE_ID, ""));
payload.put("diagnostic_device_id", prefs.getString(PREF_DIAGNOSTIC_DEVICE_ID, ""));
payload.put("organization_id", prefs.getString("organization_id", ""));
payload.put("vpn_connection_id", prefs.getString(PREF_VPN_CONNECTION_ID, ""));
payload.put("backend_url", prefs.getString("backend_url", ""));
@@ -716,15 +879,86 @@ public class RapDiagnosticService extends Service {
payload.put("last_command_at", lastCommandAt);
payload.put("last_heartbeat_at", lastHeartbeatAt);
payload.put("last_command_poll_at", lastCommandPollAt);
payload.put("last_command_poll_result", lastCommandPollResult);
payload.put("last_received_command_id", lastReceivedCommandID);
payload.put("last_received_command_type", lastReceivedCommandType);
payload.put("last_received_command_at", lastReceivedCommandAt);
payload.put("heartbeat_in_progress", heartbeatInProgress.get());
payload.put("heartbeat_started_at", heartbeatStartedAt);
payload.put("command_poll_in_progress", commandPollInProgress.get());
payload.put("command_poll_started_at", commandPollStartedAt);
payload.put("command_in_progress", commandInProgress.get());
payload.put("command_started_at", commandStartedAt);
payload.put("browser_test", browserTestSnapshot());
return payload;
}
private String describeCommandEnvelope(JSONObject envelope) {
if (envelope == null) {
return "no_content";
}
JSONObject command = envelope.optJSONObject("vpn_client_diagnostic_command");
JSONObject payload = command == null ? envelope.optJSONObject("payload") : command.optJSONObject("payload");
String id = command == null ? "" : command.optString("id", "");
String type = payload == null ? "" : payload.optString("type", "");
String value = "received";
if (!type.isEmpty()) {
value += " " + type;
}
if (!id.isEmpty()) {
value += " " + id;
}
return value;
}
private void rememberReceivedCommand(JSONObject envelope) {
JSONObject command = envelope == null ? null : envelope.optJSONObject("vpn_client_diagnostic_command");
JSONObject payload = command == null ? (envelope == null ? null : envelope.optJSONObject("payload")) : command.optJSONObject("payload");
lastReceivedCommandID = command == null ? "" : command.optString("id", "");
lastReceivedCommandType = payload == null ? "" : payload.optString("type", "");
lastReceivedCommandAt = System.currentTimeMillis();
}
private String diagnosticDeviceId(SharedPreferences prefs) {
String trusted = prefs.getString(PREF_DEVICE_ID, "");
if (trusted != null && !trusted.trim().isEmpty()) {
return trusted.trim();
}
String cached = prefs.getString(PREF_DIAGNOSTIC_DEVICE_ID, "");
if (cached != null && !cached.trim().isEmpty()) {
return cached.trim();
}
String androidId = "";
try {
androidId = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID);
} catch (Exception ignored) {
}
String seed = androidId == null || androidId.trim().isEmpty()
? UUID.randomUUID().toString()
: androidId.trim();
String generated = "diag-" + seed.replaceAll("[^A-Za-z0-9_-]", "").toLowerCase();
if (generated.length() > 80) {
generated = generated.substring(0, 80);
}
prefs.edit().putString(PREF_DIAGNOSTIC_DEVICE_ID, generated).apply();
return generated;
}
private String normalizeBackendUrl(String value) {
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
if (candidate.isEmpty()) {
return DEFAULT_BACKEND_URL;
}
String lower = candidate.toLowerCase();
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)
|| "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;
}
return candidate;
}
@@ -791,7 +1025,7 @@ public class RapDiagnosticService extends Service {
if (!connectionId.isEmpty()) {
report.put("packet_stats", client.vpnPacketStats(clusterId, connectionId));
}
client.reportVPNDiagnosticStatus(clusterId, getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_DEVICE_ID, ""), report);
client.reportVPNDiagnosticStatus(clusterId, diagnosticDeviceId(getSharedPreferences(PREFS, MODE_PRIVATE)), report);
}
if (!connectionId.isEmpty()) {
result.append(" | stats=").append(compact(client.vpnPacketStats(clusterId, connectionId).toString(), 900));
@@ -818,7 +1052,7 @@ public class RapDiagnosticService extends Service {
private String runVPNDownloadTest(String target) {
try {
Network vpn = vpnNetwork();
Network vpn = waitForVPNNetwork(5000);
if (vpn == null) {
return "vpn_download_test " + target + " -> vpn network not found";
}
@@ -1009,6 +1243,13 @@ public class RapDiagnosticService extends Service {
payload.put("diagnostic_local_heartbeat_at", runtime.getLong("diagnostic_local_heartbeat_at", 0));
payload.put("diagnostic_local_state", runtime.getString("diagnostic_local_state", ""));
payload.put("diagnostic_local_app_version", runtime.getString("diagnostic_local_app_version", ""));
payload.put("diagnostic_watchdog_started_at", runtime.getLong("diagnostic_watchdog_started_at", 0));
payload.put("diagnostic_watchdog_last_ensure_at", runtime.getLong("diagnostic_watchdog_last_ensure_at", 0));
payload.put("diagnostic_watchdog_last_heartbeat_age_ms", runtime.getLong("diagnostic_watchdog_last_heartbeat_age_ms", 0));
payload.put("diagnostic_watchdog_last_action", runtime.getString("diagnostic_watchdog_last_action", ""));
payload.put("diagnostic_watchdog_last_error", runtime.getString("diagnostic_watchdog_last_error", ""));
payload.put("diagnostic_watchdog_ensure_requests", runtime.getLong("diagnostic_watchdog_ensure_requests", 0));
payload.put("diagnostic_watchdog_restart_requests", runtime.getLong("diagnostic_watchdog_restart_requests", 0));
payload.put("uplink_read", runtime.getLong("uplink_read", 0));
payload.put("uplink_sent", runtime.getLong("uplink_sent", 0));
payload.put("downlink_received", runtime.getLong("downlink_received", 0));
@@ -1044,8 +1285,10 @@ public class RapDiagnosticService extends Service {
payload.put("errors", runtime.getLong("errors", 0));
payload.put("uplink", runtimePrefix(runtime, "uplink"));
payload.put("uplink_sender", runtimePrefix(runtime, "uplink_sender"));
payload.put("uplink_tcp", runtimePrefix(runtime, "uplink_tcp"));
payload.put("downlink", runtimePrefix(runtime, "downlink"));
payload.put("downlink_writer", runtimePrefix(runtime, "downlink_writer"));
payload.put("downlink_tcp", runtimePrefix(runtime, "downlink_tcp"));
payload.put("relay", runtimePrefix(runtime, "relay"));
payload.put("uplink_worker_count", runtime.getInt("uplink_worker_count", 0));
payload.put("uplink_queue_depth_total", runtime.getInt("uplink_queue_depth_total", 0));
@@ -1266,7 +1509,7 @@ public class RapDiagnosticService extends Service {
}
long started = System.currentTimeMillis();
try {
Network vpn = vpnNetwork();
Network vpn = waitForVPNNetwork(5000);
if (vpn == null) {
return "vpn_tcp_connect " + host + ":" + port + " -> vpn network not found";
}
@@ -1283,6 +1526,24 @@ public class RapDiagnosticService extends Service {
}
}
private Network waitForVPNNetwork(int timeoutMs) {
long deadline = System.currentTimeMillis() + Math.max(0, timeoutMs);
Network vpn;
do {
vpn = vpnNetwork();
if (vpn != null) {
return vpn;
}
try {
Thread.sleep(150);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
} while (System.currentTimeMillis() < deadline);
return null;
}
private FetchResult fetchVPNURL(Network vpn, URL url, int connectTimeoutMs, int readTimeoutMs, int maxBytes) throws Exception {
long started = System.currentTimeMillis();
HttpURLConnection connection = (HttpURLConnection) vpn.openConnection(url);
@@ -26,7 +26,9 @@ import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.Socket;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -48,10 +50,12 @@ public class RapVpnService extends VpnService {
private static final String CHANNEL_ID = "rap-vpn";
private static final String TAG = "RapVpnService";
private static final String PREFS = "rap-vpn-runtime";
private static final int DEFAULT_VPN_MTU = 1420;
private static final int DEFAULT_VPN_MTU = 1000;
private static final int VPN_TCP_MSS_CLAMP = 900;
private static final boolean PACKET_WEBSOCKET_DATAPLANE_ENABLED = false;
private static final int VPN_BATCH_MAX_PACKETS = 512;
private static final int VPN_BATCH_MAX_BYTES = 1024 * 1024;
private static final int UPLINK_WORKER_MAX_COUNT = 4;
private static final int UPLINK_WORKER_MAX_COUNT = 1;
private static final int UPLINK_QUEUE_CAPACITY = 32768;
private static final int PRIORITY_QUEUE_CAPACITY = 4096;
private static final int UPLINK_SEND_RETRY_COUNT = 2;
@@ -72,7 +76,12 @@ public class RapVpnService extends VpnService {
private static final int RUNTIME_WATCHDOG_STALE_SYNACK_MS = 15000;
private static final int RUNTIME_WATCHDOG_RECOVERY_COOLDOWN_MS = 60000;
private static final int RUNTIME_WATCHDOG_HARD_RESTART_COOLDOWN_MS = 180000;
private static final int DIAGNOSTIC_WATCHDOG_INTERVAL_MS = 5000;
private static final int DIAGNOSTIC_STALE_RESTART_MS = 30000;
private static final int DIAGNOSTIC_RESTART_COOLDOWN_MS = 15000;
private static final int VPN_START_WARMUP_TIMEOUT_MS = 6000;
private static final int VPN_TCP_WARMUP_CONNECT_TIMEOUT_MS = 2500;
private static final int VPN_TCP_WARMUP_PASSES = 3;
private static final String[] DEFAULT_DNS_PROBE_DOMAINS = new String[]{
"speedtest.rt.ru",
"2ip.ru",
@@ -97,6 +106,11 @@ public class RapVpnService extends VpnService {
"rline-host.qms.ru",
"timernet-host.qms.ru"
};
private static final String[] DEFAULT_TCP_WARMUP_TARGETS = new String[]{
"192.168.200.61:18080",
"192.168.200.95:3389",
"188.40.167.82:80"
};
private static final String PREF_NAME = "rap-vpn";
private static final String PREF_PROFILE_JSON = "profile_json";
private static final String PREF_BACKEND_URL = "backend_url";
@@ -112,6 +126,7 @@ public class RapVpnService extends VpnService {
private Thread downlinkThread;
private Thread downlinkWriterThread;
private Thread runtimeWatchdogThread;
private Thread diagnosticWatchdogThread;
private BlockingQueue<byte[]>[] uplinkQueues;
private BlockingQueue<byte[]>[] downlinkQueues;
private BlockingQueue<byte[]> uplinkPriorityQueue;
@@ -171,6 +186,8 @@ public class RapVpnService extends VpnService {
private final AtomicLong runtimeWatchdogRecoveries = new AtomicLong();
private final AtomicLong tcpHandshakeStalls = new AtomicLong();
private final AtomicLong runtimeWatchdogHardRestarts = new AtomicLong();
private final AtomicLong diagnosticEnsureRequests = new AtomicLong();
private final AtomicLong diagnosticRestartRequests = new AtomicLong();
private final AtomicBoolean hardRuntimeRestartInProgress = new AtomicBoolean();
private volatile boolean relaxedUplinkSourceValidation;
private volatile boolean relaxedDownlinkDestinationValidation;
@@ -180,6 +197,7 @@ public class RapVpnService extends VpnService {
private volatile int activePacketRelayIndex;
private volatile VpnPacketWebSocketRelay packetWebSocketRelay;
private volatile FabricServiceChannel activeFabricServiceChannel = new FabricServiceChannel();
private volatile String lastUplinkSendErrorMessage = "";
private final Object packetRelaySwitchLock = new Object();
private final Map<String, byte[]> clientSourceNat = new LinkedHashMap<String, byte[]>(4096, 0.75f, true) {
@Override
@@ -198,7 +216,7 @@ public class RapVpnService extends VpnService {
private volatile long lastRuntimeWatchdogHardRestartAt;
private volatile long lastDiagnosticEnsureAt;
private volatile long lastDiagnosticStatusEnsureAt;
private volatile boolean nextDiagnosticEnsureMayRestart;
private volatile long lastDiagnosticRestartAt;
private static final int ADDRESS_MISMATCH_TOLERANCE_PACKETS = 16;
@Override
@@ -257,8 +275,17 @@ public class RapVpnService extends VpnService {
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);
stopSelf();
return START_NOT_STICKY;
}
if (packetRelayUrls.isEmpty()) {
packetRelayUrls.add(backendUrl);
shutdownReason = "missing farm entry endpoint";
writeRuntimeStatus("error", "vpn not started: missing farm entry endpoint", 0, 0, 0, 0);
stopSelf();
return START_NOT_STICKY;
}
startPacketRelay(backendUrl, packetRelayUrls, clusterId, vpnConnectionId);
if (!running) {
@@ -284,6 +311,7 @@ public class RapVpnService extends VpnService {
private void ensureDiagnosticServiceRunning() {
try {
RapDiagnosticService.start(this);
diagnosticEnsureRequests.incrementAndGet();
writeRuntimeDetail("diagnostic_start", "diagnostic service start requested by vpn runtime", "control", 0, 0, "", -1);
} catch (Exception e) {
Log.w(TAG, "diagnostic service start failed", e);
@@ -295,8 +323,15 @@ public class RapVpnService extends VpnService {
try {
SharedPreferences runtime = getSharedPreferences(PREFS, MODE_PRIVATE);
long lastLocalHeartbeat = runtime.getLong("diagnostic_local_heartbeat_at", 0);
long age = lastLocalHeartbeat <= 0 ? Long.MAX_VALUE : System.currentTimeMillis() - lastLocalHeartbeat;
boolean restart = nextDiagnosticEnsureMayRestart && age > 45000;
long now = System.currentTimeMillis();
long age = lastLocalHeartbeat <= 0 ? Long.MAX_VALUE : now - lastLocalHeartbeat;
boolean restart = age > DIAGNOSTIC_STALE_RESTART_MS
&& now - lastDiagnosticRestartAt >= DIAGNOSTIC_RESTART_COOLDOWN_MS;
diagnosticEnsureRequests.incrementAndGet();
if (restart) {
diagnosticRestartRequests.incrementAndGet();
lastDiagnosticRestartAt = now;
}
Intent intent = new Intent(this, RapDiagnosticService.class);
intent.setAction(restart ? RapDiagnosticService.ACTION_RESTART : RapDiagnosticService.ACTION_START);
if (Build.VERSION.SDK_INT >= 26) {
@@ -304,7 +339,13 @@ public class RapVpnService extends VpnService {
} else {
startService(intent);
}
nextDiagnosticEnsureMayRestart = true;
runtime.edit()
.putLong("diagnostic_watchdog_last_ensure_at", now)
.putLong("diagnostic_watchdog_last_heartbeat_age_ms", age)
.putString("diagnostic_watchdog_last_action", restart ? "restart" : "start")
.putLong("diagnostic_watchdog_ensure_requests", diagnosticEnsureRequests.get())
.putLong("diagnostic_watchdog_restart_requests", diagnosticRestartRequests.get())
.apply();
writeRuntimeDetail(
restart ? "diagnostic_restart" : "diagnostic_start",
(restart ? "diagnostic service restart requested age_ms=" : "diagnostic service start requested age_ms=") + age,
@@ -315,6 +356,11 @@ public class RapVpnService extends VpnService {
-1);
} catch (Exception e) {
Log.w(TAG, "diagnostic service health ensure failed", e);
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putLong("diagnostic_watchdog_last_ensure_at", System.currentTimeMillis())
.putString("diagnostic_watchdog_last_action", "failed")
.putString("diagnostic_watchdog_last_error", e.getClass().getSimpleName() + ": " + e.getMessage())
.apply();
writeRuntimeDetail("diagnostic_start_failed", e.getMessage(), "control", 0, 1, e.getClass().getSimpleName(), -1);
}
}
@@ -775,6 +821,19 @@ public class RapVpnService extends VpnService {
}
}
private boolean isIPv6URLHost(String value) {
if (value == null || value.trim().isEmpty()) {
return false;
}
try {
URI uri = URI.create(value.trim());
String host = uri.getHost();
return host != null && host.contains(":");
} catch (Exception ignored) {
return false;
}
}
private void writeRuntimeConfig(VpnClientConfig config, boolean forceFullTunnel, boolean fastPathMode) {
try {
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
@@ -935,11 +994,15 @@ public class RapVpnService extends VpnService {
+ " connection=" + present(vpnConnectionId), 0, 0, 0, 0);
return;
}
List<String> relayUrls = dedupeRelayUrls(candidateUrls, backendUrl);
List<String> relayUrls = activeFabricServiceChannel.enabled ? dedupeFabricRelayUrls(candidateUrls, backendUrl) : dedupeRelayUrls(candidateUrls, backendUrl);
String selectedRelayUrl = relayUrls.isEmpty() ? "" : relayUrls.get(0);
if (selectedRelayUrl == null || selectedRelayUrl.isEmpty()) {
if ((selectedRelayUrl == null || selectedRelayUrl.isEmpty()) && !activeFabricServiceChannel.enabled) {
selectedRelayUrl = backendUrl;
}
if (selectedRelayUrl == null || selectedRelayUrl.isEmpty()) {
writeRuntimeStatus("error", "relay not started: missing fabric farm entry endpoint", 0, 0, 0, 0);
return;
}
activePacketRelayUrlByProfile = selectedRelayUrl;
activePacketRelayUrlsByProfile = new ArrayList<>(relayUrls);
activePacketRelayIndex = Math.max(0, relayUrls.indexOf(selectedRelayUrl));
@@ -971,14 +1034,18 @@ public class RapVpnService extends VpnService {
downlinkQueues[i] = new ArrayBlockingQueue<>(downlinkPerFlowCapacity);
}
configureBackendBypass(selectedRelayUrl);
startPacketWebSocketRelay(selectedRelayUrl, clusterId, vpnConnectionId);
if (PACKET_WEBSOCKET_DATAPLANE_ENABLED) {
startPacketWebSocketRelay(selectedRelayUrl, clusterId, vpnConnectionId);
} else {
writeRuntimeDetail("http_packet_batch", "packet websocket disabled; using confirmed HTTP batches", "relay", 0, 0, "", -1);
}
Log.i(TAG, "packet relay starting: backend=" + selectedRelayUrl + " cluster=" + clusterId + " vpn_connection=" + vpnConnectionId);
writeRuntimeStatus("relay", "relay starting " + vpnConnectionId, 0, 0, 0, 0);
writeRuntimeDetail("running", "packet relay active", "relay", 0, 0, "");
final String resetRelayUrl = selectedRelayUrl;
Thread resetThread = new Thread(() -> {
final String legacyResetRelayUrl = selectedRelayUrl;
Thread resetThread = activeFabricServiceChannel.enabled ? null : new Thread(() -> {
try {
RapApiClient uplinkClient = packetRelayClientForUrl(resetRelayUrl);
RapApiClient uplinkClient = packetRelayClientForUrl(legacyResetRelayUrl);
JSONObject reset = uplinkClient.resetVPNPacketQueues(clusterId, vpnConnectionId);
Log.i(TAG, "packet relay queues reset: " + reset.toString());
writeRuntimeStatus("relay_reset", reset.toString(), 0, 0, 0, 0);
@@ -996,7 +1063,12 @@ public class RapVpnService extends VpnService {
downlinkThread = new Thread(() -> runDownlinkWithRestart(clusterId, vpnConnectionId), "rap-vpn-downlink-receiver");
downlinkWriterThread = new Thread(this::pumpDownlinkQueueToTun, "rap-vpn-downlink-writer");
runtimeWatchdogThread = new Thread(() -> runRuntimeWatchdog(clusterId, vpnConnectionId), "rap-vpn-runtime-watchdog");
resetThread.start();
diagnosticWatchdogThread = new Thread(this::runDiagnosticServiceWatchdog, "rap-vpn-diagnostic-watchdog");
if (resetThread != null) {
resetThread.start();
} else {
writeRuntimeStatus("farm_dataplane", "backend relay queue reset skipped; farm owns vpn packet routes", 0, 0, 0, 0);
}
uplinkThread.start();
for (Thread senderThread : uplinkSenderThreads) {
senderThread.start();
@@ -1004,6 +1076,7 @@ public class RapVpnService extends VpnService {
downlinkThread.start();
downlinkWriterThread.start();
runtimeWatchdogThread.start();
diagnosticWatchdogThread.start();
}
private List<String> singletonUrl(String value) {
@@ -1057,6 +1130,33 @@ public class RapVpnService extends VpnService {
return out;
}
private List<String> dedupeFabricRelayUrls(List<String> candidateUrls, String backendUrl) {
List<String> ipv4OrHost = new ArrayList<>();
List<String> ipv6 = new ArrayList<>();
boolean backendIsPrivate = isPrivateURLHost(backendUrl);
if (candidateUrls != null) {
for (String url : candidateUrls) {
String normalized = normalizeHTTPBaseUrl(url);
if (!backendIsPrivate && isPrivateURLHost(normalized)) {
continue;
}
if (isIPv6URLHost(normalized)) {
addUniqueUrl(ipv6, normalized);
} else {
addUniqueUrl(ipv4OrHost, normalized);
}
}
}
List<String> out = new ArrayList<>();
for (String url : ipv4OrHost) {
addUniqueUrl(out, url);
}
for (String url : ipv6) {
addUniqueUrl(out, url);
}
return out;
}
private void addUniqueUrl(List<String> urls, String value) {
if (urls == null || value == null) {
return;
@@ -1132,6 +1232,9 @@ public class RapVpnService extends VpnService {
if (next == null || next.isEmpty() || next.equals(normalizedFailed)) {
continue;
}
if (isIPv6URLHost(next) && hasNonIPv6RelayUrl(urls)) {
continue;
}
activePacketRelayIndex = nextIndex;
activePacketRelayUrlByProfile = next;
configureBackendBypass(next);
@@ -1145,6 +1248,18 @@ public class RapVpnService extends VpnService {
}
}
private boolean hasNonIPv6RelayUrl(List<String> urls) {
if (urls == null) {
return false;
}
for (String url : urls) {
if (url != null && !url.isEmpty() && !isIPv6URLHost(url)) {
return true;
}
}
return false;
}
private String selectReachablePacketRelayUrl(List<String> relayUrls, String clusterId, String vpnConnectionId) {
if (relayUrls == null || relayUrls.isEmpty()) {
return "";
@@ -1190,11 +1305,13 @@ public class RapVpnService extends VpnService {
interruptAndJoin(downlinkThread);
interruptAndJoin(downlinkWriterThread);
interruptAndJoin(runtimeWatchdogThread);
interruptAndJoin(diagnosticWatchdogThread);
uplinkThread = null;
uplinkSenderThreads = null;
downlinkThread = null;
downlinkWriterThread = null;
runtimeWatchdogThread = null;
diagnosticWatchdogThread = null;
uplinkWorkerCount = 0;
downlinkFlowQueueCount = 0;
uplinkQueues = null;
@@ -1411,10 +1528,6 @@ public class RapVpnService extends VpnService {
return;
}
long now = System.currentTimeMillis();
if (now - lastDiagnosticEnsureAt >= 10000) {
lastDiagnosticEnsureAt = now;
ensureDiagnosticServiceHealthy();
}
int stale = staleTCPHandshakeCount();
if (stale <= 0) {
continue;
@@ -1440,6 +1553,29 @@ public class RapVpnService extends VpnService {
}
}
private void runDiagnosticServiceWatchdog() {
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putLong("diagnostic_watchdog_started_at", System.currentTimeMillis())
.putString("diagnostic_watchdog_last_action", "started")
.apply();
while (running) {
try {
ensureDiagnosticServiceHealthy();
Thread.sleep(DIAGNOSTIC_WATCHDOG_INTERVAL_MS);
} catch (InterruptedException e) {
if (!running) {
return;
}
} catch (Exception e) {
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putLong("diagnostic_watchdog_last_ensure_at", System.currentTimeMillis())
.putString("diagnostic_watchdog_last_action", "failed")
.putString("diagnostic_watchdog_last_error", e.getClass().getSimpleName() + ": " + e.getMessage())
.apply();
}
}
}
private boolean shouldHardRestartRuntime(long now) {
if (runtimeWatchdogRecoveries.get() < 2) {
return false;
@@ -1636,14 +1772,13 @@ public class RapVpnService extends VpnService {
System.arraycopy(packet, 0, copy, 0, length);
if (!hasIPv4Source(copy, length)) {
long mismatch = uplinkSourceMismatchPackets.incrementAndGet();
String natKey = natKeyForOutboundReturn(copy, length);
if (natKey.isEmpty() || !rewriteIPv4SourceToVPN(copy, length, natKey)) {
Log.w(TAG, "vpn uplink source is not vpn address; dropping " + packetSummary(copy, length));
writeRuntimeDetail("source_drop", packetSummary(copy, length), "uplink", -1, mismatch, "SOURCE_MISMATCH");
recordUplinkDrop(length);
return;
}
writeRuntimeDetail("source_nat", packetSummary(copy, length), "uplink", -1, mismatch, "SOURCE_NAT");
Log.w(TAG, "vpn uplink source is not vpn address; dropping " + packetSummary(copy, length));
writeRuntimeDetail("source_drop", packetSummary(copy, length), "uplink", -1, mismatch, "SOURCE_MISMATCH");
recordUplinkDrop(length);
return;
}
if (clampIPv4TCPMSS(copy, length, VPN_TCP_MSS_CLAMP)) {
writeRuntimeDetail("tcp_mss_clamp", packetSummary(copy, length), "uplink_tcp", -1, -1, "");
}
recordOutboundTCPHandshake(copy, length);
if (handleLocalDnsQuery(copy, length)) {
@@ -1989,6 +2124,7 @@ public class RapVpnService extends VpnService {
return;
}
String key = tcpFlowKey(flow.srcIp, flow.srcPort, flow.dstPort);
writeRuntimeDetail("tcp_syn_ack", key, "downlink_tcp", downlinkReceivedPackets.get(), tcpHandshakeStalls.get(), "");
synchronized (pendingTcpHandshakes) {
if (pendingTcpHandshakes.containsKey(key)) {
pendingTcpHandshakes.put(key, -System.currentTimeMillis());
@@ -2014,7 +2150,11 @@ public class RapVpnService extends VpnService {
}
private boolean isTCPPriorityPacket(byte[] packet, int length) {
if (packet == null || length < 40 || ((packet[0] >> 4) & 0x0f) != 4) {
return false;
}
private boolean clampIPv4TCPMSS(byte[] packet, int length, int maxMss) {
if (packet == null || length < 40 || maxMss <= 0 || ((packet[0] >> 4) & 0x0f) != 4) {
return false;
}
int ihl = (packet[0] & 0x0f) * 4;
@@ -2025,17 +2165,47 @@ public class RapVpnService extends VpnService {
if (totalLength <= 0 || totalLength > length) {
totalLength = length;
}
int tcpHeaderLength = ((packet[ihl + 12] >> 4) & 0x0f) * 4;
if (tcpHeaderLength < 20 || ihl + tcpHeaderLength > totalLength) {
int tcpOffset = ihl;
int tcpHeaderLength = ((packet[tcpOffset + 12] >> 4) & 0x0f) * 4;
if (tcpHeaderLength < 20 || tcpOffset + tcpHeaderLength > totalLength) {
return false;
}
int flags = packet[ihl + 13] & 0xff;
int flags = packet[tcpOffset + 13] & 0xff;
boolean syn = (flags & 0x02) != 0;
boolean fin = (flags & 0x01) != 0;
boolean rst = (flags & 0x04) != 0;
boolean ack = (flags & 0x10) != 0;
int payloadLength = totalLength - ihl - tcpHeaderLength;
return syn || fin || rst || (ack && payloadLength == 0);
if (!syn || ack || tcpHeaderLength <= 20) {
return false;
}
int option = tcpOffset + 20;
int end = tcpOffset + tcpHeaderLength;
while (option < end) {
int kind = packet[option] & 0xff;
if (kind == 0) {
break;
}
if (kind == 1) {
option++;
continue;
}
if (option + 1 >= end) {
break;
}
int optionLength = packet[option + 1] & 0xff;
if (optionLength < 2 || option + optionLength > end) {
break;
}
if (kind == 2 && optionLength == 4) {
int current = u16(packet, option + 2);
if (current > maxMss) {
putU16(packet, option + 2, maxMss);
normalizeIPv4PacketChecksums(packet, length);
return true;
}
return false;
}
option += optionLength;
}
return false;
}
private String tcpFlowKey(String remoteIp, int remotePort, int localPort) {
@@ -2246,7 +2416,8 @@ public class RapVpnService extends VpnService {
}
recordUplinkDrop(Math.max(0, batchBytes - 4));
writeRuntimeStatus("degraded", "uplink send failed after retry; continuing", 0, sentPackets, 0, errors);
writeRuntimeDetail("error", "uplink send failed after retry batch=" + batch.size(), "uplink_sender", sentPackets, errors, "SEND_RETRY_EXHAUSTED", workerIndex);
String retryError = lastUplinkSendErrorMessage == null || lastUplinkSendErrorMessage.isEmpty() ? "" : " last_error=" + lastUplinkSendErrorMessage;
writeRuntimeDetail("error", "uplink send failed after retry batch=" + batch.size() + retryError, "uplink_sender", sentPackets, errors, "SEND_RETRY_EXHAUSTED", workerIndex);
continue;
}
sentPackets += batch.size();
@@ -2289,6 +2460,7 @@ public class RapVpnService extends VpnService {
private boolean sendUplinkBatchWithRetry(String clusterId, String vpnConnectionId, List<byte[]> batch, int workerIndex) {
Exception lastError = null;
lastUplinkSendErrorMessage = "";
int relayAttempts = Math.max(1, activePacketRelayUrlsByProfile == null ? 1 : activePacketRelayUrlsByProfile.size());
for (int relayAttempt = 0; relayAttempt < relayAttempts && running; relayAttempt++) {
String relayUrl = currentPacketRelayUrl();
@@ -2308,7 +2480,8 @@ public class RapVpnService extends VpnService {
return true;
} catch (Exception e) {
lastError = e;
writeRuntimeDetail("retry", "uplink send retry worker=" + workerIndex + " relay=" + relayUrl + " attempt=" + attempt + " error=" + e.getClass().getSimpleName(), "uplink_sender", -1, -1, e.getClass().getSimpleName(), workerIndex);
lastUplinkSendErrorMessage = compactException(e);
writeRuntimeDetail("retry", "uplink send retry worker=" + workerIndex + " relay=" + relayUrl + " attempt=" + attempt + " error=" + lastUplinkSendErrorMessage, "uplink_sender", -1, -1, e.getClass().getSimpleName(), workerIndex);
sleepQuietly(UPLINK_SEND_RETRY_SLEEP_MS * (attempt + 1L));
}
}
@@ -2323,6 +2496,9 @@ public class RapVpnService extends VpnService {
}
private boolean sendUplinkBatchOverWebSocket(String relayUrl, String clusterId, String vpnConnectionId, List<byte[]> batch, int workerIndex) {
if (!PACKET_WEBSOCKET_DATAPLANE_ENABLED) {
return false;
}
VpnPacketWebSocketRelay relay = packetWebSocketRelay;
if (relay == null || relayUrl == null || !relayUrl.equals(relay.baseUrl())) {
return false;
@@ -2555,6 +2731,7 @@ public class RapVpnService extends VpnService {
}
addDefaultDnsProbeDomains(domains);
int resolved = 0;
int tcpOk = 0;
String last = "";
long warmUntil = System.currentTimeMillis() + 30000;
int pass = 0;
@@ -2579,7 +2756,14 @@ public class RapVpnService extends VpnService {
}
sleepQuietly(120);
}
if (passResolved >= Math.min(3, domains.size()) && pass >= 2) {
if (pass <= VPN_TCP_WARMUP_PASSES) {
int passTcpOk = runTCPWarmupPass(vpn);
tcpOk += passTcpOk;
if (passTcpOk > 0) {
last = "tcp_warmup_ok=" + passTcpOk;
}
}
if (passResolved >= Math.min(3, domains.size()) && tcpOk > 0 && pass >= 2) {
break;
}
sleepQuietly(750);
@@ -2588,15 +2772,60 @@ public class RapVpnService extends VpnService {
return;
}
if (resolved > 0) {
writeRuntimeStatus("ready", "vpn ready; dns warmup ok " + resolved + " " + dnsInfo + " " + last, 0, 0, downlinkReceivedPackets.get(), 0);
writeRuntimeStatus("ready", "vpn ready; warmup dns=" + resolved + " tcp=" + tcpOk + " " + dnsInfo + " " + last, 0, 0, downlinkReceivedPackets.get(), 0);
} else {
writeRuntimeStatus("warming", "vpn started; dns warmup pending " + dnsInfo + " " + last, 0, 0, downlinkReceivedPackets.get(), 1);
writeRuntimeStatus("warming", "vpn started; warmup pending dns=0 tcp=" + tcpOk + " " + dnsInfo + " " + last, 0, 0, downlinkReceivedPackets.get(), 1);
}
Log.i(TAG, "vpn readiness warmup complete: connection=" + vpnConnectionId + " resolved=" + resolved + " " + dnsInfo + " " + last);
Log.i(TAG, "vpn readiness warmup complete: connection=" + vpnConnectionId + " resolved=" + resolved + " tcp=" + tcpOk + " " + dnsInfo + " " + last);
}, "rap-vpn-readiness-warmup");
thread.start();
}
private int runTCPWarmupPass(Network vpn) {
int ok = 0;
for (String target : DEFAULT_TCP_WARMUP_TARGETS) {
if (!running) {
return ok;
}
TCPWarmupTarget parsed = parseTCPWarmupTarget(target);
if (parsed == null) {
continue;
}
long started = System.currentTimeMillis();
try (Socket socket = new Socket()) {
if (vpn != null) {
vpn.bindSocket(socket);
}
socket.connect(new InetSocketAddress(parsed.host, parsed.port), VPN_TCP_WARMUP_CONNECT_TIMEOUT_MS);
ok++;
writeRuntimeDetail("tcp_warmup", target + " ms=" + (System.currentTimeMillis() - started), "readiness", ok, 0, "", -1);
} catch (Exception e) {
writeRuntimeDetail("tcp_warmup_failed", target + " " + e.getClass().getSimpleName(), "readiness", ok, 1, e.getClass().getSimpleName(), -1);
}
sleepQuietly(120);
}
return ok;
}
private TCPWarmupTarget parseTCPWarmupTarget(String target) {
if (target == null) {
return null;
}
int split = target.lastIndexOf(':');
if (split <= 0 || split >= target.length() - 1) {
return null;
}
try {
int port = Integer.parseInt(target.substring(split + 1));
if (port <= 0 || port > 65535) {
return null;
}
return new TCPWarmupTarget(target.substring(0, split), port);
} catch (NumberFormatException e) {
return null;
}
}
private static void addDefaultDnsProbeDomains(Set<String> domains) {
if (domains == null) {
return;
@@ -2724,6 +2953,9 @@ public class RapVpnService extends VpnService {
}
private List<byte[]> receiveDownlinkBatch(String relayUrl, RapApiClient client, String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
if (!PACKET_WEBSOCKET_DATAPLANE_ENABLED) {
return client.receiveClientPacketBatch(clusterId, vpnConnectionId, timeoutMs);
}
VpnPacketWebSocketRelay relay = packetWebSocketRelay;
if (relay != null && relayUrl != null && relayUrl.equals(relay.baseUrl())) {
List<byte[]> packets = relay.receiveClientPacketBatch(clusterId, vpnConnectionId, timeoutMs);
@@ -2890,6 +3122,16 @@ public class RapVpnService extends VpnService {
}
}
private static class TCPWarmupTarget {
final String host;
final int port;
TCPWarmupTarget(String host, int port) {
this.host = host;
this.port = port;
}
}
private boolean writePacketToTun(FileDescriptor fd, byte[] packet, int packetLength) throws Exception {
int offset = 0;
int attempts = 0;
@@ -2950,6 +3192,19 @@ public class RapVpnService extends VpnService {
}
}
private String compactException(Exception e) {
if (e == null) {
return "";
}
String message = e.getMessage();
String value = e.getClass().getSimpleName() + (message == null || message.trim().isEmpty() ? "" : ": " + message.trim());
value = value.replace('\n', ' ').replace('\r', ' ').trim();
if (value.length() > 240) {
return value.substring(0, 240);
}
return value;
}
private void closeFdQuietly(FileDescriptor fd) {
if (fd == null) {
return;
@@ -3064,6 +3319,8 @@ public class RapVpnService extends VpnService {
.putLong("runtime_watchdog_recoveries", runtimeWatchdogRecoveries.get())
.putLong("tcp_handshake_stalls", tcpHandshakeStalls.get())
.putLong("runtime_watchdog_hard_restarts", runtimeWatchdogHardRestarts.get())
.putLong("diagnostic_watchdog_ensure_requests", diagnosticEnsureRequests.get())
.putLong("diagnostic_watchdog_restart_requests", diagnosticRestartRequests.get())
.putLong("uplink_source_mismatch_packets", uplinkSourceMismatchPackets.get())
.putLong("downlink_destination_mismatch_packets", downlinkDestinationMismatchPackets.get())
.putFloat("uplink_read_mbps", uplinkReadMbps)