diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cba764e..473446e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -138,6 +138,12 @@ android:exported="false" android:foregroundServiceType="connectedDevice" /> + + + + + + + + Android Cast — remote control + + + +
+

Remote access control

+

Port __LISTEN_PORT__ · token injected at serve time

+
+ + + +
+

Remote access settings

+
+ + + +

+ + +
+
+ +
+

Internals

+
Loading…
+
+ + + + 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 + + + +
+

Sandbox shell

+

← Settings

+
+

+  
+ +
+ + + 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 === '') {