From f818ec4f0c83fcc7393680aa38689eb960dfb6db Mon Sep 17 00:00:00 2001
From: Anton Afanasyeu
Date: Wed, 3 Jun 2026 06:50:57 +0200
Subject: [PATCH] initial web rssh stage 1
---
app/build.gradle | 5 +
app/src/main/AndroidManifest.xml | 16 +
.../androidcast/AndroidCastApplication.java | 2 +
.../com/foxx/androidcast/AppPreferences.java | 13 +
.../DeveloperSettingsActivity.java | 84 ++++
.../remoteaccess/RemoteAccessCoordinator.java | 40 ++
.../RemoteAccessHeartbeatClient.java | 106 ++++
.../remoteaccess/RemoteAccessMode.java | 44 ++
.../remoteaccess/RemoteAccessService.java | 226 +++++++++
.../remoteaccess/RemoteAccessVpnService.java | 150 ++++++
.../remoteaccess/WireGuardConfigBuilder.java | 40 ++
.../remoteaccess/WireGuardTunnelBridge.java | 64 +++
.../layout/activity_dev_settings_panel.xml | 20 +
app/src/main/res/values/strings.xml | 11 +
.../remoteaccess/RemoteAccessModeTest.java | 37 ++
.../WireGuardConfigBuilderTest.java | 32 ++
.../20260602_REVERSE_SSH_proposals_summary.md | 2 +
docs/ALPHA.md | 2 +
docs/ALPHA_SOAK.md | 2 +
docs/AV_QUALITY_QA_SESSION.md | 2 +
docs/BUILD_DEPLOY.md | 2 +
docs/COMMERCIAL.md | 2 +
docs/CRASH_REPORTER.md | 2 +
docs/GIT_FLOW.md | 2 +
docs/GRAFANA_vs_others_graphvis_pivot.md | 2 +
docs/INFRA.md | 2 +
docs/OPEN_API.md | 2 +
docs/OTA.md | 2 +
docs/PRO_AAR.md | 2 +
docs/README.md | 1 +
docs/REMOTE_ACCESS_IMPL.md | 47 ++
docs/ROADMAP.md | 2 +
docs/TICKETS_ROADMAP.md | 2 +
docs/USB_HDMI_CAST.md | 2 +
examples/app_hub/index.php | 6 +
.../backend/public/api/heartbeat.php | 9 +-
.../backend/public/api/remote_access.php | 105 ++++
.../backend/public/assets/js/remote_access.js | 193 ++++++++
.../crash_reporter/backend/public/index.php | 6 +
.../backend/scripts/test_remote_access_api.sh | 52 ++
.../sql/migrations/007_remote_access.sql | 56 +++
.../migrations/007_remote_access.sqlite.sql | 50 ++
examples/crash_reporter/backend/src/Rbac.php | 7 +
.../backend/src/RemoteAccessRepository.php | 465 ++++++++++++++++++
.../crash_reporter/backend/src/bootstrap.php | 1 +
.../crash_reporter/backend/views/layout.php | 116 +++++
orchestration/deploy.sh | 2 +
.../runtime/db-init/007_remote_access.sql | 56 +++
.../db-init/007_remote_access.sqlite.sql | 50 ++
scripts/init-wireguard-submodule.sh | 12 +
settings.gradle | 6 +
third-party/README.md | 21 +-
52 files changed, 2173 insertions(+), 10 deletions(-)
create mode 100644 app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java
create mode 100644 app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessHeartbeatClient.java
create mode 100644 app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessMode.java
create mode 100644 app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java
create mode 100644 app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnService.java
create mode 100644 app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilder.java
create mode 100644 app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardTunnelBridge.java
create mode 100644 app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessModeTest.java
create mode 100644 app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigBuilderTest.java
create mode 100644 docs/REMOTE_ACCESS_IMPL.md
create mode 100644 examples/crash_reporter/backend/public/api/remote_access.php
create mode 100644 examples/crash_reporter/backend/public/assets/js/remote_access.js
create mode 100755 examples/crash_reporter/backend/scripts/test_remote_access_api.sh
create mode 100644 examples/crash_reporter/backend/sql/migrations/007_remote_access.sql
create mode 100644 examples/crash_reporter/backend/sql/migrations/007_remote_access.sqlite.sql
create mode 100644 examples/crash_reporter/backend/src/RemoteAccessRepository.php
create mode 100644 orchestration/runtime/db-init/007_remote_access.sql
create mode 100644 orchestration/runtime/db-init/007_remote_access.sqlite.sql
create mode 100755 scripts/init-wireguard-submodule.sh
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 extends Enum>) 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
+
diff --git a/examples/crash_reporter/backend/public/api/heartbeat.php b/examples/crash_reporter/backend/public/api/heartbeat.php
index 8e7996e..af2bcae 100644
--- a/examples/crash_reporter/backend/public/api/heartbeat.php
+++ b/examples/crash_reporter/backend/public/api/heartbeat.php
@@ -26,6 +26,7 @@ if (!is_array($heartbeat)) {
$type = (string)($heartbeat['type'] ?? 'generic');
$srvEpoch = time();
$srvTz = date('O');
+$clientIp = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
$resp = [
'heartbeat' => [
@@ -52,7 +53,13 @@ if ($type === 'ntp') {
}
if ($type === 'ip') {
- $resp['heartbeat']['external_ip'] = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
+ $resp['heartbeat']['external_ip'] = $clientIp;
+}
+
+if ($type === 'ra') {
+ require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
+ $ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp);
+ $resp['heartbeat'] = array_merge($resp['heartbeat'], $ra);
}
echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
diff --git a/examples/crash_reporter/backend/public/api/remote_access.php b/examples/crash_reporter/backend/public/api/remote_access.php
new file mode 100644
index 0000000..42f664f
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/remote_access.php
@@ -0,0 +1,105 @@
+ false, 'error' => 'forbidden'], 403);
+}
+
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+$action = trim((string)($_GET['action'] ?? ''));
+
+if ($method === 'GET') {
+ if ($action === 'devices') {
+ json_out(['ok' => true, 'devices' => RemoteAccessRepository::listDevices()]);
+ }
+ if ($action === 'sessions') {
+ $filter = trim((string)($_GET['status'] ?? ''));
+ json_out(['ok' => true, 'sessions' => RemoteAccessRepository::listSessions($filter)]);
+ }
+ if ($action === 'events') {
+ 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)));
+ json_out([
+ 'ok' => true,
+ 'devices' => RemoteAccessRepository::listDevices(),
+ 'active_sessions' => $active,
+ 'inactive_sessions' => array_slice($inactive, 0, 50),
+ 'recent_events' => RemoteAccessRepository::listEvents(30),
+ ]);
+ }
+ json_out(['ok' => false, 'error' => 'unknown_action'], 400);
+}
+
+if ($method !== 'POST') {
+ json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
+}
+
+$raw = file_get_contents('php://input') ?: '';
+$body = json_decode($raw, true);
+if (!is_array($body)) {
+ json_out(['ok' => false, 'error' => 'invalid_json'], 400);
+}
+
+$user = Auth::user();
+$userId = (int)($user['id'] ?? 0);
+
+if ($action === 'whitelist') {
+ if (!Rbac::can('remote_access_admin')) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+ }
+ $deviceId = trim((string)($body['device_id'] ?? ''));
+ if ($deviceId === '') {
+ json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
+ }
+ RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null);
+ RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [
+ 'whitelisted' => !empty($body['whitelisted']),
+ ], '');
+ json_out(['ok' => true]);
+}
+
+if ($action === 'open_session') {
+ if (!Rbac::can('remote_access_operate')) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+ }
+ $deviceId = trim((string)($body['device_id'] ?? ''));
+ if ($deviceId === '') {
+ json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
+ }
+ $result = RemoteAccessRepository::openSession($deviceId, $userId);
+ if (empty($result['ok'])) {
+ json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400);
+ }
+ json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']);
+}
+
+if ($action === 'close_session') {
+ if (!Rbac::can('remote_access_operate')) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+ }
+ $sessionId = trim((string)($body['session_id'] ?? ''));
+ if ($sessionId === '') {
+ json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
+ }
+ RemoteAccessRepository::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed');
+ RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
+ json_out(['ok' => true]);
+}
+
+json_out(['ok' => false, 'error' => 'unknown_action'], 400);
diff --git a/examples/crash_reporter/backend/public/assets/js/remote_access.js b/examples/crash_reporter/backend/public/assets/js/remote_access.js
new file mode 100644
index 0000000..2734a81
--- /dev/null
+++ b/examples/crash_reporter/backend/public/assets/js/remote_access.js
@@ -0,0 +1,193 @@
+(function () {
+ function basePath() {
+ return document.body.getAttribute('data-base-path') || '';
+ }
+
+ function apiUrl(action, params) {
+ const q = new URLSearchParams(params || {});
+ q.set('action', action);
+ return basePath() + '/api/remote_access.php?' + q.toString();
+ }
+
+ function setStatus(msg, isError) {
+ const el = document.getElementById('ra-status');
+ if (!el) return;
+ el.textContent = msg;
+ el.classList.toggle('error', !!isError);
+ }
+
+ async function fetchJson(url, opts) {
+ const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || data.ok === false) {
+ throw new Error(data.error || ('HTTP ' + res.status));
+ }
+ return data;
+ }
+
+ function esc(s) {
+ const d = document.createElement('div');
+ d.textContent = s == null ? '' : String(s);
+ return d.innerHTML;
+ }
+
+ function renderDevices(devices) {
+ const tbody = document.getElementById('ra-devices-tbody');
+ if (!tbody) return;
+ tbody.innerHTML = '';
+ (devices || []).forEach((d) => {
+ const tr = document.createElement('tr');
+ const wl = Number(d.whitelisted) === 1;
+ tr.innerHTML =
+ '' + esc(d.device_id) + ' | ' +
+ '' + esc(d.opt_in_mode || 'none') + ' | ' +
+ '' + (wl ? 'yes' : 'no') + ' | ' +
+ '' + esc(d.last_seen_at || '—') + ' | ' +
+ '' + esc(d.app_version || '—') + ' | ' +
+ '' +
+ ' ' +
+ '' +
+ ' | ';
+ tbody.appendChild(tr);
+ });
+ }
+
+ function renderSessions(active, inactive) {
+ const aBody = document.getElementById('ra-active-tbody');
+ const iBody = document.getElementById('ra-inactive-tbody');
+ if (aBody) {
+ aBody.innerHTML = '';
+ (active || []).forEach((s) => {
+ const tr = document.createElement('tr');
+ tr.innerHTML =
+ '' + esc(s.session_id) + ' | ' +
+ '' + esc(s.device_id) + ' | ' +
+ '' + esc(s.status) + ' | ' +
+ '' + esc(s.tunnel) + ' | ' +
+ '' + esc(s.endpoint || '—') + ' | ' +
+ ' | ';
+ aBody.appendChild(tr);
+ });
+ }
+ if (iBody) {
+ iBody.innerHTML = '';
+ (inactive || []).forEach((s) => {
+ const tr = document.createElement('tr');
+ tr.innerHTML =
+ '' + esc(s.session_id) + ' | ' +
+ '' + esc(s.device_id) + ' | ' +
+ '' + esc(s.status) + ' | ' +
+ '' + esc(s.closed_at || '—') + ' | ' +
+ '' + esc(s.close_reason || '—') + ' | ';
+ iBody.appendChild(tr);
+ });
+ }
+ }
+
+ function renderEvents(events) {
+ const tbody = document.getElementById('ra-events-tbody');
+ if (!tbody) return;
+ tbody.innerHTML = '';
+ (events || []).forEach((e) => {
+ const tr = document.createElement('tr');
+ tr.innerHTML =
+ '' + esc(e.created_at) + ' | ' +
+ '' + esc(e.device_id) + ' | ' +
+ '' + esc(e.action) + ' | ' +
+ '' + esc(e.reason || '—') + ' | ';
+ tbody.appendChild(tr);
+ });
+ }
+
+ async function refresh() {
+ setStatus('Loading…');
+ try {
+ const data = await fetchJson(apiUrl('dashboard'));
+ renderDevices(data.devices);
+ renderSessions(data.active_sessions, data.inactive_sessions);
+ renderEvents(data.recent_events);
+ setStatus('Updated ' + new Date().toLocaleTimeString());
+ } catch (e) {
+ setStatus('Failed: ' + e.message, true);
+ }
+ }
+
+ document.addEventListener('click', async (ev) => {
+ const t = ev.target;
+ if (!(t instanceof HTMLElement)) return;
+ const openId = t.getAttribute('data-open-session');
+ if (openId) {
+ try {
+ await fetchJson(apiUrl('open_session'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ device_id: openId }),
+ });
+ await refresh();
+ } catch (e) {
+ setStatus('Open session: ' + e.message, true);
+ }
+ return;
+ }
+ const closeId = t.getAttribute('data-close-session');
+ if (closeId) {
+ try {
+ await fetchJson(apiUrl('close_session'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ session_id: closeId,
+ device_id: t.getAttribute('data-device') || '',
+ }),
+ });
+ await refresh();
+ } catch (e) {
+ setStatus('Close session: ' + e.message, true);
+ }
+ return;
+ }
+ const wlDevice = t.getAttribute('data-toggle-wl');
+ if (wlDevice) {
+ try {
+ await fetchJson(apiUrl('whitelist'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ device_id: wlDevice,
+ whitelisted: t.getAttribute('data-wl') === '1',
+ }),
+ });
+ await refresh();
+ } catch (e) {
+ setStatus('Whitelist: ' + e.message, true);
+ }
+ }
+ });
+
+ const form = document.getElementById('ra-whitelist-form');
+ if (form) {
+ form.addEventListener('submit', async (ev) => {
+ ev.preventDefault();
+ const fd = new FormData(form);
+ try {
+ await fetchJson(apiUrl('whitelist'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ device_id: fd.get('device_id'),
+ notes: fd.get('notes'),
+ whitelisted: true,
+ }),
+ });
+ form.reset();
+ await refresh();
+ } catch (e) {
+ setStatus('Whitelist add: ' + e.message, true);
+ }
+ });
+ }
+
+ refresh();
+ setInterval(refresh, 60000);
+})();
diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php
index 81b66e7..e2c0d45 100644
--- a/examples/crash_reporter/backend/public/index.php
+++ b/examples/crash_reporter/backend/public/index.php
@@ -95,6 +95,11 @@ if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_uplo
exit;
}
+if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_access.php')) {
+ require __DIR__ . '/api/remote_access.php';
+ exit;
+}
+
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
require __DIR__ . '/api/ticket_attachment.php';
exit;
@@ -197,6 +202,7 @@ $pageTitle = match ($view) {
'home' => 'Home',
'tickets' => 'Tickets',
'graphs' => 'Graphs',
+ 'remote_access' => 'Remote access',
'reports', 'report' => 'Crash reports',
default => 'Console',
};
diff --git a/examples/crash_reporter/backend/scripts/test_remote_access_api.sh b/examples/crash_reporter/backend/scripts/test_remote_access_api.sh
new file mode 100755
index 0000000..861e482
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/test_remote_access_api.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Smoke test remote access API (SQLite dev or deployed BE).
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BASE="${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
+DEVICE_ID="test-ra-device-$(date +%s)"
+COOKIE_JAR="$(mktemp)"
+trap 'rm -f "$COOKIE_JAR"' EXIT
+
+login() {
+ curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
+ -d "username=${ADMIN_USER:-admin}&password=${ADMIN_PASS:-admin}" \
+ "$BASE/login" >/dev/null
+}
+
+echo "== heartbeat ra disable =="
+curl -sf -X POST -H 'Content-Type: application/json' \
+ -d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"disable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r1\"}}" \
+ "$BASE/api/heartbeat.php" | grep -q '"action":"disabled"' && echo OK
+
+echo "== heartbeat ra enable (not whitelisted → wait) =="
+curl -sf -X POST -H 'Content-Type: application/json' \
+ -d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r2\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
+ "$BASE/api/heartbeat.php" | grep -q '"action":"wait"' && echo OK
+
+login
+echo "== whitelist device =="
+curl -sf -b "$COOKIE_JAR" -X POST -H 'Content-Type: application/json' \
+ -d "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" \
+ "$BASE/api/remote_access.php?action=whitelist" | grep -q '"ok":true' && echo OK
+
+echo "== poll enable (whitelisted, no session → wait) =="
+curl -sf -X POST -H 'Content-Type: application/json' \
+ -d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r3\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
+ "$BASE/api/heartbeat.php" | grep -q '"action":"wait"' && echo OK
+
+echo "== open session =="
+curl -sf -b "$COOKIE_JAR" -X POST -H 'Content-Type: application/json' \
+ -d "{\"device_id\":\"$DEVICE_ID\"}" \
+ "$BASE/api/remote_access.php?action=open_session" | grep -q '"ok":true' && echo OK
+
+echo "== poll → connect wireguard =="
+RESP="$(curl -sf -X POST -H 'Content-Type: application/json' \
+ -d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r4\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
+ "$BASE/api/heartbeat.php")"
+echo "$RESP" | grep -q '"action":"connect"' && echo OK
+echo "$RESP" | grep -q 'interface_private_key' && echo OK credentials
+
+echo "== dashboard API =="
+curl -sf -b "$COOKIE_JAR" "$BASE/api/remote_access.php?action=dashboard" | grep -q '"ok":true' && echo OK
+
+echo "All remote access API checks passed."
diff --git a/examples/crash_reporter/backend/sql/migrations/007_remote_access.sql b/examples/crash_reporter/backend/sql/migrations/007_remote_access.sql
new file mode 100644
index 0000000..782722e
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/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/examples/crash_reporter/backend/sql/migrations/007_remote_access.sqlite.sql b/examples/crash_reporter/backend/sql/migrations/007_remote_access.sqlite.sql
new file mode 100644
index 0000000..ea95223
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/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/examples/crash_reporter/backend/src/Rbac.php b/examples/crash_reporter/backend/src/Rbac.php
index 16a079f..cd7303a 100644
--- a/examples/crash_reporter/backend/src/Rbac.php
+++ b/examples/crash_reporter/backend/src/Rbac.php
@@ -30,6 +30,9 @@ final class Rbac {
'manage_company_users',
'manage_company_settings',
'manage_self',
+ 'remote_access_view',
+ 'remote_access_operate',
+ 'remote_access_admin',
],
self::COMPANY_ADMIN => [
'view_reports',
@@ -38,10 +41,14 @@ final class Rbac {
'manage_devices',
'manage_company_users',
'manage_self',
+ 'remote_access_view',
+ 'remote_access_operate',
+ 'remote_access_admin',
],
self::COMPANY_MEMBER => [
'view_reports',
'manage_self',
+ 'remote_access_view',
],
];
diff --git a/examples/crash_reporter/backend/src/RemoteAccessRepository.php b/examples/crash_reporter/backend/src/RemoteAccessRepository.php
new file mode 100644
index 0000000..ffae8f2
--- /dev/null
+++ b/examples/crash_reporter/backend/src/RemoteAccessRepository.php
@@ -0,0 +1,465 @@
+exec($stmt);
+ }
+ }
+ }
+
+ /** @return array heartbeat.ra response payload */
+ public static function handleDeviceHeartbeat(array $heartbeat, string $clientIp): array {
+ self::ensureSchema();
+ $deviceId = trim((string) ($heartbeat['device_id'] ?? ''));
+ if ($deviceId === '') {
+ return self::raEnvelope('deny', ['reason' => 'missing_device_id']);
+ }
+
+ $status = strtolower(trim((string) ($heartbeat['status'] ?? 'enable')));
+ $random = trim((string) ($heartbeat['random'] ?? ''));
+ $appVersion = trim((string) ($heartbeat['app_version'] ?? ''));
+ $capabilities = $heartbeat['capabilities'] ?? [];
+ if (!is_array($capabilities)) {
+ $capabilities = [];
+ }
+ $tunnelMode = self::inferTunnelMode($capabilities, $heartbeat);
+
+ if ($status === 'disable') {
+ return self::handleDisable($deviceId, $random, $clientIp, $appVersion);
+ }
+
+ self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp);
+
+ $device = self::findDevice($deviceId);
+ if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
+ self::logEvent($deviceId, null, null, 'poll_wait', 'not_whitelisted', [
+ 'opt_in' => $tunnelMode,
+ ], $clientIp);
+ return self::raEnvelope('wait', ['reason' => 'not_whitelisted']);
+ }
+
+ if ($tunnelMode === self::OPT_NONE) {
+ return self::raEnvelope('wait', ['reason' => 'opt_in_none']);
+ }
+
+ if ($tunnelMode === self::OPT_RSSH) {
+ return self::raEnvelope('deny', ['reason' => 'rssh_not_implemented']);
+ }
+
+ $session = self::findConnectableSession($deviceId, $random);
+ if ($session === null) {
+ self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp);
+ return self::raEnvelope('wait', ['reason' => 'no_pending_session']);
+ }
+
+ return self::buildConnectResponse($session, $deviceId, $clientIp);
+ }
+
+ /** @param list $capabilities */
+ private static function inferTunnelMode(array $capabilities, array $heartbeat): string {
+ foreach ($capabilities as $cap) {
+ $c = strtolower(trim((string) $cap));
+ if ($c === 'wireguard' || $c === 'wg') {
+ return self::OPT_WIREGUARD;
+ }
+ if ($c === 'ssh_reverse' || $c === 'rssh') {
+ return self::OPT_RSSH;
+ }
+ }
+ $mode = strtolower(trim((string) ($heartbeat['tunnel_mode'] ?? '')));
+ if (in_array($mode, [self::OPT_WIREGUARD, self::OPT_RSSH, self::OPT_NONE], true)) {
+ return $mode;
+ }
+ return self::OPT_WIREGUARD;
+ }
+
+ private static function handleDisable(string $deviceId, string $random, string $clientIp, string $appVersion): array {
+ $pdo = Database::pdo();
+ $now = self::nowSql();
+ $pdo->prepare(
+ 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?'
+ )->execute([self::OPT_NONE, $random, $appVersion, $now, $now, $deviceId]);
+
+ self::closeActiveSessionsForDevice($deviceId, 'user_disabled');
+ self::logEvent($deviceId, null, null, 'user_disable', 'device_opt_out', null, $clientIp);
+
+ return self::raEnvelope('disabled', ['reason' => 'user_opt_out']);
+ }
+
+ private static function upsertDevicePoll(
+ string $deviceId,
+ string $random,
+ string $appVersion,
+ string $tunnelMode,
+ string $clientIp
+ ): void {
+ $pdo = Database::pdo();
+ $now = self::nowSql();
+ $existing = self::findDevice($deviceId);
+ if ($existing) {
+ $pdo->prepare(
+ 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?'
+ )->execute([$tunnelMode, $random, $appVersion, $now, $now, $deviceId]);
+ return;
+ }
+ $pdo->prepare(
+ 'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, opt_in_mode, last_random, app_version, last_seen_at, created_at, updated_at)
+ VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)'
+ )->execute([
+ $deviceId,
+ Rbac::defaultCompanyId(),
+ $tunnelMode,
+ $random,
+ $appVersion,
+ $now,
+ $now,
+ $now,
+ ]);
+ self::logEvent($deviceId, null, null, 'device_seen', 'first_poll', ['opt_in' => $tunnelMode], $clientIp);
+ }
+
+ /** @return array|null */
+ private static function findDevice(string $deviceId): ?array {
+ $st = Database::pdo()->prepare('SELECT * FROM remote_access_devices WHERE device_id = ? LIMIT 1');
+ $st->execute([$deviceId]);
+ $row = $st->fetch(PDO::FETCH_ASSOC);
+ return is_array($row) ? $row : null;
+ }
+
+ /** @return array|null */
+ private static function findConnectableSession(string $deviceId, string $random): ?array {
+ $pdo = Database::pdo();
+ $st = $pdo->prepare(
+ "SELECT * FROM remote_access_sessions
+ WHERE device_id = ? AND status IN ('pending','active')
+ ORDER BY id DESC LIMIT 5"
+ );
+ $st->execute([$deviceId]);
+ $rows = $st->fetchAll(PDO::FETCH_ASSOC);
+ if (!is_array($rows)) {
+ return null;
+ }
+ foreach ($rows as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $exp = (int) ($row['expires_at'] ?? 0);
+ if ($exp > 0 && $exp < time()) {
+ self::closeSession((string) $row['session_id'], self::STATUS_CLOSED_TIMEOUT, 'expired');
+ continue;
+ }
+ return $row;
+ }
+ return null;
+ }
+
+ /** @param array $session */
+ private static function buildConnectResponse(array $session, string $deviceId, string $clientIp): array {
+ $sessionId = (string) ($session['session_id'] ?? '');
+ $tunnel = (string) ($session['tunnel'] ?? 'wireguard');
+ if ($tunnel !== 'wireguard') {
+ return self::raEnvelope('deny', ['reason' => 'tunnel_not_supported', 'tunnel' => $tunnel]);
+ }
+
+ $keys = self::ensureSessionWireGuardKeys($sessionId, $session);
+ $endpoint = (string) ($session['endpoint'] ?? cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820'));
+ $expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));
+
+ self::markSessionActive($sessionId);
+ self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'wireguard', null, $clientIp);
+
+ return self::raEnvelope('connect', [
+ 'session_id' => $sessionId,
+ 'endpoint' => $endpoint,
+ 'tunnel' => 'wireguard',
+ 'credentials' => [
+ 'mode' => 'ephemeral_pubkey',
+ 'interface_private_key' => $keys['client_private'],
+ 'interface_address' => $keys['client_address'],
+ 'peer_public_key' => $keys['server_public'],
+ 'peer_endpoint' => $endpoint,
+ 'peer_allowed_ips' => $keys['allowed_ips'],
+ ],
+ 'expires_at' => $expiresAt,
+ ]);
+ }
+
+ /** @param array $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string} */
+ private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array {
+ $priv = trim((string) ($session['wg_client_private_key'] ?? ''));
+ $addr = trim((string) ($session['wg_client_address'] ?? ''));
+ $pub = trim((string) ($session['wg_server_public_key'] ?? ''));
+ $allowed = trim((string) ($session['wg_peer_allowed_ips'] ?? ''));
+ if ($priv !== '' && $addr !== '' && $pub !== '') {
+ return [
+ 'client_private' => $priv,
+ 'client_address' => $addr,
+ 'server_public' => $pub,
+ 'allowed_ips' => $allowed !== '' ? $allowed : '10.66.66.1/32',
+ ];
+ }
+ $pair = self::generateWireGuardKeyPair();
+ $clientIp = self::allocateClientAddress($sessionId);
+ $serverPub = cfg('remote_access.wg_server_public_key', '');
+ if ($serverPub === '') {
+ $serverPair = self::generateWireGuardKeyPair();
+ $serverPub = $serverPair['public'];
+ }
+ $allowed = '10.66.66.1/32';
+ Database::pdo()->prepare(
+ 'UPDATE remote_access_sessions SET wg_client_private_key = ?, wg_client_address = ?, wg_server_public_key = ?, wg_peer_allowed_ips = ? WHERE session_id = ?'
+ )->execute([$pair['private'], $clientIp, $serverPub, $allowed, $sessionId]);
+
+ return [
+ 'client_private' => $pair['private'],
+ 'client_address' => $clientIp,
+ 'server_public' => $serverPub,
+ 'allowed_ips' => $allowed,
+ ];
+ }
+
+ private static function allocateClientAddress(string $sessionId): string {
+ $n = abs(crc32($sessionId)) % 200 + 2;
+ return '10.66.66.' . $n . '/32';
+ }
+
+ /** @return array{private:string,public:string} */
+ public static function generateWireGuardKeyPair(): array {
+ $priv = trim((string) @shell_exec('wg genkey 2>/dev/null') ?: '');
+ if ($priv !== '') {
+ $pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: '');
+ if ($pub !== '') {
+ return ['private' => $priv, 'public' => $pub];
+ }
+ }
+ $raw = random_bytes(32);
+ $priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
+ return ['private' => $priv, 'public' => 'dev-placeholder-pub-' . substr(hash('sha256', $priv), 0, 43)];
+ }
+
+ private static function markSessionActive(string $sessionId): void {
+ $now = self::nowSql();
+ Database::pdo()->prepare(
+ "UPDATE remote_access_sessions SET status = 'active', last_activity_at = ? WHERE session_id = ?"
+ )->execute([$now, $sessionId]);
+ }
+
+ public static function closeActiveSessionsForDevice(string $deviceId, string $reason): void {
+ $st = Database::pdo()->prepare(
+ "SELECT session_id FROM remote_access_sessions WHERE device_id = ? AND status IN ('pending','active')"
+ );
+ $st->execute([$deviceId]);
+ foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) {
+ self::closeSession((string) $sid, self::STATUS_CLOSED, $reason);
+ }
+ }
+
+ public static function closeSession(string $sessionId, string $status, string $reason): void {
+ $now = self::nowSql();
+ Database::pdo()->prepare(
+ 'UPDATE remote_access_sessions SET status = ?, close_reason = ?, closed_at = ?, last_activity_at = ? WHERE session_id = ?'
+ )->execute([$status, $reason, $now, $now, $sessionId]);
+ }
+
+ /** @return array{ok:bool,session_id?:string} */
+ public static function openSession(string $deviceId, int $operatorUserId, ?int $companyId = null): array {
+ self::ensureSchema();
+ $device = self::findDevice($deviceId);
+ if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
+ return ['ok' => false, 'error' => 'device_not_whitelisted'];
+ }
+ if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) !== self::OPT_WIREGUARD) {
+ return ['ok' => false, 'error' => 'device_not_opted_in_wg'];
+ }
+ self::closeActiveSessionsForDevice($deviceId, 'superseded');
+ $sessionId = self::uuid4();
+ $companyId = $companyId ?? (int) ($device['company_id'] ?? Rbac::defaultCompanyId());
+ $endpoint = (string) cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820');
+ $expiresAt = time() + (int) cfg('remote_access.session_ttl_s', 3600);
+ $now = self::nowSql();
+ Database::pdo()->prepare(
+ 'INSERT INTO remote_access_sessions (session_id, device_id, company_id, operator_user_id, status, tunnel, endpoint, expires_at, last_activity_at, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ )->execute([
+ $sessionId,
+ $deviceId,
+ $companyId,
+ $operatorUserId,
+ self::STATUS_PENDING,
+ 'wireguard',
+ $endpoint,
+ $expiresAt,
+ $now,
+ $now,
+ ]);
+ self::logEvent($deviceId, $sessionId, $operatorUserId, 'session_open', 'operator_requested', null, '');
+ return ['ok' => true, 'session_id' => $sessionId];
+ }
+
+ /** @return list> */
+ public static function listDevices(?int $companyId = null): array {
+ self::ensureSchema();
+ $sql = 'SELECT * FROM remote_access_devices';
+ $params = [];
+ if ($companyId !== null && !Rbac::isGlobalAdmin()) {
+ $sql .= ' WHERE company_id = ?';
+ $params[] = $companyId;
+ }
+ $sql .= ' ORDER BY last_seen_at DESC, device_id ASC';
+ $st = Database::pdo()->prepare($sql);
+ $st->execute($params);
+ return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ /** @return list> */
+ public static function listSessions(string $statusFilter = ''): array {
+ self::ensureSchema();
+ $sql = 'SELECT s.*, d.opt_in_mode, d.app_version AS device_app_version FROM remote_access_sessions s
+ LEFT JOIN remote_access_devices d ON d.device_id = s.device_id';
+ $params = [];
+ if ($statusFilter !== '') {
+ $sql .= ' WHERE s.status = ?';
+ $params[] = $statusFilter;
+ }
+ $sql .= ' ORDER BY s.id DESC LIMIT 200';
+ $st = Database::pdo()->prepare($sql);
+ $st->execute($params);
+ return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ /** @return list> */
+ public static function listEvents(int $limit = 100): array {
+ self::ensureSchema();
+ $st = Database::pdo()->prepare('SELECT * FROM remote_access_events ORDER BY id DESC LIMIT ?');
+ $st->bindValue(1, max(1, min(500, $limit)), PDO::PARAM_INT);
+ $st->execute();
+ return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ public static function setDeviceWhitelist(string $deviceId, bool $whitelisted, ?string $notes = null): bool {
+ self::ensureSchema();
+ $device = self::findDevice($deviceId);
+ if (!$device) {
+ Database::pdo()->prepare(
+ 'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
+ )->execute([
+ $deviceId,
+ Rbac::defaultCompanyId(),
+ $whitelisted ? 1 : 0,
+ $notes,
+ self::nowSql(),
+ self::nowSql(),
+ ]);
+ return true;
+ }
+ Database::pdo()->prepare(
+ 'UPDATE remote_access_devices SET whitelisted = ?, notes = COALESCE(?, notes), updated_at = ? WHERE device_id = ?'
+ )->execute([$whitelisted ? 1 : 0, $notes, self::nowSql(), $deviceId]);
+ return true;
+ }
+
+ public static function purgeStale(int $maxAgeHours = 24): int {
+ self::ensureSchema();
+ $cutoff = time() - max(1, $maxAgeHours) * 3600;
+ $pdo = Database::pdo();
+ if (Database::isMysql()) {
+ $st = $pdo->prepare(
+ "SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active')
+ AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND UNIX_TIMESTAMP(last_activity_at) < ?))"
+ );
+ $st->execute([time(), $cutoff]);
+ } else {
+ $cutoffIso = gmdate('Y-m-d H:i:s', $cutoff);
+ $st = $pdo->prepare(
+ "SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active')
+ AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND last_activity_at < ?))"
+ );
+ $st->execute([time(), $cutoffIso]);
+ }
+ $n = 0;
+ foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) {
+ self::closeSession((string) $sid, self::STATUS_CLOSED_TIMEOUT, 'purge_stale');
+ $n++;
+ }
+ return $n;
+ }
+
+ public static function logEvent(
+ string $deviceId,
+ ?string $sessionId,
+ ?int $actorUserId,
+ string $action,
+ ?string $reason,
+ ?array $meta,
+ string $clientIp
+ ): void {
+ self::ensureSchema();
+ Database::pdo()->prepare(
+ 'INSERT INTO remote_access_events (session_id, device_id, actor_user_id, action, reason, meta_json, client_ip, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
+ )->execute([
+ $sessionId,
+ $deviceId,
+ $actorUserId,
+ $action,
+ $reason,
+ $meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null,
+ $clientIp !== '' ? $clientIp : null,
+ self::nowSql(),
+ ]);
+ }
+
+ /** @param array $extra */
+ private static function raEnvelope(string $action, array $extra = []): array {
+ return array_merge([
+ 'type' => 'ra',
+ 'action' => $action,
+ 'server_date' => time(),
+ ], $extra);
+ }
+
+ private static function nowSql(): string {
+ return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s');
+ }
+
+ private static function uuid4(): string {
+ $b = random_bytes(16);
+ $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
+ $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
+ $h = bin2hex($b);
+ return substr($h, 0, 8) . '-' . substr($h, 8, 4) . '-' . substr($h, 12, 4)
+ . '-' . substr($h, 16, 4) . '-' . substr($h, 20, 12);
+ }
+}
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index 855b1a1..767d72f 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -45,6 +45,7 @@ require_once __DIR__ . '/TicketCommentRepository.php';
require_once __DIR__ . '/UserRepository.php';
require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php';
+require_once __DIR__ . '/RemoteAccessRepository.php';
require_once __DIR__ . '/AnalyticsHead.php';
function cfg(string $key, $default = null) {
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index fa8372a..84a105e 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -34,6 +34,9 @@
+
+
+
Graphs
+
+
+
+
+ Remote access
+
+
+
= h(Auth::basePath()) ?>/api/graphs.php
+
+
+
+
+
Loading…
+
+
+ Whitelisted devices
+
+
+
+
+ | Device ID |
+ Opt-in |
+ Whitelisted |
+ Last seen |
+ App |
+ Actions |
+
+
+
+
+
+
+
+
+
+ Active / pending sessions
+
+
+
+
+ | Session |
+ Device |
+ Status |
+ Tunnel |
+ Endpoint |
+ Actions |
+
+
+
+
+
+
+
+
+ Inactive / closed sessions
+
+
+
+
+ | Session |
+ Device |
+ Status |
+ Closed |
+ Reason |
+
+
+
+
+
+
+
+
+ Audit log
+
+
+
+
+ | Time |
+ Device |
+ Action |
+ Reason |
+
+
+
+
+
+
+
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.