+
+
+
+
+ Android Cast — remote control
+
+
+
+
+
+
+
+
+ Remote access settings
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/ra_control/shell.html b/app/src/main/assets/ra_control/shell.html
new file mode 100644
index 0000000..8a1c2f2
--- /dev/null
+++ b/app/src/main/assets/ra_control/shell.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ Android Cast — shell
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java
index 00b032c..c9a8c71 100644
--- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java
+++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java
@@ -69,6 +69,8 @@ public final class AppPreferences {
private static final String KEY_DEV_REMOTE_ACCESS_RANDOM = "dev_remote_access_random";
private static final String KEY_DEV_REMOTE_ACCESS_SESSION_ID = "dev_remote_access_session_id";
private static final String KEY_DEV_REMOTE_ACCESS_EXPIRES_AT = "dev_remote_access_expires_at";
+ private static final String KEY_DEV_REMOTE_ACCESS_VPN_ROUTE_SCOPE = "dev_remote_access_vpn_route_scope";
+ private static final String KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE = "dev_remote_access_vpn_app_scope";
private static final String KEY_DEV_ADB_WIFI_KEEPER = "dev_adb_wifi_keeper";
/** Minimum interval between automatic OTA checks on app launch. */
@@ -528,6 +530,40 @@ public final class AppPreferences {
return prefs(context).getLong(KEY_DEV_REMOTE_ACCESS_EXPIRES_AT, 0L);
}
+ public static com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope getDevRemoteAccessVpnRouteScope(
+ Context context) {
+ return com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope.fromStored(
+ prefs(context).getString(
+ KEY_DEV_REMOTE_ACCESS_VPN_ROUTE_SCOPE,
+ com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope.HUB_ONLY.name()));
+ }
+
+ public static void setDevRemoteAccessVpnRouteScope(
+ Context context,
+ com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope scope) {
+ if (scope == null) {
+ scope = com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope.HUB_ONLY;
+ }
+ prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_VPN_ROUTE_SCOPE, scope.name()).apply();
+ }
+
+ public static com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope getDevRemoteAccessVpnAppScope(
+ Context context) {
+ return com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope.fromStored(
+ prefs(context).getString(
+ KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE,
+ com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope.ALL_APPS.name()));
+ }
+
+ public static void setDevRemoteAccessVpnAppScope(
+ Context context,
+ com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope scope) {
+ if (scope == null) {
+ scope = com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope.ALL_APPS;
+ }
+ prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE, scope.name()).apply();
+ }
+
/**
* Reset user-facing settings shown in Android Cast settings.
* Developer-only settings are intentionally preserved.
diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
index b6327cc..34c50b6 100644
--- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
@@ -43,6 +43,7 @@ import com.foxx.androidcast.dev.DevNetworkSelfTestController;
import com.foxx.androidcast.network.diag.CastDiagPing;
import com.foxx.androidcast.remoteaccess.RemoteAccessCoordinator;
import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
+import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.textfield.TextInputEditText;
@@ -59,6 +60,10 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
private Spinner videoPresetSpinner;
private Spinner audioPresetSpinner;
private Spinner remoteAccessSpinner;
+ private Spinner remoteAccessVpnRouteSpinner;
+ private Spinner remoteAccessVpnAppSpinner;
+ private android.view.View remoteAccessVpnRouteLabel;
+ private android.view.View remoteAccessVpnAppLabel;
private RemoteAccessMode pendingRemoteAccessMode;
private boolean suppressRemoteAccessSpinnerEvents;
private SeekBar intensitySeek;
@@ -253,6 +258,87 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
@Override
public void onNothingSelected(android.widget.AdapterView> parent) {}
});
+ bindRemoteAccessVpnRoutingSection();
+ }
+
+ private void bindRemoteAccessVpnRoutingSection() {
+ remoteAccessVpnRouteLabel = findViewById(R.id.label_dev_remote_access_vpn_route);
+ remoteAccessVpnAppLabel = findViewById(R.id.label_dev_remote_access_vpn_app);
+ remoteAccessVpnRouteSpinner = findViewById(R.id.spinner_dev_remote_access_vpn_route);
+ remoteAccessVpnAppSpinner = findViewById(R.id.spinner_dev_remote_access_vpn_app_scope);
+ if (remoteAccessVpnRouteSpinner == null || remoteAccessVpnAppSpinner == null) {
+ return;
+ }
+
+ final RemoteAccessVpnRouting.RouteScope[] routeScopes = RemoteAccessVpnRouting.RouteScope.values();
+ String[] routeLabels = {
+ getString(R.string.dev_remote_access_vpn_route_hub_only),
+ getString(R.string.dev_remote_access_vpn_route_full_tunnel),
+ };
+ ArrayAdapter routeAdapter = new ArrayAdapter<>(this,
+ android.R.layout.simple_spinner_item, routeLabels);
+ routeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ remoteAccessVpnRouteSpinner.setAdapter(routeAdapter);
+ remoteAccessVpnRouteSpinner.setOnItemSelectedListener(null);
+ remoteAccessVpnRouteSpinner.setSelection(
+ AppPreferences.getDevRemoteAccessVpnRouteScope(this).ordinal(), false);
+ remoteAccessVpnRouteSpinner.setOnItemSelectedListener(simpleSelection(() -> {
+ int pos = remoteAccessVpnRouteSpinner.getSelectedItemPosition();
+ if (pos < 0 || pos >= routeScopes.length) {
+ return;
+ }
+ RemoteAccessVpnRouting.RouteScope picked = routeScopes[pos];
+ if (picked == AppPreferences.getDevRemoteAccessVpnRouteScope(this)) {
+ return;
+ }
+ AppPreferences.setDevRemoteAccessVpnRouteScope(this, picked);
+ RemoteAccessCoordinator.onVpnRoutingPreferenceChanged(this);
+ Toast.makeText(this, R.string.dev_remote_access_vpn_routing_applied, Toast.LENGTH_SHORT).show();
+ }));
+
+ final RemoteAccessVpnRouting.AppScope[] appScopes = RemoteAccessVpnRouting.AppScope.values();
+ String[] appLabels = {
+ getString(R.string.dev_remote_access_vpn_app_all),
+ getString(R.string.dev_remote_access_vpn_app_this_only),
+ };
+ ArrayAdapter appAdapter = new ArrayAdapter<>(this,
+ android.R.layout.simple_spinner_item, appLabels);
+ appAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ remoteAccessVpnAppSpinner.setAdapter(appAdapter);
+ remoteAccessVpnAppSpinner.setOnItemSelectedListener(null);
+ remoteAccessVpnAppSpinner.setSelection(
+ AppPreferences.getDevRemoteAccessVpnAppScope(this).ordinal(), false);
+ remoteAccessVpnAppSpinner.setOnItemSelectedListener(simpleSelection(() -> {
+ int pos = remoteAccessVpnAppSpinner.getSelectedItemPosition();
+ if (pos < 0 || pos >= appScopes.length) {
+ return;
+ }
+ RemoteAccessVpnRouting.AppScope picked = appScopes[pos];
+ if (picked == AppPreferences.getDevRemoteAccessVpnAppScope(this)) {
+ return;
+ }
+ AppPreferences.setDevRemoteAccessVpnAppScope(this, picked);
+ RemoteAccessCoordinator.onVpnRoutingPreferenceChanged(this);
+ Toast.makeText(this, R.string.dev_remote_access_vpn_routing_applied, Toast.LENGTH_SHORT).show();
+ }));
+
+ updateRemoteAccessVpnRoutingEnabled();
+ }
+
+ private void updateRemoteAccessVpnRoutingEnabled() {
+ boolean wg = AppPreferences.getDevRemoteAccessMode(this) == RemoteAccessMode.WIREGUARD;
+ if (remoteAccessVpnRouteSpinner != null) {
+ remoteAccessVpnRouteSpinner.setEnabled(wg);
+ }
+ if (remoteAccessVpnAppSpinner != null) {
+ remoteAccessVpnAppSpinner.setEnabled(wg);
+ }
+ if (remoteAccessVpnRouteLabel != null) {
+ remoteAccessVpnRouteLabel.setEnabled(wg);
+ }
+ if (remoteAccessVpnAppLabel != null) {
+ remoteAccessVpnAppLabel.setEnabled(wg);
+ }
}
private void applyRemoteAccessMode(RemoteAccessMode mode) {
@@ -266,6 +352,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
}
AppPreferences.setDevRemoteAccessMode(this, mode);
RemoteAccessCoordinator.applyMode(this, mode);
+ updateRemoteAccessVpnRoutingEnabled();
Toast.makeText(this, getString(R.string.dev_remote_access_applied, mode.name()), Toast.LENGTH_SHORT).show();
}
@@ -278,6 +365,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
if (resultCode == RESULT_OK) {
AppPreferences.setDevRemoteAccessMode(this, mode);
RemoteAccessCoordinator.applyMode(this, mode);
+ updateRemoteAccessVpnRoutingEnabled();
} else {
bindRemoteAccessSection();
Toast.makeText(this, R.string.dev_remote_access_vpn_denied, Toast.LENGTH_LONG).show();
diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java b/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java
index 02f0114..d7f5f9b 100644
--- a/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java
+++ b/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java
@@ -121,7 +121,8 @@ public final class CrashReporter {
if (procName == null || procName.isEmpty()) {
return false;
}
- return procName.endsWith(":vpn") || procName.endsWith(":netselftest");
+ return procName.endsWith(":vpn") || procName.endsWith(":netselftest")
+ || procName.endsWith(":ra_control");
}
public static String currentProcessName() {
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectInfo.java b/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectInfo.java
index c0e4044..03460c1 100644
--- a/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectInfo.java
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectInfo.java
@@ -27,6 +27,20 @@ public final class DevAdbConnectInfo {
public final boolean secureSettingsGranted;
public final int legacyPort;
public final long updatedMs;
+ public final RaControlEndpoint raControl;
+
+ /** Live RA control HTTP endpoint (random port per session). */
+ public static final class RaControlEndpoint {
+ public final int port;
+ public final String token;
+ public final List baseUrls;
+
+ public RaControlEndpoint(int port, String token, List baseUrls) {
+ this.port = port;
+ this.token = token != null ? token : "";
+ this.baseUrls = baseUrls != null ? baseUrls : Collections.emptyList();
+ }
+ }
public DevAdbConnectInfo(
List ips,
@@ -36,6 +50,18 @@ public final class DevAdbConnectInfo {
boolean secureSettingsGranted,
int legacyPort,
long updatedMs) {
+ this(ips, connect, adbEnabled, adbWifiEnabled, secureSettingsGranted, legacyPort, updatedMs, null);
+ }
+
+ public DevAdbConnectInfo(
+ List ips,
+ List connect,
+ boolean adbEnabled,
+ boolean adbWifiEnabled,
+ boolean secureSettingsGranted,
+ int legacyPort,
+ long updatedMs,
+ RaControlEndpoint raControl) {
this.ips = ips != null ? ips : Collections.emptyList();
this.connect = connect != null ? connect : Collections.emptyList();
this.adbEnabled = adbEnabled;
@@ -43,6 +69,7 @@ public final class DevAdbConnectInfo {
this.secureSettingsGranted = secureSettingsGranted;
this.legacyPort = legacyPort;
this.updatedMs = updatedMs;
+ this.raControl = raControl;
}
public String primaryConnectLine() {
@@ -73,6 +100,25 @@ public final class DevAdbConnectInfo {
root.put("commands", commands);
root.put("hint", "Grant WRITE_SECURE_SETTINGS once via USB; use tls_port "
+ "(not 5555) on Android 11+. Host: avahi-browse -rpt _adb-tls-connect._tcp");
+ if (raControl != null && raControl.port > 0) {
+ JSONObject ra = new JSONObject();
+ ra.put("port", raControl.port);
+ ra.put("token", raControl.token);
+ JSONArray raUrls = new JSONArray();
+ for (String ip : ips) {
+ if (!TextUtils.isEmpty(ip)) {
+ raUrls.put("http://" + ip + ":" + raControl.port + "/?token=" + raControl.token);
+ }
+ }
+ raUrls.put("http://127.0.0.1:" + raControl.port + "/?token=" + raControl.token);
+ for (String url : raControl.baseUrls) {
+ if (!TextUtils.isEmpty(url)) {
+ raUrls.put(url);
+ }
+ }
+ ra.put("urls", raUrls);
+ root.put("ra_control", ra);
+ }
return root.toString(2);
} catch (JSONException e) {
return "{}";
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiHelper.java b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiHelper.java
index fdc372a..4a421d9 100644
--- a/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiHelper.java
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiHelper.java
@@ -12,6 +12,8 @@ import android.provider.Settings;
import android.text.TextUtils;
import android.util.Log;
+import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient;
+
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
@@ -58,6 +60,7 @@ public final class DevAdbWifiHelper {
List dedupedConnect = orderConnectEndpoints(dedupe(connect), ports);
long now = System.currentTimeMillis();
+ DevAdbConnectInfo.RaControlEndpoint raControl = RemoteAccessControlClient.snapshotForAdb(app);
return new DevAdbConnectInfo(
ips,
dedupedConnect,
@@ -65,7 +68,8 @@ public final class DevAdbWifiHelper {
adbWifiEnabled,
secure,
DevAdbConnectInfo.LEGACY_TCP_PORT,
- now);
+ now,
+ raControl);
}
private static void enableDeveloperAdb(Context context) {
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java b/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
index 5bfa42b..990b916 100644
--- a/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
@@ -12,6 +12,7 @@ import com.foxx.androidcast.crash.CrashReporter;
import com.foxx.androidcast.remoteaccess.RemoteAccessFailureReason;
import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore;
+import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge;
import com.foxx.androidcast.vpn.VpnServiceClient;
import com.foxx.androidcast.vpn.VpnState;
@@ -42,6 +43,17 @@ public final class DevVpnStatusProbe {
String typeLabel = mode == RemoteAccessMode.DISABLED ? "off" : mode.name().toLowerCase(Locale.US);
r.itemPass("type", typeLabel);
+ if (mode != RemoteAccessMode.DISABLED) {
+ r.itemPass(
+ "vpn-route-scope",
+ RemoteAccessVpnRouting.routeScopeLabel(
+ AppPreferences.getDevRemoteAccessVpnRouteScope(context)));
+ r.itemPass(
+ "vpn-app-scope",
+ RemoteAccessVpnRouting.appScopeLabel(
+ AppPreferences.getDevRemoteAccessVpnAppScope(context)));
+ }
+
String vpnDetail = "";
if (mainProcess && mode == RemoteAccessMode.WIREGUARD) {
vpnDetail = VpnServiceClient.get(context).getStateDetailIfBound();
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java
index e93e495..03dc381 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java
@@ -47,4 +47,16 @@ public final class RemoteAccessCoordinator {
RemoteAccessService.start(context.getApplicationContext(), mode);
}
}
+
+ /** Reconnect WireGuard when dev routing prefs change while mode is active. */
+ public static void onVpnRoutingPreferenceChanged(Context context) {
+ Context app = context.getApplicationContext();
+ RemoteAccessMode mode = AppPreferences.getDevRemoteAccessMode(app);
+ if (mode != RemoteAccessMode.WIREGUARD) {
+ return;
+ }
+ Log.i(TAG, "VPN routing preference changed — reconnecting tunnel");
+ VpnServiceClient.get(app).tearDown();
+ RemoteAccessService.start(app, RemoteAccessMode.WIREGUARD);
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java
index 29834ad..4e4e920 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java
@@ -19,6 +19,7 @@ import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.R;
+import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient;
import com.foxx.androidcast.vpn.VpnServiceClient;
import java.net.Inet4Address;
@@ -140,6 +141,10 @@ public final class RemoteAccessService extends Service {
hb.put("app_version", com.foxx.androidcast.BuildConfig.VERSION_NAME);
hb.put("device", buildDeviceMeta(this));
hb.put("tunnel_mode", mode.toApiValue());
+ hb.put("vpn_route_scope", RemoteAccessVpnRouting.routeScopeLabel(
+ AppPreferences.getDevRemoteAccessVpnRouteScope(this)));
+ hb.put("vpn_app_scope", RemoteAccessVpnRouting.appScopeLabel(
+ AppPreferences.getDevRemoteAccessVpnAppScope(this)));
JSONArray caps = new JSONArray();
if (mode == RemoteAccessMode.WIREGUARD) {
caps.put("wireguard");
@@ -219,7 +224,7 @@ public final class RemoteAccessService extends Service {
return;
}
if ("wireguard".equals(tunnel)) {
- String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
+ String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(this, creds);
if (wgConfig.isEmpty()) {
RemoteAccessStatusStore.record(
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
@@ -238,6 +243,7 @@ public final class RemoteAccessService extends Service {
@Override
public void onSuccess() {
updateNotificationConnected(sid, tunnel);
+ startRemoteControlPlane(sid);
RemoteAccessStatusStore.record(
RemoteAccessService.this, mode, RemoteAccessStatusStore.STATE_SUCCESS,
"VpnServiceClient.applyWireGuardConfig",
@@ -263,6 +269,7 @@ public final class RemoteAccessService extends Service {
"", sessionId);
if (ReverseSshTunnelBridge.applyConfig(this, creds)) {
updateNotificationConnected(sessionId, tunnel);
+ startRemoteControlPlane(sessionId);
RemoteAccessStatusStore.record(
this, mode, RemoteAccessStatusStore.STATE_SUCCESS,
"ReverseSshTunnelBridge.applyConfig",
@@ -285,6 +292,22 @@ public final class RemoteAccessService extends Service {
}
}
+ private void startRemoteControlPlane(String sessionId) {
+ RemoteAccessControlClient.get(this).startSession(new RemoteAccessControlClient.OperationListener() {
+ @Override
+ public void onSuccess() {
+ RemoteAccessControlClient.EndpointSnapshot snap =
+ RemoteAccessControlClient.snapshot(RemoteAccessService.this);
+ Log.i(TAG, "RA control HTTP on :" + snap.port + " session=" + sessionId);
+ }
+
+ @Override
+ public void onFailure(String reason) {
+ Log.w(TAG, "RA control HTTP failed: " + reason);
+ }
+ });
+ }
+
private void updateNotificationConnected(String sessionId, String tunnel) {
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm == null) {
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java
index 1ff9d6c..3bc84ff 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java
@@ -2,13 +2,15 @@ package com.foxx.androidcast.remoteaccess;
import android.content.Context;
+import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient;
import com.foxx.androidcast.vpn.VpnServiceClient;
-/** Tear down all remote-access transports (WireGuard + RSSH). */
+/** Tear down all remote-access transports (WireGuard + RSSH + control HTTP). */
public final class RemoteAccessTunnelHelper {
private RemoteAccessTunnelHelper() {}
public static void tearDownAll(Context context) {
+ RemoteAccessControlClient.get(context).stopSessionSync();
VpnServiceClient.get(context).tearDown();
ReverseSshTunnelBridge.tearDown(context);
}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnRouting.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnRouting.java
new file mode 100644
index 0000000..39d4632
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnRouting.java
@@ -0,0 +1,84 @@
+package com.foxx.androidcast.remoteaccess;
+
+import android.content.Context;
+
+import com.foxx.androidcast.AppPreferences;
+
+/** Developer VPN routing: which destinations and which apps use the tunnel (WireGuard). */
+public final class RemoteAccessVpnRouting {
+ public static final String DEFAULT_HUB_ALLOWED_IPS = "172.200.2.1/32";
+ public static final String FULL_TUNNEL_ALLOWED_IPS = "0.0.0.0/0, ::/0";
+
+ public enum RouteScope {
+ /** Split tunnel — hub WG address only (production default). */
+ HUB_ONLY,
+ /** Route all IPv4/IPv6 through the tunnel when it is up. */
+ FULL_TUNNEL;
+
+ public static RouteScope fromStored(String raw) {
+ if (raw == null || raw.isEmpty()) {
+ return HUB_ONLY;
+ }
+ try {
+ return valueOf(raw.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return HUB_ONLY;
+ }
+ }
+ }
+
+ public enum AppScope {
+ /** Any app whose traffic matches AllowedIPs may use the VPN routes. */
+ ALL_APPS,
+ /** Only this app's traffic uses the VPN routes (VpnService / IncludedApplications). */
+ THIS_APP_ONLY;
+
+ public static AppScope fromStored(String raw) {
+ if (raw == null || raw.isEmpty()) {
+ return ALL_APPS;
+ }
+ try {
+ return valueOf(raw.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return ALL_APPS;
+ }
+ }
+ }
+
+ private RemoteAccessVpnRouting() {}
+
+ public static RouteScope routeScope(Context context) {
+ return AppPreferences.getDevRemoteAccessVpnRouteScope(context);
+ }
+
+ public static AppScope appScope(Context context) {
+ return AppPreferences.getDevRemoteAccessVpnAppScope(context);
+ }
+
+ public static String resolveAllowedIps(RouteScope routeScope, String bePeerAllowedIps) {
+ if (routeScope == RouteScope.FULL_TUNNEL) {
+ return FULL_TUNNEL_ALLOWED_IPS;
+ }
+ String hub = bePeerAllowedIps != null ? bePeerAllowedIps.trim() : "";
+ return hub.isEmpty() ? DEFAULT_HUB_ALLOWED_IPS : hub;
+ }
+
+ public static void appendInterfaceExtras(
+ StringBuilder sb,
+ AppScope appScope,
+ String packageName) {
+ if (appScope == AppScope.THIS_APP_ONLY
+ && packageName != null
+ && !packageName.isEmpty()) {
+ sb.append("IncludedApplications = ").append(packageName).append('\n');
+ }
+ }
+
+ public static String routeScopeLabel(RouteScope scope) {
+ return scope != null ? scope.name().toLowerCase() : RouteScope.HUB_ONLY.name().toLowerCase();
+ }
+
+ public static String appScopeLabel(AppScope scope) {
+ return scope != null ? scope.name().toLowerCase() : AppScope.ALL_APPS.name().toLowerCase();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilder.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilder.java
index c4006a6..6e1386a 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilder.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilder.java
@@ -1,32 +1,63 @@
package com.foxx.androidcast.remoteaccess;
+import android.content.Context;
import android.util.Log;
import org.json.JSONObject;
-/** Build wireguard-quick style config from BE connect credentials. */
+import com.foxx.androidcast.AppPreferences;
+
+/** Build wireguard-quick style config from BE connect credentials + dev VPN routing prefs. */
public final class WireGuardConfigBuilder {
private static final String TAG = "WireGuardCfg";
private WireGuardConfigBuilder() {}
- public static String fromConnectCredentials(JSONObject credentials) {
+ public static String fromConnectCredentials(Context context, JSONObject credentials) {
+ if (context == null) {
+ return fromConnectCredentials(
+ credentials,
+ RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
+ RemoteAccessVpnRouting.AppScope.ALL_APPS,
+ null);
+ }
+ return fromConnectCredentials(
+ credentials,
+ AppPreferences.getDevRemoteAccessVpnRouteScope(context),
+ AppPreferences.getDevRemoteAccessVpnAppScope(context),
+ context.getPackageName());
+ }
+
+ public static String fromConnectCredentials(
+ JSONObject credentials,
+ RemoteAccessVpnRouting.RouteScope routeScope,
+ RemoteAccessVpnRouting.AppScope appScope,
+ String packageName) {
if (credentials == null) {
return "";
}
String priv = credentials.optString("interface_private_key", "");
- String addr = credentials.optString("interface_address", "10.66.66.2/32");
+ String addr = credentials.optString("interface_address", "172.200.2.10/32");
String peerPub = credentials.optString("peer_public_key", "");
String endpoint = credentials.optString("peer_endpoint", "");
- String allowed = credentials.optString("peer_allowed_ips", "0.0.0.0/0");
+ String beAllowed = credentials.optString("peer_allowed_ips", "");
if (priv.isEmpty() || peerPub.isEmpty()) {
Log.w(TAG, "missing wg keys in credentials");
return "";
}
+ if (routeScope == null) {
+ routeScope = RemoteAccessVpnRouting.RouteScope.HUB_ONLY;
+ }
+ if (appScope == null) {
+ appScope = RemoteAccessVpnRouting.AppScope.ALL_APPS;
+ }
+ String allowed = RemoteAccessVpnRouting.resolveAllowedIps(routeScope, beAllowed);
+
StringBuilder sb = new StringBuilder();
sb.append("[Interface]\n");
sb.append("PrivateKey = ").append(priv).append('\n');
sb.append("Address = ").append(addr).append('\n');
+ RemoteAccessVpnRouting.appendInterfaceExtras(sb, appScope, packageName);
sb.append('\n');
sb.append("[Peer]\n");
sb.append("PublicKey = ").append(peerPub).append('\n');
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardQuickConfig.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardQuickConfig.java
new file mode 100644
index 0000000..faafff6
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardQuickConfig.java
@@ -0,0 +1,54 @@
+package com.foxx.androidcast.remoteaccess;
+
+import java.util.ArrayList;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Parse wg-quick snippets used by TUN fallback. */
+public final class WireGuardQuickConfig {
+ private WireGuardQuickConfig() {}
+
+ public static String parseAddress(String wgConfig) {
+ if (wgConfig == null) {
+ return null;
+ }
+ Matcher m = Pattern.compile("(?m)^Address\\s*=\\s*(.+)$").matcher(wgConfig);
+ return m.find() ? m.group(1).trim() : null;
+ }
+
+ public static String[] parseAllowedRoutes(String wgConfig) {
+ if (wgConfig == null) {
+ return new String[0];
+ }
+ Matcher m = Pattern.compile("(?m)^AllowedIPs\\s*=\\s*(.+)$").matcher(wgConfig);
+ if (!m.find()) {
+ return new String[0];
+ }
+ String[] parts = m.group(1).trim().split("\\s*,\\s*");
+ ArrayList out = new ArrayList<>();
+ for (String p : parts) {
+ if (!p.isEmpty()) {
+ out.add(p.trim());
+ }
+ }
+ return out.toArray(new String[0]);
+ }
+
+ public static String[] parseIncludedApplications(String wgConfig) {
+ if (wgConfig == null) {
+ return new String[0];
+ }
+ Matcher m = Pattern.compile("(?m)^IncludedApplications\\s*=\\s*(.+)$").matcher(wgConfig);
+ if (!m.find()) {
+ return new String[0];
+ }
+ String[] parts = m.group(1).trim().split("\\s*,\\s*");
+ ArrayList out = new ArrayList<>();
+ for (String p : parts) {
+ if (!p.isEmpty()) {
+ out.add(p.trim());
+ }
+ }
+ return out.toArray(new String[0]);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlAuth.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlAuth.java
new file mode 100644
index 0000000..7da78f7
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlAuth.java
@@ -0,0 +1,74 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import android.text.TextUtils;
+
+import java.util.Locale;
+import java.util.Map;
+
+/** Bearer / query-token checks for the RA control HTTP server. */
+public final class RemoteAccessControlAuth {
+ public static final String HEADER = "Authorization";
+ public static final String QUERY_PARAM = "token";
+
+ private RemoteAccessControlAuth() {}
+
+ public static boolean isAuthorized(String expectedToken, String authorizationHeader, String queryToken) {
+ if (TextUtils.isEmpty(expectedToken)) {
+ return false;
+ }
+ if (!TextUtils.isEmpty(queryToken) && expectedToken.equals(queryToken.trim())) {
+ return true;
+ }
+ if (TextUtils.isEmpty(authorizationHeader)) {
+ return false;
+ }
+ String header = authorizationHeader.trim();
+ if (header.regionMatches(true, 0, "Bearer ", 0, 7)) {
+ return expectedToken.equals(header.substring(7).trim());
+ }
+ return expectedToken.equals(header);
+ }
+
+ public static String bearerHeader(String token) {
+ return "Bearer " + (token != null ? token : "");
+ }
+
+ public static String extractQueryToken(String path, Map query) {
+ if (query != null && query.containsKey(QUERY_PARAM)) {
+ return query.get(QUERY_PARAM);
+ }
+ if (path == null) {
+ return "";
+ }
+ int q = path.indexOf('?');
+ if (q < 0) {
+ return "";
+ }
+ String qs = path.substring(q + 1);
+ for (String pair : qs.split("&")) {
+ int eq = pair.indexOf('=');
+ if (eq <= 0) {
+ continue;
+ }
+ String key = pair.substring(0, eq);
+ if (QUERY_PARAM.equals(key)) {
+ return pair.substring(eq + 1);
+ }
+ }
+ return "";
+ }
+
+ public static String normalizePath(String path) {
+ if (path == null || path.isEmpty()) {
+ return "/";
+ }
+ int q = path.indexOf('?');
+ if (q >= 0) {
+ path = path.substring(0, q);
+ }
+ if (!path.startsWith("/")) {
+ path = "/" + path;
+ }
+ return path.toLowerCase(Locale.US);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlClient.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlClient.java
new file mode 100644
index 0000000..95196cf
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlClient.java
@@ -0,0 +1,326 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.foxx.androidcast.BuildConfig;
+import com.foxx.androidcast.IRemoteAccessControlService;
+import com.foxx.androidcast.IVpnOperationCallback;
+import com.foxx.androidcast.dev.DevAdbConnectInfo;
+import com.foxx.androidcast.dev.DevAdbWifiKeeper;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Main-process client for {@link RemoteAccessControlService} ({@code :ra_control}).
+ */
+public final class RemoteAccessControlClient {
+ private static final String TAG = "RaControlClient";
+ private static final long BIND_TIMEOUT_MS = 8_000L;
+
+ private static volatile RemoteAccessControlClient instance;
+ private static volatile EndpointSnapshot lastSnapshot = EndpointSnapshot.stopped();
+
+ private final Context appContext;
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private final Object bindLock = new Object();
+ private final RemoteAccessControlHostImpl hostImpl;
+
+ private IRemoteAccessControlService service;
+ private boolean binding;
+ private CountDownLatch bindLatch;
+
+ public interface OperationListener {
+ void onSuccess();
+
+ void onFailure(String reason);
+ }
+
+ public static final class EndpointSnapshot {
+ public final boolean running;
+ public final int port;
+ public final String token;
+ public final List urls;
+
+ EndpointSnapshot(boolean running, int port, String token, List urls) {
+ this.running = running;
+ this.port = port;
+ this.token = token != null ? token : "";
+ this.urls = urls != null ? urls : Collections.emptyList();
+ }
+
+ static EndpointSnapshot stopped() {
+ return new EndpointSnapshot(false, 0, "", Collections.emptyList());
+ }
+ }
+
+ private RemoteAccessControlClient(Context context) {
+ appContext = context.getApplicationContext();
+ hostImpl = new RemoteAccessControlHostImpl(appContext);
+ }
+
+ public static RemoteAccessControlClient get(Context context) {
+ if (instance == null) {
+ synchronized (RemoteAccessControlClient.class) {
+ if (instance == null) {
+ instance = new RemoteAccessControlClient(context);
+ }
+ }
+ }
+ return instance;
+ }
+
+ public static EndpointSnapshot snapshot(Context context) {
+ EndpointSnapshot snap = lastSnapshot;
+ if (snap != null && snap.running) {
+ return snap;
+ }
+ RemoteAccessControlClient client = instance;
+ if (client != null && client.service != null) {
+ return client.readSnapshotFromService();
+ }
+ return EndpointSnapshot.stopped();
+ }
+
+ public static DevAdbConnectInfo.RaControlEndpoint snapshotForAdb(Context context) {
+ EndpointSnapshot snap = snapshot(context);
+ if (!snap.running || snap.port <= 0) {
+ return null;
+ }
+ return new DevAdbConnectInfo.RaControlEndpoint(snap.port, snap.token, snap.urls);
+ }
+
+ private final ServiceConnection connection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder binder) {
+ synchronized (bindLock) {
+ service = IRemoteAccessControlService.Stub.asInterface(binder);
+ binding = false;
+ if (bindLatch != null) {
+ bindLatch.countDown();
+ }
+ }
+ registerHostIfNeeded();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ synchronized (bindLock) {
+ service = null;
+ binding = false;
+ }
+ updateSnapshot(EndpointSnapshot.stopped());
+ }
+ };
+
+ public void startSession(OperationListener listener) {
+ runWithService(s -> {
+ try {
+ if (s.isRunning()) {
+ refreshSnapshot();
+ deliver(listener, true, null);
+ return;
+ }
+ s.start(new IVpnOperationCallback.Stub() {
+ @Override
+ public void onSuccess() {
+ refreshSnapshot();
+ notifyAdbJsonRefresh();
+ deliver(listener, true, null);
+ }
+
+ @Override
+ public void onFailure(String reason) {
+ updateSnapshot(EndpointSnapshot.stopped());
+ deliver(listener, false, reason);
+ }
+ });
+ } catch (RemoteException e) {
+ deliver(listener, false, e.getMessage());
+ }
+ });
+ }
+
+ public void stopSession() {
+ runWithService(s -> {
+ try {
+ s.stop(new IVpnOperationCallback.Stub() {
+ @Override
+ public void onSuccess() {
+ updateSnapshot(EndpointSnapshot.stopped());
+ notifyAdbJsonRefresh();
+ }
+
+ @Override
+ public void onFailure(String reason) {
+ updateSnapshot(EndpointSnapshot.stopped());
+ notifyAdbJsonRefresh();
+ }
+ });
+ } catch (RemoteException e) {
+ Log.w(TAG, "stop failed: " + e.getMessage());
+ updateSnapshot(EndpointSnapshot.stopped());
+ }
+ });
+ }
+
+ public void stopSessionSync() {
+ if (!ensureBoundSync()) {
+ updateSnapshot(EndpointSnapshot.stopped());
+ return;
+ }
+ try {
+ service.stop(null);
+ } catch (RemoteException ignored) {
+ }
+ updateSnapshot(EndpointSnapshot.stopped());
+ }
+
+ private void refreshSnapshot() {
+ if (!ensureBoundSync()) {
+ updateSnapshot(EndpointSnapshot.stopped());
+ return;
+ }
+ updateSnapshot(readSnapshotFromService());
+ }
+
+ private EndpointSnapshot readSnapshotFromService() {
+ IRemoteAccessControlService s = service;
+ if (s == null) {
+ return EndpointSnapshot.stopped();
+ }
+ try {
+ if (!s.isRunning()) {
+ return EndpointSnapshot.stopped();
+ }
+ int port = s.getListenPort();
+ String token = s.getAuthToken();
+ List urls = buildUrls(port);
+ return new EndpointSnapshot(true, port, token, urls);
+ } catch (RemoteException e) {
+ return EndpointSnapshot.stopped();
+ }
+ }
+
+ private static List buildUrls(int port) {
+ if (port <= 0) {
+ return Collections.emptyList();
+ }
+ List out = new ArrayList<>();
+ out.add("127.0.0.1:" + port);
+ return out;
+ }
+
+ private static void updateSnapshot(EndpointSnapshot snap) {
+ lastSnapshot = snap != null ? snap : EndpointSnapshot.stopped();
+ }
+
+ private void notifyAdbJsonRefresh() {
+ if (BuildConfig.DEBUG) {
+ DevAdbWifiKeeper.refreshNow(appContext, "ra-control-endpoint");
+ }
+ }
+
+ private void registerHostIfNeeded() {
+ IRemoteAccessControlService s = service;
+ if (s == null) {
+ return;
+ }
+ try {
+ s.registerHost(hostImpl);
+ } catch (RemoteException e) {
+ Log.w(TAG, "registerHost failed: " + e.getMessage());
+ }
+ }
+
+ private void deliver(OperationListener listener, boolean ok, String reason) {
+ if (listener == null) {
+ return;
+ }
+ main.post(() -> {
+ if (ok) {
+ listener.onSuccess();
+ } else {
+ listener.onFailure(reason != null ? reason : "error");
+ }
+ });
+ }
+
+ private interface ServiceTask {
+ void run(IRemoteAccessControlService s) throws RemoteException;
+ }
+
+ private void runWithService(ServiceTask task) {
+ IRemoteAccessControlService s = service;
+ if (s != null) {
+ try {
+ task.run(s);
+ } catch (RemoteException e) {
+ Log.w(TAG, "remote call failed: " + e.getMessage());
+ }
+ return;
+ }
+ ensureBoundAsync();
+ new Thread(() -> {
+ if (!ensureBoundSync()) {
+ return;
+ }
+ try {
+ task.run(service);
+ } catch (RemoteException e) {
+ Log.w(TAG, "remote call failed: " + e.getMessage());
+ }
+ }, "RaControlClientBind").start();
+ }
+
+ private void ensureBoundAsync() {
+ synchronized (bindLock) {
+ if (service != null || binding) {
+ return;
+ }
+ binding = true;
+ Intent i = new Intent(appContext, RemoteAccessControlService.class);
+ appContext.startService(i);
+ appContext.bindService(i, connection, Context.BIND_AUTO_CREATE);
+ }
+ }
+
+ private boolean ensureBoundSync() {
+ if (service != null) {
+ registerHostIfNeeded();
+ return true;
+ }
+ synchronized (bindLock) {
+ if (service != null) {
+ return true;
+ }
+ bindLatch = new CountDownLatch(1);
+ ensureBoundAsync();
+ try {
+ if (!bindLatch.await(BIND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ Log.w(TAG, "RA control bind timeout");
+ return false;
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ } finally {
+ bindLatch = null;
+ }
+ }
+ registerHostIfNeeded();
+ return service != null;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHostImpl.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHostImpl.java
new file mode 100644
index 0000000..d743d27
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHostImpl.java
@@ -0,0 +1,152 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.BuildConfig;
+import com.foxx.androidcast.IRemoteAccessControlHost;
+import com.foxx.androidcast.remoteaccess.RemoteAccessCoordinator;
+import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
+import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore;
+import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
+import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge;
+import com.foxx.androidcast.vpn.VpnServiceClient;
+import com.foxx.androidcast.vpn.VpnState;
+
+import org.json.JSONObject;
+
+/**
+ * Main-process AIDL host: reads/writes {@link AppPreferences} for the RA control web UI.
+ */
+public final class RemoteAccessControlHostImpl extends IRemoteAccessControlHost.Stub {
+ private static final String TAG = "RaControlHost";
+
+ private final Context appContext;
+ private final Handler main = new Handler(Looper.getMainLooper());
+
+ public RemoteAccessControlHostImpl(Context context) {
+ appContext = context.getApplicationContext();
+ }
+
+ @Override
+ public String getSettingsJson() {
+ try {
+ RemoteAccessStatusStore.Snapshot status = RemoteAccessStatusStore.load(appContext);
+ JSONObject settings = RemoteAccessControlPrefsMirror.buildSnapshot(
+ AppPreferences.getDevRemoteAccessMode(appContext),
+ AppPreferences.getDevRemoteAccessVpnRouteScope(appContext),
+ AppPreferences.getDevRemoteAccessVpnAppScope(appContext),
+ status.sessionId,
+ AppPreferences.getDevRemoteAccessExpiresAt(appContext));
+ JSONObject out = new JSONObject();
+ out.put("ok", true);
+ out.put("settings", settings);
+ return out.toString();
+ } catch (Exception e) {
+ return errorJson("settings read failed: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public String applySettingsJson(String bodyJson) {
+ RemoteAccessControlPrefsMirror.ApplyResult patch =
+ RemoteAccessControlPrefsMirror.parseApplyPatch(bodyJson);
+ if (!patch.ok) {
+ return errorJson(patch.error);
+ }
+ if (!patch.hasChanges()) {
+ return getSettingsJson();
+ }
+ final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
+ final String[] result = new String[1];
+ main.post(() -> {
+ try {
+ if (patch.mode != null) {
+ AppPreferences.setDevRemoteAccessMode(appContext, patch.mode);
+ }
+ if (patch.routeScope != null) {
+ AppPreferences.setDevRemoteAccessVpnRouteScope(appContext, patch.routeScope);
+ }
+ if (patch.appScope != null) {
+ AppPreferences.setDevRemoteAccessVpnAppScope(appContext, patch.appScope);
+ }
+ if (patch.mode != null) {
+ RemoteAccessCoordinator.applyMode(appContext, patch.mode);
+ } else if (patch.routeScope != null || patch.appScope != null) {
+ RemoteAccessCoordinator.onVpnRoutingPreferenceChanged(appContext);
+ }
+ result[0] = getSettingsJson();
+ } catch (Exception e) {
+ result[0] = errorJson("apply failed: " + e.getMessage());
+ } finally {
+ latch.countDown();
+ }
+ });
+ try {
+ latch.await(5_000L, java.util.concurrent.TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return errorJson("apply interrupted");
+ }
+ return result[0] != null ? result[0] : errorJson("apply timeout");
+ }
+
+ @Override
+ public String getInternalsJson() {
+ try {
+ RemoteAccessStatusStore.Snapshot status = RemoteAccessStatusStore.load(appContext);
+ RemoteAccessControlClient.EndpointSnapshot control =
+ RemoteAccessControlClient.snapshot(appContext);
+ JSONObject out = new JSONObject();
+ out.put("ok", true);
+ out.put("app_version", BuildConfig.VERSION_NAME);
+ out.put("git_commit", BuildConfig.GIT_COMMIT_SHORT);
+ out.put("remote_access_mode", AppPreferences.getDevRemoteAccessMode(appContext).name());
+ out.put("vpn_state", VpnServiceClient.get(appContext).getStateIfBound());
+ out.put("vpn_state_label", vpnStateLabel(VpnServiceClient.get(appContext).getStateIfBound()));
+ out.put("vpn_detail", VpnServiceClient.get(appContext).getStateDetailIfBound());
+ out.put("rssh_connected", ReverseSshTunnelBridge.isConnected());
+ out.put("status", new JSONObject()
+ .put("state", status.state)
+ .put("method", status.method)
+ .put("initiator", status.initiator)
+ .put("fail_reason", status.failReason)
+ .put("detail", status.detail)
+ .put("session_id", status.sessionId)
+ .put("updated_ms", status.updatedMs));
+ if (control != null && control.running) {
+ out.put("control_http_port", control.port);
+ out.put("control_urls", new org.json.JSONArray(control.urls));
+ }
+ return out.toString();
+ } catch (Exception e) {
+ Log.w(TAG, "internals failed: " + e.getMessage());
+ return errorJson("internals failed: " + e.getMessage());
+ }
+ }
+
+ private static String vpnStateLabel(int state) {
+ if (state == VpnState.UP) {
+ return "up";
+ }
+ if (state == VpnState.CONNECTING) {
+ return "connecting";
+ }
+ if (state == VpnState.ERROR) {
+ return "error";
+ }
+ return "down";
+ }
+
+ private static String errorJson(String message) {
+ try {
+ return RemoteAccessControlPrefsMirror.errorResponse(message).toString();
+ } catch (Exception e) {
+ return "{\"ok\":false,\"error\":\"error\"}";
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHttpServer.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHttpServer.java
new file mode 100644
index 0000000..9fb5c19
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHttpServer.java
@@ -0,0 +1,432 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import android.content.Context;
+import android.content.res.AssetManager;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Template HTTP server for RA control UI, JSON API, and WebSocket shell. */
+final class RemoteAccessControlHttpServer implements Runnable {
+ private static final String TAG = "RaControlHttp";
+ private static final String ASSET_PREFIX = "ra_control/";
+
+ interface HostBridge {
+ String getSettingsJson();
+
+ String applySettingsJson(String body);
+
+ String getInternalsJson();
+ }
+
+ private final Context context;
+ private final AtomicReference authToken = new AtomicReference<>("");
+ private final HostBridge hostBridge;
+ private final ExecutorService clients = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "RaControlHttpClient");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private volatile ServerSocket serverSocket;
+ private volatile Thread acceptThread;
+ private volatile int boundPort;
+
+ RemoteAccessControlHttpServer(Context context, HostBridge hostBridge) {
+ this.context = context.getApplicationContext();
+ this.hostBridge = hostBridge;
+ }
+
+ synchronized int start(int preferredPort) throws IOException {
+ stopInternal();
+ IOException last = null;
+ for (int attempt = 0; attempt < 32; attempt++) {
+ int port = attempt == 0 && preferredPort > 0 ? preferredPort : pickRandomPort();
+ try (ServerSocket probe = new ServerSocket()) {
+ probe.setReuseAddress(true);
+ probe.bind(new InetSocketAddress(port));
+ } catch (IOException e) {
+ last = e;
+ continue;
+ }
+ ServerSocket socket = new ServerSocket();
+ socket.setReuseAddress(true);
+ socket.bind(new InetSocketAddress(port));
+ serverSocket = socket;
+ boundPort = port;
+ acceptThread = new Thread(this, "RaControlHttp");
+ acceptThread.setDaemon(true);
+ acceptThread.start();
+ Log.i(TAG, "listening on :" + port);
+ return port;
+ }
+ if (last != null) {
+ throw last;
+ }
+ throw new IOException("failed to bind RA control HTTP port");
+ }
+
+ synchronized void stop() {
+ stopInternal();
+ }
+
+ int getBoundPort() {
+ return boundPort;
+ }
+
+ void setAuthToken(String token) {
+ authToken.set(token != null ? token : "");
+ }
+
+ @Override
+ public void run() {
+ ServerSocket socket = serverSocket;
+ if (socket == null) {
+ return;
+ }
+ while (!Thread.currentThread().isInterrupted()) {
+ try {
+ Socket client = socket.accept();
+ clients.execute(() -> handleClient(client));
+ } catch (IOException e) {
+ if (!Thread.currentThread().isInterrupted()) {
+ Log.d(TAG, "accept stopped: " + e.getMessage());
+ }
+ break;
+ }
+ }
+ }
+
+ private void handleClient(Socket client) {
+ try {
+ client.setSoTimeout(120_000);
+ byte[] headerBytes = readHeaders(client.getInputStream());
+ if (headerBytes == null) {
+ return;
+ }
+ String headers = new String(headerBytes, StandardCharsets.US_ASCII);
+ HttpRequest request = parseRequest(headers, client.getInputStream());
+ if (request == null) {
+ writeResponse(client, 400, "text/plain", "bad request");
+ return;
+ }
+ route(client, request, headers);
+ } catch (Exception e) {
+ Log.d(TAG, "client error: " + e.getMessage());
+ } finally {
+ try {
+ client.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+
+ private void route(Socket client, HttpRequest request, String rawHeaders) throws IOException {
+ String expected = authToken.get();
+ String queryToken = RemoteAccessControlAuth.extractQueryToken(request.path, request.query);
+ String authHeader = request.headers.getOrDefault("authorization", "");
+ boolean authorized = RemoteAccessControlAuth.isAuthorized(expected, authHeader, queryToken);
+
+ String path = RemoteAccessControlAuth.normalizePath(request.path);
+ if ("/ws/shell".equals(path)) {
+ if (!authorized) {
+ writeResponse(client, 401, "text/plain", "unauthorized");
+ return;
+ }
+ if (!"GET".equalsIgnoreCase(request.method)) {
+ writeResponse(client, 405, "text/plain", "method not allowed");
+ return;
+ }
+ if (!rawHeaders.toLowerCase(Locale.US).contains("upgrade: websocket")) {
+ writeResponse(client, 400, "text/plain", "expected websocket upgrade");
+ return;
+ }
+ new RemoteAccessControlShellHandler(client, rawHeaders).run();
+ return;
+ }
+
+ if (path.startsWith("/static/")) {
+ serveAsset(client, path.substring("/static/".length()), "GET".equalsIgnoreCase(request.method));
+ return;
+ }
+
+ if (!authorized && !"/".equals(path) && !path.endsWith(".css")) {
+ writeResponse(client, 401, "application/json", "{\"ok\":false,\"error\":\"unauthorized\"}");
+ return;
+ }
+
+ if ("/api/settings".equals(path)) {
+ handleSettingsApi(client, request);
+ return;
+ }
+ if ("/api/internals".equals(path)) {
+ handleInternalsApi(client, request);
+ return;
+ }
+ if ("/shell".equals(path) && "GET".equalsIgnoreCase(request.method)) {
+ serveAsset(client, "shell.html", true, expected);
+ return;
+ }
+ if (("/".equals(path) || "/index.html".equals(path)) && "GET".equalsIgnoreCase(request.method)) {
+ serveAsset(client, "index.html", true, expected);
+ return;
+ }
+ writeResponse(client, 404, "text/plain", "not found");
+ }
+
+ private void handleSettingsApi(Socket client, HttpRequest request) throws IOException {
+ if ("GET".equalsIgnoreCase(request.method)) {
+ String json = hostBridge.getSettingsJson();
+ writeResponse(client, 200, "application/json; charset=utf-8", json != null ? json : "{}");
+ return;
+ }
+ if ("POST".equalsIgnoreCase(request.method)) {
+ String body = request.body != null ? request.body : "";
+ String json = hostBridge.applySettingsJson(body);
+ writeResponse(client, 200, "application/json; charset=utf-8", json != null ? json : "{}");
+ return;
+ }
+ writeResponse(client, 405, "text/plain", "method not allowed");
+ }
+
+ private void handleInternalsApi(Socket client, HttpRequest request) throws IOException {
+ if (!"GET".equalsIgnoreCase(request.method)) {
+ writeResponse(client, 405, "text/plain", "method not allowed");
+ return;
+ }
+ String json = hostBridge.getInternalsJson();
+ writeResponse(client, 200, "application/json; charset=utf-8", json != null ? json : "{}");
+ }
+
+ private void serveAsset(Socket client, String assetName, boolean getOnly) throws IOException {
+ serveAsset(client, assetName, getOnly, authToken.get());
+ }
+
+ private void serveAsset(Socket client, String assetName, boolean getOnly, String token) throws IOException {
+ if (!getOnly) {
+ writeResponse(client, 405, "text/plain", "method not allowed");
+ return;
+ }
+ String safeName = assetName.replace("..", "").replace('\\', '/');
+ if (safeName.startsWith("/")) {
+ safeName = safeName.substring(1);
+ }
+ AssetManager assets = context.getAssets();
+ String assetPath = ASSET_PREFIX + safeName;
+ try (InputStream in = assets.open(assetPath)) {
+ byte[] bytes = readAll(in);
+ String contentType = contentTypeFor(safeName);
+ if (safeName.endsWith(".html")) {
+ String html = new String(bytes, StandardCharsets.UTF_8);
+ html = html.replace("__AUTH_TOKEN__", token != null ? token : "");
+ html = html.replace("__LISTEN_PORT__", String.valueOf(boundPort));
+ bytes = html.getBytes(StandardCharsets.UTF_8);
+ }
+ writeResponseBytes(client, 200, contentType, bytes);
+ } catch (IOException e) {
+ writeResponse(client, 404, "text/plain", "not found");
+ }
+ }
+
+ private static int pickRandomPort() {
+ int base = 32768;
+ int span = 65535 - base;
+ return base + (int) (Math.random() * span);
+ }
+
+ private synchronized void stopInternal() {
+ Thread accept = acceptThread;
+ acceptThread = null;
+ ServerSocket socket = serverSocket;
+ serverSocket = null;
+ boundPort = 0;
+ if (socket != null) {
+ try {
+ socket.close();
+ } catch (IOException ignored) {
+ }
+ }
+ if (accept != null) {
+ accept.interrupt();
+ }
+ }
+
+ private static byte[] readHeaders(InputStream in) throws IOException {
+ ByteArrayOutputStream buf = new ByteArrayOutputStream(2048);
+ int state = 0;
+ while (true) {
+ int b = in.read();
+ if (b < 0) {
+ return null;
+ }
+ buf.write(b);
+ switch (state) {
+ case 0:
+ state = b == '\r' ? 1 : 0;
+ break;
+ case 1:
+ state = b == '\n' ? 2 : 0;
+ break;
+ case 2:
+ state = b == '\r' ? 3 : 0;
+ break;
+ case 3:
+ if (b == '\n') {
+ return buf.toByteArray();
+ }
+ state = 0;
+ break;
+ default:
+ state = 0;
+ }
+ if (buf.size() > 65536) {
+ return null;
+ }
+ }
+ }
+
+ static HttpRequest parseRequest(String headersBlock, InputStream bodyIn) throws IOException {
+ int headerEnd = headersBlock.indexOf("\r\n\r\n");
+ if (headerEnd < 0) {
+ return null;
+ }
+ String headerSection = headersBlock.substring(0, headerEnd);
+ String body = headersBlock.length() > headerEnd + 4
+ ? headersBlock.substring(headerEnd + 4)
+ : "";
+ String[] lines = headerSection.split("\r\n");
+ if (lines.length == 0) {
+ return null;
+ }
+ String[] parts = lines[0].split(" ");
+ if (parts.length < 2) {
+ return null;
+ }
+ String method = parts[0];
+ String rawPath = parts[1];
+ Map query = new HashMap<>();
+ String path = rawPath;
+ int q = rawPath.indexOf('?');
+ if (q >= 0) {
+ path = rawPath.substring(0, q);
+ for (String pair : rawPath.substring(q + 1).split("&")) {
+ int eq = pair.indexOf('=');
+ if (eq > 0) {
+ query.put(pair.substring(0, eq), pair.substring(eq + 1));
+ }
+ }
+ }
+ Map headers = new HashMap<>();
+ for (int i = 1; i < lines.length; i++) {
+ int colon = lines[i].indexOf(':');
+ if (colon > 0) {
+ headers.put(lines[i].substring(0, colon).trim().toLowerCase(Locale.US),
+ lines[i].substring(colon + 1).trim());
+ }
+ }
+ int contentLength = 0;
+ if (headers.containsKey("content-length")) {
+ try {
+ contentLength = Integer.parseInt(headers.get("content-length"));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ if (contentLength > 0) {
+ byte[] bodyBytes = new byte[contentLength];
+ int already = Math.min(body.getBytes(StandardCharsets.UTF_8).length, contentLength);
+ if (already > 0) {
+ byte[] prefix = body.getBytes(StandardCharsets.UTF_8);
+ System.arraycopy(prefix, 0, bodyBytes, 0, already);
+ }
+ int off = already;
+ while (off < contentLength) {
+ int read = bodyIn.read(bodyBytes, off, contentLength - off);
+ if (read < 0) {
+ return null;
+ }
+ off += read;
+ }
+ body = new String(bodyBytes, StandardCharsets.UTF_8);
+ }
+ return new HttpRequest(method, path, query, headers, body);
+ }
+
+ static final class HttpRequest {
+ final String method;
+ final String path;
+ final Map query;
+ final Map headers;
+ final String body;
+
+ HttpRequest(
+ String method,
+ String path,
+ Map query,
+ Map headers,
+ String body) {
+ this.method = method;
+ this.path = path;
+ this.query = query;
+ this.headers = headers;
+ this.body = body;
+ }
+ }
+
+ private static void writeResponse(Socket client, int code, String contentType, String body)
+ throws IOException {
+ writeResponseBytes(client, code, contentType, body.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static void writeResponseBytes(Socket client, int code, String contentType, byte[] bytes)
+ throws IOException {
+ String status = code == 200 ? "200 OK"
+ : code == 401 ? "401 Unauthorized"
+ : code == 404 ? "404 Not Found"
+ : code == 405 ? "405 Method Not Allowed"
+ : "400 Bad Request";
+ String headers = "HTTP/1.1 " + status + "\r\n"
+ + "Content-Type: " + contentType + "\r\n"
+ + "Content-Length: " + bytes.length + "\r\n"
+ + "Connection: close\r\n\r\n";
+ OutputStream out = client.getOutputStream();
+ out.write(headers.getBytes(StandardCharsets.US_ASCII));
+ out.write(bytes);
+ out.flush();
+ }
+
+ private static String contentTypeFor(String name) {
+ if (name.endsWith(".css")) {
+ return "text/css; charset=utf-8";
+ }
+ if (name.endsWith(".js")) {
+ return "application/javascript; charset=utf-8";
+ }
+ if (name.endsWith(".html")) {
+ return "text/html; charset=utf-8";
+ }
+ return "application/octet-stream";
+ }
+
+ private static byte[] readAll(InputStream in) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int read;
+ while ((read = in.read(buf)) >= 0) {
+ out.write(buf, 0, read);
+ }
+ return out.toByteArray();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlPrefsMirror.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlPrefsMirror.java
new file mode 100644
index 0000000..a074fa3
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlPrefsMirror.java
@@ -0,0 +1,130 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
+import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
+
+/**
+ * JSON schema for the RA control web UI — mirrors developer remote-access settings.
+ * Apply logic runs in the main process via {@link RemoteAccessControlHostImpl}.
+ */
+public final class RemoteAccessControlPrefsMirror {
+ public static final String KEY_MODE = "remote_access_mode";
+ public static final String KEY_VPN_ROUTE_SCOPE = "vpn_route_scope";
+ public static final String KEY_VPN_APP_SCOPE = "vpn_app_scope";
+ public static final String KEY_SESSION_ID = "session_id";
+ public static final String KEY_EXPIRES_AT = "expires_at_epoch_sec";
+
+ private RemoteAccessControlPrefsMirror() {}
+
+ public static JSONObject buildSnapshot(
+ RemoteAccessMode mode,
+ RemoteAccessVpnRouting.RouteScope routeScope,
+ RemoteAccessVpnRouting.AppScope appScope,
+ String sessionId,
+ long expiresAtEpochSec) throws JSONException {
+ JSONObject root = new JSONObject();
+ root.put(KEY_MODE, mode != null ? mode.name() : RemoteAccessMode.DISABLED.name());
+ root.put(KEY_VPN_ROUTE_SCOPE, routeScope != null ? routeScope.name()
+ : RemoteAccessVpnRouting.RouteScope.HUB_ONLY.name());
+ root.put(KEY_VPN_APP_SCOPE, appScope != null ? appScope.name()
+ : RemoteAccessVpnRouting.AppScope.ALL_APPS.name());
+ root.put(KEY_SESSION_ID, sessionId != null ? sessionId : "");
+ root.put(KEY_EXPIRES_AT, expiresAtEpochSec);
+ JSONObject options = new JSONObject();
+ RemoteAccessMode[] modes = RemoteAccessMode.values();
+ for (RemoteAccessMode m : modes) {
+ options.put(m.name(), m.name());
+ }
+ root.put("mode_options", options);
+ root.put("vpn_route_scope_options", enumNames(RemoteAccessVpnRouting.RouteScope.values()));
+ root.put("vpn_app_scope_options", enumNames(RemoteAccessVpnRouting.AppScope.values()));
+ return root;
+ }
+
+ public static ApplyResult parseApplyPatch(String bodyJson) {
+ if (bodyJson == null || bodyJson.trim().isEmpty()) {
+ return ApplyResult.error("empty body");
+ }
+ try {
+ JSONObject body = new JSONObject(bodyJson);
+ RemoteAccessMode mode = null;
+ if (body.has(KEY_MODE)) {
+ mode = RemoteAccessMode.fromStored(body.optString(KEY_MODE, ""));
+ }
+ RemoteAccessVpnRouting.RouteScope routeScope = null;
+ if (body.has(KEY_VPN_ROUTE_SCOPE)) {
+ routeScope = RemoteAccessVpnRouting.RouteScope.fromStored(
+ body.optString(KEY_VPN_ROUTE_SCOPE, ""));
+ }
+ RemoteAccessVpnRouting.AppScope appScope = null;
+ if (body.has(KEY_VPN_APP_SCOPE)) {
+ appScope = RemoteAccessVpnRouting.AppScope.fromStored(
+ body.optString(KEY_VPN_APP_SCOPE, ""));
+ }
+ return ApplyResult.ok(mode, routeScope, appScope);
+ } catch (JSONException e) {
+ return ApplyResult.error("invalid json: " + e.getMessage());
+ }
+ }
+
+ public static JSONObject successResponse(JSONObject settings) throws JSONException {
+ JSONObject out = new JSONObject();
+ out.put("ok", true);
+ out.put("settings", settings);
+ return out;
+ }
+
+ public static JSONObject errorResponse(String message) throws JSONException {
+ JSONObject out = new JSONObject();
+ out.put("ok", false);
+ out.put("error", message != null ? message : "error");
+ return out;
+ }
+
+ private static JSONObject enumNames(Enum>[] values) throws JSONException {
+ JSONObject out = new JSONObject();
+ for (Enum> v : values) {
+ out.put(v.name(), v.name());
+ }
+ return out;
+ }
+
+ public static final class ApplyResult {
+ public final boolean ok;
+ public final String error;
+ public final RemoteAccessMode mode;
+ public final RemoteAccessVpnRouting.RouteScope routeScope;
+ public final RemoteAccessVpnRouting.AppScope appScope;
+
+ private ApplyResult(
+ boolean ok,
+ String error,
+ RemoteAccessMode mode,
+ RemoteAccessVpnRouting.RouteScope routeScope,
+ RemoteAccessVpnRouting.AppScope appScope) {
+ this.ok = ok;
+ this.error = error;
+ this.mode = mode;
+ this.routeScope = routeScope;
+ this.appScope = appScope;
+ }
+
+ public static ApplyResult ok(
+ RemoteAccessMode mode,
+ RemoteAccessVpnRouting.RouteScope routeScope,
+ RemoteAccessVpnRouting.AppScope appScope) {
+ return new ApplyResult(true, null, mode, routeScope, appScope);
+ }
+
+ public static ApplyResult error(String message) {
+ return new ApplyResult(false, message, null, null, null);
+ }
+
+ public boolean hasChanges() {
+ return mode != null || routeScope != null || appScope != null;
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlService.java
new file mode 100644
index 0000000..ea05f61
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlService.java
@@ -0,0 +1,201 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.foxx.androidcast.IRemoteAccessControlHost;
+import com.foxx.androidcast.IRemoteAccessControlService;
+import com.foxx.androidcast.IVpnOperationCallback;
+
+import java.util.UUID;
+
+/**
+ * Standalone RA control process ({@code com.foxx.androidcast:ra_control}) — HTTP UI + shell.
+ */
+public final class RemoteAccessControlService extends Service {
+ private static final String TAG = "RaControlSvc";
+
+ private final Object sessionLock = new Object();
+ private volatile IRemoteAccessControlHost host;
+ private volatile String authToken = "";
+ private volatile int listenPort;
+ private volatile boolean running;
+
+ private HandlerThread workerThread;
+ private Handler worker;
+ private RemoteAccessControlHttpServer httpServer;
+
+ private final IRemoteAccessControlService.Stub binder = new IRemoteAccessControlService.Stub() {
+ @Override
+ public void registerHost(IRemoteAccessControlHost hostRef) {
+ host = hostRef;
+ }
+
+ @Override
+ public void unregisterHost() {
+ host = null;
+ }
+
+ @Override
+ public void start(IVpnOperationCallback callback) {
+ ensureWorker();
+ worker.post(() -> startOnWorker(callback));
+ }
+
+ @Override
+ public void stop(IVpnOperationCallback callback) {
+ ensureWorker();
+ worker.post(() -> {
+ stopOnWorker();
+ notifyOp(callback, true, null);
+ });
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+
+ @Override
+ public int getListenPort() {
+ return listenPort;
+ }
+
+ @Override
+ public String getAuthToken() {
+ return authToken != null ? authToken : "";
+ }
+ };
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ httpServer = new RemoteAccessControlHttpServer(getApplicationContext(), new HostBridgeImpl());
+ Log.i(TAG, "RA control process service created");
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ return START_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ stopOnWorker();
+ if (workerThread != null) {
+ workerThread.quitSafely();
+ workerThread = null;
+ worker = null;
+ }
+ super.onDestroy();
+ }
+
+ private void startOnWorker(IVpnOperationCallback callback) {
+ synchronized (sessionLock) {
+ if (running) {
+ notifyOp(callback, true, null);
+ return;
+ }
+ authToken = UUID.randomUUID().toString();
+ httpServer.setAuthToken(authToken);
+ try {
+ listenPort = httpServer.start(0);
+ running = true;
+ Log.i(TAG, "control plane up :" + listenPort);
+ notifyOp(callback, true, null);
+ } catch (Exception e) {
+ authToken = "";
+ listenPort = 0;
+ running = false;
+ Log.w(TAG, "start failed: " + e.getMessage());
+ notifyOp(callback, false, e.getMessage());
+ }
+ }
+ }
+
+ private void stopOnWorker() {
+ synchronized (sessionLock) {
+ if (httpServer != null) {
+ httpServer.stop();
+ }
+ running = false;
+ listenPort = 0;
+ authToken = "";
+ }
+ }
+
+ private void ensureWorker() {
+ if (worker != null) {
+ return;
+ }
+ workerThread = new HandlerThread("ra-control-worker");
+ workerThread.start();
+ worker = new Handler(workerThread.getLooper());
+ }
+
+ private static void notifyOp(IVpnOperationCallback callback, boolean ok, String reason) {
+ if (callback == null) {
+ return;
+ }
+ try {
+ if (ok) {
+ callback.onSuccess();
+ } else {
+ callback.onFailure(reason != null ? reason : "error");
+ }
+ } catch (RemoteException e) {
+ Log.w(TAG, "operation callback died: " + e.getMessage());
+ }
+ }
+
+ private final class HostBridgeImpl implements RemoteAccessControlHttpServer.HostBridge {
+ @Override
+ public String getSettingsJson() {
+ IRemoteAccessControlHost h = host;
+ if (h == null) {
+ return "{\"ok\":false,\"error\":\"host not bound\"}";
+ }
+ try {
+ return h.getSettingsJson();
+ } catch (RemoteException e) {
+ return "{\"ok\":false,\"error\":\"host unreachable\"}";
+ }
+ }
+
+ @Override
+ public String applySettingsJson(String body) {
+ IRemoteAccessControlHost h = host;
+ if (h == null) {
+ return "{\"ok\":false,\"error\":\"host not bound\"}";
+ }
+ try {
+ return h.applySettingsJson(body);
+ } catch (RemoteException e) {
+ return "{\"ok\":false,\"error\":\"host unreachable\"}";
+ }
+ }
+
+ @Override
+ public String getInternalsJson() {
+ IRemoteAccessControlHost h = host;
+ if (h == null) {
+ return "{\"ok\":false,\"error\":\"host not bound\"}";
+ }
+ try {
+ return h.getInternalsJson();
+ } catch (RemoteException e) {
+ return "{\"ok\":false,\"error\":\"host unreachable\"}";
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlShellHandler.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlShellHandler.java
new file mode 100644
index 0000000..2f54c48
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlShellHandler.java
@@ -0,0 +1,201 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import android.util.Log;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Minimal WebSocket text channel driving {@code /system/bin/sh} in the app sandbox. */
+final class RemoteAccessControlShellHandler implements Runnable {
+ private static final String TAG = "RaControlShell";
+ private static final String WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+ private final Socket client;
+ private final String requestHeaders;
+
+ RemoteAccessControlShellHandler(Socket client, String requestHeaders) {
+ this.client = client;
+ this.requestHeaders = requestHeaders != null ? requestHeaders : "";
+ }
+
+ @Override
+ public void run() {
+ try {
+ if (!performHandshake()) {
+ return;
+ }
+ runShell();
+ } catch (Exception e) {
+ Log.d(TAG, "shell session ended: " + e.getMessage());
+ } finally {
+ try {
+ client.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+
+ private boolean performHandshake() throws IOException {
+ String key = headerValue("Sec-WebSocket-Key");
+ if (key.isEmpty()) {
+ writeRaw("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
+ return false;
+ }
+ String accept = computeAcceptKey(key);
+ String response = "HTTP/1.1 101 Switching Protocols\r\n"
+ + "Upgrade: websocket\r\n"
+ + "Connection: Upgrade\r\n"
+ + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n";
+ writeRaw(response);
+ return true;
+ }
+
+ private void runShell() throws IOException, InterruptedException {
+ Process process = new ProcessBuilder("/system/bin/sh")
+ .redirectErrorStream(true)
+ .start();
+ AtomicBoolean open = new AtomicBoolean(true);
+ Thread reader = new Thread(() -> pumpProcessToClient(process.getInputStream(), open), "RaShellOut");
+ reader.start();
+ InputStream in = client.getInputStream();
+ OutputStream processIn = process.getOutputStream();
+ while (open.get()) {
+ String text = readTextFrame(in);
+ if (text == null) {
+ break;
+ }
+ if ("\u0004".equals(text)) {
+ break;
+ }
+ processIn.write(text.getBytes(StandardCharsets.UTF_8));
+ processIn.flush();
+ }
+ open.set(false);
+ process.destroy();
+ reader.join(2_000L);
+ sendCloseFrame();
+ }
+
+ private void pumpProcessToClient(InputStream processOut, AtomicBoolean open) {
+ byte[] buf = new byte[4096];
+ try {
+ int read;
+ while (open.get() && (read = processOut.read(buf)) >= 0) {
+ if (read == 0) {
+ continue;
+ }
+ String chunk = new String(buf, 0, read, StandardCharsets.UTF_8);
+ sendTextFrame(chunk);
+ }
+ } catch (IOException e) {
+ open.set(false);
+ }
+ }
+
+ private String headerValue(String name) {
+ for (String line : requestHeaders.split("\r\n")) {
+ int colon = line.indexOf(':');
+ if (colon <= 0) {
+ continue;
+ }
+ if (name.equalsIgnoreCase(line.substring(0, colon).trim())) {
+ return line.substring(colon + 1).trim();
+ }
+ }
+ return "";
+ }
+
+ private void writeRaw(String text) throws IOException {
+ client.getOutputStream().write(text.getBytes(StandardCharsets.US_ASCII));
+ client.getOutputStream().flush();
+ }
+
+ private synchronized void sendTextFrame(String text) throws IOException {
+ byte[] payload = text.getBytes(StandardCharsets.UTF_8);
+ OutputStream out = client.getOutputStream();
+ out.write(0x81);
+ if (payload.length <= 125) {
+ out.write(payload.length);
+ } else if (payload.length <= 65535) {
+ out.write(126);
+ out.write((payload.length >> 8) & 0xFF);
+ out.write(payload.length & 0xFF);
+ } else {
+ throw new IOException("frame too large");
+ }
+ out.write(payload);
+ out.flush();
+ }
+
+ private synchronized void sendCloseFrame() {
+ try {
+ OutputStream out = client.getOutputStream();
+ out.write(new byte[] {(byte) 0x88, 0x00});
+ out.flush();
+ } catch (IOException ignored) {
+ }
+ }
+
+ private static String readTextFrame(InputStream in) throws IOException {
+ int b0 = in.read();
+ if (b0 < 0) {
+ return null;
+ }
+ int opcode = b0 & 0x0F;
+ if (opcode == 0x8) {
+ return null;
+ }
+ int b1 = in.read();
+ if (b1 < 0) {
+ return null;
+ }
+ boolean masked = (b1 & 0x80) != 0;
+ int len = b1 & 0x7F;
+ if (len == 126) {
+ len = (in.read() << 8) | in.read();
+ } else if (len == 127) {
+ for (int i = 0; i < 8; i++) {
+ in.read();
+ }
+ return null;
+ }
+ byte[] mask = new byte[4];
+ if (masked) {
+ if (in.read(mask) < 4) {
+ return null;
+ }
+ }
+ byte[] payload = new byte[len];
+ int off = 0;
+ while (off < len) {
+ int read = in.read(payload, off, len - off);
+ if (read < 0) {
+ return null;
+ }
+ off += read;
+ }
+ if (masked) {
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] ^= mask[i % 4];
+ }
+ }
+ return new String(payload, StandardCharsets.UTF_8);
+ }
+
+ static String computeAcceptKey(String key) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-1");
+ md.update((key + WS_GUID).getBytes(StandardCharsets.US_ASCII));
+ return Base64.getEncoder().encodeToString(md.digest());
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java b/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java
index 1e7af54..f538f79 100644
--- a/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java
+++ b/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java
@@ -7,6 +7,7 @@ import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
+import android.content.pm.PackageManager;
import android.net.VpnService;
import android.os.Build;
import android.os.ParcelFileDescriptor;
@@ -16,11 +17,9 @@ import androidx.core.app.NotificationCompat;
import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.R;
+import com.foxx.androidcast.remoteaccess.WireGuardQuickConfig;
import java.net.InetAddress;
-import java.util.ArrayList;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
/** TUN fallback when GoBackend cannot start — runs in :vpn process only. */
public final class RemoteAccessVpnService extends VpnService {
@@ -79,7 +78,7 @@ public final class RemoteAccessVpnService extends VpnService {
private void establishTun(String wgConfig) {
tearDown();
try {
- String address = parseAddress(wgConfig);
+ String address = WireGuardQuickConfig.parseAddress(wgConfig);
Builder b = new Builder();
b.setSession("AndroidCast RA");
if (address != null) {
@@ -87,7 +86,14 @@ public final class RemoteAccessVpnService extends VpnService {
b.addAddress(InetAddress.getByName(parts[0]),
parts.length > 1 ? Integer.parseInt(parts[1]) : 32);
}
- for (String route : parseAllowedRoutes(wgConfig)) {
+ for (String app : WireGuardQuickConfig.parseIncludedApplications(wgConfig)) {
+ try {
+ b.addAllowedApplication(app);
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.w(TAG, "IncludedApplications skip unknown package: " + app);
+ }
+ }
+ for (String route : WireGuardQuickConfig.parseAllowedRoutes(wgConfig)) {
String[] rp = route.split("/");
if (rp.length >= 2) {
b.addRoute(rp[0], Integer.parseInt(rp[1]));
@@ -101,32 +107,6 @@ public final class RemoteAccessVpnService extends VpnService {
}
}
- private static String parseAddress(String wgConfig) {
- if (wgConfig == null) {
- return null;
- }
- Matcher m = Pattern.compile("(?m)^Address\\s*=\\s*(.+)$").matcher(wgConfig);
- return m.find() ? m.group(1).trim() : null;
- }
-
- private static String[] parseAllowedRoutes(String wgConfig) {
- if (wgConfig == null) {
- return new String[0];
- }
- Matcher m = Pattern.compile("(?m)^AllowedIPs\\s*=\\s*(.+)$").matcher(wgConfig);
- if (!m.find()) {
- return new String[0];
- }
- String[] parts = m.group(1).trim().split("\\s*,\\s*");
- ArrayList out = new ArrayList<>();
- for (String p : parts) {
- if (!p.isEmpty()) {
- out.add(p.trim());
- }
- }
- return out.toArray(new String[0]);
- }
-
private void tearDown() {
if (tun != null) {
try {
diff --git a/app/src/main/res/layout/activity_dev_settings_panel.xml b/app/src/main/res/layout/activity_dev_settings_panel.xml
index ba88c10..1f1f9cd 100644
--- a/app/src/main/res/layout/activity_dev_settings_panel.xml
+++ b/app/src/main/res/layout/activity_dev_settings_panel.xml
@@ -85,6 +85,48 @@
android:text="@string/dev_remote_access_hint"
android:textSize="12sp" />
+
+
+
+
+
+
+
+
+
+
+
+
RSSH (reverse SSH)
RSSH (reverse SSH — alpha; not available yet)
When enabled, device polls BE every 1–7 min (type:ra). WireGuard needs Android VPN permission once. RSSH uses outbound SSH — no VPN prompt.
+ VPN route scope (WireGuard)
+ Hub only (172.200.2.1/32)
+ All traffic (0.0.0.0/0)
+ Hub-only is the production default (split tunnel). Full tunnel routes all device IP traffic through WireGuard when the session is up.
+ VPN app scope (WireGuard)
+ All apps (matching routes)
+ This app only
+ App-only uses Android IncludedApplications so other apps keep their normal path. Ignored for RSSH. Changes reconnect an active WireGuard session.
+ VPN routing updated — reconnecting if tunnel was up
Reverse SSH (RSSH) is not implemented yet.
Remote access: %1$s
VPN permission required for WireGuard remote access.
diff --git a/app/src/test/java/com/foxx/androidcast/crash/CrashReporterProcessTest.java b/app/src/test/java/com/foxx/androidcast/crash/CrashReporterProcessTest.java
index 94ea63b..279e591 100644
--- a/app/src/test/java/com/foxx/androidcast/crash/CrashReporterProcessTest.java
+++ b/app/src/test/java/com/foxx/androidcast/crash/CrashReporterProcessTest.java
@@ -11,6 +11,7 @@ public class CrashReporterProcessTest {
public void isMonitoredIsolatedProcess_vpnAndNetselftest() {
assertTrue(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:vpn"));
assertTrue(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:netselftest"));
+ assertTrue(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:ra_control"));
assertFalse(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast"));
assertFalse(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:crashwatcher"));
}
diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnRoutingTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnRoutingTest.java
new file mode 100644
index 0000000..929cd16
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnRoutingTest.java
@@ -0,0 +1,45 @@
+package com.foxx.androidcast.remoteaccess;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class RemoteAccessVpnRoutingTest {
+
+ @Test
+ public void resolveAllowedIps_hubOnlyUsesBeValue() {
+ assertEquals("172.200.2.1/32", RemoteAccessVpnRouting.resolveAllowedIps(
+ RemoteAccessVpnRouting.RouteScope.HUB_ONLY, "172.200.2.1/32"));
+ }
+
+ @Test
+ public void resolveAllowedIps_hubOnlyDefaultWhenEmpty() {
+ assertEquals(RemoteAccessVpnRouting.DEFAULT_HUB_ALLOWED_IPS, RemoteAccessVpnRouting.resolveAllowedIps(
+ RemoteAccessVpnRouting.RouteScope.HUB_ONLY, ""));
+ }
+
+ @Test
+ public void resolveAllowedIps_fullTunnel() {
+ assertEquals(RemoteAccessVpnRouting.FULL_TUNNEL_ALLOWED_IPS, RemoteAccessVpnRouting.resolveAllowedIps(
+ RemoteAccessVpnRouting.RouteScope.FULL_TUNNEL, "172.200.2.1/32"));
+ }
+
+ @Test
+ public void appendInterfaceExtras_appOnly() {
+ StringBuilder sb = new StringBuilder();
+ RemoteAccessVpnRouting.appendInterfaceExtras(
+ sb,
+ RemoteAccessVpnRouting.AppScope.THIS_APP_ONLY,
+ "com.foxx.androidcast");
+ assertTrue(sb.toString().contains("IncludedApplications = com.foxx.androidcast"));
+ }
+
+ @Test
+ public void fromStored_invalidDefaults() {
+ assertEquals(RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
+ RemoteAccessVpnRouting.RouteScope.fromStored("bogus"));
+ assertEquals(RemoteAccessVpnRouting.AppScope.ALL_APPS,
+ RemoteAccessVpnRouting.AppScope.fromStored(""));
+ }
+}
diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilderTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilderTest.java
index 1fe56c2..10049a3 100644
--- a/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilderTest.java
+++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilderTest.java
@@ -12,21 +12,60 @@ public class WireGuardConfigBuilderTest {
public void buildsConfigFromCredentials() throws Exception {
JSONObject creds = new JSONObject();
creds.put("interface_private_key", "aPrivKey=");
- creds.put("interface_address", "10.66.66.5/32");
+ creds.put("interface_address", "172.200.2.10/32");
creds.put("peer_public_key", "aPubKey=");
- creds.put("peer_endpoint", "example.org:51820");
- creds.put("peer_allowed_ips", "10.66.66.1/32");
- String cfg = WireGuardConfigBuilder.fromConnectCredentials(creds);
+ creds.put("peer_endpoint", "ra.apps.f0xx.org:45340");
+ creds.put("peer_allowed_ips", "172.200.2.1/32");
+ String cfg = WireGuardConfigBuilder.fromConnectCredentials(
+ creds,
+ RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
+ RemoteAccessVpnRouting.AppScope.ALL_APPS,
+ "com.foxx.androidcast");
assertTrue(cfg.contains("[Interface]"));
assertTrue(cfg.contains("PrivateKey = aPrivKey="));
assertTrue(cfg.contains("PublicKey = aPubKey="));
- assertTrue(cfg.contains("Endpoint = example.org:51820"));
+ assertTrue(cfg.contains("Endpoint = ra.apps.f0xx.org:45340"));
+ assertTrue(cfg.contains("AllowedIPs = 172.200.2.1/32"));
+ assertFalse(cfg.contains("IncludedApplications"));
+ }
+
+ @Test
+ public void fullTunnelOverridesBeAllowedIps() throws Exception {
+ JSONObject creds = new JSONObject();
+ creds.put("interface_private_key", "aPrivKey=");
+ creds.put("interface_address", "172.200.2.10/32");
+ creds.put("peer_public_key", "aPubKey=");
+ creds.put("peer_allowed_ips", "172.200.2.1/32");
+ String cfg = WireGuardConfigBuilder.fromConnectCredentials(
+ creds,
+ RemoteAccessVpnRouting.RouteScope.FULL_TUNNEL,
+ RemoteAccessVpnRouting.AppScope.ALL_APPS,
+ null);
+ assertTrue(cfg.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
+ }
+
+ @Test
+ public void appOnlyAddsIncludedApplications() throws Exception {
+ JSONObject creds = new JSONObject();
+ creds.put("interface_private_key", "aPrivKey=");
+ creds.put("interface_address", "172.200.2.10/32");
+ creds.put("peer_public_key", "aPubKey=");
+ String cfg = WireGuardConfigBuilder.fromConnectCredentials(
+ creds,
+ RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
+ RemoteAccessVpnRouting.AppScope.THIS_APP_ONLY,
+ "com.foxx.androidcast");
+ assertTrue(cfg.contains("IncludedApplications = com.foxx.androidcast"));
}
@Test
public void emptyWhenMissingKeys() throws Exception {
JSONObject creds = new JSONObject();
- creds.put("interface_address", "10.66.66.5/32");
- assertTrue(WireGuardConfigBuilder.fromConnectCredentials(creds).isEmpty());
+ creds.put("interface_address", "172.200.2.10/32");
+ assertTrue(WireGuardConfigBuilder.fromConnectCredentials(
+ creds,
+ RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
+ RemoteAccessVpnRouting.AppScope.ALL_APPS,
+ null).isEmpty());
}
}
diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigParseTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigParseTest.java
index d5b3d4a..5305603 100644
--- a/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigParseTest.java
+++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigParseTest.java
@@ -18,17 +18,42 @@ public class WireGuardConfigParseTest {
public void builderOutput_parsesWithWireGuardConfig() throws Exception {
JSONObject creds = new JSONObject();
creds.put("interface_private_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
- creds.put("interface_address", "10.66.66.2/32");
+ creds.put("interface_address", "172.200.2.10/32");
creds.put("peer_public_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
- creds.put("peer_endpoint", "ra.apps.f0xx.org:51820");
- creds.put("peer_allowed_ips", "10.66.66.1/32");
+ creds.put("peer_endpoint", "ra.apps.f0xx.org:45340");
+ creds.put("peer_allowed_ips", "172.200.2.1/32");
- String quick = WireGuardConfigBuilder.fromConnectCredentials(creds);
+ String quick = WireGuardConfigBuilder.fromConnectCredentials(
+ creds,
+ RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
+ RemoteAccessVpnRouting.AppScope.ALL_APPS,
+ null);
assertFalse(quick.isEmpty());
try (ByteArrayInputStream in = new ByteArrayInputStream(quick.getBytes(StandardCharsets.UTF_8))) {
Config config = Config.parse(in);
- assertEquals("10.66.66.2/32", config.getInterface().getAddresses().iterator().next().toString());
+ assertEquals("172.200.2.10/32", config.getInterface().getAddresses().iterator().next().toString());
+ }
+ }
+
+ @Test
+ public void appOnly_includedApplicationsParse() throws Exception {
+ JSONObject creds = new JSONObject();
+ creds.put("interface_private_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
+ creds.put("interface_address", "172.200.2.10/32");
+ creds.put("peer_public_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
+
+ String quick = WireGuardConfigBuilder.fromConnectCredentials(
+ creds,
+ RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
+ RemoteAccessVpnRouting.AppScope.THIS_APP_ONLY,
+ "com.foxx.androidcast");
+ assertFalse(quick.isEmpty());
+
+ try (ByteArrayInputStream in = new ByteArrayInputStream(quick.getBytes(StandardCharsets.UTF_8))) {
+ Config config = Config.parse(in);
+ assertEquals(1, config.getInterface().getIncludedApplications().size());
+ assertEquals("com.foxx.androidcast", config.getInterface().getIncludedApplications().iterator().next());
}
}
}
diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlAuthTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlAuthTest.java
new file mode 100644
index 0000000..5255f70
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlAuthTest.java
@@ -0,0 +1,29 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class RemoteAccessControlAuthTest {
+
+ @Test
+ public void bearerHeaderAuthorized() {
+ assertTrue(RemoteAccessControlAuth.isAuthorized(
+ "secret", "Bearer secret", ""));
+ assertFalse(RemoteAccessControlAuth.isAuthorized(
+ "secret", "Bearer wrong", ""));
+ }
+
+ @Test
+ public void queryTokenAuthorized() {
+ assertTrue(RemoteAccessControlAuth.isAuthorized(
+ "abc", "", "abc"));
+ }
+
+ @Test
+ public void normalizePath_stripsQuery() {
+ assertEquals("/api/settings", RemoteAccessControlAuth.normalizePath("/api/settings?token=x"));
+ }
+}
diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHttpServerTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHttpServerTest.java
new file mode 100644
index 0000000..e50e7b8
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlHttpServerTest.java
@@ -0,0 +1,35 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+
+public class RemoteAccessControlHttpServerTest {
+
+ @Test
+ public void parseRequest_readsPostBody() throws Exception {
+ String raw = "POST /api/settings HTTP/1.1\r\n"
+ + "Host: localhost\r\n"
+ + "Content-Length: 11\r\n"
+ + "\r\n"
+ + "{\"ok\":true}";
+ ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]);
+ RemoteAccessControlHttpServer.HttpRequest req =
+ RemoteAccessControlHttpServer.parseRequest(raw, in);
+ assertNotNull(req);
+ assertEquals("POST", req.method);
+ assertEquals("/api/settings", req.path);
+ assertEquals("{\"ok\":true}", req.body);
+ }
+
+ @Test
+ public void shellAcceptKey_matchesRfc6455Fixture() {
+ String key = "dGhlIHNhbXBsZSBub25jZQ==";
+ String accept = RemoteAccessControlShellHandler.computeAcceptKey(key);
+ assertEquals("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", accept);
+ }
+}
diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlPrefsMirrorTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlPrefsMirrorTest.java
new file mode 100644
index 0000000..b60e1d0
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/control/RemoteAccessControlPrefsMirrorTest.java
@@ -0,0 +1,33 @@
+package com.foxx.androidcast.remoteaccess.control;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
+import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
+
+import org.junit.Test;
+
+public class RemoteAccessControlPrefsMirrorTest {
+
+ @Test
+ public void parseApplyPatch_readsModeAndScopes() {
+ String body = "{\"remote_access_mode\":\"WIREGUARD\","
+ + "\"vpn_route_scope\":\"FULL_TUNNEL\","
+ + "\"vpn_app_scope\":\"THIS_APP_ONLY\"}";
+ RemoteAccessControlPrefsMirror.ApplyResult r =
+ RemoteAccessControlPrefsMirror.parseApplyPatch(body);
+ assertTrue(r.ok);
+ assertEquals(RemoteAccessMode.WIREGUARD, r.mode);
+ assertEquals(RemoteAccessVpnRouting.RouteScope.FULL_TUNNEL, r.routeScope);
+ assertEquals(RemoteAccessVpnRouting.AppScope.THIS_APP_ONLY, r.appScope);
+ }
+
+ @Test
+ public void parseApplyPatch_rejectsInvalidJson() {
+ RemoteAccessControlPrefsMirror.ApplyResult r =
+ RemoteAccessControlPrefsMirror.parseApplyPatch("{");
+ assertFalse(r.ok);
+ }
+}
diff --git a/docs/REMOTE_ACCESS_IMPL.md b/docs/REMOTE_ACCESS_IMPL.md
index 7535490..2b2b1cd 100644
--- a/docs/REMOTE_ACCESS_IMPL.md
+++ b/docs/REMOTE_ACCESS_IMPL.md
@@ -33,6 +33,7 @@ See the full proposal: [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVE
## App (developer settings)
- Dropdown: **Disabled** | **WireGuard** (lab) | **RSSH** (alpha — outbound reverse SSH, no VPN)
+- **VPN routing (WireGuard only):** route scope **Hub only** (`172.200.2.1/32`, split tunnel) or **All traffic** (`0.0.0.0/0`); app scope **All apps** or **This app only** (`IncludedApplications` / `VpnService.addAllowedApplication`). RSSH ignores these. Prefs sent on heartbeat as `vpn_route_scope` / `vpn_app_scope`; changing them reconnects an active WG session.
- Pref key: `dev_remote_access_mode` (`AppPreferences`)
- **Disabled:** notifies BE (`status:disable`), tears down VPN, stops `RemoteAccessService`
- **WireGuard:** foreground service; jittered poll **1–7 min** → `heartbeat.php` with `type: ra`
diff --git a/docs/VPN_DEMO_GAPS_20260613.md b/docs/VPN_DEMO_GAPS_20260613.md
index a29e0a7..037fc57 100644
--- a/docs/VPN_DEMO_GAPS_20260613.md
+++ b/docs/VPN_DEMO_GAPS_20260613.md
@@ -11,6 +11,8 @@ See also [REMOTE_ACCESS_VALIDATION.md](../REMOTE_ACCESS_VALIDATION.md), [REMOTE_
## Fixed in repo (awaiting BE sync)
+- **MariaDB `NULLS LAST`** — `listDevices()` ORDER BY broke dashboard on prod (HTTP 500); fixed to `(last_seen_at IS NULL), last_seen_at DESC`
+- **Dashboard perf** — issue counts use indexed `reports.device_id` via `devices.external_id` instead of `payload_json LIKE`
- **Operational docs** — `REMOTE_ACCESS_IMPL.md` / `REMOTE_ACCESS_VALIDATION.md` now use prod **45340** and **172.200.2.0/16** (proposal doc still uses example 51820)
- **Orphan wg peer cleanup** — `WireGuardPeerProvisioner::pruneOrphanPeers()`, hourly via `purge_remote_access.php`; manual `scripts/sync_wg_peers.php --dry-run|--apply`
- **CLI defaults** — `ra_udp_debug.sh` / `ra_lib.sh` help and fallbacks match prod subnet
diff --git a/examples/crash_reporter/backend/public/api/remote_access.php b/examples/crash_reporter/backend/public/api/remote_access.php
index d9b4863..fe96592 100644
--- a/examples/crash_reporter/backend/public/api/remote_access.php
+++ b/examples/crash_reporter/backend/public/api/remote_access.php
@@ -26,18 +26,16 @@ if ($method === 'GET') {
json_out(['ok' => true, 'events' => RemoteAccessRepository::listEvents((int)($_GET['limit'] ?? 100))]);
}
if ($action === 'dashboard') {
- $sessions = RemoteAccessRepository::listSessions('');
- $active = array_values(array_filter($sessions, static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true)));
- $inactive = array_values(array_filter($sessions, static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)));
+ $payload = RemoteAccessRepository::buildDashboardPayload(
+ rtrim(Auth::basePath(), '/'),
+ '/app/androidcast_project'
+ );
json_out([
'ok' => true,
- 'devices' => RemoteAccessRepository::listDevicesForDashboard(
- rtrim(Auth::basePath(), '/'),
- '/app/androidcast_project'
- ),
- 'active_sessions' => $active,
- 'inactive_sessions' => array_slice($inactive, 0, 50),
- 'recent_events' => RemoteAccessRepository::listEvents(30),
+ 'devices' => $payload['devices'],
+ 'active_sessions' => $payload['active_sessions'],
+ 'inactive_sessions' => $payload['inactive_sessions'],
+ 'recent_events' => $payload['recent_events'],
'permissions' => [
'can_operate' => Rbac::can('remote_access_operate'),
'can_admin' => Rbac::can('remote_access_admin'),
diff --git a/examples/crash_reporter/backend/public/assets/js/remote_access.js b/examples/crash_reporter/backend/public/assets/js/remote_access.js
index 96de18f..26f6f7a 100644
--- a/examples/crash_reporter/backend/public/assets/js/remote_access.js
+++ b/examples/crash_reporter/backend/public/assets/js/remote_access.js
@@ -411,6 +411,8 @@
lines.push(pollLabel + ': ' + d.poll_source_ip);
}
if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip);
+ if (d.vpn_route_scope) lines.push('VPN route scope: ' + d.vpn_route_scope);
+ if (d.vpn_app_scope) lines.push('VPN app scope: ' + d.vpn_app_scope);
if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key);
if (d.wg_rx_bytes != null || d.wg_tx_bytes != null) {
const rx = formatBytes(d.wg_rx_bytes || 0);
diff --git a/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh b/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh
index c93f3ec..a4321db 100755
--- a/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh
+++ b/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh
@@ -5,12 +5,16 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
-# Alpine BE: default `php` may be 8.5 without session; php-fpm81 uses 8.1.
+# Alpine BE: default `php` may be 8.5 without session/PDO; php-fpm81 uses 8.1.
php_bin() {
if [[ -n "${PHP_BIN:-}" ]]; then
echo "$PHP_BIN"
return
fi
+ if command -v php81 >/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qiE '^(session|pdo_mysql|pdo_sqlite)$'; then
+ echo php81
+ return
+ fi
if php -m 2>/dev/null | grep -qi '^session$'; then
echo php
return
@@ -34,6 +38,13 @@ echo "== Remote access production verify =="
echo "backend: $ROOT"
echo
+# MariaDB rejects PostgreSQL-style NULLS LAST/FIRST (SQLite dev may hide this).
+if grep -R --include='*.php' -E 'NULLS (LAST|FIRST)' "$ROOT/src" >/dev/null 2>&1; then
+ bad "MariaDB-incompatible SQL (NULLS LAST/FIRST) under src/"
+else
+ ok "no NULLS LAST/FIRST in PHP SQL"
+fi
+
# config.php
CFG="$ROOT/config/config.php"
if [[ ! -f "$CFG" ]]; then
@@ -97,6 +108,13 @@ foreach (["remote_access_devices","remote_access_sessions","remote_access_events
echo "OK MariaDB/SQLite remote_access tables present\n";
' || fail=1
+$DB_RUN -r '
+require "'"$ROOT"'/src/bootstrap.php";
+RemoteAccessRepository::ensureSchema();
+RemoteAccessRepository::listDevices();
+echo "OK listDevices() SQL executes\n";
+' || bad "listDevices() failed (check ORDER BY / MariaDB syntax)"
+
# Orphan wg peers (dry-run; run sync_wg_peers.php --apply on BE to fix)
if [[ -f "$ROOT/scripts/sync_wg_peers.php" ]]; then
WG_SYNC="$($DB_RUN "$ROOT/scripts/sync_wg_peers.php" --dry-run 2>/dev/null || true)"
diff --git a/examples/crash_reporter/backend/src/RemoteAccessRepository.php b/examples/crash_reporter/backend/src/RemoteAccessRepository.php
index dfd910f..6eafca8 100644
--- a/examples/crash_reporter/backend/src/RemoteAccessRepository.php
+++ b/examples/crash_reporter/backend/src/RemoteAccessRepository.php
@@ -16,46 +16,44 @@ final class RemoteAccessRepository {
public const STATUS_CLOSED = 'closed';
public const STATUS_CLOSED_TIMEOUT = 'closed_timeout';
+ private static bool $schemaReady = false;
+
public static function ensureSchema(): void {
+ if (self::$schemaReady) {
+ return;
+ }
$pdo = Database::pdo();
if (!Database::tableExists($pdo, 'remote_access_devices')) {
self::createTables($pdo);
+ self::$schemaReady = true;
return;
}
self::ensureSessionColumns($pdo);
self::ensureRsshColumns($pdo);
self::ensureDeviceMetaColumn($pdo);
self::ensureLastClientIpColumn($pdo);
+ self::$schemaReady = true;
+ }
+
+ private static function addColumnIfMissing(PDO $pdo, string $table, string $column, string $ddl): void {
+ if (!Database::tableExists($pdo, $table)) {
+ return;
+ }
+ $names = Database::columnNames($pdo, $table);
+ if (in_array($column, $names, true)) {
+ return;
+ }
+ $pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $ddl);
}
private static function ensureLastClientIpColumn(PDO $pdo): void {
- if (!Database::tableExists($pdo, 'remote_access_devices')) {
- return;
- }
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
- try {
- $pdo->exec('ALTER TABLE remote_access_devices ADD COLUMN last_client_ip ' . $col);
- } catch (PDOException $e) {
- $msg = strtolower($e->getMessage());
- if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
- throw $e;
- }
- }
+ self::addColumnIfMissing($pdo, 'remote_access_devices', 'last_client_ip', $col);
}
private static function ensureDeviceMetaColumn(PDO $pdo): void {
- if (!Database::tableExists($pdo, 'remote_access_devices')) {
- return;
- }
$col = Database::isMysql() ? 'LONGTEXT NULL' : 'TEXT NULL';
- try {
- $pdo->exec('ALTER TABLE remote_access_devices ADD COLUMN device_meta_json ' . $col);
- } catch (PDOException $e) {
- $msg = strtolower($e->getMessage());
- if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
- throw $e;
- }
- }
+ self::addColumnIfMissing($pdo, 'remote_access_devices', 'device_meta_json', $col);
}
/** @param list $where @param list $params */
@@ -113,30 +111,13 @@ final class RemoteAccessRepository {
'rssh_local_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
];
foreach ($cols as $name => $ddl) {
- try {
- $pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN ' . $name . ' ' . $ddl);
- } catch (PDOException $e) {
- $msg = strtolower($e->getMessage());
- if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
- throw $e;
- }
- }
+ self::addColumnIfMissing($pdo, 'remote_access_sessions', $name, $ddl);
}
}
private static function ensureSessionColumns(PDO $pdo): void {
- if (!Database::tableExists($pdo, 'remote_access_sessions')) {
- return;
- }
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
- try {
- $pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN wg_client_public_key ' . $col);
- } catch (PDOException $e) {
- $msg = strtolower($e->getMessage());
- if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
- throw $e;
- }
- }
+ self::addColumnIfMissing($pdo, 'remote_access_sessions', 'wg_client_public_key', $col);
}
private static function createTables(PDO $pdo): void {
@@ -185,6 +166,14 @@ final class RemoteAccessRepository {
}
$deviceMeta = is_array($heartbeat['device'] ?? null) ? $heartbeat['device'] : [];
+ $routeScope = trim((string) ($heartbeat['vpn_route_scope'] ?? ''));
+ $appScope = trim((string) ($heartbeat['vpn_app_scope'] ?? ''));
+ if ($routeScope !== '') {
+ $deviceMeta['vpn_route_scope'] = $routeScope;
+ }
+ if ($appScope !== '') {
+ $deviceMeta['vpn_app_scope'] = $appScope;
+ }
self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp, $deviceMeta);
@@ -853,22 +842,89 @@ final class RemoteAccessRepository {
return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s');
}
+ /** @return array{devices: list>, active_sessions: list>, inactive_sessions: list>, recent_events: list>} */
+ public static function buildDashboardPayload(string $issuesBase, string $projectBase): array {
+ self::ensureSchema();
+ $sessions = self::listSessions('');
+ $active = array_values(array_filter(
+ $sessions,
+ static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true)
+ ));
+ $inactive = array_values(array_filter(
+ $sessions,
+ static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)
+ ));
+
+ return [
+ 'devices' => self::listDevicesForDashboard($issuesBase, $projectBase),
+ 'active_sessions' => $active,
+ 'inactive_sessions' => array_slice($inactive, 0, 50),
+ 'recent_events' => self::listEvents(30),
+ ];
+ }
+
/** @return list> */
public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
$devices = self::listDevices();
+ if ($devices === []) {
+ return [];
+ }
+ $deviceIds = array_values(array_filter(array_map(
+ static fn($row) => (string) ($row['device_id'] ?? ''),
+ $devices
+ )));
+ $sessionRows = self::batchActiveSessionRows($deviceIds);
+ $wgPeers = WireGuardTrafficStats::peerMap();
$out = [];
foreach ($devices as $row) {
- $out[] = self::enrichDeviceRow($row, $issuesBase, $projectBase);
+ $deviceId = (string) ($row['device_id'] ?? '');
+ $out[] = self::enrichDeviceRow(
+ $row,
+ $issuesBase,
+ $projectBase,
+ $sessionRows[$deviceId] ?? null,
+ $wgPeers
+ );
}
return $out;
}
- /** @param array $row */
- private static function enrichDeviceRow(array $row, string $issuesBase, string $projectBase): array {
+ /** @param list $deviceIds @return array> */
+ private static function batchActiveSessionRows(array $deviceIds): array {
+ $deviceIds = array_values(array_unique(array_filter(array_map('strval', $deviceIds))));
+ if ($deviceIds === []) {
+ return [];
+ }
+ $placeholders = implode(',', array_fill(0, count($deviceIds), '?'));
+ $st = Database::pdo()->prepare(
+ "SELECT device_id, tunnel, wg_client_address, wg_client_public_key
+ FROM remote_access_sessions
+ WHERE status IN ('pending', 'active') AND device_id IN ($placeholders)
+ ORDER BY id DESC"
+ );
+ $st->execute($deviceIds);
+ $map = [];
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $did = (string) ($row['device_id'] ?? '');
+ if ($did !== '' && !isset($map[$did])) {
+ $map[$did] = $row;
+ }
+ }
+ return $map;
+ }
+
+ /** @param array $row @param array|null $sessionRow @param array|null $wgPeers */
+ private static function enrichDeviceRow(
+ array $row,
+ string $issuesBase,
+ string $projectBase,
+ ?array $sessionRow = null,
+ ?array $wgPeers = null
+ ): array {
$deviceId = (string) ($row['device_id'] ?? '');
$companyId = (int) ($row['company_id'] ?? 1);
$storedMeta = self::decodeDeviceMeta($row['device_meta_json'] ?? null);
- $ctx = self::lookupDeviceContext($deviceId, $companyId, $storedMeta);
+ $ctx = self::lookupDeviceContextFromMeta($deviceId, $companyId, $storedMeta);
$row['needs_whitelist'] = self::deviceNeedsWhitelist($row);
$row['status_label'] = self::deviceStatusLabel($row);
$row['device_name'] = $ctx['device_name'] ?? '';
@@ -885,7 +941,9 @@ final class RemoteAccessRepository {
$row['poll_source_ip'] = self::resolvePollSourceIp($row);
$row['poll_source_is_private'] = self::isPrivateIp($row['poll_source_ip']);
$row['device_wan_ip'] = trim((string) ($storedMeta['wan_ip'] ?? ''));
- $net = self::lookupDeviceSessionNetwork($deviceId);
+ $row['vpn_route_scope'] = trim((string) ($storedMeta['vpn_route_scope'] ?? ''));
+ $row['vpn_app_scope'] = trim((string) ($storedMeta['vpn_app_scope'] ?? ''));
+ $net = self::sessionNetworkFromRow($sessionRow, $wgPeers);
$row['vpn_ip'] = $net['vpn_ip'] ?? '';
$row['vpn_public_key'] = $net['vpn_public_key'] ?? '';
$row['wg_rx_bytes'] = $net['wg_rx_bytes'] ?? 0;
@@ -1002,8 +1060,8 @@ final class RemoteAccessRepository {
return $raw;
}
- /** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
- private static function lookupDeviceSessionNetwork(string $deviceId): array {
+ /** @param array|null $sessionRow @param array|null $wgPeers @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
+ private static function sessionNetworkFromRow(?array $sessionRow, ?array $wgPeers): array {
$empty = [
'vpn_ip' => '',
'vpn_public_key' => '',
@@ -1012,29 +1070,20 @@ final class RemoteAccessRepository {
'wg_latest_handshake' => 0,
'wg_endpoint' => '',
];
- if ($deviceId === '') {
+ if (!is_array($sessionRow)) {
return $empty;
}
- $st = Database::pdo()->prepare(
- "SELECT tunnel, wg_client_address, wg_client_public_key
- FROM remote_access_sessions
- WHERE device_id = ? AND status IN ('pending', 'active')
- ORDER BY id DESC LIMIT 1"
- );
- $st->execute([$deviceId]);
- $row = $st->fetch(PDO::FETCH_ASSOC);
- if (!is_array($row)) {
- return $empty;
- }
- $tunnel = (string) ($row['tunnel'] ?? '');
+ $tunnel = (string) ($sessionRow['tunnel'] ?? '');
if ($tunnel !== '' && $tunnel !== 'wireguard') {
return $empty;
}
- $addr = trim((string) ($row['wg_client_address'] ?? ''));
+ $addr = trim((string) ($sessionRow['wg_client_address'] ?? ''));
$vpnIp = $addr !== '' ? preg_replace('/\/\d+$/', '', $addr) ?? $addr : '';
- $pub = trim((string) ($row['wg_client_public_key'] ?? ''));
-
- $stats = $pub !== '' ? WireGuardTrafficStats::forPublicKey($pub) : null;
+ $pub = trim((string) ($sessionRow['wg_client_public_key'] ?? ''));
+ $stats = ($wgPeers !== null && $pub !== '') ? ($wgPeers[$pub] ?? null) : null;
+ if ($stats === null && $pub !== '') {
+ $stats = WireGuardTrafficStats::forPublicKey($pub);
+ }
return [
'vpn_ip' => is_string($vpnIp) ? trim($vpnIp) : '',
@@ -1046,6 +1095,72 @@ final class RemoteAccessRepository {
];
}
+ /** Fast dashboard enrichment from heartbeat meta (no reports/graph table scans). */
+ private static function lookupDeviceContextFromMeta(
+ string $deviceId,
+ int $companyId,
+ array $storedMeta = []
+ ): array {
+ if ($deviceId === '') {
+ return [];
+ }
+ unset($companyId);
+ $deviceName = trim((string) ($storedMeta['name'] ?? ''));
+ $manufacturer = trim((string) ($storedMeta['manufacturer'] ?? ''));
+ $brand = trim((string) ($storedMeta['brand'] ?? ''));
+ $model = trim((string) ($storedMeta['model'] ?? ''));
+ $hardwareDevice = trim((string) ($storedMeta['device'] ?? ''));
+ $product = trim((string) ($storedMeta['product'] ?? ''));
+ $osRelease = trim((string) ($storedMeta['release'] ?? ''));
+ $sdkInt = isset($storedMeta['sdk_int']) ? (int) $storedMeta['sdk_int'] : null;
+ $abis = is_array($storedMeta['abis'] ?? null) ? $storedMeta['abis'] : [];
+ $appVersion = trim((string) ($storedMeta['app_version'] ?? ''));
+ $deviceDisplay = $deviceName !== ''
+ ? $deviceName
+ : trim($manufacturer . ' ' . $model);
+
+ return [
+ 'issue_count' => 0,
+ 'latest_issue_id' => null,
+ 'graph_session_count' => 0,
+ 'device_name' => $deviceName,
+ 'device_display' => $deviceDisplay,
+ 'manufacturer' => $manufacturer,
+ 'brand' => $brand,
+ 'model' => $model,
+ 'hardware_device' => $hardwareDevice,
+ 'product' => $product,
+ 'os_release' => $osRelease,
+ 'sdk_int' => $sdkInt,
+ 'abis' => $abis,
+ 'app_version' => $appVersion,
+ ];
+ }
+
+ /** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
+ private static function lookupDeviceSessionNetwork(string $deviceId): array {
+ if ($deviceId === '') {
+ return [
+ 'vpn_ip' => '',
+ 'vpn_public_key' => '',
+ 'wg_rx_bytes' => 0,
+ 'wg_tx_bytes' => 0,
+ 'wg_latest_handshake' => 0,
+ 'wg_endpoint' => '',
+ ];
+ }
+ $st = Database::pdo()->prepare(
+ "SELECT tunnel, wg_client_address, wg_client_public_key
+ FROM remote_access_sessions
+ WHERE device_id = ? AND status IN ('pending', 'active')
+ ORDER BY id DESC LIMIT 1"
+ );
+ $st->execute([$deviceId]);
+ $row = $st->fetch(PDO::FETCH_ASSOC);
+
+ return self::sessionNetworkFromRow(is_array($row) ? $row : null, null);
+ }
+
/** @param array $storedMeta @return array */
private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array {
if ($deviceId === '') {
@@ -1087,21 +1202,27 @@ final class RemoteAccessRepository {
}
if (Database::tableExists($pdo, 'reports')) {
- $like = '%' . $deviceId . '%';
- $cntSt = $pdo->prepare(
- 'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.payload_json LIKE ?'
- );
- $cntSt->execute([$companyId, $like]);
- $issueCount = (int) $cntSt->fetchColumn();
+ $reportsDeviceId = self::resolveDeviceDbId($deviceId, $companyId);
+ if ($reportsDeviceId !== null) {
+ $cntSt = $pdo->prepare(
+ 'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.device_id = ?'
+ );
+ $cntSt->execute([$companyId, $reportsDeviceId]);
+ $issueCount = (int) $cntSt->fetchColumn();
- $latestSt = $pdo->prepare(
- 'SELECT r.id, r.device_model, r.app_version, r.payload_json
- FROM reports r
- WHERE r.company_id = ? AND r.payload_json LIKE ?
- ORDER BY r.received_at_ms DESC LIMIT 1'
- );
- $latestSt->execute([$companyId, $like]);
- $latest = $latestSt->fetch(PDO::FETCH_ASSOC);
+ $latestSt = $pdo->prepare(
+ 'SELECT r.id, r.device_model, r.app_version, r.payload_json
+ FROM reports r
+ WHERE r.company_id = ? AND r.device_id = ?
+ ORDER BY r.received_at_ms DESC LIMIT 1'
+ );
+ $latestSt->execute([$companyId, $reportsDeviceId]);
+ $latest = $latestSt->fetch(PDO::FETCH_ASSOC);
+ } else {
+ // Avoid payload_json LIKE scans (full table) on unregistered devices.
+ $issueCount = 0;
+ $latest = null;
+ }
if (is_array($latest)) {
$latestIssueId = (int) ($latest['id'] ?? 0) ?: null;
if ($appVersion === '') {
@@ -1163,6 +1284,28 @@ final class RemoteAccessRepository {
];
}
+ private static function resolveDeviceDbId(string $deviceId, int $companyId): ?int {
+ static $cache = [];
+ $key = $companyId . ':' . $deviceId;
+ if (array_key_exists($key, $cache)) {
+ return $cache[$key];
+ }
+ $pdo = Database::pdo();
+ if (!Database::tableExists($pdo, 'devices')) {
+ $cache[$key] = null;
+
+ return null;
+ }
+ $st = $pdo->prepare(
+ 'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
+ );
+ $st->execute([$companyId, $deviceId]);
+ $id = $st->fetchColumn();
+ $cache[$key] = ($id !== false && $id !== null && $id !== '') ? (int) $id : null;
+
+ return $cache[$key];
+ }
+
/** @param array $ctx @return list */
private static function deviceLinks(string $deviceId, string $issuesBase, string $projectBase, array $ctx): array {
if ($deviceId === '') {