實戰(zhàn):150行Go實現(xiàn)高性能socks5代理

光說不練假把式,不如上手試試,這篇來寫個有點卵用的東西。
- TCP?Server -
用 Go 實現(xiàn)一個 TCP Server 實在是太簡單了,什么?c10k problem、select、poll、epoll、kqueue、iocp、libevent,通通不需要(但為了通過面試你還是得去看呀),只需要這樣兩步:
監(jiān)聽端口 1080(socks5的默認端口)
每收到一個請求,啟動一個?goroutine 來處理它
搭起這樣一個架子,實現(xiàn)一個 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 的縮寫,其規(guī)范定義于?RFC 1928[1],感興趣的同學可以自己去翻一翻。
它是個二進制協(xié)議,不那么直觀,不過實際上非常簡單,主要分成三個步驟:
認證
建立連接
轉發(fā)數(shù)據(jù)
我們只需 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 協(xié)議規(guī)定,客戶端需要先開口:

RFC 1928,首行是字段名,次行是字節(jié)數(shù)
解釋一下:
| VER | 本次請求的協(xié)議版本號,取固定值 0x05(表示socks?5) |
| NMETHODS | 客戶端支持的認證方式數(shù)量,可取值 1~255 |
| METHODS | 可用的認證方式列表 |
我們用如下代碼來讀取客戶端的發(fā)言:
func Socks5Auth(client net.Conn) (err error) {buf := make([]byte, 256)// 讀取 VER 和 NMETHODSn, 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?-
在完成認證以后,客戶端需要告知服務端它的目標地址,協(xié)議具體要求為:

| VER | 0x05,老暗號了 |
| CMD | 連接方式,0x01=CONNECT, 0x02=BIND, 0x03=UDP ASSOCIATE |
| RSV | 保留字段,現(xiàn)在沒卵用 |
| ATYP | 地址類型,0x01=IPv4,0x03=域名,0x04=IPv6 |
| DST.ADDR | 目標地址,細節(jié)后面講 |
| DST.PORT | 目標端口,2字節(jié),網(wǎng)絡字節(jié)序(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個字節(jié),對應 IPv4 地址
0x02:先來一個字節(jié) n 表示域名長度,然后跟著 n 個字節(jié)。注意這里不是 NUL 結尾的。
0x03:16個字節(jié),對應 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 字節(jié)的無符號整數(shù)。
需要注意的是,協(xié)議里說,這里用了?“network octec order” 網(wǎng)絡字節(jié)序,其實就是 BigEndian (還記得我們在 《UTF-8:一些好像沒什么用的冷知識》里講的小人國的故事嗎?)。別擔心,Golang 已經(jīng)幫我們準備了個 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 都就位了,我們馬上創(chuàng)建一個到 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())}
最后一步是告訴客戶端,我們已經(jīng)準備好了,協(xié)議要求是:

| VER | 暗號,還是暗號! |
| REP | 狀態(tài)碼,0x00=成功,0x01=未知錯誤,…… |
| RSV | 依然是沒卵用的 RESERVED |
| ATYP | 地址類型 |
| BND.ADDR | 服務器和DST創(chuàng)建連接用的地址 |
| BND.PORT | 服務器和DST創(chuàng)建連接用的端口 |
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。
這個函數(shù)加在一起有點長,整整用了 62 行,但其實也就這么回事,對吧?
- Socks5Forward -
萬事俱備,剩下的事情就是轉發(fā)、轉發(fā)、轉發(fā)。
所謂“轉發(fā)”,其實就是從一頭讀,往另一頭寫。
需要注意的是,由于 TCP 連接是雙工通信,我們需要創(chuàng)建兩個 goroutine,用于完成“雙工轉發(fā)”。
由于 golang 有一個 io.Copy 用來做轉發(fā)的事情,代碼只要 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)}
注意:在發(fā)送完以后需要關閉連接。
- 驗證 -
把上面的代碼組裝起來,補上 "package main" 和必要的 import ,總共 145 行,一個能用的 socks5 代理服務器就成型了(完整代碼可參見[2])。
上手跑起來:
?go?run?socks5_proxy.go發(fā)起代理訪問請求:
?curl?--proxy?"socks5://127.0.0.1:1080"?\https://job.toutiao.com/s/JxLbWby
注:↑上面這個鏈接很有用,建議在瀏覽器里打開查看。
代碼是沒啥問題了,不過標題里的?“高性能” 這個 flag 立得起來嗎?
- 壓測?-
說到壓測,自然就想到老牌工具 ab (apache benchmark),不過它只支持 http 代理,這就有點尷尬了。
不過還好,開源的世界里什么都有,在?大型同性交友網(wǎng)站 Github 上,@cnlh 同學寫了個支持 socks5 代理的 benchmark 工具[3],馬上就可以燥起來:
go get github.com/cnlh/benchmark由于代理本身不提供 http 服務,我們可以基于 gin 寫一個高性能的 http server:
package mainimport "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:8080?by?10000?connections...1000000 requests in 10.57s, 115.59MB read, 42.38MB write: 94633.20: 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 !

不過由于并發(fā)量太大,導致 p99 需要 608ms;如果換成 1000 個并發(fā),QPS沒太大變化,p99 可以下降到 63ms。
接下來該我們的 socks5 代理上場了:
$ go run socks_proxy.go$?benchmark?-c?10000?-n?1000000?\://127.0.0.1:1080?\http://127.0.0.1:8080/ping:8080?by?10000?connections...1000000 requests in 11.47s, 115.59MB read, 42.38MB write: 87220.83: 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 并發(fā),QPS?89.2k,p99 則下降到了 66ms —— 說明代理本身對請求性能的影響非常?。ㄗⅲ喝绻?benchmark、http server、代理放在不同的機器上執(zhí)行,應該會看到更小的性能損耗)。
標題里的 “高性能” 這個 flag 算是立住了。

- 小結?-
最后照例簡單總結下:
Go語言非常適合實現(xiàn)網(wǎng)絡服務,代碼短小精悍,性能強大
Socks 5 是一個簡單的二進制網(wǎng)絡代理協(xié)議
網(wǎng)絡字節(jié)序實際上就是 BigEndian,大端存儲
順便一提:實際上字節(jié)跳動早期的很多服務(比如今日頭條的Feed流服務)都是用 Python 實現(xiàn)的,由于性能的原因,我們在 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
https://mp.weixin.qq.com/s/CJL0Ttexvh7XT1zoNLOJrA
推薦閱讀
