From 7185aa4ba513713755b6167744f97cadfcef5706 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 5 Oct 2026 00:56:47 +0700 Subject: [PATCH] android: 2.14.1 - traffic split, exit IP, dead-host visibility (UX phase 1) - ConnectionDetailsSheet: session traffic split "via VPN / direct (RU)" from the native engine counters (userspace mode only; system-routes mode shows no split - the engine never sees that traffic), public exit IP + country via cloudflare cdn-cgi/trace through the tunnel, current underlying network (Wi-Fi + SSID / cellular) - ConnectionCard: one-glance "N% direct" caption when the RU split moves traffic directly - dead direct hosts surfaced in UI: native snapshot of the negative-cache table via new get_dead_targets (mutex-guarded, sorted by failures) + JNI getDeadDirectTargets; section on the Routing tab and a badge in the Home availability card naming dst, failure count, remaining cooldown and the owning app (from the 2.13.15 owner-attribution cache) - HomeViewModel: split/exit/deadTargets as separate hot flows; exit refreshed with the probe round (60s cache), dead targets every round --- src/frontends/android/app/build.gradle | 4 +- .../android/logic/CharonVpnService.java | 34 ++++ .../logic/connectivity/ConnectivityProbe.kt | 55 ++++++ .../android/ui/compose/HomeScreen.kt | 154 +++++++++++++-- .../android/ui/compose/HomeViewModel.kt | 184 +++++++++++++++++- .../android/ui/compose/RoutingScreen.kt | 72 +++++++ .../main/jni/libandroidbridge/charonservice.c | 38 ++++ .../libandroidbridge/routing/direct_engine.c | 11 ++ .../libandroidbridge/routing/direct_engine.h | 20 ++ .../jni/libandroidbridge/routing/direct_tcp.c | 92 ++++++++- .../jni/libandroidbridge/routing/direct_tcp.h | 8 + .../app/src/main/res/values-ru/strings.xml | 13 ++ .../app/src/main/res/values/strings.xml | 13 ++ 13 files changed, 672 insertions(+), 26 deletions(-) diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index a073d0216..28dc34afe 100644 --- a/src/frontends/android/app/build.gradle +++ b/src/frontends/android/app/build.gradle @@ -46,8 +46,8 @@ android { minSdkVersion 23 targetSdkVersion 36 - versionCode 153 - versionName "2.14.0" + versionCode 154 + versionName "2.14.1" /* Base URL of the update storage, trailing slash included */ buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\"" diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index 4e6066158..9cfcd1ea2 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -1442,6 +1442,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe } String[] packages = getPackageManager().getPackagesForUid(uid); String owner = packages != null && packages.length > 0 ? packages[0] : "uid " + uid; + rememberDeadOwner(dst, owner); DiagLog.i(TAG, "direct tcp: dst=" + dst + " owner=" + owner); } catch (Exception e) @@ -1521,6 +1522,39 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe */ public static native long[] getRoutingStats(); + /** + * Direct destinations currently refused by the failure cooldown (dead + * hosts that apps keep hammering), provided by libandroidbridge.so. + * Each entry is "ip:port|fails|window_ms|left_ms", most failures first. + */ + public static native String[] getDeadDirectTargets(); + + /** Last known owning app per dead destination (from owner attribution). */ + private static final java.util.Map sDeadOwners = + new java.util.LinkedHashMap<>(); + + /** Remember the app that kept hammering a now-dead destination (UI badge). */ + private static void rememberDeadOwner(String dst, String owner) + { + synchronized (sDeadOwners) + { + sDeadOwners.put(dst, owner); + while (sDeadOwners.size() > 32) + { + sDeadOwners.remove(sDeadOwners.keySet().iterator().next()); + } + } + } + + /** The app last seen hammering the given dead destination, if known. */ + public static String getDeadTargetOwner(String dst) + { + synchronized (sDeadOwners) + { + return sDeadOwners.get(dst); + } + } + /** * Adapter for VpnService.Builder which is used to access it safely via JNI. * There is a corresponding C object to access it from native code. diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/ConnectivityProbe.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/ConnectivityProbe.kt index daeed670d..b76fc4787 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/ConnectivityProbe.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/ConnectivityProbe.kt @@ -22,7 +22,9 @@ import kotlinx.coroutines.withContext import java.io.IOException import java.net.InetSocketAddress import java.net.Socket +import java.net.URL import java.util.concurrent.TimeUnit +import javax.net.ssl.HttpsURLConnection /** What a probe measures. External targets go through the tunnel, SERVER goes direct. */ enum class ProbeTarget @@ -42,6 +44,12 @@ data class ProbeResult( val direct: Boolean = target == ProbeTarget.SERVER, ) +/** Public exit (as seen by the internet on the other end of the tunnel). */ +data class ExitInfo( + val ip: String, + val country: String?, +) + /** * TCP-connect "ping" for external targets and ICMP ping for the VPN server. * @@ -143,6 +151,53 @@ object ConnectivityProbe return match.groupValues[1].toDoubleOrNull()?.toLong() } + /** + * Resolve the public exit IP (and country code) as seen from the other + * end of the tunnel, via Cloudflare's cdn-cgi/trace. The plain + * connection rides the VPN (the app's default network while connected), + * so the answer reflects the tunnel egress. Returns null on any failure. + */ + suspend fun traceExit(timeoutMs: Int): ExitInfo? = withContext(Dispatchers.IO) + { + try + { + val connection = URL("https://www.cloudflare.com/cdn-cgi/trace").openConnection() + as HttpsURLConnection + try + { + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs + connection.instanceFollowRedirects = false + val text = connection.inputStream.bufferedReader().use { it.readText() } + parseTraceExit(text) + } + finally + { + connection.disconnect() + } + } + catch (_: Exception) + { + null + } + } + + /** Extracts ip= / loc= from a cdn-cgi/trace body. Visible for tests. */ + fun parseTraceExit(text: String): ExitInfo? + { + var ip: String? = null + var loc: String? = null + text.lineSequence().forEach { line -> + val value = line.substringAfter('=', "").trim() + when + { + line.startsWith("ip=") -> ip = value.takeIf { it.isNotEmpty() } + line.startsWith("loc=") -> loc = value.takeIf { it.isNotEmpty() } + } + } + return ip?.let { ExitInfo(ip = it, country = loc) } + } + private fun waitForOrTimeout(process: Process, timeoutMs: Long): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt index 011f3e0c9..4f6c78a9d 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt @@ -52,18 +52,23 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.AltRoute import androidx.compose.material.icons.rounded.ArrowDownward import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Dns import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.GppMaybe import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Public import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.SignalCellularAlt import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -137,6 +142,8 @@ fun HomeScreen( val probes by viewModel.probes.collectAsStateWithLifecycle() val probing by viewModel.probing.collectAsStateWithLifecycle() val whiteList by viewModel.whiteList.collectAsStateWithLifecycle() + val exit by viewModel.exit.collectAsStateWithLifecycle() + val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle() /* rolling RTT history per target, rendered as sparklines in the availability card */ val probeHistory = remember { mutableStateMapOf>() } LaunchedEffect(probes) @@ -259,6 +266,8 @@ fun HomeScreen( probing = probing, whiteList = whiteList, history = probeHistory, + deadTargets = deadTargets, + onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) }, onRefresh = { viewModel.refreshProbes() }, ) } @@ -341,6 +350,7 @@ fun HomeScreen( traffic = traffic, durationSec = durationSec, probes = probes, + exit = exit, onDismiss = { viewModel.openDetails(false) }, onDisconnect = { viewModel.openDetails(false) @@ -576,18 +586,31 @@ private fun ConnectionCard( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn) - Box( - modifier = Modifier - .size(1.dp, 32.dp) - .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)), - ) - TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut) + Column { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn) + Box( + modifier = Modifier + .size(1.dp, 32.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)), + ) + TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut) + } + /* one-glance confirmation that the RU split is working */ + traffic.split?.takeIf { it.directBytes > 0 }?.let { split -> + Text( + text = stringResource(R.string.traffic_direct_share_fmt, split.directShare), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 6.dp), + ) + } } } when (vpnState) @@ -620,6 +643,7 @@ private fun ConnectionDetailsSheet( traffic: TrafficUi, durationSec: Long, probes: Map, + exit: ExitUi?, onDismiss: () -> Unit, onDisconnect: () -> Unit, ) @@ -661,6 +685,36 @@ private fun ConnectionDetailsSheet( label = stringResource(R.string.traffic_up), value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}", ) + /* RU-split session breakdown; null in the system-routes mode where + * the engine never sees the direct traffic */ + traffic.split?.let { split -> + DetailRow( + icon = Icons.Rounded.AltRoute, + label = stringResource(R.string.traffic_split_tunnel), + value = "${formatBytes(split.tunnelBytes)} · ${formatRate(split.tunnelRate)}", + ) + DetailRow( + icon = Icons.Rounded.AltRoute, + label = stringResource(R.string.traffic_split_direct), + value = "${formatBytes(split.directBytes)} · ${formatRate(split.directRate)}", + ) + } + exit?.let { e -> + e.ip?.let { ip -> + DetailRow( + icon = Icons.Rounded.Public, + label = stringResource(R.string.exit_ip_label), + value = e.country?.let { "$ip · $it" } ?: ip, + ) + } + e.network?.let { network -> + DetailRow( + icon = if (e.ssid != null) Icons.Rounded.Wifi else Icons.Rounded.SignalCellularAlt, + label = stringResource(R.string.network_label), + value = e.ssid?.let { "$network · $it" } ?: network, + ) + } + } if (probes.isNotEmpty()) { probes.sortedForDisplay().forEach { result -> @@ -798,6 +852,8 @@ private fun AvailabilityCard( probing: Boolean, whiteList: WhiteListState, history: Map>, + deadTargets: List, + onOpenRouting: () -> Unit, onRefresh: () -> Unit, ) { @@ -866,6 +922,49 @@ private fun AvailabilityCard( } } } + if (deadTargets.isNotEmpty()) + { + Spacer(Modifier.height(8.dp)) + ShxDivider() + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = onOpenRouting) + .padding(vertical = 4.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(MaterialTheme.colorScheme.error, CircleShape), + ) + Text( + text = stringResource(R.string.dead_targets_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Rounded.ChevronRight, + contentDescription = stringResource(R.string.dead_targets_open), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + deadTargets.take(3).forEach { target -> + DeadTargetRow(target = target) + } + if (deadTargets.size > 3) + { + Text( + text = stringResource(R.string.dead_targets_more_fmt, deadTargets.size - 3), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 18.dp, top = 4.dp), + ) + } + } if (whiteList.config.enabled) { Spacer(Modifier.height(8.dp)) @@ -893,10 +992,39 @@ private fun AvailabilityCard( } } +/** A dead direct destination: address, the app hammering it, refusal stats. */ @Composable -private fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?) +private fun DeadTargetRow(target: DeadTargetUi) { Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(start = 18.dp, top = 6.dp, bottom = 2.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + text = target.dst, + style = MaterialTheme.typography.bodyMedium, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ) + Text( + text = listOfNotNull( + target.owner, + stringResource(R.string.dead_targets_fails_fmt, target.fails), + stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)), + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?) +{ Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt index 3d6ef1a31..b6d89239a 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt @@ -23,6 +23,8 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.os.IBinder import android.os.SystemClock import android.widget.Toast @@ -57,6 +59,7 @@ import org.strongswan.android.logic.connectivity.ProbeTarget import org.strongswan.android.logic.connectivity.ServerCheckMode import org.strongswan.android.logic.connectivity.WhiteListManager import org.strongswan.android.logic.connectivity.WhiteListState +import org.strongswan.android.logic.autoconnect.SsidReader import org.strongswan.android.ui.VpnProfileControlActivity import org.strongswan.android.utils.Constants @@ -66,6 +69,36 @@ data class TrafficUi( val rateIn: Long = 0, val rateOut: Long = 0, val durationSec: Long = 0, + /** Session split between the tunnel and direct RU traffic; null when the userspace engine is not active. */ + val split: SplitTrafficUi? = null, +) + +data class SplitTrafficUi( + val tunnelBytes: Long = 0, + val directBytes: Long = 0, + val tunnelRate: Long = 0, + val directRate: Long = 0, +) +{ + val directShare: Int + get() = if (tunnelBytes + directBytes <= 0) 0 else ((directBytes * 100) / (tunnelBytes + directBytes)).toInt() +} + +/** Public exit (ip/country as seen past the tunnel) and the underlying network. */ +data class ExitUi( + val ip: String? = null, + val country: String? = null, + val network: String? = null, + val ssid: String? = null, +) + +/** A direct destination currently refused by the engine's failure cooldown. */ +data class DeadTargetUi( + val dst: String, + val fails: Long, + val windowMs: Long, + val leftMs: Long, + val owner: String? = null, ) enum class ShxTab @@ -107,6 +140,15 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V private val _probing = MutableStateFlow(false) val probing: StateFlow = _probing + /* public exit (ip/country) and the underlying network; refreshed with the + * probe round, cached for EXIT_REFRESH_MS */ + private val _exit = MutableStateFlow(null) + val exit: StateFlow = _exit + + /* direct destinations currently refused by the engine's cooldown */ + private val _deadTargets = MutableStateFlow>(emptyList()) + val deadTargets: StateFlow> = _deadTargets + private var service: VpnStateService? = null /* written from the main thread (pullState) and the poll coroutine (Default) */ @@ -114,6 +156,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V @Volatile private var lastOut = 0L @Volatile private var lastAt = 0L @Volatile private var connectedAt = 0L + @Volatile private var lastExitAt = 0L + @Volatile private var splitAvailable = false + @Volatile private var lastTunnelBytes = 0L + @Volatile private var lastDirectBytes = 0L + @Volatile private var lastSplitAt = 0L private val connection = object : ServiceConnection { @@ -180,7 +227,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V fun refreshProbes() { WhiteListManager.getInstance(getApplication()).checkNow() - viewModelScope.launch { probeNow() } + viewModelScope.launch { + probeNow() + refreshExit(force = true) + refreshDeadTargets() + } } private suspend fun probeNow() @@ -202,6 +253,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V } addAll(healthConfig.targets) } + refreshDeadTargets() + viewModelScope.launch { refreshExit(force = false) } if (targets.isEmpty()) { return @@ -367,12 +420,19 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V lastIn = 0 lastOut = 0 lastAt = 0 + splitAvailable = CharonVpnService.isUserspaceRoutingEnabled(getApplication()) + lastTunnelBytes = 0 + lastDirectBytes = 0 + lastSplitAt = 0 } if (state != State.CONNECTED) { connectedAt = 0 _traffic.value = TrafficUi() _probes.value = emptyMap() + _exit.value = null + _deadTargets.value = emptyList() + lastExitAt = 0 } /* the retry countdown notifies every second; skip the update when nothing * actually changed so the Home screen doesn't recompose for no reason */ @@ -422,13 +482,135 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V rateIn = rateIn, rateOut = rateOut, durationSec = (now - startedAt) / 1000, + split = if (splitAvailable) splitTraffic(now) else null, ) } + /** + * Session split between the tunnel and direct RU traffic from the native + * engine counters (s[1] tunnel bytes, s[3] direct bytes). Only available + * in the userspace routing mode - with system routes the engine never + * sees the direct traffic. + */ + private suspend fun splitTraffic(now: Long): SplitTrafficUi + { + val stats = try + { + withContext(Dispatchers.Default) { CharonVpnService.getRoutingStats() } + } + catch (_: Throwable) + { + null + } + if (stats == null || stats.size < 4) + { + return SplitTrafficUi(tunnelBytes = lastTunnelBytes, directBytes = lastDirectBytes) + } + val tunnel = stats[1].coerceAtLeast(0) + val direct = stats[3].coerceAtLeast(0) + val dt = if (lastSplitAt == 0L) 0.0 else ((now - lastSplitAt) / 1000.0).coerceAtLeast(0.2) + val tunnelRate = if (lastSplitAt == 0L) 0 else ((tunnel - lastTunnelBytes) / dt).toLong().coerceAtLeast(0) + val directRate = if (lastSplitAt == 0L) 0 else ((direct - lastDirectBytes) / dt).toLong().coerceAtLeast(0) + lastTunnelBytes = tunnel + lastDirectBytes = direct + lastSplitAt = now + return SplitTrafficUi(tunnelBytes = tunnel, directBytes = direct, tunnelRate = tunnelRate, directRate = directRate) + } + + /** + * Public exit ip/country via Cloudflare trace through the tunnel, plus + * the current underlying network label. Keeps the last known answer on + * failure (the sheet simply omits the rows before the first success). + */ + private suspend fun refreshExit(force: Boolean) + { + val now = SystemClock.elapsedRealtime() + if (!force && now - lastExitAt < EXIT_REFRESH_MS) + { + return + } + if (_ui.value.vpnState != State.CONNECTED) + { + return + } + val info = ConnectivityProbe.traceExit(EXIT_TIMEOUT_MS) ?: return + lastExitAt = now + val ctx = getApplication() + val (network, ssid) = underlyingNetworkLabel(ctx) + _exit.value = ExitUi(ip = info.ip, country = info.country, network = network, ssid = ssid) + } + + private fun underlyingNetworkLabel(ctx: Context): Pair + { + val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return null to null + for (network in cm.allNetworks) + { + val caps = try + { + cm.getNetworkCapabilities(network) + } + catch (_: Exception) + { + null + } ?: continue + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) + { + continue + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) + { + return ctx.getString(R.string.network_cellular) to null + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) + { + return ctx.getString(R.string.network_wifi) to SsidReader.from(ctx, caps) + } + } + return null to null + } + + /** Snapshot of the direct destinations the engine currently refuses. */ + private suspend fun refreshDeadTargets() + { + if (_ui.value.vpnState != State.CONNECTED) + { + return + } + val raw = try + { + withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() } + } + catch (_: Throwable) + { + null + } ?: return + _deadTargets.value = parseDeadTargets(raw) + } + companion object { private const val TRAFFIC_POLL_MS = 1000L private const val PROBE_TICK_MS = 1000L private const val PROBE_INTERVAL_MS = 10_000L + private const val EXIT_REFRESH_MS = 60_000L + private const val EXIT_TIMEOUT_MS = 5000 + + /** Parses "ip:port|fails|window_ms|left_ms" entries of the native snapshot. */ + fun parseDeadTargets(raw: Array?): List = + raw?.mapNotNull { entry -> + val parts = entry.split('|') + if (parts.size != 4) + { + return@mapNotNull null + } + DeadTargetUi( + dst = parts[0], + fails = parts[1].toLongOrNull() ?: 0, + windowMs = parts[2].toLongOrNull() ?: 0, + leftMs = parts[3].toLongOrNull() ?: 0, + owner = CharonVpnService.getDeadTargetOwner(parts[0]), + ) + }?.sortedByDescending { it.fails } ?: emptyList() } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt index 6e785ecfc..a8ccfac8f 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt @@ -23,8 +23,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -35,6 +37,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -73,6 +76,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -84,15 +88,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.strongswan.android.R import org.strongswan.android.data.VpnProfileDataSource +import org.strongswan.android.logic.CharonVpnService import org.strongswan.android.logic.routing.ForceTunnelPrefix import org.strongswan.android.logic.routing.RuleSet import org.strongswan.android.logic.routing.RoutingManager @@ -235,6 +242,71 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState) modifier = Modifier.padding(horizontal = ScreenPadding), ) + /* ---- dead direct destinations the engine keeps refusing ---- */ + val deadTargets by produceState(initialValue = emptyList()) + { + while (true) + { + val raw = try + { + withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() } + } + catch (_: Throwable) + { + null + } + value = HomeViewModel.parseDeadTargets(raw) + delay(5_000) + } + } + if (deadTargets.isNotEmpty()) + { + ShxSection(title = stringResource(R.string.dead_targets_title)) + { + Text( + text = stringResource(R.string.dead_targets_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + deadTargets.forEachIndexed { index, target -> + ShxSectionDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(MaterialTheme.colorScheme.error, CircleShape), + ) + Column(Modifier.weight(1f)) { + Text( + text = target.dst, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + text = listOfNotNull( + target.owner, + stringResource(R.string.dead_targets_fails_fmt, target.fails), + stringResource( + R.string.dead_targets_cooldown_fmt, + formatDuration(target.leftMs / 1000), + ), + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + /* ---- RU networks direct: geoip list + routing mode ---- */ ShxSection(title = stringResource(R.string.routing_section_ru)) { diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c index e9f61d022..f42813b0c 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c @@ -1314,6 +1314,44 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray) return result; } +/** + * Snapshot of direct destinations currently refused by the failure cooldown + * (dead hosts that apps keep hammering). Each entry is formatted as + * "ip:port|fails|window_ms|left_ms", most failures first. + */ +JNI_METHOD(CharonVpnService, getDeadDirectTargets, jobjectArray) +{ + direct_engine_t *engine = direct_engine_get(); + direct_dead_target_t t[DIRECT_DEAD_MAX]; + jobjectArray result; + jclass cls; + size_t i, n = 0; + + if (engine) + { + n = engine->get_dead_targets(engine, t, DIRECT_DEAD_MAX); + } + cls = (*env)->FindClass(env, "java/lang/String"); + if (!cls) + { + androidjni_exception_occurred(env); + return NULL; + } + result = (*env)->NewObjectArray(env, n, cls, NULL); + for (i = 0; i < n; i++) + { + char buf[96]; + jstring str; + + snprintf(buf, sizeof(buf), "%s|%u|%u|%u", + t[i].dst, t[i].fails, t[i].cooldown_ms, t[i].cooldown_left_ms); + str = (*env)->NewStringUTF(env, buf); + (*env)->SetObjectArrayElement(env, result, i, str); + (*env)->DeleteLocalRef(env, str); + } + return result; +} + /** * Utility function to verify proposal strings (static, so `this` is the class) */ diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.c b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.c index 0415c8b42..1b44b4b84 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.c @@ -403,6 +403,16 @@ METHOD(direct_engine_t, get_stats, void, this->set_lock->unlock(this->set_lock); } +METHOD(direct_engine_t, get_dead_targets, size_t, + private_direct_engine_t *this, direct_dead_target_t *out, size_t cap) +{ + if (this->tcp) + { + return this->tcp->get_dead_targets(this->tcp, out, cap); + } + return 0; +} + /** * Parse the transport protocol of a packet, rejecting fragments */ @@ -717,6 +727,7 @@ direct_engine_t *direct_engine_create(void) .update_set = _update_set, .set_enabled = _set_enabled, .get_stats = _get_stats, + .get_dead_targets = _get_dead_targets, .destroy = _destroy, }, .set_lock = rwlock_create(RWLOCK_TYPE_DEFAULT), diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.h b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.h index 62f440ca9..b7b670dd5 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.h +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_engine.h @@ -37,6 +37,18 @@ typedef struct direct_engine_t direct_engine_t; +/** + * A direct destination currently refused by the failure cooldown (a dead + * host that apps keep hammering). For UI surfacing. + */ +#define DIRECT_DEAD_MAX 8 +typedef struct { + char dst[48]; + uint32_t fails; + uint32_t cooldown_ms; + uint32_t cooldown_left_ms; +} direct_dead_target_t; + /** * Counters snapshot for the "route diag:" block (all fields relaxed * atomics, updated from the processor and engine threads). @@ -108,6 +120,14 @@ struct direct_engine_t { */ void (*get_stats)(direct_engine_t *this, direct_stats_t *stats); + /** + * Snapshot the destinations currently refused by the direct-flow failure + * cooldown (dead hosts apps keep hammering), most failures first. + * Thread-safe (may be called from the JNI thread). + */ + size_t (*get_dead_targets)(direct_engine_t *this, + direct_dead_target_t *out, size_t cap); + void (*destroy)(direct_engine_t *this); }; diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.c b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.c index 42ea2bab2..df80a3b8f 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.c @@ -48,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -169,9 +170,11 @@ struct private_direct_tcp_t { linked_list_t *relays; /** - * Per-destination failure table (negative cache); engine thread only + * Per-destination failure table (negative cache); engine thread only, + * except for get_dead_targets() which locks neg_lock from the JNI thread */ neg_entry_t neg[NEG_SLOTS]; + pthread_mutex_t neg_lock; }; /** @@ -390,19 +393,22 @@ static bool neg_active(private_direct_tcp_t *this, const struct sockaddr_storage uint64_t now) { size_t i; + bool active = FALSE; + pthread_mutex_lock(&this->neg_lock); for (i = 0; i < NEG_SLOTS; i++) { if (neg_match(&this->neg[i], ss)) { if (now < this->neg[i].cooldown_until) { - return TRUE; + active = TRUE; } - return FALSE; + break; } } - return FALSE; + pthread_mutex_unlock(&this->neg_lock); + return active; } /** @@ -412,7 +418,9 @@ static bool neg_should_log(private_direct_tcp_t *this, const struct sockaddr_sto uint64_t now) { size_t i; + bool should = TRUE; + pthread_mutex_lock(&this->neg_lock); for (i = 0; i < NEG_SLOTS; i++) { if (neg_match(&this->neg[i], ss)) @@ -420,12 +428,17 @@ static bool neg_should_log(private_direct_tcp_t *this, const struct sockaddr_sto if (now - this->neg[i].last_log >= NEG_LOG_MS) { this->neg[i].last_log = now; - return TRUE; + should = TRUE; } - return FALSE; + else + { + should = FALSE; + } + break; } } - return TRUE; + pthread_mutex_unlock(&this->neg_lock); + return should; } /** @@ -440,7 +453,9 @@ static int neg_record_fail(private_direct_tcp_t *this, const struct sockaddr_sto { neg_entry_t *entry = NULL, *oldest = &this->neg[0]; size_t i; + int flags = 0; + pthread_mutex_lock(&this->neg_lock); for (i = 0; i < NEG_SLOTS; i++) { if (this->neg[i].used) @@ -494,11 +509,15 @@ static int neg_record_fail(private_direct_tcp_t *this, const struct sockaddr_sto } if (entry->cooldown_ms > prev || entry->fails % NEG_REPORT_FAILS == 0) { - return NEG_ARMED | NEG_ESCALATED; + flags = NEG_ARMED | NEG_ESCALATED; + } + else + { + flags = NEG_ARMED; } - return NEG_ARMED; } - return 0; + pthread_mutex_unlock(&this->neg_lock); + return flags; } /** @@ -508,6 +527,7 @@ static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage { size_t i; + pthread_mutex_lock(&this->neg_lock); for (i = 0; i < NEG_SLOTS; i++) { if (neg_match(&this->neg[i], ss)) @@ -515,6 +535,7 @@ static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage this->neg[i].used = FALSE; } } + pthread_mutex_unlock(&this->neg_lock); } /** @@ -522,7 +543,9 @@ static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage */ static void neg_clear_all(private_direct_tcp_t *this) { + pthread_mutex_lock(&this->neg_lock); memset(this->neg, 0, sizeof(this->neg)); + pthread_mutex_unlock(&this->neg_lock); } /** @@ -1124,6 +1147,7 @@ METHOD(direct_tcp_t, destroy, void, reset(this); netif_remove(&this->netif); this->relays->destroy(this->relays); + pthread_mutex_destroy(&this->neg_lock); free(this); } @@ -1136,6 +1160,52 @@ u32_t sys_now(void) return (u32_t)(time_monotonic(NULL) * 1000); } +METHOD(direct_tcp_t, get_dead_targets, size_t, + private_direct_tcp_t *this, direct_dead_target_t *out, size_t cap) +{ + neg_entry_t *sorted[NEG_SLOTS]; + size_t i, j, n = 0; + uint64_t now = now_ms(); + + if (!cap) + { + return 0; + } + pthread_mutex_lock(&this->neg_lock); + for (i = 0; i < NEG_SLOTS; i++) + { + neg_entry_t *e = &this->neg[i]; + + if (!e->used || e->fails < NEG_FAILS) + { + continue; + } + /* insertion sort by failures, most failing first */ + for (j = n; j > 0 && sorted[j-1]->fails < e->fails; j--) + { + sorted[j] = sorted[j-1]; + } + sorted[j] = e; + n++; + } + if (n > cap) + { + n = cap; + } + for (i = 0; i < n; i++) + { + neg_entry_t *e = sorted[i]; + + format_dest(&e->dest, out[i].dst, sizeof(out[i].dst)); + out[i].fails = e->fails; + out[i].cooldown_ms = e->cooldown_ms; + out[i].cooldown_left_ms = e->cooldown_until > now ? + (uint32_t)(e->cooldown_until - now) : 0; + } + pthread_mutex_unlock(&this->neg_lock); + return n; +} + direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine, direct_stats_t *stats) { @@ -1155,12 +1225,14 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine, .process_poll = _process_poll, .reset = _reset, .destroy = _destroy, + .get_dead_targets = _get_dead_targets, }, .engine = engine, .stats = stats, .mtu = mtu, .relays = linked_list_create(), ); + pthread_mutex_init(&this->neg_lock, NULL); lwip_init(); diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.h b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.h index 0428582c9..06faf9d61 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.h +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_tcp.h @@ -63,6 +63,14 @@ struct direct_tcp_t { */ size_t (*flow_count)(direct_tcp_t *this); + /** + * Snapshot the destinations currently refused by the negative cache, + * most failures first. Thread-safe (called from the JNI thread while + * the engine thread owns the table otherwise). + */ + size_t (*get_dead_targets)(direct_tcp_t *this, + direct_dead_target_t *out, size_t cap); + /** * Add this module's relay sockets to the poll set, returns how many. */ diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 84b9d4bed..06884d4a2 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -467,6 +467,19 @@ Длительность Загружено %1$s, %2$s Отправлено %1$s, %2$s + Через VPN + Напрямую (RU) + %1$d %% напрямую + Выходной IP + Сеть + Wi-Fi + Сотовая сеть + Недоступные напрямую + Приложения продолжают стучаться к этим адресам, но те не отвечают — движок отклоняет соединения на время паузы. Проверьте серверы или уберите их из настроек использующего их приложения. + отказов: %1$d + пауза %1$s + ещё %1$d + Открыть маршрутизацию Идентификаторы, DNS, MTU, split-туннель Импорт профиля Расширенная диагностика diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index eb1266efe..62b45f944 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -472,6 +472,19 @@ Duration Downloaded %1$s, %2$s Uploaded %1$s, %2$s + Via VPN + Direct (RU) + %1$d%% direct + Exit IP + Network + Wi-Fi + Mobile data + Unreachable direct hosts + Apps keep connecting to these hosts, but they never answer — the engine refuses them for a cooldown window. Check the servers or remove them from the app that uses them. + %1$d failed connects + cooldown %1$s + +%1$d more + Open routing Identifiers, DNS, MTU, split tunneling Import profile Extended diagnostics