返回

Android VPN 应用流量处理及绕过特定 IP (127.0.0.1)

java

Android VPN 应用自身流量处理及绕过特定 IP

碰到的问题

在开发一款 VPN 应用时,遇到了一个挺棘手的问题。为了保证应用的自身流量也通过 VPN 隧道,我需要特别处理。简单说,如果不把应用的包名加到 addDisallowedApplication 里,应用就连不上服务器了。

问题在于,如果不用 addDisallowedApplication,应用启动 VPN 后会立刻尝试连接服务器。但这个时候 VPN 连接还没完全建立起来,导致连接总是失败。应用会通过 ping 127.0.0.1:8086 来检测网络是否通畅,只有 ping 通了才会继续连接。

我想知道,有没有办法能直接让流量发到 127.0.0.1:8086,同时又不用 addDisallowedApplication 呢?

问题根源

这个问题的核心在于 VPN 连接建立过程和应用连接逻辑之间的时序冲突。

  1. VPN 连接异步性: VPNService 的 establish() 方法返回一个 ParcelFileDescriptor,这并不代表 VPN 隧道立刻就绪。VPN 连接的建立,包括路由配置、网络接口设置等,是异步进行的。

  2. 应用启动过快: 应用启动 VPN 后立即进行连接尝试。由于 VPN 隧道还没准备好,发往外网的请求自然无法成功,即使目标地址是本地环回地址(127.0.0.1)的特定端口(8086)。

  3. 127.0.0.1 的特殊性: 即使 VPN 隧道就绪,127.0.0.1 也默认会被包含在 VPN 路由中。发送到 127.0.0.1:8086 的数据包会被导向 VPN 接口,而不是直接到达应用自身的服务。

解决方案

解决这个问题的关键是让应用在 VPN 隧道完全建立好之后再进行连接,以及让 127.0.0.1:8086 的流量不经过 VPN 隧道。有几种方法可以实现:

1. 延迟连接 + protect() 方法

这是比较推荐的一种方法,可以同时处理 VPN 连接的异步性和特定 IP/端口的绕过。

  • 原理:

    • VpnService.protect(socket): 这个方法可以保护一个 socket,使其流量不经过 VPN。
    • 延迟连接: 通过在 VPNService 启动后增加延时,等待 VPN 隧道完全建立。
  • 步骤:

    1. 在 VpnService 的 onStartCommand() 方法中,启动 VPN 隧道,并开启一个后台线程/Handler。
    2. 在后台线程/Handler 中,创建用于连接 127.0.0.1:8086 的 Socket。
    3. 调用 VpnService.protect(socket) 保护该 Socket。
    4. 设置一个适当的延时 (比如 2-3 秒),确保 VPN 隧道建立。
    5. 通过该 Socket 连接到 127.0.0.1:8086
    6. 在连接到远端服务时,创建 Socket 时也进行 protect.
  • 代码示例 (Kotlin):

    class MyVpnService : VpnService() {
    
        private val vpnInterface: ParcelFileDescriptor? = null
    
        override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
            // ... 建立 VPN 隧道 ...
           vpnInterface = Builder()
                    .addAddress("10.0.0.2", 32)
                    .addRoute("0.0.0.0", 0)
                    // ... 其他设置 ...
                    .establish()
    
            Thread {
                try {
                    // 1. 创建 Socket
                    val socket = Socket()
    
                    // 2. 保护 Socket
                    protect(socket)
    
                    // 3. 延迟
                    Thread.sleep(3000) // 3 秒延时
    
                    // 4. 连接
                    socket.connect(InetSocketAddress("127.0.0.1", 8086))
    
                    // ... 处理连接 ...
                    val outputStream = socket.getOutputStream()
                    outputStream.write("Hello from VPN!".toByteArray())
    
                    //在连接到外部网络时保护该socket,防止递归
                    val serverSocket = Socket()
                    protect(serverSocket)
                    serverSocket.connect(InetSocketAddress("your.server.ip", 12345))
    
    
                } catch (e: Exception) {
                    e.printStackTrace()
                }
            }.start()
    
            return START_STICKY
        }
    
    //    ...
    }
    
  • 安全建议: protect() 方法只对通过 socket() 系统调用创建的 socket 有效。务必确保使用的 Socket 对象是这样创建的。

  • 进阶使用技巧 : 可以建立一个 socket pool, 通过 protect 方法来跳过 VPN 限制,达到灵活控制哪些 socket 需要走 VPN 哪些不需要。

2. 使用 bindSocket()

这个方法也可以在不用 addDisallowedApplication 的前提下解决问题。

  • 原理:

    • Network.bindSocket(socket): 这个方法可以将一个 socket 绑定到特定的网络接口。可以用来强制 socket 使用特定的网络,比如绕过 VPN。
  • 步骤:

    1. 在VPNService里获取系统的默认网络。
    2. 在创建连接到 127.0.0.1:8086 的 Socket 前,调用 bindSocket() 方法。
  • 代码示例(Kotlin):

```kotlin
 class MyVpnService : VpnService() {
    private var defaultNetwork: Network? = null
        override fun onCreate() {
            super.onCreate()
           val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
            defaultNetwork = connectivityManager.activeNetwork
     }

        override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {

             vpnInterface = Builder()
                .addAddress("10.0.0.2", 32)
                .addRoute("0.0.0.0", 0)
                // ... 其他设置 ...
                .establish()

        // ...建立 VPN...

          Thread {
               try {
               //延迟, 等待 VPN 服务启动
                Thread.sleep(3000)
                   val socket = Socket()
                   defaultNetwork?.bindSocket(socket) // 绑定到默认网络
                    socket.connect(InetSocketAddress("127.0.0.1", 8086))
                     // ... 连接到 127.0.0.1:8086 ...

               } catch (e:Exception) {
                     //...
                 }
              }.start()

           return START_STICKY;
      }
}
```
  • 进阶使用技巧
    bindSocket()方法封装成为工具方法,方便随时调用

3. 显式路由配置

这种方法需要更精细地控制路由表,操作更复杂,适合对网络底层有深入了解的开发者。不推荐初学者使用

  • 原理: 通过在 VpnService.Builder 中精确设置路由,排除 127.0.0.1/32 (或者更具体的 127.0.0.1/8),使其不经过 VPN 隧道。

  • 步骤:

    1. VpnService.Builder 中,不要 添加包含 127.0.0.1 的路由 (比如 0.0.0.0/0)。
    2. 手动添加需要的路由,确保不包含 127.0.0.1。 例如把0.0.0.0/0 拆分成 0.0.0.0/1128.0.0.0/1.
    3. 如果除了127.0.0.1外, 有其他的ip 也想排除, 也可以在这里进行拆分.
    4. 适当延迟应用中的连接逻辑,等待路由配置完成。
  • 代码示例 (Kotlin):

     class MyVpnService : VpnService() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    
           val vpnInterface = Builder()
            .addAddress("10.8.0.2", 32) //vpn interface ip
             .addRoute("0.0.0.0", 1) //CIDR notation,  0.0.0.0~127.255.255.255
             .addRoute("128.0.0.0", 1)// 128.0.0.0~255.255.255.255, 避开 127.0.0.1
               // .addRoute("192.168.1.0", 24) //  specific network
                 // ...
            .establish()
    
      //连接127.0.0.1:8086的socket
       Thread{
            Thread.sleep(3000)  //等待路由配置生效.
               //这里无需做特殊处理.直接创建和连接 socket
              val socket = Socket()
                socket.connect(InetSocketAddress("127.0.0.1",8086))
    
                //...
           }.start()
           return START_STICKY
    
    }
     }
    

注意: 在拆分0.0.0.0/0 的时候,一定要将其完整拆分为 0.0.0.0/1 and 128.0.0.0/1, 这样才能保证除了127.0.0.1外所有流量都进入 VPN 隧道。

  • 安全建议: 错误的网络路由设置可能会导致整个 VPN 连接失败或流量泄露, 需仔细配置和测试。

4. 轮询 + protect()

这是一个更稳妥但稍微复杂一点的方法。

  • 原理: 启动一个轮询线程,不断尝试创建 Socket 并连接到 127.0.0.1:8086,一旦连接成功,立即调用 protect() 保护该 Socket。

  • 步骤:

    1. onStartCommand 方法中建立好 VPN 隧道.
    2. 在主线程之外启动一个不断循环的线程。
    3. 在循环内:
      • 创建 Socket。
      • 尝试连接 127.0.0.1:8086
      • 如果连接成功, 则调用 protect()方法,然后跳出循环.
      • 如果连接失败,短暂休眠 (例如 100 毫秒),然后继续循环。
  • 代码示例(Kotlin):

        class MyVpnService : VpnService() {
              override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
               val vpnInterface = Builder()
                        .addAddress("192.168.0.1", 24)
                      .addRoute("0.0.0.0", 0)
                     .establish()
           //建立vpn tunnel
                  Thread{
                      var connected = false;
                        while(!connected) {
                              try{
                                     val socket = Socket()
                                       socket.connect(InetSocketAddress("127.0.0.1", 8086), 2000) //timeout
                                         protect(socket)
                                    connected = true
    
                              }catch(e:IOException)
                              {
                                      Thread.sleep(100);
                               }
    
                          }
    
                   }.start()
                    return START_STICKY;
               }
           }
    
    

选择哪个方案?

  • 方案1(延迟连接 + protect() 是最常用的,实现起来也最简单。适用于大部分情况。
  • 方案2 (使用bindSocket()) 在不修改路由的情况下也可以达到预期效果, 建议尝试.
  • 方案3(显式路由配置) 适用于对网络路由有深入了解的开发者,可以更精细地控制流量走向,但是也更容易出错。
  • 方案4 (轮询 + protect()) 最为稳健, 避免了竞态条件,但略微增加实现复杂性.

最终,选择哪个方案,需要根据应用的具体需求和开发者的技术水平来决定。