Skip to main content
Using sing-box Tun Mode to Implement a Transparent Proxy for V2rayU
  1. Articles/

Using sing-box Tun Mode to Implement a Transparent Proxy for V2rayU

Weaxs
Author
Weaxs

Introduction
#

I’ve been using gemini-cli a lot recently, and I’ve been having trouble logging in at home.

I use the V2rayU client for proxying on my Mac. Logging into Gemini in a browser is fine, but gemini-cli just wouldn’t work, so I spent some time resolving this.

Technically, it’s not difficult, but I’m writing this to sort out the thought process and summarize the proxy method.

Problem Analysis
#

First, I definitely need to analyze the local authentication process of gemini-cli:

  1. gemini-cli opens a local port as an OAuth callback address. The port number is random.
  2. It requests the browser to open Google Account to send a request to the OAuth server.
  3. You log in to your Google account.
  4. After the browser authentication is successful, it returns an access token to the local callback address.
  5. The local callback address finally requests the Google server to confirm the token’s accuracy and return credentials.

The key point to note above is that gemini-cli opens a random port locally.

As mentioned earlier, the local proxy I use is V2RayU.

V2rayU’s advanced settings actually show its listening and proxy interfaces, as shown in the figure below:

V2rayU.png

At this point, it’s clear that the random port opened by gemini-cli is definitely different from the port V2rayU is listening on.

Therefore, in the final step of gemini-cli authentication, it naturally cannot obtain the login credentials.

Tun (Virtual Network Interface) Mode
#

When you present this problem to ChatGPT or Gemini, it will recommend a system-level global traffic proxy—Tun mode, or virtual network interface mode.

The model will first recommend you enable V2rayU’s Tun proxy, but if you look closely, you’ll find that V2rayU simply doesn’t have a Tun proxy mode!

You can also see related feature requests in V2rayU’s issues:

V2rayU-issues.png

At this point, if you press the model and ask it to recommend some tools with a GUI, it will suggest sing-box, Clash Verge Rev, and Hiddify.

So, I downloaded all three apps and ran into a new problem: when you try to import the v2fly client’s config.json from V2rayU, it errors out. The proxy configs for these three apps are not completely aligned with v2fly’s, and the configuration needs to be modified.

The solution to this problem was, of course, to throw it back to the model and have it generate the required import format for Clash Verge Rev and Hiddify.

After the model revised it N times, the imported configuration could finally proxy.

So, I excitedly prepared to enable the Tun mode in Clash Verge Rev and Hiddify, but the app told me this mode requires administrator privileges. This time, my Mac let me down—it would not pop up the authorization prompt for me to confirm. The model suggested several methods like quitting and restarting, reinstalling, etc., but none worked. Additionally, the sing-box GUI would request authorization to install a network extension, which also failed to show the prompt. I even updated my Mac system, and it still didn’t work.

After trying many times, I got tired and finally opted for the non-GUI method.

Download the sing-box executable archive and run it from the console to listen.

Forwarding sing-box to V2rayU
#

We mentioned earlier that V2rayU itself works perfectly fine.

Since we are using sing-box, can we just use sing-box’s TUN mode to globally listen locally, and then forward all requests to V2rayU’s SOCKS port?

After checking this idea and solution with Gemini, I found it was completely feasible. The only remaining problem was how to write the config.json for sing-box.

After more than twenty revisions, Gemini finally wrote a usable config.json for me. With the config.json, we just need to execute a command like the following in the console:

sudo ./sing-box run -c "config.json"

Note that you need to start V2rayU before executing this command.

Finally, I’ll post the final version of the config.json Gemini gave me as a reference.

{
    "log": {
        "level": "info",
        "timestamp": true
    },
    "dns": {
        "servers": [
            {
                "tag": "google-dns",
                "address": "udp://8.8.8.8"
            },
            {
                "tag": "cf-dns",
                "address": "udp://1.1.1.1"
            }
        ],
        "strategy": "ipv4_only"
    },
    "inbounds": [
        {
		        ## Define inbound as Tun mode
            "type": "tun",
            "tag": "tun-in",
            "interface_name": "utun8",
            "address": "198.18.0.1/16",
            "auto_route": true,
            "strict_route": true,
            "sniff": false
        }
    ],
    "outbounds": [
        {
		        ## All outbound traffic is forwarded to the v2rayu-proxy SOCKS port (1080)
            "type": "socks",
            "tag": "v2rayu-proxy",
            "server": "127.0.0.1",
            "server_port": 1080
        },
        {
	          ## localhost and the proxy server's IP need to go through direct mode
            "type": "direct",
            "tag": "direct",
            "bind_interface": "en0"
        }
    ],
    "route": {
        "rules": [
            {
                "ip_is_private": true,
                "outbound": "direct"
            },
            {
		            ## Local domain names
                "domain": [
                    "localhost"
                ],
                "outbound": "direct"
            },
            {
		            ## Local IP
                "ip_cidr": [
                    "127.0.0.1/32"
                ],
                "outbound": "direct"
            },
            {
		            ## Proxy server's IP
                "ip_cidr": [
                    "3.xxx.xx.82/32"
                ],
                "outbound": "direct"
            },
            {
                "network": "tcp,udp",
                "outbound": "v2rayu-proxy"
            }
        ]
    }
}

How Tun Works
#

Finally, let’s take a brief look at how Tun mode is implemented at the code level.

sing-box’s tun implementation is actually in the sing-tun library.

In sing-box, it just needs to check the inbound traffic; when the type is tun, everything goes through sing-tun.

Tun Definition
#

sing-tun’s tun.go defines the Tun interface and the interface layer for Windows, Linux, and Mac respectively.

type Tun interface {
  io.ReadWriter
  Name() (string, error)
  Start() error
  Close() error
  UpdateRouteOptions(tunOptions Options) error
}

type WinTun interface {
  Tun
  ReadPacket() ([]byte, func(), error)
}

type LinuxTUN interface {
  Tun
  N.FrontHeadroom
  BatchSize() int
  BatchRead(buffers [][]byte, offset int, readN []int) (n int, err error)
  BatchWrite(buffers [][]byte, offset int) (n int, err error)
  TXChecksumOffload() bool
}

type DarwinTUN interface {
  Tun
  BatchRead() ([]*buf.Buffer, error)
  BatchWrite(buffers []*buf.Buffer) error
}

Tun Creation
#

Below, we’ll just look at the implementation on Mac, which is in the tun_darwin.go file.

The creation of utun happens in the New method of NativeTun:

  1. It opens a Socket connection via unix.Socket(unix.AF_SYSTEM, unix.SOCK_DGRAM, 2). unix.AF_SYSTEM is a system address family, specific to the macOS kernel.
  2. It sends a listening command to macOS via err = create(tunFd, ifIndex, [options.Name](http://options.name/), options). Let’s look at this method. It uses useSocket three times. The key parts are:
    1. First, it calls useSocket to add the MTU (Maximum Transmission Unit) via unix.IoctlSetIfreqMTU(socketFd, &ifr).
    2. It calls useSocket again to configure the IP and subnet mask for utun via unix.Syscall, unix.AF_INET, and unix.SIOCAIFADDR. To align with our config.json earlier, this configures it as 198.18.0.1/16.
    3. Similar to the previous step, but while the previous step configured the IPv4 IP and subnet mask for utun, this step configures IPv6 via unix.Syscall, unix.AF_INET6, and unix.SIOCAIFADDR_IN6.

At this point, we have created the utun and assigned it a local IP and mask, but we still need to route all local requests to this IP.

// https://github.com/SagerNet/sing-tun/blob/main/tun_darwin.go

var _ DarwinTUN = (*NativeTun)(nil)

const PacketOffset = 4

type NativeTun struct {
	tunFd               int
	tunFile             *os.File
	batchSize           int
	iovecs              []iovecBuffer
	iovecsOutput        []iovecBuffer
	iovecsOutputDefault []unix.Iovec
	msgHdrs             []rawfile.MsgHdrX
	msgHdrsOutput       []rawfile.MsgHdrX
	buffers             []*buf.Buffer
	stopFd              stopfd.StopFD
	options             Options
	inet4Address        [4]byte
	inet6Address        [16]byte
	routeSet            bool
	sendMsgX            bool
}

func New(options Options) (Tun, error) {
	var tunFd int
	batchSize := ((512 * 1024) / int(options.MTU)) + 1
	if options.FileDescriptor == 0 {
		ifIndex := -1
		_, err := fmt.Sscanf(options.Name, "utun%d", &ifIndex)
		if err != nil {
			return nil, E.New("bad tun name: ", options.Name)
		}

		tunFd, err = unix.Socket(unix.AF_SYSTEM, unix.SOCK_DGRAM, 2)
		if err != nil {
			return nil, err
		}

		err = create(tunFd, ifIndex, options.Name, options)
		if err != nil {
			unix.Close(tunFd)
			return nil, err
		}
		err = configure(tunFd, options.EXP_MultiPendingPackets, batchSize)
		if err != nil {
			unix.Close(tunFd)
			return nil, err
		}
	} else {
		tunFd = options.FileDescriptor
		err := configure(tunFd, options.EXP_MultiPendingPackets, batchSize)
		if err != nil {
			return nil, err
		}
	}
	nativeTun := &NativeTun{
		tunFd:         tunFd,
		tunFile:       os.NewFile(uintptr(tunFd), "utun"),
		options:       options,
		batchSize:     batchSize,
		iovecs:        make([]iovecBuffer, batchSize),
		iovecsOutput:  make([]iovecBuffer, batchSize),
		msgHdrs:       make([]rawfile.MsgHdrX, batchSize),
		msgHdrsOutput: make([]rawfile.MsgHdrX, batchSize),
		stopFd:        common.Must1(stopfd.New()),
		sendMsgX:      options.EXP_SendMsgX,
	}
	for i := 0; i < batchSize; i++ {
		nativeTun.iovecs[i] = newIovecBuffer(int(options.MTU))
		nativeTun.iovecsOutput[i] = newIovecBuffer(int(options.MTU))
	}
	if len(options.Inet4Address) > 0 {
		nativeTun.inet4Address = options.Inet4Address[0].Addr().As4()
	}
	if len(options.Inet6Address) > 0 {
		nativeTun.inet6Address = options.Inet6Address[0].Addr().As16()
	}
	return nativeTun, nil
}

func create(tunFd int, ifIndex int, name string, options Options) error {
	ctlInfo := &unix.CtlInfo{}
	copy(ctlInfo.Name[:], utunControlName)
	err := unix.IoctlCtlInfo(tunFd, ctlInfo)
	if err != nil {
		return os.NewSyscallError("IoctlCtlInfo", err)
	}

	err = unix.Connect(tunFd, &unix.SockaddrCtl{
		ID:   ctlInfo.Id,
		Unit: uint32(ifIndex) + 1,
	})
	if err != nil {
		return os.NewSyscallError("Connect", err)
	}

	err = useSocket(unix.AF_INET, unix.SOCK_DGRAM, 0, func(socketFd int) error {
		var ifr unix.IfreqMTU
		copy(ifr.Name[:], name)
		ifr.MTU = int32(options.MTU)
		return unix.IoctlSetIfreqMTU(socketFd, &ifr)
	})
	if err != nil {
		return os.NewSyscallError("IoctlSetIfreqMTU", err)
	}
	if len(options.Inet4Address) > 0 {
		for _, address := range options.Inet4Address {
			ifReq := ifAliasReq{
				Addr: unix.RawSockaddrInet4{
					Len:    unix.SizeofSockaddrInet4,
					Family: unix.AF_INET,
					Addr:   address.Addr().As4(),
				},
				Dstaddr: unix.RawSockaddrInet4{
					Len:    unix.SizeofSockaddrInet4,
					Family: unix.AF_INET,
					Addr:   address.Addr().As4(),
				},
				Mask: unix.RawSockaddrInet4{
					Len:    unix.SizeofSockaddrInet4,
					Family: unix.AF_INET,
					Addr:   netip.MustParseAddr(net.IP(net.CIDRMask(address.Bits(), 32)).String()).As4(),
				},
			}
			copy(ifReq.Name[:], name)
			err = useSocket(unix.AF_INET, unix.SOCK_DGRAM, 0, func(socketFd int) error {
				if _, _, errno := unix.Syscall(
					syscall.SYS_IOCTL,
					uintptr(socketFd),
					uintptr(unix.SIOCAIFADDR),
					uintptr(unsafe.Pointer(&ifReq)),
				); errno != 0 {
					return os.NewSyscallError("SIOCAIFADDR", errno)
				}
				return nil
			})
			if err != nil {
				return err
			}
		}
	}
	if len(options.Inet6Address) > 0 {
		for _, address := range options.Inet6Address {
			ifReq6 := ifAliasReq6{
				Addr: unix.RawSockaddrInet6{
					Len:    unix.SizeofSockaddrInet6,
					Family: unix.AF_INET6,
					Addr:   address.Addr().As16(),
				},
				Mask: unix.RawSockaddrInet6{
					Len:    unix.SizeofSockaddrInet6,
					Family: unix.AF_INET6,
					Addr:   netip.MustParseAddr(net.IP(net.CIDRMask(address.Bits(), 128)).String()).As16(),
				},
				Flags: IN6_IFF_NODAD | IN6_IFF_SECURED,
				Lifetime: addrLifetime6{
					Vltime: ND6_INFINITE_LIFETIME,
					Pltime: ND6_INFINITE_LIFETIME,
				},
			}
			if address.Bits() == 128 {
				ifReq6.Dstaddr = unix.RawSockaddrInet6{
					Len:    unix.SizeofSockaddrInet6,
					Family: unix.AF_INET6,
					Addr:   address.Addr().Next().As16(),
				}
			}
			copy(ifReq6.Name[:], name)
			err = useSocket(unix.AF_INET6, unix.SOCK_DGRAM, 0, func(socketFd int) error {
				if _, _, errno := unix.Syscall(
					syscall.SYS_IOCTL,
					uintptr(socketFd),
					uintptr(SIOCAIFADDR_IN6),
					uintptr(unsafe.Pointer(&ifReq6)),
				); errno != 0 {
					return os.NewSyscallError("SIOCAIFADDR_IN6", errno)
				}
				return nil
			})
			if err != nil {
				return err
			}
		}
	}
	return nil
}

func (t *NativeTun) Start() error {
	t.options.InterfaceMonitor.RegisterMyInterface(t.options.Name)
	return t.setRoutes()
}

Tun Listening
#

The next step is to route all requests to this 198.18.0.1/16. This is mainly handled in the Start method of NativeTun.

The core here is really just the setRoutes method and err = execRoute(unix.RTM_ADD, t.options.InterfaceScope, interfaceIndex, destination, gateway).

This change will forward all requests to the utun8 interface (198.18.0.1) and also add some extra rules from the config.json. For example, the routing table is changed to:

Destination Gateway Interface
0.0.0.0/1 198.18.0.1 utun8
128.0.0.0/1 198.18.0.1 utun8
3.xxx.xx.82/32 192.168.1.1 en0
func (t *NativeTun) Start() error {
	t.options.InterfaceMonitor.RegisterMyInterface(t.options.Name)
	return t.setRoutes()
}

func (t *NativeTun) setRoutes() error {
	if t.options.FileDescriptor == 0 {
		routeRanges, err := t.options.BuildAutoRouteRanges(false)
		if err != nil {
			return err
		}
		if len(routeRanges) > 0 {
			gateway4, gateway6 := t.options.Inet4GatewayAddr(), t.options.Inet6GatewayAddr()
			for _, destination := range routeRanges {
				var gateway netip.Addr
				if destination.Addr().Is4() {
					gateway = gateway4
				} else {
					gateway = gateway6
				}
				var interfaceIndex int
				if t.options.InterfaceScope {
					iff, err := t.options.InterfaceFinder.ByName(t.options.Name)
					if err != nil {
						return err
					}
					interfaceIndex = iff.Index
				}
				err = execRoute(unix.RTM_ADD, t.options.InterfaceScope, interfaceIndex, destination, gateway)
				if err != nil {
					if errors.Is(err, unix.EEXIST) {
						err = execRoute(unix.RTM_DELETE, false, 0, destination, gateway)
						if err != nil {
							return E.Cause(err, "remove existing route: ", destination)
						}
						err = execRoute(unix.RTM_ADD, t.options.InterfaceScope, interfaceIndex, destination, gateway)
						if err != nil {
							return E.Cause(err, "re-add route: ", destination)
						}
					} else {
						return E.Cause(err, "add route: ", destination)
					}
				}
			}
			flushDNSCache()
			t.routeSet = true
		}
	}
	return nil
}