實戰(zhàn):150行Go實現(xiàn)高性能加密隧道

1. 質(zhì)疑
上篇《實戰(zhàn):150行Go實現(xiàn)高性能socks5代理》發(fā)出來后,有同學(xué)提出了一些問題,比如說測試機(jī)配置太高,結(jié)果“不太具有說服力”、“是在耍賴”,再比如說應(yīng)該和其他開源 socks 代理對比才比較有說服力。
這些質(zhì)疑我覺得都非常有道理,經(jīng)過深刻的反思,我做出一個艱難的決定,那就是不予理會,畢竟有這時間,我還不如另寫一篇更有營養(yǎng)的,比如在這篇里,我們將看到,如何使用 150?行 Go 實現(xiàn)一個高性能的加密隧道。
不過有一個質(zhì)疑值得專門一提:@hjc4869 大佬指出,由于 tcp 是雙工通信,而 Socks5Forward 在某個方向結(jié)束后就把 src 和 dest 都關(guān)閉,不符合 tcp 規(guī)范,無法支持 half-closed connection。
這確實是個問題,好在依賴這個特性的場景不多,而且有些網(wǎng)絡(luò)節(jié)點(如部分 NAT 路由器)本身并未完整實現(xiàn)這個特性(遇到fin直接或延遲關(guān)閉,可避免一些DoS攻擊),因此該特性在實踐中并不夠可靠;此外,完整實現(xiàn)這個特性,代碼會比較啰嗦,所以為了標(biāo)題的 flag 暫且妥協(xié),感興趣的同學(xué)可以自己試著完善它(提示:可以抄一下 io.Copy 的源碼)。
2.?隧道
為了照顧新來的同學(xué),我們可能還應(yīng)該先介紹一下什么是隧道。
如下圖所示,直接訪問目標(biāo)服務(wù)時,由于網(wǎng)絡(luò)上可能存在不安全因素(竊聽等),我們會希望采用一個隧道協(xié)議,將需要傳輸?shù)膬?nèi)容封裝在協(xié)議的負(fù)載中,從而保障通信的安全。

一個典型的隧道協(xié)議就是 SSL/TLS,通過將 http 封裝在 TLS 隧道中,我們就得到了 https,同樣我們還可以有 ftps,socks5-over-tls;應(yīng)用隧道的其他場景還包括需要在不兼容的網(wǎng)絡(luò)上傳輸數(shù)據(jù)等情況。
上圖中的“加密設(shè)備”并不一定需要是個獨立的硬件,在接下來的內(nèi)容里,我們會看到如何實現(xiàn)一個軟件版本。
3. 開挖
飯要一口一口吃,隧道要一點點挖。
所以我們先搞個不加密的、用于傳輸一個 TCP Stream 的隧道,比如下圖所示,將請求先發(fā)給中繼 A(IP_A:PORT_A),A 轉(zhuǎn)發(fā)給 B (IP_B:PORT_B),再由 B 轉(zhuǎn)發(fā)到目標(biāo)節(jié)點(IP:PORT)。

對于中繼A,實現(xiàn)起來就非常簡單了,27行搞定:
func main() {listenAddr := "IP_A:PORT_A"remoteAddr := "IP_B:PORT_B"server, err := net.Listen("tcp", 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", err)continue}go Relay(client, remoteAddr)}}func Relay(client net.Conn, remoteAddr string) {remote, err := net.Dial("tcp", remoteAddr)if err != nil {client.Close()return}Socks5Forward(client, remote)}
注:這里的 Socks5Forward 借用了上篇的實現(xiàn)。
而中繼B的實現(xiàn)就更簡單了:由于它和A實際上做了相同的工作,只是收發(fā)的地址不同,因此將 listenAddr、remoteAddr 分別改成 "IP_B:PORT_B"、"IP:PORT" 就完工了。
為了方便使用,我們可以通過 flag 包,從命令行參數(shù)里讀取這倆變量:
listenAddr := flag.String("listenAddr", "127.0.0.1:2000", "")remoteAddr := flag.String("remoteAddr", "127.0.0.1:2001", "")flag.Parse()
注:flag.String 返回的是 *string,因此后面引用的地方也需相應(yīng)修改(dereference)。
3. 加密
隧道挖起來好像比想象中容易,咱們再來看看加密怎么搞。
如下圖所示,原來的中繼A、B不能只是簡單地轉(zhuǎn)發(fā)報文了 —— 它們應(yīng)當(dāng)在寫入隧道前進(jìn)行加密,從隧道讀出時進(jìn)行解密。

也就是說,對于中繼 A,remote 需要加/解密,而對于中繼 B,則是 client 需要加/解密。
對于熟讀 GoF 的同學(xué),應(yīng)該很容易就能想到,這里可以用一個代理模式(Proxy Pattern)來完成加解密的工作。
由于 net.Conn 本身是一個 interface,我們可以基于這個 interface,把 client/remote 封裝起來,實現(xiàn)一個帶加密的類型;考慮到 Socks5Forward 里面只用到 Read, Write, Close 這三個方法,我們可以進(jìn)一步簡化成這么一個 interface:
type CipherStream interface {Read(p []byte) (int, error)Write(p []byte) (int, error)Close() error}
然后我們只需要實現(xiàn)一個 XXXCipherStream,分別在 Write 里做加密、 Read 里做解密就好了。
看看新版的 Relay 方法可能更容易理解:
func Relay(client net.Conn, remoteAddr string, role string) {remote, err := net.Dial("tcp", remoteAddr)if err != nil {client.Close()return}var src, dst CipherStreamif role == "A" {src = client????dst,?err?=?NewXXXStream(remote)} else {src, err = NewXXXStream(client)dst = remote}if err != nil {src.Close()dst.Close()return}Socks5Forward(src, dst)}
注:role 可在啟動時通過命令行指定,取值為A或B。
4. 加密2
是不是簡單到想馬上寫一個 AESCipherStream ?
別急,AES 作為一個塊加密(Block Cipher)算法[1],并不太適合用在這里:它的一個 block 是 16 字節(jié),這意味著即使原始數(shù)據(jù)只有一個字節(jié)(比如 ssh 時的每一次按鍵),也需要實際傳輸 16 字節(jié);在具體實現(xiàn)中還會遇到一些瑣碎的細(xì)節(jié)(不信你試試)。
實際上,對于 TCP Stream 這種流式傳輸?shù)膱鼍?,更適合的是流式加密(Stream Cipher)算法[2]。
比如說小明要給小萌發(fā)送整整 1024 字節(jié)的信息,他們事先約定了一個 1024 字節(jié)的密鑰 k ,那么小明可以把明文 p[0..1023]?和 k[0..1023] 逐個字節(jié)異或得到密文 c[0..1023](加密),小萌收到 c 以后,將 c 和 k 再逐字節(jié)異或就能得到?萌?明文(解密)。
如果雙方每次通信都能夠約定一個不短于傳輸信息的密鑰(一次一密),就能解決香農(nóng)(對,就是信息論創(chuàng)始人Shannon)提出的“完善保密性” ——?但很遺憾,實際操作中往往做不到。
所以更常見的做法是由一個較短的數(shù)據(jù)(比如一個 256 bit 的密鑰)通過一定的算法生成無限長的密鑰流;具體實現(xiàn)中還應(yīng)當(dāng)引入一定隨機(jī)性,否則相同的明文(比如http請求通??偸?GET 或 POST打頭)總是生成相同的密文,可能會大幅降低破譯密文的難度(頻率分析法),并且還可能遭受重放攻擊。
我們當(dāng)然可以基于以上這些樸素的想法立即實現(xiàn)一個簡單的加解密算法,不過密碼學(xué)那么多的坑我們就不用一個一個去踩了,畢竟 Google 已經(jīng)在 RFC 7539 中為我們提供了 chacha20 加密算法,而且 golang 里就有現(xiàn)成的實現(xiàn)[3]。

chacha20?的基本用法是:
(a) New 一個 Cipher 對象
key 是雙方共享的一個 32 字節(jié)密鑰
nonce 是隨機(jī)生成的 24 個字節(jié),應(yīng)當(dāng)由加密方(encoder)生成,并通過?明文?發(fā)送到接收方,用于創(chuàng)建 decoder
cipher, err := NewUnauthenticatedCipher(key, nonce)(b)?調(diào)用 cipher.XORKeyStream 將 src 加/解密到 dst?里
cipher.XORKeyStream(dst,?src?[]byte)注:因為使用的 XOR,所以加、解密實際上共用同一段代碼邏輯。
5. 加密3
鋪墊完了,終于可以添加一些細(xì)節(jié)了。

我們先搞一個 Chacha20Stream 類型:
type?Chacha20Stream?struct?{key []byteencoder *chacha20.Cipherdecoder *chacha20.Cipherconn net.Conn}
然后寫一個 New 方法來創(chuàng)建對象:
隨機(jī)生成 nonce
創(chuàng)建 encoder
將 nonce 發(fā)送給對方,用于創(chuàng)建 decoder
func NewChacha20Stream(key []byte, conn net.Conn) (*Chacha20Stream, error) {??s?:=?&Chacha20Stream{????key:????key,?//?should?be?exactly?32?bytesconn: conn,}var err errornonce := make([]byte, chacha20.NonceSizeX)if _, err := rand.Read(nonce); err != nil {return nil, err}s.encoder, err = chacha20.NewUnauthenticatedCipher(s.key, nonce)if err != nil {return nil, err}if n, err := s.conn.Write(nonce); err != nil || n != len(nonce) {return nil, errors.New("write nonce failed: " + err.Error())}return s, nil}
接著是 Read 方法:首次被調(diào)用時應(yīng)當(dāng)先讀出 nonce、創(chuàng)建 decoder,然后再讀取加密數(shù)據(jù):
func (s *Chacha20Stream) Read(p []byte) (int, error) {if s.decoder == nil {nonce := make([]byte, chacha20.NonceSizeX)if n, err := io.ReadAtLeast(s.conn, nonce, len(nonce)); err != nil || n != len(nonce) {return n, errors.New("can't read nonce from stream: " + err.Error())}decoder, err := chacha20.NewUnauthenticatedCipher(s.key, nonce)if err != nil {return 0, errors.New("generate decoder failed: " + err.Error())}s.decoder = decoder}n, err := s.conn.Read(p)if err != nil || n == 0 {return n, err}dst := make([]byte, n)pn := p[:n]s.decoder.XORKeyStream(dst, pn)copy(pn, dst)return n, nil}
剩下的 Write 和 Close 方法就簡單了:
func (s *Chacha20Stream) Write(p []byte) (int, error) {dst := make([]byte, len(p))s.encoder.XORKeyStream(dst, p)return s.conn.Write(dst)}func (s *Chacha20Stream) Close() error {return s.conn.Close()}
最后把上面幾段代碼組裝起來,補(bǔ)充相關(guān) import 等,就是一個可以跑的加密隧道了,完整代碼參見這個 gist:tunnel.go[4]。
6. 燥起來
廢話不多說,跑起來瞧瞧。
啟動A:
$ go run tunnel.go -role A -secret xxx[127.0.0.1:2000]?->?[127.0.0.1:2001],?role?=?A,?secret?=?xxx
啟動B:
$ go?run?tunnel.go?-role?B?-secret?xxx?\??-listenAddr?127.0.0.1:2001?\??-remoteAddr?job.toutiao.com:80[127.0.0.1:2001] -> [job.toutiao.com:80], role = B, secret = xxx
試著發(fā)個 GET 請求,輸入頭兩行,看看響應(yīng):
$?nc?127.0.0.1 2000GET /s/JxLbWby HTTP/1.1Host: job.toutiao.comHTTP/1.1?301?Moved?PermanentlyContent-Type: text/htmlContent-Length:?178...(省略其他header)...Location:?https://job.toutiao.com/s/JxLbWby301 Moved Permanently 301 Moved Permanently
nginx
注:↑ Location 里給出的 url 推薦在瀏覽器中打開查看。
(??????)?? 完美!
代碼寫完了,那么性能怎么樣呢?懶得測了,反正肯定很好。

感興趣的同學(xué)可以自己試試,比如把上篇的 socks5 代理作為 B 的 remoteAddr,就可以沿用上一篇的壓測流程。
誒?好像發(fā)現(xiàn)了一種奇怪的用法。不過請注意,切勿濫用上述方案,否則可能會違反《中華人民共和國計算機(jī)信息網(wǎng)絡(luò)國際聯(lián)網(wǎng)管理暫行規(guī)定》第六條、第十四條之規(guī)定,后果自負(fù)。

7. 小結(jié)
又該收尾了,照例做個小結(jié):
隧道可以用于解決通信安全、協(xié)議兼容等場景;
塊加密算法(如AES)更適合文件加密等場景;
流式加密算法(如chacha20)更合適流式傳輸場景;
加密隧道和socks5代理組合起來有可能違法,請勿濫用。
那么,在祖國的大地上,有沒有既可以不違法、又能夠跨越長城走向世界的辦法呢?

(中國第一封電子郵件的內(nèi)容;圖:QQ郵箱)
可別說,還真有 —— 工信部發(fā)言人在2019年9月20日表示[5],跨國公司因自己辦公的需要,需要用專線的方式開展跨境聯(lián)網(wǎng)時,可以向經(jīng)電信主管部門批準(zhǔn),任何合法的使用均受到法律保護(hù)。
比如字節(jié)跳動,為了建設(shè)21世紀(jì)數(shù)字絲綢之路,通過技術(shù)出海,在40多個國家和地區(qū)排在應(yīng)用商店總榜前列,包括韓國、印尼、馬來西亞、俄羅斯、土耳其等“一帶一路”沿線的主要國家。
參考資料:
1.?wikipedia - 分組密碼(塊加密)
https://zh.wikipedia.org/wiki/分組密碼
2. wikipedia -?流密碼
https://zh.wikipedia.org/wiki/流密碼
3. chacha20
https://godoc.org/golang.org/x/crypto/chacha20
4. tunnel.go
https://gist.github.com/felix021/c1c613abf31a42322b28e1b7bb1407f0
5. 工信部:VPN規(guī)定不會影響國內(nèi)外企業(yè)合規(guī)開展跨境業(yè)務(wù)
https://www.sohu.com/a/342217933_115479
推薦閱讀
