From e9bdf60f82c386ebd098a406d8ddcda7ba27e84d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 17:37:45 +0700 Subject: [PATCH] android: Pin direct-flow sockets to the active network (2.13.10) Diag/charon logs from 2026-09-30 16:45 showed that after a Wi-Fi->cellular roam every direct-TCP connect failed asynchronously with EHOSTUNREACH for minutes, while the IKE socket worked and a Java socket explicitly bound to the underlying network connected fine: a bare VpnService.protect() left fresh sockets routed over the vanished network. - CharonVpnService.protectDirect(fd): protect + Network.bindSocket to the active non-VPN network (API 23+, best-effort, adoptFd/detachFd) - charonservice_protect_direct_socket() JNI wrapper; relay and UDP flow sockets use it at creation - direct_tcp: one retry on a fresh socket when an async connect fails; reprotect aborts still-connecting relays (RST) instead of letting the kernel take ~90s to report the dead route; failures log dst=ip:port - direct_udp: reprotect closes all flows; they re-open per packet with a fresh, network-pinned socket - TUN reader: EBADF after a reauth re-establish is no longer logged Published as 2.13.10 (versionCode 147). --- src/frontends/android/app/build.gradle | 4 +- .../android/logic/CharonVpnService.java | 60 ++++++++++ .../backend/android_service.c | 8 +- .../main/jni/libandroidbridge/charonservice.c | 32 ++++- .../main/jni/libandroidbridge/charonservice.h | 7 ++ .../jni/libandroidbridge/routing/direct_tcp.c | 112 +++++++++++++++--- .../jni/libandroidbridge/routing/direct_udp.c | 14 +-- 7 files changed, 202 insertions(+), 35 deletions(-) diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index e4f4d0b77..4e2d58ecd 100644 --- a/src/frontends/android/app/build.gradle +++ b/src/frontends/android/app/build.gradle @@ -37,8 +37,8 @@ android { minSdkVersion 21 targetSdkVersion 36 - versionCode 146 - versionName "2.13.9" + versionCode 147 + versionName "2.13.10" /* 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 4cc38671f..4569c2f59 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 @@ -1284,6 +1284,66 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe return KeyChain.getPrivateKey(getApplicationContext(), mCurrentUserCertificateAlias); } + /** + * Called via JNI for the sockets of direct (RU-split) flows: besides the + * regular VPN bypass this pins the socket to the currently active physical + * network. A bare protect() leaves the network choice to the system's + * default-network bookkeeping, which was observed to keep routing fresh + * sockets over a network that vanished during a Wi-Fi/cellular roam + * (async EHOSTUNREACH), while sockets explicitly bound to the underlying + * network connected fine on the same network. + * + * Must be called before connect(). Best effort: on any failure the socket + * keeps the plain protect() routing. + * + * @param fd socket file descriptor + * @return TRUE if protect() itself succeeded + */ + public boolean protectDirect(int fd) + { + if (!protect(fd)) + { + return false; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + { + android.net.Network network = null; + try + { + ConnectivityManager manager = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE); + network = manager != null ? manager.getActiveNetwork() : null; + if (network != null) + { + android.net.NetworkCapabilities caps = manager.getNetworkCapabilities(network); + if (caps == null || caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) + { /* while the tunnel is up our own default is the physical + * network; fall back to the tracked set otherwise */ + android.net.Network[] tracked = mUnderlyingTracker.getNetworks(); + network = tracked != null && tracked.length > 0 ? tracked[0] : null; + } + } + if (network != null) + { + ParcelFileDescriptor pfd = ParcelFileDescriptor.adoptFd(fd); + try + { + network.bindSocket(pfd.getFileDescriptor()); + } + finally + { + /* release ownership without closing the fd, native code owns it */ + pfd.detachFd(); + } + } + } + catch (Exception e) + { + DiagLog.i(TAG, "direct socket network bind skipped: " + e); + } + } + return true; + } + /** * Initialization of charon, provided by libandroidbridge.so * diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c index 5fe4cc655..4311a0b87 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c @@ -188,7 +188,13 @@ static job_requeue_t handle_plain(private_android_service_t *this) len = read(tunfd, raw.ptr, raw.len); if (len < 0) { - DBG1(DBG_DMN, "reading from TUN device failed: %s", strerror(errno)); + if (errno != EBADF) + { + DBG1(DBG_DMN, "reading from TUN device failed: %s", strerror(errno)); + } + /* EBADF: the TUN got closed between the check above and the read + * (e.g. a reauth re-established it) - retry, the next round sees + * tunfd < 0 and quits */ chunk_free(&raw); return JOB_REQUEUE_FAIR; } 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 ddc0d2296..60d991ade 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.c @@ -235,9 +235,10 @@ failed: } /** - * Bypass a single socket + * Bypass a single socket, optionally pinning it to the active network */ -static bool bypass_single_socket(private_charonservice_t *this, int fd) +static bool bypass_single_socket(private_charonservice_t *this, int fd, + bool direct) { JNIEnv *env; jmethodID method_id; @@ -245,7 +246,8 @@ static bool bypass_single_socket(private_charonservice_t *this, int fd) androidjni_attach_thread(&env); method_id = (*env)->GetMethodID(env, android_charonvpnservice_class, - "protect", "(I)Z"); + direct ? "protectDirect" : "protect", + "(I)Z"); if (!method_id) { goto failed; @@ -270,7 +272,7 @@ CALLBACK(bypass_single_socket_cb, void, private_charonservice_t *this; VA_ARGS_VGET(args, this); - bypass_single_socket(this, fd); + bypass_single_socket(this, fd, FALSE); } METHOD(charonservice_t, bypass_socket, bool, @@ -282,7 +284,7 @@ METHOD(charonservice_t, bypass_socket, bool, { this->sockets->insert_last(this->sockets, (void*)(intptr_t)fd); } - return bypass_single_socket(this, fd); + return bypass_single_socket(this, fd, FALSE); } this->sockets->invoke_function(this->sockets, bypass_single_socket_cb, this); return TRUE; @@ -303,7 +305,25 @@ bool charonservice_protect_socket(int fd) { return FALSE; } - return bypass_single_socket(this, fd); + return bypass_single_socket(this, fd, FALSE); +} + +/** + * Same as charonservice_protect_socket(), but additionally pins the socket to + * the currently active physical network (Java protectDirect()). Used when a + * direct-flow socket is created: after a Wi-Fi/cellular roam a bare protect() + * was observed to keep routing fresh sockets over the vanished network + * (async EHOSTUNREACH), while explicitly bound sockets connected fine. + */ +bool charonservice_protect_direct_socket(int fd) +{ + private_charonservice_t *this = (private_charonservice_t*)charonservice; + + if (!this || fd < 0) + { + return FALSE; + } + return bypass_single_socket(this, fd, TRUE); } /** 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 6d6303552..7ffb08ae4 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h @@ -173,4 +173,11 @@ extern charonservice_t *charonservice; */ bool charonservice_protect_socket(int fd); +/** + * Like charonservice_protect_socket(), but additionally pins the socket to + * the currently active physical network (Java protectDirect()); call before + * connect(). For freshly created direct-flow sockets. + */ +bool charonservice_protect_direct_socket(int fd); + #endif /** CHARONSERVICE_H_ @}*/ 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 63d654e9e..3ca213e8a 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 @@ -74,6 +75,9 @@ typedef struct { bool connected; bool dead; bool remote_eof; + bool retried; + struct sockaddr_storage dest; + socklen_t dest_len; uint8_t *to_sock; size_t to_sock_len, to_sock_cap; uint8_t *to_pcb; @@ -265,6 +269,53 @@ static void relay_flush_to_sock(tcp_relay_t *relay) } } +/** + * "ip:port" of a relay destination for log lines + */ +static void format_dest(const struct sockaddr_storage *ss, char *buf, size_t len) +{ + char host[INET6_ADDRSTRLEN]; + in_port_t port = 0; + + if (ss->ss_family == AF_INET6) + { + inet_ntop(AF_INET6, &((struct sockaddr_in6*)ss)->sin6_addr, host, sizeof(host)); + port = ((struct sockaddr_in6*)ss)->sin6_port; + } + else + { + inet_ntop(AF_INET, &((struct sockaddr_in*)ss)->sin_addr, host, sizeof(host)); + port = ((struct sockaddr_in*)ss)->sin_port; + } + snprintf(buf, len, "%s:%u", host, ntohs(port)); +} + +/** + * Open a protected, network-pinned non-blocking socket and start connecting + * it to the given destination. Returns -1 on failure. + */ +static int relay_connect_socket(const struct sockaddr_storage *ss, socklen_t len) +{ + int fd; + + fd = socket(ss->ss_family, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP); + if (fd < 0) + { + return -1; + } + if (!charonservice_protect_direct_socket(fd)) + { + close(fd); + return -1; + } + if (connect(fd, (struct sockaddr*)ss, len) < 0 && errno != EINPROGRESS) + { + close(fd); + return -1; + } + return fd; +} + /** * lwIP accept callback: a connection arrived for some destination */ @@ -275,6 +326,8 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err) struct sockaddr_storage ss; struct sockaddr_in *sin = (struct sockaddr_in*)&ss; struct sockaddr_in6 *sin6 = (struct sockaddr_in6*)&ss; + socklen_t ss_len; + char dest[INET6_ADDRSTRLEN + 8]; int fd; DIRECT_STATS_ADD(this->stats->tcp_accepts, 1); @@ -285,35 +338,23 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err) sin6->sin6_family = AF_INET6; memcpy(&sin6->sin6_addr, &ip_2_ip6(&newpcb->local_ip)->addr, 16); sin6->sin6_port = htons(newpcb->local_port); + ss_len = sizeof(struct sockaddr_in6); } else { sin->sin_family = AF_INET; memcpy(&sin->sin_addr, &ip_2_ip4(&newpcb->local_ip)->addr, 4); sin->sin_port = htons(newpcb->local_port); + ss_len = sizeof(struct sockaddr_in); } + format_dest(&ss, dest, sizeof(dest)); - fd = socket(ss.ss_family, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP); + fd = relay_connect_socket(&ss, ss_len); if (fd < 0) { DIRECT_STATS_ADD(this->stats->relay_errors, 1); - DBG1(DBG_NET, "direct tcp: accept socket failed: %s", strerror(errno)); - tcp_abort(newpcb); - return ERR_ABRT; - } - if (!charonservice_protect_socket(fd)) - { - DIRECT_STATS_ADD(this->stats->relay_errors, 1); - DBG1(DBG_NET, "direct tcp: accept protect failed fd=%d", fd); - close(fd); - tcp_abort(newpcb); - return ERR_ABRT; - } - if (connect(fd, (struct sockaddr*)&ss, sizeof(ss)) < 0 && errno != EINPROGRESS) - { - DIRECT_STATS_ADD(this->stats->relay_errors, 1); - DBG1(DBG_NET, "direct tcp: accept connect failed: %s", strerror(errno)); - close(fd); + DBG1(DBG_NET, "direct tcp: accept socket setup failed dst=%s: %s", + dest, strerror(errno)); tcp_abort(newpcb); return ERR_ABRT; } @@ -322,7 +363,9 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err) .m = this, .pcb = newpcb, .fd = fd, + .dest_len = ss_len, ); + memcpy(&relay->dest, &ss, ss_len); /* keep our own segments within the TUN MTU regardless of TCP_MSS */ if (newpcb->mss > this->mtu - 72) { @@ -581,6 +624,15 @@ METHOD(direct_tcp_t, reprotect, void, enumerator = this->relays->create_enumerator(this->relays); while (enumerator->enumerate(enumerator, &relay)) { + if (!relay->connected) + { /* SYN still in flight on the old network - it can no longer + * complete (the source/network it started on is gone, and the + * kernel may take over a minute to report EHOSTUNREACH). Abort + * now so the client re-SYNs at once and gets a fresh socket, + * pinned to the new active network. */ + relay->dead = TRUE; + continue; + } if (relay->fd >= 0 && !charonservice_protect_socket(relay->fd)) { DBG1(DBG_NET, "direct tcp: re-protect failed fd=%d", relay->fd); @@ -641,6 +693,7 @@ METHOD(direct_tcp_t, process_poll, void, { int err = 0; socklen_t len = sizeof(err); + char dest[INET6_ADDRSTRLEN + 8]; getsockopt(relay->fd, SOL_SOCKET, SO_ERROR, &err, &len); if (err == 0) @@ -649,8 +702,29 @@ METHOD(direct_tcp_t, process_poll, void, } else { + format_dest(&relay->dest, dest, sizeof(dest)); + if (!relay->retried) + { /* one retry on a fresh socket: the first attempt may have + * been pinned to a network that vanished mid-roam (its + * EHOSTUNREACH can take over a minute to surface, leaving + * the connection hanging) */ + int nfd; + + DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s, retrying on a fresh socket", + dest, strerror(err)); + relay->retried = TRUE; + close(relay->fd); + nfd = relay_connect_socket(&relay->dest, relay->dest_len); + if (nfd >= 0) + { + relay->fd = nfd; + continue; + } + err = errno; + } DIRECT_STATS_ADD(this->stats->relay_errors, 1); - DBG1(DBG_NET, "direct tcp: connect failed async: %s", strerror(err)); + DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s", + dest, strerror(err)); this->relays->remove_at(this->relays, enumerator); relay_destroy(this, relay, TRUE); continue; 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 85675d2ab..0c626bed7 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 @@ -90,7 +90,7 @@ static void flow_close(private_direct_udp_t *this, int slot) } /** - * Open a protected datagram socket connected to the remote end + * Open a protected, network-pinned datagram socket connected to the remote */ static int open_flow_socket(host_t *remote) { @@ -102,7 +102,7 @@ static int open_flow_socket(host_t *remote) DBG1(DBG_NET, "direct udp: socket failed: %s", strerror(errno)); return -1; } - if (!charonservice_protect_socket(fd)) + if (!charonservice_protect_direct_socket(fd)) { DBG1(DBG_NET, "direct udp: protecting socket failed"); close(fd); @@ -291,13 +291,13 @@ METHOD(direct_udp_t, reprotect, void, { int slot; + /* A connected datagram socket cannot be moved to another network; on a + * roam these flows are dead anyway. Close them all - the next packet of + * each flow re-opens it with a fresh socket, pinned to the new active + * network (see open_flow_socket). */ for (slot = 0; slot < FLOW_SLOTS; slot++) { - if (this->flows[slot].fd >= 0 && - !charonservice_protect_socket(this->flows[slot].fd)) - { - DBG1(DBG_NET, "direct udp: re-protect failed on slot %d", slot); - } + flow_close(this, slot); } }