实战: 150 行 Go 实现高性能 socks5 代理 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
请不要在回答技术问题时复制粘贴 AI 生成的内容
felix021
V2EX    程序员

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

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

    image

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

    TCPServer

    用 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 Version5 的缩写,其规范定义于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 (表示 socks5
    • 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 ne.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])。

    上手跑起来:

    $gorunsocks5_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 Running1000000test@127.0.0.1:8080by10000connections ... 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-c10000-n1000000\ -proxysocks5://127.0.0.1:1080 \ http://127.0.0.1:8080/ping Running1000000test@127.0.0.1:8080by10000connections ... 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 并发,QPS89.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  
    PRO
       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  
    PRO
       2020-11-21 22:52:53 +08:00
    @felix021 我刚刚看了一些资料,好像 C10K 问题重点是并发 10K 个连接,只看 QPS 好像粒度不够。我的理解是任意时刻应该至少有 10K 个连接存在。而且这个测试机配置太高了,感觉测试结果不太具有说服力。
    SingeeKing
        7
    SingeeKing  
    PRO
       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
    div id="r_9813469" class="cell">
    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
    这个二维码用什么生成的?
    C0VN
        26
    C0VN  
       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 了?
    C0VN
        29
    C0VN  
       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  
    PRO
       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     Solana     1065 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 27ms UTC 18:04 PVG 02:04 LAX 10:04 JFK 13:04
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86