1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:17:39 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-11 21:56:09 +02:00
parent 775948a3b5
commit a656a04511
8 changed files with 285 additions and 65 deletions

View File

@@ -9,6 +9,7 @@ import android.os.Process;
import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.crash.CrashReporter; import com.foxx.androidcast.crash.CrashReporter;
import com.foxx.androidcast.remoteaccess.RemoteAccessFailureReason;
import com.foxx.androidcast.remoteaccess.RemoteAccessMode; import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore; import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore;
import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge; import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge;
@@ -18,6 +19,7 @@ import com.foxx.androidcast.vpn.VpnState;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.FileReader; import java.io.FileReader;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.TimeUnit;
/** VPN / remote-access snapshot for diagnostics and network self-test. */ /** VPN / remote-access snapshot for diagnostics and network self-test. */
public final class DevVpnStatusProbe { public final class DevVpnStatusProbe {
@@ -40,30 +42,42 @@ public final class DevVpnStatusProbe {
String typeLabel = mode == RemoteAccessMode.DISABLED ? "off" : mode.name().toLowerCase(Locale.US); String typeLabel = mode == RemoteAccessMode.DISABLED ? "off" : mode.name().toLowerCase(Locale.US);
r.itemPass("type", typeLabel); r.itemPass("type", typeLabel);
StringBuilder vpnDetailBuf = new StringBuilder(); String vpnDetail = "";
String failReason = last.failReason; if (mainProcess && mode == RemoteAccessMode.WIREGUARD) {
String state = resolveState(context, mode, last, mainProcess, osVpn, vpnDetailBuf::append); vpnDetail = VpnServiceClient.get(context).getStateDetailIfBound();
String vpnDetail = vpnDetailBuf.toString();
if (mode == RemoteAccessMode.WIREGUARD
&& (failReason == null || failReason.isEmpty())
&& RemoteAccessStatusStore.STATE_FAILURE.equals(state)
&& !vpnDetail.isEmpty()) {
failReason = vpnDetail;
} }
DevNetworkSelfTestReport.Level level; String tunnelState = resolveTunnelState(context, mode, last, mainProcess, osVpn);
String controlPlane = resolveControlPlaneState(last);
DevNetworkSelfTestReport.Level tunnelLevel;
if (mode == RemoteAccessMode.DISABLED) { if (mode == RemoteAccessMode.DISABLED) {
level = DevNetworkSelfTestReport.Level.NA; tunnelLevel = DevNetworkSelfTestReport.Level.NA;
} else if (RemoteAccessStatusStore.STATE_SUCCESS.equals(state)) { } else if (RemoteAccessStatusStore.STATE_SUCCESS.equals(tunnelState)) {
level = DevNetworkSelfTestReport.Level.PASS; tunnelLevel = DevNetworkSelfTestReport.Level.PASS;
} else if (RemoteAccessStatusStore.STATE_FAILURE.equals(state)) { } else if (RemoteAccessStatusStore.STATE_FAILURE.equals(tunnelState)) {
level = DevNetworkSelfTestReport.Level.FAIL; tunnelLevel = DevNetworkSelfTestReport.Level.FAIL;
} else { } 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"); 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()) { if (!vpnDetail.isEmpty()) {
r.itemPass("detail", vpnDetail); r.itemPass("detail", vpnDetail);
} }
@@ -77,20 +91,25 @@ public final class DevVpnStatusProbe {
method = "n/a"; method = "n/a";
} }
r.itemPass("method", method); r.itemPass("method", method);
r.itemPass("initiator", formatInitiator(last.initiator, mode));
String initiator = formatInitiator(last.initiator, mode);
r.itemPass("initiator", initiator);
if (!last.sessionId.isEmpty()) { if (!last.sessionId.isEmpty()) {
r.itemPass("session", last.sessionId); 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); 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); appendTraffic(r, context);
if (last.updatedMs > 0L) { if (last.updatedMs > 0L) {
r.itemPass("last-event-age", formatAge(last.updatedMs));
r.itemPass("last-event-ms", Long.toString(last.updatedMs)); r.itemPass("last-event-ms", Long.toString(last.updatedMs));
} }
if (!mainProcess) { if (!mainProcess) {
@@ -98,29 +117,20 @@ public final class DevVpnStatusProbe {
} }
} }
private static String resolveState( /** Tunnel only — not heartbeat poll failures. */
private static String resolveTunnelState(
Context context, Context context,
RemoteAccessMode mode, RemoteAccessMode mode,
RemoteAccessStatusStore.Snapshot last, RemoteAccessStatusStore.Snapshot last,
boolean mainProcess, boolean mainProcess,
boolean osVpn, boolean osVpn) {
java.util.function.Consumer<String> detailOut) {
if (mode == RemoteAccessMode.DISABLED) { if (mode == RemoteAccessMode.DISABLED) {
return RemoteAccessStatusStore.STATE_OFF; return RemoteAccessStatusStore.STATE_OFF;
} }
if (mode == RemoteAccessMode.WIREGUARD) { if (mode == RemoteAccessMode.WIREGUARD) {
int live = VpnState.DOWN; int live = mainProcess ? VpnServiceClient.get(context).getStateIfBound() : VpnState.DOWN;
String detail = ""; if (live == VpnState.UP || (osVpn && RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state)
if (mainProcess) { && RemoteAccessFailureReason.isTunnelMethod(last.method))) {
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; return RemoteAccessStatusStore.STATE_SUCCESS;
} }
if (live == VpnState.ERROR) { if (live == VpnState.ERROR) {
@@ -129,20 +139,13 @@ public final class DevVpnStatusProbe {
if (live == VpnState.CONNECTING) { if (live == VpnState.CONNECTING) {
return RemoteAccessStatusStore.STATE_CONNECTING; return RemoteAccessStatusStore.STATE_CONNECTING;
} }
if (RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state) && osVpn) { if (RemoteAccessFailureReason.isTunnelMethod(last.method)
return RemoteAccessStatusStore.STATE_SUCCESS; && RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
return RemoteAccessStatusStore.STATE_FAILURE;
} }
if (RemoteAccessStatusStore.STATE_CONNECTING.equals(last.state) if (RemoteAccessFailureReason.isTunnelMethod(last.method)
|| RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) { && RemoteAccessStatusStore.STATE_CONNECTING.equals(last.state)) {
return last.state; return RemoteAccessStatusStore.STATE_CONNECTING;
}
return RemoteAccessStatusStore.STATE_IDLE;
}
if (RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state) && osVpn) {
return RemoteAccessStatusStore.STATE_SUCCESS;
}
if (!last.state.isEmpty() && !RemoteAccessStatusStore.STATE_OFF.equals(last.state)) {
return last.state;
} }
return osVpn ? RemoteAccessStatusStore.STATE_SUCCESS : RemoteAccessStatusStore.STATE_IDLE; return osVpn ? RemoteAccessStatusStore.STATE_SUCCESS : RemoteAccessStatusStore.STATE_IDLE;
} }
@@ -151,20 +154,77 @@ public final class DevVpnStatusProbe {
if (mainProcess) { if (mainProcess) {
rsshUp = ReverseSshTunnelBridge.isConnected(); rsshUp = ReverseSshTunnelBridge.isConnected();
} else { } else {
rsshUp = RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state); rsshUp = RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state)
&& RemoteAccessFailureReason.isTunnelMethod(last.method);
} }
if (rsshUp) { if (rsshUp) {
return RemoteAccessStatusStore.STATE_SUCCESS; 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; 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_CONNECTING;
} }
return RemoteAccessStatusStore.STATE_IDLE; 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) { private static String formatInitiator(String stored, RemoteAccessMode mode) {
if (!stored.isEmpty()) { if (!stored.isEmpty()) {
switch (stored) { switch (stored) {

View File

@@ -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;
}
}

View File

@@ -177,7 +177,7 @@ public final class RemoteAccessService extends Service {
this, mode, RemoteAccessStatusStore.STATE_FAILURE, this, mode, RemoteAccessStatusStore.STATE_FAILURE,
"RemoteAccessService.runPoll", "RemoteAccessService.runPoll",
RemoteAccessStatusStore.INITIATOR_DEVICE, RemoteAccessStatusStore.INITIATOR_DEVICE,
e.getMessage(), ""); RemoteAccessFailureReason.fromPollException(this, e), "");
} }
} }

View File

@@ -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() { public String getStateDetail() {
if (!ensureBoundSync()) { if (!ensureBoundSync()) {
return ""; return "";

View File

@@ -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"));
}
}

View File

@@ -31,6 +31,8 @@ nginx -t && rc-service nginx reload
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 ## Verify
```bash ```bash

View File

@@ -16,8 +16,8 @@ server {
try_files $uri /index.php?$query_string; try_files $uri /index.php?$query_string;
} }
# Short slug redirect (1216 hex chars) # Short slug redirect (616 hex chars). Regex MUST be quoted — bare {6,16} breaks nginx -t.
location ~ ^/[a-f0-9]{6,16}$ { location ~ "^/[a-f0-9]{6,16}$" {
try_files $uri /index.php?$query_string; try_files $uri /index.php?$query_string;
} }

View File

@@ -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) | | `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) | | 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.
--- ---