mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:57:40 +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: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). -->
|
||||
<service
|
||||
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_SESSION_ID = "dev_remote_access_session_id";
|
||||
private static final String KEY_DEV_REMOTE_ACCESS_EXPIRES_AT = "dev_remote_access_expires_at";
|
||||
private static final String KEY_DEV_REMOTE_ACCESS_VPN_ROUTE_SCOPE = "dev_remote_access_vpn_route_scope";
|
||||
private static final String KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE = "dev_remote_access_vpn_app_scope";
|
||||
private static final String KEY_DEV_ADB_WIFI_KEEPER = "dev_adb_wifi_keeper";
|
||||
|
||||
/** Minimum interval between automatic OTA checks on app launch. */
|
||||
@@ -528,6 +530,40 @@ public final class AppPreferences {
|
||||
return prefs(context).getLong(KEY_DEV_REMOTE_ACCESS_EXPIRES_AT, 0L);
|
||||
}
|
||||
|
||||
public static com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope getDevRemoteAccessVpnRouteScope(
|
||||
Context context) {
|
||||
return com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope.fromStored(
|
||||
prefs(context).getString(
|
||||
KEY_DEV_REMOTE_ACCESS_VPN_ROUTE_SCOPE,
|
||||
com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope.HUB_ONLY.name()));
|
||||
}
|
||||
|
||||
public static void setDevRemoteAccessVpnRouteScope(
|
||||
Context context,
|
||||
com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope scope) {
|
||||
if (scope == null) {
|
||||
scope = com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.RouteScope.HUB_ONLY;
|
||||
}
|
||||
prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_VPN_ROUTE_SCOPE, scope.name()).apply();
|
||||
}
|
||||
|
||||
public static com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope getDevRemoteAccessVpnAppScope(
|
||||
Context context) {
|
||||
return com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope.fromStored(
|
||||
prefs(context).getString(
|
||||
KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE,
|
||||
com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope.ALL_APPS.name()));
|
||||
}
|
||||
|
||||
public static void setDevRemoteAccessVpnAppScope(
|
||||
Context context,
|
||||
com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope scope) {
|
||||
if (scope == null) {
|
||||
scope = com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting.AppScope.ALL_APPS;
|
||||
}
|
||||
prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE, scope.name()).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user-facing settings shown in Android Cast settings.
|
||||
* Developer-only settings are intentionally preserved.
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.foxx.androidcast.dev.DevNetworkSelfTestController;
|
||||
import com.foxx.androidcast.network.diag.CastDiagPing;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessCoordinator;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
|
||||
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
@@ -59,6 +60,10 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
private Spinner videoPresetSpinner;
|
||||
private Spinner audioPresetSpinner;
|
||||
private Spinner remoteAccessSpinner;
|
||||
private Spinner remoteAccessVpnRouteSpinner;
|
||||
private Spinner remoteAccessVpnAppSpinner;
|
||||
private android.view.View remoteAccessVpnRouteLabel;
|
||||
private android.view.View remoteAccessVpnAppLabel;
|
||||
private RemoteAccessMode pendingRemoteAccessMode;
|
||||
private boolean suppressRemoteAccessSpinnerEvents;
|
||||
private SeekBar intensitySeek;
|
||||
@@ -253,6 +258,87 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
@Override
|
||||
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
|
||||
});
|
||||
bindRemoteAccessVpnRoutingSection();
|
||||
}
|
||||
|
||||
private void bindRemoteAccessVpnRoutingSection() {
|
||||
remoteAccessVpnRouteLabel = findViewById(R.id.label_dev_remote_access_vpn_route);
|
||||
remoteAccessVpnAppLabel = findViewById(R.id.label_dev_remote_access_vpn_app);
|
||||
remoteAccessVpnRouteSpinner = findViewById(R.id.spinner_dev_remote_access_vpn_route);
|
||||
remoteAccessVpnAppSpinner = findViewById(R.id.spinner_dev_remote_access_vpn_app_scope);
|
||||
if (remoteAccessVpnRouteSpinner == null || remoteAccessVpnAppSpinner == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final RemoteAccessVpnRouting.RouteScope[] routeScopes = RemoteAccessVpnRouting.RouteScope.values();
|
||||
String[] routeLabels = {
|
||||
getString(R.string.dev_remote_access_vpn_route_hub_only),
|
||||
getString(R.string.dev_remote_access_vpn_route_full_tunnel),
|
||||
};
|
||||
ArrayAdapter<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) {
|
||||
@@ -266,6 +352,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
}
|
||||
AppPreferences.setDevRemoteAccessMode(this, mode);
|
||||
RemoteAccessCoordinator.applyMode(this, mode);
|
||||
updateRemoteAccessVpnRoutingEnabled();
|
||||
Toast.makeText(this, getString(R.string.dev_remote_access_applied, mode.name()), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@@ -278,6 +365,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
if (resultCode == RESULT_OK) {
|
||||
AppPreferences.setDevRemoteAccessMode(this, mode);
|
||||
RemoteAccessCoordinator.applyMode(this, mode);
|
||||
updateRemoteAccessVpnRoutingEnabled();
|
||||
} else {
|
||||
bindRemoteAccessSection();
|
||||
Toast.makeText(this, R.string.dev_remote_access_vpn_denied, Toast.LENGTH_LONG).show();
|
||||
|
||||
@@ -121,7 +121,8 @@ public final class CrashReporter {
|
||||
if (procName == null || procName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return procName.endsWith(":vpn") || procName.endsWith(":netselftest");
|
||||
return procName.endsWith(":vpn") || procName.endsWith(":netselftest")
|
||||
|| procName.endsWith(":ra_control");
|
||||
}
|
||||
|
||||
public static String currentProcessName() {
|
||||
|
||||
@@ -27,6 +27,20 @@ public final class DevAdbConnectInfo {
|
||||
public final boolean secureSettingsGranted;
|
||||
public final int legacyPort;
|
||||
public final long updatedMs;
|
||||
public final RaControlEndpoint raControl;
|
||||
|
||||
/** Live RA control HTTP endpoint (random port per session). */
|
||||
public static final class RaControlEndpoint {
|
||||
public final int port;
|
||||
public final String token;
|
||||
public final List<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(
|
||||
List<String> ips,
|
||||
@@ -36,6 +50,18 @@ public final class DevAdbConnectInfo {
|
||||
boolean secureSettingsGranted,
|
||||
int legacyPort,
|
||||
long updatedMs) {
|
||||
this(ips, connect, adbEnabled, adbWifiEnabled, secureSettingsGranted, legacyPort, updatedMs, null);
|
||||
}
|
||||
|
||||
public DevAdbConnectInfo(
|
||||
List<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.connect = connect != null ? connect : Collections.emptyList();
|
||||
this.adbEnabled = adbEnabled;
|
||||
@@ -43,6 +69,7 @@ public final class DevAdbConnectInfo {
|
||||
this.secureSettingsGranted = secureSettingsGranted;
|
||||
this.legacyPort = legacyPort;
|
||||
this.updatedMs = updatedMs;
|
||||
this.raControl = raControl;
|
||||
}
|
||||
|
||||
public String primaryConnectLine() {
|
||||
@@ -73,6 +100,25 @@ public final class DevAdbConnectInfo {
|
||||
root.put("commands", commands);
|
||||
root.put("hint", "Grant WRITE_SECURE_SETTINGS once via USB; use tls_port "
|
||||
+ "(not 5555) on Android 11+. Host: avahi-browse -rpt _adb-tls-connect._tcp");
|
||||
if (raControl != null && raControl.port > 0) {
|
||||
JSONObject ra = new JSONObject();
|
||||
ra.put("port", raControl.port);
|
||||
ra.put("token", raControl.token);
|
||||
JSONArray raUrls = new JSONArray();
|
||||
for (String ip : ips) {
|
||||
if (!TextUtils.isEmpty(ip)) {
|
||||
raUrls.put("http://" + ip + ":" + raControl.port + "/?token=" + raControl.token);
|
||||
}
|
||||
}
|
||||
raUrls.put("http://127.0.0.1:" + raControl.port + "/?token=" + raControl.token);
|
||||
for (String url : raControl.baseUrls) {
|
||||
if (!TextUtils.isEmpty(url)) {
|
||||
raUrls.put(url);
|
||||
}
|
||||
}
|
||||
ra.put("urls", raUrls);
|
||||
root.put("ra_control", ra);
|
||||
}
|
||||
return root.toString(2);
|
||||
} catch (JSONException e) {
|
||||
return "{}";
|
||||
|
||||
@@ -12,6 +12,8 @@ import android.provider.Settings;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.reflect.Method;
|
||||
@@ -58,6 +60,7 @@ public final class DevAdbWifiHelper {
|
||||
|
||||
List<String> dedupedConnect = orderConnectEndpoints(dedupe(connect), ports);
|
||||
long now = System.currentTimeMillis();
|
||||
DevAdbConnectInfo.RaControlEndpoint raControl = RemoteAccessControlClient.snapshotForAdb(app);
|
||||
return new DevAdbConnectInfo(
|
||||
ips,
|
||||
dedupedConnect,
|
||||
@@ -65,7 +68,8 @@ public final class DevAdbWifiHelper {
|
||||
adbWifiEnabled,
|
||||
secure,
|
||||
DevAdbConnectInfo.LEGACY_TCP_PORT,
|
||||
now);
|
||||
now,
|
||||
raControl);
|
||||
}
|
||||
|
||||
private static void enableDeveloperAdb(Context context) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.foxx.androidcast.crash.CrashReporter;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessFailureReason;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
|
||||
import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge;
|
||||
import com.foxx.androidcast.vpn.VpnServiceClient;
|
||||
import com.foxx.androidcast.vpn.VpnState;
|
||||
@@ -42,6 +43,17 @@ public final class DevVpnStatusProbe {
|
||||
String typeLabel = mode == RemoteAccessMode.DISABLED ? "off" : mode.name().toLowerCase(Locale.US);
|
||||
r.itemPass("type", typeLabel);
|
||||
|
||||
if (mode != RemoteAccessMode.DISABLED) {
|
||||
r.itemPass(
|
||||
"vpn-route-scope",
|
||||
RemoteAccessVpnRouting.routeScopeLabel(
|
||||
AppPreferences.getDevRemoteAccessVpnRouteScope(context)));
|
||||
r.itemPass(
|
||||
"vpn-app-scope",
|
||||
RemoteAccessVpnRouting.appScopeLabel(
|
||||
AppPreferences.getDevRemoteAccessVpnAppScope(context)));
|
||||
}
|
||||
|
||||
String vpnDetail = "";
|
||||
if (mainProcess && mode == RemoteAccessMode.WIREGUARD) {
|
||||
vpnDetail = VpnServiceClient.get(context).getStateDetailIfBound();
|
||||
|
||||
@@ -47,4 +47,16 @@ public final class RemoteAccessCoordinator {
|
||||
RemoteAccessService.start(context.getApplicationContext(), mode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnect WireGuard when dev routing prefs change while mode is active. */
|
||||
public static void onVpnRoutingPreferenceChanged(Context context) {
|
||||
Context app = context.getApplicationContext();
|
||||
RemoteAccessMode mode = AppPreferences.getDevRemoteAccessMode(app);
|
||||
if (mode != RemoteAccessMode.WIREGUARD) {
|
||||
return;
|
||||
}
|
||||
Log.i(TAG, "VPN routing preference changed — reconnecting tunnel");
|
||||
VpnServiceClient.get(app).tearDown();
|
||||
RemoteAccessService.start(app, RemoteAccessMode.WIREGUARD);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.DeveloperSettingsActivity;
|
||||
import com.foxx.androidcast.DeviceInfo;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient;
|
||||
import com.foxx.androidcast.vpn.VpnServiceClient;
|
||||
|
||||
import java.net.Inet4Address;
|
||||
@@ -140,6 +141,10 @@ public final class RemoteAccessService extends Service {
|
||||
hb.put("app_version", com.foxx.androidcast.BuildConfig.VERSION_NAME);
|
||||
hb.put("device", buildDeviceMeta(this));
|
||||
hb.put("tunnel_mode", mode.toApiValue());
|
||||
hb.put("vpn_route_scope", RemoteAccessVpnRouting.routeScopeLabel(
|
||||
AppPreferences.getDevRemoteAccessVpnRouteScope(this)));
|
||||
hb.put("vpn_app_scope", RemoteAccessVpnRouting.appScopeLabel(
|
||||
AppPreferences.getDevRemoteAccessVpnAppScope(this)));
|
||||
JSONArray caps = new JSONArray();
|
||||
if (mode == RemoteAccessMode.WIREGUARD) {
|
||||
caps.put("wireguard");
|
||||
@@ -219,7 +224,7 @@ public final class RemoteAccessService extends Service {
|
||||
return;
|
||||
}
|
||||
if ("wireguard".equals(tunnel)) {
|
||||
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
|
||||
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(this, creds);
|
||||
if (wgConfig.isEmpty()) {
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
@@ -238,6 +243,7 @@ public final class RemoteAccessService extends Service {
|
||||
@Override
|
||||
public void onSuccess() {
|
||||
updateNotificationConnected(sid, tunnel);
|
||||
startRemoteControlPlane(sid);
|
||||
RemoteAccessStatusStore.record(
|
||||
RemoteAccessService.this, mode, RemoteAccessStatusStore.STATE_SUCCESS,
|
||||
"VpnServiceClient.applyWireGuardConfig",
|
||||
@@ -263,6 +269,7 @@ public final class RemoteAccessService extends Service {
|
||||
"", sessionId);
|
||||
if (ReverseSshTunnelBridge.applyConfig(this, creds)) {
|
||||
updateNotificationConnected(sessionId, tunnel);
|
||||
startRemoteControlPlane(sessionId);
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_SUCCESS,
|
||||
"ReverseSshTunnelBridge.applyConfig",
|
||||
@@ -285,6 +292,22 @@ public final class RemoteAccessService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private void startRemoteControlPlane(String sessionId) {
|
||||
RemoteAccessControlClient.get(this).startSession(new RemoteAccessControlClient.OperationListener() {
|
||||
@Override
|
||||
public void onSuccess() {
|
||||
RemoteAccessControlClient.EndpointSnapshot snap =
|
||||
RemoteAccessControlClient.snapshot(RemoteAccessService.this);
|
||||
Log.i(TAG, "RA control HTTP on :" + snap.port + " session=" + sessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String reason) {
|
||||
Log.w(TAG, "RA control HTTP failed: " + reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateNotificationConnected(String sessionId, String tunnel) {
|
||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||
if (nm == null) {
|
||||
|
||||
@@ -2,13 +2,15 @@ package com.foxx.androidcast.remoteaccess;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient;
|
||||
import com.foxx.androidcast.vpn.VpnServiceClient;
|
||||
|
||||
/** Tear down all remote-access transports (WireGuard + RSSH). */
|
||||
/** Tear down all remote-access transports (WireGuard + RSSH + control HTTP). */
|
||||
public final class RemoteAccessTunnelHelper {
|
||||
private RemoteAccessTunnelHelper() {}
|
||||
|
||||
public static void tearDownAll(Context context) {
|
||||
RemoteAccessControlClient.get(context).stopSessionSync();
|
||||
VpnServiceClient.get(context).tearDown();
|
||||
ReverseSshTunnelBridge.tearDown(context);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** Build wireguard-quick style config from BE connect credentials. */
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
|
||||
/** Build wireguard-quick style config from BE connect credentials + dev VPN routing prefs. */
|
||||
public final class WireGuardConfigBuilder {
|
||||
private static final String TAG = "WireGuardCfg";
|
||||
|
||||
private WireGuardConfigBuilder() {}
|
||||
|
||||
public static String fromConnectCredentials(JSONObject credentials) {
|
||||
public static String fromConnectCredentials(Context context, JSONObject credentials) {
|
||||
if (context == null) {
|
||||
return fromConnectCredentials(
|
||||
credentials,
|
||||
RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
|
||||
RemoteAccessVpnRouting.AppScope.ALL_APPS,
|
||||
null);
|
||||
}
|
||||
return fromConnectCredentials(
|
||||
credentials,
|
||||
AppPreferences.getDevRemoteAccessVpnRouteScope(context),
|
||||
AppPreferences.getDevRemoteAccessVpnAppScope(context),
|
||||
context.getPackageName());
|
||||
}
|
||||
|
||||
public static String fromConnectCredentials(
|
||||
JSONObject credentials,
|
||||
RemoteAccessVpnRouting.RouteScope routeScope,
|
||||
RemoteAccessVpnRouting.AppScope appScope,
|
||||
String packageName) {
|
||||
if (credentials == null) {
|
||||
return "";
|
||||
}
|
||||
String priv = credentials.optString("interface_private_key", "");
|
||||
String addr = credentials.optString("interface_address", "10.66.66.2/32");
|
||||
String addr = credentials.optString("interface_address", "172.200.2.10/32");
|
||||
String peerPub = credentials.optString("peer_public_key", "");
|
||||
String endpoint = credentials.optString("peer_endpoint", "");
|
||||
String allowed = credentials.optString("peer_allowed_ips", "0.0.0.0/0");
|
||||
String beAllowed = credentials.optString("peer_allowed_ips", "");
|
||||
if (priv.isEmpty() || peerPub.isEmpty()) {
|
||||
Log.w(TAG, "missing wg keys in credentials");
|
||||
return "";
|
||||
}
|
||||
if (routeScope == null) {
|
||||
routeScope = RemoteAccessVpnRouting.RouteScope.HUB_ONLY;
|
||||
}
|
||||
if (appScope == null) {
|
||||
appScope = RemoteAccessVpnRouting.AppScope.ALL_APPS;
|
||||
}
|
||||
String allowed = RemoteAccessVpnRouting.resolveAllowedIps(routeScope, beAllowed);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("[Interface]\n");
|
||||
sb.append("PrivateKey = ").append(priv).append('\n');
|
||||
sb.append("Address = ").append(addr).append('\n');
|
||||
RemoteAccessVpnRouting.appendInterfaceExtras(sb, appScope, packageName);
|
||||
sb.append('\n');
|
||||
sb.append("[Peer]\n");
|
||||
sb.append("PublicKey = ").append(peerPub).append('\n');
|
||||
|
||||
@@ -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.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.VpnService;
|
||||
import android.os.Build;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
@@ -16,11 +17,9 @@ import androidx.core.app.NotificationCompat;
|
||||
|
||||
import com.foxx.androidcast.DeveloperSettingsActivity;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.remoteaccess.WireGuardQuickConfig;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** TUN fallback when GoBackend cannot start — runs in :vpn process only. */
|
||||
public final class RemoteAccessVpnService extends VpnService {
|
||||
@@ -79,7 +78,7 @@ public final class RemoteAccessVpnService extends VpnService {
|
||||
private void establishTun(String wgConfig) {
|
||||
tearDown();
|
||||
try {
|
||||
String address = parseAddress(wgConfig);
|
||||
String address = WireGuardQuickConfig.parseAddress(wgConfig);
|
||||
Builder b = new Builder();
|
||||
b.setSession("AndroidCast RA");
|
||||
if (address != null) {
|
||||
@@ -87,7 +86,14 @@ public final class RemoteAccessVpnService extends VpnService {
|
||||
b.addAddress(InetAddress.getByName(parts[0]),
|
||||
parts.length > 1 ? Integer.parseInt(parts[1]) : 32);
|
||||
}
|
||||
for (String route : parseAllowedRoutes(wgConfig)) {
|
||||
for (String app : WireGuardQuickConfig.parseIncludedApplications(wgConfig)) {
|
||||
try {
|
||||
b.addAllowedApplication(app);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
Log.w(TAG, "IncludedApplications skip unknown package: " + app);
|
||||
}
|
||||
}
|
||||
for (String route : WireGuardQuickConfig.parseAllowedRoutes(wgConfig)) {
|
||||
String[] rp = route.split("/");
|
||||
if (rp.length >= 2) {
|
||||
b.addRoute(rp[0], Integer.parseInt(rp[1]));
|
||||
@@ -101,32 +107,6 @@ public final class RemoteAccessVpnService extends VpnService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String parseAddress(String wgConfig) {
|
||||
if (wgConfig == null) {
|
||||
return null;
|
||||
}
|
||||
Matcher m = Pattern.compile("(?m)^Address\\s*=\\s*(.+)$").matcher(wgConfig);
|
||||
return m.find() ? m.group(1).trim() : null;
|
||||
}
|
||||
|
||||
private static String[] parseAllowedRoutes(String wgConfig) {
|
||||
if (wgConfig == null) {
|
||||
return new String[0];
|
||||
}
|
||||
Matcher m = Pattern.compile("(?m)^AllowedIPs\\s*=\\s*(.+)$").matcher(wgConfig);
|
||||
if (!m.find()) {
|
||||
return new String[0];
|
||||
}
|
||||
String[] parts = m.group(1).trim().split("\\s*,\\s*");
|
||||
ArrayList<String> out = new ArrayList<>();
|
||||
for (String p : parts) {
|
||||
if (!p.isEmpty()) {
|
||||
out.add(p.trim());
|
||||
}
|
||||
}
|
||||
return out.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private void tearDown() {
|
||||
if (tun != null) {
|
||||
try {
|
||||
|
||||
@@ -85,6 +85,48 @@
|
||||
android:text="@string/dev_remote_access_hint"
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
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_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_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_applied">Remote access: %1$s</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() {
|
||||
assertTrue(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:vpn"));
|
||||
assertTrue(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:netselftest"));
|
||||
assertTrue(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:ra_control"));
|
||||
assertFalse(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast"));
|
||||
assertFalse(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:crashwatcher"));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
JSONObject creds = new JSONObject();
|
||||
creds.put("interface_private_key", "aPrivKey=");
|
||||
creds.put("interface_address", "10.66.66.5/32");
|
||||
creds.put("interface_address", "172.200.2.10/32");
|
||||
creds.put("peer_public_key", "aPubKey=");
|
||||
creds.put("peer_endpoint", "example.org:51820");
|
||||
creds.put("peer_allowed_ips", "10.66.66.1/32");
|
||||
String cfg = WireGuardConfigBuilder.fromConnectCredentials(creds);
|
||||
creds.put("peer_endpoint", "ra.apps.f0xx.org:45340");
|
||||
creds.put("peer_allowed_ips", "172.200.2.1/32");
|
||||
String cfg = WireGuardConfigBuilder.fromConnectCredentials(
|
||||
creds,
|
||||
RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
|
||||
RemoteAccessVpnRouting.AppScope.ALL_APPS,
|
||||
"com.foxx.androidcast");
|
||||
assertTrue(cfg.contains("[Interface]"));
|
||||
assertTrue(cfg.contains("PrivateKey = aPrivKey="));
|
||||
assertTrue(cfg.contains("PublicKey = aPubKey="));
|
||||
assertTrue(cfg.contains("Endpoint = example.org:51820"));
|
||||
assertTrue(cfg.contains("Endpoint = ra.apps.f0xx.org:45340"));
|
||||
assertTrue(cfg.contains("AllowedIPs = 172.200.2.1/32"));
|
||||
assertFalse(cfg.contains("IncludedApplications"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fullTunnelOverridesBeAllowedIps() throws Exception {
|
||||
JSONObject creds = new JSONObject();
|
||||
creds.put("interface_private_key", "aPrivKey=");
|
||||
creds.put("interface_address", "172.200.2.10/32");
|
||||
creds.put("peer_public_key", "aPubKey=");
|
||||
creds.put("peer_allowed_ips", "172.200.2.1/32");
|
||||
String cfg = WireGuardConfigBuilder.fromConnectCredentials(
|
||||
creds,
|
||||
RemoteAccessVpnRouting.RouteScope.FULL_TUNNEL,
|
||||
RemoteAccessVpnRouting.AppScope.ALL_APPS,
|
||||
null);
|
||||
assertTrue(cfg.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void appOnlyAddsIncludedApplications() throws Exception {
|
||||
JSONObject creds = new JSONObject();
|
||||
creds.put("interface_private_key", "aPrivKey=");
|
||||
creds.put("interface_address", "172.200.2.10/32");
|
||||
creds.put("peer_public_key", "aPubKey=");
|
||||
String cfg = WireGuardConfigBuilder.fromConnectCredentials(
|
||||
creds,
|
||||
RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
|
||||
RemoteAccessVpnRouting.AppScope.THIS_APP_ONLY,
|
||||
"com.foxx.androidcast");
|
||||
assertTrue(cfg.contains("IncludedApplications = com.foxx.androidcast"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyWhenMissingKeys() throws Exception {
|
||||
JSONObject creds = new JSONObject();
|
||||
creds.put("interface_address", "10.66.66.5/32");
|
||||
assertTrue(WireGuardConfigBuilder.fromConnectCredentials(creds).isEmpty());
|
||||
creds.put("interface_address", "172.200.2.10/32");
|
||||
assertTrue(WireGuardConfigBuilder.fromConnectCredentials(
|
||||
creds,
|
||||
RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
|
||||
RemoteAccessVpnRouting.AppScope.ALL_APPS,
|
||||
null).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +18,42 @@ public class WireGuardConfigParseTest {
|
||||
public void builderOutput_parsesWithWireGuardConfig() throws Exception {
|
||||
JSONObject creds = new JSONObject();
|
||||
creds.put("interface_private_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
creds.put("interface_address", "10.66.66.2/32");
|
||||
creds.put("interface_address", "172.200.2.10/32");
|
||||
creds.put("peer_public_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
creds.put("peer_endpoint", "ra.apps.f0xx.org:51820");
|
||||
creds.put("peer_allowed_ips", "10.66.66.1/32");
|
||||
creds.put("peer_endpoint", "ra.apps.f0xx.org:45340");
|
||||
creds.put("peer_allowed_ips", "172.200.2.1/32");
|
||||
|
||||
String quick = WireGuardConfigBuilder.fromConnectCredentials(creds);
|
||||
String quick = WireGuardConfigBuilder.fromConnectCredentials(
|
||||
creds,
|
||||
RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
|
||||
RemoteAccessVpnRouting.AppScope.ALL_APPS,
|
||||
null);
|
||||
assertFalse(quick.isEmpty());
|
||||
|
||||
try (ByteArrayInputStream in = new ByteArrayInputStream(quick.getBytes(StandardCharsets.UTF_8))) {
|
||||
Config config = Config.parse(in);
|
||||
assertEquals("10.66.66.2/32", config.getInterface().getAddresses().iterator().next().toString());
|
||||
assertEquals("172.200.2.10/32", config.getInterface().getAddresses().iterator().next().toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void appOnly_includedApplicationsParse() throws Exception {
|
||||
JSONObject creds = new JSONObject();
|
||||
creds.put("interface_private_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
creds.put("interface_address", "172.200.2.10/32");
|
||||
creds.put("peer_public_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
|
||||
String quick = WireGuardConfigBuilder.fromConnectCredentials(
|
||||
creds,
|
||||
RemoteAccessVpnRouting.RouteScope.HUB_ONLY,
|
||||
RemoteAccessVpnRouting.AppScope.THIS_APP_ONLY,
|
||||
"com.foxx.androidcast");
|
||||
assertFalse(quick.isEmpty());
|
||||
|
||||
try (ByteArrayInputStream in = new ByteArrayInputStream(quick.getBytes(StandardCharsets.UTF_8))) {
|
||||
Config config = Config.parse(in);
|
||||
assertEquals(1, config.getInterface().getIncludedApplications().size());
|
||||
assertEquals("com.foxx.androidcast", config.getInterface().getIncludedApplications().iterator().next());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
- Dropdown: **Disabled** | **WireGuard** (lab) | **RSSH** (alpha — outbound reverse SSH, no VPN)
|
||||
- **VPN routing (WireGuard only):** route scope **Hub only** (`172.200.2.1/32`, split tunnel) or **All traffic** (`0.0.0.0/0`); app scope **All apps** or **This app only** (`IncludedApplications` / `VpnService.addAllowedApplication`). RSSH ignores these. Prefs sent on heartbeat as `vpn_route_scope` / `vpn_app_scope`; changing them reconnects an active WG session.
|
||||
- Pref key: `dev_remote_access_mode` (`AppPreferences`)
|
||||
- **Disabled:** notifies BE (`status:disable`), tears down VPN, stops `RemoteAccessService`
|
||||
- **WireGuard:** foreground service; jittered poll **1–7 min** → `heartbeat.php` with `type: ra`
|
||||
|
||||
@@ -11,6 +11,8 @@ See also [REMOTE_ACCESS_VALIDATION.md](../REMOTE_ACCESS_VALIDATION.md), [REMOTE_
|
||||
|
||||
## Fixed in repo (awaiting BE sync)
|
||||
|
||||
- **MariaDB `NULLS LAST`** — `listDevices()` ORDER BY broke dashboard on prod (HTTP 500); fixed to `(last_seen_at IS NULL), last_seen_at DESC`
|
||||
- **Dashboard perf** — issue counts use indexed `reports.device_id` via `devices.external_id` instead of `payload_json LIKE`
|
||||
- **Operational docs** — `REMOTE_ACCESS_IMPL.md` / `REMOTE_ACCESS_VALIDATION.md` now use prod **45340** and **172.200.2.0/16** (proposal doc still uses example 51820)
|
||||
- **Orphan wg peer cleanup** — `WireGuardPeerProvisioner::pruneOrphanPeers()`, hourly via `purge_remote_access.php`; manual `scripts/sync_wg_peers.php --dry-run|--apply`
|
||||
- **CLI defaults** — `ra_udp_debug.sh` / `ra_lib.sh` help and fallbacks match prod subnet
|
||||
|
||||
@@ -26,18 +26,16 @@ if ($method === 'GET') {
|
||||
json_out(['ok' => true, 'events' => RemoteAccessRepository::listEvents((int)($_GET['limit'] ?? 100))]);
|
||||
}
|
||||
if ($action === 'dashboard') {
|
||||
$sessions = RemoteAccessRepository::listSessions('');
|
||||
$active = array_values(array_filter($sessions, static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true)));
|
||||
$inactive = array_values(array_filter($sessions, static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)));
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'devices' => RemoteAccessRepository::listDevicesForDashboard(
|
||||
$payload = RemoteAccessRepository::buildDashboardPayload(
|
||||
rtrim(Auth::basePath(), '/'),
|
||||
'/app/androidcast_project'
|
||||
),
|
||||
'active_sessions' => $active,
|
||||
'inactive_sessions' => array_slice($inactive, 0, 50),
|
||||
'recent_events' => RemoteAccessRepository::listEvents(30),
|
||||
);
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'devices' => $payload['devices'],
|
||||
'active_sessions' => $payload['active_sessions'],
|
||||
'inactive_sessions' => $payload['inactive_sessions'],
|
||||
'recent_events' => $payload['recent_events'],
|
||||
'permissions' => [
|
||||
'can_operate' => Rbac::can('remote_access_operate'),
|
||||
'can_admin' => Rbac::can('remote_access_admin'),
|
||||
|
||||
@@ -411,6 +411,8 @@
|
||||
lines.push(pollLabel + ': ' + d.poll_source_ip);
|
||||
}
|
||||
if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip);
|
||||
if (d.vpn_route_scope) lines.push('VPN route scope: ' + d.vpn_route_scope);
|
||||
if (d.vpn_app_scope) lines.push('VPN app scope: ' + d.vpn_app_scope);
|
||||
if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key);
|
||||
if (d.wg_rx_bytes != null || d.wg_tx_bytes != null) {
|
||||
const rx = formatBytes(d.wg_rx_bytes || 0);
|
||||
|
||||
@@ -5,12 +5,16 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Alpine BE: default `php` may be 8.5 without session; php-fpm81 uses 8.1.
|
||||
# Alpine BE: default `php` may be 8.5 without session/PDO; php-fpm81 uses 8.1.
|
||||
php_bin() {
|
||||
if [[ -n "${PHP_BIN:-}" ]]; then
|
||||
echo "$PHP_BIN"
|
||||
return
|
||||
fi
|
||||
if command -v php81 >/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qiE '^(session|pdo_mysql|pdo_sqlite)$'; then
|
||||
echo php81
|
||||
return
|
||||
fi
|
||||
if php -m 2>/dev/null | grep -qi '^session$'; then
|
||||
echo php
|
||||
return
|
||||
@@ -34,6 +38,13 @@ echo "== Remote access production verify =="
|
||||
echo "backend: $ROOT"
|
||||
echo
|
||||
|
||||
# MariaDB rejects PostgreSQL-style NULLS LAST/FIRST (SQLite dev may hide this).
|
||||
if grep -R --include='*.php' -E 'NULLS (LAST|FIRST)' "$ROOT/src" >/dev/null 2>&1; then
|
||||
bad "MariaDB-incompatible SQL (NULLS LAST/FIRST) under src/"
|
||||
else
|
||||
ok "no NULLS LAST/FIRST in PHP SQL"
|
||||
fi
|
||||
|
||||
# config.php
|
||||
CFG="$ROOT/config/config.php"
|
||||
if [[ ! -f "$CFG" ]]; then
|
||||
@@ -97,6 +108,13 @@ foreach (["remote_access_devices","remote_access_sessions","remote_access_events
|
||||
echo "OK MariaDB/SQLite remote_access tables present\n";
|
||||
' || fail=1
|
||||
|
||||
$DB_RUN -r '
|
||||
require "'"$ROOT"'/src/bootstrap.php";
|
||||
RemoteAccessRepository::ensureSchema();
|
||||
RemoteAccessRepository::listDevices();
|
||||
echo "OK listDevices() SQL executes\n";
|
||||
' || bad "listDevices() failed (check ORDER BY / MariaDB syntax)"
|
||||
|
||||
# Orphan wg peers (dry-run; run sync_wg_peers.php --apply on BE to fix)
|
||||
if [[ -f "$ROOT/scripts/sync_wg_peers.php" ]]; then
|
||||
WG_SYNC="$($DB_RUN "$ROOT/scripts/sync_wg_peers.php" --dry-run 2>/dev/null || true)"
|
||||
|
||||
@@ -16,46 +16,44 @@ final class RemoteAccessRepository {
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
public const STATUS_CLOSED_TIMEOUT = 'closed_timeout';
|
||||
|
||||
private static bool $schemaReady = false;
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
if (self::$schemaReady) {
|
||||
return;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
if (!Database::tableExists($pdo, 'remote_access_devices')) {
|
||||
self::createTables($pdo);
|
||||
self::$schemaReady = true;
|
||||
return;
|
||||
}
|
||||
self::ensureSessionColumns($pdo);
|
||||
self::ensureRsshColumns($pdo);
|
||||
self::ensureDeviceMetaColumn($pdo);
|
||||
self::ensureLastClientIpColumn($pdo);
|
||||
self::$schemaReady = true;
|
||||
}
|
||||
|
||||
private static function addColumnIfMissing(PDO $pdo, string $table, string $column, string $ddl): void {
|
||||
if (!Database::tableExists($pdo, $table)) {
|
||||
return;
|
||||
}
|
||||
$names = Database::columnNames($pdo, $table);
|
||||
if (in_array($column, $names, true)) {
|
||||
return;
|
||||
}
|
||||
$pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $ddl);
|
||||
}
|
||||
|
||||
private static function ensureLastClientIpColumn(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'remote_access_devices')) {
|
||||
return;
|
||||
}
|
||||
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_devices ADD COLUMN last_client_ip ' . $col);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
self::addColumnIfMissing($pdo, 'remote_access_devices', 'last_client_ip', $col);
|
||||
}
|
||||
|
||||
private static function ensureDeviceMetaColumn(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'remote_access_devices')) {
|
||||
return;
|
||||
}
|
||||
$col = Database::isMysql() ? 'LONGTEXT NULL' : 'TEXT NULL';
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_devices ADD COLUMN device_meta_json ' . $col);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
self::addColumnIfMissing($pdo, 'remote_access_devices', 'device_meta_json', $col);
|
||||
}
|
||||
|
||||
/** @param list<string> $where @param list<mixed> $params */
|
||||
@@ -113,30 +111,13 @@ final class RemoteAccessRepository {
|
||||
'rssh_local_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
|
||||
];
|
||||
foreach ($cols as $name => $ddl) {
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN ' . $name . ' ' . $ddl);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
self::addColumnIfMissing($pdo, 'remote_access_sessions', $name, $ddl);
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureSessionColumns(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'remote_access_sessions')) {
|
||||
return;
|
||||
}
|
||||
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN wg_client_public_key ' . $col);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
self::addColumnIfMissing($pdo, 'remote_access_sessions', 'wg_client_public_key', $col);
|
||||
}
|
||||
|
||||
private static function createTables(PDO $pdo): void {
|
||||
@@ -185,6 +166,14 @@ final class RemoteAccessRepository {
|
||||
}
|
||||
|
||||
$deviceMeta = is_array($heartbeat['device'] ?? null) ? $heartbeat['device'] : [];
|
||||
$routeScope = trim((string) ($heartbeat['vpn_route_scope'] ?? ''));
|
||||
$appScope = trim((string) ($heartbeat['vpn_app_scope'] ?? ''));
|
||||
if ($routeScope !== '') {
|
||||
$deviceMeta['vpn_route_scope'] = $routeScope;
|
||||
}
|
||||
if ($appScope !== '') {
|
||||
$deviceMeta['vpn_app_scope'] = $appScope;
|
||||
}
|
||||
|
||||
self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp, $deviceMeta);
|
||||
|
||||
@@ -853,22 +842,89 @@ final class RemoteAccessRepository {
|
||||
return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/** @return array{devices: list<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>> */
|
||||
public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
|
||||
$devices = self::listDevices();
|
||||
if ($devices === []) {
|
||||
return [];
|
||||
}
|
||||
$deviceIds = array_values(array_filter(array_map(
|
||||
static fn($row) => (string) ($row['device_id'] ?? ''),
|
||||
$devices
|
||||
)));
|
||||
$sessionRows = self::batchActiveSessionRows($deviceIds);
|
||||
$wgPeers = WireGuardTrafficStats::peerMap();
|
||||
$out = [];
|
||||
foreach ($devices as $row) {
|
||||
$out[] = self::enrichDeviceRow($row, $issuesBase, $projectBase);
|
||||
$deviceId = (string) ($row['device_id'] ?? '');
|
||||
$out[] = self::enrichDeviceRow(
|
||||
$row,
|
||||
$issuesBase,
|
||||
$projectBase,
|
||||
$sessionRows[$deviceId] ?? null,
|
||||
$wgPeers
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private static function enrichDeviceRow(array $row, string $issuesBase, string $projectBase): array {
|
||||
/** @param list<string> $deviceIds @return array<string, array<string, mixed>> */
|
||||
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'] ?? '');
|
||||
$companyId = (int) ($row['company_id'] ?? 1);
|
||||
$storedMeta = self::decodeDeviceMeta($row['device_meta_json'] ?? null);
|
||||
$ctx = self::lookupDeviceContext($deviceId, $companyId, $storedMeta);
|
||||
$ctx = self::lookupDeviceContextFromMeta($deviceId, $companyId, $storedMeta);
|
||||
$row['needs_whitelist'] = self::deviceNeedsWhitelist($row);
|
||||
$row['status_label'] = self::deviceStatusLabel($row);
|
||||
$row['device_name'] = $ctx['device_name'] ?? '';
|
||||
@@ -885,7 +941,9 @@ final class RemoteAccessRepository {
|
||||
$row['poll_source_ip'] = self::resolvePollSourceIp($row);
|
||||
$row['poll_source_is_private'] = self::isPrivateIp($row['poll_source_ip']);
|
||||
$row['device_wan_ip'] = trim((string) ($storedMeta['wan_ip'] ?? ''));
|
||||
$net = self::lookupDeviceSessionNetwork($deviceId);
|
||||
$row['vpn_route_scope'] = trim((string) ($storedMeta['vpn_route_scope'] ?? ''));
|
||||
$row['vpn_app_scope'] = trim((string) ($storedMeta['vpn_app_scope'] ?? ''));
|
||||
$net = self::sessionNetworkFromRow($sessionRow, $wgPeers);
|
||||
$row['vpn_ip'] = $net['vpn_ip'] ?? '';
|
||||
$row['vpn_public_key'] = $net['vpn_public_key'] ?? '';
|
||||
$row['wg_rx_bytes'] = $net['wg_rx_bytes'] ?? 0;
|
||||
@@ -1002,8 +1060,8 @@ final class RemoteAccessRepository {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
|
||||
private static function lookupDeviceSessionNetwork(string $deviceId): array {
|
||||
/** @param array<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 sessionNetworkFromRow(?array $sessionRow, ?array $wgPeers): array {
|
||||
$empty = [
|
||||
'vpn_ip' => '',
|
||||
'vpn_public_key' => '',
|
||||
@@ -1012,29 +1070,20 @@ final class RemoteAccessRepository {
|
||||
'wg_latest_handshake' => 0,
|
||||
'wg_endpoint' => '',
|
||||
];
|
||||
if ($deviceId === '') {
|
||||
if (!is_array($sessionRow)) {
|
||||
return $empty;
|
||||
}
|
||||
$st = Database::pdo()->prepare(
|
||||
"SELECT tunnel, wg_client_address, wg_client_public_key
|
||||
FROM remote_access_sessions
|
||||
WHERE device_id = ? AND status IN ('pending', 'active')
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
$st->execute([$deviceId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
return $empty;
|
||||
}
|
||||
$tunnel = (string) ($row['tunnel'] ?? '');
|
||||
$tunnel = (string) ($sessionRow['tunnel'] ?? '');
|
||||
if ($tunnel !== '' && $tunnel !== 'wireguard') {
|
||||
return $empty;
|
||||
}
|
||||
$addr = trim((string) ($row['wg_client_address'] ?? ''));
|
||||
$addr = trim((string) ($sessionRow['wg_client_address'] ?? ''));
|
||||
$vpnIp = $addr !== '' ? preg_replace('/\/\d+$/', '', $addr) ?? $addr : '';
|
||||
$pub = trim((string) ($row['wg_client_public_key'] ?? ''));
|
||||
|
||||
$stats = $pub !== '' ? WireGuardTrafficStats::forPublicKey($pub) : null;
|
||||
$pub = trim((string) ($sessionRow['wg_client_public_key'] ?? ''));
|
||||
$stats = ($wgPeers !== null && $pub !== '') ? ($wgPeers[$pub] ?? null) : null;
|
||||
if ($stats === null && $pub !== '') {
|
||||
$stats = WireGuardTrafficStats::forPublicKey($pub);
|
||||
}
|
||||
|
||||
return [
|
||||
'vpn_ip' => is_string($vpnIp) ? trim($vpnIp) : '',
|
||||
@@ -1046,6 +1095,72 @@ final class RemoteAccessRepository {
|
||||
];
|
||||
}
|
||||
|
||||
/** Fast dashboard enrichment from heartbeat meta (no reports/graph table scans). */
|
||||
private static function lookupDeviceContextFromMeta(
|
||||
string $deviceId,
|
||||
int $companyId,
|
||||
array $storedMeta = []
|
||||
): array {
|
||||
if ($deviceId === '') {
|
||||
return [];
|
||||
}
|
||||
unset($companyId);
|
||||
$deviceName = trim((string) ($storedMeta['name'] ?? ''));
|
||||
$manufacturer = trim((string) ($storedMeta['manufacturer'] ?? ''));
|
||||
$brand = trim((string) ($storedMeta['brand'] ?? ''));
|
||||
$model = trim((string) ($storedMeta['model'] ?? ''));
|
||||
$hardwareDevice = trim((string) ($storedMeta['device'] ?? ''));
|
||||
$product = trim((string) ($storedMeta['product'] ?? ''));
|
||||
$osRelease = trim((string) ($storedMeta['release'] ?? ''));
|
||||
$sdkInt = isset($storedMeta['sdk_int']) ? (int) $storedMeta['sdk_int'] : null;
|
||||
$abis = is_array($storedMeta['abis'] ?? null) ? $storedMeta['abis'] : [];
|
||||
$appVersion = trim((string) ($storedMeta['app_version'] ?? ''));
|
||||
$deviceDisplay = $deviceName !== ''
|
||||
? $deviceName
|
||||
: trim($manufacturer . ' ' . $model);
|
||||
|
||||
return [
|
||||
'issue_count' => 0,
|
||||
'latest_issue_id' => null,
|
||||
'graph_session_count' => 0,
|
||||
'device_name' => $deviceName,
|
||||
'device_display' => $deviceDisplay,
|
||||
'manufacturer' => $manufacturer,
|
||||
'brand' => $brand,
|
||||
'model' => $model,
|
||||
'hardware_device' => $hardwareDevice,
|
||||
'product' => $product,
|
||||
'os_release' => $osRelease,
|
||||
'sdk_int' => $sdkInt,
|
||||
'abis' => $abis,
|
||||
'app_version' => $appVersion,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
|
||||
private static function lookupDeviceSessionNetwork(string $deviceId): array {
|
||||
if ($deviceId === '') {
|
||||
return [
|
||||
'vpn_ip' => '',
|
||||
'vpn_public_key' => '',
|
||||
'wg_rx_bytes' => 0,
|
||||
'wg_tx_bytes' => 0,
|
||||
'wg_latest_handshake' => 0,
|
||||
'wg_endpoint' => '',
|
||||
];
|
||||
}
|
||||
$st = Database::pdo()->prepare(
|
||||
"SELECT tunnel, wg_client_address, wg_client_public_key
|
||||
FROM remote_access_sessions
|
||||
WHERE device_id = ? AND status IN ('pending', 'active')
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
$st->execute([$deviceId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return self::sessionNetworkFromRow(is_array($row) ? $row : null, null);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $storedMeta @return array<string, mixed> */
|
||||
private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array {
|
||||
if ($deviceId === '') {
|
||||
@@ -1087,21 +1202,27 @@ final class RemoteAccessRepository {
|
||||
}
|
||||
|
||||
if (Database::tableExists($pdo, 'reports')) {
|
||||
$like = '%' . $deviceId . '%';
|
||||
$reportsDeviceId = self::resolveDeviceDbId($deviceId, $companyId);
|
||||
if ($reportsDeviceId !== null) {
|
||||
$cntSt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.payload_json LIKE ?'
|
||||
'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.device_id = ?'
|
||||
);
|
||||
$cntSt->execute([$companyId, $like]);
|
||||
$cntSt->execute([$companyId, $reportsDeviceId]);
|
||||
$issueCount = (int) $cntSt->fetchColumn();
|
||||
|
||||
$latestSt = $pdo->prepare(
|
||||
'SELECT r.id, r.device_model, r.app_version, r.payload_json
|
||||
FROM reports r
|
||||
WHERE r.company_id = ? AND r.payload_json LIKE ?
|
||||
WHERE r.company_id = ? AND r.device_id = ?
|
||||
ORDER BY r.received_at_ms DESC LIMIT 1'
|
||||
);
|
||||
$latestSt->execute([$companyId, $like]);
|
||||
$latestSt->execute([$companyId, $reportsDeviceId]);
|
||||
$latest = $latestSt->fetch(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
// Avoid payload_json LIKE scans (full table) on unregistered devices.
|
||||
$issueCount = 0;
|
||||
$latest = null;
|
||||
}
|
||||
if (is_array($latest)) {
|
||||
$latestIssueId = (int) ($latest['id'] ?? 0) ?: null;
|
||||
if ($appVersion === '') {
|
||||
@@ -1163,6 +1284,28 @@ final class RemoteAccessRepository {
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveDeviceDbId(string $deviceId, int $companyId): ?int {
|
||||
static $cache = [];
|
||||
$key = $companyId . ':' . $deviceId;
|
||||
if (array_key_exists($key, $cache)) {
|
||||
return $cache[$key];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
if (!Database::tableExists($pdo, 'devices')) {
|
||||
$cache[$key] = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
$st = $pdo->prepare(
|
||||
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
|
||||
);
|
||||
$st->execute([$companyId, $deviceId]);
|
||||
$id = $st->fetchColumn();
|
||||
$cache[$key] = ($id !== false && $id !== null && $id !== '') ? (int) $id : null;
|
||||
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $ctx @return list<array{label: string, href: string}> */
|
||||
private static function deviceLinks(string $deviceId, string $issuesBase, string $projectBase, array $ctx): array {
|
||||
if ($deviceId === '') {
|
||||
|
||||
Reference in New Issue
Block a user