Gost 是什么
Gost 是 Golang 语言编写的网络隧道工具。得益于纯 Go 开发以及 Go 在网络开发以及交叉编译方面的强势,Gost 能够在几乎任何地方近乎零依赖地运行,因此 Gost 最早作为一个方便好用的 socks5 server 启动器在内网渗透等领域开始出名。
Gost 即将正式推出他的 v3 版本,这个版本改变之大,几乎相当于是一个独立的应用。不得不承认,Gost 的作者对于网络和隧道是有一些他的想法的。给大家解读一下在我看来的 Gostv3 设计的核心思想。
Gostv3 核心思想
在介绍 Gost 的配置之前,我们需要先理解一个概念 “传输层与应用层分离”
假设一个socks5隧道,如果我们暂时只考虑tcp代理的情况,那么这个事情其实很简单。先在客户端和服务器之间建立一个tcp连接,随后就按照socks5协议所描述的,协商版本号和认证方式,进行认证,发送代理地址,相应结果,建立隧道,这事情就好了。
隧道应用只是一个应用层的例子,我们可以把上述所做的所有事情分成两部分
- 建立可靠连接 (传输层)
- 处理应用层消息 (应用层)
对于大多数基于应用层网络协议,我们可以把他们所做的都抽象为这两件事情。
在常见的网络协议里,传输层与应用层往往是绑定的,但这是没有必要的,我们完全可以为一个应用层协议配置另一个传输层协议,唯一的要求(如果原来是tcp)就是建立可靠流式传输。
这种做法并非是没有先例的,互联网的发展过程中 web 服务逐渐使用 https 替代 http,这就是将 http 协议的应用层部分抽象出来并将其传输层协议替换为 tls 带来的一个结果。
大可放开想象,除了替换成 tls,为什么不能替换成更多的能够实现可靠传输的传输层协议呢?比如说
- kcp
- websocket
- ssh
- quic
这些协议可能乍一看怎么可能是一个传输层协议,但是他们确实能够提供可靠传输,相对于我们的应用层而言,他就是传输层。
一个网络协议,他并非属于一个固定的层,这其实也是学术界和工业界的区别,学术界可以理想地对网络协议进行划层,但是工业界只在乎能不能用好不好用。就像很多路由协议,你可能很难给他没有争议地划分到一个层去。
这就是我认为的 Gostv3 最核心的设计,他拆分了隧道协议的网络层和应用层,使他们能够自由组合
Gostv3有四种最基本的概念(listener,handler,dialer,connector)
block-beta columns 5 arr1<[" "]>(right) listener1["listener"] space connector2["connector"] arr2<[" "]>(right) space arr3<[" "]>(down) space arr4<[" "]>(up) space space handler1["handler"] space dialer2["dialer"] space space arr5<[" "]>(down) space arr6<[" "]>(up) space space dialer1["dialer"] space handler2["handler"] space space arr7<[" "]>(down) space arr8<[" "]>(up) space space connector1["connector"] arr9<["net"]>(right) listener2["listener"] space space client("client") space server("server") space
其中listner和handler分别是Gost监听服务使用的传输层和应用层,而dialer和connector分别是Gost转发时所使用的传输层和应用层
隧道实现
隧道建立
sequenceDiagram Client->>Server: 1. tcp handshake Client->>Server: 2. nonce Server->>Client: 2. nonce Client->>Server: 3. tunnel message Server->>Client: 3. tunnel message
建立隧道一共经历了三个过程
- 建立 TCP 连接
- 交换 GCM nonce
- 建立隧道
隧道协议
block-beta columns 8 0 1 2 3 4 5 6 7 len:2 data:6 data2["data..."]:8
这里做了一个最精简的隧道协议实现,仅仅使用 len 来切割数据,data 中直接传输加密的一个数据包。之所以选择 sm4 GCM 模式实现加密主要是考虑到流密码与其签名校验。
至于为什么要使用len切割,这是由于 gost 本身实现的 tun handler 把底层的 Conn 作为 udp Conn 来使用。按理说传输层也得使用一个包一个包发送的而不应该使用流式的。这里故意使用tcp主要考虑到自己的一个小小需求:避免跨省qos。
SMConn 实现
在 Golang 中,一般采用 net.Conn 接口描述一个连接。因此我们先实现一个 SMConn。
构造函数
func New(base net.Conn, key []byte) (*SMConn, error) {
sm4cipher, err := sm4.NewCipher(key)
if err != nil { return nil, fmt.Errorf("create sm4 cipher failed: %v", err) }
nonce := make([]byte, 12)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, fmt.Errorf("create nonce failed: %v", err) }
sendcrypt, err := cipher.NewGCM(sm4cipher)
if err != nil { return nil, fmt.Errorf("create sm4 gcm failed: %v", err) }
n, err := base.Write(nonce)
if err != nil || n != 12 { return nil, fmt.Errorf("send nonce failed: %v", err) }
peerNonce := make([]byte, 12)
n, err = io.ReadFull(base, peerNonce)
if err != nil || n != 12 { return nil, fmt.Errorf("reveive peer nonce failed: %v", err) }
reccrypt, err := cipher.NewGCM(sm4cipher)
if err != nil { return nil, fmt.Errorf("create sm4 gcm for receive failed: %v", err) }
return &SMConn{
buf: &bytes.Buffer{},
Conn: base,
scrypt: sendcrypt,
nonce: nonce,
rcrypt: reccrypt,
peerNonce: peerNonce,
}, nil
}
我其实不懂密码学,对于加密,我只知道他提供了什么接口,他需要什么东西,主打一个拿来就用。
上面的代码里实现了隧道的握手过程,先从密钥构造 sm4 加密套件,再从随机数生成 nonce,并通过刚刚建立的 tcp 连接交换 nonce。最后生成两个方向的 GCM 模式加解密套件。
write 方法
func (s *SMConn) Write(b []byte) (n int, err error) {
defer func() {
if err != nil { s.Conn.Close() }
}()
if len(b) == 0 { return 0, nil }
var buf []byte
ciphertext := s.scrypt.Seal(nil, s.nonce, b, nil)
buf = binary.BigEndian.AppendUint16(buf, uint16(len(ciphertext)))
n, err = s.Conn.Write(append(buf, ciphertext...))
if err != nil { return 0, fmt.Errorf("write conn failed: %v", err) }
if n != len(ciphertext)+2 { return 0, fmt.Errorf("write bytes not enough") }
return len(b), nil
}
这里先把len和密文分别写入到缓冲区,再写入到 tcp 连接。对于写入过程中遇到的任何错误,直接关闭连接处理。对端也会因为tcp 连接断开而断开连接。
read 方法
func (s *SMConn) Read(b []byte) (n int, err error) {
defer func() {
if err != nil {
s.Conn.Close()
}
}()
if len(b) == 0 { return 0, nil }
for s.buf.Len() < len(b) {
var n uint16
if err := binary.Read(s.Conn, binary.BigEndian, &n); err != nil { return 0, err }
ciphertext := make([]byte, n)
nn, err := io.ReadFull(s.Conn, ciphertext)
if err != nil { return 0, err }
if nn != int(n) { return 0, fmt.Errorf("read bytes not enough") }
plaintext, err := s.rcrypt.Open(nil, s.peerNonce, ciphertext, nil)
if err != nil { return 0, fmt.Errorf("decrypt failed: %v", err)}
s.buf.Write(plaintext)
}
return io.ReadFull(s.buf, b)
}
read 方法做了一个缓冲,解密后的明文有时并不足以填充传入的 byte 切片,剩下的内容被暂存在缓冲区。
读取内容的时候优先读取缓冲区,如果缓冲区长度不足,先读取并写入新的数据到缓冲区,直到缓冲区的长度满足要求。再从缓冲区拷贝到传入的切片。
这里的反复拷贝可能存在一些性能问题,但是目前没有那么高的性能需求,暂时不需要解决。
Gost 对接
func (d *tcpDialer) parseMetadata(md md.Metadata) (err error) {
d.md.key, err = base64.StdEncoding.DecodeString(mdutil.GetString(md, "key"))
return
}
sm4 加密的密钥以 base64 格式存储在metedata,在解析 gost metadata 的时候,解析 key 并赋值给当前的 dialer
func (d *tcpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) {
var options dialer.DialOptions
for _, opt := range opts {
opt(&options)
}
conn, err := options.NetDialer.Dial(ctx, "tcp", addr)
if err != nil {
d.logger.Error(err)
return conn, err
}
return smconn.New(conn, d.md.key)
}
在这个 sm4 加密连接的建立阶段,我们先建立了一个 tcp 连接,再使用 smconn.New 把他升级为我们的 smconn。
性能测试
内网千兆测试千兆跑满,预期内效率