SagerNet / SagerNet/sing-box

Tailscale endpoint does not recover after Android default network changes

Open
#4,515 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
38.1k
Forks
4.6k
Avg merge
19d 15h
Merged PRs (30d)
1

Description

操作系统

Android

系统版本

Android 17

安装类型

sing-box for Android 图形客户端程序

如果您使用图形客户端程序,请提供该程序版本。

1.14.0

版本

描述

Description

When sing-box for Android is running in VPN mode with a Tailscale endpoint, changing the Android system default network can leave the Tailscale endpoint unable to pass traffic.

The problem occurs when Android switches between Wi-Fi and cellular connectivity. Traffic through the Tailscale endpoint works before the network transition, but the embedded Tailscale network state is not fully synchronized with the newly selected Android Network.

After the transition, Tailscale traffic remains unavailable until the sing-box service is restarted. Restarting the service restores connectivity because Tailscale and its sockets are initialized again using the new default network.

I verified the problem and the proposed fix on a Xiaomi 15 running Android 17.

Expected behavior

After Android changes the system default network, the Tailscale endpoint should:

  1. update its last-known default-route interface;
  2. re-evaluate its network state;
  3. protect new Tailscale sockets from being captured by the VPN;
  4. bind new Tailscale sockets to the currently active Android Network;
  5. recover or re-establish connectivity without restarting the sing-box service.

Actual behavior

Without the proposed fix:

  1. Tailscale connectivity works before the Android network transition.
  2. Android changes its default network between Wi-Fi and cellular connectivity.
  3. The Tailscale endpoint is notified about an interface change.
  4. Tailscale traffic does not recover on the new default network.
  5. Restarting the sing-box service restores connectivity.

With the proposed fix, Tailscale connectivity recovers or re-establishes after the same Android default-network transition without restarting the sing-box service.

Reproduction scope

This is an Android platform integration problem involving:

  • Android ConnectivityManager;
  • Android Network;
  • Android VpnService;
  • libbox's platform interface;
  • Tailscale's Android socket-control path;
  • Tailscale's network monitor;
  • sing-box's interface monitor.

The actual transition is generated by Android, and binding a socket to a particular Android network requires Network.bindSocket.

Therefore, the Android-specific part of this problem cannot be reproduced by a normal desktop sing-box CLI process that does not have access to the Android platform APIs.

Test setup

The test environment consists of:

  • Xiaomi 15 running Android 17;
  • sing-box for Android running in VPN mode;
  • a Tailscale endpoint connected to a private Headscale deployment;
  • both Wi-Fi and cellular connectivity available on the Android device;
  • an existing reachable service or peer in the private tailnet;
  • Tailscale DNS enabled for the private tailnet domain.

The Headscale deployment and peer addresses are private, but the problem is not specific to a particular remote application service. It occurs in the Android/Tailscale network lifecycle before application-layer traffic can resume on the newly selected Android network.

Relevant configuration

The configuration below is the relevant minimal portion derived from the tested configuration.

Unrelated VLESS proxy credentials, proxy selectors, remote rule sets, and advertising rules have been removed because they are not involved in the Tailscale socket lifecycle or the Android default-network transition.

{
  "$schema": "https://sing-box.sagernet.org/schema.json",
  "log": {
    "level": "info",
    "timestamp": true
  },
  "dns": {
    "servers": [
      {
        "type": "local",
        "tag": "dns-local"
      },
      {
        "type": "tailscale",
        "tag": "dns-tailscale",
        "endpoint": "tailscale",
        "accept_search_domain": true
      }
    ],
    "rules": [
      {
        "domain_suffix": "tailnet.domain.com",
        "server": "dns-tailscale"
      },
      {
        "preferred_by": "dns-tailscale",
        "server": "dns-tailscale"
      }
    ],
    "final": "dns-local",
    "reverse_mapping": true,
    "strategy": "ipv4_only"
  },
  "endpoints": [
    {
      "type": "tailscale",
      "tag": "tailscale",
      "domain_resolver": "dns-local",
      "control_url": "https://headscale.domain.com",
      "hostname": "android-sfa",
      "accept_routes": true
    }
  ],
  "inbounds": [
    {
      "type": "tun",
      "tag": "tun-in",
      "address": "172.19.0.1/30",
      "dns_mode": "hijack",
      "auto_route": true,
      "strict_route": true
    }
  ],
  "outbounds": [
    {
      "type": "direct",
      "tag": "direct"
    }
  ],
  "route": {
    "rules": [
      {
        "preferred_by": "tailscale",
        "outbound": "tailscale"
      },
      {
        "ip_is_private": true,
        "outbound": "direct"
      }
    ],
    "final": "direct",
    "auto_detect_interface": true,
    "default_domain_resolver": "dns-local",
    "default_network_strategy": "fallback",
    "default_network_type": [
      "cellular",
      "wifi"
    ],
    "default_fallback_network_type": [
      "wifi",
      "cellular"
    ],
    "default_fallback_delay": "300ms"
  },
  "experimental": {
    "cache_file": {
      "enabled": true
    }
  }
}

The complete tested configuration additionally contains unrelated proxy outbounds and remote rule sets. Those sections do not participate in the Tailscale Android socket-control path and are intentionally omitted from the public report to avoid publishing unrelated service credentials.

Steps to reproduce

  1. Enroll the Tailscale endpoint with the Headscale deployment.

  2. Start sing-box for Android in VPN mode while Wi-Fi is the Android system default network.

  3. Wait until the Tailscale endpoint has started and an existing peer or service in the private tailnet is reachable.

  4. Confirm that traffic through the Tailscale endpoint works.

  5. Disable Wi-Fi and wait for Android to select cellular data as the validated default network.

  6. Retry the same Tailscale peer or service.

  7. Without the proposed fix, observe that Tailscale traffic does not recover on the cellular network.

  8. Restart the sing-box service.

  9. Confirm that restarting the service restores Tailscale connectivity.

  10. Repeat the test while switching the Android system default network between cellular connectivity and Wi-Fi.

  11. Install the locally built Release APK containing the proposed changes.

  12. Repeat the same network-transition test.

  13. Confirm that the patched build recovers or re-establishes Tailscale connectivity without restarting the sing-box service.

Reproduction result

Without the proposed fix
  • Tailscale traffic works before the Android default-network transition.
  • Android selects a new default network.
  • Tailscale traffic stops working through the new network.
  • Retrying the connection does not recover it.
  • Restarting the sing-box service restores connectivity.
With the proposed fix
  • Android selects the new default network.
  • The Tailscale default-route interface is updated.
  • Tailscale receives a network-change event after the route state is updated.
  • New Tailscale sockets are protected from the VPN.
  • New Tailscale sockets are bound to the current Android Network.
  • Tailscale connectivity recovers or re-establishes without restarting the sing-box service.

Logs

No complete service log was captured during the original manual reproduction.

The problem did not cause an application crash, so there is no crash stack trace. The observable failure was loss of traffic through the Tailscale endpoint after Android changed the default network, followed by recovery after restarting the sing-box service.

The proposed fix was verified by repeating the same Android default-network transition with the patched Release APK.

I can repeat the reproduction and collect the complete Android service log from:

/sdcard/Android/data/io.nekohasekai.sfa/files/stderr.log

if the maintainers require additional runtime diagnostics.

Technical analysis

The Android application already observes the system default network, but several parts of the embedded Tailscale state are not fully synchronized with the transition.

1. The last-known default-route interface is not updated before the event

The previous interface update path only injects an event into Tailscale's network monitor.

Tailscale may therefore handle the event while its last-known default-route interface still points to the previous Android network interface.

The required ordering is:

  1. call netmon.UpdateLastKnownDefaultRouteInterface(interfaceName);
  2. call NetMon.InjectEvent() after the Tailscale server has started.

Injecting the event before updating the interface state can cause Tailscale to re-evaluate the network using stale route information.

2. Tailscale sockets are protected but not bound to the active Android Network

The Android VPN service protects sockets using VpnService.protect, preventing them from being routed back into the VPN tunnel.

However, protecting a socket and binding it to a particular Android Network are different operations.

After the Android default network changes, newly created Tailscale sockets need to be:

  1. protected using VpnService.protect;
  2. bound to the current Android Network using Network.bindSocket.

Tailscale's Android socket-control path provides the required protect-before-bind ordering when both hooks are installed.

3. The Android default Network is accessed from different threads

Android connectivity callbacks update the current default Network, while Tailscale socket-control callbacks read it from another thread.

The stored Android Network reference therefore requires safe cross-thread visibility.

The proposed Android change marks DefaultNetworkMonitor.defaultNetwork as @Volatile.

4. Startup can race with a default-network callback

Registering the callback and reading the current interface without synchronization can produce the following race:

  1. startup reads an older interface snapshot;
  2. Android reports a newer default interface;
  3. the callback updates Tailscale to the newer interface;
  4. startup writes the older snapshot back to Tailscale.

The callback, initial synchronization, and post-start synchronization therefore need to be serialized.

5. Process-wide Android hooks must be cleared during shutdown

The Tailscale Android socket hooks and default-route state are process-wide.

During endpoint shutdown, the interface callback must be disabled and unregistered. Any callback already in progress must complete before the process-wide hooks and route state are cleared.

Proposed fix

The proposed fix is split into two focused commits because the implementation spans both sing-box core and sing-box for Android.

sing-box core

The core change:

  • adds UsePlatformTailscaleBindToNetwork;
  • adds TailscaleBindToNetwork;
  • exposes both methods through the libbox platform interface;
  • provides compatible no-op implementations for non-Android platform implementations;
  • installs Android socket-protect and network-bind hooks in Tailscale's Android socket-control path;
  • preserves the required protect-before-bind ordering;
  • updates Tailscale's last-known default-route interface before injecting a network-change event;
  • registers directly with sing-box's interface monitor;
  • synchronizes callback, initial, and post-start interface updates;
  • unregisters and drains interface callbacks during shutdown;
  • clears process-wide Android hooks and default-route state during shutdown;
  • adds tests for the update-before-inject ordering.
sing-box for Android

The Android change:

  • marks DefaultNetworkMonitor.defaultNetwork as @Volatile;
  • implements the generated libbox platform methods;
  • enables network binding on Android API level 23 and later;
  • obtains the current Android default Network;
  • duplicates the raw socket descriptor using ParcelFileDescriptor.fromFd;
  • binds the duplicated descriptor using Network.bindSocket;
  • closes only the duplicated descriptor;
  • does not close or invalidate Tailscale's original raw socket descriptor;
  • catches IOException and logs a warning without changing ownership of the original descriptor;
  • provides default no-op implementations for non-VPN platform wrappers.

Socket descriptor ownership

ParcelFileDescriptor.fromFd(fd) duplicates the supplied socket descriptor.

The proposed implementation binds the duplicated descriptor and closes only that duplicate using Kotlin's use block. Tailscale retains ownership of the original raw socket descriptor.

This prevents the Android integration from accidentally closing a socket owned by Tailscale while still allowing Network.bindSocket to operate on the underlying socket.

Ordering and concurrency guarantees

For every observed default-interface transition, the core implementation performs:

  1. update Tailscale's last-known default-route interface;
  2. inject a network-monitor event if the Tailscale server has started.

The interface callback is registered before reading the current interface. The callback, initial synchronization, and post-start synchronization share a mutex so that an older startup snapshot cannot overwrite a newer Android network transition.

During shutdown, the implementation:

  1. marks the interface callback inactive;
  2. unregisters it from sing-box's interface monitor;
  3. waits for any callback already in progress;
  4. clears Tailscale's process-wide Android socket hooks;
  5. clears the last-known default-route interface.

Development and build environment

The changes were built on the following local development machine:

Host operating system: macOS 26.6.2
Host build: 25G83
Host architecture: arm64 / Apple Silicon
Docker version: 29.8.0
Virtual machine memory: 16 GB
Project Go version: go1.26.7
Gradle version: 9.7.0
Gradle JVM maximum heap: 8192 MB
Android compile SDK: 37
Android target SDK: 37
Android NDK: 28.0.13004108
Android build-tools used for verification: 37.0.0

The Android build and Go/libbox build were executed in the prepared containerized build environment rather than using the host macOS Java or Go installation.

Automated verification

The following checks passed:

go test -tags with_gvisor ./protocol/tailscale
go test -race -tags with_gvisor ./protocol/tailscale
CGO_ENABLED=0 GOOS=android GOARCH=arm64 \
  go test -c -tags with_gvisor ./protocol/tailscale

Additional checks completed successfully:

  • git diff --check for both commits;
  • Android arm64 cross-compilation of protocol/tailscale;
  • arm64-only libbox.aar;
  • arm64-only libbox-legacy.aar;
  • Gradle task :app:assembleOtherRelease;
  • APK signature verification;
  • APK zip-alignment verification;
  • Release manifest verification;
  • verification that the Release manifest does not contain the application-debuggable flag;
  • verification that APK native libraries are present only under lib/arm64-v8a/.

Verified Release artifact

The manually tested Release artifact was:

SFA-1.15.0-alpha.2-tailscale-network-switch-arm64-v8a-release.apk

Artifact metadata:

Variant: otherRelease
Package: io.nekohasekai.sfa
Version name: 1.15.0-alpha.2
Version code: 732
Minimum SDK: 24
Target SDK: 37
Native ABI: arm64-v8a only
APK Signature Scheme: v2
APK SHA-256: 99dfa2a184ed58e48313c080034f498db17bf5380a90a6e2256fb95b4e23bdb0

The APK is a locally signed testing artifact and is not intended to replace an official upstream release.

Manual verification

  • Device: Xiaomi 15
  • Android version: Android 17
  • Network transition: Android system default-network switching between Wi-Fi and cellular connectivity
  • Result without the fix: Tailscale traffic does not recover until the sing-box service is restarted
  • Result with the fix: Tailscale connectivity recovers or re-establishes after the network transition
  • Service restart required with the fix: No

Repository integration

The sing-box core commit intentionally does not update the clients/android submodule pointer.

Updating the submodule pointer in the core commit would include unrelated Android repository history. The core and Android changes should be reviewed independently, and the Android submodule pointer can be updated later through the normal client update process after both changes are accepted.

The two proposed commits are intended to be reviewed and integrated together.

Contribution restrictions

I cannot open regular upstream pull requests because:

  • SagerNet/sing-box currently limits pull request creation to repository collaborators;
  • pull request creation is disabled by the repository owner in SagerNet/sing-box-for-android.

Both focused commits are available in my forks and are ready for review.

Could you please advise on the preferred contribution path?

If appropriate, the commits can be cherry-picked directly:

# sing-box
git cherry-pick 7648f1bdc529d3479c7245129f5d6a0b77237c7a

# sing-box for Android
git cherry-pick 86aa8b7bd634e2d8b72fc884e2dd1bf494b6dd30

I can also submit regular pull requests if pull request creation is made available, or revise the changes according to maintainer feedback.

重现方式

When sing-box for Android is running in VPN mode with a Tailscale endpoint, switching the Android system's default network (e.g., between Wi-Fi and mobile data) can prevent the Tailscale endpoint from passing traffic.

日志

支持我们
完整性要求
  • 我保证阅读了文档,了解所有我编写的配置文件项的含义,而不是大量堆砌看似有用的选项或默认值。
  • 我保证提供了可以在本地重现该问题的服务器、客户端配置文件与流程,而不是一个脱敏的复杂客户端配置文件。
  • 我保证提供了可用于重现我报告的错误的最简配置,而不是依赖远程服务器、TUN、图形界面客户端或者其他闭源软件。
  • 我保证提供了完整的配置文件与日志,而不是出于对自身智力的自信而仅提供了部分认为有用的部分。

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the referenced core commit and the libbox platform interface, then inspect the Android ConnectivityManager, DefaultNetworkMonitor, and Tailscale socket-control integration described in the issue. Run the ordering tests mentioned there, then reproduce Wi-Fi/cellular switching with a Release APK and verify that Tailscale traffic recovers without restarting the service.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, go
Domain
mobile, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.