Refactor RDP proxy handling and update related tests
This commit is contained in:
@@ -9,8 +9,10 @@ Implemented now:
|
||||
while the device session is valid;
|
||||
- load organization-scoped VPN client profile from `/clusters/{clusterID}/vpn/client-profile`;
|
||||
- request Android VPN permission and create a `VpnService` TUN interface;
|
||||
- relay TUN packets through the Control Plane HTTP packet relay to the active
|
||||
`home-1` gateway lease.
|
||||
- run as a normal fabric node with the `vpn-client` service role. The local
|
||||
`VpnService` TUN is the IPv4 ingress for that node, and packet channels are
|
||||
routed by the farm to an authorized `ipv4-egress` pool. HTTP batch fallback
|
||||
and old VPN protocols are not part of the supported test path.
|
||||
- user-facing HOME-first screen: connect/disconnect is primary, while backend,
|
||||
cluster, organization, login, and password are kept in the settings dialog;
|
||||
- saved connection settings in app preferences so repeat connects do not require
|
||||
@@ -19,9 +21,11 @@ Implemented now:
|
||||
device session is revoked or expires, the app asks for the password once and
|
||||
then rotates the device keys/profile again.
|
||||
|
||||
This is still a lab runtime, not a production WireGuard/IPsec implementation.
|
||||
The active Linux gateway node must be able to create `/dev/net/tun`, run `ip`,
|
||||
`sysctl`, and `iptables`, and enable NAT for `10.77.0.0/24`.
|
||||
This is still a lab runtime. The required target model is Android as a farm
|
||||
node with the `vpn-client` role. The VPN service must attach to the mesh as
|
||||
that node and route to an authorized IPv4 exit pool; there is no separate VPN
|
||||
entry point. Exit configuration is always pool based, including pools that
|
||||
currently contain only one node.
|
||||
|
||||
Build from this repository on Windows:
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ android {
|
||||
return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
}
|
||||
|
||||
def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "https://vpn.cin.su/api/v1"
|
||||
def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "http://192.168.200.61:18080/api/v1"
|
||||
def defaultFabricBootstrapPeers = project.findProperty("RAP_ANDROID_FABRIC_BOOTSTRAP_PEERS") ?: "quic://192.168.200.85:18080,quic://195.123.240.88:19131"
|
||||
def defaultClusterId = project.findProperty("RAP_ANDROID_DEFAULT_CLUSTER_ID") ?: "cfc0743d-d960-49fb-9de8-96e063d5e4aa"
|
||||
def defaultOrganizationId = project.findProperty("RAP_ANDROID_DEFAULT_ORGANIZATION_ID") ?: "125ff8b2-5ac1-4406-9bbb-ebbe18f7c7ed"
|
||||
|
||||
@@ -30,9 +31,10 @@ android {
|
||||
applicationId "su.cin.rapvpn"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 210
|
||||
versionName "0.2.210"
|
||||
versionCode 227
|
||||
versionName "0.2.227"
|
||||
buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\""
|
||||
buildConfigField "String", "FABRIC_BOOTSTRAP_PEERS", "\"${normalizeGradleString(defaultFabricBootstrapPeers)}\""
|
||||
buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\""
|
||||
buildConfigField "String", "DEFAULT_ORGANIZATION_ID", "\"${normalizeGradleString(defaultOrganizationId)}\""
|
||||
}
|
||||
@@ -45,5 +47,6 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation files("libs/rap-fabricvpn.aar")
|
||||
implementation "com.squareup.okhttp3:okhttp:5.3.2"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -10,12 +10,6 @@
|
||||
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">
|
||||
|
||||
@@ -25,7 +25,7 @@ import java.util.Locale;
|
||||
public class MainActivity extends Activity {
|
||||
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
|
||||
private static final String DEFAULT_BACKEND_URL = BuildConfig.DEFAULT_BACKEND_URL;
|
||||
private static final String PUBLIC_FABRIC_BACKEND_URL = "https://vpn.cin.su/api/v1";
|
||||
private static final String FABRIC_BOOTSTRAP_PEERS = BuildConfig.FABRIC_BOOTSTRAP_PEERS;
|
||||
private static final String DEFAULT_CLUSTER_ID = BuildConfig.DEFAULT_CLUSTER_ID;
|
||||
private static final String DEFAULT_ORGANIZATION_ID = BuildConfig.DEFAULT_ORGANIZATION_ID;
|
||||
private static final String PREF_SELECTED_EXIT_NODE_ID = "selected_exit_node_id";
|
||||
@@ -46,11 +46,9 @@ public class MainActivity extends Activity {
|
||||
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;
|
||||
@@ -68,7 +66,7 @@ public class MainActivity extends Activity {
|
||||
int pad = dp(20);
|
||||
root.setPadding(pad, pad, pad, pad);
|
||||
|
||||
backendUrl = field("Backend URL", preferredBackendUrl());
|
||||
backendUrl = field("Fabric control bootstrap", preferredBackendUrl());
|
||||
clusterId = field("Cluster ID", prefs.getString("cluster_id", DEFAULT_CLUSTER_ID));
|
||||
organizationId = field("Organization ID", prefs.getString("organization_id", DEFAULT_ORGANIZATION_ID));
|
||||
email = field("Email", prefs.getString("email", "m"));
|
||||
@@ -93,12 +91,6 @@ public class MainActivity extends Activity {
|
||||
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));
|
||||
@@ -111,11 +103,11 @@ public class MainActivity extends Activity {
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
|
||||
Button load = new Button(this);
|
||||
load.setText("Войти / обновить профиль");
|
||||
load.setText("Войти / обновить пулы");
|
||||
load.setOnClickListener(v -> loadProfile(false));
|
||||
|
||||
Button start = new Button(this);
|
||||
start.setText("Включить HOME VPN");
|
||||
start.setText("Подключить");
|
||||
start.setOnClickListener(v -> prepareVpn());
|
||||
|
||||
Button stop = new Button(this);
|
||||
@@ -156,17 +148,12 @@ public class MainActivity extends Activity {
|
||||
});
|
||||
|
||||
Button settings = new Button(this);
|
||||
settings.setText("Настройки");
|
||||
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);
|
||||
@@ -204,25 +191,17 @@ public class MainActivity extends Activity {
|
||||
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
|
||||
authContext = authenticate(client);
|
||||
String activeOrganizationId = resolveOrganizationId(client, authContext.userId);
|
||||
String requestedExitNodeId = selectedExitNodeId();
|
||||
profileJson = client.vpnClientProfile(
|
||||
clusterId.getText().toString(),
|
||||
activeOrganizationId,
|
||||
authContext.userId,
|
||||
requestedExitNodeId
|
||||
""
|
||||
);
|
||||
vpnConnectionId = firstConnectionId(profileJson);
|
||||
saveProfileState();
|
||||
JSONObject resourcePayload = client.resources(activeOrganizationId, authContext.userId);
|
||||
lastResources = resourcePayload.optJSONArray("resources");
|
||||
if (lastResources == null) {
|
||||
lastResources = new JSONArray();
|
||||
}
|
||||
String resourcesText = resourcesText(resourcePayload);
|
||||
runOnUiThread(() -> {
|
||||
profileSummary.setText(summaryText());
|
||||
serverDirectory.setText(resourcesText);
|
||||
status.setText(startAfterLoad ? "Профиль обновлен. Запускаю VPN..." : "Профиль и ключи устройства обновлены.");
|
||||
status.setText(startAfterLoad ? "Список пулов обновлен. Подключаю..." : "Список доступных пулов обновлен.");
|
||||
startDiagnosticChannel();
|
||||
if (startAfterLoad) {
|
||||
requestVpnPermission();
|
||||
@@ -233,12 +212,12 @@ public class MainActivity extends Activity {
|
||||
String message = friendlyError(ex);
|
||||
boolean canUseSavedProfile = startAfterLoad && !profileJson.isEmpty() && !vpnConnectionId.isEmpty();
|
||||
if (canUseSavedProfile) {
|
||||
status.setText("Профиль сейчас не обновился: " + message + ". Запускаю VPN с сохраненным рабочим профилем.");
|
||||
status.setText("Список пулов сейчас не обновился: " + message + ". Подключаюсь с сохраненным рабочим профилем.");
|
||||
startDiagnosticChannel();
|
||||
requestVpnPermission();
|
||||
return;
|
||||
}
|
||||
status.setText("Ошибка профиля: " + message);
|
||||
status.setText("Ошибка входа: " + message);
|
||||
if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) {
|
||||
clearSavedAuth(false);
|
||||
showSettingsDialog();
|
||||
@@ -250,7 +229,7 @@ public class MainActivity extends Activity {
|
||||
|
||||
private void prepareVpn() {
|
||||
loadProfile(true);
|
||||
status.setText("Обновляю сессию устройства и VPN-профиль...");
|
||||
status.setText("Обновляю сессию устройства и доступные пулы...");
|
||||
}
|
||||
|
||||
private void requestVpnPermission() {
|
||||
@@ -281,7 +260,7 @@ public class MainActivity extends Activity {
|
||||
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 + ". Ожидаю статус подключения.");
|
||||
status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал.");
|
||||
runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText());
|
||||
runtimeStatus.postDelayed(() -> {
|
||||
String state = runtimePrefs.getString("state", "");
|
||||
@@ -493,7 +472,7 @@ public class MainActivity extends Activity {
|
||||
if ("planned".equals(status)) {
|
||||
String entry = fabricRoute.optString("selected_entry_node_id", "").trim();
|
||||
String exit = fabricRoute.optString("selected_exit_node_id", "").trim();
|
||||
if (!entry.isEmpty() && !exit.isEmpty()) {
|
||||
if (!exit.isEmpty()) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -510,29 +489,6 @@ public class MainActivity extends Activity {
|
||||
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);
|
||||
}
|
||||
@@ -542,23 +498,101 @@ public class MainActivity extends Activity {
|
||||
String connectionId = vpnConnectionId == null || vpnConnectionId.isEmpty()
|
||||
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
|
||||
: vpnConnectionId;
|
||||
String backendText = backendUrl == null ? "" : backendUrl.getText().toString().trim();
|
||||
String clusterText = clusterId == null ? "" : clusterId.getText().toString().trim();
|
||||
String organizationText = organizationId == null ? "" : organizationId.getText().toString().trim();
|
||||
String exitNode = selectedExitNodeId();
|
||||
String poolText = availablePoolsText();
|
||||
String selectedPoolText = selectedPoolName();
|
||||
String profileDNS = profileDNSServersText();
|
||||
return "Версия: " + APP_VERSION
|
||||
+ "\nКластер: " + (clusterText.isEmpty() ? "не задан" : clusterText)
|
||||
+ "\nОрганизация: " + (organizationText.isEmpty() ? "не задана" : organizationText)
|
||||
+ "\nТочка входа: автоматическая (из настроек кластера)"
|
||||
+ "\nТочка выхода: " + (exitNode.isEmpty() ? "не выбрана (по умолчанию)" : exitNode)
|
||||
+ "\nУзел Android: в ферме"
|
||||
+ "\nBootstrap фермы: " + bootstrapPeerCount() + " узл."
|
||||
+ "\nДоступные выходы: " + (poolText.isEmpty() ? "войдите для загрузки" : poolText)
|
||||
+ "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "автоматически" : selectedPoolText)
|
||||
+ "\nDNS выхода: " + (profileDNS.isEmpty() ? "будет получен из профиля" : profileDNS)
|
||||
+ "\nBackend: " + (backendText.isEmpty() ? "не задан" : backendText)
|
||||
+ "\nТрафик: " + (prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true) ? "весь через VPN" : "по профилю")
|
||||
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
|
||||
+ "\nConnection: " + (connectionId.isEmpty() ? "нет" : connectionId);
|
||||
}
|
||||
|
||||
private int bootstrapPeerCount() {
|
||||
if (FABRIC_BOOTSTRAP_PEERS == null || FABRIC_BOOTSTRAP_PEERS.trim().isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (String value : FABRIC_BOOTSTRAP_PEERS.split(",")) {
|
||||
if (!value.trim().isEmpty()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private String availablePoolsText() {
|
||||
if (profileJson == null || profileJson.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
JSONObject root = new JSONObject(profileJson);
|
||||
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
|
||||
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
|
||||
if (connections == null || connections.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (int i = 0; i < connections.length(); i++) {
|
||||
JSONObject connection = connections.optJSONObject(i);
|
||||
if (connection == null) {
|
||||
continue;
|
||||
}
|
||||
String name = connection.optString("exit_pool_name", "").trim();
|
||||
if (name.isEmpty()) {
|
||||
name = connection.optString("name", "").trim();
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (out.length() > 0) {
|
||||
out.append(", ");
|
||||
}
|
||||
out.append(name);
|
||||
}
|
||||
return out.toString();
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String selectedPoolName() {
|
||||
if (profileJson == null || profileJson.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
JSONObject root = new JSONObject(profileJson);
|
||||
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
|
||||
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
|
||||
if (connections == null || connections.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
String preferredConnection = vpnConnectionId == null || vpnConnectionId.isEmpty()
|
||||
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
|
||||
: vpnConnectionId;
|
||||
for (int i = 0; i < connections.length(); i++) {
|
||||
JSONObject connection = connections.optJSONObject(i);
|
||||
if (connection == null) {
|
||||
continue;
|
||||
}
|
||||
if (!preferredConnection.isEmpty() && !preferredConnection.equals(connection.optString("id", ""))) {
|
||||
continue;
|
||||
}
|
||||
String name = connection.optString("exit_pool_name", "").trim();
|
||||
if (name.isEmpty()) {
|
||||
name = connection.optString("name", "").trim();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String profileDNSServersText() {
|
||||
if (profileJson == null || profileJson.trim().isEmpty()) {
|
||||
return runtimePrefs == null ? "" : runtimePrefs.getString("dns_servers", "");
|
||||
@@ -665,17 +699,14 @@ public class MainActivity extends Activity {
|
||||
|| "http://vpn.cin.su/api/v1".equals(lower)
|
||||
|| "https://vpn.cin.su:443/api/v1".equals(lower)
|
||||
|| "http://94.141.118.222:19191/api/v1".equals(lower)
|
||||
|| "http://195.123.240.88:19131/api/v1".equals(lower)
|
||||
|| "http://192.168.200.61:18080/api/v1".equals(lower)
|
||||
|| "http://docker-test.cin.su:18080/api/v1".equals(lower)) {
|
||||
return PUBLIC_FABRIC_BACKEND_URL;
|
||||
|| "http://195.123.240.88:19131/api/v1".equals(lower)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String selectedExitNodeId() {
|
||||
String configured = prefs == null ? "" : prefs.getString(PREF_SELECTED_EXIT_NODE_ID, "");
|
||||
return normalizeSelectedExitNodeId(configured);
|
||||
return "";
|
||||
}
|
||||
|
||||
private String normalizeSelectedExitNodeId(String value) {
|
||||
@@ -824,16 +855,10 @@ public class MainActivity extends Activity {
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
int pad = dp(12);
|
||||
form.setPadding(pad, pad, pad, pad);
|
||||
EditText backendDraft = field("Backend URL", backendUrl.getText().toString());
|
||||
EditText clusterDraft = field("Cluster ID", clusterId.getText().toString());
|
||||
EditText organizationDraft = field("Organization ID", organizationId.getText().toString());
|
||||
EditText emailDraft = field("Email", email.getText().toString());
|
||||
EditText passwordDraft = field("Password", password.getText().toString());
|
||||
passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
passwordDraft.setHint("Password (не сохраняется)");
|
||||
EditText selectedExitDraft = field(
|
||||
"Точка выхода (Node ID, например ifcm)",
|
||||
prefs.getString(PREF_SELECTED_EXIT_NODE_ID, ""));
|
||||
CheckBox showPassword = new CheckBox(this);
|
||||
showPassword.setText("Показать пароль");
|
||||
showPassword.setTextColor(0xff111111);
|
||||
@@ -847,30 +872,19 @@ public class MainActivity extends Activity {
|
||||
forceFullTunnel.setText("Полный маршрут через VPN");
|
||||
forceFullTunnel.setChecked(prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true));
|
||||
forceFullTunnel.setTextColor(0xff111111);
|
||||
form.addView(backendDraft);
|
||||
form.addView(clusterDraft);
|
||||
form.addView(organizationDraft);
|
||||
form.addView(emailDraft);
|
||||
form.addView(passwordDraft);
|
||||
form.addView(selectedExitDraft);
|
||||
form.addView(showPassword);
|
||||
form.addView(forceFullTunnel);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Настройки подключения")
|
||||
.setTitle("Аккаунт VPN")
|
||||
.setView(form)
|
||||
.setPositiveButton("Сохранить", (dialog, which) -> {
|
||||
backendUrl.setText(backendDraft.getText().toString());
|
||||
clusterId.setText(clusterDraft.getText().toString());
|
||||
organizationId.setText(organizationDraft.getText().toString());
|
||||
email.setText(emailDraft.getText().toString());
|
||||
password.setText(passwordDraft.getText().toString());
|
||||
String normalizedExit = normalizeSelectedExitNodeId(selectedExitDraft.getText().toString());
|
||||
prefs.edit()
|
||||
.putString(PREF_SELECTED_EXIT_NODE_ID, normalizedExit)
|
||||
.remove(PREF_SELECTED_EXIT_NODE_ID)
|
||||
.apply();
|
||||
if (!normalizedExit.equals(selectedExitDraft.getText().toString().trim())) {
|
||||
status.setText("Точка выхода очищена: значение было не похоже на Node ID/alias.");
|
||||
}
|
||||
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, forceFullTunnel.isChecked()).apply();
|
||||
saveSettings();
|
||||
profileSummary.setText(summaryText());
|
||||
@@ -898,60 +912,4 @@ public class MainActivity extends Activity {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ public class RapDiagnosticService extends Service {
|
||||
private static final String CHANNEL_ID = "rap-vpn-diagnostics";
|
||||
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
|
||||
private static final String DEFAULT_BACKEND_URL = BuildConfig.DEFAULT_BACKEND_URL;
|
||||
private static final String PUBLIC_FABRIC_BACKEND_URL = "https://vpn.cin.su/api/v1";
|
||||
private static final String INTERNAL_BACKEND_URL = "http://192.168.200.61:18080/api/v1";
|
||||
private static final String DEFAULT_CLUSTER_ID = BuildConfig.DEFAULT_CLUSTER_ID;
|
||||
private static final String DEFAULT_ORGANIZATION_ID = BuildConfig.DEFAULT_ORGANIZATION_ID;
|
||||
@@ -427,6 +426,9 @@ public class RapDiagnosticService extends Service {
|
||||
}
|
||||
String type = payload.optString("type", "");
|
||||
JSONObject params = payload.optJSONObject("payload");
|
||||
if (params == null) {
|
||||
params = payload.optJSONObject("params");
|
||||
}
|
||||
if (params == null) {
|
||||
params = payload;
|
||||
}
|
||||
@@ -447,7 +449,7 @@ public class RapDiagnosticService extends Service {
|
||||
result = runVPNHttpGet(params.optString("url", "http://192.168.200.61:18080/"), params.optInt("timeout_ms", 15000));
|
||||
} else if ("vpn_page_probe".equals(type)) {
|
||||
result = runVPNPageProbe(params);
|
||||
} else if ("vpn_tcp_connect".equals(type) || "vpn_rdp_probe".equals(type)) {
|
||||
} else if ("vpn_tcp_connect".equals(type)) {
|
||||
result = runVPNTCPConnect(params.optString("host", "192.168.200.95"), params.optInt("port", 3389), params.optInt("timeout_ms", 7000));
|
||||
} else if ("vpn_tcp_connect_default".equals(type)) {
|
||||
result = runDefaultTCPConnect(params.optString("host", "192.168.200.95"), params.optInt("port", 3389), params.optInt("timeout_ms", 7000));
|
||||
@@ -472,7 +474,7 @@ public class RapDiagnosticService extends Service {
|
||||
} else if ("vpn_deep_test".equals(type)) {
|
||||
result = runVPNDeepTest(client, clusterId, params);
|
||||
} else if ("vpn_download_test".equals(type)) {
|
||||
result = runVPNDownloadTest(params.optString("url", "http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json"));
|
||||
result = runVPNDownloadTest(params.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json"));
|
||||
} else if ("vpn_mixed_load_test".equals(type) || "vpn_parallel_http_get".equals(type)) {
|
||||
result = runVPNMixedLoadTest(params);
|
||||
} else if ("launch_telegram".equals(type)) {
|
||||
@@ -590,6 +592,16 @@ public class RapDiagnosticService extends Service {
|
||||
}
|
||||
String relay = runtime.getString("packet_relay_active_base_url", "");
|
||||
String state = runtime.getString("state", "");
|
||||
boolean fabricMode = runtime.getBoolean("mesh_node_route_mode", false)
|
||||
|| "fabric_mesh_node_route_v1".equals(runtime.getString("dataplane_selected_transport", ""));
|
||||
if (fabricMode && ("fabric".equals(state)
|
||||
|| "fabric_downlink".equals(state)
|
||||
|| "uplink_sent".equals(state)
|
||||
|| "uplink_read".equals(state)
|
||||
|| "downlink".equals(state)
|
||||
|| "downlink_idle".equals(state))) {
|
||||
return true;
|
||||
}
|
||||
if (!relay.isEmpty() && ("running".equals(state)
|
||||
|| "ready".equals(state)
|
||||
|| "warming".equals(state)
|
||||
@@ -602,7 +614,7 @@ public class RapDiagnosticService extends Service {
|
||||
}
|
||||
long sent = runtime.getLong("uplink_sent_total", 0);
|
||||
long down = runtime.getLong("downlink_received_total", 0);
|
||||
return !relay.isEmpty() && (sent > 0 || down > 0);
|
||||
return (fabricMode || !relay.isEmpty()) && (sent > 0 || down > 0);
|
||||
}
|
||||
|
||||
private static final class ControlEndpoint {
|
||||
@@ -662,7 +674,6 @@ public class RapDiagnosticService extends Service {
|
||||
return "vpn_http_get".equals(type)
|
||||
|| "vpn_page_probe".equals(type)
|
||||
|| "vpn_tcp_connect".equals(type)
|
||||
|| "vpn_rdp_probe".equals(type)
|
||||
|| "vpn_download_test".equals(type);
|
||||
}
|
||||
|
||||
@@ -688,17 +699,17 @@ public class RapDiagnosticService extends Service {
|
||||
if ("vpn_page_probe".equals(type)) {
|
||||
return runVPNPageProbe(payload);
|
||||
}
|
||||
if ("vpn_tcp_connect".equals(type) || "vpn_rdp_probe".equals(type)) {
|
||||
if ("vpn_tcp_connect".equals(type)) {
|
||||
return runVPNTCPConnect(payload.optString("host", "192.168.200.95"), payload.optInt("port", 3389), payload.optInt("timeout_ms", 7000));
|
||||
}
|
||||
if ("vpn_download_test".equals(type)) {
|
||||
if (recoveryAttempt) {
|
||||
return runVPNDownloadTest(
|
||||
payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json"),
|
||||
payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json"),
|
||||
RECOVERY_DOWNLOAD_CONNECT_TIMEOUT_MS,
|
||||
RECOVERY_DOWNLOAD_READ_TIMEOUT_MS);
|
||||
}
|
||||
return runVPNDownloadTest(payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json"));
|
||||
return runVPNDownloadTest(payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json"));
|
||||
}
|
||||
return "retry skipped: unsupported probe " + type;
|
||||
}
|
||||
@@ -711,7 +722,7 @@ public class RapDiagnosticService extends Service {
|
||||
copy = new JSONObject();
|
||||
}
|
||||
try {
|
||||
if ("vpn_tcp_connect".equals(type) || "vpn_rdp_probe".equals(type)) {
|
||||
if ("vpn_tcp_connect".equals(type)) {
|
||||
int requested = copy.optInt("timeout_ms", RECOVERY_TCP_TIMEOUT_MS);
|
||||
copy.put("timeout_ms", Math.max(1000, Math.min(requested, RECOVERY_TCP_TIMEOUT_MS)));
|
||||
} else if ("vpn_page_probe".equals(type)) {
|
||||
@@ -758,6 +769,8 @@ public class RapDiagnosticService extends Service {
|
||||
last = state + "/sender=" + senderState + "/age_ms=" + age;
|
||||
boolean readyState = "downlink".equals(state)
|
||||
|| "downlink_idle".equals(state)
|
||||
|| "fabric".equals(state)
|
||||
|| "fabric_downlink".equals(state)
|
||||
|| "uplink_sent".equals(state)
|
||||
|| "uplink_read".equals(state)
|
||||
|| "runtime_recovery".equals(state);
|
||||
@@ -900,9 +913,8 @@ public class RapDiagnosticService extends Service {
|
||||
if (organizationId == null || organizationId.trim().isEmpty()) {
|
||||
organizationId = DEFAULT_ORGANIZATION_ID;
|
||||
}
|
||||
String exitNodeId = prefs.getString(PREF_SELECTED_EXIT_NODE_ID, "");
|
||||
RapApiClient client = new RapApiClient(backendUrl, this, true);
|
||||
String profileJson = client.vpnClientProfile(clusterId, organizationId, userId, exitNodeId);
|
||||
String profileJson = client.vpnClientProfile(clusterId, organizationId, userId, "");
|
||||
JSONObject root = new JSONObject(profileJson);
|
||||
JSONObject profile = root.getJSONObject("vpn_client_profile");
|
||||
String connectionId = profile.getJSONArray("connections").getJSONObject(0).getString("id");
|
||||
@@ -927,7 +939,6 @@ public class RapDiagnosticService extends Service {
|
||||
String organizationId = params.optString("organization_id", DEFAULT_ORGANIZATION_ID).trim();
|
||||
String userId = params.optString("user_id", "").trim();
|
||||
String trustedDeviceId = params.optString("trusted_device_id", "").trim();
|
||||
String selectedExitNodeId = params.optString("selected_exit_node_id", params.optString("exit_node_id", "")).trim();
|
||||
String profileJson = params.optString("profile_json", "").trim();
|
||||
JSONObject root;
|
||||
if (profileJson.isEmpty()) {
|
||||
@@ -962,13 +973,6 @@ public class RapDiagnosticService extends Service {
|
||||
}
|
||||
JSONObject connection = profile.getJSONArray("connections").getJSONObject(0);
|
||||
String connectionId = params.optString("vpn_connection_id", connection.optString("id", "")).trim();
|
||||
JSONObject config = connection.optJSONObject("client_config");
|
||||
if (selectedExitNodeId.isEmpty() && config != null) {
|
||||
JSONObject route = config.optJSONObject("vpn_fabric_route");
|
||||
if (route != null) {
|
||||
selectedExitNodeId = route.optString("selected_exit_node_id", "");
|
||||
}
|
||||
}
|
||||
SharedPreferences.Editor editor = getSharedPreferences(PREFS, MODE_PRIVATE).edit()
|
||||
.putString("backend_url", backendUrl)
|
||||
.putString("cluster_id", clusterId)
|
||||
@@ -979,9 +983,7 @@ public class RapDiagnosticService extends Service {
|
||||
if (!trustedDeviceId.isEmpty()) {
|
||||
editor.putString(PREF_DEVICE_ID, trustedDeviceId);
|
||||
}
|
||||
if (!selectedExitNodeId.isEmpty()) {
|
||||
editor.putString(PREF_SELECTED_EXIT_NODE_ID, selectedExitNodeId);
|
||||
}
|
||||
editor.remove(PREF_SELECTED_EXIT_NODE_ID);
|
||||
editor.apply();
|
||||
Intent stopIntent = new Intent(this, RapVpnService.class);
|
||||
stopIntent.setAction(RapVpnService.ACTION_STOP);
|
||||
@@ -1095,10 +1097,8 @@ public class RapDiagnosticService extends Service {
|
||||
|| "http://vpn.cin.su/api/v1".equals(lower)
|
||||
|| "https://vpn.cin.su:443/api/v1".equals(lower)
|
||||
|| "http://94.141.118.222:19191/api/v1".equals(lower)
|
||||
|| "http://195.123.240.88:19131/api/v1".equals(lower)
|
||||
|| "http://192.168.200.61:18080/api/v1".equals(lower)
|
||||
|| "http://docker-test.cin.su:18080/api/v1".equals(lower)) {
|
||||
return PUBLIC_FABRIC_BACKEND_URL;
|
||||
|| "http://195.123.240.88:19131/api/v1".equals(lower)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -1187,7 +1187,7 @@ public class RapDiagnosticService extends Service {
|
||||
result.append(" | dns=").append(runVPNDNSLookup(host));
|
||||
result.append(" | vpn_http=").append(runVPNHttpGet(url, 15000));
|
||||
result.append(" | vpn_local_http=").append(runVPNHttpGet(localUrl, 15000));
|
||||
result.append(" | download=").append(runVPNDownloadTest(payload.optString("download_url", "http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json")));
|
||||
result.append(" | download=").append(runVPNDownloadTest(payload.optString("download_url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json")));
|
||||
return compact(result.toString(), 2500);
|
||||
}
|
||||
|
||||
@@ -1240,13 +1240,13 @@ public class RapDiagnosticService extends Service {
|
||||
if (timeoutMs > 45000) {
|
||||
timeoutMs = 45000;
|
||||
}
|
||||
String rdpHost = payload.optString("rdp_host", "192.168.200.95");
|
||||
int rdpPort = payload.optInt("rdp_port", 3389);
|
||||
String tcpHost = payload.optString("tcp_host", "192.168.200.95");
|
||||
int tcpPort = payload.optInt("tcp_port", 443);
|
||||
String[] defaults = new String[] {
|
||||
"http://2ip.ru/",
|
||||
"http://example.com/",
|
||||
"http://neverssl.com/",
|
||||
"http://192.168.200.61:18080/downloads/rap-android-rdp-vpn-build.json",
|
||||
"http://192.168.200.61:18080/downloads/rap-android-vpn-build.json",
|
||||
"http://192.168.200.61:18080/"
|
||||
};
|
||||
List<String> urls = new ArrayList<>();
|
||||
@@ -1264,7 +1264,7 @@ public class RapDiagnosticService extends Service {
|
||||
urls.add(url);
|
||||
}
|
||||
}
|
||||
String rdpBefore = runVPNTCPConnect(rdpHost, rdpPort, Math.min(timeoutMs, 10000));
|
||||
String tcpBefore = runVPNTCPConnect(tcpHost, tcpPort, Math.min(timeoutMs, 10000));
|
||||
String[] results = new String[parallel];
|
||||
Thread[] threads = new Thread[parallel];
|
||||
final int requestTimeoutMs = timeoutMs;
|
||||
@@ -1301,13 +1301,13 @@ public class RapDiagnosticService extends Service {
|
||||
sample.append(item == null ? "timeout/no_result" : item);
|
||||
}
|
||||
}
|
||||
String rdpAfter = runVPNTCPConnect(rdpHost, rdpPort, Math.min(timeoutMs, 10000));
|
||||
String tcpAfter = runVPNTCPConnect(tcpHost, tcpPort, Math.min(timeoutMs, 10000));
|
||||
return compact("vpn_mixed_load_test parallel=" + parallel
|
||||
+ " ok=" + ok
|
||||
+ " failed=" + failed
|
||||
+ " elapsed_ms=" + elapsed
|
||||
+ " rdp_before={" + rdpBefore + "}"
|
||||
+ " rdp_after={" + rdpAfter + "}"
|
||||
+ " tcp_before={" + tcpBefore + "}"
|
||||
+ " tcp_after={" + tcpAfter + "}"
|
||||
+ " sample={" + sample + "}", 2500);
|
||||
}
|
||||
|
||||
@@ -1573,6 +1573,8 @@ public class RapDiagnosticService extends Service {
|
||||
payload.put("packet_relay_candidate_urls", runtime.getString("packet_relay_candidate_urls", ""));
|
||||
payload.put("dataplane_transport_candidate_count", runtime.getInt("dataplane_transport_candidate_count", 0));
|
||||
payload.put("dataplane_entry_candidate_count", runtime.getInt("dataplane_entry_candidate_count", 0));
|
||||
payload.put("dataplane_exit_candidate_count", runtime.getInt("dataplane_exit_candidate_count", 0));
|
||||
payload.put("fabric_mesh_exit_endpoints", runtime.getString("fabric_mesh_exit_endpoints", ""));
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ import android.util.Log;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import su.cin.rapvpn.fabric.fabricvpn.Fabricvpn;
|
||||
import su.cin.rapvpn.fabric.fabricvpn.Manager;
|
||||
import su.cin.rapvpn.fabric.fabricvpn.SocketProtector;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
import java.net.DatagramPacket;
|
||||
@@ -79,7 +83,7 @@ public class RapVpnService extends VpnService {
|
||||
private static final int RUNTIME_STATUS_INTERVAL_MS = 500;
|
||||
private static final int RUNTIME_WATCHDOG_INTERVAL_MS = 2000;
|
||||
private static final int RUNTIME_WATCHDOG_STALE_SYNACK_MS = 7000;
|
||||
private static final int RUNTIME_WATCHDOG_RDP_STALE_SYNACK_MS = 4000;
|
||||
private static final int RUNTIME_WATCHDOG_PRIORITY_STALE_SYNACK_MS = 4000;
|
||||
private static final int RUNTIME_WATCHDOG_STALE_ROUNDS_BEFORE_RECOVERY = 3;
|
||||
private static final int RUNTIME_WATCHDOG_STALE_SYNACKS_BEFORE_RECOVERY = 4;
|
||||
private static final int RUNTIME_WATCHDOG_MAX_STALE_ROUNDS_BEFORE_RECOVERY = 6;
|
||||
@@ -209,6 +213,9 @@ public class RapVpnService extends VpnService {
|
||||
private volatile int activePacketRelayIndex;
|
||||
private volatile VpnPacketWebSocketRelay packetWebSocketRelay;
|
||||
private volatile FabricServiceChannel activeFabricServiceChannel = new FabricServiceChannel();
|
||||
private volatile boolean activeMeshNodeRouteMode;
|
||||
private volatile String activeFabricRuntimeConfigJson = "";
|
||||
private volatile Manager fabricVpnManager;
|
||||
private volatile String lastUplinkSendErrorMessage = "";
|
||||
private final Object packetRelaySwitchLock = new Object();
|
||||
private final Map<String, byte[]> clientSourceNat = new LinkedHashMap<String, byte[]>(4096, 0.75f, true) {
|
||||
@@ -288,40 +295,18 @@ public class RapVpnService extends VpnService {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
persistStartConfig(profile, backendUrl, clusterId, vpnConnectionId);
|
||||
List<String> packetRelayUrls = activePacketRelayUrlsByProfile == null || activePacketRelayUrlsByProfile.isEmpty()
|
||||
? singletonUrl(activePacketRelayUrlByProfile)
|
||||
: new ArrayList<>(activePacketRelayUrlsByProfile);
|
||||
if (!activeFabricServiceChannel.enabled) {
|
||||
shutdownReason = "fabric service channel lease required";
|
||||
writeRuntimeStatus("error", "vpn not started: fabric service channel lease required", 0, 0, 0, 0);
|
||||
if (!activeMeshNodeRouteMode) {
|
||||
shutdownReason = "fabric mesh node route profile required";
|
||||
writeRuntimeStatus("error", "vpn not started: fabric_mesh_node_route_v1 profile required; legacy relay dataplanes are disabled", 0, 0, 0, 0);
|
||||
writeRuntimeDetail("legacy_dataplane_disabled", "Android VPN client accepts only the QUIC fabric mesh node route profile. HTTP relay, WebSocket relay and entry endpoint modes are disabled for runtime traffic.", "mesh", 0, 1, "legacy_dataplane_disabled", -1);
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (packetRelayUrls.isEmpty()) {
|
||||
shutdownReason = "missing farm entry endpoint";
|
||||
writeRuntimeStatus("error", "vpn not started: missing farm entry endpoint", 0, 0, 0, 0);
|
||||
if (!startFabricMeshNodeRuntime(clusterId, vpnConnectionId, activeFabricRuntimeConfigJson)) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
startPacketRelay(backendUrl, packetRelayUrls, clusterId, vpnConnectionId);
|
||||
if (!running) {
|
||||
shutdownReason = "relay not running";
|
||||
writeRuntimeStatus("error", "vpn not started: relay not running", 0, 0, 0, 0);
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (tunnel == null || backendUrl == null || backendUrl.isEmpty()
|
||||
|| clusterId == null || clusterId.isEmpty()
|
||||
|| vpnConnectionId == null || vpnConnectionId.isEmpty()) {
|
||||
shutdownReason = "invalid runtime";
|
||||
writeRuntimeStatus("error", "vpn not started: invalid runtime", 0, 0, 0, 0);
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
writeRuntimeStatus("running", "vpn service active " + vpnAddressIPv4, 0, 0, downlinkReceivedPackets.get(), 0);
|
||||
startVPNReadinessWarmup(configuredDnsServers(), configuredDnsProbeDomains(), vpnConnectionId);
|
||||
shutdownReason = "running";
|
||||
return START_NOT_STICKY;
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void ensureDiagnosticServiceRunning() {
|
||||
@@ -467,6 +452,8 @@ public class RapVpnService extends VpnService {
|
||||
activePacketRelayUrlByProfile = "";
|
||||
activePacketRelayUrlsByProfile = new ArrayList<>();
|
||||
activeFabricServiceChannel = new FabricServiceChannel();
|
||||
activeMeshNodeRouteMode = false;
|
||||
activeFabricRuntimeConfigJson = "";
|
||||
VpnClientConfig config = parseClientConfig(profileJson, backendUrl);
|
||||
SharedPreferences prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE);
|
||||
boolean forceFullTunnel = prefs.getBoolean(MainActivity.PREF_FORCE_FULL_TUNNEL, true);
|
||||
@@ -525,6 +512,8 @@ public class RapVpnService extends VpnService {
|
||||
activePacketRelayUrlByProfile = config.packetRelayBaseUrl;
|
||||
activePacketRelayUrlsByProfile = new ArrayList<>(config.packetRelayBaseUrls);
|
||||
activeFabricServiceChannel = config.fabricServiceChannel;
|
||||
activeMeshNodeRouteMode = config.meshNodeRouteMode;
|
||||
activeFabricRuntimeConfigJson = config.fabricRuntimeConfigJson;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "vpn tunnel establish failed", e);
|
||||
writeRuntimeStatus("error", "tunnel failed: " + e.getMessage(), 0, 0, 0, 0);
|
||||
@@ -561,7 +550,10 @@ public class RapVpnService extends VpnService {
|
||||
if ("planned".equals(status) && connection == null) {
|
||||
String entry = candidateRoute.optString("selected_entry_node_id", "").trim();
|
||||
String exit = candidateRoute.optString("selected_exit_node_id", "").trim();
|
||||
if (!entry.isEmpty() && !exit.isEmpty()) {
|
||||
boolean clientNodeEntry = candidateRoute.optBoolean("client_node_entry", false)
|
||||
|| "client_node".equalsIgnoreCase(candidateRoute.optString("entry_selector", ""))
|
||||
|| "client_node_to_exit_pool".equalsIgnoreCase(candidateRoute.optString("route_type", ""));
|
||||
if ((!entry.isEmpty() || clientNodeEntry) && !exit.isEmpty()) {
|
||||
connection = candidate;
|
||||
selectedConnectionId = candidateId;
|
||||
break;
|
||||
@@ -581,6 +573,7 @@ public class RapVpnService extends VpnService {
|
||||
return config;
|
||||
}
|
||||
JSONObject clientConfig = connection.optJSONObject("client_config");
|
||||
JSONObject dataplaneSession = null;
|
||||
if (clientConfig != null) {
|
||||
String vpnAddress = clientConfig.optString("vpn_address", "");
|
||||
if (!vpnAddress.isEmpty()) {
|
||||
@@ -593,7 +586,7 @@ public class RapVpnService extends VpnService {
|
||||
readStringArray(clientConfig.optJSONArray("dns_servers"), config.dnsServers, true);
|
||||
readStringArray(clientConfig.optJSONArray("dns_probe_domains"), config.dnsProbeDomains, false);
|
||||
readStringArray(clientConfig.optJSONArray("routes"), config.splitRoutes, false);
|
||||
JSONObject dataplaneSession = clientConfig.optJSONObject("vpn_dataplane_session");
|
||||
dataplaneSession = clientConfig.optJSONObject("vpn_dataplane_session");
|
||||
if (dataplaneSession != null) {
|
||||
config.dataplaneSessionStatus = dataplaneSession.optString("status", "");
|
||||
config.dataplanePreferredTransport = dataplaneSession.optString("preferred_transport", "");
|
||||
@@ -604,7 +597,17 @@ public class RapVpnService extends VpnService {
|
||||
config.dataplaneTransportCandidateCount = transportCandidates == null ? 0 : transportCandidates.length();
|
||||
JSONArray entryCandidates = dataplaneSession.optJSONArray("entry_candidates");
|
||||
config.dataplaneEntryCandidateCount = entryCandidates == null ? 0 : entryCandidates.length();
|
||||
JSONArray exitCandidates = dataplaneSession.optJSONArray("exit_candidates");
|
||||
config.dataplaneExitCandidateCount = exitCandidates == null ? 0 : exitCandidates.length();
|
||||
JSONObject routeBundle = dataplaneSession.optJSONObject("fabric_route_bundle");
|
||||
JSONArray routeBundleEndpoints = routeBundle == null ? null : routeBundle.optJSONArray("endpoint_candidates");
|
||||
config.fabricMeshExitEndpoints = summarizeFabricMeshEndpoints(routeBundleEndpoints == null ? exitCandidates : routeBundleEndpoints);
|
||||
config.dataplaneSelectedTransport = selectDataplanePacketTransport(dataplaneSession);
|
||||
config.meshNodeRouteMode = isMeshNodeRouteDataplane(dataplaneSession);
|
||||
if (config.meshNodeRouteMode) {
|
||||
config.dataplaneSelectedTransport = "fabric_mesh_node_route_v1";
|
||||
config.configNotes.add("Fabric mesh node route: Android node connects to exit pool directly through mesh");
|
||||
}
|
||||
config.packetRelayBaseUrls.addAll(selectDataplanePacketRelayBaseUrls(dataplaneSession, backendUrl));
|
||||
if (!config.packetRelayBaseUrls.isEmpty()) {
|
||||
config.packetRelayBaseUrl = config.packetRelayBaseUrls.get(0);
|
||||
@@ -656,12 +659,102 @@ public class RapVpnService extends VpnService {
|
||||
selectedConnectionId = waitingConnectionId;
|
||||
}
|
||||
config.selectedConnectionId = selectedConnectionId;
|
||||
if (config.meshNodeRouteMode && dataplaneSession != null) {
|
||||
String clusterId = profile == null ? "" : profile.optString("cluster_id", "");
|
||||
config.fabricRuntimeConfigJson = buildFabricRuntimeConfig(clusterId, selectedConnectionId, config.dataplaneExitNodeId, dataplaneSession);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
config.configNotes.add("Failed parsing profile: using defaults");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
private String buildFabricRuntimeConfig(String clusterId, String vpnConnectionId, String exitNodeId, JSONObject dataplaneSession) {
|
||||
try {
|
||||
JSONObject out = new JSONObject();
|
||||
String sessionCluster = dataplaneSession == null ? "" : dataplaneSession.optString("cluster_id", "");
|
||||
String sessionId = dataplaneSession == null ? "" : dataplaneSession.optString("session_id", "");
|
||||
String connectionId = vpnConnectionId == null ? "" : vpnConnectionId.trim();
|
||||
out.put("cluster_id", firstNonEmpty(clusterId, sessionCluster));
|
||||
out.put("local_node_id", "android-vpn-" + firstNonEmpty(connectionId, sessionId, String.valueOf(System.currentTimeMillis())));
|
||||
out.put("exit_node_id", firstNonEmpty(exitNodeId, dataplaneSession == null ? "" : dataplaneSession.optString("exit_node_id", "")));
|
||||
out.put("vpn_connection_id", firstNonEmpty(connectionId, sessionId));
|
||||
out.put("stream_shards", 4);
|
||||
JSONObject request = dataplaneSession == null ? null : dataplaneSession.optJSONObject("fabric_service_channel_request");
|
||||
JSONObject routeBundle = dataplaneSession == null ? null : dataplaneSession.optJSONObject("fabric_route_bundle");
|
||||
if (request != null) {
|
||||
out.put("service_channel_request", request);
|
||||
}
|
||||
if (routeBundle != null) {
|
||||
out.put("route_bundle", routeBundle);
|
||||
}
|
||||
JSONArray endpointCache = new JSONArray();
|
||||
JSONArray candidates = routeBundle == null ? null : routeBundle.optJSONArray("endpoint_candidates");
|
||||
if (candidates == null && routeBundle != null) {
|
||||
candidates = routeBundle.optJSONArray("target_candidates");
|
||||
}
|
||||
if (candidates == null) {
|
||||
candidates = dataplaneSession == null ? null : dataplaneSession.optJSONArray("exit_candidates");
|
||||
}
|
||||
if (candidates != null) {
|
||||
for (int i = 0; i < candidates.length(); i++) {
|
||||
JSONObject candidate = candidates.optJSONObject(i);
|
||||
if (!isUsableFabricQUICCandidate(candidate)) {
|
||||
continue;
|
||||
}
|
||||
JSONObject endpoint = new JSONObject();
|
||||
endpoint.put("endpoint_id", candidate.optString("endpoint_id", candidate.optString("address", "")));
|
||||
endpoint.put("node_id", candidate.optString("node_id", out.optString("exit_node_id", "")));
|
||||
endpoint.put("transport", firstNonEmpty(candidate.optString("transport", ""), "direct_quic"));
|
||||
endpoint.put("address", candidate.optString("address", ""));
|
||||
endpoint.put("priority", candidate.optInt("priority", 1000 + i));
|
||||
endpoint.put("peer_cert_sha256", firstNonEmpty(
|
||||
candidate.optString("peer_cert_sha256", ""),
|
||||
candidate.optString("tls_cert_sha256", ""),
|
||||
nestedString(candidate, "metadata", "peer_cert_sha256"),
|
||||
nestedString(candidate, "metadata", "tls_cert_sha256")));
|
||||
endpoint.put("tls_cert_sha256", endpoint.optString("peer_cert_sha256", ""));
|
||||
endpointCache.put(endpoint);
|
||||
}
|
||||
}
|
||||
out.put("deprecated_runtime_endpoint_cache", endpointCache);
|
||||
return out.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isUsableFabricQUICCandidate(JSONObject candidate) {
|
||||
if (candidate == null) {
|
||||
return false;
|
||||
}
|
||||
String address = candidate.optString("address", "").trim();
|
||||
String transport = candidate.optString("transport", "").trim();
|
||||
if (address.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return address.toLowerCase(Locale.US).startsWith("quic://")
|
||||
|| "quic".equalsIgnoreCase(transport)
|
||||
|| "direct_quic".equalsIgnoreCase(transport);
|
||||
}
|
||||
|
||||
private String nestedString(JSONObject object, String parent, String child) {
|
||||
JSONObject nested = object == null ? null : object.optJSONObject(parent);
|
||||
return nested == null ? "" : nested.optString(child, "");
|
||||
}
|
||||
|
||||
private String firstNonEmpty(String... values) {
|
||||
if (values == null) {
|
||||
return "";
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private int parseMtu(int mtu) {
|
||||
if (mtu <= 0) {
|
||||
return DEFAULT_VPN_MTU;
|
||||
@@ -691,10 +784,33 @@ public class RapVpnService extends VpnService {
|
||||
}
|
||||
|
||||
private String selectDataplanePacketTransport(JSONObject dataplaneSession) {
|
||||
if (isMeshNodeRouteDataplane(dataplaneSession)) {
|
||||
return "fabric_mesh_node_route_v1";
|
||||
}
|
||||
JSONObject candidate = selectSafeEntryDirectHTTPCandidate(dataplaneSession);
|
||||
return candidate == null ? "" : "entry_direct_http_v1";
|
||||
}
|
||||
|
||||
private boolean isMeshNodeRouteDataplane(JSONObject dataplaneSession) {
|
||||
if (dataplaneSession == null) {
|
||||
return false;
|
||||
}
|
||||
if ("fabric_mesh_node_route_v1".equals(dataplaneSession.optString("preferred_transport", ""))) {
|
||||
return true;
|
||||
}
|
||||
JSONArray transportCandidates = dataplaneSession.optJSONArray("transport_candidates");
|
||||
if (transportCandidates == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < transportCandidates.length(); i++) {
|
||||
JSONObject candidate = transportCandidates.optJSONObject(i);
|
||||
if (candidate != null && "fabric_mesh_node_route_v1".equals(candidate.optString("type", ""))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<String> selectDataplanePacketRelayBaseUrls(JSONObject dataplaneSession, String backendUrl) {
|
||||
List<String> urls = new ArrayList<>();
|
||||
JSONObject candidate = selectSafeEntryDirectHTTPCandidate(dataplaneSession);
|
||||
@@ -787,6 +903,37 @@ public class RapVpnService extends VpnService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String summarizeFabricMeshEndpoints(JSONArray candidates) {
|
||||
if (candidates == null || candidates.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
List<String> endpoints = new ArrayList<>();
|
||||
for (int i = 0; i < candidates.length(); i++) {
|
||||
JSONObject candidate = candidates.optJSONObject(i);
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
String address = candidate.optString("address", "").trim();
|
||||
if (address.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String transport = candidate.optString("transport", "").trim();
|
||||
if (!address.toLowerCase().startsWith("quic://")
|
||||
&& !"quic".equalsIgnoreCase(transport)
|
||||
&& !"direct_quic".equalsIgnoreCase(transport)) {
|
||||
continue;
|
||||
}
|
||||
String nodeId = candidate.optString("node_id", "").trim();
|
||||
String endpointId = candidate.optString("endpoint_id", "").trim();
|
||||
String label = nodeId.isEmpty() ? address : nodeId + "=" + address;
|
||||
if (!endpointId.isEmpty()) {
|
||||
label += "#" + endpointId;
|
||||
}
|
||||
endpoints.add(label);
|
||||
}
|
||||
return joinList(endpoints);
|
||||
}
|
||||
|
||||
private String normalizeHTTPBaseUrl(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
@@ -879,12 +1026,16 @@ public class RapVpnService extends VpnService {
|
||||
.putString("dataplane_entry_node_id", config.dataplaneEntryNodeId)
|
||||
.putString("dataplane_exit_node_id", config.dataplaneExitNodeId)
|
||||
.putString("dataplane_selected_transport", config.dataplaneSelectedTransport)
|
||||
.putBoolean("mesh_node_route_mode", config.meshNodeRouteMode)
|
||||
.putString("fabric_runtime_config_json", config.fabricRuntimeConfigJson)
|
||||
.putString("packet_relay_profile_base_url", config.packetRelayBaseUrl)
|
||||
.putString("packet_relay_active_base_url", "")
|
||||
.putString("packet_relay_base_url", config.packetRelayBaseUrl)
|
||||
.putString("packet_relay_candidate_urls", joinList(config.packetRelayBaseUrls))
|
||||
.putInt("dataplane_transport_candidate_count", config.dataplaneTransportCandidateCount)
|
||||
.putInt("dataplane_entry_candidate_count", config.dataplaneEntryCandidateCount)
|
||||
.putInt("dataplane_exit_candidate_count", config.dataplaneExitCandidateCount)
|
||||
.putString("fabric_mesh_exit_endpoints", config.fabricMeshExitEndpoints)
|
||||
.commit();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
@@ -1001,6 +1152,143 @@ public class RapVpnService extends VpnService {
|
||||
return parts.length > 0 && !parts[0].isEmpty() ? parts[0] : "10.77.0.2";
|
||||
}
|
||||
|
||||
private boolean startFabricMeshNodeRuntime(String clusterId, String vpnConnectionId, String fabricRuntimeConfigJson) {
|
||||
if (tunnel == null || clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) {
|
||||
writeRuntimeStatus("error", "fabric runtime not started: tunnel/cluster/connection missing", 0, 0, 0, 0);
|
||||
return false;
|
||||
}
|
||||
if (fabricRuntimeConfigJson == null || fabricRuntimeConfigJson.trim().isEmpty()) {
|
||||
writeRuntimeStatus("error", "fabric runtime not started: missing QUIC exit pool config", 0, 0, 0, 0);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSONObject cfg = new JSONObject(fabricRuntimeConfigJson);
|
||||
JSONArray endpoints = cfg.optJSONArray("endpoints");
|
||||
boolean hasRouteLease = hasFabricRouteLeaseCandidates(cfg.optJSONObject("route_bundle"));
|
||||
JSONObject request = cfg.optJSONObject("service_channel_request");
|
||||
if (request == null || request.optString("schema_version", "").isEmpty()) {
|
||||
writeRuntimeStatus("error", "fabric runtime not started: missing service channel request", 0, 0, 0, 0);
|
||||
return false;
|
||||
}
|
||||
if (!hasRouteLease && (endpoints == null || endpoints.length() == 0)) {
|
||||
writeRuntimeStatus("error", "fabric runtime not started: no fabric route lease candidates", 0, 0, 0, 0);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
writeRuntimeStatus("error", "fabric runtime config invalid: " + e.getMessage(), 0, 0, 0, 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
stopPacketRelay();
|
||||
try {
|
||||
Fabricvpn.touch();
|
||||
Manager manager = Fabricvpn.newManager();
|
||||
manager.setSocketProtector(new SocketProtector() {
|
||||
@Override
|
||||
public boolean protect(long fd) {
|
||||
try {
|
||||
return RapVpnService.this.protect((int) fd);
|
||||
} catch (Exception e) {
|
||||
writeRuntimeDetail("socket_protect_failed", e.getMessage(), "fabric", 0, 1, e.getClass().getSimpleName(), -1);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
manager.start(fabricRuntimeConfigJson);
|
||||
fabricVpnManager = manager;
|
||||
} catch (Throwable e) {
|
||||
fabricVpnManager = null;
|
||||
writeRuntimeStatus("error", "fabric runtime start failed: " + e.getMessage(), 0, 0, 0, 1);
|
||||
writeRuntimeDetail("start_failed", "QUIC fabric vpn runtime failed: " + e.getMessage(), "fabric", 0, 1, e.getClass().getSimpleName(), -1);
|
||||
return false;
|
||||
}
|
||||
|
||||
running = true;
|
||||
long runtimeId = runtimeGeneration.incrementAndGet();
|
||||
runtimeStartedAt = System.currentTimeMillis();
|
||||
initializePacketRuntimeQueues();
|
||||
configureBackendBypass("");
|
||||
writeRuntimeStatus("fabric", "QUIC fabric VPN runtime connected " + vpnConnectionId, 0, 0, 0, 0);
|
||||
writeRuntimeDetail("running", fabricSnapshot(), "fabric", 0, 0, "", -1);
|
||||
uplinkThread = new Thread(() -> pumpTunToRelay(runtimeId, clusterId, vpnConnectionId), "rap-vpn-fabric-uplink");
|
||||
uplinkSenderThreads = new Thread[uplinkWorkerCount];
|
||||
for (int i = 0; i < uplinkWorkerCount; i++) {
|
||||
final int workerIndex = i;
|
||||
uplinkSenderThreads[i] = new Thread(() -> pumpUplinkQueueToRelay(runtimeId, workerIndex, clusterId, vpnConnectionId), "rap-vpn-fabric-uplink-sender-" + workerIndex);
|
||||
}
|
||||
downlinkThread = new Thread(() -> pumpFabricDownlinkToQueue(runtimeId), "rap-vpn-fabric-downlink-receiver");
|
||||
downlinkWriterThread = new Thread(() -> pumpDownlinkQueueToTun(runtimeId), "rap-vpn-fabric-downlink-writer");
|
||||
diagnosticWatchdogThread = new Thread(this::runDiagnosticServiceWatchdog, "rap-vpn-diagnostic-watchdog");
|
||||
uplinkThread.start();
|
||||
for (Thread senderThread : uplinkSenderThreads) {
|
||||
senderThread.start();
|
||||
}
|
||||
downlinkThread.start();
|
||||
downlinkWriterThread.start();
|
||||
diagnosticWatchdogThread.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean hasFabricRouteLeaseCandidates(JSONObject routeBundle) {
|
||||
if (routeBundle == null) {
|
||||
return false;
|
||||
}
|
||||
JSONObject lease = routeBundle.optJSONObject("route_lease");
|
||||
if (lease == null) {
|
||||
return false;
|
||||
}
|
||||
JSONObject primary = lease.optJSONObject("primary_path");
|
||||
if (hasUsableFabricQUICCandidate(primary == null ? null : primary.optJSONArray("endpoint_candidates"))) {
|
||||
return true;
|
||||
}
|
||||
JSONArray standby = lease.optJSONArray("warm_standby_paths");
|
||||
if (standby == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < standby.length(); i++) {
|
||||
JSONObject path = standby.optJSONObject(i);
|
||||
if (hasUsableFabricQUICCandidate(path == null ? null : path.optJSONArray("endpoint_candidates"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean hasUsableFabricQUICCandidate(JSONArray candidates) {
|
||||
if (candidates == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < candidates.length(); i++) {
|
||||
if (isUsableFabricQUICCandidate(candidates.optJSONObject(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void initializePacketRuntimeQueues() {
|
||||
uplinkWorkerCount = Math.max(UPLINK_WORKER_MIN_COUNT, Math.min(UPLINK_WORKER_MAX_COUNT, Math.max(1, Runtime.getRuntime().availableProcessors())));
|
||||
uplinkQueueOffersByWorker = createAtomicCounters(uplinkWorkerCount);
|
||||
uplinkQueueDropsByWorker = createAtomicCounters(uplinkWorkerCount);
|
||||
uplinkSenderPacketsByWorker = createAtomicCounters(uplinkWorkerCount);
|
||||
uplinkSenderErrorsByWorker = createAtomicCounters(uplinkWorkerCount);
|
||||
uplinkPriorityQueue = new ArrayBlockingQueue<>(PRIORITY_QUEUE_CAPACITY);
|
||||
uplinkQueues = new ArrayBlockingQueue[uplinkWorkerCount];
|
||||
for (int i = 0; i < uplinkWorkerCount; i++) {
|
||||
uplinkQueues[i] = new ArrayBlockingQueue<>(UPLINK_QUEUE_CAPACITY);
|
||||
}
|
||||
downlinkFlowQueueCount = Math.max(1, Math.min(DOWNLINK_FLOW_QUEUE_MAX_COUNT, Math.max(1, Runtime.getRuntime().availableProcessors())));
|
||||
downlinkQueueOffersByFlow = createAtomicCounters(downlinkFlowQueueCount);
|
||||
downlinkQueueDropsByFlow = createAtomicCounters(downlinkFlowQueueCount);
|
||||
downlinkWriterPacketsByFlow = createAtomicCounters(downlinkFlowQueueCount);
|
||||
downlinkPriorityQueue = new ArrayBlockingQueue<>(PRIORITY_QUEUE_CAPACITY);
|
||||
downlinkQueues = new ArrayBlockingQueue[downlinkFlowQueueCount];
|
||||
int downlinkPerFlowCapacity = Math.max(512, DOWNLINK_QUEUE_CAPACITY / downlinkFlowQueueCount);
|
||||
for (int i = 0; i < downlinkFlowQueueCount; i++) {
|
||||
downlinkQueues[i] = new ArrayBlockingQueue<>(downlinkPerFlowCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
private void startPacketRelay(String backendUrl, List<String> candidateUrls, String clusterId, String vpnConnectionId) {
|
||||
if (tunnel == null || backendUrl == null || backendUrl.isEmpty() || clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) {
|
||||
Log.e(TAG, "packet relay not started: tunnel=" + (tunnel != null)
|
||||
@@ -1316,6 +1604,14 @@ public class RapVpnService extends VpnService {
|
||||
if (relay != null) {
|
||||
relay.close();
|
||||
}
|
||||
Manager manager = fabricVpnManager;
|
||||
fabricVpnManager = null;
|
||||
if (manager != null) {
|
||||
try {
|
||||
manager.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
closeTunHandles();
|
||||
interruptAndJoin(uplinkThread);
|
||||
if (uplinkSenderThreads != null) {
|
||||
@@ -1602,7 +1898,7 @@ public class RapVpnService extends VpnService {
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
int stale = staleTCPHandshakeCount();
|
||||
int rdpStale = staleRdpTCPHandshakeCount();
|
||||
int priorityStale = stalePriorityTCPHandshakeCount();
|
||||
long downlinkPackets = downlinkReceivedPackets.get();
|
||||
long uplinkPackets = uplinkSentPackets.get();
|
||||
boolean downlinkProgressed = downlinkPackets > lastRuntimeWatchdogDownlinkPackets;
|
||||
@@ -1618,16 +1914,16 @@ public class RapVpnService extends VpnService {
|
||||
}
|
||||
if (downlinkProgressed) {
|
||||
runtimeWatchdogStaleRounds = 0;
|
||||
writeRuntimeDetail("watchdog_observed_downlink", "stale=" + stale + " rdp_stale=" + rdpStale + " downlink_progress=true uplink_progress=" + uplinkProgressed, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1);
|
||||
writeRuntimeDetail("watchdog_observed_downlink", "stale=" + stale + " priority_stale=" + priorityStale + " downlink_progress=true uplink_progress=" + uplinkProgressed, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1);
|
||||
continue;
|
||||
}
|
||||
runtimeWatchdogStaleRounds++;
|
||||
if (rdpStale > 0 && now - lastRuntimeWatchdogRecoveryAt >= RUNTIME_WATCHDOG_RECOVERY_COOLDOWN_MS) {
|
||||
if (priorityStale > 0 && now - lastRuntimeWatchdogRecoveryAt >= RUNTIME_WATCHDOG_RECOVERY_COOLDOWN_MS) {
|
||||
runtimeWatchdogStaleRounds = 0;
|
||||
tcpHandshakeStalls.addAndGet(rdpStale);
|
||||
tcpHandshakeStalls.addAndGet(priorityStale);
|
||||
runtimeWatchdogRecoveries.incrementAndGet();
|
||||
lastRuntimeWatchdogRecoveryAt = now;
|
||||
recoverPacketRelayRuntime(clusterId, vpnConnectionId, "rdp_tcp_handshake_stall stale=" + rdpStale);
|
||||
recoverPacketRelayRuntime(clusterId, vpnConnectionId, "priority_tcp_handshake_stall stale=" + priorityStale);
|
||||
continue;
|
||||
}
|
||||
boolean relayOpen = isPacketWebSocketRelayOpen();
|
||||
@@ -1808,11 +2104,11 @@ public class RapVpnService extends VpnService {
|
||||
return staleTCPHandshakeCount(RUNTIME_WATCHDOG_STALE_SYNACK_MS, false);
|
||||
}
|
||||
|
||||
private int staleRdpTCPHandshakeCount() {
|
||||
return staleTCPHandshakeCount(RUNTIME_WATCHDOG_RDP_STALE_SYNACK_MS, true);
|
||||
private int stalePriorityTCPHandshakeCount() {
|
||||
return staleTCPHandshakeCount(RUNTIME_WATCHDOG_PRIORITY_STALE_SYNACK_MS, true);
|
||||
}
|
||||
|
||||
private int staleTCPHandshakeCount(int staleAfterMs, boolean rdpOnly) {
|
||||
private int staleTCPHandshakeCount(int staleAfterMs, boolean priorityOnly) {
|
||||
long now = System.currentTimeMillis();
|
||||
int stale = 0;
|
||||
synchronized (pendingTcpHandshakes) {
|
||||
@@ -1829,7 +2125,7 @@ public class RapVpnService extends VpnService {
|
||||
remove.add(entry.getKey());
|
||||
continue;
|
||||
}
|
||||
if (rdpOnly && !entry.getKey().contains("|3389|")) {
|
||||
if (priorityOnly && !isPriorityTCPFlowKey(entry.getKey())) {
|
||||
continue;
|
||||
}
|
||||
if (age >= staleAfterMs) {
|
||||
@@ -2309,8 +2605,11 @@ public class RapVpnService extends VpnService {
|
||||
int ihl = (packet[0] & 0x0f) * 4;
|
||||
int tcpHeaderLength = ((packet[ihl + 12] >> 4) & 0x0f) * 4;
|
||||
int tcpPayloadLength = Math.max(0, ipTotalLength - ihl - tcpHeaderLength);
|
||||
boolean rdp = flow.srcPort == 3389 || flow.dstPort == 3389;
|
||||
return rdp || syn || fin || rst || (ack && tcpPayloadLength == 0) || (psh && tcpPayloadLength <= 96);
|
||||
return syn || fin || rst || (ack && tcpPayloadLength == 0) || (psh && tcpPayloadLength <= 96);
|
||||
}
|
||||
|
||||
private boolean isPriorityTCPFlowKey(String key) {
|
||||
return key != null && !key.isEmpty();
|
||||
}
|
||||
|
||||
private boolean clampIPv4TCPMSS(byte[] packet, int length, int maxMss) {
|
||||
@@ -2619,6 +2918,10 @@ public class RapVpnService extends VpnService {
|
||||
}
|
||||
|
||||
private boolean sendUplinkBatchWithRetry(String clusterId, String vpnConnectionId, List<byte[]> batch, int workerIndex) {
|
||||
Manager manager = fabricVpnManager;
|
||||
if (manager != null) {
|
||||
return sendFabricUplinkBatch(manager, batch, workerIndex);
|
||||
}
|
||||
Exception lastError = null;
|
||||
lastUplinkSendErrorMessage = "";
|
||||
int relayAttempts = Math.max(1, activePacketRelayUrlsByProfile == null ? 1 : activePacketRelayUrlsByProfile.size());
|
||||
@@ -2677,6 +2980,108 @@ public class RapVpnService extends VpnService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean sendFabricUplinkBatch(Manager manager, List<byte[]> batch, int workerIndex) {
|
||||
if (manager == null || batch == null || batch.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
for (byte[] packet : batch) {
|
||||
if (packet != null && packet.length > 0) {
|
||||
manager.sendPacket(packet);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
lastUplinkSendErrorMessage = compactException(e);
|
||||
writeRuntimeDetail("fabric_send_failed", "fabric QUIC send failed worker=" + workerIndex + " error=" + e.getMessage(), "fabric", -1, 1, e.getClass().getSimpleName(), workerIndex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void pumpFabricDownlinkToQueue(long runtimeId) {
|
||||
long fetchedPackets = 0;
|
||||
long errors = 0;
|
||||
while (isRuntimeActive(runtimeId)) {
|
||||
Manager manager = fabricVpnManager;
|
||||
if (manager == null) {
|
||||
sleepQuietly(50);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
byte[] packet = manager.receivePacket(DOWNLINK_POLL_MS_MAX);
|
||||
if (packet == null || packet.length == 0) {
|
||||
if (fetchedPackets > 0) {
|
||||
writeRuntimeDetail("idle", fabricSnapshot(), "fabric_downlink", fetchedPackets, errors, "");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isIPv4Packet(packet)) {
|
||||
recordDownlinkDrop(packet.length);
|
||||
continue;
|
||||
}
|
||||
int length = effectiveIPv4Length(packet, packet.length);
|
||||
if (length <= 0) {
|
||||
errors++;
|
||||
recordDownlinkDrop(packet.length);
|
||||
writeRuntimeDetail("length_drop", packetSummary(packet, packet.length), "fabric_downlink", fetchedPackets, errors, "LENGTH");
|
||||
continue;
|
||||
}
|
||||
boolean restoredClientNAT = restoreClientSourceNATDestination(packet, length);
|
||||
if (!fastPathModeEnabled && !hasIPv4Destination(packet, length)) {
|
||||
long mismatch = downlinkDestinationMismatchPackets.incrementAndGet();
|
||||
if (mismatch <= ADDRESS_MISMATCH_TOLERANCE_PACKETS) {
|
||||
recordDownlinkDrop(length);
|
||||
writeRuntimeDetail("dest_drop", packetSummary(packet, length), "fabric_downlink", fetchedPackets, errors, "DEST_MISMATCH");
|
||||
continue;
|
||||
}
|
||||
relaxedDownlinkDestinationValidation = true;
|
||||
}
|
||||
boolean transportChecksumWasValid = hasValidIPv4TransportChecksum(packet, length);
|
||||
boolean normalized = normalizeIPv4PacketChecksums(packet, length);
|
||||
if (normalized && (!transportChecksumWasValid || restoredClientNAT)) {
|
||||
downlinkTransportChecksumRepairs.incrementAndGet();
|
||||
}
|
||||
if (!normalized) {
|
||||
errors++;
|
||||
recordDownlinkDrop(length);
|
||||
writeRuntimeDetail("normalize_drop", packetSummary(packet, length), "fabric_downlink", fetchedPackets, errors, "CHECKSUM_NORMALIZE");
|
||||
continue;
|
||||
}
|
||||
recordInboundTCPHandshake(packet, length);
|
||||
if (offerDownlinkPacket(packet, length)) {
|
||||
fetchedPackets++;
|
||||
if (fetchedPackets <= 5 || fetchedPackets % 25 == 0) {
|
||||
writeRuntimeStatus("fabric_downlink", "queued " + packetSummary(packet, length), 0, 0, downlinkReceivedPackets.get(), errors);
|
||||
}
|
||||
} else if (running) {
|
||||
errors++;
|
||||
recordDownlinkDrop(length);
|
||||
writeRuntimeDetail("queue_drop", packetSummary(packet, length), "fabric_downlink", fetchedPackets, errors, "QUEUE_FULL");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
if (!isRuntimeActive(runtimeId)) {
|
||||
return;
|
||||
}
|
||||
errors++;
|
||||
writeRuntimeStatus("degraded", "fabric downlink receive failed: " + e.getMessage(), 0, 0, fetchedPackets, errors);
|
||||
writeRuntimeDetail("receive_failed", "fabric receive failed: " + e.getMessage(), "fabric_downlink", fetchedPackets, errors, e.getClass().getSimpleName());
|
||||
sleepQuietly(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String fabricSnapshot() {
|
||||
Manager manager = fabricVpnManager;
|
||||
if (manager == null) {
|
||||
return "fabric runtime not connected";
|
||||
}
|
||||
try {
|
||||
return manager.snapshotJSON();
|
||||
} catch (Exception e) {
|
||||
return "fabric snapshot failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String lastWebSocketRelayError() {
|
||||
VpnPacketWebSocketRelay relay = packetWebSocketRelay;
|
||||
if (relay == null) {
|
||||
@@ -3407,7 +3812,7 @@ public class RapVpnService extends VpnService {
|
||||
}
|
||||
}
|
||||
|
||||
private String compactException(Exception e) {
|
||||
private String compactException(Throwable e) {
|
||||
if (e == null) {
|
||||
return "";
|
||||
}
|
||||
@@ -3974,11 +4379,15 @@ public class RapVpnService extends VpnService {
|
||||
String dataplaneEntryNodeId = "";
|
||||
String dataplaneExitNodeId = "";
|
||||
String dataplaneSelectedTransport = "";
|
||||
boolean meshNodeRouteMode;
|
||||
String fabricMeshExitEndpoints = "";
|
||||
String fabricRuntimeConfigJson = "";
|
||||
String packetRelayBaseUrl = "";
|
||||
final List<String> packetRelayBaseUrls = new ArrayList<>();
|
||||
FabricServiceChannel fabricServiceChannel = new FabricServiceChannel();
|
||||
int dataplaneTransportCandidateCount = 0;
|
||||
int dataplaneEntryCandidateCount = 0;
|
||||
int dataplaneExitCandidateCount = 0;
|
||||
final Set<String> configNotes = new LinkedHashSet<>();
|
||||
final Set<String> dnsServers = new LinkedHashSet<>();
|
||||
final Set<String> dnsProbeDomains = new LinkedHashSet<>();
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user