跳过正文
使用 sing-box Tun 模式实现 V2rayU 的透明代理
  1. 文章/

使用 sing-box Tun 模式实现 V2rayU 的透明代理

Weaxs
作者
Weaxs

前言
#

因为最近一直在用 gemini-cli,在家里使用的时候一直有登录不上的问题。

自己用的是 mac 的 V2rayU 客户端的代理,在浏览器上登录 Gemini 肯定是没啥问题的,但 gemini-cli 一直不行,就花了些功夫解决这个问题。

技术上其实没什么难的,只是梳理一下思路,总结一下代理方式。

问题分析
#

首先我肯肯定需要分析一下 gemini-cli 在本地鉴权的过程:

  1. gemini-cli 开启一个本地端口作为 oauth 的 callback 地址,端口号是随机的
  2. 请求浏览器打开 google account 已向 oauth 服务器发送
  3. 登录自己的 google 账号
  4. 浏览器验证通过后,会返回一个 access token 给到本地的 callback 地址
  5. 本地的 callback 地址最终请求 google 服务器已确认token 的准确性并返回凭证

上面终端需要注意的就是,gemini-cli 会在本地随机开一个端口

我们前面说到本人本地用的代理是 V2RayU。

V2rayU 的高级设置了其实写了其监听和代理的接口,如下图

V2rayU.png

至此就可以很明显地发现,gemini-cli 开启的随机端口和 V2rayU 所监听的端口肯定不一样。

所以在 gemini-cli 鉴权的最后一步自然也无法获取到登录凭证。

Tun (虚拟网卡) 模式
#

当你把这个问题给到 ChatGPT 或者 Gemini 的时候,这时它会推荐给以一个 系统层面全局流量代理 —— Tun 模式、虚拟网卡模式。

模型会先推荐你去启用 V2rayU 的 Tun 代理,但是仔细找找你会发现,V2rayU 根本没有 Tun 代理模式 !

去 V2rayU 的 issue 里也能看到相关的 feature

V2rayU-issues.png

这个时候如果你追问模型,并让他给你推荐一些有 GUI 的工具,那么它会推荐你 sing-boxClash Verge RevHiddify

于是笔者挨个下了这三个 app,并解锁了新的问题:当你把 V2rayU 中的 v2fly 客户端 config.json 尝试导入进来的时候会报错。这三个 app 中的代理的 config 和 v2fly 中的并不完全对齐,需要改配置。

这个问题的解决办法当然也是扔给模型,让他给我产出 Clash Verge RevHiddify 需要的导入格式。

在模型改了 N 遍之后,导入的配置终于可以代理了。

于是我兴冲冲地准备开启 Clash Verge RevHiddify 的 Tun 模式,app 中告诉我此模式需要管理员的权限。这次轮到 mac 掉链子了——mac 死活不弹出需要我确认授权的入口,模型给了几种方式像是完全退出重启、重装等等试了都不行。除此之外 sing-box 的 GUI 会要求授权安装一个网络拓展,也是无法弹出授权入口。我甚至更新了 mac 系统还是不行。

尝试了很多次之后累了,最终选择了非 GUI 的方法。

下载 sing-box 的可执行程序压缩包,并在 console 控制台中开启监听。

sing-box 转发到 V2rayU
#

我们前面说过 V2rayU 本身是好使的,完全没啥问题。

我们既然用了 sing-box ,那是不是可以直接用 sing-box 的 TUN 模式全局监听本地,然后将请求都转发到 V2rayU 的 sock 端口。

和 Gemini 对了一下这个思路和方案之后发现完全可行,那么剩下的问题就是 sing-boxconfig.json 怎么写了。

经过二十多版的修改,Gemini 终于给我写出了可以用的config.json,有了 config.json 之后我们只需要在控制台执行类似下面这个命令就行

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

需要注意的是执行这个命令之前需要先开启 V2rayU。

最后贴一下 Gemini 给我的最终版的 config.json 作为参考。

{
    "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": [
        {
		        ## inbound 定义为 Tun 模式
            "type": "tun",
            "tag": "tun-in",
            "interface_name": "utun8",
            "address": "198.18.0.1/16",
            "auto_route": true,
            "strict_route": true,
            "sniff": false
        }
    ],
    "outbounds": [
        {
		        ## 所有出站转发到 v2rayu-proxy 对应的 sock 端口 (1080)
            "type": "socks",
            "tag": "v2rayu-proxy",
            "server": "127.0.0.1",
            "server_port": 1080
        },
        {
	          ## localhost 和 代理服务器的 ip 需要直接走 direct 模式
            "type": "direct",
            "tag": "direct",
            "bind_interface": "en0"
        }
    ],
    "route": {
        "rules": [
            {
                "ip_is_private": true,
                "outbound": "direct"
            },
            {
		            ## 本地域名
                "domain": [
                    "localhost"
                ],
                "outbound": "direct"
            },
            {
		            ## 本地 ip
                "ip_cidr": [
                    "127.0.0.1/32"
                ],
                "outbound": "direct"
            },
            {
		            ## 代理服务器的 ip
                "ip_cidr": [
                    "3.xxx.xx.82/32"
                ],
                "outbound": "direct"
            },
            {
                "network": "tcp,udp",
                "outbound": "v2rayu-proxy"
            }
        ]
    }
}

Tun 原理
#

最后我们从代码层面简单地看看 Tun 模式是如何实现的。

sing-box 的 tun 实现实际上是在 sing-tun 库中。

sing-box 中只需要判断 inbound 入站,当发现类型是 tun 时,所有都走sing-tun

Tun 定义
#

sing-tuntun.go 分别定义了 Tun 的接口,并定义了 Windows、Linux 和 Mac 中的 interface 层。

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 创建
#

下面我们只看看 Mac 中的实现,这部分代码在 tun_darwin.go

utun 的创建是在 NativeTunNew 方法中 :

  1. 通过 tunFd, err = unix.Socket(unix.AF_SYSTEM, unix.SOCK_DGRAM, 2) 开启一个 Socket 连接 unix.AF_SYSTEM System 的地址簇,这个地址是 macOS 内核特有的
  2. 通过 err = create(tunFd, ifIndex, [options.Name](http://options.name/), options) 向 macOS 发送监听的指令,具体我们看看这个方法,这个方法使用了三次 useSocket ,具体看这部分就行
    1. 先调用 useSocket 通过 unix.IoctlSetIfreqMTU(socketFd, &ifr) 添加 MTU (Maximum Transmission Unit, 最大传输单元)
    2. 调用 useSocket 通过 unix.Syscallunix.AF_INETunix.SIOCAIFADDRutun 配置 IP 和 子网掩码,和我们上面的 config.json 对齐,那这里配置的就是 198.18.0.1/16
    3. 和 上一个步骤类似,只不过上一个步骤是给 utun 配置 ipv4 的 IP 和子网掩码,这一步是通过 unix.Syscallunix.AF_INET6 和 unix.SIOCAIFADDR_IN6utun 配置 ipv6

至此我们已经创建了 utun,并给他分配了一个本地的 ip 和 掩码,但是我们还需要把本地的请求全都打到这个 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 监听
#

下面就是需要把请求都路由到这个 198.18.0.1/16 上去,这部分主要是在 NativeTunStart 方法中

这里其实核心的只有 setRoutes 方法 和 err = execRoute(unix.RTM_ADD, t.options.InterfaceScope, interfaceIndex, destination, gateway)

这里的改动会将所有的请求都转到 utun8 接口(198.18.0.1),并将 config.json中的一些额外的规则也添加进来,例如路由表改成了

目的地 (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
}