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