diff --git a/app/build.gradle b/app/build.gradle index e13feb3..314da5b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -129,4 +129,9 @@ dependencies { implementation 'com.google.android.play:review:2.0.2' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20240303' + + if (project.findProject(':tunnel') != null) { + implementation project(':tunnel') + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d221802..80d94ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ + + + + + + + + + diff --git a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java index 87fa241..91e4045 100644 --- a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java +++ b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java @@ -20,6 +20,7 @@ import com.foxx.androidcast.display.WiredDisplayMonitor; import com.foxx.androidcast.media.CodecPriorityCatalog; import com.foxx.androidcast.network.CastTransportFactory; import com.foxx.androidcast.receiver.av.ReceiverAvRuntime; +import com.foxx.androidcast.remoteaccess.RemoteAccessCoordinator; /** Applies saved theme and locale at process start. */ public class AndroidCastApplication extends Application { @@ -39,5 +40,6 @@ public class AndroidCastApplication extends Application { CastTransportFactory.init(this); WiredDisplayMonitor.getInstance(this).ensureRegistered(); CommercialFeatures.refreshFromPreferences(this); + RemoteAccessCoordinator.onBootIfNeeded(this); } } diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java index 9f44c25..7c0911c 100644 --- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java +++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java @@ -61,6 +61,7 @@ public final class AppPreferences { private static final String KEY_DEV_DIAG_PING_PONG = "dev_diag_ping_pong"; private static final String KEY_DEV_DIAG_PING_RESPONSE_MODE = "dev_diag_ping_response_mode"; private static final String KEY_DEV_BACKEND_NTP_SYNC = "dev_backend_ntp_sync"; + private static final String KEY_DEV_REMOTE_ACCESS_MODE = "dev_remote_access_mode"; /** Minimum interval between automatic OTA checks on app launch. */ public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L; @@ -428,6 +429,18 @@ public final class AppPreferences { prefs(context).edit().putBoolean(KEY_DEV_BACKEND_NTP_SYNC, enabled).apply(); } + public static com.foxx.androidcast.remoteaccess.RemoteAccessMode getDevRemoteAccessMode(Context context) { + return com.foxx.androidcast.remoteaccess.RemoteAccessMode.fromStored( + prefs(context).getString(KEY_DEV_REMOTE_ACCESS_MODE, com.foxx.androidcast.remoteaccess.RemoteAccessMode.DISABLED.name())); + } + + public static void setDevRemoteAccessMode(Context context, com.foxx.androidcast.remoteaccess.RemoteAccessMode mode) { + if (mode == null) { + mode = com.foxx.androidcast.remoteaccess.RemoteAccessMode.DISABLED; + } + prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_MODE, mode.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 2efdc27..dbe17b3 100644 --- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java +++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java @@ -38,12 +38,16 @@ import com.foxx.androidcast.sender.SenderResolutionUiMode; import com.foxx.androidcast.dev.DevDiagnosticsController; 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.stats.ui.SessionStatsAnalyzerActivity; import com.google.android.material.tabs.TabLayout; import com.google.android.material.textfield.TextInputEditText; /** Hidden developer settings (opened via app-info area easter egg). */ public class DeveloperSettingsActivity extends AppCompatActivity { + private static final int REQUEST_VPN_PREPARE = 9107; + private DevDiagnosticsController diagnosticsController; private DevNetworkSelfTestController networkSelfTestController; private android.view.View settingsPanel; @@ -51,6 +55,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity { private android.view.View networkSelfTestPanel; private Spinner videoPresetSpinner; private Spinner audioPresetSpinner; + private Spinner remoteAccessSpinner; + private RemoteAccessMode pendingRemoteAccessMode; + private boolean suppressRemoteAccessSpinnerEvents; private SeekBar intensitySeek; private TextView intensityValue; @@ -106,6 +113,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity { }); bindAvPresets(); + bindRemoteAccessSection(); bindDiagPingSection(); bindSenderResolutionUiModeSection(); bindWiredDisplaySection(); @@ -144,11 +152,87 @@ public class DeveloperSettingsActivity extends AppCompatActivity { findViewById(R.id.edit_ota_manifest_url), findViewById(R.id.check_ota_on_launch)); bindAvPresets(); + bindRemoteAccessSection(); bindSenderResolutionUiModeSection(); bindWiredDisplaySection(); bindCommercialSection(); } + private void bindRemoteAccessSection() { + remoteAccessSpinner = findViewById(R.id.spinner_dev_remote_access_mode); + if (remoteAccessSpinner == null) { + return; + } + RemoteAccessMode[] modes = RemoteAccessMode.values(); + String[] labels = new String[]{ + getString(R.string.dev_remote_access_disabled), + getString(R.string.dev_remote_access_wireguard), + getString(R.string.dev_remote_access_rssh_unavailable), + }; + ArrayAdapter adapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + remoteAccessSpinner.setAdapter(adapter); + RemoteAccessMode current = AppPreferences.getDevRemoteAccessMode(this); + suppressRemoteAccessSpinnerEvents = true; + remoteAccessSpinner.setOnItemSelectedListener(null); + remoteAccessSpinner.setSelection(current.ordinal(), false); + suppressRemoteAccessSpinnerEvents = false; + remoteAccessSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(android.widget.AdapterView parent, android.view.View view, + int position, long id) { + if (suppressRemoteAccessSpinnerEvents) { + return; + } + RemoteAccessMode picked = modes[position]; + if (picked == RemoteAccessMode.RSSH) { + Toast.makeText(DeveloperSettingsActivity.this, + R.string.dev_remote_access_rssh_toast, Toast.LENGTH_SHORT).show(); + suppressRemoteAccessSpinnerEvents = true; + remoteAccessSpinner.setSelection( + AppPreferences.getDevRemoteAccessMode(DeveloperSettingsActivity.this).ordinal(), false); + suppressRemoteAccessSpinnerEvents = false; + return; + } + applyRemoteAccessMode(picked); + } + + @Override + public void onNothingSelected(android.widget.AdapterView parent) {} + }); + } + + private void applyRemoteAccessMode(RemoteAccessMode mode) { + if (mode == RemoteAccessMode.WIREGUARD) { + android.content.Intent prep = android.net.VpnService.prepare(this); + if (prep != null) { + pendingRemoteAccessMode = mode; + startActivityForResult(prep, REQUEST_VPN_PREPARE); + return; + } + } + AppPreferences.setDevRemoteAccessMode(this, mode); + RemoteAccessCoordinator.applyMode(this, mode); + Toast.makeText(this, getString(R.string.dev_remote_access_applied, mode.name()), Toast.LENGTH_SHORT).show(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, android.content.Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_VPN_PREPARE && pendingRemoteAccessMode != null) { + RemoteAccessMode mode = pendingRemoteAccessMode; + pendingRemoteAccessMode = null; + if (resultCode == RESULT_OK) { + AppPreferences.setDevRemoteAccessMode(this, mode); + RemoteAccessCoordinator.applyMode(this, mode); + } else { + bindRemoteAccessSection(); + Toast.makeText(this, R.string.dev_remote_access_vpn_denied, Toast.LENGTH_LONG).show(); + } + } + } + private void bindDiagPingSection() { CheckBox enabled = findViewById(R.id.check_dev_diag_ping_pong); bindDeveloperCheckbox(enabled, AppPreferences.isDevDiagPingPongEnabled(this), diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java new file mode 100644 index 0000000..63ac4b7 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java @@ -0,0 +1,40 @@ +package com.foxx.androidcast.remoteaccess; + +import android.content.Context; +import android.util.Log; + +import com.foxx.androidcast.AppPreferences; + +/** Start/stop remote access foreground polling + VPN based on developer mode. */ +public final class RemoteAccessCoordinator { + private static final String TAG = "RemoteAccessCoord"; + + private RemoteAccessCoordinator() {} + + public static void applyMode(Context context, RemoteAccessMode mode) { + Context app = context.getApplicationContext(); + if (mode == null || mode == RemoteAccessMode.DISABLED) { + Log.i(TAG, "disabling remote access"); + RemoteAccessService.stop(app); + RemoteAccessVpnService.stop(app); + RemoteAccessService.sendDisableOnce(app); + return; + } + if (mode == RemoteAccessMode.WIREGUARD) { + RemoteAccessService.start(app, mode); + return; + } + Log.w(TAG, "unsupported mode " + mode); + } + + public static void syncFromPreferences(Context context) { + applyMode(context, AppPreferences.getDevRemoteAccessMode(context)); + } + + public static void onBootIfNeeded(Context context) { + RemoteAccessMode mode = AppPreferences.getDevRemoteAccessMode(context); + if (mode.isActive()) { + RemoteAccessService.start(context.getApplicationContext(), mode); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessHeartbeatClient.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessHeartbeatClient.java new file mode 100644 index 0000000..3e54ff3 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessHeartbeatClient.java @@ -0,0 +1,106 @@ +package com.foxx.androidcast.remoteaccess; + +import android.content.Context; +import android.util.Log; + +import com.foxx.androidcast.BuildConfig; +import com.foxx.androidcast.crash.CrashSettingsStore; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** POST heartbeat type:ra to BE (shared by RemoteAccessService). */ +public final class RemoteAccessHeartbeatClient { + private static final String TAG = "RemoteAccessHB"; + private static final int HTTP_TIMEOUT_MS = 15_000; + + private RemoteAccessHeartbeatClient() {} + + public static String resolveHeartbeatUrl(Context context) { + String crash = CrashSettingsStore.load(context).uploadUrl; + if (crash != null && !crash.trim().isEmpty()) { + return toHeartbeatUrl(crash.trim()); + } + String ota = com.foxx.androidcast.AppPreferences.getOtaChannelUrl(context); + if (ota != null && !ota.trim().isEmpty()) { + return toHeartbeatUrl(ota.trim()); + } + if (BuildConfig.OTA_CHANNEL_URL_DEFAULT != null && !BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim().isEmpty()) { + return toHeartbeatUrl(BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim()); + } + return ""; + } + + public static String toHeartbeatUrl(String backend) { + if (backend.contains("/api/upload.php")) { + return backend.replace("/api/upload.php", "/api/heartbeat.php"); + } + if (backend.endsWith("/")) { + return backend + "api/heartbeat.php"; + } + return backend + "/api/heartbeat.php"; + } + + /** @return parsed heartbeat object from response, or null on failure */ + public static JSONObject postRa(Context context, JSONObject heartbeatBody) throws Exception { + String url = resolveHeartbeatUrl(context); + if (url.isEmpty()) { + throw new IllegalStateException("no_backend_url"); + } + JSONObject req = new JSONObject(); + req.put("heartbeat", heartbeatBody); + String text = httpPostJson(url, req.toString()); + JSONObject resp = new JSONObject(text); + return resp.optJSONObject("heartbeat"); + } + + private static String httpPostJson(String url, String body) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + c.setRequestMethod("POST"); + c.setConnectTimeout(HTTP_TIMEOUT_MS); + c.setReadTimeout(HTTP_TIMEOUT_MS); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + try (OutputStream os = c.getOutputStream()) { + os.write(bytes); + } + int code = c.getResponseCode(); + String resp = readBody(c); + c.disconnect(); + if (code < 200 || code >= 300) { + Log.w(TAG, "heartbeat HTTP " + code + " body=" + resp); + throw new IllegalStateException("http_" + code); + } + return resp; + } + + private static String readBody(HttpURLConnection c) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(c.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + if (sb.length() > 8192) { + break; + } + } + return sb.toString(); + } catch (Exception e) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(c.getErrorStream(), StandardCharsets.UTF_8))) { + String line = br.readLine(); + return line != null ? line : ""; + } catch (Exception ignored) { + return ""; + } + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessMode.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessMode.java new file mode 100644 index 0000000..3441bde --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessMode.java @@ -0,0 +1,44 @@ +package com.foxx.androidcast.remoteaccess; + +import java.util.Locale; + +/** Developer setting: on-demand remote debug transport. */ +public enum RemoteAccessMode { + /** Remote access off; tear down tunnels and notify BE. */ + DISABLED, + /** WireGuard outbound tunnel (v1). */ + WIREGUARD, + /** Reverse SSH — reserved; not selectable in UI until implemented. */ + RSSH; + + public static RemoteAccessMode fromStored(String name) { + if (name == null || name.isEmpty()) { + return DISABLED; + } + try { + return valueOf(name.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + if ("NONE".equalsIgnoreCase(name)) { + return DISABLED; + } + return DISABLED; + } + } + + /** BE heartbeat opt_in_mode / capabilities value. */ + public String toApiValue() { + switch (this) { + case WIREGUARD: + return "wireguard"; + case RSSH: + return "rssh"; + case DISABLED: + default: + return "none"; + } + } + + public boolean isActive() { + return this == 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 new file mode 100644 index 0000000..131df99 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java @@ -0,0 +1,226 @@ +package com.foxx.androidcast.remoteaccess; + +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.os.SystemClock; +import android.util.Log; + +import androidx.core.app.NotificationCompat; + +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.DeveloperSettingsActivity; +import com.foxx.androidcast.DeviceInfo; +import com.foxx.androidcast.R; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** Jittered heartbeat type:ra polling while remote access mode is active. */ +public final class RemoteAccessService extends Service { + private static final String TAG = "RemoteAccessSvc"; + + public static final String ACTION_START = "com.foxx.androidcast.remoteaccess.START"; + public static final String ACTION_STOP = "com.foxx.androidcast.remoteaccess.STOP"; + public static final String ACTION_POLL = "com.foxx.androidcast.remoteaccess.POLL"; + public static final String ACTION_DISABLE_ONCE = "com.foxx.androidcast.remoteaccess.DISABLE_ONCE"; + public static final String EXTRA_MODE = "mode"; + + private static final int NOTIF_ID = 0x7a00; + private static final String CHANNEL_ID = "remote_access"; + private static final int MIN_POLL_MS = 60_000; + private static final int MAX_POLL_MS = 420_000; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Random random = new Random(); + private String sessionRandom = UUID.randomUUID().toString(); + + public static void start(Context context, RemoteAccessMode mode) { + Intent i = new Intent(context, RemoteAccessService.class); + i.setAction(ACTION_START); + i.putExtra(EXTRA_MODE, mode.name()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(i); + } else { + context.startService(i); + } + } + + public static void stop(Context context) { + Intent i = new Intent(context, RemoteAccessService.class); + i.setAction(ACTION_STOP); + context.startService(i); + } + + public static void sendDisableOnce(Context context) { + Intent i = new Intent(context, RemoteAccessService.class); + i.setAction(ACTION_DISABLE_ONCE); + context.startService(i); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent != null ? intent.getAction() : ACTION_POLL; + if (ACTION_STOP.equals(action)) { + cancelPoll(this); + WireGuardTunnelBridge.tearDown(this); + stopForeground(true); + stopSelf(); + return START_NOT_STICKY; + } + if (ACTION_DISABLE_ONCE.equals(action)) { + executor.execute(() -> postDisableHeartbeat()); + return START_NOT_STICKY; + } + RemoteAccessMode mode = AppPreferences.getDevRemoteAccessMode(this); + if (intent != null && intent.hasExtra(EXTRA_MODE)) { + mode = RemoteAccessMode.fromStored(intent.getStringExtra(EXTRA_MODE)); + } + if (!mode.isActive()) { + stop(this); + return START_NOT_STICKY; + } + ensureChannel(); + startForeground(NOTIF_ID, buildNotification(mode)); + final RemoteAccessMode pollMode = mode; + if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) { + executor.execute(() -> runPoll(pollMode)); + } + scheduleNextPoll(); + return START_STICKY; + } + + private void runPoll(RemoteAccessMode mode) { + try { + JSONObject hb = new JSONObject(); + hb.put("type", "ra"); + hb.put("status", "enable"); + hb.put("device_id", DeviceInfo.getDeviceUuid(this)); + hb.put("random", sessionRandom); + hb.put("app_version", com.foxx.androidcast.BuildConfig.VERSION_NAME); + hb.put("tunnel_mode", mode.toApiValue()); + JSONArray caps = new JSONArray(); + caps.put("wireguard"); + caps.put("files_app_home"); + hb.put("capabilities", caps); + + JSONObject resp = RemoteAccessHeartbeatClient.postRa(this, hb); + if (resp == null) { + return; + } + String raAction = resp.optString("action", "wait"); + if ("connect".equals(raAction)) { + handleConnect(resp); + } else if ("disabled".equals(raAction) || "deny".equals(raAction)) { + WireGuardTunnelBridge.tearDown(this); + } + } catch (Exception e) { + Log.w(TAG, "poll failed: " + e.getMessage()); + } + } + + private void postDisableHeartbeat() { + try { + WireGuardTunnelBridge.tearDown(this); + JSONObject hb = new JSONObject(); + hb.put("type", "ra"); + hb.put("status", "disable"); + hb.put("device_id", DeviceInfo.getDeviceUuid(this)); + hb.put("random", sessionRandom); + RemoteAccessHeartbeatClient.postRa(this, hb); + } catch (Exception e) { + Log.w(TAG, "disable notify failed: " + e.getMessage()); + } + } + + private void handleConnect(JSONObject resp) { + if (!"wireguard".equals(resp.optString("tunnel", ""))) { + Log.w(TAG, "unsupported tunnel in connect"); + return; + } + JSONObject creds = resp.optJSONObject("credentials"); + String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds); + if (wgConfig.isEmpty()) { + return; + } + WireGuardTunnelBridge.applyConfig(this, wgConfig); + } + + private void scheduleNextPoll() { + int delay = MIN_POLL_MS + random.nextInt(MAX_POLL_MS - MIN_POLL_MS + 1); + AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + if (am == null) { + return; + } + PendingIntent pi = PendingIntent.getService(this, 0, + new Intent(this, RemoteAccessService.class).setAction(ACTION_POLL), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + long trigger = SystemClock.elapsedRealtime() + delay; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, pi); + } else { + am.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, pi); + } + } + + private static void cancelPoll(Context context) { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (am == null) { + return; + } + PendingIntent pi = PendingIntent.getService(context, 0, + new Intent(context, RemoteAccessService.class).setAction(ACTION_POLL), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + am.cancel(pi); + } + + private Notification buildNotification(RemoteAccessMode mode) { + Intent open = new Intent(this, DeveloperSettingsActivity.class); + PendingIntent pi = PendingIntent.getActivity(this, 0, open, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.dev_remote_access_notification_title)) + .setContentText(getString(R.string.dev_remote_access_notification_poll, mode.name())) + .setContentIntent(pi) + .setOngoing(true) + .build(); + } + + private void ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + NotificationChannel ch = new NotificationChannel(CHANNEL_ID, + getString(R.string.dev_remote_access_notification_channel), + NotificationManager.IMPORTANCE_LOW); + nm.createNotificationChannel(ch); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnService.java new file mode 100644 index 0000000..d1ac8fc --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnService.java @@ -0,0 +1,150 @@ +package com.foxx.androidcast.remoteaccess; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.net.VpnService; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import androidx.core.app.NotificationCompat; + +import com.foxx.androidcast.DeveloperSettingsActivity; +import com.foxx.androidcast.R; + +import java.net.InetAddress; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Holds WireGuard TUN when tunnel module is not linked; also prepares interface for userspace WG. */ +public final class RemoteAccessVpnService extends VpnService { + private static final String TAG = "RemoteAccessVPN"; + public static final String ACTION_UP = "com.foxx.androidcast.remoteaccess.VPN_UP"; + public static final String ACTION_DOWN = "com.foxx.androidcast.remoteaccess.VPN_DOWN"; + public static final String EXTRA_WG_CONFIG = "wg_config"; + + private static final int NOTIF_ID = 0x7a01; + private static final String CHANNEL_ID = "remote_access_vpn"; + + private ParcelFileDescriptor tun; + + public static boolean startWithConfig(Context context, String wgConfig) { + Intent prep = VpnService.prepare(context); + if (prep != null) { + Log.w(TAG, "VPN permission not granted"); + return false; + } + Intent i = new Intent(context, RemoteAccessVpnService.class); + i.setAction(ACTION_UP); + i.putExtra(EXTRA_WG_CONFIG, wgConfig); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(i); + } else { + context.startService(i); + } + return true; + } + + public static void stop(Context context) { + Intent i = new Intent(context, RemoteAccessVpnService.class); + i.setAction(ACTION_DOWN); + context.startService(i); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent != null ? intent.getAction() : null; + if (ACTION_DOWN.equals(action)) { + tearDown(); + stopForeground(true); + stopSelf(); + return START_NOT_STICKY; + } + if (ACTION_UP.equals(action)) { + String cfg = intent.getStringExtra(EXTRA_WG_CONFIG); + ensureChannel(); + startForeground(NOTIF_ID, buildNotification("WireGuard remote access")); + establishTun(cfg); + return START_STICKY; + } + return START_NOT_STICKY; + } + + private void establishTun(String wgConfig) { + tearDown(); + try { + String address = parseAddress(wgConfig); + Builder b = new Builder(); + b.setSession("AndroidCast RA"); + if (address != null) { + String[] parts = address.split("/"); + b.addAddress(InetAddress.getByName(parts[0]), + parts.length > 1 ? Integer.parseInt(parts[1]) : 32); + } else { + b.addAddress("10.66.66.2", 32); + } + b.addRoute("10.66.66.0", 24); + b.setMtu(1280); + tun = b.establish(); + Log.i(TAG, "VPN interface established"); + } catch (Exception e) { + Log.e(TAG, "VPN establish failed", e); + } + } + + 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 void tearDown() { + if (tun != null) { + try { + tun.close(); + } catch (Exception ignored) { + } + tun = null; + } + } + + private Notification buildNotification(String text) { + Intent open = new Intent(this, DeveloperSettingsActivity.class); + PendingIntent pi = PendingIntent.getActivity(this, 0, open, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.dev_remote_access_notification_title)) + .setContentText(text) + .setContentIntent(pi) + .setOngoing(true) + .build(); + } + + private void ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + NotificationChannel ch = new NotificationChannel(CHANNEL_ID, + getString(R.string.dev_remote_access_notification_channel), + NotificationManager.IMPORTANCE_LOW); + nm.createNotificationChannel(ch); + } + + @Override + public void onDestroy() { + tearDown(); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilder.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilder.java new file mode 100644 index 0000000..c4006a6 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilder.java @@ -0,0 +1,40 @@ +package com.foxx.androidcast.remoteaccess; + +import android.util.Log; + +import org.json.JSONObject; + +/** Build wireguard-quick style config from BE connect credentials. */ +public final class WireGuardConfigBuilder { + private static final String TAG = "WireGuardCfg"; + + private WireGuardConfigBuilder() {} + + public static String fromConnectCredentials(JSONObject credentials) { + if (credentials == null) { + return ""; + } + String priv = credentials.optString("interface_private_key", ""); + String addr = credentials.optString("interface_address", "10.66.66.2/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"); + if (priv.isEmpty() || peerPub.isEmpty()) { + Log.w(TAG, "missing wg keys in credentials"); + return ""; + } + StringBuilder sb = new StringBuilder(); + sb.append("[Interface]\n"); + sb.append("PrivateKey = ").append(priv).append('\n'); + sb.append("Address = ").append(addr).append('\n'); + sb.append('\n'); + sb.append("[Peer]\n"); + sb.append("PublicKey = ").append(peerPub).append('\n'); + if (!endpoint.isEmpty()) { + sb.append("Endpoint = ").append(endpoint).append('\n'); + } + sb.append("AllowedIPs = ").append(allowed).append('\n'); + sb.append("PersistentKeepalive = 25\n"); + return sb.toString(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardTunnelBridge.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardTunnelBridge.java new file mode 100644 index 0000000..fe07e77 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardTunnelBridge.java @@ -0,0 +1,64 @@ +package com.foxx.androidcast.remoteaccess; + +import android.content.Context; +import android.util.Log; + +/** + * Optional integration with wireguard-android tunnel module (third-party/wireguard-android). + * Falls back to {@link RemoteAccessVpnService} TUN setup when module is absent. + */ +public final class WireGuardTunnelBridge { + private static final String TAG = "WGTunnelBridge"; + + private WireGuardTunnelBridge() {} + + public static boolean applyConfig(Context context, String wgQuickConfig) { + if (wgQuickConfig == null || wgQuickConfig.trim().isEmpty()) { + return false; + } + try { + Class configClass = Class.forName("com.wireguard.config.Config"); + Object config = configClass.getMethod("parse", String.class).invoke(null, wgQuickConfig); + Class goBackendClass = Class.forName("com.wireguard.android.backend.GoBackend"); + Object backend = goBackendClass.getConstructor(Context.class).newInstance(context); + Class tunnelClass = Class.forName("com.wireguard.android.backend.Tunnel"); + Object tunnel = proxyTunnel(tunnelClass); + Class stateClass = Class.forName("com.wireguard.android.backend.Tunnel$State"); + Object up = enumConstant(stateClass, "UP"); + goBackendClass.getMethod("setState", tunnelClass, stateClass, configClass) + .invoke(backend, tunnel, up, config); + Log.i(TAG, "wireguard tunnel UP via GoBackend"); + return true; + } catch (Throwable t) { + Log.i(TAG, "GoBackend unavailable, using VpnService fallback: " + t.getClass().getSimpleName()); + return RemoteAccessVpnService.startWithConfig(context, wgQuickConfig); + } + } + + public static void tearDown(Context context) { + RemoteAccessVpnService.stop(context); + try { + Class goBackendClass = Class.forName("com.wireguard.android.backend.GoBackend"); + Object backend = goBackendClass.getConstructor(Context.class).newInstance(context); + Class tunnelClass = Class.forName("com.wireguard.android.backend.Tunnel"); + Object tunnel = proxyTunnel(tunnelClass); + Class stateClass = Class.forName("com.wireguard.android.backend.Tunnel$State"); + Object down = enumConstant(stateClass, "DOWN"); + goBackendClass.getMethod("setState", tunnelClass, stateClass, Class.forName("com.wireguard.config.Config")) + .invoke(backend, tunnel, down, null); + } catch (Throwable ignored) { + } + } + + private static Object proxyTunnel(Class tunnelClass) { + return java.lang.reflect.Proxy.newProxyInstance( + tunnelClass.getClassLoader(), + new Class[]{tunnelClass}, + (proxy, method, args) -> "getName".equals(method.getName()) ? "androidcast-ra" : null); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object enumConstant(Class stateClass, String name) { + return Enum.valueOf((Class) stateClass, name); + } +} 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 cff2d75..d7ea754 100644 --- a/app/src/main/res/layout/activity_dev_settings_panel.xml +++ b/app/src/main/res/layout/activity_dev_settings_panel.xml @@ -51,6 +51,26 @@ android:text="@string/dev_backend_ntp_sync_hint" android:textSize="12sp" /> + + + + + + Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets. Enable backend NTP sync Disabled by default. Allows self-test/backend heartbeat to fetch server time correction without changing system clock. + Remote access (on-demand debug) + Disabled + WireGuard + RSSH (reverse SSH — not available yet) + When enabled, device polls BE every 1–7 min (type: ra). Disabled sends teardown + BE notification. RSSH is reserved for a future release. + Reverse SSH (RSSH) is not implemented yet. + Remote access: %1$s + VPN permission required for WireGuard remote access. + Remote access + Remote debug active + Polling BE (%1$s) Network self-test Network self-test tab unlocked Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, peer reachability, backend RTT/throughput, NAT and NTP correction. diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessModeTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessModeTest.java new file mode 100644 index 0000000..2ac89e4 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessModeTest.java @@ -0,0 +1,37 @@ +package com.foxx.androidcast.remoteaccess; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class RemoteAccessModeTest { + + @Test + public void fromStored_noneAlias() { + assertEquals(RemoteAccessMode.DISABLED, RemoteAccessMode.fromStored("none")); + } + + @Test + public void fromStored_wireguard() { + assertEquals(RemoteAccessMode.WIREGUARD, RemoteAccessMode.fromStored("WIREGUARD")); + } + + @Test + public void toApiValue_wireguard() { + assertEquals("wireguard", RemoteAccessMode.WIREGUARD.toApiValue()); + } + + @Test + public void toApiValue_disabled() { + assertEquals("none", RemoteAccessMode.DISABLED.toApiValue()); + } + + @Test + public void isActive_onlyWireGuard() { + assertFalse(RemoteAccessMode.DISABLED.isActive()); + assertTrue(RemoteAccessMode.WIREGUARD.isActive()); + assertFalse(RemoteAccessMode.RSSH.isActive()); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilderTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilderTest.java new file mode 100644 index 0000000..1fe56c2 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilderTest.java @@ -0,0 +1,32 @@ +package com.foxx.androidcast.remoteaccess; + +import org.json.JSONObject; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class WireGuardConfigBuilderTest { + + @Test + 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("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); + assertTrue(cfg.contains("[Interface]")); + assertTrue(cfg.contains("PrivateKey = aPrivKey=")); + assertTrue(cfg.contains("PublicKey = aPubKey=")); + assertTrue(cfg.contains("Endpoint = example.org:51820")); + } + + @Test + public void emptyWhenMissingKeys() throws Exception { + JSONObject creds = new JSONObject(); + creds.put("interface_address", "10.66.66.5/32"); + assertTrue(WireGuardConfigBuilder.fromConnectCredentials(creds).isEmpty()); + } +} diff --git a/docs/20260602_REVERSE_SSH_proposals_summary.md b/docs/20260602_REVERSE_SSH_proposals_summary.md index 6960c7e..21b4242 100644 --- a/docs/20260602_REVERSE_SSH_proposals_summary.md +++ b/docs/20260602_REVERSE_SSH_proposals_summary.md @@ -4,6 +4,8 @@ _Date: 2026-06-02 — **merged document**: initial proposal (rev. 1) + infra rev --- +--- + ## Table of contents diff --git a/docs/ALPHA.md b/docs/ALPHA.md index c5802fa..467238f 100644 --- a/docs/ALPHA.md +++ b/docs/ALPHA.md @@ -8,6 +8,8 @@ Last updated: 2026-05-23. Use this document for **feature freeze**, **alpha buil --- +--- + ## Table of contents diff --git a/docs/ALPHA_SOAK.md b/docs/ALPHA_SOAK.md index 4f074ee..9d88939 100644 --- a/docs/ALPHA_SOAK.md +++ b/docs/ALPHA_SOAK.md @@ -4,6 +4,8 @@ Manual QA on **two devices** on the same Wi‑Fi. Goal: stable show path without --- +--- + ## Table of contents diff --git a/docs/AV_QUALITY_QA_SESSION.md b/docs/AV_QUALITY_QA_SESSION.md index e836cf0..138365e 100644 --- a/docs/AV_QUALITY_QA_SESSION.md +++ b/docs/AV_QUALITY_QA_SESSION.md @@ -9,6 +9,8 @@ Research and recommendations for **movie cast** vs **video cast** quality enhanc --- +--- + ## Table of contents diff --git a/docs/BUILD_DEPLOY.md b/docs/BUILD_DEPLOY.md index 9e30e32..65cc903 100644 --- a/docs/BUILD_DEPLOY.md +++ b/docs/BUILD_DEPLOY.md @@ -2,6 +2,8 @@ --- +--- + ## Table of contents diff --git a/docs/COMMERCIAL.md b/docs/COMMERCIAL.md index 733ed3e..28f0ecc 100644 --- a/docs/COMMERCIAL.md +++ b/docs/COMMERCIAL.md @@ -4,6 +4,8 @@ Ads, in-app purchases, and Play Store helpers are **disabled for normal users**. --- +--- + ## Table of contents diff --git a/docs/CRASH_REPORTER.md b/docs/CRASH_REPORTER.md index 7a0cfee..20429bf 100644 --- a/docs/CRASH_REPORTER.md +++ b/docs/CRASH_REPORTER.md @@ -2,6 +2,8 @@ --- +--- + ## Table of contents diff --git a/docs/GIT_FLOW.md b/docs/GIT_FLOW.md index f2688ca..1b271fe 100644 --- a/docs/GIT_FLOW.md +++ b/docs/GIT_FLOW.md @@ -15,6 +15,8 @@ This document defines how we develop, integrate, release, and hotfix. **Cursor a --- +--- + ## Table of contents diff --git a/docs/GRAFANA_vs_others_graphvis_pivot.md b/docs/GRAFANA_vs_others_graphvis_pivot.md index 3394baf..46f8774 100644 --- a/docs/GRAFANA_vs_others_graphvis_pivot.md +++ b/docs/GRAFANA_vs_others_graphvis_pivot.md @@ -4,6 +4,8 @@ _Date: 2026-06-02_ --- +--- + ## Table of contents diff --git a/docs/INFRA.md b/docs/INFRA.md index 763b833..eed3992 100644 --- a/docs/INFRA.md +++ b/docs/INFRA.md @@ -15,6 +15,8 @@ --- +--- + ## Table of contents diff --git a/docs/OPEN_API.md b/docs/OPEN_API.md index 5977138..eef668f 100644 --- a/docs/OPEN_API.md +++ b/docs/OPEN_API.md @@ -4,6 +4,8 @@ Triage **real device crashes** vs **synthetic / bulk test traffic** when exposin --- +--- + ## Table of contents diff --git a/docs/OTA.md b/docs/OTA.md index 76aa94b..47178c1 100644 --- a/docs/OTA.md +++ b/docs/OTA.md @@ -8,6 +8,8 @@ MQTT is fully supported for OTA sources using retained topic payloads (`mqtt://. --- +--- + ## Table of contents diff --git a/docs/PRO_AAR.md b/docs/PRO_AAR.md index eeaa5c9..19588c0 100644 --- a/docs/PRO_AAR.md +++ b/docs/PRO_AAR.md @@ -4,6 +4,8 @@ Design notes for a commercial build without forking the cast protocol. **Not imp --- +--- + ## Table of contents diff --git a/docs/README.md b/docs/README.md index 895a58a..a304a86 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ Click a link to open a guide. Section links jump within the document (GitHub, Cu | [OPEN_API.md](OPEN_API.md) | Crash ingest API design, triage tags, heuristics. | | [OTA.md](OTA.md) | OTA channel layout v0, manifests, MQTT, publish flow. | | [PRO_AAR.md](PRO_AAR.md) | cast-core / cast-pro AAR split and Play Billing. | +| [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md) | | | [ROADMAP.md](ROADMAP.md) | Implementation roadmap, alpha definition, deferred work. | | [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) | Tickets console, Gitea, attachments, agent queue. | | [USB_HDMI_CAST.md](USB_HDMI_CAST.md) | Roadmap D/E: HDMI awareness, USB-tether transport. | diff --git a/docs/REMOTE_ACCESS_IMPL.md b/docs/REMOTE_ACCESS_IMPL.md new file mode 100644 index 0000000..1924b45 --- /dev/null +++ b/docs/REMOTE_ACCESS_IMPL.md @@ -0,0 +1,47 @@ +# Remote access — implementation notes (v1 WireGuard) + +See the full proposal: [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md). + +## Table of contents + + +- [App (developer settings)](#app-developer-settings) +- [BE](#be) +- [WireGuard third-party](#wireguard-third-party) +- [Tests](#tests) + + +**Documentation index:** [README.md](README.md) + +--- + +## App (developer settings) + +- Dropdown: **Disabled** | **WireGuard** | **RSSH (greyed — not selectable)** +- Pref key: `dev_remote_access_mode` (`AppPreferences`) +- **Disabled:** stops `RemoteAccessService`, tears down VPN, POST `type:ra` + `status:disable` +- **WireGuard:** foreground poll 1–7 min → `RemoteAccessService` → BE `heartbeat.php` + +## BE + +- Migration: `sql/migrations/007_remote_access.sql` +- Repository: `src/RemoteAccessRepository.php` +- Device API: `public/api/heartbeat.php` (`type: ra`) +- Admin API: `public/api/remote_access.php` +- UI: `?view=remote_access` (nav + hub card) +- RBAC: `remote_access_view`, `remote_access_operate`, `remote_access_admin` + +## WireGuard third-party + +```bash +bash scripts/init-wireguard-submodule.sh +``` + +Optional `:tunnel` module from `third-party/wireguard-android`. Without it, `WireGuardTunnelBridge` uses `RemoteAccessVpnService` (TUN fallback). + +## Tests + +```bash +./gradlew :app:testDebugUnitTest +bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh +``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d818d22..4e33ee7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,6 +6,8 @@ Last updated: 2026-05-23. Living document — refine as alpha and commercial tra --- +--- + ## Table of contents diff --git a/docs/TICKETS_ROADMAP.md b/docs/TICKETS_ROADMAP.md index ceb7bb3..8b41824 100644 --- a/docs/TICKETS_ROADMAP.md +++ b/docs/TICKETS_ROADMAP.md @@ -2,6 +2,8 @@ --- +--- + ## Table of contents diff --git a/docs/USB_HDMI_CAST.md b/docs/USB_HDMI_CAST.md index 8a4251d..f8c1490 100644 --- a/docs/USB_HDMI_CAST.md +++ b/docs/USB_HDMI_CAST.md @@ -4,6 +4,8 @@ Android Cast streams over **Wi‑Fi** (UDP/TCP/QUIC). USB-C→HDMI adapters use --- +--- + ## Table of contents diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php index 8d495ae..0409a53 100644 --- a/examples/app_hub/index.php +++ b/examples/app_hub/index.php @@ -110,6 +110,12 @@ require_once dirname(__DIR__) . '/platform/footer.php'; AndroidCast graphs + + + Remote access + + +
  • + + + Remote access + +
  • +
  • /api/graphs.php

    + +
    +
    +

    Remote access

    +
    + +
    +
    +
    +

    Loading…

    + +
    +

    Whitelisted devices

    +
    + + + + + + + + + + + + +
    Device IDOpt-inWhitelistedLast seenAppActions
    +
    +
    + + + +
    +
    + +
    +

    Active / pending sessions

    +
    + + + + + + + + + + + + +
    SessionDeviceStatusTunnelEndpointActions
    +
    +
    + +
    +

    Inactive / closed sessions

    +
    + + + + + + + + + + + +
    SessionDeviceStatusClosedReason
    +
    +
    + +
    +

    Audit log

    +
    + + + + + + + + + + +
    TimeDeviceActionReason
    +
    +
    +
    diff --git a/orchestration/deploy.sh b/orchestration/deploy.sh index df11f56..8e36e89 100755 --- a/orchestration/deploy.sh +++ b/orchestration/deploy.sh @@ -111,6 +111,7 @@ return [ EOF SRC_MIG_070="$ROOT_DIR/runtime/db-init/070_graph_ecosystem.sql" +SRC_MIG_007="$ROOT_DIR/runtime/db-init/007_remote_access.sql" write_migration_with_db() { local src="$1" @@ -129,6 +130,7 @@ write_migration_with_db "$SRC_MIG_004" "$DB_INIT_DIR/040_ticket_workflow.sql" write_migration_with_db "$SRC_MIG_005" "$DB_INIT_DIR/050_ticket_attachments.sql" write_migration_with_db "$SRC_MIG_060" "$DB_INIT_DIR/060_build_ecosystem.sql" write_migration_with_db "$SRC_MIG_070" "$DB_INIT_DIR/070_graph_ecosystem.sql" +write_migration_with_db "$SRC_MIG_007" "$DB_INIT_DIR/007_remote_access.sql" echo "Pulling images..." docker compose -f "$ROOT_DIR/docker-compose.yml" pull landing-fe gitea-fe crashes-fe build-fe mariadb gitea || true diff --git a/orchestration/runtime/db-init/007_remote_access.sql b/orchestration/runtime/db-init/007_remote_access.sql new file mode 100644 index 0000000..782722e --- /dev/null +++ b/orchestration/runtime/db-init/007_remote_access.sql @@ -0,0 +1,56 @@ +-- Remote access control plane (WireGuard v1; reverse SSH reserved). +-- MariaDB / production: mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql + +CREATE TABLE IF NOT EXISTS remote_access_devices ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(128) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + whitelisted TINYINT(1) NOT NULL DEFAULT 0, + opt_in_mode ENUM('none','wireguard','rssh') NOT NULL DEFAULT 'none', + last_random VARCHAR(96) NULL, + app_version VARCHAR(64) NULL, + last_seen_at TIMESTAMP NULL, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_ra_device (device_id), + KEY idx_ra_device_company (company_id), + KEY idx_ra_device_whitelist (whitelisted, opt_in_mode) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS remote_access_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(36) NOT NULL, + device_id VARCHAR(128) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + operator_user_id INT UNSIGNED NULL, + status ENUM('pending','active','inactive','closed','closed_timeout') NOT NULL DEFAULT 'pending', + tunnel ENUM('wireguard','ssh_reverse') NOT NULL DEFAULT 'wireguard', + endpoint VARCHAR(255) NULL, + wg_client_private_key VARCHAR(64) NULL, + wg_client_address VARCHAR(64) NULL, + wg_server_public_key VARCHAR(64) NULL, + wg_peer_allowed_ips VARCHAR(255) NULL, + expires_at INT UNSIGNED NULL, + last_activity_at TIMESTAMP NULL, + close_reason VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP NULL, + UNIQUE KEY uq_ra_session (session_id), + KEY idx_ra_sess_device (device_id, status), + KEY idx_ra_sess_status (status, last_activity_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS remote_access_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(36) NULL, + device_id VARCHAR(128) NOT NULL, + actor_user_id INT UNSIGNED NULL, + action VARCHAR(64) NOT NULL, + reason VARCHAR(255) NULL, + meta_json LONGTEXT NULL, + client_ip VARCHAR(64) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_ra_evt_device (device_id, created_at), + KEY idx_ra_evt_session (session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/orchestration/runtime/db-init/007_remote_access.sqlite.sql b/orchestration/runtime/db-init/007_remote_access.sqlite.sql new file mode 100644 index 0000000..ea95223 --- /dev/null +++ b/orchestration/runtime/db-init/007_remote_access.sqlite.sql @@ -0,0 +1,50 @@ +-- SQLite dev mirror for remote access tables. + +CREATE TABLE IF NOT EXISTS remote_access_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL UNIQUE, + company_id INTEGER NOT NULL DEFAULT 1, + whitelisted INTEGER NOT NULL DEFAULT 0, + opt_in_mode TEXT NOT NULL DEFAULT 'none', + last_random TEXT NULL, + app_version TEXT NULL, + last_seen_at TEXT NULL, + notes TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS remote_access_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + device_id TEXT NOT NULL, + company_id INTEGER NOT NULL DEFAULT 1, + operator_user_id INTEGER NULL, + status TEXT NOT NULL DEFAULT 'pending', + tunnel TEXT NOT NULL DEFAULT 'wireguard', + endpoint TEXT NULL, + wg_client_private_key TEXT NULL, + wg_client_address TEXT NULL, + wg_server_public_key TEXT NULL, + wg_peer_allowed_ips TEXT NULL, + expires_at INTEGER NULL, + last_activity_at TEXT NULL, + close_reason TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + closed_at TEXT NULL +); + +CREATE TABLE IF NOT EXISTS remote_access_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NULL, + device_id TEXT NOT NULL, + actor_user_id INTEGER NULL, + action TEXT NOT NULL, + reason TEXT NULL, + meta_json TEXT NULL, + client_ip TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_ra_evt_device ON remote_access_events(device_id, created_at); +CREATE INDEX IF NOT EXISTS idx_ra_sess_device ON remote_access_sessions(device_id, status); diff --git a/scripts/init-wireguard-submodule.sh b/scripts/init-wireguard-submodule.sh new file mode 100755 index 0000000..bf76e3b --- /dev/null +++ b/scripts/init-wireguard-submodule.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Initialize wireguard-android submodule for embedded tunnel (optional but recommended for production WG). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +if [[ ! -d third-party/wireguard-android/.git ]]; then + git submodule add https://git.zx2c4.com/wireguard-android third-party/wireguard-android || \ + git clone --depth 1 https://git.zx2c4.com/wireguard-android third-party/wireguard-android +fi +git submodule update --init --recursive third-party/wireguard-android +echo "wireguard-android ready under third-party/wireguard-android" +echo "Gradle includes :tunnel when third-party/wireguard-android/tunnel exists." diff --git a/settings.gradle b/settings.gradle index 1c5ad14..406f3e7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,3 +20,9 @@ rootProject.name = "AndroidCast" include ':app' include ':session-studio' project(':session-studio').projectDir = file('desktop/session-studio') + +def wgTunnelDir = file('third-party/wireguard-android/tunnel') +if (wgTunnelDir.exists()) { + include ':tunnel' + project(':tunnel').projectDir = wgTunnelDir +} diff --git a/third-party/README.md b/third-party/README.md index 4eb4930..d544821 100644 --- a/third-party/README.md +++ b/third-party/README.md @@ -1,15 +1,18 @@ -# Third-party native dependencies +# Third-party vendored dependencies -Initialize submodules: +## wireguard-android (optional Gradle module) + +Remote access WireGuard v1 can use the in-app `RemoteAccessVpnService` fallback (TUN only). +For full userspace WireGuard crypto/data plane, initialize the upstream tunnel library: ```bash -git submodule update --init --recursive +bash scripts/init-wireguard-submodule.sh ``` -| Path | Project | Use | -|------|---------|-----| -| `libvpx/` | [webmproject/libvpx](https://github.com/webmproject/libvpx) | VP8/VP9 software codec | -| `opus/` | [xiph/opus](https://github.com/xiph/opus) | Opus audio (in-band FEC) | -| `speex/` | [xiph/speex](https://github.com/xiph/speex) | Speex audio | +Then build with the `:tunnel` module (auto-included when present): -Built via `ndk/CMakeLists.txt` into `libandroidcast_codecs.so`. +- Submodule path: `third-party/wireguard-android/` +- Gradle module: `third-party/wireguard-android/tunnel` +- License: MIT / Apache-2.0 (see upstream `COPYING`) + +`WireGuardTunnelBridge` loads `com.wireguard.android.backend.GoBackend` via reflection when the module is on the classpath.