12

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

 3 years ago
source link: https://studygolang.com/articles/31685
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

7fIR3ab.jpg!mobile

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

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

uUJZVfR.jpg!mobile

全文完

(不是)

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 代理,这就有点尴尬了。

不过还好,开源的世界里什么都有,在

大型同性交友网站 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 !

AzQN7vN.jpg!mobile

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

MVJBj2B.jpg!mobile

- 小结 -

最后照例简单总结下:

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

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

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

↓↓↓ 长期招聘 ↓↓↓

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

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

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

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

字节跳动所有职位

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

欢迎关注

rIBNZjj.png!mobile

参考链接

  1. RFC1928 - SOCKS Protocol Version 5
  2. Minimal socks5 proxy in Golang
  3. Benchmark by @cnlh
  4. 今日头条Go建千亿级微服务的实践

有疑问加站长微信联系(非本文作者)

eUjI7rn.png!mobile

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK