Record project continuation changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Fri May 01 13:10:35 MSK 2026
|
||||
gradle.version=9.5.0
|
||||
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
# RAP Android VPN
|
||||
|
||||
This is the Android client for the experimental RAP VPN service.
|
||||
|
||||
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`;
|
||||
- request Android VPN permission and create a `VpnService` TUN interface;
|
||||
- relay TUN packets through the Control Plane HTTP packet relay to the active
|
||||
`home-1` gateway lease.
|
||||
- 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
|
||||
retyping the profile.
|
||||
- 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.
|
||||
|
||||
This is still a lab runtime, not a production WireGuard/IPsec implementation.
|
||||
The active Linux gateway node must be able to create `/dev/net/tun`, run `ip`,
|
||||
`sysctl`, and `iptables`, and enable NAT for `10.77.0.0/24`.
|
||||
|
||||
Build from this repository on Windows:
|
||||
|
||||
```powershell
|
||||
$env:ANDROID_HOME="C:\Android\Sdk"
|
||||
$env:ANDROID_SDK_ROOT="C:\Android\Sdk"
|
||||
pwsh -ExecutionPolicy Bypass -File ..\..\scripts\android\build-android-apk.ps1
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
Or run directly from the project:
|
||||
|
||||
```powershell
|
||||
$env:ANDROID_HOME="C:\Android\Sdk"
|
||||
$env:ANDROID_SDK_ROOT="C:\Android\Sdk"
|
||||
gradle assembleDebug
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "su.cin.rapvpn"
|
||||
compileSdk 35
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
// Для тестовой среды используем debug-сертификат как fallback, чтобы APK всегда можно было установить.
|
||||
// Когда будет отдельный keystore для prod/release — заменим на него в этом блоке.
|
||||
initWith signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
def normalizeGradleString = { value ->
|
||||
return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
}
|
||||
|
||||
def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "http://vpn.cin.su:19191/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"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "su.cin.rapvpn"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 159
|
||||
versionName "0.2.159"
|
||||
buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\""
|
||||
buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\""
|
||||
buildConfigField "String", "DEFAULT_ORGANIZATION_ID", "\"${normalizeGradleString(defaultOrganizationId)}\""
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "com.squareup.okhttp3:okhttp:5.3.2"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="RAP VPN"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".RdpActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TestVpnActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".TestTrafficActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<service
|
||||
android:name=".RapVpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
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">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.Intent;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.VpnService;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.widget.Button;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
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 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_REFRESH_TOKEN = "refresh_token";
|
||||
private static final String PREF_REFRESH_EXPIRES_AT = "refresh_expires_at";
|
||||
private static final String PREF_USER_ID = "user_id";
|
||||
private static final String PREF_DEVICE_ID = "device_id";
|
||||
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;
|
||||
private EditText password;
|
||||
private TextView status;
|
||||
private TextView profileSummary;
|
||||
private TextView serverDirectory;
|
||||
private TextView runtimeStatus;
|
||||
private String profileJson = "";
|
||||
private String vpnConnectionId = "";
|
||||
private JSONArray lastResources = new JSONArray();
|
||||
private RapApiClient.AuthContext authContext = null;
|
||||
private SharedPreferences prefs;
|
||||
private SharedPreferences runtimePrefs;
|
||||
private SecureTokenStore secureTokens;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
runtimePrefs = getSharedPreferences("rap-vpn-runtime", MODE_PRIVATE);
|
||||
secureTokens = new SecureTokenStore(this);
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(0xff101820);
|
||||
int pad = dp(20);
|
||||
root.setPadding(pad, pad, pad, pad);
|
||||
|
||||
backendUrl = field("Backend URL", 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"));
|
||||
password = field("Password", "");
|
||||
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
normalizeAndPersistDefaults();
|
||||
if (!prefs.contains(PREF_FORCE_FULL_TUNNEL)) {
|
||||
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, true).apply();
|
||||
}
|
||||
profileJson = prefs.getString(PREF_PROFILE_JSON, "");
|
||||
vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
|
||||
restoreAuthContext();
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText("RAP HOME VPN " + APP_VERSION);
|
||||
title.setTextColor(0xffffffff);
|
||||
title.setTextSize(26);
|
||||
title.setPadding(0, 0, 0, dp(8));
|
||||
|
||||
profileSummary = new TextView(this);
|
||||
profileSummary.setTextColor(0xffc8d6df);
|
||||
profileSummary.setTextSize(14);
|
||||
profileSummary.setText(summaryText());
|
||||
|
||||
serverDirectory = new TextView(this);
|
||||
serverDirectory.setTextColor(0xffe8eef2);
|
||||
serverDirectory.setTextSize(15);
|
||||
serverDirectory.setPadding(0, dp(14), 0, dp(14));
|
||||
serverDirectory.setText("");
|
||||
|
||||
status = new TextView(this);
|
||||
status.setTextColor(0xffd8eadf);
|
||||
status.setPadding(0, dp(14), 0, dp(14));
|
||||
status.setText("Готово. Версия " + APP_VERSION + ".");
|
||||
|
||||
runtimeStatus = new TextView(this);
|
||||
runtimeStatus.setTextColor(0xff9fb6c2);
|
||||
runtimeStatus.setTextSize(13);
|
||||
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("Включить HOME VPN");
|
||||
start.setOnClickListener(v -> prepareVpn());
|
||||
|
||||
Button stop = new Button(this);
|
||||
stop.setText("Отключить VPN");
|
||||
stop.setOnClickListener(v -> {
|
||||
Intent stopIntent = new Intent(this, RapVpnService.class);
|
||||
stopIntent.setAction(RapVpnService.ACTION_STOP);
|
||||
try {
|
||||
startService(stopIntent);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
runtimePrefs.edit()
|
||||
.putString("state", "stopped")
|
||||
.putString("message", "stop requested from app")
|
||||
.putLong("updated_at", System.currentTimeMillis())
|
||||
.apply();
|
||||
status.setText("Отключаю VPN...");
|
||||
runtimeStatus.postDelayed(() -> {
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
status.setText(isSystemVpnActive() ? "VPN еще активен в Android. Повторяю остановку..." : "VPN отключен.");
|
||||
if (isSystemVpnActive()) {
|
||||
try {
|
||||
startService(stopIntent);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
runtimeStatus.postDelayed(() -> {
|
||||
if (isSystemVpnActive()) {
|
||||
runtimePrefs.edit()
|
||||
.putString("state", "stopped")
|
||||
.putString("message", "force stop app process after VPN stop request")
|
||||
.putLong("updated_at", System.currentTimeMillis())
|
||||
.apply();
|
||||
android.os.Process.killProcess(android.os.Process.myPid());
|
||||
}
|
||||
}, 1800);
|
||||
}
|
||||
}, 1200);
|
||||
});
|
||||
|
||||
Button settings = new Button(this);
|
||||
settings.setText("Настройки");
|
||||
settings.setOnClickListener(v -> showSettingsDialog());
|
||||
|
||||
Button servers = new Button(this);
|
||||
servers.setText("Открыть удаленный сервер");
|
||||
servers.setOnClickListener(v -> showServerPicker());
|
||||
|
||||
root.addView(title);
|
||||
root.addView(profileSummary);
|
||||
root.addView(load);
|
||||
root.addView(servers);
|
||||
root.addView(start);
|
||||
root.addView(stop);
|
||||
root.addView(settings);
|
||||
root.addView(status);
|
||||
root.addView(runtimeStatus);
|
||||
setContentView(root);
|
||||
scheduleRuntimeStatusRefresh();
|
||||
if (authContext != null && !authContext.deviceId.isEmpty()) {
|
||||
startDiagnosticChannel();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private EditText field(String hint, String value) {
|
||||
EditText input = new EditText(this);
|
||||
input.setHint(hint);
|
||||
input.setText(value);
|
||||
input.setSingleLine(true);
|
||||
return input;
|
||||
}
|
||||
|
||||
private void loadProfile() {
|
||||
loadProfile(false);
|
||||
}
|
||||
|
||||
private void loadProfile(boolean startAfterLoad) {
|
||||
status.setText("Загрузка...");
|
||||
saveSettings();
|
||||
new Thread(() -> {
|
||||
try {
|
||||
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
|
||||
authContext = authenticate(client);
|
||||
String activeOrganizationId = resolveOrganizationId(client, authContext.userId);
|
||||
String requestedExitNodeId = selectedExitNodeId();
|
||||
profileJson = client.vpnClientProfile(
|
||||
clusterId.getText().toString(),
|
||||
activeOrganizationId,
|
||||
authContext.userId,
|
||||
requestedExitNodeId
|
||||
);
|
||||
vpnConnectionId = firstConnectionId(profileJson);
|
||||
saveProfileState();
|
||||
JSONObject resourcePayload = client.resources(activeOrganizationId, authContext.userId);
|
||||
lastResources = resourcePayload.optJSONArray("resources");
|
||||
if (lastResources == null) {
|
||||
lastResources = new JSONArray();
|
||||
}
|
||||
String resourcesText = resourcesText(resourcePayload);
|
||||
runOnUiThread(() -> {
|
||||
profileSummary.setText(summaryText());
|
||||
serverDirectory.setText(resourcesText);
|
||||
status.setText(startAfterLoad ? "Профиль обновлен. Запускаю VPN..." : "Профиль и ключи устройства обновлены.");
|
||||
startDiagnosticChannel();
|
||||
if (startAfterLoad) {
|
||||
requestVpnPermission();
|
||||
}
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
runOnUiThread(() -> {
|
||||
String message = friendlyError(ex);
|
||||
boolean canUseSavedProfile = startAfterLoad && !profileJson.isEmpty() && !vpnConnectionId.isEmpty();
|
||||
if (canUseSavedProfile) {
|
||||
status.setText("Профиль сейчас не обновился: " + message + ". Запускаю VPN с сохраненным рабочим профилем.");
|
||||
startDiagnosticChannel();
|
||||
requestVpnPermission();
|
||||
return;
|
||||
}
|
||||
status.setText("Ошибка профиля: " + message);
|
||||
if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) {
|
||||
clearSavedAuth(false);
|
||||
showSettingsDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void prepareVpn() {
|
||||
loadProfile(true);
|
||||
status.setText("Обновляю сессию устройства и VPN-профиль...");
|
||||
}
|
||||
|
||||
private void requestVpnPermission() {
|
||||
if (profileJson.isEmpty()) {
|
||||
status.setText("VPN-профиль не загружен.");
|
||||
return;
|
||||
}
|
||||
Intent prepare = VpnService.prepare(this);
|
||||
if (prepare != null) {
|
||||
startActivityForResult(prepare, VPN_PREPARE_REQUEST);
|
||||
return;
|
||||
}
|
||||
startVpn();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == VPN_PREPARE_REQUEST && resultCode == RESULT_OK) {
|
||||
startVpn();
|
||||
}
|
||||
}
|
||||
|
||||
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 + ". Backend: " + backendUrl.getText() + ". Connection: " + vpnConnectionId + ". Ожидаю статус подключения.");
|
||||
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 еще применяет туннель, ожидаю рабочий канал.");
|
||||
} else {
|
||||
status.setText("VPN еще не активен в Android. Проверьте системный запрос разрешения VPN.");
|
||||
}
|
||||
} else {
|
||||
status.setText("VPN включен Android. Версия " + APP_VERSION + ".");
|
||||
}
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
private void scheduleRuntimeStatusRefresh() {
|
||||
runtimeStatus.postDelayed(() -> {
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
scheduleRuntimeStatusRefresh();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
private String runtimeStatusText() {
|
||||
String state = runtimePrefs.getString("state", "нет данных");
|
||||
String message = runtimePrefs.getString("message", "");
|
||||
long updatedAt = runtimePrefs.getLong("updated_at", 0);
|
||||
long read = runtimePrefs.getLong("uplink_read", 0);
|
||||
long sent = runtimePrefs.getLong("uplink_sent", 0);
|
||||
long down = runtimePrefs.getLong("downlink_received", 0);
|
||||
long errors = runtimePrefs.getLong("errors", 0);
|
||||
long readBytes = runtimePrefs.getLong("uplink_read_bytes", 0);
|
||||
long sentBytes = runtimePrefs.getLong("uplink_sent_bytes", 0);
|
||||
long downBytes = runtimePrefs.getLong("downlink_received_bytes", 0);
|
||||
long droppedRead = runtimePrefs.getLong("uplink_dropped_packets", 0);
|
||||
long droppedDown = runtimePrefs.getLong("downlink_dropped_packets", 0);
|
||||
long bypassControl = runtimePrefs.getLong("uplink_bypassed_control_packets", 0);
|
||||
long sourceMismatch = runtimePrefs.getLong("uplink_source_mismatch_packets", 0);
|
||||
long destinationMismatch = runtimePrefs.getLong("downlink_destination_mismatch_packets", 0);
|
||||
float uplinkReadMbps = runtimePrefs.getFloat("uplink_read_mbps", 0f);
|
||||
float uplinkSentMbps = runtimePrefs.getFloat("uplink_sent_mbps", 0f);
|
||||
float downlinkMbps = runtimePrefs.getFloat("downlink_received_mbps", 0f);
|
||||
float uplinkReadPps = runtimePrefs.getFloat("uplink_read_pps", 0f);
|
||||
float uplinkSentPps = runtimePrefs.getFloat("uplink_sent_pps", 0f);
|
||||
float downlinkPps = runtimePrefs.getFloat("downlink_received_pps", 0f);
|
||||
int workerCount = runtimePrefs.getInt("uplink_worker_count", 0);
|
||||
int queueDepthTotal = runtimePrefs.getInt("uplink_queue_depth_total", 0);
|
||||
int queueDepthMax = runtimePrefs.getInt("uplink_queue_depth_max", 0);
|
||||
String queueDepths = runtimePrefs.getString("uplink_queue_depths", "");
|
||||
long queue0Drops = runtimePrefs.getLong("uplink_queue_0_drops", 0);
|
||||
long queue1Drops = runtimePrefs.getLong("uplink_queue_1_drops", 0);
|
||||
long queue2Drops = runtimePrefs.getLong("uplink_queue_2_drops", 0);
|
||||
long queue3Drops = runtimePrefs.getLong("uplink_queue_3_drops", 0);
|
||||
long queue0Offers = runtimePrefs.getLong("uplink_queue_0_offers", 0);
|
||||
long queue1Offers = runtimePrefs.getLong("uplink_queue_1_offers", 0);
|
||||
long queue2Offers = runtimePrefs.getLong("uplink_queue_2_offers", 0);
|
||||
long queue3Offers = runtimePrefs.getLong("uplink_queue_3_offers", 0);
|
||||
long sender0Packets = runtimePrefs.getLong("uplink_sender_worker_packets_0", 0);
|
||||
long sender1Packets = runtimePrefs.getLong("uplink_sender_worker_packets_1", 0);
|
||||
long sender2Packets = runtimePrefs.getLong("uplink_sender_worker_packets_2", 0);
|
||||
long sender3Packets = runtimePrefs.getLong("uplink_sender_worker_packets_3", 0);
|
||||
long sender0Errors = runtimePrefs.getLong("uplink_sender_worker_errors_0", 0);
|
||||
long sender1Errors = runtimePrefs.getLong("uplink_sender_worker_errors_1", 0);
|
||||
long sender2Errors = runtimePrefs.getLong("uplink_sender_worker_errors_2", 0);
|
||||
long sender3Errors = runtimePrefs.getLong("uplink_sender_worker_errors_3", 0);
|
||||
String age = updatedAt <= 0 ? "никогда" : ((System.currentTimeMillis() - updatedAt) / 1000) + " сек назад";
|
||||
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", "");
|
||||
boolean forceFullTunnelRuntime = false;
|
||||
boolean fastPathEnabled = false;
|
||||
try {
|
||||
forceFullTunnelRuntime = runtimePrefs.getBoolean("force_full_tunnel", false);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
fastPathEnabled = runtimePrefs.getBoolean("fast_path_enabled", forceFullTunnelRuntime);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
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))) {
|
||||
state = "stale_no_os_vpn";
|
||||
message = "Сервис говорит об активном состоянии, но Android VPN-интерфейс не активен. Проверьте разрешения/ручной запуск.";
|
||||
staleState = false;
|
||||
}
|
||||
return "Диагностика: " + state
|
||||
+ "\n" + message
|
||||
+ "\nOS VPN: " + (osVpnActive ? "активен" : (runtimeActive ? "runtime активен" : "неактивен"))
|
||||
+ "\n" + (staleState ? "статус устарел" : "статус актуален")
|
||||
+ "\nread/sent/down: " + read + "/" + sent + "/" + down
|
||||
+ "\nerrors/drops: " + errors + "/" + (droppedRead + droppedDown)
|
||||
+ "\ncontrol bypass: " + bypassControl
|
||||
+ "\naddress mismatch (up/down): " + sourceMismatch + " / " + destinationMismatch
|
||||
+ "\nthroughput Mbps: up " + String.format(Locale.US, "%.2f", uplinkSentMbps)
|
||||
+ " / down " + String.format(Locale.US, "%.2f", downlinkMbps)
|
||||
+ "\npps: up " + String.format(Locale.US, "%.1f", uplinkSentPps)
|
||||
+ " / 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)
|
||||
+ "\nforced_full_tunnel: " + (forceFullTunnelRuntime ? "да" : "нет")
|
||||
+ "\nfast_path_mode: " + (fastPathEnabled ? "включен" : "выключен")
|
||||
+ "\nbytes read/sent/down: " + readBytes + "/" + sentBytes + "/" + downBytes
|
||||
+ "\nworkers: " + workerCount
|
||||
+ "\nqueue depth total/max: " + queueDepthTotal + " / " + queueDepthMax
|
||||
+ "\nqueue depths: " + (queueDepths.isEmpty() ? "-" : queueDepths)
|
||||
+ "\nqueue0 q/s: " + queue0Offers + "/" + queue0Drops
|
||||
+ " q1 " + queue1Offers + "/" + queue1Drops
|
||||
+ " q2 " + queue2Offers + "/" + queue2Drops
|
||||
+ " q3 " + queue3Offers + "/" + queue3Drops
|
||||
+ "\nsender pkt/err: w0 " + sender0Packets + "/" + sender0Errors
|
||||
+ " w1 " + sender1Packets + "/" + sender1Errors
|
||||
+ " w2 " + sender2Packets + "/" + sender2Errors
|
||||
+ " w3 " + sender3Packets + "/" + sender3Errors
|
||||
+ "\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);
|
||||
if (connectivityManager == null) {
|
||||
return false;
|
||||
}
|
||||
Network[] networks = connectivityManager.getAllNetworks();
|
||||
if (networks != null) {
|
||||
for (Network network : networks) {
|
||||
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
|
||||
if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isVpnRuntimeActive() {
|
||||
String state = runtimePrefs.getString("state", "");
|
||||
if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) {
|
||||
return false;
|
||||
}
|
||||
long updatedAt = runtimePrefs.getLong("updated_at", 0);
|
||||
if (updatedAt <= 0 || (System.currentTimeMillis() - updatedAt) > 15_000) {
|
||||
return false;
|
||||
}
|
||||
String relay = runtimePrefs.getString("packet_relay_active_base_url", "");
|
||||
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)
|
||||
|| "downlink".equals(state)
|
||||
|| "downlink_idle".equals(state)
|
||||
|| "uplink_sent".equals(state)
|
||||
|| read > 0 || sent > 0 || down > 0);
|
||||
}
|
||||
|
||||
private String firstConnectionId(String profile) throws Exception {
|
||||
JSONObject root = new JSONObject(profile);
|
||||
JSONObject vpnProfile = root.getJSONObject("vpn_client_profile");
|
||||
JSONArray connections = vpnProfile.getJSONArray("connections");
|
||||
if (connections.length() == 0) {
|
||||
throw new IllegalStateException("VPN profile has no connections");
|
||||
}
|
||||
String fallback = null;
|
||||
String waiting = null;
|
||||
for (int i = 0; i < connections.length(); i++) {
|
||||
JSONObject connection = connections.optJSONObject(i);
|
||||
if (connection == null) {
|
||||
continue;
|
||||
}
|
||||
String id = connection.optString("id", "").trim();
|
||||
if (id.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (fallback == null) {
|
||||
fallback = id;
|
||||
}
|
||||
JSONObject clientConfig = connection.optJSONObject("client_config");
|
||||
if (clientConfig == null) {
|
||||
continue;
|
||||
}
|
||||
JSONObject fabricRoute = clientConfig.optJSONObject("vpn_fabric_route");
|
||||
if (fabricRoute == null) {
|
||||
continue;
|
||||
}
|
||||
String status = fabricRoute.optString("status", "").trim().toLowerCase();
|
||||
if ("planned".equals(status)) {
|
||||
String entry = fabricRoute.optString("selected_entry_node_id", "").trim();
|
||||
String exit = fabricRoute.optString("selected_exit_node_id", "").trim();
|
||||
if (!entry.isEmpty() && !exit.isEmpty()) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if (("connecting".equals(status) || "active".equals(status) || "assigned".equals(status)) && waiting == null) {
|
||||
waiting = id;
|
||||
}
|
||||
}
|
||||
if (waiting != null) {
|
||||
return waiting;
|
||||
}
|
||||
if (fallback != null) {
|
||||
return fallback;
|
||||
}
|
||||
return connections.getJSONObject(0).getString("id");
|
||||
}
|
||||
|
||||
private String resourcesText(JSONObject payload) throws Exception {
|
||||
JSONArray resources = payload.optJSONArray("resources");
|
||||
if (resources == null || resources.length() == 0) {
|
||||
return "Серверы: доступных ресурсов нет.";
|
||||
}
|
||||
StringBuilder text = new StringBuilder("Серверы:\n");
|
||||
int limit = Math.min(resources.length(), 6);
|
||||
for (int i = 0; i < limit; i++) {
|
||||
JSONObject resource = resources.getJSONObject(i);
|
||||
text.append("• ")
|
||||
.append(resource.optString("name", "server"))
|
||||
.append(" ")
|
||||
.append(resource.optString("protocol", "rdp"))
|
||||
.append(" ")
|
||||
.append(resource.optString("address", ""))
|
||||
.append('\n');
|
||||
}
|
||||
if (resources.length() > limit) {
|
||||
text.append("и еще ").append(resources.length() - limit).append("...");
|
||||
}
|
||||
return text.toString().trim();
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return (int) (value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
|
||||
private String summaryText() {
|
||||
String deviceId = prefs == null ? "" : prefs.getString(PREF_DEVICE_ID, "");
|
||||
String connectionId = vpnConnectionId == null || vpnConnectionId.isEmpty()
|
||||
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
|
||||
: vpnConnectionId;
|
||||
String backendText = backendUrl == null ? "" : backendUrl.getText().toString().trim();
|
||||
String clusterText = clusterId == null ? "" : clusterId.getText().toString().trim();
|
||||
String organizationText = organizationId == null ? "" : organizationId.getText().toString().trim();
|
||||
String exitNode = selectedExitNodeId();
|
||||
String profileDNS = profileDNSServersText();
|
||||
return "Версия: " + APP_VERSION
|
||||
+ "\nКластер: " + (clusterText.isEmpty() ? "не задан" : clusterText)
|
||||
+ "\nОрганизация: " + (organizationText.isEmpty() ? "не задана" : organizationText)
|
||||
+ "\nТочка входа: автоматическая (из настроек кластера)"
|
||||
+ "\nТочка выхода: " + (exitNode.isEmpty() ? "не выбрана (по умолчанию)" : exitNode)
|
||||
+ "\nDNS выхода: " + (profileDNS.isEmpty() ? "будет получен из профиля" : profileDNS)
|
||||
+ "\nBackend: " + (backendText.isEmpty() ? "не задан" : backendText)
|
||||
+ "\nТрафик: " + (prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true) ? "весь через VPN" : "по профилю")
|
||||
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
|
||||
+ "\nConnection: " + (connectionId.isEmpty() ? "нет" : connectionId);
|
||||
}
|
||||
|
||||
private String profileDNSServersText() {
|
||||
if (profileJson == null || profileJson.trim().isEmpty()) {
|
||||
return runtimePrefs == null ? "" : runtimePrefs.getString("dns_servers", "");
|
||||
}
|
||||
try {
|
||||
JSONObject root = new JSONObject(profileJson);
|
||||
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
|
||||
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
|
||||
if (connections == null || connections.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
String preferredConnection = vpnConnectionId == null || vpnConnectionId.isEmpty()
|
||||
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
|
||||
: vpnConnectionId;
|
||||
JSONObject selected = null;
|
||||
for (int i = 0; i < connections.length(); i++) {
|
||||
JSONObject candidate = connections.optJSONObject(i);
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
if (!preferredConnection.isEmpty() && preferredConnection.equals(candidate.optString("id", ""))) {
|
||||
selected = candidate;
|
||||
break;
|
||||
}
|
||||
if (selected == null) {
|
||||
selected = candidate;
|
||||
}
|
||||
}
|
||||
JSONObject clientConfig = selected == null ? null : selected.optJSONObject("client_config");
|
||||
JSONArray dns = clientConfig == null ? null : clientConfig.optJSONArray("dns_servers");
|
||||
return joinJSONArray(dns);
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String joinJSONArray(JSONArray values) {
|
||||
if (values == null || values.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (int i = 0; i < values.length(); i++) {
|
||||
String value = values.optString(i, "").trim();
|
||||
if (value.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (out.length() > 0) {
|
||||
out.append(",");
|
||||
}
|
||||
out.append(value);
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (organizationId.getText().toString().trim().isEmpty()) {
|
||||
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())
|
||||
.apply();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (organizationId.getText().toString().trim().isEmpty()) {
|
||||
organizationId.setText(DEFAULT_ORGANIZATION_ID);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeBackendUrl(String value) {
|
||||
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
|
||||
if (candidate.isEmpty()) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String selectedExitNodeId() {
|
||||
String configured = prefs == null ? "" : prefs.getString(PREF_SELECTED_EXIT_NODE_ID, "");
|
||||
return normalizeSelectedExitNodeId(configured);
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
|
||||
private RapApiClient.AuthContext authenticate(RapApiClient client) throws Exception {
|
||||
String savedRefresh = savedRefreshToken();
|
||||
if (!savedRefresh.isEmpty()) {
|
||||
try {
|
||||
RapApiClient.AuthContext refreshed = client.refresh(savedRefresh);
|
||||
saveAuthContext(refreshed);
|
||||
return refreshed;
|
||||
} catch (Exception ignored) {
|
||||
clearSavedAuth(false);
|
||||
}
|
||||
}
|
||||
String passwordValue = password.getText().toString().trim();
|
||||
if (passwordValue.isEmpty()) {
|
||||
throw new IllegalStateException("Сессия устройства истекла или отозвана. Введите пароль один раз, дальше ключи обновятся автоматически.");
|
||||
}
|
||||
RapApiClient.AuthContext loggedIn = client.login(email.getText().toString().trim(), passwordValue, deviceFingerprint());
|
||||
saveAuthContext(loggedIn);
|
||||
return loggedIn;
|
||||
}
|
||||
|
||||
private String resolveOrganizationId(RapApiClient client, String userId) throws Exception {
|
||||
JSONObject payload = client.organizations(userId);
|
||||
JSONArray organizations = payload.optJSONArray("organizations");
|
||||
if (organizations == null || organizations.length() == 0) {
|
||||
throw new IllegalStateException("У пользователя нет активной организации.");
|
||||
}
|
||||
String configured = organizationId.getText().toString().trim();
|
||||
JSONObject fallback = null;
|
||||
for (int i = 0; i < organizations.length(); i++) {
|
||||
JSONObject item = organizations.optJSONObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String id = item.optString("id", "");
|
||||
String name = item.optString("name", "");
|
||||
String slug = item.optString("slug", "");
|
||||
if (!configured.isEmpty() && configured.equals(id)) {
|
||||
return configured;
|
||||
}
|
||||
if (fallback == null || "HOME".equalsIgnoreCase(name) || "home".equalsIgnoreCase(slug)) {
|
||||
fallback = item;
|
||||
}
|
||||
}
|
||||
String selected = fallback != null ? fallback.optString("id", "") : "";
|
||||
if (selected.isEmpty()) {
|
||||
throw new IllegalStateException("Не удалось выбрать организацию пользователя.");
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
organizationId.setText(selected);
|
||||
saveSettings();
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
private void saveAuthContext(RapApiClient.AuthContext context) throws Exception {
|
||||
secureTokens.put(PREF_REFRESH_TOKEN, context.refreshToken);
|
||||
prefs.edit()
|
||||
.putString(PREF_USER_ID, context.userId)
|
||||
.putString(PREF_DEVICE_ID, context.deviceId)
|
||||
.putString(PREF_REFRESH_EXPIRES_AT, context.refreshTokenExpiresAt)
|
||||
.putString(PREF_PROFILE_JSON, profileJson)
|
||||
.putString(PREF_VPN_CONNECTION_ID, vpnConnectionId)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void saveProfileState() {
|
||||
prefs.edit()
|
||||
.putString(PREF_PROFILE_JSON, profileJson)
|
||||
.putString(PREF_VPN_CONNECTION_ID, vpnConnectionId)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void restoreAuthContext() {
|
||||
String userId = prefs.getString(PREF_USER_ID, "");
|
||||
String deviceId = prefs.getString(PREF_DEVICE_ID, "");
|
||||
if (!userId.isEmpty() && !deviceId.isEmpty()) {
|
||||
authContext = new RapApiClient.AuthContext(
|
||||
userId,
|
||||
deviceId,
|
||||
"",
|
||||
"",
|
||||
secureTokens.get(PREF_REFRESH_TOKEN),
|
||||
prefs.getString(PREF_REFRESH_EXPIRES_AT, ""));
|
||||
}
|
||||
}
|
||||
|
||||
private void clearSavedAuth(boolean clearProfile) {
|
||||
secureTokens.remove(PREF_REFRESH_TOKEN);
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.remove(PREF_REFRESH_EXPIRES_AT)
|
||||
.remove(PREF_USER_ID)
|
||||
.remove(PREF_DEVICE_ID);
|
||||
if (clearProfile) {
|
||||
editor.remove(PREF_PROFILE_JSON).remove(PREF_VPN_CONNECTION_ID);
|
||||
profileJson = "";
|
||||
vpnConnectionId = "";
|
||||
}
|
||||
editor.apply();
|
||||
authContext = null;
|
||||
}
|
||||
|
||||
private String savedRefreshToken() {
|
||||
String token = secureTokens.get(PREF_REFRESH_TOKEN);
|
||||
if (!token.isEmpty()) {
|
||||
return token;
|
||||
}
|
||||
String legacyToken = prefs.getString(PREF_REFRESH_TOKEN, "");
|
||||
if (!legacyToken.isEmpty()) {
|
||||
try {
|
||||
secureTokens.put(PREF_REFRESH_TOKEN, legacyToken);
|
||||
prefs.edit().remove(PREF_REFRESH_TOKEN).apply();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return legacyToken;
|
||||
}
|
||||
|
||||
private String deviceFingerprint() {
|
||||
String existing = prefs.getString(PREF_DEVICE_FINGERPRINT, "");
|
||||
if (!existing.isEmpty()) {
|
||||
return existing;
|
||||
}
|
||||
String generated = "android-" + java.util.UUID.randomUUID();
|
||||
prefs.edit().putString(PREF_DEVICE_FINGERPRINT, generated).apply();
|
||||
return generated;
|
||||
}
|
||||
|
||||
private void showSettingsDialog() {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
int pad = dp(12);
|
||||
form.setPadding(pad, pad, pad, pad);
|
||||
EditText backendDraft = field("Backend URL", backendUrl.getText().toString());
|
||||
EditText clusterDraft = field("Cluster ID", clusterId.getText().toString());
|
||||
EditText organizationDraft = field("Organization ID", organizationId.getText().toString());
|
||||
EditText emailDraft = field("Email", email.getText().toString());
|
||||
EditText passwordDraft = field("Password", password.getText().toString());
|
||||
passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
passwordDraft.setHint("Password (не сохраняется)");
|
||||
EditText selectedExitDraft = field(
|
||||
"Точка выхода (Node ID, например ifcm)",
|
||||
prefs.getString(PREF_SELECTED_EXIT_NODE_ID, ""));
|
||||
CheckBox showPassword = new CheckBox(this);
|
||||
showPassword.setText("Показать пароль");
|
||||
showPassword.setTextColor(0xff111111);
|
||||
showPassword.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | (isChecked
|
||||
? InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||
: InputType.TYPE_TEXT_VARIATION_PASSWORD));
|
||||
passwordDraft.setSelection(passwordDraft.getText().length());
|
||||
});
|
||||
CheckBox forceFullTunnel = new CheckBox(this);
|
||||
forceFullTunnel.setText("Полный маршрут через VPN");
|
||||
forceFullTunnel.setChecked(prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true));
|
||||
forceFullTunnel.setTextColor(0xff111111);
|
||||
form.addView(backendDraft);
|
||||
form.addView(clusterDraft);
|
||||
form.addView(organizationDraft);
|
||||
form.addView(emailDraft);
|
||||
form.addView(passwordDraft);
|
||||
form.addView(selectedExitDraft);
|
||||
form.addView(showPassword);
|
||||
form.addView(forceFullTunnel);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Настройки подключения")
|
||||
.setView(form)
|
||||
.setPositiveButton("Сохранить", (dialog, which) -> {
|
||||
backendUrl.setText(backendDraft.getText().toString());
|
||||
clusterId.setText(clusterDraft.getText().toString());
|
||||
organizationId.setText(organizationDraft.getText().toString());
|
||||
email.setText(emailDraft.getText().toString());
|
||||
password.setText(passwordDraft.getText().toString());
|
||||
String normalizedExit = normalizeSelectedExitNodeId(selectedExitDraft.getText().toString());
|
||||
prefs.edit()
|
||||
.putString(PREF_SELECTED_EXIT_NODE_ID, normalizedExit)
|
||||
.apply();
|
||||
if (!normalizedExit.equals(selectedExitDraft.getText().toString().trim())) {
|
||||
status.setText("Точка выхода очищена: значение было не похоже на Node ID/alias.");
|
||||
}
|
||||
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, forceFullTunnel.isChecked()).apply();
|
||||
saveSettings();
|
||||
profileSummary.setText(summaryText());
|
||||
})
|
||||
.setNeutralButton("Забыть устройство", (dialog, which) -> {
|
||||
clearSavedAuth(true);
|
||||
status.setText("Устройство забыто. Для следующего входа нужен пароль.");
|
||||
})
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private String friendlyError(Exception ex) {
|
||||
String message = ex.getMessage();
|
||||
if (message == null || message.trim().isEmpty()) {
|
||||
return "неизвестная ошибка";
|
||||
}
|
||||
if (message.contains("auth.invalid_credentials") || message.contains("Неверный логин")) {
|
||||
int passwordLength = password.getText() == null ? 0 : password.getText().toString().length();
|
||||
return "Неверный логин или пароль. Проверьте раскладку и спецсимволы. Длина введенного пароля: " + passwordLength + ".";
|
||||
}
|
||||
if (message.contains("auth.invalid_refresh_token") || message.contains("invalid refresh token")) {
|
||||
return "Сессия устройства истекла. Введите пароль один раз, дальше ключи обновятся автоматически.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
private void showServerPicker() {
|
||||
if (lastResources.length() == 0) {
|
||||
loadProfile();
|
||||
status.setText("Загружаю список серверов...");
|
||||
return;
|
||||
}
|
||||
String[] labels = new String[lastResources.length()];
|
||||
for (int i = 0; i < lastResources.length(); i++) {
|
||||
JSONObject resource = lastResources.optJSONObject(i);
|
||||
labels[i] = resource == null
|
||||
? "server"
|
||||
: resource.optString("name", "server") + " " + resource.optString("address", "");
|
||||
}
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Удаленный сервер")
|
||||
.setItems(labels, (dialog, which) -> startRemoteDesktop(which))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void startRemoteDesktop(int index) {
|
||||
JSONObject resource = lastResources.optJSONObject(index);
|
||||
if (resource == null) {
|
||||
return;
|
||||
}
|
||||
if (authContext == null || authContext.userId.isEmpty() || authContext.deviceId.isEmpty()) {
|
||||
loadProfile();
|
||||
status.setText("Профиль обновляется. Повторите открытие сервера.");
|
||||
return;
|
||||
}
|
||||
status.setText("Открываю " + resource.optString("name", "сервер") + "...");
|
||||
new Thread(() -> {
|
||||
try {
|
||||
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
|
||||
JSONObject result = client.startSession(resource.getString("id"), authContext.userId, authContext.deviceId);
|
||||
Intent intent = new Intent(this, RdpActivity.class);
|
||||
intent.putExtra(RdpActivity.EXTRA_SESSION_RESULT, result.toString());
|
||||
intent.putExtra(RdpActivity.EXTRA_GATEWAY_URL, gatewayUrl());
|
||||
intent.putExtra(RdpActivity.EXTRA_RESOURCE_NAME, resource.optString("name", "Remote Desktop"));
|
||||
runOnUiThread(() -> {
|
||||
status.setText("Сессия создана.");
|
||||
startActivity(intent);
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
runOnUiThread(() -> status.setText("Ошибка RDP: " + ex.getMessage()));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private String gatewayUrl() {
|
||||
String api = backendUrl.getText().toString().trim();
|
||||
String gateway = api.replace("https://", "wss://").replace("http://", "ws://");
|
||||
if (gateway.endsWith("/")) {
|
||||
gateway = gateway.substring(0, gateway.length() - 1);
|
||||
}
|
||||
return gateway + "/gateway/ws";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
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;
|
||||
import okhttp3.Dispatcher;
|
||||
import okhttp3.ConnectionPool;
|
||||
import okhttp3.Dns;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
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;
|
||||
|
||||
RapApiClient(String baseUrl) {
|
||||
this(baseUrl, (Context) null);
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, Context context) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
this.fabricServiceChannel = new FabricServiceChannel();
|
||||
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
|
||||
// Network with EACCES, which must not block login/profile refresh.
|
||||
this.networkMode = context == null ? "default_network" : "default_network_context";
|
||||
builder.dns(new BackendPinnedDns(baseUrl));
|
||||
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();
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, Context context, boolean preferUnderlyingNetwork) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
this.fabricServiceChannel = new FabricServiceChannel();
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
String mode = context == null ? "default_network" : "default_network_context";
|
||||
if (preferUnderlyingNetwork && context != null) {
|
||||
SocketFactory socketFactory = underlyingSocketFactory(context);
|
||||
if (socketFactory != null) {
|
||||
builder.socketFactory(socketFactory);
|
||||
mode = "underlying_network_context";
|
||||
}
|
||||
}
|
||||
this.networkMode = mode;
|
||||
builder.dns(new BackendPinnedDns(baseUrl));
|
||||
builder.connectTimeout(3, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(6, TimeUnit.SECONDS);
|
||||
builder.readTimeout(6, TimeUnit.SECONDS);
|
||||
builder.callTimeout(8, 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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
String parsedHost = "";
|
||||
try {
|
||||
parsedHost = URI.create(baseUrl == null ? "" : baseUrl).getHost();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
backendHost = parsedHost == null ? "" : parsedHost.trim().toLowerCase();
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
private SocketFactory underlyingSocketFactory(Context context) {
|
||||
ConnectivityManager connectivity = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
if (connectivity == null) {
|
||||
return null;
|
||||
}
|
||||
for (Network network : connectivity.getAllNetworks()) {
|
||||
NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network);
|
||||
if (capabilities == null) {
|
||||
continue;
|
||||
}
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
continue;
|
||||
}
|
||||
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||
continue;
|
||||
}
|
||||
return network.getSocketFactory();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
AuthContext login(String email, String password, String deviceFingerprint) throws Exception {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("email", email);
|
||||
body.put("password", password);
|
||||
body.put("device_fingerprint", deviceFingerprint);
|
||||
body.put("device_label", "RAP Android VPN");
|
||||
body.put("trust_device", true);
|
||||
JSONObject response = post("/auth/login", body);
|
||||
return parseAuthContext(response);
|
||||
}
|
||||
|
||||
AuthContext refresh(String refreshToken) throws Exception {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("refresh_token", refreshToken);
|
||||
return parseAuthContext(post("/auth/refresh", body));
|
||||
}
|
||||
|
||||
String vpnClientProfile(String clusterId, String organizationId, String userId, String exitNodeId) throws Exception {
|
||||
String path = "/clusters/" + clusterId + "/vpn/client-profile?organization_id=" + organizationId + "&user_id=" + userId;
|
||||
if (exitNodeId != null && !exitNodeId.trim().isEmpty()) {
|
||||
path += "&exit_node_id=" + exitNodeId.trim();
|
||||
}
|
||||
return get(path).toString();
|
||||
}
|
||||
|
||||
JSONObject organizations(String userId) throws Exception {
|
||||
return get("/organizations/?user_id=" + userId);
|
||||
}
|
||||
|
||||
JSONObject resources(String organizationId, String userId) throws Exception {
|
||||
String path = "/resources/?organization_id=" + organizationId + "&user_id=" + userId;
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject get(String path) throws Exception {
|
||||
Request request = new Request.Builder().url(baseUrl + path).get().build();
|
||||
return read(request);
|
||||
}
|
||||
|
||||
private JSONObject post(String path, JSONObject body) throws Exception {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(body.toString().getBytes(StandardCharsets.UTF_8), JSON))
|
||||
.build();
|
||||
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("HTTP " + response.code());
|
||||
}
|
||||
ResponseBody body = response.body();
|
||||
return body == null ? new byte[0] : body.bytes();
|
||||
}
|
||||
}
|
||||
|
||||
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 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("HTTP " + response.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String clientPacketPath(String clusterId, String vpnConnectionId, String suffix) {
|
||||
String path = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, false);
|
||||
if (path.isEmpty()) {
|
||||
path = "/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets";
|
||||
}
|
||||
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();
|
||||
String text = body == null ? "" : body.string();
|
||||
if (!response.isSuccessful()) {
|
||||
if (response.code() == 401 && text.contains("auth.invalid_credentials")) {
|
||||
throw new IllegalStateException("Неверный логин или пароль.");
|
||||
}
|
||||
if (response.code() == 401 && text.contains("auth.invalid_refresh_token")) {
|
||||
throw new IllegalStateException("Сессия устройства истекла. Введите пароль один раз.");
|
||||
}
|
||||
throw new IllegalStateException("HTTP " + response.code() + ": " + text);
|
||||
}
|
||||
return new JSONObject(text);
|
||||
}
|
||||
}
|
||||
|
||||
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", "");
|
||||
if (userId.isEmpty()) {
|
||||
userId = user.optString("ID", "");
|
||||
}
|
||||
JSONObject device = response.optJSONObject("device");
|
||||
String deviceId = device != null ? device.optString("id", "") : "";
|
||||
if (deviceId.isEmpty() && device != null) {
|
||||
deviceId = device.optString("ID", "");
|
||||
}
|
||||
JSONObject tokens = response.optJSONObject("tokens");
|
||||
String accessToken = tokens != null ? tokens.optString("access_token", "") : "";
|
||||
String accessExpiresAt = tokens != null ? tokens.optString("access_token_expires_at", "") : "";
|
||||
String refreshToken = tokens != null ? tokens.optString("refresh_token", "") : "";
|
||||
String refreshExpiresAt = tokens != null ? tokens.optString("refresh_token_expires_at", "") : "";
|
||||
return new AuthContext(userId, deviceId, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt);
|
||||
}
|
||||
|
||||
private String trimRight(String value) {
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
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;
|
||||
final String accessToken;
|
||||
final String accessTokenExpiresAt;
|
||||
final String refreshToken;
|
||||
final String refreshTokenExpiresAt;
|
||||
|
||||
AuthContext(String userId, String deviceId, String accessToken, String accessTokenExpiresAt, String refreshToken, String refreshTokenExpiresAt) {
|
||||
this.userId = userId;
|
||||
this.deviceId = deviceId;
|
||||
this.accessToken = accessToken;
|
||||
this.accessTokenExpiresAt = accessTokenExpiresAt;
|
||||
this.refreshToken = refreshToken;
|
||||
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.VpnService;
|
||||
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";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (context == null || intent == null) {
|
||||
return;
|
||||
}
|
||||
String action = intent.getAction();
|
||||
if (!Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)
|
||||
&& !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;
|
||||
}
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
if (VpnService.prepare(context) != null) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
context.startForegroundService(service);
|
||||
} else {
|
||||
context.startService(service);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Bundle;
|
||||
import android.util.Base64;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
|
||||
public class RdpActivity extends Activity {
|
||||
static final String EXTRA_SESSION_RESULT = "session_result";
|
||||
static final String EXTRA_GATEWAY_URL = "gateway_url";
|
||||
static final String EXTRA_RESOURCE_NAME = "resource_name";
|
||||
|
||||
private final OkHttpClient http = new OkHttpClient();
|
||||
private ImageView desktop;
|
||||
private TextView overlay;
|
||||
private WebSocket webSocket;
|
||||
private int desktopWidth = 1;
|
||||
private int desktopHeight = 1;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
|
||||
|
||||
FrameLayout root = new FrameLayout(this);
|
||||
root.setBackgroundColor(0xff05090c);
|
||||
desktop = new ImageView(this);
|
||||
desktop.setScaleType(ImageView.ScaleType.FIT_CENTER);
|
||||
desktop.setBackgroundColor(0xff05090c);
|
||||
desktop.setOnTouchListener((view, event) -> {
|
||||
sendTouch(event);
|
||||
return true;
|
||||
});
|
||||
overlay = new TextView(this);
|
||||
overlay.setTextColor(0xffffffff);
|
||||
overlay.setTextSize(14);
|
||||
overlay.setBackgroundColor(0x66000000);
|
||||
overlay.setPadding(14, 10, 14, 10);
|
||||
overlay.setText("Подключение...");
|
||||
root.addView(desktop, new FrameLayout.LayoutParams(-1, -1));
|
||||
root.addView(overlay, new FrameLayout.LayoutParams(-2, -2));
|
||||
setContentView(root);
|
||||
connect();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (webSocket != null) {
|
||||
webSocket.close(1000, "activity closed");
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void connect() {
|
||||
try {
|
||||
JSONObject result = new JSONObject(getIntent().getStringExtra(EXTRA_SESSION_RESULT));
|
||||
JSONObject token = result.getJSONObject("attach_token");
|
||||
String attachToken = token.getString("token");
|
||||
String gatewayUrl = getIntent().getStringExtra(EXTRA_GATEWAY_URL);
|
||||
String url = gatewayUrl + "?attach_token=" + attachToken;
|
||||
runOnUiThread(() -> overlay.setText(getIntent().getStringExtra(EXTRA_RESOURCE_NAME)));
|
||||
Request request = new Request.Builder().url(url).build();
|
||||
webSocket = http.newWebSocket(request, new WebSocketListener() {
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket, Response response) {
|
||||
runOnUiThread(() -> overlay.setText("Подключено"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, String text) {
|
||||
handleEnvelope(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
||||
runOnUiThread(() -> overlay.setText("Ошибка: " + t.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket webSocket, int code, String reason) {
|
||||
runOnUiThread(() -> overlay.setText("Отключено"));
|
||||
}
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
overlay.setText("Ошибка запуска: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleEnvelope(String text) {
|
||||
try {
|
||||
JSONObject envelope = new JSONObject(text);
|
||||
String type = envelope.optString("type");
|
||||
if ("session.state".equals(type)) {
|
||||
JSONObject payload = envelope.optJSONObject("payload");
|
||||
String state = payload == null ? "" : payload.optString("state", "");
|
||||
if (!state.isEmpty() && !"active".equals(state)) {
|
||||
runOnUiThread(() -> overlay.setText("Сессия: " + state));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!"session.frame".equals(type)) {
|
||||
return;
|
||||
}
|
||||
JSONObject payload = envelope.optJSONObject("payload");
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
String frameData = payload.optString("frame_data", "");
|
||||
int width = payload.optInt("frame_width", payload.optInt("desktop_width", 0));
|
||||
int height = payload.optInt("frame_height", payload.optInt("desktop_height", 0));
|
||||
byte[] bytes = Base64.decode(frameData, Base64.DEFAULT);
|
||||
Bitmap bitmap = decodeFrame(bytes, width, height, payload.optString("frame_format", ""));
|
||||
if (bitmap != null) {
|
||||
desktopWidth = Math.max(1, width);
|
||||
desktopHeight = Math.max(1, height);
|
||||
runOnUiThread(() -> {
|
||||
desktop.setImageBitmap(bitmap);
|
||||
overlay.setText("");
|
||||
});
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
runOnUiThread(() -> overlay.setText("Кадр: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap decodeFrame(byte[] bytes, int width, int height, String format) {
|
||||
Bitmap compressed = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
if (compressed != null) {
|
||||
return compressed;
|
||||
}
|
||||
if (width <= 0 || height <= 0 || bytes.length < width * height * 4) {
|
||||
return null;
|
||||
}
|
||||
int[] colors = new int[width * height];
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
for (int i = 0; i < colors.length; i++) {
|
||||
int b = buffer.get() & 0xff;
|
||||
int g = buffer.get() & 0xff;
|
||||
int r = buffer.get() & 0xff;
|
||||
int a = buffer.get() & 0xff;
|
||||
colors[i] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
return Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888);
|
||||
}
|
||||
|
||||
private void sendTouch(MotionEvent event) {
|
||||
if (webSocket == null || desktop.getWidth() <= 0 || desktop.getHeight() <= 0) {
|
||||
return;
|
||||
}
|
||||
String action;
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
action = "down";
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
action = "up";
|
||||
break;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
action = "move";
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
double x = Math.max(0, Math.min(1, event.getX() / Math.max(1f, desktop.getWidth())));
|
||||
double y = Math.max(0, Math.min(1, event.getY() / Math.max(1f, desktop.getHeight())));
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("correlation_id", UUID.randomUUID().toString());
|
||||
payload.put("client_captured_at", java.time.Instant.now().toString());
|
||||
payload.put("kind", "mouse");
|
||||
payload.put("action", action);
|
||||
payload.put("button", "left");
|
||||
payload.put("normalized_x", x);
|
||||
payload.put("normalized_y", y);
|
||||
payload.put("surface_width", desktopWidth);
|
||||
payload.put("surface_height", desktopHeight);
|
||||
JSONObject envelope = new JSONObject();
|
||||
envelope.put("type", "input");
|
||||
envelope.put("payload", payload);
|
||||
webSocket.send(envelope.toString().getBytes(StandardCharsets.UTF_8).length > 0 ? envelope.toString() : "{}");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
|
||||
final class SecureTokenStore {
|
||||
private static final String PREFS = "rap-vpn-secure";
|
||||
private static final String KEY_ALIAS = "rap-vpn-refresh-token";
|
||||
private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
|
||||
private static final int IV_LENGTH = 12;
|
||||
private static final int TAG_LENGTH_BITS = 128;
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
|
||||
SecureTokenStore(Context context) {
|
||||
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
void put(String name, String value) throws Exception {
|
||||
if (value == null || value.isEmpty()) {
|
||||
remove(name);
|
||||
return;
|
||||
}
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key());
|
||||
byte[] ciphertext = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] iv = cipher.getIV();
|
||||
if (iv == null || iv.length == 0) {
|
||||
throw new IllegalStateException("Android Keystore did not provide encryption IV");
|
||||
}
|
||||
byte[] payload = new byte[iv.length + ciphertext.length];
|
||||
System.arraycopy(iv, 0, payload, 0, iv.length);
|
||||
System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length);
|
||||
prefs.edit().putString(name, Base64.encodeToString(payload, Base64.NO_WRAP)).apply();
|
||||
}
|
||||
|
||||
String get(String name) {
|
||||
String encoded = prefs.getString(name, "");
|
||||
if (encoded.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
byte[] payload = Base64.decode(encoded, Base64.NO_WRAP);
|
||||
if (payload.length <= IV_LENGTH) {
|
||||
return "";
|
||||
}
|
||||
byte[] iv = Arrays.copyOfRange(payload, 0, IV_LENGTH);
|
||||
byte[] ciphertext = Arrays.copyOfRange(payload, IV_LENGTH, payload.length);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
void remove(String name) {
|
||||
prefs.edit().remove(name).apply();
|
||||
}
|
||||
|
||||
private SecretKey key() throws Exception {
|
||||
KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE);
|
||||
keyStore.load(null);
|
||||
KeyStore.Entry entry = keyStore.getEntry(KEY_ALIAS, null);
|
||||
if (entry instanceof KeyStore.SecretKeyEntry) {
|
||||
return ((KeyStore.SecretKeyEntry) entry).getSecretKey();
|
||||
}
|
||||
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);
|
||||
generator.init(new KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build());
|
||||
return generator.generateKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.Gravity;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
public class TestTrafficActivity extends Activity {
|
||||
static final String PREFS = "rap-vpn-browser-test";
|
||||
static final String EXTRA_URL = "url";
|
||||
|
||||
private TextView status;
|
||||
private WebView webView;
|
||||
private String target;
|
||||
private int assetErrorCount;
|
||||
private int mainErrorCount;
|
||||
private int httpErrorCount;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
LinearLayout layout = new LinearLayout(this);
|
||||
layout.setOrientation(LinearLayout.VERTICAL);
|
||||
layout.setBackgroundColor(Color.WHITE);
|
||||
status = new TextView(this);
|
||||
status.setTextColor(Color.rgb(20, 30, 40));
|
||||
status.setTextSize(14);
|
||||
status.setGravity(Gravity.START);
|
||||
status.setPadding(18, 14, 18, 14);
|
||||
layout.addView(status, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
webView = new WebView(this);
|
||||
layout.addView(webView, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f));
|
||||
setContentView(layout);
|
||||
String url = getIntent().getStringExtra(EXTRA_URL);
|
||||
if (url == null || url.isEmpty()) {
|
||||
url = "http://192.168.200.61:18080/";
|
||||
}
|
||||
target = url;
|
||||
assetErrorCount = 0;
|
||||
mainErrorCount = 0;
|
||||
httpErrorCount = 0;
|
||||
configureWebView();
|
||||
saveStatus("starting", "open " + target, 0, target, "");
|
||||
status.setText("Web test starting: " + target);
|
||||
webView.loadUrl(target);
|
||||
new Thread(() -> runRequest(target), "rap-test-traffic-http").start();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
try {
|
||||
saveStatus("destroyed", "activity destroyed", webView == null ? 0 : webView.getProgress(), target, "");
|
||||
if (webView != null) {
|
||||
webView.stopLoading();
|
||||
webView.destroy();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void configureWebView() {
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setLoadsImagesAutomatically(true);
|
||||
settings.setBlockNetworkLoads(false);
|
||||
settings.setCacheMode(WebSettings.LOAD_NO_CACHE);
|
||||
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
|
||||
}
|
||||
webView.setWebChromeClient(new WebChromeClient() {
|
||||
@Override
|
||||
public void onProgressChanged(WebView view, int newProgress) {
|
||||
String url = view == null ? target : view.getUrl();
|
||||
String title = view == null ? "" : view.getTitle();
|
||||
String message = "progress=" + newProgress + " title=" + safe(title);
|
||||
status.setText(message + "\n" + safe(url));
|
||||
saveStatus(newProgress >= 100 ? "progress_complete" : "loading", message, newProgress, url, "");
|
||||
if (newProgress >= 100) {
|
||||
scheduleDomProbe(1200);
|
||||
scheduleDomProbe(5000);
|
||||
scheduleDomProbe(12000);
|
||||
}
|
||||
}
|
||||
});
|
||||
webView.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) {
|
||||
assetErrorCount = 0;
|
||||
mainErrorCount = 0;
|
||||
httpErrorCount = 0;
|
||||
status.setText("started\n" + safe(url));
|
||||
saveStatus("started", "page started", 0, url, "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
int progress = Math.max(100, view == null ? 100 : view.getProgress());
|
||||
String title = view == null ? "" : view.getTitle();
|
||||
status.setText("finished progress=" + progress + "\n" + safe(title) + "\n" + safe(url));
|
||||
saveStatus("finished", "page finished title=" + safe(title), progress, url, "");
|
||||
scheduleDomProbe(1000);
|
||||
scheduleDomProbe(5000);
|
||||
scheduleDomProbe(12000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
String url = request == null || request.getUrl() == null ? "" : request.getUrl().toString();
|
||||
String description = error == null ? "unknown" : String.valueOf(error.getDescription());
|
||||
boolean mainFrame = request != null && request.isForMainFrame();
|
||||
if (mainFrame) {
|
||||
mainErrorCount++;
|
||||
} else {
|
||||
assetErrorCount++;
|
||||
}
|
||||
status.setText("error main=" + mainFrame + "\n" + description + "\n" + url);
|
||||
saveStatus(mainFrame ? "main_error" : "asset_error", description, view == null ? 0 : view.getProgress(), url, mainFrame ? "MAIN" : "ASSET");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
|
||||
String url = request == null || request.getUrl() == null ? "" : request.getUrl().toString();
|
||||
int code = errorResponse == null ? 0 : errorResponse.getStatusCode();
|
||||
boolean mainFrame = request != null && request.isForMainFrame();
|
||||
httpErrorCount++;
|
||||
saveStatus(mainFrame ? "main_http_error" : "asset_http_error", "HTTP " + code, view == null ? 0 : view.getProgress(), url, mainFrame ? "HTTP_MAIN" : "HTTP_ASSET");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void runRequest(String target) {
|
||||
String result;
|
||||
try {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection();
|
||||
connection.setConnectTimeout(30000);
|
||||
connection.setReadTimeout(30000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
result = "HTTP " + connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
} catch (Exception e) {
|
||||
result = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
String finalResult = result;
|
||||
saveHttpProbe(finalResult);
|
||||
runOnUiThread(() -> status.setText(status.getText() + "\nhttp_probe=" + finalResult));
|
||||
}
|
||||
|
||||
private void saveStatus(String state, String message, int progress, String url, String errorType) {
|
||||
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
|
||||
.putString("state", safe(state))
|
||||
.putString("message", safe(message))
|
||||
.putInt("progress", progress)
|
||||
.putString("url", safe(url))
|
||||
.putString("target_url", safe(target))
|
||||
.putString("error_type", safe(errorType))
|
||||
.putInt("asset_error_count", assetErrorCount)
|
||||
.putInt("main_error_count", mainErrorCount)
|
||||
.putInt("http_error_count", httpErrorCount)
|
||||
.putLong("updated_at", System.currentTimeMillis())
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void saveHttpProbe(String result) {
|
||||
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
|
||||
.putString("http_probe", safe(result))
|
||||
.putLong("http_probe_at", System.currentTimeMillis())
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void scheduleDomProbe(long delayMs) {
|
||||
handler.postDelayed(this::runDomProbe, Math.max(0, delayMs));
|
||||
}
|
||||
|
||||
private void runDomProbe() {
|
||||
if (webView == null) {
|
||||
return;
|
||||
}
|
||||
String script = "(function(){"
|
||||
+ "function txt(e){return ((e.innerText||e.textContent||e.value||e.getAttribute('aria-label')||'')+'').replace(/\\s+/g,' ').trim();}"
|
||||
+ "function vis(e){var r=e.getBoundingClientRect();var s=getComputedStyle(e);return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}"
|
||||
+ "var nodes=Array.prototype.slice.call(document.querySelectorAll('button,[role=button],input[type=button],input[type=submit],a'));"
|
||||
+ "var buttons=nodes.map(function(e){return {text:txt(e).slice(0,48),disabled:!!e.disabled||e.getAttribute('aria-disabled')==='true'||e.classList.contains('disabled'),visible:vis(e),tag:e.tagName,cls:(e.className||'').toString().slice(0,64)};}).filter(function(x){return x.text||/button/i.test(x.tag);}).slice(0,40);"
|
||||
+ "var start=buttons.filter(function(x){return /старт|start/i.test(x.text)||/start/i.test(x.cls);});"
|
||||
+ "var qms=(document.documentElement.innerHTML.match(/qms\\.ru/g)||[]).length;"
|
||||
+ "var out={readyState:document.readyState,title:document.title,scripts:document.scripts.length,buttons:buttons.length,start:start,qms:qms,url:location.href};"
|
||||
+ "return JSON.stringify(out);"
|
||||
+ "})()";
|
||||
try {
|
||||
webView.evaluateJavascript(script, value -> {
|
||||
String probe = safe(value);
|
||||
saveDomProbe(probe);
|
||||
status.setText(status.getText() + "\ndom_probe=" + probe);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
saveDomProbe(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveDomProbe(String result) {
|
||||
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
|
||||
.putString("dom_probe", safe(result))
|
||||
.putLong("dom_probe_at", System.currentTimeMillis())
|
||||
.apply();
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.VpnService;
|
||||
import android.os.Bundle;
|
||||
import android.util.Base64;
|
||||
import android.widget.TextView;
|
||||
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_CLUSTER_ID = "cluster_id";
|
||||
public static final String EXTRA_VPN_CONNECTION_ID = "vpn_connection_id";
|
||||
private static final int VPN_PREPARE_REQUEST = 77;
|
||||
|
||||
private Intent serviceIntent;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
TextView text = new TextView(this);
|
||||
text.setText("RAP VPN test launcher");
|
||||
setContentView(text);
|
||||
serviceIntent = buildServiceIntent(getIntent());
|
||||
Intent prepare = VpnService.prepare(this);
|
||||
if (prepare != null) {
|
||||
startActivityForResult(prepare, VPN_PREPARE_REQUEST);
|
||||
return;
|
||||
}
|
||||
startVpn();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == VPN_PREPARE_REQUEST && resultCode == RESULT_OK) {
|
||||
startVpn();
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
private String profileJson(Intent source) {
|
||||
String direct = source.getStringExtra(EXTRA_PROFILE_JSON);
|
||||
if (direct != null && !direct.isEmpty()) {
|
||||
return direct;
|
||||
}
|
||||
String encoded = source.getStringExtra(EXTRA_PROFILE_BASE64);
|
||||
if (encoded == null || encoded.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
byte[] raw = Base64.decode(encoded, Base64.DEFAULT);
|
||||
return new String(raw, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void startVpn() {
|
||||
startForegroundService(serviceIntent);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
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 final String baseUrl;
|
||||
private final VpnService vpnService;
|
||||
private final OkHttpClient httpClient;
|
||||
private final FabricServiceChannel fabricServiceChannel;
|
||||
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 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)) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
Request.Builder requestBuilder = new Request.Builder().url(wsUrl);
|
||||
this.fabricServiceChannel.applyHeaders(requestBuilder);
|
||||
Request request = requestBuilder.build();
|
||||
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);
|
||||
WebSocket socket = webSocket;
|
||||
if (socket == null || !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";
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
|
||||
List<byte[]> receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws InterruptedException {
|
||||
connect(clusterId, vpnConnectionId);
|
||||
int waitMs = Math.max(1, timeoutMs);
|
||||
List<byte[]> packets = incoming.poll(waitMs, TimeUnit.MILLISECONDS);
|
||||
return packets == null ? new ArrayList<>() : packets;
|
||||
}
|
||||
|
||||
void close() {
|
||||
synchronized (lock) {
|
||||
closeLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeLocked() {
|
||||
open = false;
|
||||
connecting = false;
|
||||
incoming.clear();
|
||||
if (webSocket != null) {
|
||||
try {
|
||||
webSocket.close(1000, "relay switch");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
webSocket = null;
|
||||
}
|
||||
|
||||
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) {
|
||||
open = true;
|
||||
connecting = false;
|
||||
reconnectAfterMs = 0;
|
||||
lastError = "";
|
||||
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;
|
||||
}
|
||||
if (!incoming.offer(packets)) {
|
||||
incoming.poll();
|
||||
incoming.offer(packets);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket webSocket, int code, String reason) {
|
||||
open = false;
|
||||
connecting = false;
|
||||
reconnectAfterMs = System.currentTimeMillis() + 1000;
|
||||
lastError = "closed " + code + " " + reason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
||||
open = false;
|
||||
connecting = false;
|
||||
reconnectAfterMs = System.currentTimeMillis() + 3000;
|
||||
lastError = t == null ? "websocket failure" : t.getClass().getSimpleName() + ": " + t.getMessage();
|
||||
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 static String trimRight(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:colorAccent">#2f6f50</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id "com.android.application" version "9.2.0" apply false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
sdk.dir=C:/Android/sdk
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "RapAndroidVpn"
|
||||
include ":app"
|
||||
Reference in New Issue
Block a user