2021 第一篇,继续写点有意思的小东西。
上篇《实战:150 行 Go 实现高性能 socks5 代理》发出来后,有同学提出了一些问题,比如说测试机配置太高,结果“不太具有说服力”、“是在耍赖”,再比如说应该和其他开源 socks 代理对比才比较有说服力。
这些质疑我觉得都非常有道理,经过深刻的反思,我做出一个艰难的决定,那就是不予理会,毕竟有这时间,我还不如另写一篇更有营养的,比如在这篇里,我们将看到,如何使用 150 行 Go 实现一个高性能的加密隧道。
不过有一个质疑值得专门一提:@hjc4869 大佬指出,由于 tcp 是双工通信,而 Socks5Forward 在某个方向结束后就把 src 和 dest 都关闭,不符合 tcp 规范,无法支持 half-closed connection 。
这确实是个问题,好在依赖这个特性的场景不多,而且有些网络节点(如部分 NAT 路由器)本身并未完整实现这个特性(遇到 fin 直接或延迟关闭,可避免一些 DoS 攻击),因此该特性在实践中并不够可靠;此外,完整实现这个特性,代码会比较啰嗦,所以为了标题的 flag 暂且妥协,感兴趣的同学可以自己试着完善它(提示:可以抄一下 io.Copy 的源码)。
为了照顾新来的同学,我们可能还应该先介绍一下什么是隧道。
如下图所示,直接访问目标服务时,由于网络上可能存在不安全因素(窃听等),我们会希望采用一个隧道协议,将需要传输的内容封装在协议的负载中,从而保障通信的安全。
一个典型的隧道协议就是 SSL/TLS,通过将 http 封装在 TLS 隧道中,我们就得到了 https,同样我们还可以有 ftps,socks5-over-tls ;应用隧道的其他场景还包括需要在不兼容的网络上传输数据等情况。
上图中的“加密设备”并不一定需要是个独立的硬件,在接下来的内容里,我们会看到如何实现一个软件版本。
饭要一口一口吃,隧道要一点点挖。
所以我们先搞个不加密的、用于传输一个 TCP Stream 的隧道,比如下图所示,将请求先发给中继 A ( IP_A:PORT_A ),A 转发给 B ( IP_B:PORT_B ),再由 B 转发到目标节点( IP:PORT )。
对于中继 A,实现起来就非常简单了,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: %vn", 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
借用了上篇的实现。
而中继 B 的实现就更简单了:由于它和 A 实际上做了相同的工作,只是收发的地址不同,因此将 listenAddr
、remoteAddr
分别改成 "IP_B:PORT_B"、"IP:PORT" 就完工了。
为了方便使用,我们可以通过 flag 包,从命令行参数里读取这俩变量:
listenAddr := flag.String("listenAddr", "127.0.0.1:2000", "")
remoteAddr := flag.String("remoteAddr", "127.0.0.1:2001", "")
flag.Parse()
注:flag.String 返回的是 *string,因此后面引用的地方也需相应修改( dereference )。
隧道挖起来好像比想象中容易,咱们再来看看加密怎么搞。
如下图所示,原来的中继 A 、B 不能只是简单地转发报文了 —— 它们应当在写入隧道前进行加密,从隧道读出时进行解密。
也就是说,对于中继 A,remote 需要加 /解密,而对于中继 B,则是 client 需要加 /解密。
对于熟读 GoF 的同学,应该很容易就能想到,这里可以用一个代理模式( Proxy Pattern )来完成加解密的工作。
由于 net.Conn
本身是一个 interface,我们可以基于这个 interface,把 client/remote 封装起来,实现一个带加密的类型;考虑到 Socks5Forward
里面只用到 Read, Write, Close 这三个方法,我们可以进一步简化成这么一个 interface:
type CipherStream interface {
Read(p []byte) (int, error)
Write(p []byte) (int, error)
Close() error
}
然后我们只需要实现一个 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 CipherStream
if 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 。
是不是简单到想马上写一个 AESCipherStream
?
别急,AES 作为一个块加密( Block Cipher )算法 [1],并不太适合用在这里:它的一个 block 是 16 字节,这意味着即使原始数据只有一个字节(比如 ssh 时的每一次按键),也需要实际传输 16 字节;在具体实现中还会遇到一些琐碎的细节(不信你试试)。
实际上,对于 TCP Stream 这种流式传输的场景,更适合的是流式加密( Stream Cipher )算法 [2]。
比如说小明要给小萌发送整整 1024 字节的信息,他们事先约定了一个 1024 字节的密钥 k,那么小明可以把明文 p[0..1023] 和 k[0..1023] 逐个字节异或得到密文 c[0..1023](加密),小萌收到 c 以后,将 c 和 k 再逐字节异或就能得到明文(解密)。
如果双方每次通信都能够约定一个不短于传输信息的密钥(一次一密),就能解决香农(对,就是信息论创始人 Shannon )提出的“完善保密性” —— 但很遗憾,实际操作中往往做不到。
所以更常见的做法是由一个较短的数据(比如一个 256 bit 的密钥)通过一定的算法生成无限长的密钥流;具体实现中还应当引入一定随机性,否则相同的明文(比如 http 请求通常总是 GET 或 POST 打头)总是生成相同的密文,可能会大幅降低破译密文的难度(频率分析法),并且还可能遭受重放攻击。
我们当然可以基于以上这些朴素的想法立即实现一个简单的加解密算法,不过密码学那么多的坑我们就不用一个一个去踩了,毕竟 Google 已经在 RFC 7539 中为我们提供了 chacha20 加密算法,而且 golang 里就有现成的实现 [3]。
chacha20 的基本用法是:
(a) New 一个 Cipher 对象
cipher, err := NewUnauthenticatedCipher(key, nonce)
(b) 调用 cipher.XORKeyStream 将 src 加 /解密到 dst 里
cipher.XORKeyStream(dst, src []byte)
注:因为使用的 XOR,所以加、解密实际上共用同一段代码逻辑。
铺垫完了,终于可以添加一些细节了。
我们先搞一个 Chacha20Stream 类型:
type Chacha20Stream struct {
key []byte
encoder *chacha20.Cipher
decoder *chacha20.Cipher
conn net.Conn
}
然后写一个 New 方法来创建对象:
func NewChacha20Stream(key []byte, conn net.Conn) (*Chacha20Stream, error) {
s := &Chacha20Stream{
key: key, // should be exactly 32 bytes
conn: conn,
}
var err error
nonce := 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 方法:首次被调用时应当先读出 nonce 、创建 decoder,然后再读取加密数据:
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()
}
最后把上面几段代码组装起来,补充相关 import 等,就是一个可以跑的加密隧道了,完整代码参见这个 gist:tunnel.go [4]。
废话不多说,跑起来瞧瞧。
启动 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
试着发个 GET 请求,输入头两行,看看响应:
$ nc 127.0.0.1 2000
GET /s/JxLbWby HTTP/1.1
Host: job.toutiao.com
HTTP/1.1 301 Moved Permanently
Content-Type: text/html
Content-Length: 178
...(省略其他 header)...
Location: https://job.toutiao.com/s/JxLbWby
<html>
<head><title>301 Moved Permanently</title></head>
<body bgcolor="white">
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>
注:↑ Location 里给出的 url 推荐在浏览器中打开查看。
完美!
代码写完了,那么性能怎么样呢?懒得测了,反正肯定很好。
感兴趣的同学可以自己试试,比如把上篇的 socks5 代理作为 B 的 remoteAddr
,就可以沿用上一篇的压测流程。
诶?好像发现了一种奇怪的用法。不过请注意,切勿滥用上述方案,否则可能会违反《中华人民共和国计算机信息网络国际联网管理暂行规定》第六条、第十四条之规定,后果自负。
又该收尾了,照例做个小结:
那么,在祖国的大地上,有没有既可以不违法、又能够跨越长城走向世界的办法呢?
(中国第一封电子邮件的内容;图:QQ 邮箱)
可别说,还真有 —— 工信部发言人在 2019 年 9 月 20 日表示[5],跨国公司因自己办公的需要,需要用专线的方式开展跨境联网时,可以向经电信主管部门批准,任何合法的使用均受到法律保护。
比如字节跳动,为了建设 21 世纪数字丝绸之路,通过技术出海,在 40 多个国家和地区排在应用商店总榜前列,包括韩国、印尼、马来西亚、俄罗斯、土耳其等“一带一路”沿线的主要国家。
如果你也想合法地访问境外学习资料,不妨投个简历,一起为一带一路做贡献吧。
↓↓↓ 长期招聘 ↓↓↓
投放研发工程师 — 穿山甲 @上海
https://job.toutiao.com/s/JP6gWsy
后端研发工程师 - 穿山甲 @北京
https://job.toutiao.com/s/JP6pK95
字节跳动所有职位
https://job.toutiao.com/s/JP6oV3S
▄▄▄▄▄▄▄ ▄ ▄▄▄▄ ▄▄▄▄▄▄▄
█ ▄▄▄ █ ▄▀ ▄ ▀██▄ ▀█▄ █ ▄▄▄ █
█ ███ █ █ █ █▀▀▀█▀ █ ███ █
█▄▄▄▄▄█ ▄ █▀█ █▀█ ▄▀█ █▄▄▄▄▄█
▄▄▄ ▄▄▄▄█ ▀▄█▀▀▀█ ▄█▄▄ ▄
▄█▄▄▄▄▄▀▄▀▄██ ▀ ▄ █▀▄▄▀▄▄█
█ █▀▄▀▄▄▀▀█▄▀█▄▀█████▀█▀▀█ █▄
▀▀ █▄██▄█▀ █ ▀█▀ ▀█▀ ▄▀▀▄█
█▀ ▀ ▄▄▄▄▄▄▀▄██ █ ▄████▀▀ █▄
▄▀▄▄▄ ▄ ▀▀▄████▀█▀ ▀ █▄▄▄▀▄█
▄▀▀██▄▄ █▀▄▀█▀▀ █▀ ▄▄▄██▀ ▀
▄▄▄▄▄▄▄ █ █▀ ▀▀ ▄██ ▄ █▄▀██
█ ▄▄▄ █ █▄ ▀▄▀ ▀██ █▄▄▄█▄ ▀
█ ███ █ ▄ ███▀▀▀█▄ █▀▄ ██▄ ▀█
█▄▄▄▄▄█ ██ ▄█▀█ █ ▀██▄▄▄ █▄
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.