diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index 094cc4178..0c9b395e8 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 155 - versionName "2.14.2" + versionCode 159 + versionName "2.14.6" /* 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 9cfcd1ea2..cda134e30 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 @@ -565,6 +565,10 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe mIsDisconnecting = true; SimpleFetcher.disable(); deinitializeCharon(); + /* the native engine (and its pushed set) is gone with charon: + * reset the flag so the next disabled push can early-return + * again until a fresh session pushes at CHILD_SA_UP */ + sUserspacePushActive = false; Log.i(TAG, "charon stopped"); DiagLog.i(TAG, "charon stopped"); mCurrentProfile = null; @@ -1218,7 +1222,9 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe DiagLog.i(TAG, "route diag: udp=" + s[4] + " pkts (" + s[12] + " flows), tcp=" + s[6] + " pkts (" + s[13] + " flows, " + s[14] + " accepts)"); DiagLog.i(TAG, "route diag: drops icmp/frag/malformed=" + s[8] + "/" + s[9] + "/" + s[10] - + ", relayErrors=" + s[11] + ", cooldownHits=" + s[19]); + + ", relayErrors=" + s[11] + ", cooldownHits=" + s[19] + + (s.length > 20 ? ", fallbackHits=" + s[20] : "") + + (s.length > 21 ? ", queueDrops=" + s[21] : "")); } private void scheduleRouteDiagnostics() @@ -1466,6 +1472,20 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe + " failed connects, cooldown " + cooldownS + "s"); } + /** + * Called via JNI when a dead direct destination is rerouted through the + * tunnel: its failure cooldown escalated past the fallback bar, so the + * engine stops refusing the app's connects with RSTs and hands the host + * to the IPsec path instead (until the network changes or the failure + * state expires). + * + * @param dst preformatted "ip:port" of the destination + */ + public void logFallbackDestination(String dst) + { + DiagLog.i(TAG, "direct tcp: dst=" + dst + " dead: rerouting via tunnel"); + } + /** * Initialization of charon, provided by libandroidbridge.so * 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 4f6c78a9d..6d4b75b18 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 @@ -268,6 +268,7 @@ fun HomeScreen( history = probeHistory, deadTargets = deadTargets, onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) }, + onForceTunnel = viewModel::forceTunnelDeadTarget, onRefresh = { viewModel.refreshProbes() }, ) } @@ -854,6 +855,7 @@ private fun AvailabilityCard( history: Map>, deadTargets: List, onOpenRouting: () -> Unit, + onForceTunnel: (String) -> Unit, onRefresh: () -> Unit, ) { @@ -953,7 +955,7 @@ private fun AvailabilityCard( ) } deadTargets.take(3).forEach { target -> - DeadTargetRow(target = target) + DeadTargetRow(target = target, onForceTunnel = onForceTunnel) } if (deadTargets.size > 3) { @@ -992,9 +994,10 @@ private fun AvailabilityCard( } } -/** A dead direct destination: address, the app hammering it, refusal stats. */ +/** A dead direct destination: address, the app hammering it, refusal stats, + * and a one-tap action to force the host through the tunnel. */ @Composable -private fun DeadTargetRow(target: DeadTargetUi) +private fun DeadTargetRow(target: DeadTargetUi, onForceTunnel: (String) -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, @@ -1019,6 +1022,26 @@ private fun DeadTargetRow(target: DeadTargetUi) color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (target.viaTunnel) + { + Text( + text = stringResource(R.string.dead_targets_via_tunnel), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + else + { + TextButton( + onClick = { onForceTunnel(target.dst) }, + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp), + ) { + Text( + text = stringResource(R.string.dead_targets_force), + style = MaterialTheme.typography.labelMedium, + ) + } + } } } 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 acb075338..e459d66f7 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 @@ -52,6 +52,7 @@ import org.strongswan.android.logic.VpnProfileExporter import org.strongswan.android.logic.VpnStateService import org.strongswan.android.logic.VpnStateService.ErrorState import org.strongswan.android.logic.VpnStateService.State +import org.strongswan.android.logic.routing.RoutingManager import org.strongswan.android.logic.connectivity.ConnectivityProbe import org.strongswan.android.logic.connectivity.HealthCheckManager import org.strongswan.android.logic.connectivity.ProbeResult @@ -99,6 +100,8 @@ data class DeadTargetUi( val windowMs: Long, val leftMs: Long, val owner: String? = null, + /** the engine already reroutes this host through the tunnel */ + val viaTunnel: Boolean = false, ) enum class ShxTab @@ -583,6 +586,23 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V return null to null } + /** + * Adds a /32 (or /128) force-tunnel prefix for a dead "ip:port" target, + * so the host permanently leaves the direct set. Applied on the fly, + * no reconnect needed. + */ + fun forceTunnelDeadTarget(dst: String) + { + val ip = dst.substringBeforeLast(':').removeSurrounding("[", "]") + val bits = if (ip.contains(':')) 128 else 32 + /* on the main dispatcher like every other RoutingManager call: + * its config mutations are read-modify-write and not thread-safe + * across dispatchers */ + viewModelScope.launch { + RoutingManager.getInstance(getApplication()).addForcePrefix("$ip/$bits") + } + } + /** Snapshot of the direct destinations the engine currently refuses. */ private suspend fun refreshDeadTargets() { @@ -609,11 +629,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V 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. */ + /** Parses "ip:port|fails|window_ms|left_ms|via_tunnel" entries of the native snapshot. */ fun parseDeadTargets(raw: Array?): List = raw?.mapNotNull { entry -> val parts = entry.split('|') - if (parts.size != 4) + if (parts.size < 4) { return@mapNotNull null } @@ -623,6 +643,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V windowMs = parts[2].toLongOrNull() ?: 0, leftMs = parts[3].toLongOrNull() ?: 0, owner = CharonVpnService.getDeadTargetOwner(parts[0]), + viaTunnel = parts.getOrNull(4) == "1", ) }?.sortedByDescending { it.fails } ?: emptyList() } 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 f42813b0c..5aad4e4cb 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c @@ -427,6 +427,38 @@ failed: androidjni_detach_thread(); } +/** + * Tell Java a dead direct destination was rerouted through the tunnel (its + * cooldown escalated past the fallback bar). Best effort; at most once per + * insertion. + */ +void charonservice_log_fallback_destination(const char *dst) +{ + private_charonservice_t *this = (private_charonservice_t*)charonservice; + JNIEnv *env; + jmethodID method_id; + jstring jdst; + + if (!this || !dst) + { + return; + } + + androidjni_attach_thread(&env); + jdst = (*env)->NewStringUTF(env, dst); + method_id = (*env)->GetMethodID(env, android_charonvpnservice_class, + "logFallbackDestination", + "(Ljava/lang/String;)V"); + if (!jdst || !method_id) + { + goto failed; + } + (*env)->CallVoidMethod(env, this->vpn_service, method_id, jdst); +failed: + androidjni_exception_occurred(env); + androidjni_detach_thread(); +} + /** * Converts the given Java array of byte arrays (byte[][]) to a linked list * of chunk_t objects. @@ -1209,15 +1241,25 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void, jlongArray jft4from, jlongArray jft4to, jbyteArray jft6from, jbyteArray jft6to) { /* the engine exists only while the userspace routing mode is enabled; - * in mode A none of its code runs at all */ - direct_engine_t *engine = enabled ? direct_engine_ensure() : direct_engine_get(); + * in mode A none of its code runs at all. It may also only be CREATED + * while the charon libraries are alive: the engine thread registers in + * the strongSwan threading table that library_deinit() frees on every + * disconnect, so a settings toggle pushed from the UI while parked is + * a use-after-free crash - the set is pushed again on the next connect. + * charon_mutex closes the remaining race against a concurrent + * disconnect destroying the engine under the same mutex */ + direct_engine_t *engine; ru_set_t *set; uint32_t *v4f = NULL, *v4t = NULL, *ft4f = NULL, *ft4t = NULL; uint8_t *v6f = NULL, *v6t = NULL, *ft6f = NULL, *ft6t = NULL; size_t n4 = 0, n6 = 0, nf4 = 0, nf6 = 0, n4b = 0; + pthread_mutex_lock(&charon_mutex); + engine = (enabled && charon_running) ? + direct_engine_ensure() : direct_engine_get(); if (!engine) { + pthread_mutex_unlock(&charon_mutex); return; } @@ -1227,6 +1269,7 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void, { /* malformed input, keep the previous set */ free(v4f); free(v4t); + pthread_mutex_unlock(&charon_mutex); return; } v6f = bytes_to_v6(env, jv6from, &n6); @@ -1238,6 +1281,7 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void, free(v4t); free(v6f); free(v6t); + pthread_mutex_unlock(&charon_mutex); return; } ft4f = longs_to_v4(env, jft4from, &nf4); @@ -1250,6 +1294,7 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void, free(v6t); free(ft4f); free(ft4t); + pthread_mutex_unlock(&charon_mutex); return; } ft6f = bytes_to_v6(env, jft6from, &nf6); @@ -1265,12 +1310,14 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void, free(ft4t); free(ft6f); free(ft6t); + pthread_mutex_unlock(&charon_mutex); return; } set = ru_set_create(v4f, v4t, n4, v6f, v6t, n6, ft4f, ft4t, nf4, ft6f, ft6t, nf6); engine->update_set(engine, set); engine->set_enabled(engine, enabled); + pthread_mutex_unlock(&charon_mutex); } /** @@ -1280,7 +1327,7 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray) { direct_engine_t *engine = direct_engine_get(); direct_stats_t s = {}; - jlong v[20]; + jlong v[22]; jlongArray result; memset(&s, 0, sizeof(s)); @@ -1308,16 +1355,18 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray) v[17] = (jlong)s.set_ft_v4; v[18] = (jlong)(s.enabled ? 1 : 0); v[19] = (jlong)s.cooldown_hits; + v[20] = (jlong)s.fallback_hits; + v[21] = (jlong)s.queue_drops; - result = (*env)->NewLongArray(env, 20); - (*env)->SetLongArrayRegion(env, result, 0, 20, v); + result = (*env)->NewLongArray(env, 22); + (*env)->SetLongArrayRegion(env, result, 0, 22, v); 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. + * "ip:port|fails|window_ms|left_ms|via_tunnel", most failures first. */ JNI_METHOD(CharonVpnService, getDeadDirectTargets, jobjectArray) { @@ -1327,6 +1376,9 @@ JNI_METHOD(CharonVpnService, getDeadDirectTargets, jobjectArray) jclass cls; size_t i, n = 0; + /* stack snapshot: zero it so no field can ever leak garbage */ + memset(t, 0, sizeof(t)); + if (engine) { n = engine->get_dead_targets(engine, t, DIRECT_DEAD_MAX); @@ -1340,11 +1392,12 @@ JNI_METHOD(CharonVpnService, getDeadDirectTargets, jobjectArray) result = (*env)->NewObjectArray(env, n, cls, NULL); for (i = 0; i < n; i++) { - char buf[96]; + char buf[160]; 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); + snprintf(buf, sizeof(buf), "%s|%u|%u|%u|%u", + t[i].dst, t[i].fails, t[i].cooldown_ms, t[i].cooldown_left_ms, + t[i].via_tunnel ? 1 : 0); str = (*env)->NewStringUTF(env, buf); (*env)->SetObjectArrayElement(env, result, i, str); (*env)->DeleteLocalRef(env, str); diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h index fc1c23c8f..a0a316ab9 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h @@ -198,4 +198,11 @@ void charonservice_log_connection_owner(const struct sockaddr *local, void charonservice_log_dead_destination(const char *dst, uint32_t fails, uint32_t cooldown_s); +/** + * Log (via Java) that a dead direct destination was rerouted through the + * tunnel (its cooldown escalated past the fallback bar). Best effort; the + * engine calls this at most once per insertion. + */ +void charonservice_log_fallback_destination(const char *dst); + #endif /** CHARONSERVICE_H_ @}*/ 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 1b44b4b84..9b90b32b5 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 @@ -34,8 +34,10 @@ extern void lwip_assert_diag(const char *msg); #include /** - * Poll set capacity; beyond that flows are simply not polled for one round - * (the wakeup pipe keeps the loop spinning at the poll timeout worst case) + * Poll set capacity. The set always fits: TCP relays are capped at + * DIRECT_MAX_TCP_RELAYS, UDP flows at FLOW_SLOTS (256), plus the wakeup + * pipe - a flow beyond the caps is refused at admission instead of being + * silently never polled. */ #define MAX_POLL_FDS 1024 @@ -63,6 +65,23 @@ extern void lwip_assert_diag(const char *msg); */ #define IP4_FRAG_MASK 0x3fff +/** + * Tunnel fallback for dead direct hosts: after the failure cooldown of a + * destination escalated (direct_tcp), its whole address is rerouted through + * the tunnel here - reactive only, entries appear from existing connect + * failures and vanish with the failure state. Guards: FALLBACK_TTL_MS + * without a new failure, any network change, any TUN unbind. + */ +#define FALLBACK_SLOTS 16 +#define FALLBACK_TTL_MS (10 * 60 * 1000) + +typedef struct { + bool used; + bool v6; + uint8_t addr[16]; + uint64_t last_fail; +} fallback_entry_t; + typedef struct private_direct_engine_t private_direct_engine_t; /** @@ -89,6 +108,12 @@ struct private_direct_engine_t { ru_set_t *set; bool enabled; + /** + * Dead direct hosts rerouted through the tunnel (see FALLBACK_* above), + * guarded by set_lock like the classification set + */ + fallback_entry_t fallback[FALLBACK_SLOTS]; + /** * TUN binding, guarded by bind_lock */ @@ -160,6 +185,134 @@ static bool reprotect_pending; static int parse_protocol(chunk_t enc); +/** + * Monotonic clock in milliseconds (matches sys_now() in direct_tcp.c); + * defined below the engine thread helpers, declared here for the fallback + * table + */ +static uint64_t monotonic_ms(void); + +/** + * Is the address currently rerouted through the tunnel? Caller holds + * set_lock (read); linear over a tiny fixed array, only reached for + * destinations the RU set already classified as direct. + */ +static bool fallback_addr(private_direct_engine_t *this, bool v6, const void *addr) +{ + size_t i, len = v6 ? 16 : 4; + + for (i = 0; i < FALLBACK_SLOTS; i++) + { + fallback_entry_t *e = &this->fallback[i]; + + if (e->used && e->v6 == v6 && memcmp(e->addr, addr, len) == 0) + { + return TRUE; + } + } + return FALSE; +} + +METHOD(direct_engine_t, mark_fallback, bool, + private_direct_engine_t *this, bool v6, const void *addr) +{ + uint64_t now = monotonic_ms(); + size_t i, len = v6 ? 16 : 4; + fallback_entry_t *slot; + + this->set_lock->write_lock(this->set_lock); + for (i = 0; i < FALLBACK_SLOTS; i++) + { + fallback_entry_t *e = &this->fallback[i]; + + if (e->used && e->v6 == v6 && memcmp(e->addr, addr, len) == 0) + { + e->last_fail = now; + this->set_lock->unlock(this->set_lock); + return FALSE; + } + } + /* first free slot, else recycle the least recently failed one */ + slot = &this->fallback[0]; + for (i = 0; i < FALLBACK_SLOTS; i++) + { + if (!this->fallback[i].used) + { + slot = &this->fallback[i]; + break; + } + if (this->fallback[i].last_fail < slot->last_fail) + { + slot = &this->fallback[i]; + } + } + memset(slot, 0, sizeof(*slot)); + slot->used = TRUE; + slot->v6 = v6; + memcpy(slot->addr, addr, len); + slot->last_fail = now; + this->set_lock->unlock(this->set_lock); + return TRUE; +} + +/** + * Drop all fallback entries (network change, TUN unbind: the direct path + * may work again). Skips the lock entirely in the common empty case. + */ +static void fallback_clear_all(private_direct_engine_t *this) +{ + size_t i; + + for (i = 0; i < FALLBACK_SLOTS; i++) + { + if (this->fallback[i].used) + { + break; + } + } + if (i == FALLBACK_SLOTS) + { + return; + } + this->set_lock->write_lock(this->set_lock); + memset(this->fallback, 0, sizeof(this->fallback)); + this->set_lock->unlock(this->set_lock); +} + +/** + * Expire entries whose failure state went quiet (piggybacks on the engine + * loop's existing sweep cadence - no extra wakeups) + */ +static void fallback_sweep(private_direct_engine_t *this, uint64_t now) +{ + size_t i; + bool any = FALSE; + + for (i = 0; i < FALLBACK_SLOTS; i++) + { + if (this->fallback[i].used) + { + any = TRUE; + break; + } + } + if (!any) + { + return; + } + this->set_lock->write_lock(this->set_lock); + for (i = 0; i < FALLBACK_SLOTS; i++) + { + fallback_entry_t *e = &this->fallback[i]; + + if (e->used && now - e->last_fail >= FALLBACK_TTL_MS) + { /* no new failures for the whole TTL: let the direct path retry */ + e->used = FALSE; + } + } + this->set_lock->unlock(this->set_lock); +} + void direct_engine_request_reprotect(void) { __atomic_store_n(&reprotect_pending, TRUE, __ATOMIC_RELEASE); @@ -184,45 +337,74 @@ METHOD(direct_engine_t, handle, bool, this->set_lock->read_lock(this->set_lock); if (this->enabled && this->set) { - if (addr.len == 4) - { /* network order (big-endian) bytes as one numeric value - the - * same representation the Java side sorts its ranges by; a plain - * memcpy would reverse the byte order on little-endian ARM and - * break the binary search completely */ - uint32_t a = ((uint32_t)addr.ptr[0] << 24) | - ((uint32_t)addr.ptr[1] << 16) | - ((uint32_t)addr.ptr[2] << 8) | - (uint32_t)addr.ptr[3]; + if (addr.len == 4) + { /* network order (big-endian) bytes as one numeric value - the + * same representation the Java side sorts its ranges by; a plain + * memcpy would reverse the byte order on little-endian ARM and + * break the binary search completely */ + uint32_t a = ((uint32_t)addr.ptr[0] << 24) | + ((uint32_t)addr.ptr[1] << 16) | + ((uint32_t)addr.ptr[2] << 8) | + (uint32_t)addr.ptr[3]; - if (!this->set->force_tunnel_v4(this->set, a) && - this->set->contains_v4(this->set, a)) - { - direct = TRUE; + if (!this->set->force_tunnel_v4(this->set, a) && + this->set->contains_v4(this->set, a)) + { + if (fallback_addr(this, FALSE, addr.ptr)) + { /* dead direct host rerouted by its failure cooldown */ + DIRECT_STATS_ADD(this->stats.fallback_hits, 1); + } + else + { + direct = TRUE; + } + } } - } - else if (addr.len == 16) - { /* memcmp order == network order for v6, no conversion needed */ - const uint8_t *a = (uint8_t*)addr.ptr; + else if (addr.len == 16) + { /* memcmp order == network order for v6, no conversion needed */ + const uint8_t *a = (uint8_t*)addr.ptr; - if (!this->set->force_tunnel_v6(this->set, a) && - this->set->contains_v6(this->set, a)) - { - direct = TRUE; + if (!this->set->force_tunnel_v6(this->set, a) && + this->set->contains_v6(this->set, a)) + { + if (fallback_addr(this, TRUE, addr.ptr)) + { /* dead direct host rerouted by its failure cooldown */ + DIRECT_STATS_ADD(this->stats.fallback_hits, 1); + } + else + { + direct = TRUE; + } + } } - } } this->set_lock->unlock(this->set_lock); /* TCP and UDP leave directly through the engine; only ICMP etc. cannot - * (no raw sockets for apps) and stay in the tunnel. The engine loop - * wakes lwIP (tcp_active) on the first TCP packet, before that the - * stack stays completely dormant. */ + * (no raw sockets for apps) and stay in the tunnel. Count why the + * direct path refused them, so the drops line in route diag is + * meaningful (the packets themselves are counted as tunnel below). + * The engine loop wakes lwIP (tcp_active) on the first TCP packet, + * before that the stack stays completely dormant. */ if (direct) { int proto = parse_protocol(enc); if (proto != IPPROTO_UDP && proto != IPPROTO_TCP) { + switch (proto) + { + case -2: + DIRECT_STATS_ADD(this->stats.drop_frag, 1); + break; + case -1: + DIRECT_STATS_ADD(this->stats.drop_malformed, 1); + break; + default: + /* ICMP and other protocols to RU destinations */ + DIRECT_STATS_ADD(this->stats.drop_icmp, 1); + break; + } direct = FALSE; } } @@ -239,7 +421,7 @@ METHOD(direct_engine_t, handle, bool, this->q_lock->lock(this->q_lock); if (this->queue_full) { /* overflow: drop rather than block the tunnel reader */ - DIRECT_STATS_ADD(this->stats.drop_malformed, 1); + DIRECT_STATS_ADD(this->stats.queue_drops, 1); this->q_lock->unlock(this->q_lock); packet->destroy(packet); return TRUE; @@ -390,6 +572,8 @@ METHOD(direct_engine_t, get_stats, void, .drop_malformed = __atomic_load_n(&this->stats.drop_malformed, __ATOMIC_RELAXED), .relay_errors = __atomic_load_n(&this->stats.relay_errors, __ATOMIC_RELAXED), .cooldown_hits = __atomic_load_n(&this->stats.cooldown_hits, __ATOMIC_RELAXED), + .fallback_hits = __atomic_load_n(&this->stats.fallback_hits, __ATOMIC_RELAXED), + .queue_drops = __atomic_load_n(&this->stats.queue_drops, __ATOMIC_RELAXED), .udp_flows = __atomic_load_n(&this->stats.udp_flows, __ATOMIC_RELAXED), .tcp_flows = __atomic_load_n(&this->stats.tcp_flows, __ATOMIC_RELAXED), .tcp_accepts = __atomic_load_n(&this->stats.tcp_accepts, __ATOMIC_RELAXED), @@ -406,11 +590,23 @@ METHOD(direct_engine_t, get_stats, void, METHOD(direct_engine_t, get_dead_targets, size_t, private_direct_engine_t *this, direct_dead_target_t *out, size_t cap) { + size_t i, n = 0; + if (this->tcp) { - return this->tcp->get_dead_targets(this->tcp, out, cap); + n = this->tcp->get_dead_targets(this->tcp, out, cap); } - return 0; + if (n) + { /* annotate the entries whose address the engine currently reroutes + * through the tunnel (cooldown escalated past the fallback bar) */ + this->set_lock->read_lock(this->set_lock); + for (i = 0; i < n; i++) + { + out[i].via_tunnel = fallback_addr(this, out[i].v6, out[i].addr); + } + this->set_lock->unlock(this->set_lock); + } + return n; } /** @@ -605,6 +801,10 @@ static void *engine_thread(private_direct_engine_t *this) { this->tcp->reprotect(this->tcp); } + /* the network changed: whatever was dead for direct connects + * may work again - drop the tunnel fallbacks with the rest of + * the failure state (tcp->reprotect clears the neg table) */ + fallback_clear_all(this); } this->bind_lock->lock(this->bind_lock); @@ -651,6 +851,7 @@ static void *engine_thread(private_direct_engine_t *this) { this->tcp->reset(this->tcp); } + fallback_clear_all(this); this->unbound_flushed = TRUE; continue; } @@ -668,8 +869,9 @@ static void *engine_thread(private_direct_engine_t *this) if (now - last_sweep >= ENGINE_SWEEP_MS) { /* wall-clock based, so the adaptive timeout does not - * change the flow-expiry cadence */ + * change the flow-expiry cadence */ this->udp->sweep(this->udp); + fallback_sweep(this, now); last_sweep = now; } if (active) @@ -726,6 +928,7 @@ direct_engine_t *direct_engine_create(void) .wake_engine = _wake_engine, .update_set = _update_set, .set_enabled = _set_enabled, + .mark_fallback = _mark_fallback, .get_stats = _get_stats, .get_dead_targets = _get_dead_targets, .destroy = _destroy, 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 b7b670dd5..deec338e5 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 @@ -42,11 +42,28 @@ typedef struct direct_engine_t direct_engine_t; * host that apps keep hammering). For UI surfacing. */ #define DIRECT_DEAD_MAX 8 + +/** + * Cap on simultaneously relayed direct TCP flows. Every live relay, every + * UDP flow (FLOW_SLOTS = 256) and the wakeup pipe must fit into the + * engine's poll set (MAX_POLL_FDS = 1024); beyond the cap new connections + * are refused with a fast RST (counted as relay errors) instead of being + * admitted and then never polled. + */ +#define DIRECT_MAX_TCP_RELAYS 767 typedef struct { - char dst[48]; + /* long enough for a full-form IPv6 address plus ":port" and NUL + * (format_dest writes "ip:port"; 48 truncated the worst v6 case) */ + char dst[64]; uint32_t fails; uint32_t cooldown_ms; uint32_t cooldown_left_ms; + /** destination address, filled by direct_tcp (v4: first 4 bytes) */ + bool v6; + uint8_t addr[16]; + /** the engine currently reroutes this address through the tunnel + * (its cooldown escalated); filled by the engine */ + bool via_tunnel; } direct_dead_target_t; /** @@ -63,6 +80,8 @@ typedef struct direct_stats_t { uint64_t drop_malformed; uint64_t relay_errors; uint64_t cooldown_hits; + uint64_t fallback_hits; + uint64_t queue_drops; uint64_t udp_flows, tcp_flows; uint64_t tcp_accepts; size_t set_v4, set_v6, set_ft_v4, set_ft_v6; @@ -110,6 +129,16 @@ struct direct_engine_t { */ void (*set_enabled)(direct_engine_t *this, bool enabled); + /** + * Reroute one address through the tunnel: called by direct_tcp when a + * destination's failure cooldown escalated (a dead direct host), so its + * packets stop burning in the direct RST loop. Address-only - all ports + * of the host are affected. Entries expire with the failure state and + * are dropped on network changes. Returns TRUE if the address was newly + * added (the caller logs the transition once). + */ + bool (*mark_fallback)(direct_engine_t *this, bool v6, const void *addr); + /** * Wake the engine thread without changing any state (roam re-protect). */ 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 df80a3b8f..830eb33c0 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 @@ -82,6 +82,14 @@ #define NEG_COOLDOWN_MAX_MS (5 * 60 * 1000) #define NEG_EXPIRY_MS (10 * 60 * 1000) +/** + * Once the cooldown window grew to this size the host is considered dead + * for direct connects: its whole address is rerouted through the tunnel + * (direct_engine fallback) until the failure state expires or the network + * changes, so apps stuck on the RST loop get a working path instead. + */ +#define NEG_FALLBACK_MS (3 * 60 * 1000) + /** * Report the dead destination at every NEG_REPORT_FAILS-th failure even * after the window has maxed out, so long-running outages stay visible @@ -520,11 +528,42 @@ static int neg_record_fail(private_direct_tcp_t *this, const struct sockaddr_sto return flags; } +/** + * The destination just (re)entered its cooldown with a window that has + * grown past NEG_FALLBACK_MS: dead for direct connects. Reroute its whole + * address through the tunnel; log the transition once per insertion. + * window is only nonzero when the caller's neg_record_fail armed just now. + */ +static void neg_escalate_fallback(private_direct_tcp_t *this, + const struct sockaddr_storage *ss, + const char *dest, uint32_t window) +{ + bool added; + + if (window < NEG_FALLBACK_MS) + { + return; + } + if (ss->ss_family == AF_INET6) + { + added = this->engine->mark_fallback(this->engine, TRUE, + &((const struct sockaddr_in6*)ss)->sin6_addr); + } + else + { + added = this->engine->mark_fallback(this->engine, FALSE, + &((const struct sockaddr_in*)ss)->sin_addr); + } + if (added) + { + charonservice_log_fallback_destination(dest); + } +} + /** * Forget failures for a destination after a successful connect */ -static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage *ss) -{ +static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage *ss){ size_t i; pthread_mutex_lock(&this->neg_lock); @@ -590,6 +629,12 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err) uint64_t now = now_ms(); int fd; + if (!newpcb) + { /* lwIP reports accept-time allocation failures this way + * (TCP_EVENT_ACCEPT with a NULL pcb) */ + return ERR_ABRT; + } + DIRECT_STATS_ADD(this->stats->tcp_accepts, 1); memset(&ss, 0, sizeof(ss)); @@ -638,14 +683,25 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err) return ERR_ABRT; } + if (this->relays->get_count(this->relays) >= DIRECT_MAX_TCP_RELAYS) + { /* poll-set capacity reached: fail fast instead of admitting a + * flow that would never be polled (its data would stall) */ + DIRECT_STATS_ADD(this->stats->relay_errors, 1); + tcp_abort(newpcb); + return ERR_ABRT; + } + fd = relay_connect_socket(&ss, ss_len); if (fd < 0) { + uint32_t fails = 0, window = 0; + DIRECT_STATS_ADD(this->stats->relay_errors, 1); - if (neg_record_fail(this, &ss, now, NULL, NULL) & NEG_ARMED) + if (neg_record_fail(this, &ss, now, &fails, &window) & NEG_ARMED) { charonservice_log_connection_owner((struct sockaddr*)&org, (struct sockaddr*)&ss, dest); + neg_escalate_fallback(this, &ss, dest, window); } if (neg_should_log(this, &ss, now)) { @@ -764,6 +820,10 @@ static lwip_err_t netif_output(struct netif *netif, struct pbuf *p) { return ERR_BUF; } + /* stack -> kernel direction (SYN-ACKs, data, FINs, retransmissions): + * counted here so tcp= reads symmetric with the TUN -> stack side */ + DIRECT_STATS_ADD(this->stats->tcp_pkts, 1); + DIRECT_STATS_ADD(this->stats->tcp_bytes, len); ip_packet_t *packet = ip_packet_create(chunk_clone(chunk_create(buf, len))); if (packet) { @@ -1057,6 +1117,7 @@ METHOD(direct_tcp_t, process_poll, void, charonservice_log_connection_owner( (struct sockaddr*)&relay->origin, (struct sockaddr*)&relay->dest, dest); + neg_escalate_fallback(this, &relay->dest, dest, window); } if (flags & NEG_ESCALATED) { /* the cooldown window just grew (or a milestone was @@ -1201,6 +1262,16 @@ METHOD(direct_tcp_t, get_dead_targets, size_t, out[i].cooldown_ms = e->cooldown_ms; out[i].cooldown_left_ms = e->cooldown_until > now ? (uint32_t)(e->cooldown_until - now) : 0; + /* raw address for the engine's via_tunnel annotation */ + out[i].v6 = e->dest.ss_family == AF_INET6; + if (out[i].v6) + { + memcpy(out[i].addr, &((struct sockaddr_in6*)&e->dest)->sin6_addr, 16); + } + else + { + memcpy(out[i].addr, &((struct sockaddr_in*)&e->dest)->sin_addr, 4); + } } pthread_mutex_unlock(&this->neg_lock); return n; diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_udp.c b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_udp.c index 0c626bed7..04255749b 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_udp.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/routing/direct_udp.c @@ -215,6 +215,12 @@ METHOD(direct_udp_t, handle_packet, void, { DIRECT_STATS_ADD(this->stats->relay_errors, 1); } + else + { /* kernel -> remote direction, symmetric with the response + * counter in flow_process (which used to be the only one) */ + DIRECT_STATS_ADD(this->stats->udp_pkts, 1); + DIRECT_STATS_ADD(this->stats->udp_bytes, data.len); + } packet->destroy(packet); } 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 06884d4a2..60d6c856e 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 @@ -475,11 +475,13 @@ Wi-Fi Сотовая сеть Недоступные напрямую - Приложения продолжают стучаться к этим адресам, но те не отвечают — движок отклоняет соединения на время паузы. Проверьте серверы или уберите их из настроек использующего их приложения. + Приложения продолжают стучаться к этим адресам, но те не отвечают — движок отклоняет соединения на время паузы. Давно умершие хосты автоматически переводятся в туннель VPN до смены сети. Проверьте серверы или уберите их из настроек использующего их приложения. отказов: %1$d пауза %1$s ещё %1$d Открыть маршрутизацию + через туннель + Через VPN Идентификаторы, 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 62b45f944..88381edc1 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -480,11 +480,13 @@ 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. + Apps keep connecting to these hosts, but they never answer — the engine refuses them for a cooldown window. Long-dead hosts are automatically rerouted through the VPN tunnel until the network changes. Check the servers or remove them from the app that uses them. %1$d failed connects cooldown %1$s +%1$d more Open routing + via tunnel + Via VPN Identifiers, DNS, MTU, split tunneling Import profile Extended diagnostics