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:
gemini-cliopens a local port as an OAuth callback address. The port number is random.- It requests the browser to open Google Account to send a request to the OAuth server.
- You log in to your Google account.
- After the browser authentication is successful, it returns an access token to the local callback address.
- 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:

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:

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:
- It opens a Socket connection via
unix.Socket(unix.AF_SYSTEM, unix.SOCK_DGRAM, 2).unix.AF_SYSTEMis a system address family, specific to the macOS kernel. - 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 usesuseSocketthree times. The key parts are:- First, it calls
useSocketto add the MTU (Maximum Transmission Unit) viaunix.IoctlSetIfreqMTU(socketFd, &ifr). - It calls
useSocketagain to configure the IP and subnet mask for utun viaunix.Syscall,unix.AF_INET, andunix.SIOCAIFADDR. To align with ourconfig.jsonearlier, this configures it as 198.18.0.1/16. - 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, andunix.SIOCAIFADDR_IN6.
- First, it calls
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
}