android: Pin direct-flow sockets to the active network (2.13.10)
Android / pre-check (push) Failing after 6s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 5s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Canceled after 3s
SonarCloud / sonarcloud (push) Canceled after 0s
TKM / pre-check (push) Canceled after 0s
TKM / tkm (push) Canceled after 0s
Windows / pre-check (push) Canceled after 1s
Windows / cross-compile (win32) (push) Canceled after 0s
Windows / cross-compile (win64) (push) Canceled after 0s
Windows / native (i686, mingw32, win32) (push) Canceled after 0s
Windows / native (x86_64, mingw64, win64) (push) Canceled after 0s

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).
This commit is contained in:
Denozordec committed 2026-09-30 17:37:45 +07:00
1 parent d12fb96dc2
commit e9bdf60f82
7 files changed
+202 -35

No files matched your search

+2 -2
View File
@@ -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}\""
@@ -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
*
@@ -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;
}
@@ -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);
}
/**
@@ -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_ @}*/
@@ -48,6 +48,7 @@
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
@@ -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;
@@ -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);
}
}