sing-box icon indicating copy to clipboard operation
sing-box copied to clipboard

Dual VPN routing issue: private CIDR should go via corporate VPN interface in sing-box

Open fyeeme opened this issue 1 month ago • 3 comments

Operating system

macOS

System version

26.1

Installation type

sing-box for macOS Graphical Client

If you are using a graphical client, please provide the version of the client.

Version 1.12.9 (1)

Version

sing-box version 1.12.12

Environment: go1.25.3 darwin/arm64
Tags: with_acme,with_clash_api,with_dhcp,with_gvisor,with_quic,with_tailscale,with_utls,with_wireguard
CGO: enabled

Description

1. Background

Due to my work environment, I need to use a corporate VPN to access some internal IP ranges, while also needing sing-box for accessing the open Internet. So I have to run two VPNs at the same time:

  • Corporate VPN: Tunnelblick 8.0 (build 6300)
  • Proxy: clash, sing-box

2. Problem

I had been using clash, but it hasn’t been maintained for more than two years (the repo was taken down). Recently I found there’s a replacement [client](https://github.com/clash-verge-rev/clash-verge-rev) (84.9K), but it feels too heavy. So I started looking for alternatives and eventually found sing-box. Its advantages:

  1. Lightweight, the client is under 10 MB.
  2. Supports automatic failover, so when the selected group goes down, the proxy doesn’t get interrupted and I don’t have to manually switch to another group.

The second point is especially important for me. Because my provider is unstable, I used to have to manually switch nodes from time to time, which was very annoying. So I switched to sing-box directly.

However, a new issue came up: some IP ranges that must go through the corporate VPN started to time out after switching to sing-box.

ERROR[0005] [2634791637 5.0s] connection: open connection to 172.20.8.6:9848 using outbound/direct[direct]: dial tcp 172.20.8.6:9848: i/o timeout
ERROR[0005] [2065905086 5.0s] connection: open connection to 172.20.8.6:9848 using outbound/direct[direct]: dial tcp 172.20.8.6:9848: i/o timeout

From the logs and rules, everything looks correct — it’s already using direct to access that IP. But it still times out. I suspect this is because two VPNs are enabled at the same time, and clash may have handled this scenario automatically, while sing-box does not.

As a result, some IP ranges that should go through the corporate VPN are effectively “hijacked” and sent out via the public network together with other direct IPs, causing access failures.

Verification:

➜  sing-box git:(stable) route get 172.10.8.6                                           

   route to: 172.10.8.6
destination: 172.10.8.6
    gateway: syn-172-110-110-10.res.spectrum.com
  interface: utun6
      flags: <UP,GATEWAY,HOST,DONE,WASCLONED,IFSCOPE,IFREF>
 recvpipe  sendpipe  ssthresh  rtt,msec    rttvar  hopcount      mtu     expire
       0         0  36411581        39        18         0      1500         0 
➜  sing-box git:(stable) route get www.baidu.com

   route to: 223.109.82.212
destination: default
       mask: default
    gateway: 172.10.1.1
  interface: en7
      flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
 recvpipe  sendpipe  ssthresh  rtt,msec    rttvar  hopcount      mtu     expire
       0         0         0         0         0         0      1500         0 
➜  sing-box git:(stable) 

From the output of route get for IPs that should and should not go through the corporate VPN, we can see that different network interfaces are used, which basically confirms the suspicion above.

Once we know the cause, the solution is clear: Update the configuration so that IP ranges needing the corporate VPN use a dedicated network interface, while others use the default interface.

{
    "outbounds": [
        {
            "tag": "direct",
            "type": "direct"
        },
        // New outbound for IP ranges that must go through the corporate VPN
        {
            "tag": "direct-vpn",
            "type": "direct",
            "bind_interface": "utun6"
        }
    ],
    "route": {
        "rules": [
            // Corporate VPN internal IP ranges must be before ip_is_private!
            {
                "ip_cidr": ["172.10.0.0/16", "172.11.0.0/16"],
                "outbound": "direct-vpn"
            },
            // Other private IPs go after
            {
                "ip_is_private": true,
                "outbound": "direct"
            }
        ]
    }
}

In short, add this under outbounds:

{
    "tag": "direct-vpn",
    "type": "direct",
    "bind_interface": "utun6"
}

And add this under route.rules:

// Corporate VPN internal IP ranges must be before ip_is_private!
{
    "ip_cidr": ["172.10.0.0/16", "172.11.0.0/16"],
    "outbound": "direct-vpn"
},

After restarting sing-box, I saw logs like the following, which shows the configuration has taken effect. This finally solved the issue that had troubled me for a week.

INFO[0000] [3699172302 1ms] outbound/direct[direct-vpn]: outbound connection to 172.10.8.18:9848
INFO[0000] [300026688 1ms] outbound/direct[direct-vpn]: outbound connection to 172.10.8.18:9848

Reproduction

see issue detail

Logs


Supporter

Integrity requirements

  • [x] I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
  • [x] I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
  • [x] I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
  • [x] I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.

fyeeme avatar Dec 06 '25 05:12 fyeeme

There is a simpler solution, if you are using Tun, just add the private ip you want sing-box to let alone to the Tun's route_exclude_address section, that way sing-box will not hijack them, and they will be properly routed by the system's routing table. If you are using system proxy (which seems you are not), simply setting the private ip range to use the direct outbound should work.

Mahdi-zarei avatar Dec 07 '25 01:12 Mahdi-zarei

You mean configuring ip_is_private, right? That probably won’t solve the problem.

{
  "route": {
    "rules": [
      {
        "ip_is_private": true,
        "action": "route",
        "outbound": "direct"
      },
      {
        "action": "route",
        "outbound": "proxy"
      }
    ]
  }
}

Maybe I didn’t explain it clearly. Some of the internal network traffic needs to go out via Tunnelblick’s proxy interface (utun6), while most of the other internal traffic should use the default utun7.

So if I simply set "outbound": "direct", Tunnelblick will stop working. This is also why, even though everything is using the "direct" outbound, I still configure bind_interface to make certain internal networks use utun6.

Also, clients like Clash can actually solve this problem out of the box. But with sing-box, I haven’t found a better way so far.

As a result, if I use some sing-box clients that auto-update, they overwrite my rules and break the Tunnelblick configuration. The only way to fix it is to disable updates, but then my “airport” / subscription stops working when the server addresses change.

fyeeme avatar Dec 07 '25 06:12 fyeeme

I’d like to clarify my setup and what I’ve observed, because my earlier comments might have created some confusion around TUN vs system proxy.

In both Clash and sing-box, I am using system proxy mode, not TUN mode. The comparison and all the issues I’m seeing are based on that.

What confused me initially is that with the same basic networking environment (Tunnelblick + macOS), Clash “just works” with my corporate VPN, while sing-box required extra configuration (like bind_interface + ip_cidr). After digging into it, I think I understand why.


1. What actually happens in system-proxy mode on macOS

In system-proxy mode, there are really two layers deciding where traffic goes:

(1) The OS decides whether to send traffic to the proxy

The GUI (Clash or sing-box) calls macOS APIs (like networksetup) to set HTTP/HTTPS/SOCKS proxy to something like:

127.0.0.1:PORT

—that’s usually an inbound like mixed / http / socks in the config.

At the same time, the GUI can also configure a bypass list, e.g.:

127.0.0.1, localhost, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, <local>, ...

Anything matching the bypass list never touches the proxy at all. The app connects directly, and the OS routing table decides whether it goes through utun6 (Tunnelblick), a physical NIC, etc.

(2) Once traffic hits the proxy, the proxy decides: “Proxy or DIRECT?”

If the OS does send a connection to the local proxy, then the app’s connection hits an inbound (e.g. mixed), and now the proxy’s own routing logic (rules, rule-sets, etc.) decides:

  • send it through a remote outbound (proxy), or
  • treat it as DIRECT (connect directly to the target from the local machine).

In sing-box, that might look like:

{
  "route": {
    "rules": [
      {
        "ip_cidr": [
          "172.20.0.0/16",
          "172.31.0.0/16"
        ],
        "outbound": "direct-vpn"
      },
      {
        "ip_is_private": true,
        "outbound": "direct"
      }
    ]
  }
}

Both direct and direct-vpn are “local connects”; they call connect() from the local machine, and then the OS routing table + interface binding decide which NIC (e.g. en7, utun6) actually sends the packets.

The problem I hit is specifically inside step (2): how sing-box chooses the NIC for “DIRECT” outbounds.


2. Why Clash “automatically” works with Tunnelblick

From what I can tell, there are two reasons why Clash “just works” in my environment.

2.1 OS-level bypass: Clash often never sees corporate VPN traffic

Many Clash macOS clients (Clash Verge, older ClashX, etc.) automatically add a bunch of private ranges into the system proxy bypass list when system proxy is enabled, for example:

  • 10.0.0.0/8
  • 172.16.0.0/12
  • 192.168.0.0/16
  • <local>

These already cover a large chunk of “internal” ranges.

In my case, when I access something like 172.20.x.x / 172.31.x.x (corporate VPN subnets), the system may simply decide:

Don’t use the HTTP/SOCKS proxy for this. Connect directly.

Then macOS looks at its routing table, sees that those prefixes are routed via utun6 (Tunnelblick), and sends traffic that way. Clash never even sees those connections. From my point of view, it looks like “Clash handled corporate VPN traffic automatically”, but in reality:

The OS + Tunnelblick handled it; Clash was bypassed.

2.2 Even when traffic reaches Clash, DIRECT usually doesn’t bind a NIC

On top of that, Clash’s DIRECT outbound, in common configs, doesn’t usually bind a specific interface. It just does a normal connect(dst) and lets the OS routing table decide per destination.

For example, on my machine:

route get 172.10.8.6   # corporate VPN host
# -> interface: utun6

route get www.baidu.com
# -> interface: en7

If Clash does:

  • DIRECT to 172.10.8.6 → OS chooses utun6.
  • DIRECT to www.baidu.com → OS chooses en7.

So even if some 172.x traffic does go into Clash, as long as Clash doesn’t override the interface, the actual behavior still matches the OS routing table. That again feels “automatic”.


3. Why sing-box breaks Tunnelblick in system-proxy mode

The key difference I ran into is sing-box’s routing feature: route.auto_detect_interface / route.default_interface.

The official docs explain auto_detect_interface like this (simplified):

Bind outbound connections to the default NIC by default, to prevent routing loops when using TUN.

This makes perfect sense if sing-box is running its own TUN inbound and wants to avoid traffic looping back into itself.

However, in my setup:

  • I am not using a sing-box tun inbound at all.
  • I’m using Tunnelblick as the VPN (utun6) for some internal ranges.
  • I’m using sing-box only as a system proxy (mixed/http inbound + set_system_proxy).

On macOS in this configuration:

  • The default route (0.0.0.0/0) is through my main NIC, e.g. en7.
  • Tunnelblick just adds a few more specific routes like 172.20.0.0/16utun6.

With auto_detect_interface: true enabled:

  1. sing-box detects the default NIC (the interface used for 0.0.0.0/0) — in my case, en7.

  2. Any outbound without an explicit bind_interface (including <type: "direct">) gets bound to en7.

  3. Now when I visit 172.20.8.6:

    • My routing rules say: ip_is_private: trueoutbound: direct.
    • But direct has been bound to en7 by auto_detect_interface.
    • There is no valid route to 172.20.8.6 via en7, because that subnet is only reachable via utun6.
    • Result: timeouts like i/o timeout in the log.

So in system-proxy mode, the thing that broke Tunnelblick for me wasn’t the routing rules themselves, but:

route.auto_detect_interface: true forcing DIRECT traffic onto the default NIC, ignoring the OS routing table’s more specific routes for the corporate VPN ranges.

That’s a great safety feature for sing-box TUN scenarios, but in my “Tunnelblick + system proxy only” setup, it actually works against the OS routing.


4. Why my manual ip_cidr + bind_interface workaround fixed it (before)

Before I realized the auto_detect_interface issue, I tried this workaround:

"outbounds": [
  {
    "tag": "direct",
    "type": "direct"
  },
  {
    "tag": "direct-vpn",
    "type": "direct",
    "bind_interface": "utun6"
  }
],
"route": {
  "rules": [
    {
      "ip_cidr": ["172.10.0.0/16", "172.11.0.0/16"],
      "outbound": "direct-vpn"
    },
    {
      "ip_is_private": true,
      "outbound": "direct"
    }
  ]
}

From sing-box’s point of view this means:

  1. For corporate VPN internal subnets (172.10/16, 172.11/16):

    • Match the first rule → use outbound direct-vpn.
    • direct-vpn is explicitly bind_interface: utun6.
    • Once an outbound has its own bind_interface, the global auto_detect_interface doesn’t override it.
    • So these ranges always go through Tunnelblick (utun6).
  2. For other private IPs (192.168.x.x, 10.x.x.x, other 172.* ranges that don’t match the first rule):

    • Match the second rule → ip_is_private: trueoutbound: direct.
    • direct still gets bound by auto_detect_interface to the default NIC (e.g. en7).
    • That’s acceptable for my local/private but non-VPN subnets.
  3. Public IPs fall back to whatever outbound I assign in final (usually some proxy node).

Effectively, I was re-implementing the OS routing logic inside sing-box:

“These specific 172.x ranges belong to Tunnelblick → bind them to utun6. Other internal ranges use the default NIC.”

This worked, but it meant I had to maintain corporate subnets manually in my sing-box config.


5. The key experiment: disabling auto_detect_interface in pure system-proxy mode

Then I tried the obvious experiment:

  • Keep using only system-proxy mode in sing-box (no sing-box TUN).
  • Disable route.auto_detect_interface.

In other words:

"route": {
  "rules": [
    {
      "ip_is_private": true,
      "outbound": "direct"
    }
    // plus other usual rules: CN direct, others proxy, etc.
  ],
  "final": "proxy",
  "auto_detect_interface": false
}

With this change:

  • direct no longer has a forced NIC.

  • It simply does connect(dst) and lets the OS routing table decide the interface — just like Clash’s DIRECT in most cases.

  • For corporate VPN ranges like 172.20.x.x:

    • The OS knows they should go to utun6 (Tunnelblick).
    • sing-box doesn’t override that anymore.
  • For normal public traffic:

    • The OS chooses the normal default NIC.

At that point, I no longer needed ip_cidr + bind_interface: utun6 at all in pure system-proxy mode. The behavior became very similar to what I see with Clash.


6. My current conclusions and suggested configuration

Based on all this, here’s how I understand it and how I plan to configure things.

A. If I’m using sing-box only as system proxy (no sing-box TUN)

In this scenario:

  • Tunnelblick owns its own TUN (utun6) and routes some internal subnets.
  • sing-box just listens on 127.0.0.1:PORT and is set as the system HTTP/HTTPS/SOCKS proxy.

What I will do:

  1. Disable route.auto_detect_interface:

    "route": {
      "rules": [
        {
          "ip_is_private": true,
          "outbound": "direct"
        }
        // ...other rules...
      ],
      "final": "proxy",
      "auto_detect_interface": false
    }
    
  2. Keep direct as a normal outbound without any bind_interface.

    "outbounds": [
      {
        "type": "direct",
        "tag": "direct"
      },
      {
        "type": "socks",
        "tag": "proxy",
        "server": "your-server",
        "server_port": 12345
      }
    ]
    

This way:

  • Any DIRECT (including internal VPN ranges) is handed back to the OS routing table.
  • The OS + Tunnelblick decide whether the traffic goes out via utun6 or a physical NIC.
  • The behavior matches my expectations and is very close to Clash in system-proxy mode.

I no longer need bind_interface: utun6 or per-subnet ip_cidr rules inside sing-box for the corporate VPN case.

B. If I ever use sing-box TUN plus Tunnelblick (dual tunneling)

This is a different scenario (I haven’t fully switched to it, but conceptually):

  • sing-box runs its own TUN inbound and wants to capture most traffic.
  • Tunnelblick is another TUN (utun6) for a subset of routes.

In that case, I now understand:

  • It does make sense to keep auto_detect_interface: true to avoid loops.

  • And for the subnets that must go through Tunnelblick, I should:

    • create a dedicated outbound with bind_interface: "utun6", and
    • route specific ip_cidr ranges there with higher-priority rules.

For example:

"outbounds": [
  {
    "type": "direct",
    "tag": "direct"
  },
  {
    "type": "direct",
    "tag": "direct-vpn",
    "bind_interface": "utun6"
  }
],
"route": {
  "rules": [
    {
      "ip_cidr": [
        "172.10.0.0/16",
        "172.11.0.0/16"
      ],
      "outbound": "direct-vpn"
    },
    {
      "ip_is_private": true,
      "outbound": "direct"
    }
  ],
  "final": "proxy",
  "auto_detect_interface": true
}

In short:

  • System-proxy only (my current real-world setup):

    • Turn off auto_detect_interface.
    • Don’t bind interfaces on direct.
    • Let macOS + Tunnelblick handle which NIC to use.
  • TUN mode (if I ever switch to it):

    • Keep auto_detect_interface on.
    • Use bind_interface + ip_cidr on special outbounds for Tunnelblick (utun6).

TL;DR

  • The reason Clash “just worked” with Tunnelblick while sing-box did not was not that Clash magically understood my corporate VPN, but that:

    • Clash either bypassed those subnets at the system-proxy level, or
    • didn’t bind a specific NIC for DIRECT and simply respected the OS routing table.
  • In my sing-box setup, route.auto_detect_interface: true forced all DIRECT traffic onto the default NIC, effectively ignoring the OS’s more specific routes to utun6.

  • After disabling auto_detect_interface in pure system-proxy mode, sing-box now behaves correctly without any ip_cidr + bind_interface hacks, and Tunnelblick works as expected. @Mahdi-zarei

fyeeme avatar Dec 07 '25 07:12 fyeeme