實戰:150 行 Go 實現高性能 socks5 代理
光說不練假把式,不如上手試試,這篇來寫個有點卵用的東西。
**- 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,首行是字段名,次行是字節數
解釋一下:
我們用如下代碼來讀取客戶端的發言:
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 -
**
在完成認證以後,客戶端需要告知服務端它的目標地址,協議具體要求爲:
咱們先讀取前四個字段:
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())
}
最後一步是告訴客戶端,我們已經準備好了,協議要求是:
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 代理服務器就成型了(完整代碼可參見 [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
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 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 算是立住了。
- 小結 -
最後照例簡單總結下:
-
Go 語言非常適合實現網絡服務,代碼短小精悍,性能強大
-
Socks 5 是一個簡單的二進制網絡代理協議
-
網絡字節序實際上就是 BigEndian,大端存儲
參考鏈接:
- RFC1928 - SOCKS Protocol Version 5
https://tools.ietf.org/html/rfc1928
- Minimal socks5 proxy in Golang
https://gist.github.com/felix021/7f9d05fa1fd9f8f62cbce9edbdb19253
- Benchmark by @cnlh
https://github.com/cnlh/benchmark
https://mp.weixin.qq.com/s/CJL0Ttexvh7XT1zoNLOJrA
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Gr7b5Guj4wL15YmIZmvKeQ