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

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

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

對(duì)于中繼A,實(shí)現(xiàn)起來(lái)就非常簡(jiǎ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 借用了上篇的實(shí)現(xiàn)。
而中繼B的實(shí)現(xiàn)就更簡(jiǎn)單了:由于它和A實(shí)際上做了相同的工作,只是收發(fā)的地址不同,因此將 listenAddr、remoteAddr 分別改成 "IP_B:PORT_B"、"IP:PORT" 就完工了。
為了方便使用,我們可以通過(guò) 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. 加密
隧道挖起來(lái)好像比想象中容易,咱們?cè)賮?lái)看看加密怎么搞。
如下圖所示,原來(lái)的中繼A、B不能只是簡(jiǎn)單地轉(zhuǎn)發(fā)報(bào)文了 —— 它們應(yīng)當(dāng)在寫入隧道前進(jìn)行加密,從隧道讀出時(shí)進(jìn)行解密。

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

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

我們先搞一個(gè) Chacha20Stream 類型:
type?Chacha20Stream?struct?{key []byteencoder *chacha20.Cipherdecoder *chacha20.Cipherconn net.Conn}
然后寫一個(gè) New 方法來(lái)創(chuàng)建對(duì)象:
隨機(jī)生成 nonce
創(chuàng)建 encoder
將 nonce 發(fā)送給對(duì)方,用于創(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)用時(shí)應(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 方法就簡(jiǎn)單了:
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()}
最后把上面幾段代碼組裝起來(lái),補(bǔ)充相關(guān) import 等,就是一個(gè)可以跑的加密隧道了,完整代碼參見這個(gè) gist:tunnel.go[4]。
6. 燥起來(lái)
廢話不多說(shuō),跑起來(lái)瞧瞧。
啟動(dòng)A:
$ go run tunnel.go -role A -secret xxx[127.0.0.1:2000]?->?[127.0.0.1:2001],?role?=?A,?secret?=?xxx
啟動(dòng)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ā)個(gè) GET 請(qǐng)求,輸入頭兩行,看看響應(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 推薦在瀏覽器中打開查看。
(??????)?? 完美!
代碼寫完了,那么性能怎么樣呢?懶得測(cè)了,反正肯定很好。

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

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

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