Magic Tools
Pitfall NotesBy CooconOctober 4, 202624 views6 min read

Clash Verge TUN Mode Breaks All Internet Access: Hysteria2 Traffic Loops Back Into the TUN, and Tailscale Hijacks DNS

Symptoms

Setup: Mac mini, macOS 26.3.1; Clash Verge Rev 2.5.6 with the mihomo v1.19.31 core, running as root through "service mode". The subscription has 7 self-hosted nodes (5 Hysteria2, 2 VLESS-REALITY), global mode, with 美国-DMIT-Hysteria2 (a US Hysteria2 node on DMIT) selected. Tailscale is also running on this machine.

  • With only "system proxy" on, the browser and curl -x http://127.0.0.1:7897 work fine.
  • After turning on "virtual network adapter mode (TUN)" in settings, the switch turns on without any error, but no website loads, not even Baidu.

"Even Chinese sites fail" is the key detail. If DNS poisoning were the only problem, domestic sites would still work. With both domestic and foreign sites down, nothing going through the TUN is getting out.

Debugging tool: talk to the mihomo core's API directly

The Clash Verge GUI only shows switch states, so debugging means asking the core directly. In service mode, the core exposes its control interface on a unix socket that the current user can access (the secret is in config.yaml in the Clash Verge directory):

S=/var/run/clash-verge-service/users/501/verge-mihomo.sock
A="Authorization: Bearer set-your-secret"

curl -s --unix-socket $S -H "$A" http://x/version
# {"meta":true,"version":"v1.19.31"}

# Live logs at debug level; keep this running in another terminal
curl -sN --unix-socket $S -H "$A" "http://x/logs?level=debug"

# Toggle TUN without touching the GUI
curl -s --unix-socket $S -H "$A" -X PATCH http://x/configs -d '{"tun":{"enable":true}}'

Every experiment below uses these three commands: turn TUN on, watch the log, test connectivity with curl --noproxy '*' (bypassing the system proxy so the traffic really goes through the TUN), then turn it off again.

Step 1: is the TUN itself up?

$ ifconfig utun5 | grep inet
	inet 198.18.0.1 --> 198.18.0.1 netmask 0xfffffffc
$ netstat -rn -f inet | grep utun5 | head -3
1                  198.18.0.1         UGSc                utun5
2/7                198.18.0.1         UGSc                utun5
4/6                198.18.0.1         UGSc                utun5

The interface exists, and auto-route has covered the default route with split routes (1/8, 2/7, 4/6…). The core log looks normal too:

[TUN] default interface changed by monitor, => en1
[TUN] Tun adapter listening at: utun5([198.18.0.1/30],[]), mtu: 9000, auto route: true, auto redir: false, ip stack: gVisor

But every connectivity test fails:

https://www.google.com 000 8.002101s ip=31.13.92.37
https://www.baidu.com  000 8.005689s ip=103.235.47.188

So the TUN itself is fine; the problem is what happens after traffic enters it.

Step 2: a suspicious connection in the log, the proxy proxying itself

Next, the connection log after TUN is turned on:

[UDP] 198.18.0.1:55638 --> 179.253.x.x:443 using GLOBAL
[UDP] 198.18.0.1:56470 --> 163.7.x.x:8488 using GLOBAL
[UDP] 198.18.0.1:54918 --> 36.50.x.x:8488 using GLOBAL
[TCP] 198.18.0.1:57612 --> 103.235.47.188:443 using GLOBAL

The first three destinations are the Hysteria2 nodes themselves (:443 is the DMIT Hysteria2, :8488 the other two). The only thing on this machine that sends UDP to those addresses is mihomo itself. The source address is 198.18.0.1, meaning these packets came in through the TUN adapter.

In other words: mihomo wants to reach a Hysteria2 node → the UDP packet isn't bound to the physical interface en1 → the routing table sends it into utun5 → mihomo receives it from the TUN and, per the GLOBAL rule, hands it to Hysteria2 again → back into the TUN… It's a loop, and no connection through Hysteria2 can ever be established.

Step 3: confirm it's specific to Hysteria2 (UDP)

With TUN on, test the delay of nodes using different protocols:

$ curl -s --unix-socket $S -H "$A" "http://x/proxies/<node name>/delay?url=http://www.gstatic.com/generate_204&timeout=5000"
美国-DMIT-VLESS  {"delay":156}
日本-Hysteria2   {"message":"Timeout"}

(The two nodes are a US VLESS node and a Japan Hysteria2 node.) With TUN off, they measure 156 ms and 66 ms, both fine. Comparing logs: every VLESS outbound prints this line, and Hysteria2 never does:

[TUN] Auto detect interface for 179.253.x.x --> en1

auto-detect-interface: true is meant to bind the core's own outbound connections to the physical interface so they don't loop back into the TUN. From what I observed, it works for VLESS (TCP) outbounds and not for Hysteria2 (QUIC/UDP) outbounds. There is a similar report on GitHub (MetaCubeX/mihomo#1222, "after enabling Tun mode, Hysteria and Hysteria2 nodes can't connect") with no official conclusion. I didn't dig into the core's source to confirm, so this describes observed behavior only.

Switching the global node to VLESS and testing again:

TUN+VLESS https://www.baidu.com   200 2.03s  ip=103.235.46.96
TUN+VLESS https://www.google.com  000 5.49s  ip=31.13.92.37
TUN+VLESS https://www.youtube.com 000 6.03s  ip=174.132.167.252

Baidu works, so the loop really is a Hysteria2 problem. But Google and YouTube still fail, and the resolved IPs are wrong: 31.13.92.37 is a Facebook address, a classic DNS-poisoning result. So there's a second problem.

Step 4: why isn't Clash handling DNS?

The TUN config has dns-hijack: [any:53], so every query on port 53 should be intercepted by mihomo. Here's what DNS the system is actually using:

$ scutil --dns | grep "nameserver\[0\]" | sort | uniq -c
   3   nameserver[0] : 100.100.100.100
   2   nameserver[0] : 240e:306:2085:bd00:42b0:76ff:fec3:f948
$ netstat -rn -f inet | grep -E "100.64|100.100.100.100/32"
100.64/10          link#23            UCS                 utun4
100.100.100.100/32 link#23            UCS                 utun4

Tailscale's MagicDNS (100.100.100.100) has taken over system DNS, and that address has a dedicated route to Tailscale's own adapter, utun4. It's more specific than Clash's split routes, so DNS queries go straight into Tailscale and never pass through utun5, which is why dns-hijack never sees them. During the whole investigation, [DNS] hijack appeared in the core log exactly once, triggered by my own manual dig @198.18.0.2.

Tailscale forwards the queries upstream (router / ISP DNS) and gets poisoned IPs back. Inside the TUN, mihomo only sees "connect to 31.13.92.37:443" with no idea of the real domain, so it has the proxy node connect to a Facebook IP while the TLS handshake's SNI says google.com. That can only fail.

Also, Clash Verge changes the system DNS when toggling TUN (the log shows set system dns successfully), but Tailscale's resolver has higher priority in scutil, so that doesn't help either. At the time, the runtime config had no dns section at all ("DNS override" was off and the subscription didn't define one), and no sniffer section.

This site has an earlier piece, Tailscale took over DNS and left the proxy box with no upstream. That time the server side broke; this time it's the client. Both are the same kind of problem: once Tailscale is installed, DNS doesn't go where you think it goes.

The fix

The two problems get separate fixes, and both go into Clash Verge's subscription Merge override (right-click the subscription → "Edit override", or edit that subscription's merge file under profiles/ directly). That way the subscription itself is untouched and updates won't overwrite the changes.

1. Exclude node IPs from the TUN routes

tun:
  route-exclude-address:
    - 179.253.x.x/32   # one line per node server IP
    - 163.7.x.x/32
    - 36.50.x.x/32

With this on, the split routes in the routing table skip these addresses (179.253.232/26, 179.253.232.65/32… with exactly the node's IP missing), so packets to the nodes take the default route out of en1 and never enter the TUN. This fix doesn't depend on the core binding the interface correctly; it cuts the loop at the routing layer.

The cost: when a node changes IP, you have to update this list. If a node is configured by domain name, enter its resolved IP.

2. Turn on sniffer to recover domains from SNI

sniffer:
  enable: true
  parse-pure-ip: true        # also sniff connections whose destination is a bare IP
  force-dns-mapping: true
  override-destination: true # replace the destination IP with the sniffed domain
  sniff:
    HTTP:
      ports: [80, 8080-8880]
    TLS:
      ports: [443, 8443]
    QUIC:
      ports: [443, 8443]

It doesn't matter that DNS bypasses Clash: when the browser opens a TLS connection to the poisoned IP, the SNI in its ClientHello still carries the real domain. sniffer reads it, replaces the destination, and lets the proxy node resolve it remotely.

I also tested sniffer combined with DNS override (fake-ip), and everything worked as well. But with Tailscale owning DNS, fake-ip never actually receives the system's queries; sniffer is what does the work. So I kept only the two sections above.

Another approach: stop Tailscale from managing DNS

tailscale set --accept-dns=false returns system DNS to the router, so queries go through the TUN again and get hijacked. The cost is losing MagicDNS hostname resolution. All the SSH configs on this machine use IPs, so in theory I could turn it off, but the sniffer approach leaves Tailscale alone and touches less, so I didn't use this one.

Verification

Using the runtime config Clash Verge regenerated after a restart (clash-verge.yaml now includes both sections), with TUN on, the global node still on Hysteria2, and --noproxy '*' on every request:

https://www.google.com    200 0.65s
https://www.youtube.com   200 1.66s
https://github.com        200 1.48s
https://api.anthropic.com 403 1.10s   # the root path returns 403 anyway, so it's reachable
https://www.baidu.com     200 1.70s
exit ip country US AS906 DMIT Cloud Services

I also confirmed Tailscale was unaffected: ssh winmini (a Windows machine on the LAN) returned its hostname normally, and 100.100.100.100:53 was reachable. That's because Tailscale's 100.64/10 route is more specific than the TUN's split routes, so its traffic never enters the TUN in the first place.

Checklist: no internet after turning on Clash TUN

  1. Do domestic sites load? If they don't either, traffic isn't getting out at all (loop, outbound problem). If only foreign sites fail, suspect DNS first.
  2. Look for "the proxy connecting to itself" in the core log: 198.18.0.1 --> <node IP>:<node port> in the TUN connection log means a loop.
  3. With TUN on, test node delay per protocol: if only UDP-based protocols (Hysteria2 / TUIC) time out, exclude the node IPs with route-exclude-address.
  4. Check which DNS the system really uses with scutil --dns: if it's 100.100.100.100 (Tailscale) or another VPN's address, DNS isn't going through the TUN and you need sniffer.
  5. Check whether the runtime config has dns and sniffer sections: Clash Verge's runtime file is ~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml. If they're missing, the GUI switches never merged them in.

Get field notes like this every Saturday

Subscribe to Dev Breakfast: daily AI coding picks at 8:00, plus a Saturday roundup of this week's hands-on tests with Claude Code / Codex / local models. Written in Chinese.

Related Articles

Claude Code MCP Shows Connected but 0 Tools: "Invalid result for tools/list" (ttlMs / cacheScope), Reproduced and Fixed

Claude Code MCP Shows Connected but 0 Tools: "Invalid result for tools/list" (ttlMs / cacheScope), Reproduced and Fixed

An MCP server shows as connected but exposes 0 tools, and the log says Invalid result for tools/list with ttlMs and cacheScope failing validation. Reproduced with a stub server on Claude Code 2.1.280, 2.1.285 and 2.1.288: the cause is not extra fields being rejected. The server negotiated MCP 2026-07-28 and then left out fields that revision requires (resultType, ttlMs, cacheScope); adding unknown fields works fine. Whether stdio uses the new protocol is decided by a remote flag that is off by default, so the same version breaks for some users and not others, and 2.1.280 fails the same way once negotiation is on. MCP_PROTOCOL_NEGOTIATION=legacy (also works in settings.json env) restores the tools; servers fix it by adding the three fields.

mcpclaude-code+4
pitfallsOct 3, 20268 min
14
Claude Code install errors, reproduced: EACCES, a 600s mirror stall, Node 20 silently getting an old version, a region-block install.sh, and the native installer removing your npm copy

Claude Code install errors, reproduced: EACCES, a 600s mirror stall, Node 20 silently getting an old version, a region-block install.sh, and the native installer removing your npm copy

I reproduced every Claude Code install failure I could on macOS: 15 verbatim errors, each with wall time and exit code. npm -g into /usr/local fails with EACCES, exit 243. A cache dir that is merely 0555 gets blamed on root-owned files, with sudo chown advice. From Beijing, npmmirror took 147s and then >600s, npmjs 11-12s (2 samples each). On Node 20, an unpinned install silently lands on 2.1.197. Fetching claude.ai/install.sh from a blocked region gives curl exit 0 and a 447 KB HTML page. The native installer runs npm uninstall -g on your npm copy without saying so; it removed mine.

claude-codetroubleshooting+5
pitfallsSep 29, 202611 min
150
Claude Code "bash denied by auto mode": Why It Blocks, "could not evaluate" and "unavailable for this model" Tested

Claude Code "bash denied by auto mode": Why It Blocks, "could not evaluate" and "unavailable for this model" Tested

In auto mode, a blocked Bash call usually shows one of three messages: denied by auto mode, Auto mode could not evaluate this action, or auto mode unavailable for this model. I ran 40-odd real sessions on Claude Code 2.1.280. denied means the classifier judged the action out of scope, most often [Code from External]: it would run external code you never named. Retrying won't help. Name the source in your prompt, or declare it trusted in autoMode.environment in user-level settings. could not evaluate means the classifier returned no usable verdict. unavailable for this model means the model is older than claude-opus-4-6; under claude -p it is silently downgraded, with only a WARN line in the debug log. In 2.1.280 the verdict is computed server-side and returned with the main response. Behind a relay gateway that only admits Claude Code clients, the local fallback classifier request gets a 503, which is a reliable cause of "temporarily unavailable (server error)".

claude-codepermissions+4
pitfallsSep 27, 202611 min
138
Claude Code "Invalid API key · Fix external API key": Not logged in, Credit balance is too low, API Error 401/429/529 — Exact Messages and Retry Behavior, Tested

Claude Code "Invalid API key · Fix external API key": Not logged in, Credit balance is too low, API Error 401/429/529 — Exact Messages and Retry Behavior, Tested

28 cases, 61 claude -p runs on Claude Code 2.1.280 against a local Messages API stub. A 401 is retried 10 times, so Invalid API key · Fix external API key shows up after ~3 minutes; a key with non-ASCII chars or an embedded newline is rejected locally with 0 requests in 0.28 s. Every error goes to stdout, stderr is 0 bytes, exit 1, and JSON subtype still says success. CLAUDE_CODE_MAX_RETRIES=0 fails a 401 in 0.28 s. Set both KEY and TOKEN and both headers are sent.

claude-codetroubleshooting+4
pitfallsSep 27, 202611 min
141

Published by Magic Tools