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

建立隧道一共经历了三个过程

  1. 建立 TCP 连接
  2. 交换 GCM nonce
  3. 建立隧道

隧道协议

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。

性能测试

内网千兆测试千兆跑满,预期内效率