實戰:-65 行 Go 實現低延遲隧道

在違法的邊緣繼續整點活。

1. 槽點

儘管上篇我已經反覆強調,但是一位不願意透露姓名的王先生還是不顧勸阻,執意以身試法。

不僅如此,王先生試探以後還吐槽說,雖然上上篇搭上上篇能上了,但是延遲和吞吐不給力啊,有沒有辦法可以再 去肥增瘦 優化一下?

我躲在王先生寬闊的背影裏思索了一下,覺得確實還有很大的優化空間。

2. 總覽

騷話不多說,我們先來觀察一下王先生的不法行爲:

王先生的請求先後通過中繼 A、中繼 B、socks5 代理,才能到達法外之地,整個鏈路總共需要建立 4 次 TCP 連接。

乍一看有點多,不過好在王先生和中繼 A 通常在同一個網絡(甚至中繼 A 可能就跑在王先生的機器上),他倆之間的延遲基本可以忽略,因此我把王先生和中繼 A 用虛線框起來了。

同樣用虛線框起來的中繼 B 和 socks5 代理也一樣,它們往往也部署在同一臺機器上,因此也可以忽略這裏建立 TCP 連接的耗時。

所以真正影響整個鏈路的耗時是 “A ↔ B”、“socks5 代理 ↔ 法外之地” 的延遲。假設這倆的 RTT(Round Trip Time)分別是 x、y 毫秒,那麼建好整個鏈路總共需要多久呢?這裏先假設網絡通暢、沒有丟包。

敲黑板,這不是一道送分題,那些張口就想回答 x + y 的同學,

爲什麼不對呢?

問題在於 socks5 協議,它有一個鑑權協商環節,至少需要一個 RTT (如果使用了鑑權則不止),所以整體上需要 2 * x + y 才能建立起整個鏈路,然後王先生纔可以開始它的試探。

注:可能某些同學會有疑問,TCP 創建連接不是三次握手麼,爲什麼不是 3x+1.5y ?這是因爲◼️◼️◼️ ◼️◼️◼️ ◼️◼️◼️ ◼️◼️◼️

既然我們已經分析出了延遲的來源,優化方法就呼之欲出了。

3. 優化

我們首先可以對這個 2x 開刀。

由於我們並不需要鑑權(上上篇的實現也是直接選擇了不鑑權),因此如果換一個更簡單的協議,不做鑑權協商,就能減少一個 RTT。

但是這樣一來我們提供的是一個新的協議,各軟件並不支持,項目的兼容性成了問題。因此我們還是期望能提供完整的 socks5 能力,那麼我們怎麼才能做到既要又要呢?

C++ 之父 Bjarne Stroustrup 的導師 David Wheeler 有一句名言 [1]:

All problems in computer science can be solved by another level of indirection.

en.wikipedia.org/wiki/Indirection

翻譯過來就是:計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決

這一點我們在上篇已經感受到了,通過 Chacha20Stream 這個中間層對 net.Conn 進行封裝,我們的代碼沒有做太多改動就完成了加密工作。

類似地,如果我們在中繼 A 前面加一層,它對 APP 提供完整的 socks5 API,但是鑑權協商步驟在本地完成,並不需要發給中繼 A,由於它和應用層在同一個內網,這裏鑑權的耗時可以忽略不計,因此我們可以省掉一個 “中繼 A ↔ 中繼 B” RTT。

這個方案也可以看成將 socks5 拆成了兩部分,如下圖所示,socks5 frontend 裏實現了鑑權邏輯,socks5 backend 則負責連接目標服務:

具體代碼這裏就不貼了 因爲沒寫,感興趣的同學可以自己試着實現一下。

另外順便一提,著名項目 shadwscks 就是這麼幹的,甚至更激進,建議各位去圍觀源碼(tcprelay.py)。

4. 優化 ²

經過一番騷操作,我們將建立鏈路的延遲壓到了 x + y,那麼如果我們想要進一步壓縮,還可以對哪一個部分開刀呢?

—— 當然是繼續搞 x 了,因爲 y 是無論如何省不掉的。

基於我們上篇實現的加密隧道,中繼 A 和中繼 B 之間想要建立一個 TCP 連接,就需要三次握手,這我們還能怎麼優化呢?

一個可行的方案是,不要建立 TCP 連接。可別忘了,在傳輸層除了 TCP,我們還可以用 UDP —— 我們可以基於它實現一個 reliable 的協議,並且在第一個報文裏就允許帶上請求數據,這樣就可以把 x 完全省掉了。(注:這裏省掉的只是建立連接的 RTT ,請求數據的傳輸時間是無法省掉的。)

不過直接用 UDP 寫一個協議有點超綱了 因爲我沒寫過,好在有很多現成的實現,包括 UDT、KCP、QUIC 等等。我比較喜歡 KCP 協議,所以下面我們會看到,如何基於現成的 KCP 協議庫來完成我們的低延遲隧道。

5. KCP

KCP 作者在項目 [2] 簡介裏是這麼說的:

KCP 是一個快速可靠協議,能以比 TCP 浪費 10%-20% 的帶寬的代價,換取平均延遲降低 30%-40%,且最大延遲降低三倍的傳輸效果。

github.com/skywind3000/kcp

KCP 不僅可以幫我們減少建立連接的 RTT ,而且還通過在 RTO(Retransmission TimeOut)、選擇性重傳、快速重傳、ACK、非退讓流控等一系列策略上的優化,顯著優化了整個傳輸過程的延遲,尤其在高丟包率的網絡狀況下效果非常顯著。

更多細節參見項目主頁,這裏就不做文字搬運了,我們來關注下具體怎麼用它。

該項目是一個 C 庫,不過這裏我們打算用 Go ,所以接下來我們會看到如何使用 xtaci 大佬的 kcp-go [3] 來實現王先生的非分之想。

6. 中繼 A

由於 kcp 是個 udp 協議,所以中繼 A、B 的實現就有點不一樣了。

不過 A 和上一版差不了太多,主要差別是,我們需要將收到的數據通過 kcp 協議發出去:

func serverA() {
  server, err := net.Listen("tcp", GlobalConfig.ListenAddr)
  if err != nil {
    fmt.Printf("Listen failed: %v\n", err)
    return
  }
  for {
    client, err := server.Accept()
    if err != nil {
      fmt.Printf("Accept failed: %v\n", err)
      continue
    }
    go RelayTCPToKCP(client)
  }
}

RelayTCPToKCP 的實現倒是簡單:

func RelayTCPToKCP(client net.Conn) {
  block, _ := kcp.NewNoneBlockCrypt(nil)
  sess, err := kcp.DialWithOptions(GlobalConfig.RemoteAddr, block, 10, 3)
  if err != nil {
    client.Close()
    return
  }
  remote, err := NewChacha20Stream(GlobalConfig.Key, sess)
  if err != nil {
    client.Close()
    return
  }
  Socks5Forward(client, remote)
}

7. 中繼 B

中繼 B 雖然不能直接複用 A 的代碼了,但是和 A 仍然很像:

func serverB() {
  block, _ := kcp.NewNoneBlockCrypt(nil)
  listener, err := kcp.ListenWithOptions(GlobalConfig.ListenAddr, block, 10, 3)
  if err != nil {
    log.Fatal(err)
  }
  for {
    client, err := listener.AcceptKCP()
    if err != nil {
      fmt.Println("err accept kcp client:", err)
      continue
    }
    go RelayKCPToTCP(client)
  }
}

可以看出架子其實一模一樣,如果我們搞一個 Server Interface(New、Accept、Handle),也完全能將 A、B 統一起來,不過也就這麼幾行代碼,還是不折騰了。

剩下的 RelayKCPToTCP 也沒什麼懸念了:

func RelayKCPToTCP(client net.Conn) {
  src, err := NewChacha20Stream(GlobalConfig.Key, client)
  if err != nil {
    client.Close()
    return
  }
  remote, err := net.Dial("tcp", GlobalConfig.RemoteAddr)
  if err != nil {
    client.Close()
    return
  }
  Socks5Forward(src, remote)
}

8. 其他

標題裏說的 +65 行就是指上面四段代碼了,不過想跑起來還得補充一些細節,完整版本參見 gist: tunnel_kcp.go[4],總共不到 200 行代碼。

想要追求極致的同學,還可以考慮把 socks5.go 和這個 tunnel_kcp.go 合併成一個 socks5_over_kcp.go,即 socks5 frontend 在完成鑑權協商以後,通過 kcp+chacha20 將報文轉發給  socks5 backend,這樣在部署代碼的時候就只需要一個 binary 了。

細心的同學可能有注意到,我們在創建 KCP 的 server/client 的時候先創建了一個 NoneBlock,實際上 kcp-go 裏面本就提供 AESBlock、Salsa20Block 等多種支持加密的 block ,也就是說我們其實連 Chacha20Stream 都可以省掉(chacha20 其實就是 salsa20 加密的一個變種),詳情可參見該庫的文檔。

另外,xtaci 大佬其實早就實現了一個基於 kcp 的隧道 kcptun [5],強烈推薦前往圍觀。kcptun 項目中還用到了 xtaci 大佬的另一個很有意思的項目 smux [6],基於流的多路複用(類比於 http2 的多路複用),可以在一個 kcp (或 tcp)連接裏創建多個獨立的子流。

9. 小結

最後照例做個小結:

經過上述一系列騷操作,我們既提供了完整的 socks5 代理,又減少了建立連接的耗時,還大幅降低了傳輸的延遲 —— 原來既要又要還要也不是那麼難嘛。

牛逼吹完還得冷靜一下,這幾篇寫的小工具雖然很有趣,但技術含量和我廠大佬們比起來,只能說是小巫見大巫。

參考資料:

1. Wikipedia - Indirection

en.wikipedia.org/wiki/Indirection

  1. skywind3000/kcp

github.com/skywind3000/kcp

  1. xtaci/kcp-go

github.com/xtaci/kcp-go

4. gist - tunnel_kcp.go

https://gist.github.com/felix021/95f39be9d9bc27ddaa5d7361cb60d94a

5. xtaci/kcptun

github.com/xtaci/kcptun

  1. xtaci/smux

github.com/xtaci/smux

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/4r1I9-wCLcs2uf8uxXtVvQ