3
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,18 +1,25 @@
|
||||
# RAP Android VPN
|
||||
|
||||
This is the Android client for the experimental RAP VPN service.
|
||||
This is the Android mobile node build with the `vpn-client` service enabled.
|
||||
|
||||
Implemented now:
|
||||
|
||||
- login through `/auth/login`;
|
||||
- trusted-device reconnect through `/auth/refresh` without retyping the password
|
||||
while the device session is valid;
|
||||
- load organization-scoped VPN client profile from `/clusters/{clusterID}/vpn/client-profile`;
|
||||
- installation as a first-class fabric node with an embedded QUIC bootstrap
|
||||
seed set. The seed set is not a backend selector: it contains every known
|
||||
public or local entry candidate that may help the node join the fabric from
|
||||
its current network.
|
||||
- runtime launch uses a persisted `fabric_bootstrap_config`, not a backend API
|
||||
URL. The Android node starts by attaching to the fabric through bootstrap
|
||||
peers and then discovers/uses services through fabric rules.
|
||||
- login and trusted-device refresh through the QUIC fabric control channel;
|
||||
- load organization-scoped VPN client profile through the fabric control channel;
|
||||
- request Android VPN permission and create a `VpnService` TUN interface;
|
||||
- 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.
|
||||
routed by the farm to an authorized `ipv4-egress` pool. The supported
|
||||
dataplane is the QUIC fabric runtime only. HTTP batch forwarding, WebSocket
|
||||
packet relay, direct backend packet relay, and old VPN protocols are removed
|
||||
from the runtime 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
|
||||
@@ -20,12 +27,23 @@ Implemented now:
|
||||
- encrypted refresh-token storage through Android Keystore. If the trusted
|
||||
device session is revoked or expires, the app asks for the password once and
|
||||
then rotates the device keys/profile again.
|
||||
- no separate diagnostic foreground service: runtime status is reported by the
|
||||
node/VPN service itself, so the Android build does not keep a parallel legacy
|
||||
control process alive.
|
||||
|
||||
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.
|
||||
currently contain only one node. A phone installed in a closed network may join
|
||||
through local seed nodes from that network; it does not need direct Internet
|
||||
access if a nearby fabric node can route onward.
|
||||
|
||||
Current code contract:
|
||||
|
||||
- Android control bootstrap field: `fabric_bootstrap_config`
|
||||
- Android runtime dataplane: QUIC `Fabricvpn` runtime only
|
||||
- Android runtime status keys: `fabric_transport_*`
|
||||
|
||||
Build from this repository on Windows:
|
||||
|
||||
|
||||
@@ -22,8 +22,12 @@ android {
|
||||
return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
}
|
||||
|
||||
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 defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: ""
|
||||
// This is a node bootstrap seed set, not an API/backend selector. The
|
||||
// Android app installs as a fabric node and tries every QUIC endpoint that
|
||||
// may be reachable from its current network: public nodes, LAN nodes, or a
|
||||
// closed-site neighbor that can route onward through the fabric.
|
||||
def defaultFabricBootstrapPeers = project.findProperty("RAP_ANDROID_FABRIC_BOOTSTRAP_PEERS") ?: "quic://94.141.118.222:19199#sha256=49892029a27db9c394a41bc4cb917d9cceb1f86219417c351764d2ed9d6bc683,quic://94.141.118.222:19191#sha256=72e51f1631b32c3a7d1e8732fe3325e0395a897a5aa31db645888c142e4ae401,quic://192.168.200.61:19134#sha256=72e51f1631b32c3a7d1e8732fe3325e0395a897a5aa31db645888c142e4ae401,quic://192.168.200.61:19132#sha256=8d28b75144d25d29e3b8f8022b6165258ce3cb0e227a2d9d97996839abb89c2a,quic://192.168.200.61:19133#sha256=a71b07e55b810f57b01696c485b765b336983e963238163085824bf04022ecaa,quic://192.168.200.85:18080#sha256=49892029a27db9c394a41bc4cb917d9cceb1f86219417c351764d2ed9d6bc683,quic://192.168.200.85:18081#sha256=2a3be67e6345943a36cfa1197a5879c2b112c81adc019fd1ee9d7dffbf188b57,quic://192.168.200.85:18082#sha256=a318c1a756ff43595635961768dfd1677afa7e2cbf945d724c107ff82426378a"
|
||||
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"
|
||||
|
||||
@@ -31,8 +35,8 @@ android {
|
||||
applicationId "su.cin.rapvpn"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 227
|
||||
versionName "0.2.227"
|
||||
versionCode 239
|
||||
versionName "0.2.239"
|
||||
buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\""
|
||||
buildConfigField "String", "FABRIC_BOOTSTRAP_PEERS", "\"${normalizeGradleString(defaultFabricBootstrapPeers)}\""
|
||||
buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -42,15 +42,6 @@
|
||||
android:value="vpn" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".RapDiagnosticService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="vpn-diagnostics" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".RapAutostartReceiver"
|
||||
android:exported="false">
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.util.Base64;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import okhttp3.Request;
|
||||
|
||||
final class FabricServiceChannel {
|
||||
final boolean enabled;
|
||||
final String channelId;
|
||||
final String token;
|
||||
final String pathTemplate;
|
||||
final String webSocketPathTemplate;
|
||||
final String authorityPayloadHeader;
|
||||
final String authoritySignatureHeader;
|
||||
final String serviceClass;
|
||||
final String channelClass;
|
||||
|
||||
FabricServiceChannel() {
|
||||
this(false, "", "", "", "", "", "", "", "");
|
||||
}
|
||||
|
||||
private FabricServiceChannel(
|
||||
boolean enabled,
|
||||
String channelId,
|
||||
String token,
|
||||
String pathTemplate,
|
||||
String webSocketPathTemplate,
|
||||
String authorityPayloadHeader,
|
||||
String authoritySignatureHeader,
|
||||
String serviceClass,
|
||||
String channelClass) {
|
||||
this.enabled = enabled;
|
||||
this.channelId = safe(channelId);
|
||||
this.token = safe(token);
|
||||
this.pathTemplate = safe(pathTemplate);
|
||||
this.webSocketPathTemplate = safe(webSocketPathTemplate);
|
||||
this.authorityPayloadHeader = safe(authorityPayloadHeader);
|
||||
this.authoritySignatureHeader = safe(authoritySignatureHeader);
|
||||
this.serviceClass = safe(serviceClass);
|
||||
this.channelClass = safe(channelClass);
|
||||
}
|
||||
|
||||
static FabricServiceChannel fromLease(JSONObject lease) {
|
||||
if (lease == null) {
|
||||
return new FabricServiceChannel();
|
||||
}
|
||||
JSONObject tokenObject = lease.optJSONObject("token");
|
||||
JSONObject entryHttp = lease.optJSONObject("entry_http");
|
||||
String channelId = lease.optString("channel_id", "");
|
||||
String token = tokenObject == null ? "" : tokenObject.optString("token", "");
|
||||
String pathTemplate = entryHttp == null ? "" : entryHttp.optString("path_template", "");
|
||||
String wsTemplate = entryHttp == null ? "" : entryHttp.optString("websocket_path_template", "");
|
||||
String serviceClass = lease.optString("service_class", "vpn_packets");
|
||||
String channelClass = "vpn_packet";
|
||||
JSONObject authoritySignature = lease.optJSONObject("authority_signature");
|
||||
JSONObject authorityPayload = lease.optJSONObject("authority_payload");
|
||||
String payloadHeader = authorityPayload == null ? "" : encodeHeader(authorityPayload.toString());
|
||||
String signatureHeader = authoritySignature == null ? "" : encodeHeader(authoritySignature.toString());
|
||||
boolean enabled = !channelId.isEmpty() && token.startsWith("rap_fsc_") && !pathTemplate.isEmpty();
|
||||
return new FabricServiceChannel(enabled, channelId, token, pathTemplate, wsTemplate, payloadHeader, signatureHeader, serviceClass, channelClass);
|
||||
}
|
||||
|
||||
String packetPath(String clusterId, String vpnConnectionId, boolean webSocket) {
|
||||
return packetPathForBase("", clusterId, vpnConnectionId, webSocket);
|
||||
}
|
||||
|
||||
String packetPathForBase(String baseUrl, String clusterId, String vpnConnectionId, boolean webSocket) {
|
||||
String template = webSocket && !webSocketPathTemplate.isEmpty() ? webSocketPathTemplate : pathTemplate;
|
||||
if (!enabled || template.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String path = template
|
||||
.replace("{cluster_id}", safe(clusterId))
|
||||
.replace("{clusterID}", safe(clusterId))
|
||||
.replace("{channel_id}", channelId)
|
||||
.replace("{channelID}", channelId)
|
||||
.replace("{resource_id}", safe(vpnConnectionId))
|
||||
.replace("{resourceID}", safe(vpnConnectionId))
|
||||
.replace("{vpn_connection_id}", safe(vpnConnectionId))
|
||||
.replace("{vpnConnectionID}", safe(vpnConnectionId));
|
||||
path = path.startsWith("/") ? path : "/" + path;
|
||||
String basePath = "";
|
||||
try {
|
||||
URI uri = URI.create(baseUrl == null ? "" : baseUrl);
|
||||
basePath = uri.getRawPath() == null ? "" : trimRight(uri.getRawPath());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (basePath.endsWith("/api/v1") && path.startsWith("/api/v1/")) {
|
||||
path = path.substring("/api/v1".length());
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
Request.Builder applyHeaders(Request.Builder builder) {
|
||||
if (!enabled || builder == null) {
|
||||
return builder;
|
||||
}
|
||||
builder.header("X-RAP-Service-Channel-Token", token);
|
||||
builder.header("X-RAP-Fabric-Channel-ID", channelId);
|
||||
if (!serviceClass.isEmpty()) {
|
||||
builder.header("X-RAP-Service-Class", serviceClass);
|
||||
}
|
||||
if (!channelClass.isEmpty()) {
|
||||
builder.header("X-RAP-Channel-Class", channelClass);
|
||||
}
|
||||
if (!authorityPayloadHeader.isEmpty()) {
|
||||
builder.header("X-RAP-Service-Channel-Authority-Payload", authorityPayloadHeader);
|
||||
}
|
||||
if (!authoritySignatureHeader.isEmpty()) {
|
||||
builder.header("X-RAP-Service-Channel-Authority-Signature", authoritySignatureHeader);
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static String encodeHeader(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return Base64.encodeToString(value.getBytes(StandardCharsets.UTF_8), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String trimRight(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,13 @@ 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 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";
|
||||
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_FABRIC_NODE_ID = "fabric_node_id";
|
||||
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";
|
||||
@@ -39,7 +38,6 @@ public class MainActivity extends Activity {
|
||||
private static final String PREF_PROFILE_JSON = "profile_json";
|
||||
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
|
||||
static final String PREF_FORCE_FULL_TUNNEL = "force_full_tunnel";
|
||||
private EditText backendUrl;
|
||||
private EditText clusterId;
|
||||
private EditText organizationId;
|
||||
private EditText email;
|
||||
@@ -66,7 +64,6 @@ public class MainActivity extends Activity {
|
||||
int pad = dp(20);
|
||||
root.setPadding(pad, pad, pad, pad);
|
||||
|
||||
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"));
|
||||
@@ -102,10 +99,6 @@ public class MainActivity extends Activity {
|
||||
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("Подключить");
|
||||
start.setOnClickListener(v -> prepareVpn());
|
||||
@@ -148,12 +141,11 @@ public class MainActivity extends Activity {
|
||||
});
|
||||
|
||||
Button settings = new Button(this);
|
||||
settings.setText("Аккаунт");
|
||||
settings.setText("Настройка");
|
||||
settings.setOnClickListener(v -> showSettingsDialog());
|
||||
|
||||
root.addView(title);
|
||||
root.addView(profileSummary);
|
||||
root.addView(load);
|
||||
root.addView(start);
|
||||
root.addView(stop);
|
||||
root.addView(settings);
|
||||
@@ -161,9 +153,7 @@ public class MainActivity extends Activity {
|
||||
root.addView(runtimeStatus);
|
||||
setContentView(root);
|
||||
scheduleRuntimeStatusRefresh();
|
||||
if (authContext != null && !authContext.deviceId.isEmpty()) {
|
||||
startDiagnosticChannel();
|
||||
}
|
||||
registerCandidateNodeAsync(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -179,62 +169,38 @@ public class MainActivity extends Activity {
|
||||
return input;
|
||||
}
|
||||
|
||||
private void loadProfile() {
|
||||
loadProfile(false);
|
||||
}
|
||||
|
||||
private void loadProfile(boolean startAfterLoad) {
|
||||
status.setText("Загрузка...");
|
||||
saveSettings();
|
||||
private void prepareVpn() {
|
||||
if (!hasSelectedPool()) {
|
||||
status.setText("Сначала выберите выходной пул.");
|
||||
showSettingsDialog();
|
||||
return;
|
||||
}
|
||||
status.setText("Проверяю доступ к выбранному пулу...");
|
||||
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,
|
||||
""
|
||||
);
|
||||
vpnConnectionId = firstConnectionId(profileJson);
|
||||
saveProfileState();
|
||||
refreshSavedProfileForCurrentUser();
|
||||
if (!hasSelectedPool()) {
|
||||
throw new IllegalStateException("Выбранный пул больше не доступен.");
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
profileSummary.setText(summaryText());
|
||||
status.setText(startAfterLoad ? "Список пулов обновлен. Подключаю..." : "Список доступных пулов обновлен.");
|
||||
startDiagnosticChannel();
|
||||
if (startAfterLoad) {
|
||||
requestVpnPermission();
|
||||
}
|
||||
status.setText("Доступ подтвержден. Подключаюсь к выбранному пулу.");
|
||||
requestVpnPermission();
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
runOnUiThread(() -> {
|
||||
String message = friendlyError(ex);
|
||||
boolean canUseSavedProfile = startAfterLoad && !profileJson.isEmpty() && !vpnConnectionId.isEmpty();
|
||||
if (canUseSavedProfile) {
|
||||
status.setText("Список пулов сейчас не обновился: " + message + ". Подключаюсь с сохраненным рабочим профилем.");
|
||||
startDiagnosticChannel();
|
||||
requestVpnPermission();
|
||||
return;
|
||||
}
|
||||
status.setText("Ошибка входа: " + message);
|
||||
if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) {
|
||||
clearSavedAuth(false);
|
||||
showSettingsDialog();
|
||||
}
|
||||
status.setText("Нужна настройка: " + message);
|
||||
showSettingsDialog();
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void prepareVpn() {
|
||||
loadProfile(true);
|
||||
status.setText("Обновляю сессию устройства и доступные пулы...");
|
||||
}
|
||||
|
||||
private void requestVpnPermission() {
|
||||
if (profileJson.isEmpty()) {
|
||||
status.setText("VPN-профиль не загружен.");
|
||||
if (!hasSelectedPool()) {
|
||||
status.setText("Выходной пул не выбран или больше не доступен.");
|
||||
showSettingsDialog();
|
||||
return;
|
||||
}
|
||||
Intent prepare = VpnService.prepare(this);
|
||||
@@ -254,32 +220,37 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
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 + ". Ожидаю рабочий канал.");
|
||||
runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText());
|
||||
runtimeStatus.postDelayed(() -> {
|
||||
String state = runtimePrefs.getString("state", "");
|
||||
boolean runtimeActive = isVpnRuntimeActive();
|
||||
if (!isSystemVpnActive()) {
|
||||
if (runtimeActive) {
|
||||
status.setText("VPN runtime активен, рабочий канал поднят. Android еще обновляет системный статус.");
|
||||
} else if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) {
|
||||
status.setText("VPN не включился: " + runtimePrefs.getString("message", "Android остановил VPN-сервис") + ".");
|
||||
} else if ("starting".equals(state) || "tunnel".equals(state) || "relay_selected".equals(state) || "relay".equals(state) || "relay_reset".equals(state)) {
|
||||
status.setText("VPN запускается. Android еще применяет туннель, ожидаю рабочий канал.");
|
||||
try {
|
||||
Intent intent = new Intent(this, RapVpnService.class);
|
||||
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson);
|
||||
intent.putExtra(RapVpnService.EXTRA_FABRIC_BOOTSTRAP_CONFIG, fabricControlConfig());
|
||||
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString());
|
||||
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
|
||||
startForegroundService(intent);
|
||||
status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал.");
|
||||
runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText());
|
||||
runtimeStatus.postDelayed(() -> {
|
||||
String state = runtimePrefs.getString("state", "");
|
||||
boolean runtimeActive = isVpnRuntimeActive();
|
||||
if (!isSystemVpnActive()) {
|
||||
if (runtimeActive) {
|
||||
status.setText("VPN runtime активен, рабочий канал поднят. Android еще обновляет системный статус.");
|
||||
} else if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) {
|
||||
status.setText("VPN не включился: " + runtimePrefs.getString("message", "Android остановил VPN-сервис") + ".");
|
||||
} else if ("starting".equals(state) || "tunnel".equals(state) || isTransportWarmupState(state)) {
|
||||
status.setText("VPN запускается. Android еще применяет туннель, ожидаю рабочий канал.");
|
||||
} else {
|
||||
status.setText("VPN еще не активен в Android. Проверьте системный запрос разрешения VPN.");
|
||||
}
|
||||
} else {
|
||||
status.setText("VPN еще не активен в Android. Проверьте системный запрос разрешения VPN.");
|
||||
status.setText("VPN включен Android. Версия " + APP_VERSION + ".");
|
||||
}
|
||||
} else {
|
||||
status.setText("VPN включен Android. Версия " + APP_VERSION + ".");
|
||||
}
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
}, 2500);
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
}, 2500);
|
||||
} catch (Exception e) {
|
||||
status.setText("VPN не запущен: bootstrap-конфиг фабрики недоступен.");
|
||||
runtimeStatus.setText("Ошибка запуска: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleRuntimeStatusRefresh() {
|
||||
@@ -335,9 +306,9 @@ public class MainActivity extends Activity {
|
||||
boolean osVpnActive = isSystemVpnActive();
|
||||
String routes = runtimePrefs.getString("routes", "");
|
||||
String dnsServers = runtimePrefs.getString("dns_servers", "");
|
||||
String profileRelayUrl = runtimePrefs.getString("packet_relay_profile_base_url", "");
|
||||
String activeRelayUrl = runtimePrefs.getString("packet_relay_active_base_url", "");
|
||||
String relayCandidates = runtimePrefs.getString("packet_relay_candidate_urls", "");
|
||||
String profileTransportEndpoint = runtimePrefs.getString("fabric_transport_profile_endpoint", "");
|
||||
String activeTransportEndpoint = runtimePrefs.getString("fabric_transport_active_endpoint", "");
|
||||
String transportCandidates = runtimePrefs.getString("fabric_transport_candidate_endpoints", "");
|
||||
boolean forceFullTunnelRuntime = false;
|
||||
boolean fastPathEnabled = false;
|
||||
try {
|
||||
@@ -350,11 +321,14 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
boolean staleState = updatedAt > 0 && (System.currentTimeMillis() - updatedAt) > 12_000;
|
||||
boolean runtimeActive = isVpnRuntimeActive();
|
||||
if (!osVpnActive && !runtimeActive && ("running".equals(state) || "tunnel".equals(state) || "relay".equals(state) || "relay_reset".equals(state))) {
|
||||
if (!osVpnActive && !runtimeActive && ("running".equals(state) || "tunnel".equals(state) || isTransportWarmupState(state))) {
|
||||
state = "stale_no_os_vpn";
|
||||
message = "Сервис говорит об активном состоянии, но Android VPN-интерфейс не активен. Проверьте разрешения/ручной запуск.";
|
||||
staleState = false;
|
||||
}
|
||||
String transportEndpoint = activeTransportEndpoint.isEmpty() ? "-" : activeTransportEndpoint;
|
||||
String transportTargets = transportCandidates.isEmpty() ? "-" : transportCandidates;
|
||||
String profileTarget = profileTransportEndpoint.isEmpty() ? "-" : profileTransportEndpoint;
|
||||
return "Диагностика: " + state
|
||||
+ "\n" + message
|
||||
+ "\nOS VPN: " + (osVpnActive ? "активен" : (runtimeActive ? "runtime активен" : "неактивен"))
|
||||
@@ -369,9 +343,9 @@ public class MainActivity extends Activity {
|
||||
+ " / down " + String.format(Locale.US, "%.1f", downlinkPps)
|
||||
+ "\nDNS выхода: " + (dnsServers.isEmpty() ? "-" : dnsServers)
|
||||
+ "\nroutes: " + (routes.isEmpty() ? "-" : routes)
|
||||
+ "\nrelay active: " + (activeRelayUrl.isEmpty() ? "-" : activeRelayUrl)
|
||||
+ "\nrelay profile: " + (profileRelayUrl.isEmpty() ? "-" : profileRelayUrl)
|
||||
+ "\nrelay candidates: " + (relayCandidates.isEmpty() ? "-" : relayCandidates)
|
||||
+ "\ntransport endpoint: " + transportEndpoint
|
||||
+ "\nprofile target: " + profileTarget
|
||||
+ "\ntransport candidates: " + transportTargets
|
||||
+ "\nforced_full_tunnel: " + (forceFullTunnelRuntime ? "да" : "нет")
|
||||
+ "\nfast_path_mode: " + (fastPathEnabled ? "включен" : "выключен")
|
||||
+ "\nbytes read/sent/down: " + readBytes + "/" + sentBytes + "/" + downBytes
|
||||
@@ -389,13 +363,6 @@ public class MainActivity extends Activity {
|
||||
+ "\nобновлено: " + age;
|
||||
}
|
||||
|
||||
private void startDiagnosticChannel() {
|
||||
if (authContext == null || authContext.deviceId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
RapDiagnosticService.start(this);
|
||||
}
|
||||
|
||||
private boolean isSystemVpnActive() {
|
||||
try {
|
||||
ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
|
||||
@@ -426,20 +393,31 @@ public class MainActivity extends Activity {
|
||||
if (updatedAt <= 0 || (System.currentTimeMillis() - updatedAt) > 15_000) {
|
||||
return false;
|
||||
}
|
||||
String relay = runtimePrefs.getString("packet_relay_active_base_url", "");
|
||||
String activeTransportEndpoint = runtimePrefs.getString("fabric_transport_active_endpoint", "");
|
||||
long read = runtimePrefs.getLong("uplink_read_total", 0);
|
||||
long sent = runtimePrefs.getLong("uplink_sent_total", 0);
|
||||
long down = runtimePrefs.getLong("downlink_received_total", 0);
|
||||
return !relay.isEmpty() && ("running".equals(state)
|
||||
|| "relay".equals(state)
|
||||
|| "relay_reset".equals(state)
|
||||
return !activeTransportEndpoint.isEmpty() && ("running".equals(state)
|
||||
|| "fabric_transport".equals(state)
|
||||
|| "fabric_transport_reset".equals(state)
|
||||
|| "downlink".equals(state)
|
||||
|| "downlink_idle".equals(state)
|
||||
|| "uplink_sent".equals(state)
|
||||
|| read > 0 || sent > 0 || down > 0);
|
||||
}
|
||||
|
||||
private boolean isTransportWarmupState(String state) {
|
||||
return "fabric_transport_selected".equals(state)
|
||||
|| "fabric_transport".equals(state)
|
||||
|| "fabric_transport_reset".equals(state)
|
||||
|| "fabric_transport_switch".equals(state);
|
||||
}
|
||||
|
||||
private String firstConnectionId(String profile) throws Exception {
|
||||
String selected = prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, "").trim();
|
||||
if (!selected.isEmpty() && profileContainsConnection(profile, selected)) {
|
||||
return selected;
|
||||
}
|
||||
JSONObject root = new JSONObject(profile);
|
||||
JSONObject vpnProfile = root.getJSONObject("vpn_client_profile");
|
||||
JSONArray connections = vpnProfile.getJSONArray("connections");
|
||||
@@ -489,6 +467,36 @@ public class MainActivity extends Activity {
|
||||
return connections.getJSONObject(0).getString("id");
|
||||
}
|
||||
|
||||
private boolean hasSelectedPool() {
|
||||
return profileJson != null
|
||||
&& !profileJson.trim().isEmpty()
|
||||
&& vpnConnectionId != null
|
||||
&& !vpnConnectionId.trim().isEmpty()
|
||||
&& profileContainsConnection(profileJson, vpnConnectionId.trim());
|
||||
}
|
||||
|
||||
private boolean profileContainsConnection(String profile, String connectionId) {
|
||||
if (profile == null || profile.trim().isEmpty() || connectionId == null || connectionId.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSONObject root = new JSONObject(profile);
|
||||
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
|
||||
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
|
||||
if (connections == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < connections.length(); i++) {
|
||||
JSONObject connection = connections.optJSONObject(i);
|
||||
if (connection != null && connectionId.trim().equals(connection.optString("id", ""))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return (int) (value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
@@ -504,8 +512,8 @@ public class MainActivity extends Activity {
|
||||
return "Версия: " + APP_VERSION
|
||||
+ "\nУзел Android: в ферме"
|
||||
+ "\nBootstrap фермы: " + bootstrapPeerCount() + " узл."
|
||||
+ "\nДоступные выходы: " + (poolText.isEmpty() ? "войдите для загрузки" : poolText)
|
||||
+ "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "автоматически" : selectedPoolText)
|
||||
+ "\nДоступные выходы: " + (poolText.isEmpty() ? "не загружены" : poolText)
|
||||
+ "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "не выбран" : selectedPoolText)
|
||||
+ "\nDNS выхода: " + (profileDNS.isEmpty() ? "будет получен из профиля" : profileDNS)
|
||||
+ "\nТрафик: " + (prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true) ? "весь через VPN" : "по профилю")
|
||||
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
|
||||
@@ -647,20 +655,7 @@ public class MainActivity extends Activity {
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private String preferredBackendUrl() {
|
||||
String saved = prefs.getString("backend_url", DEFAULT_BACKEND_URL);
|
||||
String normalized = normalizeBackendUrl(saved);
|
||||
if (!normalized.equals(saved == null ? "" : saved.trim())) {
|
||||
prefs.edit().putString("backend_url", normalized).apply();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void saveSettings() {
|
||||
String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString());
|
||||
if (!normalizedBackend.equals(backendUrl.getText().toString().trim())) {
|
||||
backendUrl.setText(normalizedBackend);
|
||||
}
|
||||
normalizeAndPersistDefaults();
|
||||
if (clusterId.getText().toString().trim().isEmpty()) {
|
||||
clusterId.setText(DEFAULT_CLUSTER_ID);
|
||||
@@ -669,7 +664,6 @@ public class MainActivity extends Activity {
|
||||
organizationId.setText(DEFAULT_ORGANIZATION_ID);
|
||||
}
|
||||
prefs.edit()
|
||||
.putString("backend_url", normalizedBackend)
|
||||
.putString("cluster_id", clusterId.getText().toString())
|
||||
.putString("organization_id", organizationId.getText().toString())
|
||||
.putString("email", email.getText().toString())
|
||||
@@ -677,10 +671,6 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private void normalizeAndPersistDefaults() {
|
||||
String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString());
|
||||
if (normalizedBackend.isEmpty()) {
|
||||
backendUrl.setText(DEFAULT_BACKEND_URL);
|
||||
}
|
||||
if (clusterId.getText().toString().trim().isEmpty()) {
|
||||
clusterId.setText(DEFAULT_CLUSTER_ID);
|
||||
}
|
||||
@@ -689,38 +679,48 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeBackendUrl(String value) {
|
||||
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
|
||||
if (candidate.isEmpty()) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
private String fabricControlConfig() throws Exception {
|
||||
JSONArray endpoints = new JSONArray();
|
||||
for (String peer : FABRIC_BOOTSTRAP_PEERS.split(",")) {
|
||||
String raw = peer == null ? "" : peer.trim();
|
||||
String address = raw;
|
||||
String certSHA256 = "";
|
||||
int fragmentIndex = raw.indexOf('#');
|
||||
if (fragmentIndex >= 0) {
|
||||
address = raw.substring(0, fragmentIndex).trim();
|
||||
String fragment = raw.substring(fragmentIndex + 1).trim();
|
||||
if (fragment.startsWith("sha256=")) {
|
||||
certSHA256 = fragment.substring("sha256=".length()).trim();
|
||||
}
|
||||
}
|
||||
if (address.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
JSONObject endpoint = new JSONObject();
|
||||
endpoint.put("endpoint_id", address);
|
||||
endpoint.put("address", address);
|
||||
endpoint.put("transport", "direct_quic");
|
||||
if (certSHA256.matches("^[0-9a-fA-F]{64}$")) {
|
||||
endpoint.put("peer_cert_sha256", certSHA256.toLowerCase(Locale.US));
|
||||
}
|
||||
endpoints.put(endpoint);
|
||||
}
|
||||
String lower = candidate.toLowerCase(Locale.US);
|
||||
if ("http://vpn.cin.su:19191/api/v1".equals(lower)
|
||||
|| "http://vpn.cin.su/api/v1".equals(lower)
|
||||
|| "https://vpn.cin.su:443/api/v1".equals(lower)
|
||||
|| "http://94.141.118.222:19191/api/v1".equals(lower)
|
||||
|| "http://195.123.240.88:19131/api/v1".equals(lower)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
if (endpoints.length() == 0) {
|
||||
throw new IllegalStateException("В клиенте нет bootstrap-узлов фермы.");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String selectedExitNodeId() {
|
||||
return "";
|
||||
}
|
||||
|
||||
private String normalizeSelectedExitNodeId(String value) {
|
||||
String candidate = value == null ? "" : value.trim();
|
||||
if (candidate.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (candidate.matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) {
|
||||
return candidate;
|
||||
}
|
||||
if (candidate.matches("^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$")) {
|
||||
return candidate;
|
||||
}
|
||||
return "";
|
||||
JSONObject service = new JSONObject();
|
||||
service.put("schema_version", "rap.fabric_service_channel_request.v1");
|
||||
service.put("channel_id", "android-control");
|
||||
service.put("service_class", "identity_runtime");
|
||||
service.put("source_role", "vpn-client");
|
||||
JSONObject cfg = new JSONObject();
|
||||
cfg.put("cluster_id", DEFAULT_CLUSTER_ID);
|
||||
cfg.put("local_node_id", fabricNodeId());
|
||||
cfg.put("vpn_connection_id", "fabric-control");
|
||||
cfg.put("stream_shards", 1);
|
||||
cfg.put("service_channel_request", service);
|
||||
cfg.put("endpoints", endpoints);
|
||||
return cfg.toString();
|
||||
}
|
||||
|
||||
private RapApiClient.AuthContext authenticate(RapApiClient client) throws Exception {
|
||||
@@ -743,6 +743,44 @@ public class MainActivity extends Activity {
|
||||
return loggedIn;
|
||||
}
|
||||
|
||||
private RapApiClient.AuthContext authenticateWithPassword(RapApiClient client, String emailValue, String passwordValue) throws Exception {
|
||||
if (passwordValue == null || passwordValue.trim().isEmpty()) {
|
||||
throw new IllegalStateException("Введите пароль для идентификации устройства и выбора пула.");
|
||||
}
|
||||
RapApiClient.AuthContext loggedIn = client.login(emailValue.trim(), passwordValue.trim(), deviceFingerprint());
|
||||
saveAuthContext(loggedIn);
|
||||
return loggedIn;
|
||||
}
|
||||
|
||||
private void refreshSavedProfileForCurrentUser() throws Exception {
|
||||
String userId = prefs.getString(PREF_USER_ID, "");
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
throw new IllegalStateException("Устройство еще не привязано к пользователю.");
|
||||
}
|
||||
RapApiClient client = new RapApiClient(fabricControlConfig(), this);
|
||||
String refreshToken = savedRefreshToken();
|
||||
if (!refreshToken.isEmpty()) {
|
||||
authContext = client.refresh(refreshToken);
|
||||
saveAuthContext(authContext);
|
||||
userId = authContext.userId;
|
||||
}
|
||||
String activeOrganizationId = resolveOrganizationId(client, userId);
|
||||
String refreshedProfile = client.vpnClientProfile(
|
||||
clusterId.getText().toString(),
|
||||
activeOrganizationId,
|
||||
userId,
|
||||
""
|
||||
);
|
||||
if (!profileContainsConnection(refreshedProfile, vpnConnectionId)) {
|
||||
profileJson = refreshedProfile;
|
||||
vpnConnectionId = "";
|
||||
saveProfileState();
|
||||
throw new IllegalStateException("Администратор закрыл доступ к выбранному пулу или пул удален.");
|
||||
}
|
||||
profileJson = refreshedProfile;
|
||||
saveProfileState();
|
||||
}
|
||||
|
||||
private String resolveOrganizationId(RapApiClient client, String userId) throws Exception {
|
||||
JSONObject payload = client.organizations(userId);
|
||||
JSONArray organizations = payload.optJSONArray("organizations");
|
||||
@@ -850,6 +888,89 @@ public class MainActivity extends Activity {
|
||||
return generated;
|
||||
}
|
||||
|
||||
private String fabricNodeId() {
|
||||
String existing = prefs.getString(PREF_FABRIC_NODE_ID, "");
|
||||
if (existing != null && existing.matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) {
|
||||
return existing.toLowerCase(Locale.US);
|
||||
}
|
||||
String generated = java.util.UUID.randomUUID().toString();
|
||||
prefs.edit().putString(PREF_FABRIC_NODE_ID, generated).apply();
|
||||
return generated;
|
||||
}
|
||||
|
||||
private void registerCandidateNodeAsync(boolean showStatus) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
RapApiClient client = new RapApiClient(fabricControlConfig(), this);
|
||||
String nodeId = registerCandidateNode(client);
|
||||
sendCandidateHeartbeat(client, nodeId);
|
||||
if (showStatus) {
|
||||
runOnUiThread(() -> status.setText("Узел телефона виден ферме как кандидат: " + nodeId));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
if (showStatus) {
|
||||
runOnUiThread(() -> status.setText("Узел телефона пока не зарегистрирован в ферме: " + friendlyError(ex)));
|
||||
}
|
||||
}
|
||||
}, "rap-fabric-candidate-register").start();
|
||||
}
|
||||
|
||||
private String registerCandidateNode(RapApiClient client) throws Exception {
|
||||
String nodeId = fabricNodeId();
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("source", "android_vpn_client");
|
||||
metadata.put("candidate_access", true);
|
||||
metadata.put("fabric_transport", "quic");
|
||||
metadata.put("connectivity_mode", "outbound_only");
|
||||
metadata.put("app_version", APP_VERSION);
|
||||
metadata.put("device_fingerprint", deviceFingerprint());
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("cluster_id", clusterId.getText().toString().trim().isEmpty() ? DEFAULT_CLUSTER_ID : clusterId.getText().toString().trim());
|
||||
payload.put("node_key", "android-vpn:" + deviceFingerprint());
|
||||
payload.put("name", "android-vpn-" + deviceFingerprint().replace("android-", "").substring(0, Math.min(8, deviceFingerprint().replace("android-", "").length())));
|
||||
payload.put("ownership_type", "customer_managed");
|
||||
payload.put("owner_organization_id", organizationId.getText().toString().trim().isEmpty() ? DEFAULT_ORGANIZATION_ID : organizationId.getText().toString().trim());
|
||||
payload.put("reported_version", APP_VERSION);
|
||||
payload.put("metadata", metadata);
|
||||
JSONObject response = client.registerFabricNode(payload);
|
||||
String registeredNodeId = response.optString("node_id", nodeId).trim();
|
||||
if (!registeredNodeId.isEmpty()) {
|
||||
prefs.edit().putString(PREF_FABRIC_NODE_ID, registeredNodeId).apply();
|
||||
return registeredNodeId;
|
||||
}
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
private void sendCandidateHeartbeat(RapApiClient client, String nodeId) throws Exception {
|
||||
JSONObject capabilities = new JSONObject();
|
||||
capabilities.put("fabric_quic_node", true);
|
||||
capabilities.put("android_vpn_client", true);
|
||||
capabilities.put("candidate_access", true);
|
||||
capabilities.put("vpn_client", true);
|
||||
JSONObject serviceStates = new JSONObject();
|
||||
serviceStates.put("vpn-client", new JSONObject()
|
||||
.put("state", isSystemVpnActive() ? "running" : "candidate")
|
||||
.put("runtime", "android_vpnservice")
|
||||
.put("transport", "fabric_quic_route"));
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("source", "android_vpn_client");
|
||||
metadata.put("candidate", true);
|
||||
metadata.put("passive", true);
|
||||
metadata.put("app_version", APP_VERSION);
|
||||
metadata.put("mesh_endpoint_report", new JSONObject()
|
||||
.put("schema_version", "rap.mesh_endpoint_report.v1")
|
||||
.put("transport", "quic")
|
||||
.put("connectivity_mode", "outbound_only")
|
||||
.put("endpoint_candidates", new JSONArray()));
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("health_status", "healthy");
|
||||
payload.put("reported_version", APP_VERSION);
|
||||
payload.put("capabilities", capabilities);
|
||||
payload.put("service_states", serviceStates);
|
||||
payload.put("metadata", metadata);
|
||||
client.sendFabricNodeHeartbeat(clusterId.getText().toString().trim().isEmpty() ? DEFAULT_CLUSTER_ID : clusterId.getText().toString().trim(), nodeId, payload);
|
||||
}
|
||||
|
||||
private void showSettingsDialog() {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
@@ -877,17 +998,15 @@ public class MainActivity extends Activity {
|
||||
form.addView(showPassword);
|
||||
form.addView(forceFullTunnel);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Аккаунт VPN")
|
||||
.setTitle("Настройка VPN")
|
||||
.setView(form)
|
||||
.setPositiveButton("Сохранить", (dialog, which) -> {
|
||||
.setPositiveButton("Войти и выбрать выход", (dialog, which) -> {
|
||||
email.setText(emailDraft.getText().toString());
|
||||
password.setText(passwordDraft.getText().toString());
|
||||
prefs.edit()
|
||||
.remove(PREF_SELECTED_EXIT_NODE_ID)
|
||||
.apply();
|
||||
String passwordValue = passwordDraft.getText().toString();
|
||||
password.setText("");
|
||||
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, forceFullTunnel.isChecked()).apply();
|
||||
saveSettings();
|
||||
profileSummary.setText(summaryText());
|
||||
loginAndChoosePool(emailDraft.getText().toString(), passwordValue);
|
||||
})
|
||||
.setNeutralButton("Забыть устройство", (dialog, which) -> {
|
||||
clearSavedAuth(true);
|
||||
@@ -897,6 +1016,72 @@ public class MainActivity extends Activity {
|
||||
.show();
|
||||
}
|
||||
|
||||
private void loginAndChoosePool(String emailValue, String passwordValue) {
|
||||
status.setText("Идентифицирую устройство и загружаю доступные выходы...");
|
||||
new Thread(() -> {
|
||||
try {
|
||||
RapApiClient client = new RapApiClient(fabricControlConfig(), this);
|
||||
authContext = authenticateWithPassword(client, emailValue, passwordValue);
|
||||
String activeOrganizationId = resolveOrganizationId(client, authContext.userId);
|
||||
String loadedProfile = client.vpnClientProfile(
|
||||
clusterId.getText().toString(),
|
||||
activeOrganizationId,
|
||||
authContext.userId,
|
||||
""
|
||||
);
|
||||
runOnUiThread(() -> showPoolChoiceDialog(loadedProfile));
|
||||
} catch (Exception ex) {
|
||||
runOnUiThread(() -> {
|
||||
status.setText("Ошибка настройки: " + friendlyError(ex));
|
||||
if (friendlyError(ex).contains("пароль")) {
|
||||
clearSavedAuth(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void showPoolChoiceDialog(String loadedProfile) {
|
||||
try {
|
||||
JSONObject root = new JSONObject(loadedProfile);
|
||||
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
|
||||
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
|
||||
if (connections == null || connections.length() == 0) {
|
||||
throw new IllegalStateException("Для пользователя нет доступных выходных пулов.");
|
||||
}
|
||||
String[] labels = new String[connections.length()];
|
||||
String[] ids = new String[connections.length()];
|
||||
int selectedIndex = 0;
|
||||
for (int i = 0; i < connections.length(); i++) {
|
||||
JSONObject connection = connections.getJSONObject(i);
|
||||
ids[i] = connection.optString("id", "");
|
||||
String name = connection.optString("exit_pool_name", "").trim();
|
||||
if (name.isEmpty()) {
|
||||
name = connection.optString("name", "").trim();
|
||||
}
|
||||
labels[i] = name.isEmpty() ? "Выход " + (i + 1) : name;
|
||||
if (!vpnConnectionId.isEmpty() && vpnConnectionId.equals(ids[i])) {
|
||||
selectedIndex = i;
|
||||
}
|
||||
}
|
||||
int initialSelection = selectedIndex;
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Выходной пул")
|
||||
.setSingleChoiceItems(labels, initialSelection, (dialog, which) -> {
|
||||
profileJson = loadedProfile;
|
||||
vpnConnectionId = ids[which];
|
||||
saveProfileState();
|
||||
profileSummary.setText(summaryText());
|
||||
status.setText("Выбран выходной пул: " + labels[which]);
|
||||
dialog.dismiss();
|
||||
})
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show();
|
||||
} catch (Exception ex) {
|
||||
status.setText("Ошибка выбора пула: " + friendlyError(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private String friendlyError(Exception ex) {
|
||||
String message = ex.getMessage();
|
||||
if (message == null || message.trim().isEmpty()) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -16,35 +15,28 @@ import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import su.cin.rapvpn.fabric.fabricvpn.Fabricvpn;
|
||||
import su.cin.rapvpn.fabric.fabricvpn.Manager;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.net.SocketFactory;
|
||||
|
||||
final class RapApiClient {
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream");
|
||||
private static final int MAX_PACKET_BATCH_PACKETS = 512;
|
||||
private static final int MAX_PACKET_BATCH_BYTES = 512 * 1024;
|
||||
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
|
||||
private static final int MAX_BATCH_HEADER_BYTES = 4;
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient httpClient;
|
||||
private final String networkMode;
|
||||
private final FabricServiceChannel fabricServiceChannel;
|
||||
private final Manager fabricControlManager;
|
||||
|
||||
RapApiClient(String baseUrl) {
|
||||
this(baseUrl, (Context) null);
|
||||
@@ -52,7 +44,7 @@ final class RapApiClient {
|
||||
|
||||
RapApiClient(String baseUrl, Context context) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
this.fabricServiceChannel = new FabricServiceChannel();
|
||||
this.fabricControlManager = startFabricControlManager(baseUrl);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
// Regular app and diagnostic requests should use Android's default
|
||||
// routing. Some devices reject binding app sockets to a specific
|
||||
@@ -74,7 +66,7 @@ final class RapApiClient {
|
||||
|
||||
RapApiClient(String baseUrl, Context context, boolean preferUnderlyingNetwork) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
this.fabricServiceChannel = new FabricServiceChannel();
|
||||
this.fabricControlManager = startFabricControlManager(baseUrl);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
String mode = context == null ? "default_network" : "default_network_context";
|
||||
if (preferUnderlyingNetwork && context != null) {
|
||||
@@ -99,74 +91,27 @@ final class RapApiClient {
|
||||
this.httpClient = builder.build();
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, VpnService vpnService) {
|
||||
this(baseUrl, vpnService, new FabricServiceChannel());
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, VpnService vpnService, FabricServiceChannel fabricServiceChannel) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
this.fabricServiceChannel = fabricServiceChannel == null ? new FabricServiceChannel() : fabricServiceChannel;
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
if (vpnService != null) {
|
||||
builder.socketFactory(new ProtectedSocketFactory(vpnService));
|
||||
builder.dns(new BackendPinnedDns(baseUrl));
|
||||
this.networkMode = "protected_socket";
|
||||
} else {
|
||||
this.networkMode = "default_network";
|
||||
}
|
||||
builder.connectTimeout(3, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(8, TimeUnit.SECONDS);
|
||||
builder.readTimeout(8, TimeUnit.SECONDS);
|
||||
builder.callTimeout(10, TimeUnit.SECONDS);
|
||||
builder.retryOnConnectionFailure(false);
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
dispatcher.setMaxRequests(64);
|
||||
dispatcher.setMaxRequestsPerHost(32);
|
||||
builder.dispatcher(dispatcher);
|
||||
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
|
||||
this.httpClient = builder.build();
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, Network network) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
this.fabricServiceChannel = new FabricServiceChannel();
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
if (network != null) {
|
||||
builder.socketFactory(network.getSocketFactory());
|
||||
builder.dns(hostname -> {
|
||||
InetAddress[] addresses = network.getAllByName(hostname);
|
||||
if (addresses == null || addresses.length == 0) {
|
||||
throw new UnknownHostException(hostname);
|
||||
}
|
||||
List<InetAddress> out = new ArrayList<>();
|
||||
Collections.addAll(out, addresses);
|
||||
return out;
|
||||
});
|
||||
this.networkMode = "vpn_network";
|
||||
} else {
|
||||
builder.dns(new BackendPinnedDns(baseUrl));
|
||||
this.networkMode = "default_network";
|
||||
}
|
||||
builder.connectTimeout(5, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(12, TimeUnit.SECONDS);
|
||||
builder.readTimeout(12, TimeUnit.SECONDS);
|
||||
builder.callTimeout(15, TimeUnit.SECONDS);
|
||||
builder.retryOnConnectionFailure(true);
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
dispatcher.setMaxRequests(64);
|
||||
dispatcher.setMaxRequestsPerHost(32);
|
||||
builder.dispatcher(dispatcher);
|
||||
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
|
||||
this.httpClient = builder.build();
|
||||
}
|
||||
|
||||
String networkMode() {
|
||||
return networkMode;
|
||||
}
|
||||
|
||||
private Manager startFabricControlManager(String config) {
|
||||
String value = config == null ? "" : config.trim();
|
||||
if (!value.startsWith("{")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Fabricvpn.touch();
|
||||
Manager manager = Fabricvpn.newManager();
|
||||
manager.start(value);
|
||||
return manager;
|
||||
} catch (Exception e) {
|
||||
String detail = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
throw new IllegalStateException("Не удалось подключиться к ферме через QUIC bootstrap. Последняя ошибка: " + detail, e);
|
||||
}
|
||||
}
|
||||
|
||||
static final class BackendPinnedDns implements Dns {
|
||||
private static final String VPN_PUBLIC_HOST = "vpn.cin.su";
|
||||
private static final String VPN_PUBLIC_IPV4 = "94.141.118.222";
|
||||
private final String backendHost;
|
||||
|
||||
BackendPinnedDns(String baseUrl) {
|
||||
@@ -180,10 +125,6 @@ final class RapApiClient {
|
||||
|
||||
@Override
|
||||
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
|
||||
String host = hostname == null ? "" : hostname.trim().toLowerCase();
|
||||
if (!backendHost.isEmpty() && host.equals(backendHost) && VPN_PUBLIC_HOST.equals(host)) {
|
||||
return Collections.singletonList(InetAddress.getByName(VPN_PUBLIC_IPV4));
|
||||
}
|
||||
return Dns.SYSTEM.lookup(hostname);
|
||||
}
|
||||
}
|
||||
@@ -243,103 +184,26 @@ final class RapApiClient {
|
||||
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 registerFabricNode(JSONObject payload) throws Exception {
|
||||
return post("/node-agents/register", payload);
|
||||
}
|
||||
|
||||
JSONObject reportVPNDiagnosticStatus(String clusterId, String deviceId, JSONObject payload) throws Exception {
|
||||
return post("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/status", payload);
|
||||
}
|
||||
|
||||
JSONObject nextVPNDiagnosticCommand(String clusterId, String deviceId, int timeoutMs) throws Exception {
|
||||
byte[] payload = getBytes("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/commands?timeout_ms=" + timeoutMs);
|
||||
if (payload.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return new JSONObject(new String(payload, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
JSONObject vpnPacketStats(String clusterId, String vpnConnectionId) throws Exception {
|
||||
return get("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/stats");
|
||||
}
|
||||
|
||||
JSONObject resetVPNPacketQueues(String clusterId, String vpnConnectionId) throws Exception {
|
||||
return post("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/reset", new JSONObject());
|
||||
}
|
||||
|
||||
void sendClientPacket(String clusterId, String vpnConnectionId, byte[] packet, int length) throws Exception {
|
||||
postBytes(clientPacketPath(clusterId, vpnConnectionId, ""), packet, length);
|
||||
}
|
||||
|
||||
void sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) throws Exception {
|
||||
if (packets == null || packets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<List<byte[]>> chunks = chunkPacketsForBatch(packets);
|
||||
if (chunks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (List<byte[]> chunk : chunks) {
|
||||
postBytes(clientPacketPath(clusterId, vpnConnectionId, "?batch=true"), encodePacketBatch(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
byte[] receiveClientPacket(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
|
||||
try {
|
||||
return getBytes(clientPacketPath(clusterId, vpnConnectionId, "?timeout_ms=" + timeoutMs));
|
||||
} catch (InterruptedIOException e) {
|
||||
return new byte[0];
|
||||
} catch (IOException e) {
|
||||
if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) {
|
||||
return new byte[0];
|
||||
}
|
||||
throw e;
|
||||
} catch (IllegalStateException e) {
|
||||
String message = e.getMessage();
|
||||
if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) {
|
||||
return new byte[0];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
List<byte[]> receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
|
||||
byte[] payload;
|
||||
try {
|
||||
payload = getBytes(clientPacketPath(clusterId, vpnConnectionId, "?batch=true&timeout_ms=" + timeoutMs));
|
||||
if (payload == null || payload.length == 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
if (!isLikelyPacketBatch(payload)) {
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
return decodePacketBatch(payload);
|
||||
} catch (InterruptedIOException e) {
|
||||
return new ArrayList<>();
|
||||
} catch (IOException e) {
|
||||
if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
throw e;
|
||||
} catch (IllegalStateException e) {
|
||||
String message = e.getMessage();
|
||||
if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
JSONObject sendFabricNodeHeartbeat(String clusterId, String nodeId, JSONObject payload) throws Exception {
|
||||
return post("/clusters/" + clusterId + "/nodes/" + nodeId + "/heartbeats", payload);
|
||||
}
|
||||
|
||||
private JSONObject get(String path) throws Exception {
|
||||
if (fabricControlManager != null) {
|
||||
return fabricControlJSON("GET", path, null);
|
||||
}
|
||||
Request request = new Request.Builder().url(baseUrl + path).get().build();
|
||||
return read(request);
|
||||
}
|
||||
|
||||
private JSONObject post(String path, JSONObject body) throws Exception {
|
||||
if (fabricControlManager != null) {
|
||||
return fabricControlJSON("POST", path, body);
|
||||
}
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(body.toString().getBytes(StandardCharsets.UTF_8), JSON))
|
||||
@@ -347,39 +211,60 @@ final class RapApiClient {
|
||||
return read(request);
|
||||
}
|
||||
|
||||
private byte[] getBytes(String path) throws Exception {
|
||||
Request.Builder builder = new Request.Builder().url(baseUrl + path).get();
|
||||
applyFabricHeadersIfNeeded(builder, path);
|
||||
Request request = builder.build();
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (response.code() == 204) {
|
||||
return new byte[0];
|
||||
}
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalStateException(describeHttpFailure(response));
|
||||
}
|
||||
ResponseBody body = response.body();
|
||||
return body == null ? new byte[0] : body.bytes();
|
||||
private JSONObject fabricControlJSON(String method, String path, JSONObject body) throws Exception {
|
||||
byte[] payload = fabricControlBodyBytes(method, path, body);
|
||||
if (payload.length == 0) {
|
||||
return new JSONObject();
|
||||
}
|
||||
return new JSONObject(new String(payload, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
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 byte[] fabricControlBodyBytes(String method, String path, JSONObject body) throws Exception {
|
||||
JSONObject request = new JSONObject();
|
||||
request.put("method", method);
|
||||
request.put("path", path);
|
||||
if (body != null) {
|
||||
request.put("body", body);
|
||||
}
|
||||
String raw;
|
||||
try {
|
||||
raw = fabricControlManager.controlRequest(request.toString());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Ферма сейчас не смогла выполнить контрольный запрос. Попробуйте еще раз.", e);
|
||||
}
|
||||
JSONObject wrapper = raw == null || raw.trim().isEmpty() ? new JSONObject() : new JSONObject(raw);
|
||||
int statusCode = wrapper.optInt("status_code", 200);
|
||||
Object bodyValue = wrapper.opt("body");
|
||||
String bodyText = jsonBodyText(bodyValue);
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
if (statusCode == 401 && bodyText.contains("auth.invalid_credentials")) {
|
||||
throw new IllegalStateException("Неверный логин или пароль.");
|
||||
}
|
||||
if (statusCode == 401 && bodyText.contains("auth.invalid_refresh_token")) {
|
||||
throw new IllegalStateException("Сессия устройства истекла. Введите пароль один раз.");
|
||||
}
|
||||
throw new IllegalStateException("fabric control HTTP " + statusCode + ": " + compactText(bodyText, 240));
|
||||
}
|
||||
return bodyText.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void postBytes(String path, byte[] bodyBytes) throws Exception {
|
||||
Request.Builder builder = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(bodyBytes, OCTET_STREAM));
|
||||
applyFabricHeadersIfNeeded(builder, path);
|
||||
Request request = builder.build();
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalStateException(describeHttpFailure(response));
|
||||
}
|
||||
private String jsonBodyText(Object bodyValue) {
|
||||
if (bodyValue == null || JSONObject.NULL.equals(bodyValue)) {
|
||||
return "";
|
||||
}
|
||||
if (bodyValue instanceof JSONObject || bodyValue instanceof JSONArray) {
|
||||
return bodyValue.toString();
|
||||
}
|
||||
String text = String.valueOf(bodyValue);
|
||||
return text == null ? "" : text;
|
||||
}
|
||||
|
||||
private String compactText(String text, int limit) {
|
||||
String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' ').trim();
|
||||
if (value.length() > limit) {
|
||||
return value.substring(0, limit);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String describeHttpFailure(Response response) {
|
||||
@@ -401,45 +286,6 @@ final class RapApiClient {
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
private String clientPacketPath(String clusterId, String vpnConnectionId, String suffix) throws IOException {
|
||||
String path = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, false);
|
||||
if (path.isEmpty()) {
|
||||
throw new IOException("fabric service channel lease required for VPN packet dataplane");
|
||||
}
|
||||
return path + (suffix == null ? "" : suffix);
|
||||
}
|
||||
|
||||
private void applyFabricHeadersIfNeeded(Request.Builder builder, String path) {
|
||||
if (path != null && path.contains("/fabric/service-channels/")) {
|
||||
fabricServiceChannel.applyHeaders(builder);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] encodePacketBatch(List<byte[]> packets) {
|
||||
int total = 0;
|
||||
for (byte[] packet : packets) {
|
||||
if (packet != null && packet.length > 0) {
|
||||
total += 4 + packet.length;
|
||||
}
|
||||
}
|
||||
byte[] out = new byte[total];
|
||||
int offset = 0;
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
int length = packet.length;
|
||||
out[offset] = (byte) ((length >> 24) & 0xff);
|
||||
out[offset + 1] = (byte) ((length >> 16) & 0xff);
|
||||
out[offset + 2] = (byte) ((length >> 8) & 0xff);
|
||||
out[offset + 3] = (byte) (length & 0xff);
|
||||
offset += 4;
|
||||
System.arraycopy(packet, 0, out, offset, length);
|
||||
offset += length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private JSONObject read(Request request) throws Exception {
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
ResponseBody body = response.body();
|
||||
@@ -457,93 +303,6 @@ final class RapApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private List<byte[]> decodePacketBatch(byte[] payload) {
|
||||
List<byte[]> packets = new ArrayList<>();
|
||||
int offset = 0;
|
||||
while (payload != null && offset + 4 <= payload.length) {
|
||||
int length = ((payload[offset] & 0xff) << 24)
|
||||
| ((payload[offset + 1] & 0xff) << 16)
|
||||
| ((payload[offset + 2] & 0xff) << 8)
|
||||
| (payload[offset + 3] & 0xff);
|
||||
offset += 4;
|
||||
if (length <= 0 || offset + length > payload.length) {
|
||||
break;
|
||||
}
|
||||
byte[] packet = new byte[length];
|
||||
System.arraycopy(payload, offset, packet, 0, length);
|
||||
packets.add(packet);
|
||||
offset += length;
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
private List<List<byte[]>> chunkPacketsForBatch(List<byte[]> packets) {
|
||||
List<List<byte[]>> chunks = new ArrayList<>();
|
||||
List<byte[]> current = new ArrayList<>();
|
||||
int currentBytes = 0;
|
||||
boolean hasData = false;
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
if (packet.length > MAX_SINGLE_PACKET_BYTES) {
|
||||
continue;
|
||||
}
|
||||
hasData = true;
|
||||
|
||||
int projected = currentBytes + MAX_BATCH_HEADER_BYTES + packet.length;
|
||||
if (!current.isEmpty() && (current.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES)) {
|
||||
chunks.add(current);
|
||||
current = new ArrayList<>();
|
||||
currentBytes = 0;
|
||||
}
|
||||
current.add(packet);
|
||||
currentBytes = projected;
|
||||
}
|
||||
if (!hasData) {
|
||||
return chunks;
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
chunks.add(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private boolean isLikelyPacketBatch(byte[] payload) {
|
||||
if (payload == null || payload.length < MAX_BATCH_HEADER_BYTES) {
|
||||
return false;
|
||||
}
|
||||
int offset = 0;
|
||||
int consumed = 0;
|
||||
while (offset + MAX_BATCH_HEADER_BYTES <= payload.length) {
|
||||
int length = ((payload[offset] & 0xff) << 24)
|
||||
| ((payload[offset + 1] & 0xff) << 16)
|
||||
| ((payload[offset + 2] & 0xff) << 8)
|
||||
| (payload[offset + 3] & 0xff);
|
||||
offset += MAX_BATCH_HEADER_BYTES;
|
||||
if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES) {
|
||||
return false;
|
||||
}
|
||||
if (offset + length > payload.length) {
|
||||
return false;
|
||||
}
|
||||
offset += length;
|
||||
consumed++;
|
||||
if (consumed > MAX_PACKET_BATCH_PACKETS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return offset == payload.length && consumed > 0;
|
||||
}
|
||||
|
||||
private List<byte[]> receiveSinglePacketAsBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
|
||||
byte[] payload = receiveClientPacket(clusterId, vpnConnectionId, timeoutMs);
|
||||
if (payload == null || payload.length == 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return new ArrayList<>(Collections.singletonList(payload));
|
||||
}
|
||||
|
||||
private AuthContext parseAuthContext(JSONObject response) throws Exception {
|
||||
JSONObject user = response.getJSONObject("user");
|
||||
String userId = user.optString("id", "");
|
||||
@@ -570,65 +329,6 @@ final class RapApiClient {
|
||||
return value;
|
||||
}
|
||||
|
||||
static final class ProtectedSocketFactory extends SocketFactory {
|
||||
private final SocketFactory delegate = SocketFactory.getDefault();
|
||||
private final VpnService vpnService;
|
||||
|
||||
ProtectedSocketFactory(VpnService vpnService) {
|
||||
this.vpnService = vpnService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket() throws IOException {
|
||||
Socket socket = delegate.createSocket();
|
||||
socket.bind(null);
|
||||
return protect(socket);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException {
|
||||
Socket socket = createSocket();
|
||||
socket.connect(new InetSocketAddress(host, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
|
||||
Socket socket = delegate.createSocket();
|
||||
socket.bind(new InetSocketAddress(localHost, localPort));
|
||||
protect(socket);
|
||||
socket.connect(new InetSocketAddress(host, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress host, int port) throws IOException {
|
||||
Socket socket = createSocket();
|
||||
socket.connect(new InetSocketAddress(host, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
|
||||
Socket socket = delegate.createSocket();
|
||||
socket.bind(new InetSocketAddress(localAddress, localPort));
|
||||
protect(socket);
|
||||
socket.connect(new InetSocketAddress(address, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
private Socket protect(Socket socket) throws IOException {
|
||||
if (!vpnService.protect(socket)) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
throw new IOException("protect control-plane socket failed");
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
|
||||
static final class AuthContext {
|
||||
final String userId;
|
||||
final String deviceId;
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.os.Build;
|
||||
public final class RapAutostartReceiver extends BroadcastReceiver {
|
||||
private static final String PREFS = "rap-vpn";
|
||||
private static final String PREF_PROFILE_JSON = "profile_json";
|
||||
private static final String PREF_BACKEND_URL = "backend_url";
|
||||
private static final String PREF_CLUSTER_ID = "cluster_id";
|
||||
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
|
||||
private static final String PREF_MANUAL_STOPPED = "manual_stopped";
|
||||
@@ -25,21 +24,18 @@ public final class RapAutostartReceiver extends BroadcastReceiver {
|
||||
&& !Intent.ACTION_BOOT_COMPLETED.equals(action)) {
|
||||
return;
|
||||
}
|
||||
RapDiagnosticService.start(context);
|
||||
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
if (prefs.getBoolean(PREF_MANUAL_STOPPED, false)) {
|
||||
return;
|
||||
}
|
||||
if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) {
|
||||
// Diagnostic service owns post-upgrade VPN restart. Starting both services from
|
||||
// MY_PACKAGE_REPLACED can race foreground-service startup and leave diagnostics stale.
|
||||
// After package replacement we wait for an explicit user action or runtime resume.
|
||||
return;
|
||||
}
|
||||
String profile = prefs.getString(PREF_PROFILE_JSON, "");
|
||||
String backendUrl = prefs.getString(PREF_BACKEND_URL, "");
|
||||
String clusterId = prefs.getString(PREF_CLUSTER_ID, "");
|
||||
String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
|
||||
if (profile.isEmpty() || backendUrl.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
|
||||
if (profile.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (VpnService.prepare(context) != null) {
|
||||
@@ -47,7 +43,6 @@ public final class RapAutostartReceiver extends BroadcastReceiver {
|
||||
}
|
||||
Intent service = new Intent(context, RapVpnService.class);
|
||||
service.putExtra("profile_json", profile);
|
||||
service.putExtra("backend_url", backendUrl);
|
||||
service.putExtra("cluster_id", clusterId);
|
||||
service.putExtra("vpn_connection_id", vpnConnectionId);
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,7 @@ public class TestTrafficActivity extends Activity {
|
||||
setContentView(layout);
|
||||
String url = getIntent().getStringExtra(EXTRA_URL);
|
||||
if (url == null || url.isEmpty()) {
|
||||
url = "http://192.168.200.61:18080/";
|
||||
url = "http://example.com/";
|
||||
}
|
||||
target = url;
|
||||
assetErrorCount = 0;
|
||||
|
||||
@@ -11,7 +11,7 @@ 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_FABRIC_BOOTSTRAP_CONFIG = "fabric_bootstrap_config";
|
||||
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;
|
||||
@@ -44,7 +44,10 @@ public class TestVpnActivity extends Activity {
|
||||
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));
|
||||
String fabricBootstrapConfig = source.getStringExtra(EXTRA_FABRIC_BOOTSTRAP_CONFIG);
|
||||
if (fabricBootstrapConfig != null && !fabricBootstrapConfig.isEmpty()) {
|
||||
intent.putExtra(RapVpnService.EXTRA_FABRIC_BOOTSTRAP_CONFIG, fabricBootstrapConfig);
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.net.VpnService;
|
||||
import android.util.Log;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.ConnectionPool;
|
||||
import okhttp3.Dispatcher;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
import okio.ByteString;
|
||||
|
||||
final class VpnPacketWebSocketRelay {
|
||||
private static final String TAG = "RapVpnWebSocketRelay";
|
||||
private static final int MAX_PACKET_BATCH_PACKETS = 512;
|
||||
private static final int MAX_PACKET_BATCH_BYTES = 1024 * 1024;
|
||||
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
|
||||
private static final long CONNECTING_STALE_MS = 8000;
|
||||
private static final long OPEN_WAIT_MS = 3500;
|
||||
private static final int PRIORITY_GRACE_MS = 2;
|
||||
|
||||
private final String baseUrl;
|
||||
private final VpnService vpnService;
|
||||
private final OkHttpClient httpClient;
|
||||
private final FabricServiceChannel fabricServiceChannel;
|
||||
private final BlockingQueue<List<byte[]>> priorityIncoming = new ArrayBlockingQueue<>(512);
|
||||
private final BlockingQueue<List<byte[]>> incoming = new ArrayBlockingQueue<>(2048);
|
||||
private final Object lock = new Object();
|
||||
|
||||
private WebSocket webSocket;
|
||||
private String connectedClusterId = "";
|
||||
private String connectedVpnConnectionId = "";
|
||||
private volatile boolean open;
|
||||
private volatile boolean connecting;
|
||||
private volatile long connectingSinceMs;
|
||||
private volatile long reconnectAfterMs;
|
||||
private volatile String lastError = "";
|
||||
|
||||
VpnPacketWebSocketRelay(String baseUrl, VpnService vpnService) {
|
||||
this(baseUrl, vpnService, new FabricServiceChannel());
|
||||
}
|
||||
|
||||
VpnPacketWebSocketRelay(String baseUrl, VpnService vpnService, FabricServiceChannel fabricServiceChannel) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
this.vpnService = vpnService;
|
||||
this.fabricServiceChannel = fabricServiceChannel == null ? new FabricServiceChannel() : fabricServiceChannel;
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
if (vpnService != null) {
|
||||
builder.socketFactory(new RapApiClient.ProtectedSocketFactory(vpnService));
|
||||
}
|
||||
builder.dns(new RapApiClient.BackendPinnedDns(baseUrl));
|
||||
builder.connectTimeout(5, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(10, TimeUnit.SECONDS);
|
||||
builder.readTimeout(0, TimeUnit.SECONDS);
|
||||
builder.retryOnConnectionFailure(true);
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
dispatcher.setMaxRequests(16);
|
||||
dispatcher.setMaxRequestsPerHost(8);
|
||||
builder.dispatcher(dispatcher);
|
||||
builder.connectionPool(new ConnectionPool(8, 5, TimeUnit.MINUTES));
|
||||
this.httpClient = builder.build();
|
||||
}
|
||||
|
||||
String baseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
boolean isOpen() {
|
||||
return open;
|
||||
}
|
||||
|
||||
String lastError() {
|
||||
return lastError == null ? "" : lastError;
|
||||
}
|
||||
|
||||
void connect(String clusterId, String vpnConnectionId) {
|
||||
if (clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
synchronized (lock) {
|
||||
if (open && clusterId.equals(connectedClusterId) && vpnConnectionId.equals(connectedVpnConnectionId)) {
|
||||
return;
|
||||
}
|
||||
if (connecting && clusterId.equals(connectedClusterId) && vpnConnectionId.equals(connectedVpnConnectionId)) {
|
||||
if (now - connectingSinceMs < CONNECTING_STALE_MS) {
|
||||
return;
|
||||
}
|
||||
lastError = "stale websocket connect";
|
||||
closeLocked();
|
||||
}
|
||||
if (now < reconnectAfterMs) {
|
||||
return;
|
||||
}
|
||||
closeLocked();
|
||||
String wsUrl = webSocketUrl(clusterId, vpnConnectionId);
|
||||
if (wsUrl.isEmpty()) {
|
||||
lastError = "invalid websocket url";
|
||||
reconnectAfterMs = now + 5000;
|
||||
return;
|
||||
}
|
||||
connectedClusterId = clusterId;
|
||||
connectedVpnConnectionId = vpnConnectionId;
|
||||
connecting = true;
|
||||
connectingSinceMs = now;
|
||||
Request.Builder requestBuilder = new Request.Builder().url(wsUrl);
|
||||
this.fabricServiceChannel.applyHeaders(requestBuilder);
|
||||
Request request = requestBuilder.build();
|
||||
lastError = "connecting";
|
||||
webSocket = httpClient.newWebSocket(request, new Listener());
|
||||
}
|
||||
}
|
||||
|
||||
boolean sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) {
|
||||
packets = cleanPacketBatch(packets);
|
||||
if (packets.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
connect(clusterId, vpnConnectionId);
|
||||
if (!awaitOpen(OPEN_WAIT_MS)) {
|
||||
return false;
|
||||
}
|
||||
WebSocket socket = webSocket;
|
||||
if (socket == null) {
|
||||
lastError = "websocket missing after open";
|
||||
return false;
|
||||
}
|
||||
byte[] payload = encodePacketBatch(packets);
|
||||
if (payload.length == 0) {
|
||||
return true;
|
||||
}
|
||||
boolean queued = socket.send(ByteString.of(payload));
|
||||
if (!queued) {
|
||||
lastError = "websocket send queue rejected batch";
|
||||
synchronized (lock) {
|
||||
if (socket == webSocket) {
|
||||
reconnectAfterMs = 0;
|
||||
closeLocked();
|
||||
}
|
||||
}
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
|
||||
List<byte[]> receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws InterruptedException {
|
||||
connect(clusterId, vpnConnectionId);
|
||||
awaitOpen(Math.min(OPEN_WAIT_MS, Math.max(1, timeoutMs)));
|
||||
int waitMs = Math.max(1, timeoutMs);
|
||||
List<byte[]> packets = priorityIncoming.poll();
|
||||
if (packets != null) {
|
||||
return packets;
|
||||
}
|
||||
packets = priorityIncoming.poll(Math.min(PRIORITY_GRACE_MS, waitMs), TimeUnit.MILLISECONDS);
|
||||
if (packets != null) {
|
||||
return packets;
|
||||
}
|
||||
packets = incoming.poll();
|
||||
if (packets != null) {
|
||||
return packets;
|
||||
}
|
||||
packets = priorityIncoming.poll();
|
||||
if (packets != null) {
|
||||
return packets;
|
||||
}
|
||||
packets = incoming.poll(Math.max(1, waitMs - PRIORITY_GRACE_MS), TimeUnit.MILLISECONDS);
|
||||
return packets == null ? new ArrayList<>() : packets;
|
||||
}
|
||||
|
||||
void close() {
|
||||
synchronized (lock) {
|
||||
closeLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeLocked() {
|
||||
open = false;
|
||||
connecting = false;
|
||||
connectingSinceMs = 0;
|
||||
priorityIncoming.clear();
|
||||
incoming.clear();
|
||||
if (webSocket != null) {
|
||||
try {
|
||||
webSocket.close(1000, "relay switch");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
webSocket = null;
|
||||
}
|
||||
|
||||
private boolean awaitOpen(long timeoutMs) {
|
||||
long deadline = System.currentTimeMillis() + Math.max(1, timeoutMs);
|
||||
synchronized (lock) {
|
||||
while (!open && connecting) {
|
||||
long waitMs = deadline - System.currentTimeMillis();
|
||||
if (waitMs <= 0) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
lock.wait(waitMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
lastError = "interrupted waiting for websocket open";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!open && "connecting".equals(lastError)) {
|
||||
lastError = "connecting_timeout";
|
||||
}
|
||||
return open;
|
||||
}
|
||||
}
|
||||
|
||||
private String webSocketUrl(String clusterId, String vpnConnectionId) {
|
||||
try {
|
||||
URI uri = URI.create(baseUrl);
|
||||
String scheme = "https".equalsIgnoreCase(uri.getScheme()) ? "wss" : "ws";
|
||||
String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "" : trimRight(uri.getRawPath());
|
||||
String fabricPath = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, true);
|
||||
if (!fabricPath.isEmpty()) {
|
||||
path += fabricPath;
|
||||
} else {
|
||||
path += "/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets/ws";
|
||||
}
|
||||
URI ws = new URI(scheme, uri.getRawUserInfo(), uri.getHost(), uri.getPort(), path, null, null);
|
||||
return ws.toString();
|
||||
} catch (Exception e) {
|
||||
lastError = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private final class Listener extends WebSocketListener {
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket, Response response) {
|
||||
synchronized (lock) {
|
||||
open = true;
|
||||
connecting = false;
|
||||
reconnectAfterMs = 0;
|
||||
lastError = "";
|
||||
lock.notifyAll();
|
||||
}
|
||||
Log.i(TAG, "vpn packet websocket opened " + baseUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, ByteString bytes) {
|
||||
List<byte[]> packets = decodePacketBatch(bytes.toByteArray());
|
||||
if (packets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
offerIncomingPacketBatch(packets);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket webSocket, int code, String reason) {
|
||||
synchronized (lock) {
|
||||
open = false;
|
||||
connecting = false;
|
||||
reconnectAfterMs = System.currentTimeMillis() + 1000;
|
||||
lastError = "closed " + code + " " + reason;
|
||||
lock.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
||||
String responseStatus = "";
|
||||
if (response != null) {
|
||||
responseStatus = " status=" + response.code();
|
||||
}
|
||||
synchronized (lock) {
|
||||
open = false;
|
||||
connecting = false;
|
||||
reconnectAfterMs = System.currentTimeMillis() + 3000;
|
||||
lastError = (t == null ? "websocket failure" : t.getClass().getSimpleName() + ": " + t.getMessage()) + responseStatus;
|
||||
lock.notifyAll();
|
||||
}
|
||||
Log.w(TAG, "vpn packet websocket failed " + baseUrl + ": " + lastError);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<byte[]> cleanPacketBatch(List<byte[]> packets) {
|
||||
List<byte[]> cleaned = new ArrayList<>();
|
||||
int bytes = 0;
|
||||
if (packets == null) {
|
||||
return cleaned;
|
||||
}
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length <= 0 || packet.length > MAX_SINGLE_PACKET_BYTES) {
|
||||
continue;
|
||||
}
|
||||
int projected = bytes + 4 + packet.length;
|
||||
if (cleaned.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES) {
|
||||
break;
|
||||
}
|
||||
cleaned.add(packet);
|
||||
bytes = projected;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static byte[] encodePacketBatch(List<byte[]> packets) {
|
||||
packets = cleanPacketBatch(packets);
|
||||
int total = 0;
|
||||
for (byte[] packet : packets) {
|
||||
total += 4 + packet.length;
|
||||
}
|
||||
byte[] out = new byte[total];
|
||||
int offset = 0;
|
||||
for (byte[] packet : packets) {
|
||||
int length = packet.length;
|
||||
out[offset] = (byte) ((length >> 24) & 0xff);
|
||||
out[offset + 1] = (byte) ((length >> 16) & 0xff);
|
||||
out[offset + 2] = (byte) ((length >> 8) & 0xff);
|
||||
out[offset + 3] = (byte) (length & 0xff);
|
||||
offset += 4;
|
||||
System.arraycopy(packet, 0, out, offset, length);
|
||||
offset += length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<byte[]> decodePacketBatch(byte[] payload) {
|
||||
List<byte[]> packets = new ArrayList<>();
|
||||
int offset = 0;
|
||||
while (payload != null && offset + 4 <= payload.length && packets.size() < MAX_PACKET_BATCH_PACKETS) {
|
||||
int length = ((payload[offset] & 0xff) << 24)
|
||||
| ((payload[offset + 1] & 0xff) << 16)
|
||||
| ((payload[offset + 2] & 0xff) << 8)
|
||||
| (payload[offset + 3] & 0xff);
|
||||
offset += 4;
|
||||
if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES || offset + length > payload.length) {
|
||||
break;
|
||||
}
|
||||
byte[] packet = new byte[length];
|
||||
System.arraycopy(payload, offset, packet, 0, length);
|
||||
packets.add(packet);
|
||||
offset += length;
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
private void offerIncomingPacketBatch(List<byte[]> packets) {
|
||||
BlockingQueue<List<byte[]>> target = containsTCPControlPacket(packets) ? priorityIncoming : incoming;
|
||||
if (!target.offer(packets)) {
|
||||
target.poll();
|
||||
target.offer(packets);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsTCPControlPacket(List<byte[]> packets) {
|
||||
if (packets == null) {
|
||||
return false;
|
||||
}
|
||||
for (byte[] packet : packets) {
|
||||
if (isTCPControlPacket(packet)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isTCPControlPacket(byte[] packet) {
|
||||
if (packet == null || packet.length < 20 || (packet[0] >> 4) != 4) {
|
||||
return false;
|
||||
}
|
||||
int ihl = (packet[0] & 0x0f) * 4;
|
||||
if (ihl < 20 || packet.length < ihl + 20 || packet[9] != 6) {
|
||||
return false;
|
||||
}
|
||||
int flags = packet[ihl + 13] & 0xff;
|
||||
return (flags & 0x17) != 0;
|
||||
}
|
||||
|
||||
private static String trimRight(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user