рабочий вариант, но скороть 10 МБит
build / backend (push) Has been cancelled
build / node-agent (push) Has been cancelled
build / worker (push) Has been cancelled

This commit is contained in:
2026-05-22 21:46:49 +03:00
parent 469fa0e860
commit 20d361a886
280 changed files with 954890 additions and 18524 deletions
+6 -7
View File
@@ -17,19 +17,18 @@ Implemented now:
- 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. 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;
dataplane is the QUIC fabric runtime only. The Android node never contacts
the farm over HTTP; control, profile refresh, channel request, packet uplink,
and downlink all go through the farm protocol.
- user-facing HOME-first screen: connect/disconnect is primary, while cluster,
organization, login, and password are kept in the settings dialog;
- saved connection settings in app preferences so repeat connects do not require
retyping the profile.
- encrypted refresh-token storage through Android Keystore. If the trusted
device session is revoked or expires, the app asks for the password once and
then rotates the device keys/profile again.
- 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.
node/VPN service itself.
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
+6 -10
View File
@@ -22,12 +22,10 @@ android {
return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"")
}
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"
// This is a node bootstrap seed set, not an API/backend selector. Android
// runs the same fabric node core as Linux/Windows; only its local IPv4
// ingress adapter is Android VpnService/TUN.
def defaultFabricBootstrapPeers = project.findProperty("RAP_ANDROID_FABRIC_BOOTSTRAP_PEERS") ?: "quic://94.141.118.222:19199,quic://94.141.118.222:19191,quic://195.123.240.88:19131,quic://192.168.200.61:19134,quic://192.168.200.61:19135,quic://192.168.200.61:19136,quic://192.168.200.61:19137"
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"
@@ -35,9 +33,8 @@ android {
applicationId "su.cin.rapvpn"
minSdk 26
targetSdk 35
versionCode 239
versionName "0.2.239"
buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\""
versionCode 270
versionName "0.2.270"
buildConfigField "String", "FABRIC_BOOTSTRAP_PEERS", "\"${normalizeGradleString(defaultFabricBootstrapPeers)}\""
buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\""
buildConfigField "String", "DEFAULT_ORGANIZATION_ID", "\"${normalizeGradleString(defaultOrganizationId)}\""
@@ -52,5 +49,4 @@ android {
dependencies {
implementation files("libs/rap-fabricvpn.aar")
implementation "com.squareup.okhttp3:okhttp:5.3.2"
}
Binary file not shown.
Binary file not shown.
@@ -36,7 +36,7 @@ public class MainActivity extends Activity {
private static final String PREF_USER_ID = "user_id";
private static final String PREF_DEVICE_ID = "device_id";
private static final String PREF_PROFILE_JSON = "profile_json";
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
private static final String PREF_TUNNEL_ID = "tunnel_id";
static final String PREF_FORCE_FULL_TUNNEL = "force_full_tunnel";
private EditText clusterId;
private EditText organizationId;
@@ -74,7 +74,14 @@ public class MainActivity extends Activity {
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, true).apply();
}
profileJson = prefs.getString(PREF_PROFILE_JSON, "");
vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
vpnConnectionId = prefs.getString(PREF_TUNNEL_ID, "");
if (vpnConnectionId.isEmpty() && !profileJson.trim().isEmpty()) {
try {
vpnConnectionId = firstConnectionId(profileJson);
prefs.edit().putString(PREF_TUNNEL_ID, vpnConnectionId).apply();
} catch (Exception ignored) {
}
}
restoreAuthContext();
TextView title = new TextView(this);
@@ -154,6 +161,7 @@ public class MainActivity extends Activity {
setContentView(root);
scheduleRuntimeStatusRefresh();
registerCandidateNodeAsync(false);
autoResumeVpnIfReady();
}
@Override
@@ -225,7 +233,7 @@ public class MainActivity extends Activity {
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);
intent.putExtra(RapVpnService.EXTRA_TUNNEL_ID, vpnConnectionId);
startForegroundService(intent);
status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал.");
runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText());
@@ -253,6 +261,18 @@ public class MainActivity extends Activity {
}
}
private void autoResumeVpnIfReady() {
if (prefs.getBoolean("manual_stopped", false) || !hasSelectedPool() || isVpnRuntimeActive()) {
return;
}
if (VpnService.prepare(this) != null) {
status.setText("Профиль готов. Нажмите «Подключить», чтобы Android выдал разрешение VPN.");
return;
}
status.setText("Профиль готов. Автоматически восстанавливаю VPN через ферму.");
runtimeStatus.postDelayed(this::startVpn, 500);
}
private void scheduleRuntimeStatusRefresh() {
runtimeStatus.postDelayed(() -> {
runtimeStatus.setText(runtimeStatusText());
@@ -414,7 +434,7 @@ public class MainActivity extends Activity {
}
private String firstConnectionId(String profile) throws Exception {
String selected = prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, "").trim();
String selected = prefs == null ? "" : prefs.getString(PREF_TUNNEL_ID, "").trim();
if (!selected.isEmpty() && profileContainsConnection(profile, selected)) {
return selected;
}
@@ -448,9 +468,16 @@ public class MainActivity extends Activity {
}
String status = fabricRoute.optString("status", "").trim().toLowerCase();
if ("planned".equals(status)) {
String entry = fabricRoute.optString("selected_entry_node_id", "").trim();
String exit = fabricRoute.optString("selected_exit_node_id", "").trim();
if (!exit.isEmpty()) {
JSONObject dataplaneSession = clientConfig.optJSONObject("vpn_dataplane_session");
JSONObject tunnel = dataplaneSession == null ? null : dataplaneSession.optJSONObject("fabric_service_tunnel");
String pool = firstNonEmpty(
dataplaneSession == null ? "" : dataplaneSession.optString("pool_id", ""),
tunnel == null ? "" : tunnel.optString("pool_id", ""),
fabricRoute.optString("exit_pool_id", "")).trim();
String tunnelId = firstNonEmpty(
dataplaneSession == null ? "" : dataplaneSession.optString("tunnel_id", ""),
tunnel == null ? "" : tunnel.optString("tunnel_id", "")).trim();
if (!pool.isEmpty() || !tunnelId.isEmpty()) {
return id;
}
}
@@ -504,7 +531,7 @@ public class MainActivity extends Activity {
private String summaryText() {
String deviceId = prefs == null ? "" : prefs.getString(PREF_DEVICE_ID, "");
String connectionId = vpnConnectionId == null || vpnConnectionId.isEmpty()
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
? (prefs == null ? "" : prefs.getString(PREF_TUNNEL_ID, ""))
: vpnConnectionId;
String poolText = availablePoolsText();
String selectedPoolText = selectedPoolName();
@@ -512,8 +539,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)
@@ -580,7 +607,7 @@ public class MainActivity extends Activity {
return "";
}
String preferredConnection = vpnConnectionId == null || vpnConnectionId.isEmpty()
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
? (prefs == null ? "" : prefs.getString(PREF_TUNNEL_ID, ""))
: vpnConnectionId;
for (int i = 0; i < connections.length(); i++) {
JSONObject connection = connections.optJSONObject(i);
@@ -613,7 +640,7 @@ public class MainActivity extends Activity {
return "";
}
String preferredConnection = vpnConnectionId == null || vpnConnectionId.isEmpty()
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
? (prefs == null ? "" : prefs.getString(PREF_TUNNEL_ID, ""))
: vpnConnectionId;
JSONObject selected = null;
for (int i = 0; i < connections.length(); i++) {
@@ -712,11 +739,11 @@ public class MainActivity extends Activity {
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");
service.put("source_role", "ipv4-ingress");
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("tunnel_id", "fabric-control");
cfg.put("stream_shards", 1);
cfg.put("service_channel_request", service);
cfg.put("endpoints", endpoints);
@@ -822,14 +849,14 @@ public class MainActivity extends Activity {
.putString(PREF_DEVICE_ID, context.deviceId)
.putString(PREF_REFRESH_EXPIRES_AT, context.refreshTokenExpiresAt)
.putString(PREF_PROFILE_JSON, profileJson)
.putString(PREF_VPN_CONNECTION_ID, vpnConnectionId)
.putString(PREF_TUNNEL_ID, vpnConnectionId)
.apply();
}
private void saveProfileState() {
prefs.edit()
.putString(PREF_PROFILE_JSON, profileJson)
.putString(PREF_VPN_CONNECTION_ID, vpnConnectionId)
.putString(PREF_TUNNEL_ID, vpnConnectionId)
.apply();
}
@@ -854,7 +881,7 @@ public class MainActivity extends Activity {
.remove(PREF_USER_ID)
.remove(PREF_DEVICE_ID);
if (clearProfile) {
editor.remove(PREF_PROFILE_JSON).remove(PREF_VPN_CONNECTION_ID);
editor.remove(PREF_PROFILE_JSON).remove(PREF_TUNNEL_ID);
profileJson = "";
vpnConnectionId = "";
}
@@ -867,15 +894,15 @@ public class MainActivity extends Activity {
if (!token.isEmpty()) {
return token;
}
String legacyToken = prefs.getString(PREF_REFRESH_TOKEN, "");
if (!legacyToken.isEmpty()) {
String plaintextToken = prefs.getString(PREF_REFRESH_TOKEN, "");
if (!plaintextToken.isEmpty()) {
try {
secureTokens.put(PREF_REFRESH_TOKEN, legacyToken);
secureTokens.put(PREF_REFRESH_TOKEN, plaintextToken);
prefs.edit().remove(PREF_REFRESH_TOKEN).apply();
} catch (Exception ignored) {
}
}
return legacyToken;
return plaintextToken;
}
private String deviceFingerprint() {
@@ -918,7 +945,7 @@ public class MainActivity extends Activity {
private String registerCandidateNode(RapApiClient client) throws Exception {
String nodeId = fabricNodeId();
JSONObject metadata = new JSONObject();
metadata.put("source", "android_vpn_client");
metadata.put("source", "ipv4_ingress_adapter");
metadata.put("candidate_access", true);
metadata.put("fabric_transport", "quic");
metadata.put("connectivity_mode", "outbound_only");
@@ -926,8 +953,8 @@ public class MainActivity extends Activity {
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("node_key", "ipv4-ingress:" + deviceFingerprint());
payload.put("name", "ipv4-ingress-" + 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);
@@ -944,19 +971,23 @@ public class MainActivity extends Activity {
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("ipv4_ingress", true);
capabilities.put("candidate_access", true);
capabilities.put("vpn_client", true);
capabilities.put("local_packet_io", true);
JSONObject serviceStates = new JSONObject();
serviceStates.put("vpn-client", new JSONObject()
.put("state", isSystemVpnActive() ? "running" : "candidate")
JSONObject runtime = runtimeSnapshot();
String runtimeState = runtime.optString("state", "");
serviceStates.put("ipv4-ingress", new JSONObject()
.put("state", isSystemVpnActive() ? firstNonEmpty(runtimeState, "running") : "candidate")
.put("runtime", "android_vpnservice")
.put("transport", "fabric_quic_route"));
.put("transport", "fabric_quic_route")
.put("status", runtime));
JSONObject metadata = new JSONObject();
metadata.put("source", "android_vpn_client");
metadata.put("source", "ipv4_ingress_adapter");
metadata.put("candidate", true);
metadata.put("passive", true);
metadata.put("app_version", APP_VERSION);
metadata.put("vpn_runtime_status", runtime);
metadata.put("mesh_endpoint_report", new JSONObject()
.put("schema_version", "rap.mesh_endpoint_report.v1")
.put("transport", "quic")
@@ -971,6 +1002,42 @@ public class MainActivity extends Activity {
client.sendFabricNodeHeartbeat(clusterId.getText().toString().trim().isEmpty() ? DEFAULT_CLUSTER_ID : clusterId.getText().toString().trim(), nodeId, payload);
}
private JSONObject runtimeSnapshot() {
JSONObject runtime = new JSONObject();
try {
runtime.put("schema_version", "rap.android_vpn_runtime_status.v1");
runtime.put("state", runtimePrefs.getString("state", ""));
runtime.put("message", runtimePrefs.getString("message", ""));
runtime.put("updated_at_ms", runtimePrefs.getLong("updated_at", 0));
runtime.put("runtime_started_at_ms", runtimePrefs.getLong("runtime_started_at", 0));
runtime.put("selected_connection_id", prefs.getString(PREF_TUNNEL_ID, ""));
runtime.put("system_vpn_active", isSystemVpnActive());
runtime.put("uplink_read_packets", runtimePrefs.getLong("uplink_read_total", 0));
runtime.put("uplink_sent_packets", runtimePrefs.getLong("uplink_sent_total", 0));
runtime.put("downlink_received_packets", runtimePrefs.getLong("downlink_received_total", 0));
runtime.put("uplink_dropped_packets", runtimePrefs.getLong("uplink_dropped_packets", 0));
runtime.put("downlink_dropped_packets", runtimePrefs.getLong("downlink_dropped_packets", 0));
runtime.put("local_dns_queries", runtimePrefs.getLong("local_dns_queries", 0));
runtime.put("local_dns_replies", runtimePrefs.getLong("local_dns_replies", 0));
runtime.put("local_dns_errors", runtimePrefs.getLong("local_dns_errors", 0));
runtime.put("runtime_watchdog_recoveries", runtimePrefs.getLong("runtime_watchdog_recoveries", 0));
runtime.put("runtime_watchdog_hard_restarts", runtimePrefs.getLong("runtime_watchdog_hard_restarts", 0));
runtime.put("uplink_read_mbps", runtimePrefs.getFloat("uplink_read_mbps", 0));
runtime.put("uplink_sent_mbps", runtimePrefs.getFloat("uplink_sent_mbps", 0));
runtime.put("downlink_received_mbps", runtimePrefs.getFloat("downlink_received_mbps", 0));
runtime.put("fabric_state", runtimePrefs.getString("fabric_state", ""));
runtime.put("fabric_message", runtimePrefs.getString("fabric_message", ""));
runtime.put("uplink_state", runtimePrefs.getString("uplink_state", ""));
runtime.put("uplink_message", runtimePrefs.getString("uplink_message", ""));
runtime.put("downlink_state", runtimePrefs.getString("downlink_state", ""));
runtime.put("downlink_message", runtimePrefs.getString("downlink_message", ""));
runtime.put("downlink_writer_state", runtimePrefs.getString("downlink_writer_state", ""));
runtime.put("downlink_writer_message", runtimePrefs.getString("downlink_writer_message", ""));
} catch (Exception ignored) {
}
return runtime;
}
private void showSettingsDialog() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
@@ -1000,7 +1067,7 @@ public class MainActivity extends Activity {
new AlertDialog.Builder(this)
.setTitle("Настройка VPN")
.setView(form)
.setPositiveButton("Войти и выбрать выход", (dialog, which) -> {
.setPositiveButton("Войти и выбрать пул", (dialog, which) -> {
email.setText(emailDraft.getText().toString());
String passwordValue = passwordDraft.getText().toString();
password.setText("");
@@ -1017,7 +1084,7 @@ public class MainActivity extends Activity {
}
private void loginAndChoosePool(String emailValue, String passwordValue) {
status.setText("Идентифицирую устройство и загружаю доступные выходы...");
status.setText("Идентифицирую устройство и загружаю доступные пулы...");
new Thread(() -> {
try {
RapApiClient client = new RapApiClient(fabricControlConfig(), this);
@@ -1082,6 +1149,18 @@ public class MainActivity extends Activity {
}
}
private String firstNonEmpty(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
if (value != null && !value.trim().isEmpty()) {
return value.trim();
}
}
return "";
}
private String friendlyError(Exception ex) {
String message = ex.getMessage();
if (message == null || message.trim().isEmpty()) {
@@ -1,41 +1,21 @@
package su.cin.rapvpn;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Dispatcher;
import okhttp3.ConnectionPool;
import okhttp3.Dns;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import android.net.VpnService;
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.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
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 final String baseUrl;
private final OkHttpClient httpClient;
private final String networkMode;
private final Context context;
private final Manager fabricControlManager;
RapApiClient(String baseUrl) {
@@ -44,65 +24,43 @@ final class RapApiClient {
RapApiClient(String baseUrl, Context context) {
this.baseUrl = trimRight(baseUrl);
this.context = context;
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
// Network with EACCES, which must not block login/profile refresh.
this.networkMode = context == null ? "default_network" : "default_network_context";
builder.dns(new BackendPinnedDns(baseUrl));
builder.connectTimeout(5, TimeUnit.SECONDS);
builder.writeTimeout(12, TimeUnit.SECONDS);
builder.readTimeout(12, TimeUnit.SECONDS);
builder.callTimeout(15, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(true);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);
dispatcher.setMaxRequestsPerHost(32);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
this.networkMode = context == null ? "fabric_control" : "fabric_control_context";
}
RapApiClient(String baseUrl, Context context, boolean preferUnderlyingNetwork) {
this.baseUrl = trimRight(baseUrl);
this.fabricControlManager = startFabricControlManager(baseUrl);
OkHttpClient.Builder builder = new OkHttpClient.Builder();
String mode = context == null ? "default_network" : "default_network_context";
if (preferUnderlyingNetwork && context != null) {
SocketFactory socketFactory = underlyingSocketFactory(context);
if (socketFactory != null) {
builder.socketFactory(socketFactory);
mode = "underlying_network_context";
}
}
this.networkMode = mode;
builder.dns(new BackendPinnedDns(baseUrl));
builder.connectTimeout(3, TimeUnit.SECONDS);
builder.writeTimeout(6, TimeUnit.SECONDS);
builder.readTimeout(6, TimeUnit.SECONDS);
builder.callTimeout(8, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(true);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);
dispatcher.setMaxRequestsPerHost(32);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
this(baseUrl, context);
}
String networkMode() {
return networkMode;
}
void close() {
try {
fabricControlManager.stop();
} catch (Exception ignored) {
}
}
private Manager startFabricControlManager(String config) {
String value = config == null ? "" : config.trim();
if (!value.startsWith("{")) {
return null;
throw new IllegalStateException("Android узел должен подключаться к ферме только через QUIC fabric bootstrap.");
}
try {
Fabricvpn.touch();
Manager manager = Fabricvpn.newManager();
if (context instanceof VpnService) {
VpnService vpnService = (VpnService) context;
manager.setSocketProtector(new SocketProtector() {
@Override
public boolean protect(long fd) {
return vpnService.protect((int) fd);
}
});
}
manager.start(value);
return manager;
} catch (Exception e) {
@@ -111,45 +69,6 @@ final class RapApiClient {
}
}
static final class BackendPinnedDns implements Dns {
private final String backendHost;
BackendPinnedDns(String baseUrl) {
String parsedHost = "";
try {
parsedHost = URI.create(baseUrl == null ? "" : baseUrl).getHost();
} catch (Exception ignored) {
}
backendHost = parsedHost == null ? "" : parsedHost.trim().toLowerCase();
}
@Override
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
return Dns.SYSTEM.lookup(hostname);
}
}
private SocketFactory underlyingSocketFactory(Context context) {
ConnectivityManager connectivity = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (connectivity == null) {
return null;
}
for (Network network : connectivity.getAllNetworks()) {
NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network);
if (capabilities == null) {
continue;
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
continue;
}
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
continue;
}
return network.getSocketFactory();
}
return null;
}
AuthContext login(String email, String password, String deviceFingerprint) throws Exception {
JSONObject body = new JSONObject();
body.put("email", email);
@@ -167,11 +86,8 @@ final class RapApiClient {
return parseAuthContext(post("/auth/refresh", body));
}
String vpnClientProfile(String clusterId, String organizationId, String userId, String exitNodeId) throws Exception {
String vpnClientProfile(String clusterId, String organizationId, String userId, String ignoredExitNodeId) throws Exception {
String path = "/clusters/" + clusterId + "/vpn/client-profile?organization_id=" + organizationId + "&user_id=" + userId;
if (exitNodeId != null && !exitNodeId.trim().isEmpty()) {
path += "&exit_node_id=" + exitNodeId.trim();
}
return get(path).toString();
}
@@ -193,22 +109,11 @@ final class RapApiClient {
}
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);
return fabricControlJSON("GET", path, null);
}
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))
.build();
return read(request);
return fabricControlJSON("POST", path, body);
}
private JSONObject fabricControlJSON(String method, String path, JSONObject body) throws Exception {
@@ -243,7 +148,7 @@ final class RapApiClient {
if (statusCode == 401 && bodyText.contains("auth.invalid_refresh_token")) {
throw new IllegalStateException("Сессия устройства истекла. Введите пароль один раз.");
}
throw new IllegalStateException("fabric control HTTP " + statusCode + ": " + compactText(bodyText, 240));
throw new IllegalStateException("fabric control status " + statusCode + ": " + compactText(bodyText, 240));
}
return bodyText.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
@@ -267,42 +172,6 @@ final class RapApiClient {
return value;
}
private String describeHttpFailure(Response response) {
StringBuilder message = new StringBuilder("HTTP ").append(response.code());
ResponseBody body = response.body();
if (body != null) {
try {
String text = body.string();
if (text != null && !text.trim().isEmpty()) {
text = text.replace('\n', ' ').replace('\r', ' ').trim();
if (text.length() > 240) {
text = text.substring(0, 240);
}
message.append(": ").append(text);
}
} catch (Exception ignored) {
}
}
return message.toString();
}
private JSONObject read(Request request) throws Exception {
try (Response response = httpClient.newCall(request).execute()) {
ResponseBody body = response.body();
String text = body == null ? "" : body.string();
if (!response.isSuccessful()) {
if (response.code() == 401 && text.contains("auth.invalid_credentials")) {
throw new IllegalStateException("Неверный логин или пароль.");
}
if (response.code() == 401 && text.contains("auth.invalid_refresh_token")) {
throw new IllegalStateException("Сессия устройства истекла. Введите пароль один раз.");
}
throw new IllegalStateException("HTTP " + response.code() + ": " + text);
}
return new JSONObject(text);
}
}
private AuthContext parseAuthContext(JSONObject response) throws Exception {
JSONObject user = response.getJSONObject("user");
String userId = user.optString("id", "");
@@ -7,11 +7,14 @@ import android.content.SharedPreferences;
import android.net.VpnService;
import android.os.Build;
import org.json.JSONArray;
import org.json.JSONObject;
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_CLUSTER_ID = "cluster_id";
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
private static final String PREF_TUNNEL_ID = "tunnel_id";
private static final String PREF_MANUAL_STOPPED = "manual_stopped";
@Override
@@ -28,13 +31,12 @@ public final class RapAutostartReceiver extends BroadcastReceiver {
if (prefs.getBoolean(PREF_MANUAL_STOPPED, false)) {
return;
}
if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) {
// After package replacement we wait for an explicit user action or runtime resume.
return;
}
String profile = prefs.getString(PREF_PROFILE_JSON, "");
String clusterId = prefs.getString(PREF_CLUSTER_ID, "");
String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
String vpnConnectionId = prefs.getString(PREF_TUNNEL_ID, "");
if (vpnConnectionId.isEmpty() || !profileContainsConnection(profile, vpnConnectionId)) {
vpnConnectionId = firstConnectionId(profile);
}
if (profile.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
return;
}
@@ -44,11 +46,66 @@ public final class RapAutostartReceiver extends BroadcastReceiver {
Intent service = new Intent(context, RapVpnService.class);
service.putExtra("profile_json", profile);
service.putExtra("cluster_id", clusterId);
service.putExtra("vpn_connection_id", vpnConnectionId);
service.putExtra("tunnel_id", vpnConnectionId);
if (Build.VERSION.SDK_INT >= 26) {
context.startForegroundService(service);
} else {
context.startService(service);
}
}
private String firstConnectionId(String profile) {
if (profile == null || profile.trim().isEmpty()) {
return "";
}
try {
JSONObject root = new JSONObject(profile);
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
if (vpnProfile == null) {
vpnProfile = root;
}
JSONArray connections = vpnProfile.optJSONArray("connections");
if (connections == null) {
return "";
}
for (int i = 0; i < connections.length(); i++) {
JSONObject connection = connections.optJSONObject(i);
if (connection == null) {
continue;
}
String connectionId = connection.optString("id", "").trim();
if (!connectionId.isEmpty()) {
return connectionId;
}
}
} catch (Exception ignored) {
}
return "";
}
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");
if (vpnProfile == null) {
vpnProfile = root;
}
JSONArray connections = 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", "").trim())) {
return true;
}
}
} catch (Exception ignored) {
}
return false;
}
}
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ public class TestVpnActivity extends Activity {
public static final String EXTRA_PROFILE_BASE64 = "profile_base64";
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";
public static final String EXTRA_TUNNEL_ID = "tunnel_id";
private static final int VPN_PREPARE_REQUEST = 77;
private Intent serviceIntent;
@@ -49,7 +49,7 @@ public class TestVpnActivity extends Activity {
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));
intent.putExtra(RapVpnService.EXTRA_TUNNEL_ID, source.getStringExtra(EXTRA_TUNNEL_ID));
return intent;
}