mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:38:53 +03:00
be sync
This commit is contained in:
@@ -9,6 +9,7 @@ import android.os.Process;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
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.ReverseSshTunnelBridge;
|
||||
@@ -18,6 +19,7 @@ import com.foxx.androidcast.vpn.VpnState;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.FileReader;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** VPN / remote-access snapshot for diagnostics and network self-test. */
|
||||
public final class DevVpnStatusProbe {
|
||||
@@ -40,30 +42,42 @@ public final class DevVpnStatusProbe {
|
||||
String typeLabel = mode == RemoteAccessMode.DISABLED ? "off" : mode.name().toLowerCase(Locale.US);
|
||||
r.itemPass("type", typeLabel);
|
||||
|
||||
StringBuilder vpnDetailBuf = new StringBuilder();
|
||||
String failReason = last.failReason;
|
||||
String state = resolveState(context, mode, last, mainProcess, osVpn, vpnDetailBuf::append);
|
||||
String vpnDetail = vpnDetailBuf.toString();
|
||||
|
||||
if (mode == RemoteAccessMode.WIREGUARD
|
||||
&& (failReason == null || failReason.isEmpty())
|
||||
&& RemoteAccessStatusStore.STATE_FAILURE.equals(state)
|
||||
&& !vpnDetail.isEmpty()) {
|
||||
failReason = vpnDetail;
|
||||
String vpnDetail = "";
|
||||
if (mainProcess && mode == RemoteAccessMode.WIREGUARD) {
|
||||
vpnDetail = VpnServiceClient.get(context).getStateDetailIfBound();
|
||||
}
|
||||
|
||||
DevNetworkSelfTestReport.Level level;
|
||||
String tunnelState = resolveTunnelState(context, mode, last, mainProcess, osVpn);
|
||||
String controlPlane = resolveControlPlaneState(last);
|
||||
|
||||
DevNetworkSelfTestReport.Level tunnelLevel;
|
||||
if (mode == RemoteAccessMode.DISABLED) {
|
||||
level = DevNetworkSelfTestReport.Level.NA;
|
||||
} else if (RemoteAccessStatusStore.STATE_SUCCESS.equals(state)) {
|
||||
level = DevNetworkSelfTestReport.Level.PASS;
|
||||
} else if (RemoteAccessStatusStore.STATE_FAILURE.equals(state)) {
|
||||
level = DevNetworkSelfTestReport.Level.FAIL;
|
||||
tunnelLevel = DevNetworkSelfTestReport.Level.NA;
|
||||
} else if (RemoteAccessStatusStore.STATE_SUCCESS.equals(tunnelState)) {
|
||||
tunnelLevel = DevNetworkSelfTestReport.Level.PASS;
|
||||
} else if (RemoteAccessStatusStore.STATE_FAILURE.equals(tunnelState)) {
|
||||
tunnelLevel = DevNetworkSelfTestReport.Level.FAIL;
|
||||
} else {
|
||||
level = DevNetworkSelfTestReport.Level.WARN;
|
||||
tunnelLevel = DevNetworkSelfTestReport.Level.WARN;
|
||||
}
|
||||
r.item(level, "state", state);
|
||||
r.item(tunnelLevel, "tunnel-state", tunnelState);
|
||||
|
||||
if (mode != RemoteAccessMode.DISABLED
|
||||
&& !RemoteAccessStatusStore.STATE_OFF.equals(controlPlane)) {
|
||||
DevNetworkSelfTestReport.Level cpLevel =
|
||||
RemoteAccessStatusStore.STATE_FAILURE.equals(controlPlane)
|
||||
? DevNetworkSelfTestReport.Level.FAIL
|
||||
: RemoteAccessStatusStore.STATE_SUCCESS.equals(controlPlane)
|
||||
? DevNetworkSelfTestReport.Level.PASS
|
||||
: DevNetworkSelfTestReport.Level.WARN;
|
||||
r.item(cpLevel, "control-plane", controlPlane);
|
||||
}
|
||||
|
||||
r.itemPass("os-vpn-transport", osVpn ? "active" : "none");
|
||||
if (mainProcess && mode == RemoteAccessMode.WIREGUARD) {
|
||||
boolean bound = VpnServiceClient.get(context).isBound();
|
||||
r.itemPass("vpn-process-bound", bound ? "yes (:vpn AIDL)" : "no (not bound — normal if idle)");
|
||||
}
|
||||
if (!vpnDetail.isEmpty()) {
|
||||
r.itemPass("detail", vpnDetail);
|
||||
}
|
||||
@@ -77,20 +91,25 @@ public final class DevVpnStatusProbe {
|
||||
method = "n/a";
|
||||
}
|
||||
r.itemPass("method", method);
|
||||
|
||||
String initiator = formatInitiator(last.initiator, mode);
|
||||
r.itemPass("initiator", initiator);
|
||||
r.itemPass("initiator", formatInitiator(last.initiator, mode));
|
||||
|
||||
if (!last.sessionId.isEmpty()) {
|
||||
r.itemPass("session", last.sessionId);
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_FAILURE.equals(state)
|
||||
&& failReason != null && !failReason.isEmpty()) {
|
||||
|
||||
String failReason = resolveDisplayedFailReason(context, mode, last, tunnelState, controlPlane);
|
||||
if (failReason != null && !failReason.isEmpty()) {
|
||||
r.itemFail("fail-reason", failReason);
|
||||
}
|
||||
|
||||
if (RemoteAccessFailureReason.isControlPlaneMethod(last.method)
|
||||
&& RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
r.itemPass("heartbeat-url", RemoteAccessFailureReason.heartbeatUrlForDiagnostics(context));
|
||||
}
|
||||
|
||||
appendTraffic(r, context);
|
||||
if (last.updatedMs > 0L) {
|
||||
r.itemPass("last-event-age", formatAge(last.updatedMs));
|
||||
r.itemPass("last-event-ms", Long.toString(last.updatedMs));
|
||||
}
|
||||
if (!mainProcess) {
|
||||
@@ -98,51 +117,35 @@ public final class DevVpnStatusProbe {
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveState(
|
||||
/** Tunnel only — not heartbeat poll failures. */
|
||||
private static String resolveTunnelState(
|
||||
Context context,
|
||||
RemoteAccessMode mode,
|
||||
RemoteAccessStatusStore.Snapshot last,
|
||||
boolean mainProcess,
|
||||
boolean osVpn,
|
||||
java.util.function.Consumer<String> detailOut) {
|
||||
boolean osVpn) {
|
||||
if (mode == RemoteAccessMode.DISABLED) {
|
||||
return RemoteAccessStatusStore.STATE_OFF;
|
||||
}
|
||||
if (mode == RemoteAccessMode.WIREGUARD) {
|
||||
int live = VpnState.DOWN;
|
||||
String detail = "";
|
||||
if (mainProcess) {
|
||||
VpnServiceClient vpn = VpnServiceClient.get(context);
|
||||
live = vpn.getState();
|
||||
detail = vpn.getStateDetail();
|
||||
}
|
||||
if (detailOut != null && detail != null && !detail.isEmpty()) {
|
||||
detailOut.accept(detail);
|
||||
}
|
||||
if (mainProcess) {
|
||||
if (live == VpnState.UP) {
|
||||
return RemoteAccessStatusStore.STATE_SUCCESS;
|
||||
}
|
||||
if (live == VpnState.ERROR) {
|
||||
return RemoteAccessStatusStore.STATE_FAILURE;
|
||||
}
|
||||
if (live == VpnState.CONNECTING) {
|
||||
return RemoteAccessStatusStore.STATE_CONNECTING;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state) && osVpn) {
|
||||
return RemoteAccessStatusStore.STATE_SUCCESS;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_CONNECTING.equals(last.state)
|
||||
|| RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
return last.state;
|
||||
}
|
||||
return RemoteAccessStatusStore.STATE_IDLE;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state) && osVpn) {
|
||||
int live = mainProcess ? VpnServiceClient.get(context).getStateIfBound() : VpnState.DOWN;
|
||||
if (live == VpnState.UP || (osVpn && RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state)
|
||||
&& RemoteAccessFailureReason.isTunnelMethod(last.method))) {
|
||||
return RemoteAccessStatusStore.STATE_SUCCESS;
|
||||
}
|
||||
if (!last.state.isEmpty() && !RemoteAccessStatusStore.STATE_OFF.equals(last.state)) {
|
||||
return last.state;
|
||||
if (live == VpnState.ERROR) {
|
||||
return RemoteAccessStatusStore.STATE_FAILURE;
|
||||
}
|
||||
if (live == VpnState.CONNECTING) {
|
||||
return RemoteAccessStatusStore.STATE_CONNECTING;
|
||||
}
|
||||
if (RemoteAccessFailureReason.isTunnelMethod(last.method)
|
||||
&& RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
return RemoteAccessStatusStore.STATE_FAILURE;
|
||||
}
|
||||
if (RemoteAccessFailureReason.isTunnelMethod(last.method)
|
||||
&& RemoteAccessStatusStore.STATE_CONNECTING.equals(last.state)) {
|
||||
return RemoteAccessStatusStore.STATE_CONNECTING;
|
||||
}
|
||||
return osVpn ? RemoteAccessStatusStore.STATE_SUCCESS : RemoteAccessStatusStore.STATE_IDLE;
|
||||
}
|
||||
@@ -151,20 +154,77 @@ public final class DevVpnStatusProbe {
|
||||
if (mainProcess) {
|
||||
rsshUp = ReverseSshTunnelBridge.isConnected();
|
||||
} else {
|
||||
rsshUp = RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state);
|
||||
rsshUp = RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state)
|
||||
&& RemoteAccessFailureReason.isTunnelMethod(last.method);
|
||||
}
|
||||
if (rsshUp) {
|
||||
return RemoteAccessStatusStore.STATE_SUCCESS;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
if (RemoteAccessFailureReason.isTunnelMethod(last.method)
|
||||
&& RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
return RemoteAccessStatusStore.STATE_FAILURE;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_CONNECTING.equals(last.state)) {
|
||||
if (RemoteAccessFailureReason.isTunnelMethod(last.method)
|
||||
&& RemoteAccessStatusStore.STATE_CONNECTING.equals(last.state)) {
|
||||
return RemoteAccessStatusStore.STATE_CONNECTING;
|
||||
}
|
||||
return RemoteAccessStatusStore.STATE_IDLE;
|
||||
}
|
||||
|
||||
private static String resolveControlPlaneState(RemoteAccessStatusStore.Snapshot last) {
|
||||
if (!RemoteAccessFailureReason.isControlPlaneMethod(last.method)) {
|
||||
if (RemoteAccessStatusStore.STATE_IDLE.equals(last.state)
|
||||
|| RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state)) {
|
||||
return last.state;
|
||||
}
|
||||
return RemoteAccessStatusStore.STATE_IDLE;
|
||||
}
|
||||
return last.state;
|
||||
}
|
||||
|
||||
private static String resolveDisplayedFailReason(
|
||||
Context context,
|
||||
RemoteAccessMode mode,
|
||||
RemoteAccessStatusStore.Snapshot last,
|
||||
String tunnelState,
|
||||
String controlPlane) {
|
||||
if (mode == RemoteAccessMode.DISABLED) {
|
||||
return "";
|
||||
}
|
||||
String expanded = RemoteAccessFailureReason.expandForDisplay(context, last.method, last.failReason);
|
||||
|
||||
if (RemoteAccessFailureReason.isControlPlaneMethod(last.method)
|
||||
&& RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
if (!RemoteAccessStatusStore.STATE_SUCCESS.equals(tunnelState)) {
|
||||
return "[heartbeat] " + expanded;
|
||||
}
|
||||
return "[heartbeat] " + expanded + " (tunnel not affected)";
|
||||
}
|
||||
if (RemoteAccessFailureReason.isTunnelMethod(last.method)
|
||||
&& RemoteAccessStatusStore.STATE_FAILURE.equals(tunnelState)) {
|
||||
return "[tunnel] " + expanded;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_FAILURE.equals(controlPlane)
|
||||
&& !expanded.isEmpty()) {
|
||||
return "[heartbeat] " + expanded;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String formatAge(long updatedMs) {
|
||||
long ago = System.currentTimeMillis() - updatedMs;
|
||||
if (ago < 0) {
|
||||
return "0s ago";
|
||||
}
|
||||
if (ago < TimeUnit.MINUTES.toMillis(1)) {
|
||||
return (ago / 1000L) + "s ago";
|
||||
}
|
||||
if (ago < TimeUnit.HOURS.toMillis(1)) {
|
||||
return (ago / TimeUnit.MINUTES.toMillis(1)) + "m ago";
|
||||
}
|
||||
return (ago / TimeUnit.HOURS.toMillis(1)) + "h ago";
|
||||
}
|
||||
|
||||
private static String formatInitiator(String stored, RemoteAccessMode mode) {
|
||||
if (!stored.isEmpty()) {
|
||||
switch (stored) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.foxx.androidcast.remoteaccess;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import java.net.ConnectException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Human-readable remote-access failure text for status store and diagnostics. */
|
||||
public final class RemoteAccessFailureReason {
|
||||
private RemoteAccessFailureReason() {}
|
||||
|
||||
public static String fromPollException(Context context, Exception e) {
|
||||
String host = heartbeatHost(context);
|
||||
if (e instanceof SocketTimeoutException) {
|
||||
return "heartbeat HTTP timeout — device→" + host + " (15s connect/read)";
|
||||
}
|
||||
if (e instanceof UnknownHostException) {
|
||||
return "heartbeat DNS failed — " + host + " (" + safeMsg(e) + ")";
|
||||
}
|
||||
if (e instanceof ConnectException) {
|
||||
return "heartbeat TCP connect failed — " + host + " (" + safeMsg(e) + ")";
|
||||
}
|
||||
if (e instanceof IllegalStateException) {
|
||||
String msg = safeMsg(e);
|
||||
if ("no_backend_url".equals(msg)) {
|
||||
return "no crash/OTA URL configured — cannot resolve heartbeat.php";
|
||||
}
|
||||
if (msg.startsWith("http_")) {
|
||||
return "heartbeat HTTP " + msg.substring(5) + " — " + host;
|
||||
}
|
||||
return "heartbeat error — " + msg;
|
||||
}
|
||||
return e.getClass().getSimpleName() + ": " + safeMsg(e);
|
||||
}
|
||||
|
||||
public static boolean isControlPlaneMethod(String method) {
|
||||
if (method == null || method.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return method.contains("runPoll")
|
||||
|| method.contains("RemoteAccessHeartbeatClient")
|
||||
|| method.contains("postRa");
|
||||
}
|
||||
|
||||
public static boolean isTunnelMethod(String method) {
|
||||
if (method == null || method.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return method.contains("VpnServiceClient")
|
||||
|| method.contains("ReverseSshTunnelBridge")
|
||||
|| method.contains("handleConnect");
|
||||
}
|
||||
|
||||
public static String heartbeatHost(Context context) {
|
||||
String url = RemoteAccessHeartbeatClient.resolveHeartbeatUrl(context);
|
||||
if (url.isEmpty()) {
|
||||
return "(no URL configured)";
|
||||
}
|
||||
try {
|
||||
String host = new java.net.URL(url).getHost();
|
||||
if (host != null && !host.isEmpty()) {
|
||||
return host;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return url.length() > 48 ? url.substring(0, 48) + "…" : url;
|
||||
}
|
||||
|
||||
public static String heartbeatUrlForDiagnostics(Context context) {
|
||||
String url = RemoteAccessHeartbeatClient.resolveHeartbeatUrl(context);
|
||||
if (url.isEmpty()) {
|
||||
return "(unset — set crash upload or OTA URL in dev settings)";
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private static String safeMsg(Exception e) {
|
||||
String m = e.getMessage();
|
||||
if (m == null || m.isEmpty()) {
|
||||
return e.getClass().getSimpleName();
|
||||
}
|
||||
if ("timeout".equalsIgnoreCase(m)) {
|
||||
return "timeout";
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Legacy bare "timeout" from old builds — expand for diagnostics display. */
|
||||
public static String expandForDisplay(Context context, String method, String failReason) {
|
||||
if (failReason == null || failReason.isEmpty()) {
|
||||
return failReason;
|
||||
}
|
||||
if (isControlPlaneMethod(method)
|
||||
&& ("timeout".equalsIgnoreCase(failReason)
|
||||
|| failReason.toLowerCase(Locale.US).contains("timed out"))) {
|
||||
return fromPollException(context, new SocketTimeoutException(failReason));
|
||||
}
|
||||
if (failReason.equals("RSSH connect failed or timed out")) {
|
||||
return failReason + " — device→bastion (JSch 30s + 35s wait; check ra.apps.f0xx.org:443)";
|
||||
}
|
||||
return failReason;
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ public final class RemoteAccessService extends Service {
|
||||
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
"RemoteAccessService.runPoll",
|
||||
RemoteAccessStatusStore.INITIATOR_DEVICE,
|
||||
e.getMessage(), "");
|
||||
RemoteAccessFailureReason.fromPollException(this, e), "");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,36 @@ public final class VpnServiceClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-blocking peek for diagnostics — never waits on :vpn process bind. */
|
||||
public int getStateIfBound() {
|
||||
IVpnService s = service;
|
||||
if (s == null) {
|
||||
return VpnState.DOWN;
|
||||
}
|
||||
try {
|
||||
return s.getState();
|
||||
} catch (RemoteException e) {
|
||||
return VpnState.DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
public String getStateDetailIfBound() {
|
||||
IVpnService s = service;
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
String detail = s.getStateDetail();
|
||||
return detail != null ? detail : "";
|
||||
} catch (RemoteException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
return service != null;
|
||||
}
|
||||
|
||||
public String getStateDetail() {
|
||||
if (!ensureBoundSync()) {
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.foxx.androidcast.remoteaccess;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class RemoteAccessFailureReasonTest {
|
||||
|
||||
@Test
|
||||
public void isControlPlaneMethod_detectsRunPoll() {
|
||||
assertTrue(RemoteAccessFailureReason.isControlPlaneMethod("RemoteAccessService.runPoll"));
|
||||
assertTrue(RemoteAccessFailureReason.isControlPlaneMethod("RemoteAccessHeartbeatClient.postRa"));
|
||||
assertFalse(RemoteAccessFailureReason.isControlPlaneMethod("VpnServiceClient.applyWireGuardConfig"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isTunnelMethod_detectsVpnAndRssh() {
|
||||
assertTrue(RemoteAccessFailureReason.isTunnelMethod("VpnServiceClient.applyWireGuardConfig"));
|
||||
assertTrue(RemoteAccessFailureReason.isTunnelMethod("ReverseSshTunnelBridge.applyConfig"));
|
||||
assertFalse(RemoteAccessFailureReason.isTunnelMethod("RemoteAccessService.runPoll"));
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ nginx -t && rc-service nginx reload
|
||||
nginx -t && rc-service nginx reload
|
||||
```
|
||||
|
||||
**Alpine/nginx note:** regex locations with `{n,m}` quantifiers must be **double-quoted** or nginx parses `{` as a block delimiter (`nginx: [emerg] improper regexp`).
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
|
||||
@@ -16,8 +16,8 @@ server {
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
# Short slug redirect (12–16 hex chars)
|
||||
location ~ ^/[a-f0-9]{6,16}$ {
|
||||
# Short slug redirect (6–16 hex chars). Regex MUST be quoted — bare {6,16} breaks nginx -t.
|
||||
location ~ "^/[a-f0-9]{6,16}$" {
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ Draft interleaved `https://t.co` + token → `htta5bpesf:4/3/3td.2c3oa` → Java
|
||||
| `signer` on GET | Optional query param; omit from success body (security) |
|
||||
| Fail body echoes `bearer` | Truncate in response to first 6 + `…` in production logs; full echo in JSON only for client debugging (SPEC) |
|
||||
|
||||
**Routing suggestion:** nginx sends `/api/` → PHP front controller; `^~ /[a-f0-9]{6,16}$` → redirect handler.
|
||||
**Routing suggestion:** nginx sends `/api/` → PHP front controller; slug paths match regex (quoted on BE: `location ~ "^/[a-f0-9]{6,16}$"`) → redirect handler.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user