前言 #
因为最近一直在用 gemini-cli,在家里使用的时候一直有登录不上的问题。
自己用的是 mac 的 V2rayU 客户端的代理,在浏览器上登录 Gemini 肯定是没啥问题的,但 gemini-cli 一直不行,就花了些功夫解决这个问题。
技术上其实没什么难的,只是梳理一下思路,总结一下代理方式。
问题分析 #
首先我肯肯定需要分析一下 gemini-cli 在本地鉴权的过程:
- gemini-cli 开启一个本地端口作为 oauth 的 callback 地址,端口号是随机的
- 请求浏览器打开 google account 已向 oauth 服务器发送
- 登录自己的 google 账号
- 浏览器验证通过后,会返回一个 access token 给到本地的 callback 地址
- 本地的 callback 地址最终请求 google 服务器已确认token 的准确性并返回凭证
上面终端需要注意的就是,gemini-cli 会在本地随机开一个端口
我们前面说到本人本地用的代理是 V2RayU。
V2rayU 的高级设置了其实写了其监听和代理的接口,如下图

至此就可以很明显地发现,gemini-cli 开启的随机端口和 V2rayU 所监听的端口肯定不一样。
所以在 gemini-cli 鉴权的最后一步自然也无法获取到登录凭证。
Tun (虚拟网卡) 模式 #
当你把这个问题给到 ChatGPT 或者 Gemini 的时候,这时它会推荐给以一个 系统层面全局流量代理 —— Tun 模式、虚拟网卡模式。
模型会先推荐你去启用 V2rayU 的 Tun 代理,但是仔细找找你会发现,V2rayU 根本没有 Tun 代理模式 !
去 V2rayU 的 issue 里也能看到相关的 feature

这个时候如果你追问模型,并让他给你推荐一些有 GUI 的工具,那么它会推荐你 sing-box、Clash Verge Rev、Hiddify。
于是笔者挨个下了这三个 app,并解锁了新的问题:当你把 V2rayU 中的 v2fly 客户端 config.json 尝试导入进来的时候会报错。这三个 app 中的代理的 config 和 v2fly 中的并不完全对齐,需要改配置。
这个问题的解决办法当然也是扔给模型,让他给我产出 Clash Verge Rev 和 Hiddify 需要的导入格式。
在模型改了 N 遍之后,导入的配置终于可以代理了。
于是我兴冲冲地准备开启 Clash Verge Rev 和 Hiddify 的 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-box 的 config.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-tun 中 tun.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 的创建是在 NativeTun 的 New 方法中 :
- 通过
tunFd, err = unix.Socket(unix.AF_SYSTEM, unix.SOCK_DGRAM, 2)开启一个 Socket 连接unix.AF_SYSTEMSystem 的地址簇,这个地址是 macOS 内核特有的 - 通过
err = create(tunFd, ifIndex, [options.Name](http://options.name/), options)向 macOS 发送监听的指令,具体我们看看这个方法,这个方法使用了三次useSocket,具体看这部分就行- 先调用
useSocket通过unix.IoctlSetIfreqMTU(socketFd, &ifr)添加 MTU (Maximum Transmission Unit, 最大传输单元) - 调用
useSocket通过unix.Syscall、unix.AF_INET和unix.SIOCAIFADDR给 utun 配置 IP 和 子网掩码,和我们上面的 config.json 对齐,那这里配置的就是 198.18.0.1/16 - 和 上一个步骤类似,只不过上一个步骤是给 utun 配置 ipv4 的 IP 和子网掩码,这一步是通过
unix.Syscall、unix.AF_INET6 和unix.SIOCAIFADDR_IN6给 utun 配置 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 上去,这部分主要是在 NativeTun 的 Start 方法中
这里其实核心的只有 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
}