Skip to main content

Singbox Dual-Stack: Two Pitfalls

·871 words·5 mins·
soloopooo
Author
soloopooo
What about you?

I recently configured dual-stack (IPv4 + IPv6) on my sing-box and hit two pitfalls worth documenting.

Pitfall 1: DNS Strategy Hidden as ipv4_only
#

IPv6 just wouldn’t work. Found "strategy": "ipv4_only" in DNS config silently discarding all AAAA records. Delete it or change to prefer_ipv6. Easy fix.

Pitfall 2: IPv6 Large Packets Randomly Hang in Chain Proxy
#

The real nightmare. My topology:

Client Mihomo (SOCKS5) → Public relay node → Server Nginx (Stream) → sing-box (AnyTLS) → Internet

After enabling dual-stack, test-ipv6.com showed a strange split:

  • IPv6 address detected ✅
  • Small packet 10/10 ✅
  • Large packet transfer test ❌ (progress bar stuck, timeout)

Pages loaded fine, images and video hung. DNS resolved, TLS handshake succeeded — but data flow died as soon as packets got large.

Debugging
#

I suspected the relay node, Nginx config, sing-box, even tried anytls-go — all failed.

At one point I wondered if some intermediate link in the chain had an MTU problem. But then I realized: the chain proxy already wraps the original request in multiple tunnel layers. The intermediate segments are transporting encapsulated tunnel traffic — MTU at those middle hops simply doesn’t matter. The only places that matter are the two endpoints: client egress and server egress.

So I ran curl directly on the server for the large packet test URL, and found the server itself couldn’t complete it. The problem was at the network layer, not the proxy software.

The Smoking Gun
#

test-ipv6.com’s large packet test hits this URL (quote it to prevent shell from misparsing & and ?):

curl -6 "https://mtu1280.osaka.test-ipv6.com/ip/?callback=?&size=1600&fill=xxxxxxxx...&testdomain=test-ipv6.com&testname=test_v6mtu"

It sends 1600 bytes of padding. If MTU is healthy, you get a JSONP callback with your IPv6 address. If it hangs, there’s an MTU black hole.

Root Cause: Non-Standard Datacenter MTU
#

Probed with ping -M do -s and found the exact boundary:

ping -M do -s 1232 ipv6.google.com  # works
ping -M do -s 1233 ipv6.google.com  # fails

1232 + 8 (ICMPv6 header) + 40 (IPv6 header) = 1280.

The datacenter’s IPv6 path MTU is hard-limited to 1280 bytes. An upstream device has a non-standard MTU, and ICMPv6 Type 2 (Packet Too Big) is either never sent or gets dropped by the proxy layers. PMTUD is completely broken here.

Since the datacenter doesn’t play by the rules — no ICMPv6 Type 2, no path MTU discovery — there’s no point relying on protocol negotiation. Forget PMTUD, forget client cooperation. Just enforce the limit at the server’s physical layer: packets over 1280 simply don’t leave this machine.

Fix: Per-Route MTU Isolation
#

Goal: different MTU for IPv4 (1500) and IPv6 (1280) on the same NIC.

Wrong Turn: Changing NIC MTU
#

sudo ip link set dev eth0 mtu 1280 worked, but unnecessarily limited IPv4 to 1280 too.

Final Solution: ip -6 route change
#

Modify the RA-learned default route to limit only IPv6 path MTU:

# 1. Restore eth0 to 1500
sudo ip link set dev eth0 mtu 1500

# 2. Check current IPv6 default route
ip -6 route show default
# Output: default via fe80::464c:a8ff:fe08:f27f dev eth0 metric 100 mtu 1500

# 3. Change default route, force IPv6 MTU=1280
sudo ip -6 route change default via fe80::464c:a8ff:fe08:f27f dev eth0 mtu 1280 metric 100

# 4. Change backup route too if exists
sudo ip -6 route change default via fe80::464c:a8ff:fe08:f27f dev eth0 metric 1024 mtu 1280

Verify:

ip -6 route show default
# default via fe80::... dev eth0 metric 100 mtu 1280  ✅
ip link show eth0 | grep mtu
# mtu 1500  ✅ IPv4 unaffected

Route-level MTU takes priority over interface MTU. IPv4 uses the default 1500, IPv6 hits the mtu 1280 route entry — kernel handles segmentation automatically. Same NIC, two standards, no conflict.

Since routes are learned via RA, they reset on reboot. To persist via netplan:

# /etc/netplan/99-ipv6-mtu.yaml
network:
  version: 2
  renderer: networkd
  ethernets:
    eth0:
      mtu: 1500
      dhcp4: true
      dhcp6: true
      routes:
        - to: ::/0
          via: "fe80::464c:a8ff:fe08:f27f"  # replace with your gateway
          on-link: true
          mtu: 1280
sudo netplan apply

Yet Another Pitfall: RA Resets the Route Every 1800s
#

After a while, it broke again. ip -6 route showed:

default via fe80::464c:a8ff:fe08:f27f dev eth0 proto ra metric 100 mtu 1500
default via fe80::464c:a8ff:fe08:f27f dev eth0 proto static metric 1024 onlink mtu 1280

The RA route (metric 100) has higher priority than my static route (metric 1024). Kernel always picks lower metric, so traffic still used 1500 MTU. RA refreshes every 1800s, overwriting manual changes.

Fix: netplan with metric 1
#

netplan apply triggers RA refresh, so just set metric: 1 in netplan routes — RA uses metric 100, ours is always higher priority:

# /etc/netplan/99-ipv6-mtu.yaml
network:
  version: 2
  renderer: networkd
  ethernets:
    eth0:
      mtu: 1500
      dhcp4: true
      dhcp6: true
      routes:
        - to: ::/0
          via: "fe80::464c:a8ff:fe08:f27f"  # replace with your gateway
          on-link: true
          metric: 1
          mtu: 1280
sudo netplan apply

No cron needed. Netplan handles it all in one shot.

TL;DR
#

  1. IPv6 not working → check DNS strategy isn’t ipv4_only
  2. IPv6 small packets work, large packets hang → test on the server directly with the test-ipv6.com curl URL. Use ping -M do -s to find the MTU boundary, then ip -6 route change for per-route MTU isolation — IPv4 at 1500, IPv6 at 1280