Record project continuation changes
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "su.cin.rapvpn"
|
||||
compileSdk 35
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "su.cin.rapvpn"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 64
|
||||
versionName "0.2.64"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<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.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>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,650 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.Intent;
|
||||
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 = "http://195.123.240.88:19131/api/v1";
|
||||
private static final String DEFAULT_ENTRY_NODE_ID = "b829ffde-690b-47ab-9522-0f22ab42596d";
|
||||
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";
|
||||
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", "cfc0743d-d960-49fb-9de8-96e063d5e4aa"));
|
||||
organizationId = field("Organization ID", prefs.getString("organization_id", "125ff8b2-5ac1-4406-9bbb-ebbe18f7c7ed"));
|
||||
email = field("Email", prefs.getString("email", "m"));
|
||||
password = field("Password", "");
|
||||
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
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);
|
||||
startService(stopIntent);
|
||||
status.setText("VPN остановлен.");
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
});
|
||||
|
||||
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);
|
||||
profileJson = client.vpnClientProfile(clusterId.getText().toString(), activeOrganizationId, authContext.userId, DEFAULT_ENTRY_NODE_ID);
|
||||
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);
|
||||
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());
|
||||
}
|
||||
|
||||
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);
|
||||
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) + " сек назад";
|
||||
return "Диагностика: " + state
|
||||
+ "\n" + message
|
||||
+ "\nread/sent/down: " + read + "/" + sent + "/" + down
|
||||
+ "\nerrors/drops: " + errors + "/" + (droppedRead + droppedDown)
|
||||
+ "\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)
|
||||
+ "\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 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");
|
||||
}
|
||||
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;
|
||||
return "Версия: " + APP_VERSION
|
||||
+ "\nВход: usa-los-1"
|
||||
+ "\nОрганизация: HOME"
|
||||
+ "\nВыход: home-1"
|
||||
+ "\nBackend: " + backendUrl.getText()
|
||||
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
|
||||
+ "\nConnection: " + (connectionId.isEmpty() ? "нет" : connectionId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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 String normalizeBackendUrl(String value) {
|
||||
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
|
||||
if (candidate.isEmpty() || isLegacyControlPlaneUrl(candidate)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private boolean isLegacyControlPlaneUrl(String value) {
|
||||
String lower = value.toLowerCase();
|
||||
return lower.equals("http://94.141.118.222:19191/api/v1")
|
||||
|| lower.equals("http://vpn.cin.su:19191/api/v1")
|
||||
|| lower.equals("http://192.168.200.61:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su/api/v1")
|
||||
|| lower.equals("http://192.168.200.61/api/v1");
|
||||
}
|
||||
|
||||
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 (не сохраняется)");
|
||||
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());
|
||||
});
|
||||
form.addView(backendDraft);
|
||||
form.addView(clusterDraft);
|
||||
form.addView(organizationDraft);
|
||||
form.addView(emailDraft);
|
||||
form.addView(passwordDraft);
|
||||
form.addView(showPassword);
|
||||
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());
|
||||
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,583 @@
|
||||
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.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.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 = 128;
|
||||
private static final int MAX_PACKET_BATCH_BYTES = 128 * 1024;
|
||||
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
|
||||
private static final int MAX_BATCH_HEADER_BYTES = 4;
|
||||
private static final int BATCH_RETRY_THRESHOLD = 2;
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient httpClient;
|
||||
private final String networkMode;
|
||||
private volatile boolean batchModeEnabled = true;
|
||||
private volatile int batchModeFailures = 0;
|
||||
|
||||
RapApiClient(String baseUrl) {
|
||||
this(baseUrl, null);
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, Context context) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
SocketFactory socketFactory = context == null ? null : underlyingSocketFactory(context);
|
||||
if (socketFactory != null) {
|
||||
builder.socketFactory(socketFactory);
|
||||
this.networkMode = "direct_network";
|
||||
} else {
|
||||
this.networkMode = "default_network";
|
||||
}
|
||||
builder.connectTimeout(10, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(45, TimeUnit.SECONDS);
|
||||
builder.readTimeout(45, TimeUnit.SECONDS);
|
||||
builder.callTimeout(50, 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 = trimRight(baseUrl);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
if (vpnService != null) {
|
||||
SocketFactory socketFactory = underlyingSocketFactory(vpnService);
|
||||
builder.socketFactory(socketFactory != null ? socketFactory : new ProtectedSocketFactory(vpnService));
|
||||
this.networkMode = socketFactory != null ? "direct_network" : "protected_socket";
|
||||
} else {
|
||||
this.networkMode = "default_network";
|
||||
}
|
||||
builder.connectTimeout(10, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(45, TimeUnit.SECONDS);
|
||||
builder.readTimeout(45, TimeUnit.SECONDS);
|
||||
builder.callTimeout(50, TimeUnit.SECONDS);
|
||||
builder.retryOnConnectionFailure(true);
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
dispatcher.setMaxRequests(64);
|
||||
dispatcher.setMaxRequestsPerHost(32);
|
||||
builder.dispatcher(dispatcher);
|
||||
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
|
||||
this.httpClient = builder.build();
|
||||
}
|
||||
|
||||
String networkMode() {
|
||||
return networkMode;
|
||||
}
|
||||
|
||||
private 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 entryNodeId) throws Exception {
|
||||
String path = "/clusters/" + clusterId + "/vpn/client-profile?organization_id=" + organizationId + "&user_id=" + userId;
|
||||
if (entryNodeId != null && !entryNodeId.trim().isEmpty()) {
|
||||
path += "&entry_node_id=" + entryNodeId.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("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets", packet, length);
|
||||
}
|
||||
|
||||
void sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) throws Exception {
|
||||
if (!batchModeEnabled) {
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packets == null || packets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<List<byte[]>> chunks = chunkPacketsForBatch(packets);
|
||||
if (chunks.isEmpty()) {
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (List<byte[]> chunk : chunks) {
|
||||
postBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?batch=true", encodePacketBatch(chunk));
|
||||
}
|
||||
resetBatchMode();
|
||||
} catch (Exception e) {
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] receiveClientPacket(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
|
||||
try {
|
||||
return getBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?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 {
|
||||
if (!batchModeEnabled) {
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
byte[] payload;
|
||||
try {
|
||||
payload = getBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?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<>();
|
||||
}
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
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<>();
|
||||
}
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
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 request = new Request.Builder().url(baseUrl + path).get().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 request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(bodyBytes, OCTET_STREAM))
|
||||
.build();
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalStateException("HTTP " + response.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 boolean shouldDisableBatchMode(Throwable error) {
|
||||
return error != null;
|
||||
}
|
||||
|
||||
private synchronized void disableBatchMode() {
|
||||
batchModeFailures++;
|
||||
if (batchModeFailures >= BATCH_RETRY_THRESHOLD) {
|
||||
batchModeEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void resetBatchMode() {
|
||||
batchModeFailures = 0;
|
||||
batchModeEnabled = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private 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 {
|
||||
return protect(delegate.createSocket());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException {
|
||||
Socket socket = protect(delegate.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 = protect(delegate.createSocket());
|
||||
socket.bind(new InetSocketAddress(localHost, localPort));
|
||||
socket.connect(new InetSocketAddress(host, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress host, int port) throws IOException {
|
||||
Socket socket = protect(delegate.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 = protect(delegate.createSocket());
|
||||
socket.bind(new InetSocketAddress(localAddress, localPort));
|
||||
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,708 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.Uri;
|
||||
import android.net.VpnService;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
public class RapDiagnosticService extends Service {
|
||||
static final String ACTION_START = "su.cin.rapvpn.DIAGNOSTIC_START";
|
||||
static final String ACTION_STOP = "su.cin.rapvpn.DIAGNOSTIC_STOP";
|
||||
private static final String CHANNEL_ID = "rap-vpn-diagnostics";
|
||||
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
|
||||
private static final String DEFAULT_BACKEND_URL = "http://195.123.240.88:19131/api/v1";
|
||||
private static final String DEFAULT_ENTRY_NODE_ID = "b829ffde-690b-47ab-9522-0f22ab42596d";
|
||||
private static final String PREFS = "rap-vpn";
|
||||
private static final String RUNTIME_PREFS = "rap-vpn-runtime";
|
||||
private static final String PREF_REFRESH_TOKEN = "refresh_token";
|
||||
private static final String PREF_USER_ID = "user_id";
|
||||
private static final String PREF_DEVICE_ID = "device_id";
|
||||
private static final String PREF_PROFILE_JSON = "profile_json";
|
||||
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
|
||||
private volatile boolean running;
|
||||
private Thread worker;
|
||||
private String serviceState = "";
|
||||
private String lastCommandType = "";
|
||||
private String lastCommandResult = "";
|
||||
private long lastCommandAt = 0;
|
||||
private long lastHeartbeatAt = 0;
|
||||
private long lastCommandPollAt = 0;
|
||||
private String controlNetworkMode = "";
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
running = false;
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
}
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
startForeground(1002, notification());
|
||||
startWorker();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
running = false;
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
static void start(android.content.Context context) {
|
||||
Intent intent = new Intent(context, RapDiagnosticService.class);
|
||||
intent.setAction(ACTION_START);
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
context.startForegroundService(intent);
|
||||
} else {
|
||||
context.startService(intent);
|
||||
}
|
||||
}
|
||||
|
||||
private void startWorker() {
|
||||
if (worker != null && worker.isAlive()) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
worker = new Thread(this::runLoop, "rap-vpn-diagnostic-service");
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
while (running) {
|
||||
try {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
String backendUrl = normalizeBackendUrl(prefs.getString("backend_url", ""));
|
||||
if (!backendUrl.equals(prefs.getString("backend_url", ""))) {
|
||||
prefs.edit().putString("backend_url", backendUrl).apply();
|
||||
}
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String deviceId = prefs.getString(PREF_DEVICE_ID, "");
|
||||
if (backendUrl.isEmpty() || clusterId.isEmpty() || deviceId.isEmpty()) {
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
RapApiClient client = new RapApiClient(backendUrl, this);
|
||||
controlNetworkMode = client.networkMode();
|
||||
lastHeartbeatAt = System.currentTimeMillis();
|
||||
serviceState = "online " + new SimpleDateFormat("HH:mm:ss").format(new Date());
|
||||
client.reportVPNDiagnosticStatus(clusterId, deviceId, statusPayload("heartbeat"));
|
||||
lastCommandPollAt = System.currentTimeMillis();
|
||||
JSONObject commandEnvelope = client.nextVPNDiagnosticCommand(clusterId, deviceId, 5000);
|
||||
if (commandEnvelope != null) {
|
||||
handleCommand(client, clusterId, deviceId, commandEnvelope);
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
serviceState = "error: " + e.getMessage();
|
||||
try {
|
||||
Thread.sleep(3000);
|
||||
} catch (InterruptedException interrupted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCommand(RapApiClient client, String clusterId, String deviceId, JSONObject envelope) throws Exception {
|
||||
JSONObject command = envelope.optJSONObject("vpn_client_diagnostic_command");
|
||||
JSONObject payload = command == null ? envelope.optJSONObject("payload") : command.optJSONObject("payload");
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
String type = payload.optString("type", "");
|
||||
String result;
|
||||
if ("start_vpn".equals(type)) {
|
||||
result = startVPNFromSavedProfile();
|
||||
} else if ("stop_vpn".equals(type)) {
|
||||
Intent stopIntent = new Intent(this, RapVpnService.class);
|
||||
stopIntent.setAction(RapVpnService.ACTION_STOP);
|
||||
startService(stopIntent);
|
||||
result = "stop_vpn accepted";
|
||||
} else if ("http_get".equals(type)) {
|
||||
result = runHttpGet(payload.optString("url", "http://192.168.200.61:18080/"));
|
||||
} else if ("vpn_http_get".equals(type)) {
|
||||
result = runVPNHttpGet(payload.optString("url", "http://192.168.200.61:18080/"));
|
||||
} else if ("vpn_dns_lookup".equals(type)) {
|
||||
result = runVPNDNSLookup(payload.optString("host", "2ip.ru"));
|
||||
} else if ("open_url".equals(type)) {
|
||||
String url = payload.optString("url", "http://2ip.ru/");
|
||||
Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(open);
|
||||
result = "open_url accepted " + url;
|
||||
} else if ("vpn_stats".equals(type)) {
|
||||
result = collectVPNStats(client, clusterId);
|
||||
} else if ("full_vpn_test".equals(type)) {
|
||||
result = runFullVPNTest(client, clusterId, payload);
|
||||
} else if ("refresh_profile".equals(type)) {
|
||||
result = refreshProfile();
|
||||
} else {
|
||||
result = "unknown command " + type;
|
||||
}
|
||||
lastCommandType = type;
|
||||
lastCommandResult = result;
|
||||
lastCommandAt = System.currentTimeMillis();
|
||||
JSONObject report = statusPayload("command_result");
|
||||
report.put("command_type", type);
|
||||
report.put("command_result", result);
|
||||
client.reportVPNDiagnosticStatus(clusterId, deviceId, report);
|
||||
}
|
||||
|
||||
private String startVPNFromSavedProfile() {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
String profileJson = prefs.getString(PREF_PROFILE_JSON, "");
|
||||
String backendUrl = prefs.getString("backend_url", "");
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
|
||||
if (profileJson.isEmpty() || backendUrl.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
|
||||
return "start_vpn skipped: profile/backend/cluster/connection missing";
|
||||
}
|
||||
if (VpnService.prepare(this) != null) {
|
||||
Intent launcher = new Intent(this, TestVpnActivity.class);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_PROFILE_JSON, profileJson);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_BACKEND_URL, backendUrl);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_CLUSTER_ID, clusterId);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
|
||||
launcher.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(launcher);
|
||||
return "start_vpn permission required: opened vpn launcher " + vpnConnectionId;
|
||||
}
|
||||
Intent intent = new Intent(this, RapVpnService.class);
|
||||
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson);
|
||||
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, backendUrl);
|
||||
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId);
|
||||
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
startForegroundService(intent);
|
||||
} else {
|
||||
startService(intent);
|
||||
}
|
||||
return "start_vpn accepted " + vpnConnectionId;
|
||||
}
|
||||
|
||||
private String refreshProfile() {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
try {
|
||||
String refreshToken = new SecureTokenStore(this).get(PREF_REFRESH_TOKEN);
|
||||
if (refreshToken.isEmpty()) {
|
||||
return "refresh_profile skipped: refresh token missing";
|
||||
}
|
||||
RapApiClient client = new RapApiClient(normalizeBackendUrl(prefs.getString("backend_url", "")), this);
|
||||
RapApiClient.AuthContext auth = client.refresh(refreshToken);
|
||||
String organizationId = prefs.getString("organization_id", "");
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String profileJson = client.vpnClientProfile(clusterId, organizationId, auth.userId, DEFAULT_ENTRY_NODE_ID);
|
||||
JSONObject root = new JSONObject(profileJson);
|
||||
JSONObject profile = root.getJSONObject("vpn_client_profile");
|
||||
String connectionId = profile.getJSONArray("connections").getJSONObject(0).getString("id");
|
||||
prefs.edit()
|
||||
.putString(PREF_USER_ID, auth.userId)
|
||||
.putString(PREF_DEVICE_ID, auth.deviceId)
|
||||
.putString(PREF_PROFILE_JSON, profileJson)
|
||||
.putString(PREF_VPN_CONNECTION_ID, connectionId)
|
||||
.apply();
|
||||
new SecureTokenStore(this).put(PREF_REFRESH_TOKEN, auth.refreshToken);
|
||||
return "refresh_profile ok " + connectionId;
|
||||
} catch (Exception e) {
|
||||
return "refresh_profile failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject statusPayload(String event) throws Exception {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("event", event);
|
||||
payload.put("app_version", APP_VERSION);
|
||||
payload.put("service", "diagnostic");
|
||||
payload.put("user_id", prefs.getString(PREF_USER_ID, ""));
|
||||
payload.put("device_id", prefs.getString(PREF_DEVICE_ID, ""));
|
||||
payload.put("organization_id", prefs.getString("organization_id", ""));
|
||||
payload.put("vpn_connection_id", prefs.getString(PREF_VPN_CONNECTION_ID, ""));
|
||||
payload.put("backend_url", prefs.getString("backend_url", ""));
|
||||
payload.put("control_network_mode", controlNetworkMode);
|
||||
payload.put("profile_loaded", !prefs.getString(PREF_PROFILE_JSON, "").isEmpty());
|
||||
payload.put("runtime", runtimeSnapshot());
|
||||
payload.put("vpn_config", vpnConfigSnapshot());
|
||||
payload.put("service_state", serviceState);
|
||||
payload.put("last_result", lastCommandResult);
|
||||
payload.put("last_command_type", lastCommandType);
|
||||
payload.put("last_command_result", lastCommandResult);
|
||||
payload.put("last_command_at", lastCommandAt);
|
||||
payload.put("last_heartbeat_at", lastHeartbeatAt);
|
||||
payload.put("last_command_poll_at", lastCommandPollAt);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String normalizeBackendUrl(String value) {
|
||||
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
|
||||
if (candidate.isEmpty() || isLegacyControlPlaneUrl(candidate)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private boolean isLegacyControlPlaneUrl(String value) {
|
||||
String lower = value.toLowerCase();
|
||||
return lower.equals("http://94.141.118.222:19191/api/v1")
|
||||
|| lower.equals("http://vpn.cin.su:19191/api/v1")
|
||||
|| lower.equals("http://192.168.200.61:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su/api/v1")
|
||||
|| lower.equals("http://192.168.200.61/api/v1");
|
||||
}
|
||||
|
||||
private String collectVPNStats(RapApiClient client, String clusterId) {
|
||||
String connectionId = getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, "");
|
||||
if (connectionId.isEmpty()) {
|
||||
return "vpn_stats skipped: connection missing";
|
||||
}
|
||||
try {
|
||||
JSONObject stats = client.vpnPacketStats(clusterId, connectionId);
|
||||
return "vpn_stats " + compact(stats.toString(), 900);
|
||||
} catch (Exception e) {
|
||||
return "vpn_stats failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String runFullVPNTest(RapApiClient client, String clusterId, JSONObject payload) {
|
||||
String url = payload.optString("url", "http://2ip.ru/");
|
||||
int watchSeconds = payload.optInt("watch_seconds", 30);
|
||||
if (watchSeconds < 5) {
|
||||
watchSeconds = 5;
|
||||
}
|
||||
if (watchSeconds > 120) {
|
||||
watchSeconds = 120;
|
||||
}
|
||||
String connectionId = getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, "");
|
||||
StringBuilder result = new StringBuilder();
|
||||
try {
|
||||
result.append(refreshProfile()).append(" | ");
|
||||
if (!connectionId.isEmpty()) {
|
||||
result.append("reset=").append(compact(client.resetVPNPacketQueues(clusterId, connectionId).toString(), 240)).append(" | ");
|
||||
}
|
||||
result.append(startVPNFromSavedProfile()).append(" | ");
|
||||
Thread.sleep(3000);
|
||||
Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(open);
|
||||
result.append("open_url=").append(url);
|
||||
long deadline = System.currentTimeMillis() + watchSeconds * 1000L;
|
||||
while (running && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(5000);
|
||||
JSONObject report = statusPayload("full_vpn_test_watch");
|
||||
report.put("test_url", url);
|
||||
if (!connectionId.isEmpty()) {
|
||||
report.put("packet_stats", client.vpnPacketStats(clusterId, connectionId));
|
||||
}
|
||||
client.reportVPNDiagnosticStatus(clusterId, getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_DEVICE_ID, ""), report);
|
||||
}
|
||||
if (!connectionId.isEmpty()) {
|
||||
result.append(" | stats=").append(compact(client.vpnPacketStats(clusterId, connectionId).toString(), 900));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.append(" | full_vpn_test failed: ").append(e.getClass().getSimpleName()).append(": ").append(e.getMessage());
|
||||
}
|
||||
return compact(result.toString(), 1200);
|
||||
}
|
||||
|
||||
private String compact(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String compacted = value.replace('\n', ' ').replace('\r', ' ');
|
||||
if (compacted.length() <= maxLength) {
|
||||
return compacted;
|
||||
}
|
||||
return compacted.substring(0, Math.max(0, maxLength - 3)) + "...";
|
||||
}
|
||||
|
||||
private JSONObject runtimeSnapshot() throws Exception {
|
||||
SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("state", runtime.getString("state", ""));
|
||||
payload.put("message", runtime.getString("message", ""));
|
||||
payload.put("updated_at", runtime.getLong("updated_at", 0));
|
||||
payload.put("runtime_started_at", runtime.getLong("runtime_started_at", 0));
|
||||
payload.put("uplink_read", runtime.getLong("uplink_read", 0));
|
||||
payload.put("uplink_sent", runtime.getLong("uplink_sent", 0));
|
||||
payload.put("downlink_received", runtime.getLong("downlink_received", 0));
|
||||
payload.put("uplink_read_total", runtime.getLong("uplink_read_total", 0));
|
||||
payload.put("uplink_read_bytes", runtime.getLong("uplink_read_bytes", 0));
|
||||
payload.put("uplink_sent_total", runtime.getLong("uplink_sent_total", 0));
|
||||
payload.put("uplink_sent_bytes", runtime.getLong("uplink_sent_bytes", 0));
|
||||
payload.put("downlink_received_total", runtime.getLong("downlink_received_total", 0));
|
||||
payload.put("downlink_received_bytes", runtime.getLong("downlink_received_bytes", 0));
|
||||
payload.put("uplink_read_mbps", runtime.getFloat("uplink_read_mbps", 0f));
|
||||
payload.put("uplink_sent_mbps", runtime.getFloat("uplink_sent_mbps", 0f));
|
||||
payload.put("downlink_received_mbps", runtime.getFloat("downlink_received_mbps", 0f));
|
||||
payload.put("uplink_read_pps", runtime.getFloat("uplink_read_pps", 0f));
|
||||
payload.put("uplink_sent_pps", runtime.getFloat("uplink_sent_pps", 0f));
|
||||
payload.put("downlink_received_pps", runtime.getFloat("downlink_received_pps", 0f));
|
||||
payload.put("uplink_dropped_packets", runtime.getLong("uplink_dropped_packets", 0));
|
||||
payload.put("uplink_dropped_bytes", runtime.getLong("uplink_dropped_bytes", 0));
|
||||
payload.put("downlink_dropped_packets", runtime.getLong("downlink_dropped_packets", 0));
|
||||
payload.put("downlink_dropped_bytes", runtime.getLong("downlink_dropped_bytes", 0));
|
||||
payload.put("errors", runtime.getLong("errors", 0));
|
||||
payload.put("uplink", runtimePrefix(runtime, "uplink"));
|
||||
payload.put("uplink_sender", runtimePrefix(runtime, "uplink_sender"));
|
||||
payload.put("downlink", runtimePrefix(runtime, "downlink"));
|
||||
payload.put("relay", runtimePrefix(runtime, "relay"));
|
||||
payload.put("uplink_worker_count", runtime.getInt("uplink_worker_count", 0));
|
||||
payload.put("uplink_queue_depth_total", runtime.getInt("uplink_queue_depth_total", 0));
|
||||
payload.put("uplink_queue_depth_max", runtime.getInt("uplink_queue_depth_max", 0));
|
||||
payload.put("uplink_queue_depths", runtime.getString("uplink_queue_depths", ""));
|
||||
payload.put("uplink_queue_0_offers", runtime.getLong("uplink_queue_0_offers", 0));
|
||||
payload.put("uplink_queue_1_offers", runtime.getLong("uplink_queue_1_offers", 0));
|
||||
payload.put("uplink_queue_2_offers", runtime.getLong("uplink_queue_2_offers", 0));
|
||||
payload.put("uplink_queue_3_offers", runtime.getLong("uplink_queue_3_offers", 0));
|
||||
payload.put("uplink_queue_0_drops", runtime.getLong("uplink_queue_0_drops", 0));
|
||||
payload.put("uplink_queue_1_drops", runtime.getLong("uplink_queue_1_drops", 0));
|
||||
payload.put("uplink_queue_2_drops", runtime.getLong("uplink_queue_2_drops", 0));
|
||||
payload.put("uplink_queue_3_drops", runtime.getLong("uplink_queue_3_drops", 0));
|
||||
payload.put("uplink_sender_worker_packets_0", runtime.getLong("uplink_sender_worker_packets_0", 0));
|
||||
payload.put("uplink_sender_worker_packets_1", runtime.getLong("uplink_sender_worker_packets_1", 0));
|
||||
payload.put("uplink_sender_worker_packets_2", runtime.getLong("uplink_sender_worker_packets_2", 0));
|
||||
payload.put("uplink_sender_worker_packets_3", runtime.getLong("uplink_sender_worker_packets_3", 0));
|
||||
payload.put("uplink_sender_worker_errors_0", runtime.getLong("uplink_sender_worker_errors_0", 0));
|
||||
payload.put("uplink_sender_worker_errors_1", runtime.getLong("uplink_sender_worker_errors_1", 0));
|
||||
payload.put("uplink_sender_worker_errors_2", runtime.getLong("uplink_sender_worker_errors_2", 0));
|
||||
payload.put("uplink_sender_worker_errors_3", runtime.getLong("uplink_sender_worker_errors_3", 0));
|
||||
payload.put("uplink_queue_depth", runtime.getInt("uplink_queue_depth", 0));
|
||||
payload.put("downlink_restarts", runtime.getLong("downlink_restarts", 0));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private JSONObject vpnConfigSnapshot() throws Exception {
|
||||
SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("vpn_address", runtime.getString("vpn_address", ""));
|
||||
payload.put("dns_servers", runtime.getString("dns_servers", ""));
|
||||
payload.put("routes", runtime.getString("routes", ""));
|
||||
payload.put("full_tunnel", runtime.getBoolean("full_tunnel", false));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private JSONObject runtimePrefix(SharedPreferences runtime, String prefix) throws Exception {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("state", runtime.getString(prefix + "_state", ""));
|
||||
payload.put("message", runtime.getString(prefix + "_message", ""));
|
||||
payload.put("updated_at", runtime.getLong(prefix + "_updated_at", 0));
|
||||
payload.put("packets", runtime.getLong(prefix + "_packets", 0));
|
||||
payload.put("bytes", runtime.getLong(prefix + "_bytes", 0));
|
||||
payload.put("errors", runtime.getLong(prefix + "_errors", 0));
|
||||
payload.put("error_type", runtime.getString(prefix + "_error_type", ""));
|
||||
payload.put("thread_alive", runtime.getBoolean(prefix + "_thread_alive", false));
|
||||
payload.put("rate_mbps", runtime.getFloat(prefix + "_rate_mbps", 0f));
|
||||
payload.put("rate_pps", runtime.getFloat(prefix + "_rate_pps", 0f));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String runHttpGet(String target) {
|
||||
try {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection();
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "http_get " + target + " -> HTTP " + code;
|
||||
} catch (Exception e) {
|
||||
return "http_get " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String runVPNHttpGet(String target) {
|
||||
try {
|
||||
Network vpn = vpnNetwork();
|
||||
if (vpn == null) {
|
||||
return "vpn_http_get " + target + " -> vpn network not found";
|
||||
}
|
||||
URL url = new URL(target);
|
||||
HttpURLConnection connection;
|
||||
String resolved = "";
|
||||
if ("http".equalsIgnoreCase(url.getProtocol()) && !isIPv4Literal(url.getHost())) {
|
||||
resolved = firstManualVPNAddress(vpn, url.getHost());
|
||||
}
|
||||
if (!resolved.isEmpty()) {
|
||||
URL resolvedURL = new URL(url.getProtocol(), resolved, url.getPort(), url.getFile());
|
||||
connection = (HttpURLConnection) vpn.openConnection(resolvedURL);
|
||||
connection.setRequestProperty("Host", hostHeader(url));
|
||||
} else {
|
||||
connection = (HttpURLConnection) vpn.openConnection(url);
|
||||
}
|
||||
try {
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "vpn_http_get " + target + " -> HTTP " + code;
|
||||
} catch (UnknownHostException e) {
|
||||
String fallbackResolved = firstManualVPNAddress(vpn, url.getHost());
|
||||
if (fallbackResolved.isEmpty() || !"http".equalsIgnoreCase(url.getProtocol())) {
|
||||
throw e;
|
||||
}
|
||||
URL resolvedURL = new URL(url.getProtocol(), fallbackResolved, url.getPort(), url.getFile());
|
||||
connection = (HttpURLConnection) vpn.openConnection(resolvedURL);
|
||||
connection.setRequestProperty("Host", hostHeader(url));
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "vpn_http_get " + target + " -> HTTP " + code;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return "vpn_http_get " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isIPv4Literal(String host) {
|
||||
if (host == null) {
|
||||
return false;
|
||||
}
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
for (String part : parts) {
|
||||
int value = Integer.parseInt(part);
|
||||
if (value < 0 || value > 255) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String runVPNDNSLookup(String host) {
|
||||
try {
|
||||
Network vpn = vpnNetwork();
|
||||
if (vpn == null) {
|
||||
return "vpn_dns_lookup " + host + " -> vpn network not found";
|
||||
}
|
||||
StringBuilder result = new StringBuilder();
|
||||
try {
|
||||
InetAddress[] system = vpn.getAllByName(host);
|
||||
result.append("system=");
|
||||
appendAddresses(result, system);
|
||||
} catch (Exception e) {
|
||||
result.append("system=").append(e.getClass().getSimpleName()).append(":").append(e.getMessage());
|
||||
}
|
||||
String manual = manualVPNDNSLookup(vpn, host);
|
||||
result.append(" manual=").append(manual);
|
||||
return "vpn_dns_lookup " + host + " -> " + result;
|
||||
} catch (Exception e) {
|
||||
return "vpn_dns_lookup " + host + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String firstManualVPNAddress(Network vpn, String host) {
|
||||
String result = manualVPNDNSLookup(vpn, host);
|
||||
if (result.startsWith("ok:")) {
|
||||
String addresses = result.substring(3);
|
||||
int comma = addresses.indexOf(',');
|
||||
return comma >= 0 ? addresses.substring(0, comma) : addresses;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String manualVPNDNSLookup(Network vpn, String host) {
|
||||
String dnsServers = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE).getString("dns_servers", "");
|
||||
if (dnsServers.isEmpty()) {
|
||||
return "skipped:no_dns_servers";
|
||||
}
|
||||
String dnsServer = dnsServers.split(",", 2)[0].trim();
|
||||
if (dnsServer.isEmpty()) {
|
||||
return "skipped:no_dns_servers";
|
||||
}
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
vpn.bindSocket(socket);
|
||||
socket.setSoTimeout(5000);
|
||||
byte[] query = buildDNSQuery(host);
|
||||
DatagramPacket packet = new DatagramPacket(query, query.length, InetAddress.getByName(dnsServer), 53);
|
||||
socket.send(packet);
|
||||
byte[] response = new byte[512];
|
||||
DatagramPacket answer = new DatagramPacket(response, response.length);
|
||||
socket.receive(answer);
|
||||
List<String> addresses = parseDNSAResponse(response, answer.getLength());
|
||||
if (addresses.isEmpty()) {
|
||||
return "empty:" + dnsServer;
|
||||
}
|
||||
return "ok:" + String.join(",", addresses);
|
||||
} catch (SocketTimeoutException e) {
|
||||
return "timeout:" + dnsServer;
|
||||
} catch (Exception e) {
|
||||
return e.getClass().getSimpleName() + ":" + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildDNSQuery(String host) throws Exception {
|
||||
byte[] out = new byte[512];
|
||||
int id = new Random().nextInt(0xffff);
|
||||
out[0] = (byte) ((id >> 8) & 0xff);
|
||||
out[1] = (byte) (id & 0xff);
|
||||
out[2] = 0x01;
|
||||
out[5] = 0x01;
|
||||
int offset = 12;
|
||||
for (String label : host.split("\\.")) {
|
||||
byte[] bytes = label.getBytes("UTF-8");
|
||||
out[offset++] = (byte) bytes.length;
|
||||
System.arraycopy(bytes, 0, out, offset, bytes.length);
|
||||
offset += bytes.length;
|
||||
}
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 1;
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 1;
|
||||
byte[] query = new byte[offset];
|
||||
System.arraycopy(out, 0, query, 0, offset);
|
||||
return query;
|
||||
}
|
||||
|
||||
private List<String> parseDNSAResponse(byte[] packet, int length) {
|
||||
List<String> addresses = new ArrayList<>();
|
||||
if (length < 12) {
|
||||
return addresses;
|
||||
}
|
||||
int qd = u16(packet, 4);
|
||||
int an = u16(packet, 6);
|
||||
int offset = 12;
|
||||
for (int i = 0; i < qd; i++) {
|
||||
offset = skipDNSName(packet, length, offset);
|
||||
offset += 4;
|
||||
if (offset > length) {
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < an && offset < length; i++) {
|
||||
offset = skipDNSName(packet, length, offset);
|
||||
if (offset + 10 > length) {
|
||||
return addresses;
|
||||
}
|
||||
int type = u16(packet, offset);
|
||||
int cls = u16(packet, offset + 2);
|
||||
int rdLen = u16(packet, offset + 8);
|
||||
offset += 10;
|
||||
if (type == 1 && cls == 1 && rdLen == 4 && offset + 4 <= length) {
|
||||
addresses.add((packet[offset] & 0xff) + "." + (packet[offset + 1] & 0xff) + "." + (packet[offset + 2] & 0xff) + "." + (packet[offset + 3] & 0xff));
|
||||
}
|
||||
offset += rdLen;
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private int skipDNSName(byte[] packet, int length, int offset) {
|
||||
while (offset < length) {
|
||||
int value = packet[offset] & 0xff;
|
||||
offset++;
|
||||
if (value == 0) {
|
||||
break;
|
||||
}
|
||||
if ((value & 0xc0) == 0xc0) {
|
||||
offset++;
|
||||
break;
|
||||
}
|
||||
offset += value;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private int u16(byte[] packet, int offset) {
|
||||
if (packet == null || offset + 1 >= packet.length) {
|
||||
return 0;
|
||||
}
|
||||
return ((packet[offset] & 0xff) << 8) | (packet[offset + 1] & 0xff);
|
||||
}
|
||||
|
||||
private void appendAddresses(StringBuilder result, InetAddress[] addresses) {
|
||||
if (addresses == null || addresses.length == 0) {
|
||||
result.append("empty");
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < addresses.length; i++) {
|
||||
if (i > 0) {
|
||||
result.append(",");
|
||||
}
|
||||
result.append(addresses[i].getHostAddress());
|
||||
}
|
||||
}
|
||||
|
||||
private String hostHeader(URL url) {
|
||||
if (url.getPort() > 0) {
|
||||
return url.getHost() + ":" + url.getPort();
|
||||
}
|
||||
return url.getHost();
|
||||
}
|
||||
|
||||
private Network vpnNetwork() {
|
||||
ConnectivityManager connectivity = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
|
||||
if (connectivity == null) {
|
||||
return null;
|
||||
}
|
||||
for (Network network : connectivity.getAllNetworks()) {
|
||||
NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network);
|
||||
if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
return network;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Notification notification() {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "RAP VPN diagnostics", NotificationManager.IMPORTANCE_LOW);
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
Notification.Builder builder = Build.VERSION.SDK_INT >= 26 ? new Notification.Builder(this, CHANNEL_ID) : new Notification.Builder(this);
|
||||
return builder
|
||||
.setContentTitle("RAP VPN diagnostics")
|
||||
.setContentText("Diagnostic channel is active")
|
||||
.setSmallIcon(android.R.drawable.stat_sys_upload_done)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
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,40 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
public class TestTrafficActivity extends Activity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
TextView text = new TextView(this);
|
||||
text.setText("traffic test starting");
|
||||
setContentView(text);
|
||||
String url = getIntent().getStringExtra("url");
|
||||
if (url == null || url.isEmpty()) {
|
||||
url = "http://192.168.200.61:18080/";
|
||||
}
|
||||
String target = url;
|
||||
new Thread(() -> runRequest(text, target), "rap-test-traffic").start();
|
||||
}
|
||||
|
||||
private void runRequest(TextView text, 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;
|
||||
runOnUiThread(() -> text.setText(finalResult));
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
Reference in New Issue
Block a user