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

Go语言精选

共 7748字,需浏览 16分钟

 · 2020-12-01


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




- TCP Server -


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


  • 监听端口 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 代理就完成啦!是不是就像画一匹马一样简单?



全文完 (不是)





- Socks5Auth -


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


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 -


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



VER0x05,老暗号了
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
状态码,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(targetclient)}

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




- 验证 -


把上面的代码组装起来,补上 "package main" 和必要的 import ,总共 145 行,一个能用的 socks5 代理服务器就成型了(完整代码可参见[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 代理,这就有点尴尬了。


不过还好,开源的世界里什么都有,在 大型同性交友网站 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 writeRequests/sec: 94633.20Transfer/sec: 14.95MBError : 0Percentage of the requests served within a certain time (ms)    50%           47    90%           299    95%           403    99%           608   100%           1722


11 行代码就能扛住 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 writeRequests/sec: 87220.83Transfer/sec: 13.78MBError : 0Percentage 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 算是立住了。





- 小结 -


最后照例简单总结下:


  • Go语言非常适合实现网络服务,代码短小精悍,性能强大

  • Socks 5 是一个简单的二进制网络代理协议

  • 网络字节序实际上就是 BigEndian,大端存储


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


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



参考链接:


1. RFC1928 - SOCKS Protocol Version 5

https://tools.ietf.org/html/rfc1928


2. Minimal socks5 proxy in Golang

https://gist.github.com/felix021/7f9d05fa1fd9f8f62cbce9edbdb19253


3. Benchmark by @cnlh

https://github.com/cnlh/benchmark


4. 今日头条Go建千亿级微服务的实践

https://mp.weixin.qq.com/s/CJL0Ttexvh7XT1zoNLOJrA



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。



浏览 6
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报