Record project continuation changes

This commit is contained in:
2026-05-12 21:02:29 +03:00
parent 3059d1d7a3
commit 8f69d53193
339 changed files with 101111 additions and 1769 deletions
+24
View File
@@ -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>