Mythologyli / zju-connect

ZJU RVPN 客户端的 Go 语言实现
GNU Affero General Public License v3.0
338 stars 23 forks source link

feat: support tun mode #44

Closed Mythologyli closed 11 months ago

Mythologyli commented 11 months ago

此 PR 希望为 zju-connect 带来 TUN 模式,具体的好处有:

  1. 更方便地进行 SSH/RDP 等连接
  2. 运行在主路由时带来便利
  3. 便于对接 Android VpnService

目前的初步想法为:

  1. TUN 接口默认路由 10.0.0.0/8。如果路由 0.0.0.0 可能分流比较复杂
  2. 在 Windows 平台上主要作为 SOCKS5/HTTP 代理的补充,使得 zju-connect 在 SSH/RDP/正版软件认证等方面的体验与 EasyConnect 类似

存在的问题:

  1. 当前版本开启 TUN 模式后禁用 SOCKS5/HTTP 代理、端口转发等功能,因为这些功能是基于 gVisor 网络栈实现的。目前想法是开启 TUN 后禁用 gVisor 网络栈,然后 net.Dial 走 TUN 网卡
  2. Linux 下设置 TUN 网卡是用 ifconfig 实现的,某些发行版可能不自带 ifconfig,如果能用别的方法实现更好
  3. github.com/songgao/water 对 Android 的支持似乎不好,所以目前暂未实现 Android
cxz66666 commented 11 months ago

好功能,我今天之前看看 😸

Mythologyli commented 11 months ago
IPv4 Route Table
===========================================================================
Active Routes:
Network Destination        Netmask          Gateway       Interface  Metric
          0.0.0.0          0.0.0.0      192.168.8.1    192.168.8.200     25
          0.0.0.0          0.0.0.0         On-link      10.190.65.75  10004
         10.0.0.0        255.0.0.0         On-link      10.190.65.75    261
     10.190.65.75  255.255.255.255         On-link      10.190.65.75    261
   10.255.255.255  255.255.255.255         On-link      10.190.65.75    261

目前运行时路由表是这个样子

cxz66666 commented 11 months ago

可以考虑使用ip命令取代ifconfig,所有Linux发行版基本上都提供了ip命令(吧)

Mythologyli commented 11 months ago

可以考虑使用ip命令取代ifconfig,所有Linux发行版基本上都提供了ip命令(吧)

确实,不过 macOS 上貌似还没有,所以 darwin 那个还是用 ifconfig

https://superuser.com/questions/687310/ip-command-in-mac-os-x-terminal/

cxz66666 commented 11 months ago

想到一个可能需要的功能 开了tun mode后,尽管只在10.0.0.0/8网段下提供服务,但是可能还是需要有一个全局生效的dns服务器,开在tun设备的53端口上,来负责把*.zju.edu.cn解析到10.0.0.0/8, windows可以通过设置tun网卡的dns1,并把tun网卡metrics调低,用命令netsh interface ipv4 show interface就可以看到dns优先级,linux就稍微麻烦点,得把/etc/resolve.conf覆写掉

Mythologyli commented 11 months ago

Windows 下 DNS 设置现在我正在做,目前在写 DNS Server Linux 下也许可以用这种方法:https://github.com/xjasonlyu/tun2socks/wiki/Hijack-DNS

Mythologyli commented 11 months ago

如果在 TUN 模式下,把 zju-connect 内部的 DNS 逻辑暴露成一个 DNS Server,并设置 TUN 接口使用这个 DNS,下面这个本地解析的逻辑会不会导致循环:

func (resolve *DnsResolve) ResolveWithLocal(ctx context.Context, host string) (context.Context, net.IP, error) {
    if target, err := net.ResolveIPAddr("ip4", host); err != nil {
        log.Printf("Resolve IPv4 addr failed using local DNS: " + host + ". Try IPv6 addr.")

        if target, err = net.ResolveIPAddr("ip6", host); err != nil {
            log.Printf("Resolve IPv6 addr failed using local DNS: " + host + ". Reject connection.")
            return ctx, nil, err
        } else {
            log.Printf("%s -> %s", host, target.IP.String())
            return ctx, target.IP, nil
        }
    } else {
        log.Printf("%s -> %s", host, target.IP.String())
        return ctx, target.IP, nil
    }
}

感觉如果暴露出一个 DNS Server 的话,干脆取消解析失败后用 local DNS 的逻辑,这样就可以得到一个使用 ZJU DNS + DNS 规则 + 自定义 DNS 规则的 DNS Server。如果以上方式都失败就直接放弃

cxz66666 commented 11 months ago

我也想过这个问题,感觉是会的,但是手头账号用于ical了没法试 😢

Mythologyli commented 11 months ago

要么就取消解析失败后用 local DNS 的逻辑,这样就可以得到一个使用 ZJU DNS + 服务端下发的 DNS 规则 + 自定义 DNS 规则的 DNS Server。如果以上方式都失败就直接放弃

或者更复杂一点,给一个设置项手动指定后备 DNS

cxz66666 commented 11 months ago

如果不设置后备dns的话,tun mode接管所有dns会不会有问题,可以考虑像clash tun mode那样默认几个国内的dns 不过windows下想了一下应该也没啥问题,毕竟可以支持使用多个dns, dns server没响应就用其他的。但是linux使用劫持了之后,还能使用其他网卡下的dns吗

Mythologyli commented 11 months ago
flag.StringVar(&core.ZjuDnsServer, "zju-dns-server", "10.10.0.21", "ZJU DNS server address")
flag.StringVar(&core.SecondaryDnsServer, "secondary-dns-server", "114.114.114.114", "Secondary DNS server address. Leave empty to use system default DNS server")
flag.StringVar(&core.DnsServerBind, "dns-server-bind", "", "The address DNS server listens on (e.g. 127.0.0.1:53)")
flag.StringVar(&core.TunDnsServer, "tun-dns-server", "", "DNS Server address for TUN interface (e.g. 127.0.0.1). You should not specify the port")

打算用 secondary-dns-server 决定 ZJU DNS 解析失败后用什么。给一个默认值,如果留空就直接用默认的解析

Windows 虽然有多个 DNS,但超时时间会导致体验很差

cxz66666 commented 11 months ago

打算用 secondary-dns-server 决定 ZJU DNS 解析失败后用什么。给一个默认值,如果留空就直接用默认的解析

那tun mode下ResolveWithLocal的逻辑,如果使用system default的dns会导致循环吗(

Mythologyli commented 11 months ago

打算用 secondary-dns-server 决定 ZJU DNS 解析失败后用什么。给一个默认值,如果留空就直接用默认的解析

那tun mode下ResolveWithLocal的逻辑,如果使用system default的dns会导致循环吗(

如果设置成空就会,但默认是用 114.114.114.114

Mythologyli commented 11 months ago
func (resolve *DnsResolve) Resolve(ctx context.Context, host string) (context.Context, net.IP, error) {
    if config.IsDnsRuleAvailable() {
        if ip, hasDnsRule := config.GetSingleDnsRule(host); hasDnsRule {
            ctx = context.WithValue(ctx, "USE_PROXY", true)
            log.Printf("%s -> %s", host, ip)
            return ctx, net.ParseIP(ip), nil
        }
    }
    var useProxy = false
    if config.IsZjuForceProxyRuleAvailable() {
        if isInZjuForceProxyRule := config.IsInZjuForceProxyRule(host); isInZjuForceProxyRule {
            useProxy = true
        }
    }
    if !useProxy && config.IsDomainRuleAvailable() {
        if _, found := config.GetSingleDomainRule(host); found {
            useProxy = true
        }
    }

    ctx = context.WithValue(ctx, "USE_PROXY", useProxy)

    if UseZjuDns {
        if cachedIP, found := GetDnsCache(host); found {
            log.Printf("%s -> %s", host, cachedIP.String())
            return ctx, cachedIP, nil
        } else {

这个地方现在的逻辑似乎是服务端下发的规则 > 自定义规则,因为 GetDnsCache 在后面

Mythologyli commented 11 months ago

刚刚看了一下原版 EasyConnect,发现它会根据下发的规则把一些 IP 加入路由表。我打算做一下这个功能

cxz66666 commented 11 months ago

我看到现在linux还没有拦截dns的代码,感觉在linux上可以用类似clash的机制,把所有dst是53的请求全扔到tun上,然后tun再去处理,这样即使用户没显式指定DnsServerBind,也能正常处理tun mode下的dns

clash tun下的ip rule:

9500:   not from all dport 53 lookup main suppress_prefixlength 0 (所有非53端口的走main表)
9510:   not from all iif lo lookup 1970566510  (所有非lo的走1970566510)
9520:   from 0.0.0.0 iif lo uidrange 0-4294967294 lookup 1970566510
9530:   from 198.18.0.1 iif lo uidrange 0-4294967294 lookup 1970566510

ip table 1970566510:

default dev utun proto static 
Mythologyli commented 11 months ago

对 ip rule 还不太懂()如果你有空的话要不实现一下这部分~我可以帮忙测试

cxz66666 commented 11 months ago

对 ip rule 还不太懂()如果你有空的话要不实现一下这部分~我可以帮忙测试

okok,这两天我试着实现一下linux/drawin拦截dns部分

Mythologyli commented 11 months ago

目前发现一个匪夷所思的问题

在使用 TUN 模式时,无论是直接走 TUN 还是通过 SOCKS5 间接走,都会在访问 speedtest.zju.edu.cn 时出现问题。一般表现为:第一次打开时网页能加载,测速时有延迟和抖动但是无速度,之后刷新会无法加载网站。此时哪怕是重连 zju-connect 也无法加载,等待一段时间后可能会恢复第一次打开时的状态。无法加载页面时的抓包结果:

image

别的网站暂时没发现此现象

Windows 和 Ubuntu 上使用 TUN 模式都可以复现这个问题。使用 gVisor 时不存在此问题,原版 EasyConnect 也不存在此问题

Mythologyli commented 11 months ago

更新:目前发现这个 TUN 模式问题还不小

使用 TUN 模式 + SOCKS5 + proxy_all 测试,访问百度无法成功: image

访问 cnki.net 却可以成功,访问 CC98 也成功: image

如果用 gVisor,都可以成功

Mythologyli commented 11 months ago

感觉像是 TUN 和 EasyConnect 服务端“不合”的问题

在 Ubuntu 下测试走 TUN 访问百度这些都没问题,当然 speedtest.zju.edu.cn 不行(还有学在浙大也不行)

在 Windows 下测试走 TUN 访问百度,通过 EasyConnect 服务端访问是不行的,如果先走 TUN 再走别的网卡访问(也就是直连)是可以的

百度的话是在服务端下发的规则里的,学在浙大也是常用的网站。感觉不解决这个问题这个 TUN 模式很难应用

测试结果: Windows: TUN+PROXY: 百度(失败)浙大测速(失败)学在浙大(失败) TUN+DIRECT: 百度(成功)浙大测速(成功)学在浙大(成功)

Ubuntu: TUN+PROXY: 百度(成功)浙大测速(失败)学在浙大(失败) TUN+DIRECT: 百度(成功)浙大测速(成功)学在浙大(成功)

cxz66666 commented 11 months ago

我这边在wsl2 2.0(开启镜像windows网络) 上用wsl的chrome和wireshark也测了一下

TUN+PROXY: 百度(失败)浙大测速(失败)学在浙大(成功)cspo.zju.edu.cn(成功) TUN+DIRECT: 百度(成功)浙大测速(失败)学在浙大(成功)cspo.zju.edu.cn(成功)

cxz66666 commented 11 months ago

BTW,我看到现在baidu.com会走proxy,翻了一下,使用domainutil.Domain拿到top domain,后续根据top domain进行匹配是正常的吗,看到baidu.com 是因为hm.baidu.com的规则被引入,那是否意思是应该hm.baidu.com走proxy,其他*.baidu.com走direct

func AppendSingleDomainRule(host string, ports []int, debug bool) {
    if domainRules == nil {
        domainRules = hashmap.New[string, []int]()
    }

    var domain = domainutil.Domain(host)
    if domain == "" {
        domain = host
    }
    if strings.Contains(host, "baidu") {
        log.Printf("AppendSingleDomainRule: %s %s[%v]", host, domain, ports)
    }

    if debug {
        log.Printf("AppendSingleDomainRule: %s[%v]", domain, ports)
    }

    domainRules.Set(domain, ports)
}
cxz66666 commented 11 months ago

tun mode导致baidu无法访问的原因主要是因为route的设置,现在默认是10.0.0.0/8 才被路由到 tun 网卡,进而走rvpn逻辑,所以

    if useProxy {
        if TunMode {
            if network == "tcp" {
                log.Printf("tcp tun %s -> PROXY", addr)

                addrTarget := net.TCPAddr{
                    IP:   target.IP,
                    Port: port,
                }

                bind := net.TCPAddr{
                    IP:   net.IP(dialer.client.clientIp),
                    Port: 0,
                }

                return net.DialTCP(network, &bind, &addrTarget)

这里只有addrTarget是10.0.0.0/8的包,才能被tun接收并处理,baidu.com解析出来是39.*.*.*,会直接以src ip: 10.190.*.*, dst ip:39.*.*.*的形式从wan口出

Mythologyli commented 11 months ago

感觉问题不在 route 上,我先给 server 设置一条路由,然后给 tun 设置到 0.0.0.0 的路由,并且 metric 比有线网卡低,百度还是无法访问

在 TUN 网卡上抓包也能抓到: image

并且一开始百度服务器是有回应的

cxz66666 commented 11 months ago

感觉问题不在 route 上,我先给 server 设置一条路由,然后给 tun 设置到 0.0.0.0 的路由,并且 metric 比有线网卡低,百度还是无法访问

确实,现在 speedtest.zju.edu.cn 解析出来是10.0.0.0/8 但是还是没法访问,还得再想想。

cxz66666 commented 11 months ago

灵光乍现,MTU改成1400即可! sudo ip link set dev tun0 mtu 1400

Mythologyli commented 11 months ago

灵光乍现,MTU改成1400即可! sudo ip link set dev tun0 mtu 1400

Nice!果然是这样,在 Windows 这边也成功修复问题

之所以没想到是因为 tun_stack_windows.go 在 Setup TUN 的时候设置过 1400 的 MTU,结果用命令一查发现没生效(太坑了)

cxz66666 commented 11 months ago

tun mode最终提供给的形式应该是0.0.0.0的路由还是10.0.0.0/8的路由呢,如果是10.0.0.0/8的形式话,tun mode 配合 proxy会有大问题,非10.0.0.0/8的ip没法响应

Mythologyli commented 11 months ago

tun mode最终提供给的形式应该是0.0.0.0的路由还是10.0.0.0/8的路由呢,如果是10.0.0.0/8的形式话,tun mode 配合 proxy会有大问题,非10.0.0.0/8的ip没法响应

Windows 上可以设置两个 0.0.0.0 的路由并设置优先级,在 linux 上这样可行吗

cxz66666 commented 11 months ago

Windows 上可以设置两个 0.0.0.0 的路由并设置优先级,在 linux 上这样可行吗

不可行,linux能干的事情是创两个路由表,每个路由表指定默认路由,然后将不同的流分到使用不同的路由表

cxz66666 commented 11 months ago

感觉一个可行的思路是用10.0.0.0/8, 然后这里只处理addr 是10.0.0.0/8的,其他proxy情况交给gvisor处理,基本上也够tun的场景使用

dns则是全局劫持,windows和linux都能做到

        if TunMode && target.IP[0] == 10{
            if network == "tcp" {
                log.Printf("tcp tun %s -> PROXY", addr)

                addrTarget := net.TCPAddr{
                    IP:   target.IP,
                    Port: port,
                }

                bind := net.TCPAddr{
                    IP:   net.IP(dialer.client.clientIp),
                    Port: 0,
                }

                return net.DialTCP(network, &bind, &addrTarget)
            } else if network == "udp" {
                log.Printf("%s -> PROXY", addr)

                addrTarget := net.UDPAddr{
                    IP:   target.IP,
                    Port: port,
                }

                bind := net.UDPAddr{
                    IP:   net.IP(dialer.client.clientIp),
                    Port: 0,
                }

                return net.DialUDP(network, &bind, &addrTarget)
Mythologyli commented 11 months ago

意思是同时启用 TUN 和 gVisor 网络栈吗,有没有可能产生冲突,比如发出的包的源端口,还有入站的情况怎么处理

cxz66666 commented 11 months ago

意思是同时启用 TUN 和 gVisor 网络栈吗,有没有可能产生冲突,比如发出的包的源端口,还有入站的情况怎么处理

是的同时启用,确实我没试过启用这俩会不会有问题,不过我猜测大概率可行。源端口的话还按照现在的代码来应该就能用)

入站的话 gVisor应该不用其他处理,其他入站情况

  1. dns解析劫持,开启tun模式后所有dns走proxy
  2. 10.* ip 直接进tun设备走代理
  3. http proxy和socks proxy走代理的话根据是不是10.*区分
  4. 非http/socks proxy, 非10.* ip 正常走wan口出

好像不行,再想想。。

Mythologyli commented 11 months ago

其实最“漂亮”的方案是像 https://github.com/xjasonlyu/tun2socks 那样,用一个 gVisor 网络栈处理来自 TUN 设备的 TCP/UDP,然后改一改 tun2socks 的 proxy(https://github.com/xjasonlyu/tun2socks/tree/main/proxy) ,走 EasyConnect 的 gVisor 栈出去,相当于起两个 gVisor 栈,不过代价会不会有点大了(

cxz66666 commented 11 months ago

我感觉是不是有可能只创建一个gVisor栈,把tun设备整成一个EasyConnectGvisorEndpoint NIC,添加到gVisor栈中,然后所有路由都走之前的default NIC出,看起来就很合理

Mythologyli commented 11 months ago

意思是同时启用 TUN 和 gVisor 网络栈吗,有没有可能产生冲突,比如发出的包的源端口,还有入站的情况怎么处理

如果严格保证 10.0.0.0/8 走 TUN 其他走 gVisor 也许能保证源端口不冲突?()

EasyConnect 服务端发回的每一个包都要同时往 TUN 和 gVisor 里面写

Mythologyli commented 11 months ago

我感觉是不是有可能只创建一个gVisor栈,把tun设备整成一个EasyConnectGvisorEndpoint NIC,添加到gVisor栈中,然后所有路由都走之前的default NIC出,看起来就很合理

这样话应该没法给 TUN 设备设置正确的 IP,大概率要放弃入站功能(RVPN 的网卡是可以入站的,并且没有防火墙)

cxz66666 commented 11 months ago

如果严格保证 10.0.0.0/8 走 TUN 其他走 gVisor 也许能保证源端口不冲突?()

刚才试了下,起不来,只能和server端建立一个tls 😢

Mythologyli commented 11 months ago

也许可以通过绑定到接口解决: https://github.com/extremecoders-re/go-dispatch-proxy/blob/master/servers_response_linux.go#L24-L24

cxz66666 commented 11 months ago

找到一个有意思的实现: https://github.com/jursonmo/tcpbinddev

使用原生的BindToDevice之后connection就不归go的网络轮询器管理了,之后读会用阻塞的read syscall,这个使用net.FileConn(file)再给connection加回网络轮询器

现在把代码改成这样是可以正常tun mode下用http proxy下访问内网外网的,美中不足就是只有linux实现

if useProxy {
    if TunMode {
        if network == "tcp" {
            log.Printf("tcp tun %s -> PROXY", addr)

            addrTarget := net.TCPAddr{
                IP:   target.IP,
                Port: port,
            }

            bind := net.TCPAddr{
                IP:   net.IP(dialer.client.clientIp),
                Port: 0,
            }
            return tcpbinddev.TcpBindToDev("tcp4", addrTarget.String(), bind.String(), "tun0", 3)
Mythologyli commented 11 months ago

https://github.com/extremecoders-re/go-dispatch-proxy/blob/master/servers_response_linux.go#L24-L24 这个方案我昨天试是可以的

https://github.com/jursonmo/tcpbinddev 这一个我刚才试一下也是可以的。看他的 README 这个是有性能优势嘛

另外 UDP 的话有类似的实现吗

cxz66666 commented 11 months ago

https://github.com/jursonmo/tcpbinddev 这一个我刚才试一下也是可以的。看他的 README 这个是有性能优势嘛

我仔细分析了一下go的dial过程,发现他claim的点是在使用原生的syscall.Socket下有性能优势,但是如果像dispatch repo里使用Dialer下的Control函数,就还是走go的网络监控器,一切和net.DialTCP类似,所以dispatch的方案更好,而且更加通用

cxz66666 commented 11 months ago

https://github.com/golang/go/blob/9162c4be9c1eb2a36f46a493d8931a7caa3cac10/src/net/sock_posix.go#L92-L132

分析到这里发现调用Control是在调用TCP/UDP都通用的路径上,所以这个方法应该TCP/UDP都通用(猜测)

Mythologyli commented 11 months ago

OS X 的情况还不太了解,不知道是像 Windows 那样绑定的 IP 就可以还是要想办法绑定到接口。你那边有条件测试嘛

cxz66666 commented 11 months ago

我晚上可以测一下macos上的效果。btw,windows不需要绑定到接口吗(

Mythologyli commented 11 months ago

不需要,Windows 只需要一条 0.0.0.0/0 的路由(把 metric 设置为 9999 避免成为默认),然后 dial 的时候设置源 IP 就可以

Mythologyli commented 11 months ago
func (s *Stack) DialTCP(addr *net.TCPAddr) (net.Conn, error) {
    return net.DialTCP("tcp4", &net.TCPAddr{
        IP:   s.endpoint.ip,
        Port: 0,
    }, addr)
}

func (s *Stack) DialUDP(addr *net.UDPAddr) (net.Conn, error) {
    return net.DialUDP("udp4", &net.UDPAddr{
        IP:   s.endpoint.ip,
        Port: 0,
    }, addr)
}

image

cxz66666 commented 11 months ago

在macos上找到了这样的解决方法,并测试成功:

        if TunMode {
            if network == "tcp" {
                log.Printf("%s -> PROXY", addr)
                goDialer := &net.Dialer{
                    Control: func(network, address string, c syscall.RawConn) error {
                        return c.Control(func(fd uintptr) {
                            iface, err := net.InterfaceByName("utun10")
                            if err != nil {
                                fmt.Println(err)
                            }
                            if err = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_RECVIF, iface.Index); err != nil {
                                fmt.Println(err)
                            }
                        })
                    },
                }
                return goDialer.Dial("tcp", addr)

https://github.com/insomniacslk/dhcp/issues/378 https://stackoverflow.com/questions/20616029/os-x-equivalent-of-so-bindtodevice/57013928#57013928 这两个里面都提到了使用IP_BOUND_IF这个flag,但是我使用这个flag并不行,设置成IP_RECVIF则可以在proxy mode下正确的访问内网外网

cxz66666 commented 11 months ago

测试了一下macos上clash tun mode的情况,由于没有linux那样的网络子系统,clash是修改dns到8.8.8.8,然后路由写死,全部流量进clash,包括8.8.8.8:53,然后再用之前系统的dns进行解析。

感觉最后可以提供一个这样形式的dns server:

  1. tun mode下强制开始dns server,用户可以配置文件中选择绑定/不绑定到ip:port
  2. proxy mode下默认不开启dns server,用户可以配置开启/绑定到ip:port
  3. windows下用netsh dnsservers; linux下新建一张route table,劫持53到tun设备;macos下修改dns服务器到某个ip A,配置路由A到tun设备。

还有一个想法:初步实现中,使用配置文件中指定的SecondaryDns感觉没啥问题,但是后续改成可以自动使用用户机器原本的dns