V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
felix021
V2EX  ›  程序员

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

  •  
  •   felix021 ·
    felix021 · 2020-11-21 21:50:05 +08:00 · 5723 次点击
    这是一个创建于 1455 天前的主题,其中的信息可能已经有所发展或是发生改变。

    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 条回复    2021-09-09 01:17:14 +08:00
    toomlo
        1
    toomlo  
       2020-11-21 21:59:04 +08:00
    萌新看懂了,可以进字节了咩
    felix021
        2
    felix021  
    OP
       2020-11-21 22:00:13 +08:00   ❤️ 1
    @toomlo 投个简历试试就知道了,大型真人闯关游戏
    leewi9coder
        3
    leewi9coder  
       2020-11-21 22:03:46 +08:00
    可以教张 xx 做产品了吗
    geebos
        4
    geebos  
       2020-11-21 22:28:07 +08:00
    这个测试机的配置是不是有点高,C10k 我记得 RAM 是 2GB
    felix021
        5
    felix021  
    OP
       2020-11-21 22:32:44 +08:00
    @geebos 嗯,顺手用了,不过 10k 个 goroutine 也就 20MB 的栈空间,实测大概 70MB 的内存吧,2GB 绰绰有余了。
    geebos
        6
    geebos  
       2020-11-21 22:52:53 +08:00
    @felix021 我刚刚看了一些资料,好像 C10K 问题重点是并发 10K 个连接,只看 QPS 好像粒度不够。我的理解是任意时刻应该至少有 10K 个连接存在。而且这个测试机配置太高了,感觉测试结果不太具有说服力。
    SingeeKing
        7
    SingeeKing  
       2020-11-21 22:53:36 +08:00
    我感觉测试机用 Xeon 6130 (16c32t) *2 + 376G RAM 是在耍赖
    felix021
        8
    felix021  
    OP
       2020-11-21 23:29:32 +08:00
    @geebos 是的,所以我 benchmark 指定了 -c 10000,开了 10k 个连接。

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

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

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

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

    if err != nil {
    xxx
    return
    }

    🐶
    misaka19000
        32
    misaka19000  
       2020-11-22 22:59:06 +08:00
    是时候贴出我这个菜鸡用 go 写的代理了。。。

    https://github.com/RitterHou/stinger
    jinliming2
        33
    jinliming2  
       2020-11-23 00:45:24 +08:00
    我这就去把之前用第三方 socks5 库的部分替换掉……
    danbai
        34
    danbai  
       2020-11-23 09:02:27 +08:00 via Android
    stdying
        35
    stdying  
       2020-11-23 09:02:35 +08:00 via Android
    手机华为浏览器打开这个链接自动关闭
    nutting
        36
    nutting  
       2020-11-23 09:07:14 +08:00
    gost 就是 go 开发的,包含一系列代理之类的功能,很强大
    feelinglucky
        37
    feelinglucky  
       2020-11-23 21:07:29 +08:00
    felix021
        38
    felix021  
    OP
       2021-09-09 01:17:14 +08:00
    @xrr2016 毕竟 Errlang 的名字不是白给的,萝卜白菜吧这个
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2886 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 03:40 · PVG 11:40 · LAX 19:40 · JFK 22:40
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.