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

2020-11-21 21:50:05 +08:00
 felix021

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

TCP Server

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

搭起这样一个架子,实现一个 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 代理就完成啦!是不是就像画一匹马一样简单?

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

Socks5Auth

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

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

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

解释一下:

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

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...

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

简单起见我们就不认证了,给客户端回复 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     |
+----+-----+-------+------+----------+----------+

咱们先读取前四个字段:

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:

  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     |
+----+-----+-------+------+----------+----------+

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 !

不过由于并发量太大,导致 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 算是立住了。

- 小结 -

最后照例简单总结下:

顺便一提:实际上字节跳动早期的很多服务(比如今日头条的 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

欢迎关注

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

参考链接

  1. RFC1928 - SOCKS Protocol Version 5
  2. Minimal socks5 proxy in Golang
  3. Benchmark by @cnlh
  4. 今日头条 Go 建千亿级微服务的实践
5845 次点击
所在节点    程序员
38 条回复
sadfQED2
2020-11-22 12:23:35 +08:00
哈哈,我刚好上周也写了一个内网穿透工具。
https://github.com/Jinnrry/Mercurius

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

if err != nil {
xxx
return
}

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

https://github.com/RitterHou/stinger
jinliming2
2020-11-23 00:45:24 +08:00
我这就去把之前用第三方 socks5 库的部分替换掉……
danbai
2020-11-23 09:02:27 +08:00
stdying
2020-11-23 09:02:35 +08:00
手机华为浏览器打开这个链接自动关闭
nutting
2020-11-23 09:07:14 +08:00
gost 就是 go 开发的,包含一系列代理之类的功能,很强大
feelinglucky
2020-11-23 21:07:29 +08:00
felix021
2021-09-09 01:17:14 +08:00
@xrr2016 毕竟 Errlang 的名字不是白给的,萝卜白菜吧这个

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://tanronggui.xyz/t/727922

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX