• 请不要在回答技术问题时复制粘贴 AI 生成的内容
felix021
V2EX  ›  程序员

实战: 150 行 Go 实现高性能 socks5 代理

  •  
  •   felix021 ·
    felix021 · Nov 21, 2020 · 7476 views
    This topic created in 2174 days ago, the information mentioned may be changed or developed.

    image

    光说不练假把式,不如上手试试,这篇来写个有点卵用的东西。

    TCP Server

    用 Go 实现一个 TCP Server 实在是太简单了,什么 c10k problem 、select 、poll 、epoll 、kqueue 、iocp 、libevent,通通不需要(<del>但为了通过面试你还是得去看呀</del>),只需要这样两步:

    • 监听端口 1080 ( socks5 的默认端口)
    • 每收到一个请求,启动一个 goroutine 来处理它

    搭起这样一个架子,实现一个 Hello world,大约需要 30 行代码:

    func main() {
      server, err := net.Listen("tcp", ":1080")
      if err != nil {
        fmt.Printf("Listen failed: %v\n", err)
        return
      }
    
      for {
        client, err := server.Accept()
        if err != nil {
          fmt.Printf("Accept failed: %v", err)
          continue
        }
        go process(client)
      }
    }
    
    func process(client net.Conn) {
      remoteAddr := client.RemoteAddr().String()
      fmt.Printf("Connection from %s\n", remoteAddr)
      client.Write([]byte("Hello world!\n"))
      client.Close()
    }
    

    SOCKS5

    socks5 是 SOCKS Protocol Version 5 的缩写,其规范定义于 RFC 1928[1],感兴趣的同学可以自己去翻一翻。

    它是个二进制协议,不那么直观,不过实际上非常简单,主要分成三个步骤:

    • 认证
    • 建立连接
    • 转发数据

    我们只需 16 行就能把 socks5 的架子搭起来:

    func process(client net.Conn) {
      if err := Socks5Auth(client); err != nil {
        fmt.Println("auth error:", err)
        client.Close()
        return
      }
    
      target, err := Socks5Connect(client)
      if err != nil {
        fmt.Println("connect error:", err)
        client.Close()
        return
      }
    
      Socks5Forward(client, target)
    }
    
    

    这样一看是不是特别简单?

    然后你只要把 Socks5Auth 、Socks5Connect 和 Socks5Forward 给补上,一个完整的 socks5 代理就完成啦!是不是就像画一匹马一样简单?

    image

    <del>全文完</del>(不是)

    Socks5Auth

    言归正传,socks5 协议规定,客户端需要先开口:

    +----+----------+----------+
    |VER | NMETHODS | METHODS  |
    +----+----------+----------+
    | 1  |    1     | 1 to 255 |
    +----+----------+----------+
    

    (RFC 1928,首行是字段名,次行是字节数)

    解释一下:

    • VER
      • 本次请求的协议版本号,取固定值 0x05 (表示 socks 5)
    • NMETHODS
      • 客户端支持的认证方式数量,可取值 1~255
    • METHODS
      • 可用的认证方式列表

    我们用如下代码来读取客户端的发言:

    func Socks5Auth(client net.Conn) (err error) {
      buf := make([]byte, 256)
    
      // 读取 VER 和 NMETHODS
      n, err := io.ReadFull(client, buf[:2])
      if n != 2 {
        return errors.New("reading header: " + err.Error())
      }
    
      ver, nMethods := int(buf[0]), int(buf[1])
      if ver != 5 {
        return errors.New("invalid version")
      }
    
      // 读取 METHODS 列表
      n, err = io.ReadFull(client, buf[:nMethods])
      if n != nMethods {
        return errors.New("reading methods: " + err.Error())
      }
    
      //TO BE CONTINUED...
    

    然后服务端得选择一种认证方式,告诉客户端:

    • VER
      • 也是 0x05,对上 SOCKS 5 的暗号
    • METHOD
      • 选定的认证方式;其中 0x00 表示不需要认证,0x02 是用户名 /密码认证,……

    简单起见我们就不认证了,给客户端回复 0x05 、0x00 即可:

      //无需认证
      n, err = client.Write([]byte{0x05, 0x00})
      if n != 2 || err != nil {
        return errors.New("write rsp err: " + err.Error())
      }
    
      return nil
    }
    

    以上 Socks5Auth 总共 28 行。

    Socks5Connect

    在完成认证以后,客户端需要告知服务端它的目标地址,协议具体要求为:

    +----+-----+-------+------+----------+----------+
    |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
    +----+-----+-------+------+----------+----------+
    | 1  |  1  | X'00' |  1   | Variable |    2     |
    +----+-----+-------+------+----------+----------+
    
    • VER
      • 0x05,老暗号了
    • CMD
      • 连接方式,0x01=CONNECT, 0x02=BIND, 0x03=UDP ASSOCIATE
    • RSV
      • 保留字段,现在没卵用
    • ATYP
      • 地址类型,0x01=IPv4,0x03=域名,0x04=IPv6
    • DST.ADDR
      • 目标地址,细节后面讲
    • DST.PORT
      • 目标端口,2 字节,网络字节序( network octec order )

    咱们先读取前四个字段:

    func Socks5Connect(client net.Conn) (net.Conn, error) {
      buf := make([]byte, 256)
    
      n, err := io.ReadFull(client, buf[:4])
      if n != 4 {
        return nil, errors.New("read header: " + err.Error())
      }
    
      ver, cmd, _, atyp := buf[0], buf[1], buf[2], buf[3]
      if ver != 5 || cmd != 1 {
        return nil, errors.New("invalid ver/cmd")
      }
    
      //TO BE CONTINUED...
    

    注:BIND 和 UDP ASSOCIATE 这两个 cmd 我们这里就先偷懒不支持了。

    接下来问题是如何读取 DST.ADDR 和 DST.PORT 。

    如前所述,ADDR 的格式取决于 ATYP:

    • 0x01:4 个字节,对应 IPv4 地址
    • 0x02:先来一个字节 n 表示域名长度,然后跟着 n 个字节。注意这里不是 NUL 结尾的。
    • 0x03:16 个字节,对应 IPv6 地址
      addr := ""
      switch atyp {
      case 1:
        n, err = io.ReadFull(client, buf[:4])
        if n != 4 {
          return nil, errors.New("invalid IPv4: " + err.Error())
        }
        addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
    
      case 3:
        n, err = io.ReadFull(client, buf[:1])
        if n != 1 {
          return nil, errors.New("invalid hostname: " + err.Error())
        }
        addrLen := int(buf[0])
    
        n, err = io.ReadFull(client, buf[:addrLen])
        if n != addrLen {
          return nil, errors.New("invalid hostname: " + err.Error())
        }
        addr = string(buf[:addrLen])
    
      case 4:
        return nil, errors.New("IPv6: no supported yet")
    
      default:
        return nil, errors.New("invalid atyp")
      }
    
    

    注:这里再偷个懒,IPv6 也不管了。

    接着要读取的 PORT 是一个 2 字节的无符号整数。

    需要注意的是,协议里说,这里用了 “network octec order” 网络字节序,其实就是 BigEndian (还记得我们在 《UTF-8:一些好像没什么用的冷知识》里讲的小人国的故事吗?)。别担心,Golang 已经帮我们准备了个 BigEndian 类型:

      n, err = io.ReadFull(client, buf[:2])
      if n != 2 {
        return nil, errors.New("read port: " + err.Error())
      }
      port := binary.BigEndian.Uint16(buf[:2])
    

    既然 ADDR 和 PORT 都就位了,我们马上创建一个到 dst 的连接:

     destAddrPort := fmt.Sprintf("%s:%d", addr, port)
     dest, err := net.Dial("tcp", destAddrPort)
     if err != nil {
       return nil, errors.New("dial dst: " + err.Error())
     }
    

    最后一步是告诉客户端,我们已经准备好了,协议要求是:

    +----+-----+-------+------+----------+----------+
    |VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
    +----+-----+-------+------+----------+----------+
    | 1  |  1  | X'00' |  1   | Variable |    2     |
    +----+-----+-------+------+----------+----------+
    
    • VER
      • 暗号,还是暗号!
    • REP
      • 状态码,0x00=成功,0x01=未知错误,……
    • RSV
      • 依然是没卵用的 RESERVED
    • ATYP
      • 地址类型
    • BND.ADDR
      • 服务器和 DST 创建连接用的地址
    • BND.PORT
      • 服务器和 DST 创建连接用的端口

    BND.ADDR/PORT 本应填入 dest.LocalAddr(),但因为基本上也没甚卵用,我们就直接用 0 填充了:

      n, err = client.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
      if err != nil {
      dest.Close()
        return nil, errors.New("write rsp: " + err.Error())
      }
      return dest, nil
    }
    

    注: ATYP = 0x01 表示 IPv4,所以需要填充 6 个 0 —— 4 for ADDR, 2 for PORT 。

    这个函数加在一起有点长,整整用了 62 行,但其实也就这么回事,对吧?

    Socks5Forward

    万事俱备,剩下的事情就是转发、转发、转发。

    所谓“转发”,其实就是从一头读,往另一头写。

    需要注意的是,由于 TCP 连接是双工通信,我们需要创建两个 goroutine,用于完成“双工转发”。

    由于 golang 有一个 io.Copy 用来做转发的事情,代码只要 9 行,简单到难以形容:

    func Socks5Forward(client, target net.Conn) {
      forward := func(src, dest net.Conn) {
        defer src.Close()
        defer dest.Close()
        io.Copy(src, dest)
      }
      go forward(client, target)
      go forward(target, client)
    }
    

    注意:在发送完以后需要关闭连接。

    验证

    把上面的代码组装起来,补上 package main 和必要的 import,总共 145 行,一个能用的 socks5 代理服务器就成型了(完整代码可参见这个 gist[2])。

    上手跑起来:

    $ go run socks5_proxy.go
    

    发起代理访问请求:

    $ curl --proxy "socks5://127.0.0.1:1080" \
      https://job.toutiao.com/s/JxLbWby
    

    注:↑上面这个链接很有用,建议在浏览器里打开查看。

    代码是没啥问题了,不过标题里的 “高性能” 这个 flag 立得起来吗?

    压测

    说到压测,自然就想到老牌工具 ab ( apache benchmark ),不过它只支持 http 代理,这就有点尴尬了。

    不过还好,开源的世界里什么都有,在 <del>大型同性交友网站</del> Github 上,@cnlh 同学写了个支持 socks5 代理的 benchmark 工具[3],马上就可以燥起来:

    $ go get github.com/cnlh/benchmark
    

    由于代理本身不提供 http 服务,我们可以基于 gin 写一个高性能的 http server:

    package main
    import "github.com/gin-gonic/gin"
    
    func main() {
      r := gin.Default()
      r.GET("/ping", func(c *gin.Context) {
        c.String(200, "pong")
      })
      r.Run(":8080")
    }
    

    跑起来:

    $ go run http_server.go
    

    先对它进行一轮压测,测试机是 Xeon 6130(16c32t) *2 + 376G RAM 。

    简单粗暴,直接上 c10k + 100w 请求:

    $ benchmark -c 10000 -n 1000000 \
      http://127.0.0.1:8080/ping
    
    Running 1000000 test @ 127.0.0.1:8080 by 10000 connections
    ...
    1000000 requests in 10.57s, 115.59MB read, 42.38MB write
    Requests/sec: 94633.20
    Transfer/sec: 14.95MB
    Error       : 0
    Percentage of the requests served within a certain time (ms)
     50%           47
     90%           299
     95%           403
     99%           608
     100%          1722
    

    10 行代码就能扛住 c10k problem,还做到了 94.6k QPS !

    image

    不过由于并发量太大,导致 p99 需要 608ms ;如果换成 1000 个并发,QPS 没太大变化,p99 可以下降到 63ms 。

    接下来该我们的 socks5 代理上场了:

    $ go run socks_proxy.go
    
    $ benchmark -c 10000 -n 1000000 \
      -proxy socks5://127.0.0.1:1080  \
      http://127.0.0.1:8080/ping
    
    Running 1000000 test @ 127.0.0.1:8080 by 10000 connections
    ...
    1000000 requests in 11.47s, 115.59MB read, 42.38MB write
    Requests/sec: 87220.83
    Transfer/sec: 13.78MB
    Error       : 0
    Percentage of the requests served within a certain time (ms)
     50%           102
     90%           318
     95%           424
     99%           649
     100%          1848
    

    QPS 微降到 87.2k ,p99 649ms 也不算显著上涨;换成 1000 并发,QPS 89.2k ,p99 则下降到了 66ms —— 说明代理本身对请求性能的影响非常小(注:如果把 benchmark 、http server 、代理放在不同的机器上执行,应该会看到更小的性能损耗)。

    标题里的 “高性能” 这个 flag 算是立住了。

    image

    - 小结 -

    最后照例简单总结下:

    • Go 语言非常适合实现网络服务,代码短小精悍,性能强大
    • Socks 5 是一个简单的二进制网络代理协议
    • 网络字节序实际上就是 BigEndian,大端存储

    顺便一提:实际上字节跳动早期的很多服务(比如今日头条的 Feed 流服务)都是用 Python 实现的,由于性能的原因,我们在 2015 年开始用 Go 重构,并逐渐演化出了自研的微服务框架,感兴趣的同学可以阅读 InfoQ 的这篇《今日头条 Go 建千亿级微服务的实践》[4]。

    当然,想要进一步了解的话,最好的方式还是能直接看到这个微服务框架的源码,并且实际上手用它 ——

    ↓↓↓ 长期招聘 ↓↓↓

    投放研发工程师 — 穿山甲 @上海

    https://job.toutiao.com/s/JP6gWsy

    后端研发工程师 - 穿山甲 @北京

    https://job.toutiao.com/s/JP6pK95

    字节跳动所有职位

    https://job.toutiao.com/s/JP6oV3S

    欢迎关注

    weixin2s.png

       ▄▄▄▄▄▄▄   ▄      ▄▄▄▄ ▄▄▄▄▄▄▄  
       █ ▄▄▄ █ ▄▀ ▄ ▀██▄ ▀█▄ █ ▄▄▄ █  
       █ ███ █  █  █  █▀▀▀█▀ █ ███ █  
       █▄▄▄▄▄█ ▄ █▀█ █▀█ ▄▀█ █▄▄▄▄▄█  
       ▄▄▄ ▄▄▄▄█  ▀▄█▀▀▀█ ▄█▄▄   ▄    
       ▄█▄▄▄▄▄▀▄▀▄██   ▀ ▄  █▀▄▄▀▄▄█  
       █ █▀▄▀▄▄▀▀█▄▀█▄▀█████▀█▀▀█ █▄  
        ▀▀  █▄██▄█▀  █ ▀█▀ ▀█▀ ▄▀▀▄█  
       █▀ ▀ ▄▄▄▄▄▄▀▄██  █ ▄████▀▀ █▄  
       ▄▀▄▄▄ ▄ ▀▀▄████▀█▀  ▀ █▄▄▄▀▄█  
       ▄▀▀██▄▄  █▀▄▀█▀▀ █▀ ▄▄▄██▀ ▀   
       ▄▄▄▄▄▄▄ █ █▀ ▀▀   ▄██ ▄ █▄▀██  
       █ ▄▄▄ █ █▄ ▀▄▀ ▀██  █▄▄▄█▄  ▀  
       █ ███ █ ▄ ███▀▀▀█▄ █▀▄ ██▄ ▀█  
       █▄▄▄▄▄█ ██ ▄█▀█  █ ▀██▄▄▄  █▄  
    

    参考链接

    1. RFC1928 - SOCKS Protocol Version 5
    2. Minimal socks5 proxy in Golang
    3. Benchmark by @cnlh
    4. 今日头条 Go 建千亿级微服务的实践
    38 replies  •  2021-09-09 01:17:14 +08:00
    toomlo
    toomlo
    Nov 21, 2020
    1
    萌新看懂了,可以进字节了咩
    felix021
    felix021
    OP
    Nov 21, 2020❤️1
    2
    @toomlo 投个简历试试就知道了,大型真人闯关游戏
    leewi9coder
    leewi9coder
    Nov 21, 2020
    3
    可以教张 xx 做产品了吗
    geebos
    geebos
    Nov 21, 2020
    4
    这个测试机的配置是不是有点高,C10k 我记得 RAM 是 2GB
    felix021
    felix021
    OP
    Nov 21, 2020
    5
    @geebos 嗯,顺手用了,不过 10k 个 goroutine 也就 20MB 的栈空间,实测大概 70MB 的内存吧,2GB 绰绰有余了。
    geebos
    geebos
    Nov 21, 2020
    6
    @felix021 我刚刚看了一些资料,好像 C10K 问题重点是并发 10K 个连接,只看 QPS 好像粒度不够。我的理解是任意时刻应该至少有 10K 个连接存在。而且这个测试机配置太高了,感觉测试结果不太具有说服力。
    SingeeKing
    SingeeKing
    Nov 21, 2020
    7
    我感觉测试机用 Xeon 6130 (16c32t) *2 + 376G RAM 是在耍赖
    felix021
    felix021
    OP
    Nov 21, 2020
    8
    @geebos 是的,所以我 benchmark 指定了 -c 10000,开了 10k 个连接。

    如果你们对性能一般的机器感兴趣的话可以自己试试,扛住 10w 并发没啥问题,但是延时可能会比较难看一点 @geebos @SingeeKing
    hjc4869
    hjc4869
    Nov 22, 2020 via Android
    9
    楼主这么实现 close 是有问题的。
    felix021
    felix021
    OP
    Nov 22, 2020 via Android
    10
    @hjc4869 是指哪一个 close 有问题? process 的吗?
    hjc4869
    hjc4869
    Nov 22, 2020❤️3
    11
    @felix021 TCP 是可以单向 shutdown,另一个方向继续传输数据的,但是像你这么实现只要有一边 shutdown 了,整个连接就被强制关闭了,因为任意一个方向的 io.Copy() 结束之后就会执行 defer 的两个 Close()。
    vduang
    vduang
    Nov 22, 2020 via Android
    12
    建议和其他开源 socks 代理对比下性能数据,这样比较有说服力。
    AmrtaShiva
    AmrtaShiva
    Nov 22, 2020 via iPhone
    13
    能用这写个类似 V2xxx 的工具?
    Ehco1996
    Ehco1996
    Nov 22, 2020❤️1
    14
    大型真人闯关游戏笑到我了...

    不过用 go 写代理是真的简单快捷,小弟也写了个 tcp Over Ws/s 的代理工具

    欢迎各位大佬瞅瞅 https://github.com/Ehco1996/ehco
    dick20cm
    dick20cm
    Nov 22, 2020
    15
    夸一下,以小见大楼主代码写的真不错
    monkeyWie
    monkeyWie
    Nov 22, 2020 via Android
    16
    go 开发网络相关的东西心智负担是真的低。
    wweir
    wweir
    Nov 22, 2020 via Android
    17
    socks5
    b00tyhunt3r
    b00tyhunt3r
    Nov 22, 2020
    18
    马一下
    felix021
    felix021
    OP
    Nov 22, 2020
    19
    @hjc4869 感谢指出,目前这个逻辑确实不能正确处理 half closed tcp connection ;不过考虑到真实网络对于 TCP 的这个 feature 支持并不好(比如有些 NAT 的实现就是遇到 FIN 直接关闭),实践中也几乎没有看到 half-closed 的场景,所以暂时就先这么实现了(如果想要完全符合 RFC,实现的代码会比较啰嗦)
    felix021
    felix021
    OP
    Nov 22, 2020
    20
    @AmrtaShiva 可以的,有很多已经很完善的开源项目了,比如 gost,clash 等
    sadfQED2
    sadfQED2
    Nov 22, 2020 via Android
    21
    哈哈,我刚好上周也写了一个内网穿透工具。
    https://github.com/Jinnrry/Mercurius

    目前能够实现 tcp 协议的代理,总共大概一千行代码
    eudore
    eudore
    Nov 22, 2020
    22
    楼主贴个 github 给小弟参考参考啊!
    Lemeng
    Lemeng
    Nov 22, 2020
    23
    看评论,学姿势
    julyclyde
    julyclyde
    Nov 22, 2020❤️1
    24
    过几天 js 程序员们也会再来写一遍
    oxogenesis
    oxogenesis
    Nov 22, 2020
    25
    这个二维码用什么生成的?
    C0VN
    C0VN
    Nov 22, 2020
    26
    @felix021 问一下楼主!!
    我在写一个 udp 服务端。我通过`UDPConn.ReadFromUDP(data)`获取的数据是 []byte,如果收到一些不标准的数据包在解析数据的时候就会 slice 下标越界。那么用上面提到的`io.ReadFull`处理应该能比较好的处理上面的情况,但是我不太清楚如何将一个已经获取的 []byte 转换成 ReadFull 函数需要的 Reader 类型,请问该用什么函数怎么转换呢?
    Jirajine
    Jirajine
    Nov 22, 2020
    27
    @xavierskip #26 UDPConn 本身就实现 Reader 了,没必要再套一层。[]byte 转换成 bytes.Buffer 才能实现。
    这种问题直接查文档就是了。
    go 只是 goroutine 方便,实现协议上各种 iferr 还是挺蛋疼的。
    MasterMonkey
    MasterMonkey
    Nov 22, 2020 via iPhone
    28
    来一个 3 小时编程挑战,完胜你就可以进 byte 了?
    C0VN
    C0VN
    Nov 22, 2020 via Android
    29
    @Jirajine 看了下 bytes.NewBuffer 应该可以。或者直接用 UDPConn 我来试试看!
    isayme
    isayme
    Nov 22, 2020 via iPhone
    30
    很棒
    xrr2016
    xrr2016
    Nov 22, 2020
    31
    怎么感觉 Go 代码一直在写

    if err != nil {
    xxx
    return
    }

    🐶
    misaka19000
    misaka19000
    Nov 22, 2020
    32
    是时候贴出我这个菜鸡用 go 写的代理了。。。

    https://github.com/RitterHou/stinger
    jinliming2
    jinliming2
    Nov 23, 2020
    33
    我这就去把之前用第三方 socks5 库的部分替换掉……
    danbai
    danbai
    PRO
    Nov 23, 2020 via Android
    34
    stdying
    stdying
    Nov 23, 2020 via Android
    35
    手机华为浏览器打开这个链接自动关闭
    nutting
    nutting
    Nov 23, 2020
    36
    gost 就是 go 开发的,包含一系列代理之类的功能,很强大
    feelinglucky
    feelinglucky
    Nov 23, 2020
    37
    felix021
    felix021
    OP
    Sep 9, 2021
    38
    @xrr2016 毕竟 Errlang 的名字不是白给的,萝卜白菜吧这个
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Privacy   ·   Solana   ·   2876 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 91ms · UTC 12:05 · PVG 20:05 · LAX 04:05 · JFK 07:05
    ♥ Do have faith in what you're doing.