mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
initial web rssh stage 1
This commit is contained in:
@@ -129,4 +129,9 @@ dependencies {
|
|||||||
implementation 'com.google.android.play:review:2.0.2'
|
implementation 'com.google.android.play:review:2.0.2'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
testImplementation 'org.json:json:20240303'
|
||||||
|
|
||||||
|
if (project.findProject(':tunnel') != null) {
|
||||||
|
implementation project(':tunnel')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".AndroidCastApplication"
|
android:name=".AndroidCastApplication"
|
||||||
@@ -127,6 +128,21 @@
|
|||||||
android:value="Persistent status bar icon for Android Cast" />
|
android:value="Persistent status bar icon for Android Cast" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".remoteaccess.RemoteAccessService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".remoteaccess.RemoteAccessVpnService"
|
||||||
|
android:exported="false"
|
||||||
|
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||||
|
android:foregroundServiceType="connectedDevice">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.net.VpnService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".TrayNotificationReceiver"
|
android:name=".TrayNotificationReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.foxx.androidcast.display.WiredDisplayMonitor;
|
|||||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
import com.foxx.androidcast.network.CastTransportFactory;
|
||||||
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||||
|
import com.foxx.androidcast.remoteaccess.RemoteAccessCoordinator;
|
||||||
|
|
||||||
/** Applies saved theme and locale at process start. */
|
/** Applies saved theme and locale at process start. */
|
||||||
public class AndroidCastApplication extends Application {
|
public class AndroidCastApplication extends Application {
|
||||||
@@ -39,5 +40,6 @@ public class AndroidCastApplication extends Application {
|
|||||||
CastTransportFactory.init(this);
|
CastTransportFactory.init(this);
|
||||||
WiredDisplayMonitor.getInstance(this).ensureRegistered();
|
WiredDisplayMonitor.getInstance(this).ensureRegistered();
|
||||||
CommercialFeatures.refreshFromPreferences(this);
|
CommercialFeatures.refreshFromPreferences(this);
|
||||||
|
RemoteAccessCoordinator.onBootIfNeeded(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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_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_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. */
|
/** Minimum interval between automatic OTA checks on app launch. */
|
||||||
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
|
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();
|
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.
|
* Reset user-facing settings shown in Android Cast settings.
|
||||||
* Developer-only settings are intentionally preserved.
|
* Developer-only settings are intentionally preserved.
|
||||||
|
|||||||
@@ -38,12 +38,16 @@ import com.foxx.androidcast.sender.SenderResolutionUiMode;
|
|||||||
import com.foxx.androidcast.dev.DevDiagnosticsController;
|
import com.foxx.androidcast.dev.DevDiagnosticsController;
|
||||||
import com.foxx.androidcast.dev.DevNetworkSelfTestController;
|
import com.foxx.androidcast.dev.DevNetworkSelfTestController;
|
||||||
import com.foxx.androidcast.network.diag.CastDiagPing;
|
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.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||||
import com.google.android.material.tabs.TabLayout;
|
import com.google.android.material.tabs.TabLayout;
|
||||||
import com.google.android.material.textfield.TextInputEditText;
|
import com.google.android.material.textfield.TextInputEditText;
|
||||||
|
|
||||||
/** Hidden developer settings (opened via app-info area easter egg). */
|
/** Hidden developer settings (opened via app-info area easter egg). */
|
||||||
public class DeveloperSettingsActivity extends AppCompatActivity {
|
public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||||
|
private static final int REQUEST_VPN_PREPARE = 9107;
|
||||||
|
|
||||||
private DevDiagnosticsController diagnosticsController;
|
private DevDiagnosticsController diagnosticsController;
|
||||||
private DevNetworkSelfTestController networkSelfTestController;
|
private DevNetworkSelfTestController networkSelfTestController;
|
||||||
private android.view.View settingsPanel;
|
private android.view.View settingsPanel;
|
||||||
@@ -51,6 +55,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
private android.view.View networkSelfTestPanel;
|
private android.view.View networkSelfTestPanel;
|
||||||
private Spinner videoPresetSpinner;
|
private Spinner videoPresetSpinner;
|
||||||
private Spinner audioPresetSpinner;
|
private Spinner audioPresetSpinner;
|
||||||
|
private Spinner remoteAccessSpinner;
|
||||||
|
private RemoteAccessMode pendingRemoteAccessMode;
|
||||||
|
private boolean suppressRemoteAccessSpinnerEvents;
|
||||||
private SeekBar intensitySeek;
|
private SeekBar intensitySeek;
|
||||||
private TextView intensityValue;
|
private TextView intensityValue;
|
||||||
|
|
||||||
@@ -106,6 +113,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bindAvPresets();
|
bindAvPresets();
|
||||||
|
bindRemoteAccessSection();
|
||||||
bindDiagPingSection();
|
bindDiagPingSection();
|
||||||
bindSenderResolutionUiModeSection();
|
bindSenderResolutionUiModeSection();
|
||||||
bindWiredDisplaySection();
|
bindWiredDisplaySection();
|
||||||
@@ -144,11 +152,87 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
findViewById(R.id.edit_ota_manifest_url),
|
findViewById(R.id.edit_ota_manifest_url),
|
||||||
findViewById(R.id.check_ota_on_launch));
|
findViewById(R.id.check_ota_on_launch));
|
||||||
bindAvPresets();
|
bindAvPresets();
|
||||||
|
bindRemoteAccessSection();
|
||||||
bindSenderResolutionUiModeSection();
|
bindSenderResolutionUiModeSection();
|
||||||
bindWiredDisplaySection();
|
bindWiredDisplaySection();
|
||||||
bindCommercialSection();
|
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<String> 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() {
|
private void bindDiagPingSection() {
|
||||||
CheckBox enabled = findViewById(R.id.check_dev_diag_ping_pong);
|
CheckBox enabled = findViewById(R.id.check_dev_diag_ping_pong);
|
||||||
bindDeveloperCheckbox(enabled, AppPreferences.isDevDiagPingPongEnabled(this),
|
bindDeveloperCheckbox(enabled, AppPreferences.isDevDiagPingPongEnabled(this),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,26 @@
|
|||||||
android:text="@string/dev_backend_ntp_sync_hint"
|
android:text="@string/dev_backend_ntp_sync_hint"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/dev_remote_access_mode"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_dev_remote_access_mode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/dev_remote_access_hint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -261,6 +261,17 @@
|
|||||||
<string name="dev_diag_ping_hint">Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets.</string>
|
<string name="dev_diag_ping_hint">Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets.</string>
|
||||||
<string name="dev_backend_ntp_sync_enable">Enable backend NTP sync</string>
|
<string name="dev_backend_ntp_sync_enable">Enable backend NTP sync</string>
|
||||||
<string name="dev_backend_ntp_sync_hint">Disabled by default. Allows self-test/backend heartbeat to fetch server time correction without changing system clock.</string>
|
<string name="dev_backend_ntp_sync_hint">Disabled by default. Allows self-test/backend heartbeat to fetch server time correction without changing system clock.</string>
|
||||||
|
<string name="dev_remote_access_mode">Remote access (on-demand debug)</string>
|
||||||
|
<string name="dev_remote_access_disabled">Disabled</string>
|
||||||
|
<string name="dev_remote_access_wireguard">WireGuard</string>
|
||||||
|
<string name="dev_remote_access_rssh_unavailable">RSSH (reverse SSH — not available yet)</string>
|
||||||
|
<string name="dev_remote_access_hint">When enabled, device polls BE every 1–7 min (type: ra). Disabled sends teardown + BE notification. RSSH is reserved for a future release.</string>
|
||||||
|
<string name="dev_remote_access_rssh_toast">Reverse SSH (RSSH) is not implemented yet.</string>
|
||||||
|
<string name="dev_remote_access_applied">Remote access: %1$s</string>
|
||||||
|
<string name="dev_remote_access_vpn_denied">VPN permission required for WireGuard remote access.</string>
|
||||||
|
<string name="dev_remote_access_notification_channel">Remote access</string>
|
||||||
|
<string name="dev_remote_access_notification_title">Remote debug active</string>
|
||||||
|
<string name="dev_remote_access_notification_poll">Polling BE (%1$s)</string>
|
||||||
<string name="dev_tab_network_self_test">Network self-test</string>
|
<string name="dev_tab_network_self_test">Network self-test</string>
|
||||||
<string name="dev_network_selftest_unlocked">Network self-test tab unlocked</string>
|
<string name="dev_network_selftest_unlocked">Network self-test tab unlocked</string>
|
||||||
<string name="dev_network_selftest_intro">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.</string>
|
<string name="dev_network_selftest_intro">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.</string>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ _Date: 2026-06-02 — **merged document**: initial proposal (rev. 1) + infra rev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Last updated: 2026-05-23. Use this document for **feature freeze**, **alpha buil
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Manual QA on **two devices** on the same Wi‑Fi. Goal: stable show path without
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ Research and recommendations for **movie cast** vs **video cast** quality enhanc
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Ads, in-app purchases, and Play Store helpers are **disabled for normal users**.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ This document defines how we develop, integrate, release, and hotfix. **Cursor a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ _Date: 2026-06-02_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Triage **real device crashes** vs **synthetic / bulk test traffic** when exposin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ MQTT is fully supported for OTA sources using retained topic payloads (`mqtt://.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Design notes for a commercial build without forking the cast protocol. **Not imp
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [ROADMAP.md](ROADMAP.md) | Implementation roadmap, alpha definition, deferred work. |
|
||||||
| [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) | Tickets console, Gitea, attachments, agent queue. |
|
| [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. |
|
| [USB_HDMI_CAST.md](USB_HDMI_CAST.md) | Roadmap D/E: HDMI awareness, USB-tether transport. |
|
||||||
|
|||||||
47
docs/REMOTE_ACCESS_IMPL.md
Normal file
47
docs/REMOTE_ACCESS_IMPL.md
Normal file
@@ -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
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
- [App (developer settings)](#app-developer-settings)
|
||||||
|
- [BE](#be)
|
||||||
|
- [WireGuard third-party](#wireguard-third-party)
|
||||||
|
- [Tests](#tests)
|
||||||
|
<!-- /toc -->
|
||||||
|
|
||||||
|
**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
|
||||||
|
```
|
||||||
@@ -6,6 +6,8 @@ Last updated: 2026-05-23. Living document — refine as alpha and commercial tra
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Android Cast streams over **Wi‑Fi** (UDP/TCP/QUIC). USB-C→HDMI adapters use
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!-- toc -->
|
<!-- toc -->
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ require_once dirname(__DIR__) . '/platform/footer.php';
|
|||||||
</span>
|
</span>
|
||||||
<span class="hub-card-label">AndroidCast graphs</span>
|
<span class="hub-card-label">AndroidCast graphs</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="hub-card card card--lift" href="crashes/?view=remote_access">
|
||||||
|
<span class="hub-card-icon" aria-hidden="true">
|
||||||
|
<span class="nav-icon nav-icon--reports"></span>
|
||||||
|
</span>
|
||||||
|
<span class="hub-card-label">Remote access</span>
|
||||||
|
</a>
|
||||||
<a class="hub-card card card--lift" href="build/">
|
<a class="hub-card card card--lift" href="build/">
|
||||||
<span class="hub-card-icon" aria-hidden="true">
|
<span class="hub-card-icon" aria-hidden="true">
|
||||||
<span class="nav-icon nav-icon--builder"></span>
|
<span class="nav-icon nav-icon--builder"></span>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ if (!is_array($heartbeat)) {
|
|||||||
$type = (string)($heartbeat['type'] ?? 'generic');
|
$type = (string)($heartbeat['type'] ?? 'generic');
|
||||||
$srvEpoch = time();
|
$srvEpoch = time();
|
||||||
$srvTz = date('O');
|
$srvTz = date('O');
|
||||||
|
$clientIp = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||||
|
|
||||||
$resp = [
|
$resp = [
|
||||||
'heartbeat' => [
|
'heartbeat' => [
|
||||||
@@ -52,7 +53,13 @@ if ($type === 'ntp') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($type === 'ip') {
|
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);
|
echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|||||||
105
examples/crash_reporter/backend/public/api/remote_access.php
Normal file
105
examples/crash_reporter/backend/public/api/remote_access.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
Auth::check();
|
||||||
|
|
||||||
|
function json_out(array $payload, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Rbac::can('remote_access_view')) {
|
||||||
|
json_out(['ok' => 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);
|
||||||
@@ -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 =
|
||||||
|
'<td><code>' + esc(d.device_id) + '</code></td>' +
|
||||||
|
'<td>' + esc(d.opt_in_mode || 'none') + '</td>' +
|
||||||
|
'<td>' + (wl ? 'yes' : 'no') + '</td>' +
|
||||||
|
'<td>' + esc(d.last_seen_at || '—') + '</td>' +
|
||||||
|
'<td>' + esc(d.app_version || '—') + '</td>' +
|
||||||
|
'<td>' +
|
||||||
|
'<button type="button" class="btn btn-sm" data-open-session="' + esc(d.device_id) + '">Open session</button> ' +
|
||||||
|
'<button type="button" class="btn btn-sm" data-toggle-wl="' + esc(d.device_id) + '" data-wl="' + (wl ? '0' : '1') + '">' +
|
||||||
|
(wl ? 'Revoke WL' : 'Whitelist') + '</button>' +
|
||||||
|
'</td>';
|
||||||
|
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 =
|
||||||
|
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
||||||
|
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
||||||
|
'<td>' + esc(s.status) + '</td>' +
|
||||||
|
'<td>' + esc(s.tunnel) + '</td>' +
|
||||||
|
'<td>' + esc(s.endpoint || '—') + '</td>' +
|
||||||
|
'<td><button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button></td>';
|
||||||
|
aBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (iBody) {
|
||||||
|
iBody.innerHTML = '';
|
||||||
|
(inactive || []).forEach((s) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
||||||
|
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
||||||
|
'<td>' + esc(s.status) + '</td>' +
|
||||||
|
'<td>' + esc(s.closed_at || '—') + '</td>' +
|
||||||
|
'<td>' + esc(s.close_reason || '—') + '</td>';
|
||||||
|
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 =
|
||||||
|
'<td>' + esc(e.created_at) + '</td>' +
|
||||||
|
'<td><code>' + esc(e.device_id) + '</code></td>' +
|
||||||
|
'<td>' + esc(e.action) + '</td>' +
|
||||||
|
'<td>' + esc(e.reason || '—') + '</td>';
|
||||||
|
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);
|
||||||
|
})();
|
||||||
@@ -95,6 +95,11 @@ if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_uplo
|
|||||||
exit;
|
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')) {
|
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
|
||||||
require __DIR__ . '/api/ticket_attachment.php';
|
require __DIR__ . '/api/ticket_attachment.php';
|
||||||
exit;
|
exit;
|
||||||
@@ -197,6 +202,7 @@ $pageTitle = match ($view) {
|
|||||||
'home' => 'Home',
|
'home' => 'Home',
|
||||||
'tickets' => 'Tickets',
|
'tickets' => 'Tickets',
|
||||||
'graphs' => 'Graphs',
|
'graphs' => 'Graphs',
|
||||||
|
'remote_access' => 'Remote access',
|
||||||
'reports', 'report' => 'Crash reports',
|
'reports', 'report' => 'Crash reports',
|
||||||
default => 'Console',
|
default => 'Console',
|
||||||
};
|
};
|
||||||
|
|||||||
52
examples/crash_reporter/backend/scripts/test_remote_access_api.sh
Executable file
52
examples/crash_reporter/backend/scripts/test_remote_access_api.sh
Executable file
@@ -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."
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -30,6 +30,9 @@ final class Rbac {
|
|||||||
'manage_company_users',
|
'manage_company_users',
|
||||||
'manage_company_settings',
|
'manage_company_settings',
|
||||||
'manage_self',
|
'manage_self',
|
||||||
|
'remote_access_view',
|
||||||
|
'remote_access_operate',
|
||||||
|
'remote_access_admin',
|
||||||
],
|
],
|
||||||
self::COMPANY_ADMIN => [
|
self::COMPANY_ADMIN => [
|
||||||
'view_reports',
|
'view_reports',
|
||||||
@@ -38,10 +41,14 @@ final class Rbac {
|
|||||||
'manage_devices',
|
'manage_devices',
|
||||||
'manage_company_users',
|
'manage_company_users',
|
||||||
'manage_self',
|
'manage_self',
|
||||||
|
'remote_access_view',
|
||||||
|
'remote_access_operate',
|
||||||
|
'remote_access_admin',
|
||||||
],
|
],
|
||||||
self::COMPANY_MEMBER => [
|
self::COMPANY_MEMBER => [
|
||||||
'view_reports',
|
'view_reports',
|
||||||
'manage_self',
|
'manage_self',
|
||||||
|
'remote_access_view',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
465
examples/crash_reporter/backend/src/RemoteAccessRepository.php
Normal file
465
examples/crash_reporter/backend/src/RemoteAccessRepository.php
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-demand remote access control plane (heartbeat type: ra).
|
||||||
|
* v1: WireGuard opt-in from device; reverse SSH (RSSH) reserved for future use.
|
||||||
|
*/
|
||||||
|
final class RemoteAccessRepository {
|
||||||
|
public const OPT_NONE = 'none';
|
||||||
|
public const OPT_WIREGUARD = 'wireguard';
|
||||||
|
public const OPT_RSSH = 'rssh';
|
||||||
|
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
public const STATUS_INACTIVE = 'inactive';
|
||||||
|
public const STATUS_CLOSED = 'closed';
|
||||||
|
public const STATUS_CLOSED_TIMEOUT = 'closed_timeout';
|
||||||
|
|
||||||
|
public static function ensureSchema(): void {
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
if (Database::tableExists($pdo, 'remote_access_devices')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::createTables($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function createTables(PDO $pdo): void {
|
||||||
|
$path = Database::isMysql()
|
||||||
|
? __DIR__ . '/../sql/migrations/007_remote_access.sql'
|
||||||
|
: __DIR__ . '/../sql/migrations/007_remote_access.sqlite.sql';
|
||||||
|
if (!is_readable($path)) {
|
||||||
|
throw new RuntimeException('remote access migration missing: ' . $path);
|
||||||
|
}
|
||||||
|
$sql = (string) file_get_contents($path);
|
||||||
|
foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) {
|
||||||
|
if ($stmt !== '') {
|
||||||
|
$pdo->exec($stmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> 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<string> $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<string, mixed>|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<string, mixed>|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<string, mixed> $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<string, mixed> $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<array<string, mixed>> */
|
||||||
|
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<array<string, mixed>> */
|
||||||
|
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<array<string, mixed>> */
|
||||||
|
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<string, mixed> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ require_once __DIR__ . '/TicketCommentRepository.php';
|
|||||||
require_once __DIR__ . '/UserRepository.php';
|
require_once __DIR__ . '/UserRepository.php';
|
||||||
require_once __DIR__ . '/TicketRepository.php';
|
require_once __DIR__ . '/TicketRepository.php';
|
||||||
require_once __DIR__ . '/GraphRepository.php';
|
require_once __DIR__ . '/GraphRepository.php';
|
||||||
|
require_once __DIR__ . '/RemoteAccessRepository.php';
|
||||||
require_once __DIR__ . '/AnalyticsHead.php';
|
require_once __DIR__ . '/AnalyticsHead.php';
|
||||||
|
|
||||||
function cfg(string $key, $default = null) {
|
function cfg(string $key, $default = null) {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
<?php if (($view ?? '') === 'graphs'): ?>
|
<?php if (($view ?? '') === 'graphs'): ?>
|
||||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (($view ?? '') === 'remote_access'): ?>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
<?php AnalyticsHead::render('crashes'); ?>
|
<?php AnalyticsHead::render('crashes'); ?>
|
||||||
</head>
|
</head>
|
||||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||||
@@ -87,6 +90,17 @@
|
|||||||
<span class="nav-label">Graphs</span>
|
<span class="nav-label">Graphs</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<?php if (Rbac::can('remote_access_view')): ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=remote_access"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'remote_access' ? 'active' : '' ?>"
|
||||||
|
aria-label="Remote access"
|
||||||
|
title="Remote access">
|
||||||
|
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
|
||||||
|
<span class="nav-label">Remote access</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
<li>
|
<li>
|
||||||
<a href="/app/androidcast_project/build/"
|
<a href="/app/androidcast_project/build/"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
@@ -320,6 +334,108 @@
|
|||||||
<code><?= h(Auth::basePath()) ?>/api/graphs.php</code>
|
<code><?= h(Auth::basePath()) ?>/api/graphs.php</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<?php elseif (($view ?? '') === 'remote_access'): ?>
|
||||||
|
<div id="remote-access-app" class="reports-app">
|
||||||
|
<div class="toolbar reports-toolbar">
|
||||||
|
<h1>Remote access</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span>Theme</span>
|
||||||
|
<select id="theme-select" aria-label="UI theme">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="graphs-quick-links" aria-label="Related services">
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=home">Console home</a>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=reports">Crash reports</a>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=tickets">Tickets</a>
|
||||||
|
<a href="/app/androidcast_project/graphs/">Graphs</a>
|
||||||
|
<a href="/app/androidcast_project/build/">Builder</a>
|
||||||
|
<a href="/app/androidcast_project/">Hub</a>
|
||||||
|
</nav>
|
||||||
|
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Whitelisted devices</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="ra-devices-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Device ID</th>
|
||||||
|
<th scope="col">Opt-in</th>
|
||||||
|
<th scope="col">Whitelisted</th>
|
||||||
|
<th scope="col">Last seen</th>
|
||||||
|
<th scope="col">App</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ra-devices-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<form id="ra-whitelist-form" class="tag-editor-add" style="margin-top:1rem">
|
||||||
|
<label class="tag-field"><span>Device ID</span><input type="text" name="device_id" required maxlength="128"></label>
|
||||||
|
<label class="tag-field"><span>Notes</span><input type="text" name="notes" maxlength="255"></label>
|
||||||
|
<button type="submit" class="btn">Add / whitelist</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Active / pending sessions</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="ra-active-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Session</th>
|
||||||
|
<th scope="col">Device</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Tunnel</th>
|
||||||
|
<th scope="col">Endpoint</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ra-active-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Inactive / closed sessions</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="ra-inactive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Session</th>
|
||||||
|
<th scope="col">Device</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Closed</th>
|
||||||
|
<th scope="col">Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ra-inactive-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Audit log</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="ra-events-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Device</th>
|
||||||
|
<th scope="col">Action</th>
|
||||||
|
<th scope="col">Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ra-events-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ return [
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
SRC_MIG_070="$ROOT_DIR/runtime/db-init/070_graph_ecosystem.sql"
|
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() {
|
write_migration_with_db() {
|
||||||
local src="$1"
|
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_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_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_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..."
|
echo "Pulling images..."
|
||||||
docker compose -f "$ROOT_DIR/docker-compose.yml" pull landing-fe gitea-fe crashes-fe build-fe mariadb gitea || true
|
docker compose -f "$ROOT_DIR/docker-compose.yml" pull landing-fe gitea-fe crashes-fe build-fe mariadb gitea || true
|
||||||
|
|||||||
56
orchestration/runtime/db-init/007_remote_access.sql
Normal file
56
orchestration/runtime/db-init/007_remote_access.sql
Normal file
@@ -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;
|
||||||
50
orchestration/runtime/db-init/007_remote_access.sqlite.sql
Normal file
50
orchestration/runtime/db-init/007_remote_access.sqlite.sql
Normal file
@@ -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);
|
||||||
12
scripts/init-wireguard-submodule.sh
Executable file
12
scripts/init-wireguard-submodule.sh
Executable file
@@ -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."
|
||||||
@@ -20,3 +20,9 @@ rootProject.name = "AndroidCast"
|
|||||||
include ':app'
|
include ':app'
|
||||||
include ':session-studio'
|
include ':session-studio'
|
||||||
project(':session-studio').projectDir = file('desktop/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
|
||||||
|
}
|
||||||
|
|||||||
21
third-party/README.md
vendored
21
third-party/README.md
vendored
@@ -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
|
```bash
|
||||||
git submodule update --init --recursive
|
bash scripts/init-wireguard-submodule.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
| Path | Project | Use |
|
Then build with the `:tunnel` module (auto-included when present):
|
||||||
|------|---------|-----|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user