From a656a045117e66cbe6519715fd4bf168bf8e8d82 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Thu, 11 Jun 2026 21:56:09 +0200 Subject: [PATCH] be sync --- .../androidcast/dev/DevVpnStatusProbe.java | 182 ++++++++++++------ .../RemoteAccessFailureReason.java | 105 ++++++++++ .../remoteaccess/RemoteAccessService.java | 2 +- .../androidcast/vpn/VpnServiceClient.java | 30 +++ .../RemoteAccessFailureReasonTest.java | 23 +++ backend/url-shortener/DEPLOY.md | 2 + .../deploy/nginx.be-url-shortener.fragment | 4 +- docs/DRs/20100611_3_url_shortener.md | 2 +- 8 files changed, 285 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessFailureReason.java create mode 100644 app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessFailureReasonTest.java diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java b/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java index ffd6ec5..b0a3c81 100644 --- a/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java +++ b/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java @@ -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 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) { diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessFailureReason.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessFailureReason.java new file mode 100644 index 0000000..8572ee3 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessFailureReason.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java index 4fc1e2e..335c540 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java @@ -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), ""); } } diff --git a/app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java b/app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java index b118d82..c2f4149 100644 --- a/app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java +++ b/app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java @@ -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 ""; diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessFailureReasonTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessFailureReasonTest.java new file mode 100644 index 0000000..1542f1c --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessFailureReasonTest.java @@ -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")); + } +} diff --git a/backend/url-shortener/DEPLOY.md b/backend/url-shortener/DEPLOY.md index b1441ea..d37563e 100644 --- a/backend/url-shortener/DEPLOY.md +++ b/backend/url-shortener/DEPLOY.md @@ -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 diff --git a/backend/url-shortener/deploy/nginx.be-url-shortener.fragment b/backend/url-shortener/deploy/nginx.be-url-shortener.fragment index f664524..f229d5a 100644 --- a/backend/url-shortener/deploy/nginx.be-url-shortener.fragment +++ b/backend/url-shortener/deploy/nginx.be-url-shortener.fragment @@ -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; } diff --git a/docs/DRs/20100611_3_url_shortener.md b/docs/DRs/20100611_3_url_shortener.md index 00dd3a1..3959914 100644 --- a/docs/DRs/20100611_3_url_shortener.md +++ b/docs/DRs/20100611_3_url_shortener.md @@ -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. ---