标签 SSL 下的文章

Go TLS服务端绑定证书的几种方式

本文永久链接 – https://tonybai.com/2023/10/13/multiple-ways-to-bind-certificates-on-go-tls-server-side

随着互联网的发展,网站提供的服务类型和规模不断扩大,同时也对Web服务的安全性提出了更高的要求。TLS(Transport Layer Security)已然成为Web服务最重要的安全基础设施之一。默认情况下,一个TLS服务器通常只绑定一个证书,但当服务复杂度增加时,单一证书已然难以满足需求。这时,服务端绑定多个TLS证书就成为一个非常实用的功能。

Go语言中的net/http包和tls包对TLS提供了强大的支持,在密码学和安全专家Filippo Valsorda的精心设计下,Go提供了多种TLS服务端绑定证书的方式,本文将详细探讨服务端绑定TLS证书的几种方式,包括绑定单个证书、多个证书、自定义证书绑定逻辑等。我会配合示例代码,了解每种方式的使用场景、实现原理和优缺点。

注:本文假设读者已熟悉基本的TLS使用方法,并具备Go语言编程经验。如果你不具备Go语言基础知识,可以将学习我撰写的极客时间专栏《Go语言第一课》作为你入门Go的起点。

1. 热身:制作证书

为了后续示例说明方便,我们先来把示例所需的私钥和证书都做出来,本文涉及的证书以及他们之间的签发关系如下图:

注:示例使用的自签名根证书。

从图中我们看到,我们证书分为三个层次,最左边是CA的根证书(root certificate,比如ca-cert.pem),之后是根CA签发的中间CA证书(intermediate certificate,比如inter-cert.pem),从安全和管理角度出发,真正签发服务器证书的都是这些中间CA;最右侧则是由中间CA签发的叶子证书(leaf certificate,比如leaf-server-cert.pem),也就是服务器配置的服务端证书(server certificate),我们为三个不同域名创建了不同的服务器证书。

在这里,我们制作上述证书没有使用类似openssl这样的工具,而是通过Go代码生成的,下面是生成上述证书的代码片段:

// tls-certs-binding/make_certs/main.go

func main() {
    // 生成CA根证书密钥对
    caKey, err := rsa.GenerateKey(rand.Reader, 2048)
    checkError(err)

    // 生成CA证书模板
    caTemplate := x509.Certificate{
        SerialNumber: big.NewInt(1),
        Subject: pkix.Name{
            Organization: []string{"Go CA"},
        },
        NotBefore:             time.Now(),
        NotAfter:              time.Now().Add(time.Hour * 24 * 365),
        KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
        BasicConstraintsValid: true,
        IsCA:                  true,
    }

    // 使用模板自签名生成CA证书
    caCert, err := x509.CreateCertificate(rand.Reader, &caTemplate, &caTemplate, &caKey.PublicKey, caKey)
    checkError(err)

    // 生成中间CA密钥对
    interKey, err := rsa.GenerateKey(rand.Reader, 2048)
    checkError(err)

    // 生成中间CA证书模板
    interTemplate := x509.Certificate{
        SerialNumber: big.NewInt(2),
        Subject: pkix.Name{
            Organization: []string{"Go Intermediate CA"},
        },
        NotBefore:             time.Now(),
        NotAfter:              time.Now().Add(time.Hour * 24 * 365),
        KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
        BasicConstraintsValid: true,
        IsCA:                  true,
    }

    // 用CA证书签名生成中间CA证书
    interCert, err := x509.CreateCertificate(rand.Reader, &interTemplate, &caTemplate, &interKey.PublicKey, caKey)
    checkError(err)

    // 生成叶子证书密钥对
    leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
    checkError(err)

    // 生成叶子证书模板,CN为server.com
    leafTemplate := x509.Certificate{
        SerialNumber: big.NewInt(3),
        Subject: pkix.Name{
            Organization: []string{"Go Server"},
            CommonName:   "server.com",
        },
        NotBefore:    time.Now(),
        NotAfter:     time.Now().Add(time.Hour * 24 * 365),
        KeyUsage:     x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
        ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
        IPAddresses:  []net.IP{net.ParseIP("127.0.0.1")},
        DNSNames:     []string{"server.com"},
        SubjectKeyId: []byte{1, 2, 3, 4},
    }

    // 用中间CA证书签名生成叶子证书
    leafCert, err := x509.CreateCertificate(rand.Reader, &leafTemplate, &interTemplate, &leafKey.PublicKey, interKey)
    checkError(err)

    // 生成server1.com叶子证书
    leafKey1, _ := rsa.GenerateKey(rand.Reader, 2048)

    leafTemplate1 := x509.Certificate{
        SerialNumber: big.NewInt(4),
        Subject: pkix.Name{
            CommonName: "server1.com",
        },
        NotBefore: time.Now(),
        NotAfter:  time.Now().Add(time.Hour * 24 * 365),

        KeyUsage:    x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
        ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
        DNSNames:    []string{"server1.com"},
    }

    leafCert1, _ := x509.CreateCertificate(rand.Reader, &leafTemplate1, &interTemplate, &leafKey1.PublicKey, interKey)

    // 生成server2.com叶子证书
    leafKey2, _ := rsa.GenerateKey(rand.Reader, 2048)

    leafTemplate2 := x509.Certificate{
        SerialNumber: big.NewInt(5),
        Subject: pkix.Name{
            CommonName: "server2.com",
        },
        NotBefore: time.Now(),
        NotAfter:  time.Now().Add(time.Hour * 24 * 365),

        KeyUsage:    x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
        ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
        DNSNames:    []string{"server2.com"},
    }

    leafCert2, _ := x509.CreateCertificate(rand.Reader, &leafTemplate2, &interTemplate, &leafKey2.PublicKey, interKey)

    // 将证书和密钥编码为PEM格式
    caCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert})
    caKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(caKey)})

    interCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert})
    interKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(interKey)})

    leafCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert})
    leafKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(leafKey)})

    leafCertPEM1 := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert1})
    leafKeyPEM1 := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(leafKey1)})

    leafCertPEM2 := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert2})
    leafKeyPEM2 := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(leafKey2)})

    // 将PEM写入文件
    writeDataToFile("ca-cert.pem", caCertPEM)
    writeDataToFile("ca-key.pem", caKeyPEM)

    writeDataToFile("inter-cert.pem", interCertPEM)
    writeDataToFile("inter-key.pem", interKeyPEM)

    writeDataToFile("leaf-server-cert.pem", leafCertPEM)
    writeDataToFile("leaf-server-key.pem", leafKeyPEM)

    writeDataToFile("leaf-server1-cert.pem", leafCertPEM1)
    writeDataToFile("leaf-server1-key.pem", leafKeyPEM1)

    writeDataToFile("leaf-server2-cert.pem", leafCertPEM2)
    writeDataToFile("leaf-server2-key.pem", leafKeyPEM2)
}

运行这个程序后,当前目录下就会出现如下私钥文件(xx-key.pem)和证书文件(xx-cert.pem):

$ls *pem
ca-cert.pem     inter-cert.pem      leaf-server-cert.pem    leaf-server1-cert.pem   leaf-server2-cert.pem
ca-key.pem      inter-key.pem       leaf-server-key.pem leaf-server1-key.pem    leaf-server2-key.pem

制作完证书后,我们就来看看日常使用最多的绑定单一TLS证书的情况。

2. 绑定单一TLS证书

做过web应用的读者,想必对绑定单一TLS证书的实现方式并不陌生。服务端只需要加载一对服务端私钥与公钥证书即可对外提供基于TLS的安全网络服务,这里一个echo服务为例,我们来看下服务端的代码:

// tls-certs-binding/bind_single_cert/sever/main.go

// 服务端
func startServer(certFile, keyFile string) {
    // 读取证书和密钥
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        log.Fatal(err)
    }

    // 创建TLS配置
    config := &tls.Config{
        Certificates: []tls.Certificate{cert},
    }

    // 启动TLS服务器
    listener, err := tls.Listen("tcp", ":8443", config)
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    log.Println("Server started")

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Println(err)
            continue
        }
        handleConnection(conn)
    }
}

func handleConnection(conn net.Conn) {
    defer conn.Close()
    // 处理连接...
    // 循环读取客户端的数据
    for {
        buf := make([]byte, 1024)
        n, err := conn.Read(buf)
        if err != nil {
            // 读取失败则退出
            return
        }

        // 回显数据给客户端
        s := string(buf[:n])
        fmt.Printf("recv data: %s\n", s)
        conn.Write(buf[:n])
    }
}

func main() {
    // 启动服务器
    startServer("leaf-server-cert.pem", "leaf-server-key.pem")
}

根据TLS的原理,客户端在与服务端的握手过程中,服务端会将服务端证书(leaf-server-cert.pem)发到客户端供后者验证,客户端使用服务器公钥证书校验服务器身份。这一过程的实质是客户端利用CA证书中的公钥或中间CA证书中的公钥对服务端证书中由CA私钥或中间CA私钥签名的数据进行验签

// tls-certs-binding/bind_single_cert/client/main.go

func main() {
    caCert, err := ioutil.ReadFile("inter-cert.pem")
    if err != nil {
        log.Fatal(err)
    }

    caCertPool := x509.NewCertPool()
    caCertPool.AppendCertsFromPEM(caCert)

    config := &tls.Config{
        RootCAs: caCertPool,
    }

    conn, err := tls.Dial("tcp", "server.com:8443", config)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    // 每秒发送信息
    ticker := time.NewTicker(time.Second)
    for range ticker.C {
        msg := "hello, tls"
        conn.Write([]byte(msg))

        // 读取回复
        buf := make([]byte, len(msg))
        conn.Read(buf)
        log.Println(string(buf))
    }

}

这里我们使用了签发了leaf-server-cert.pem证书的中间CA(inter-cert.pem)来验证服务端证书(leaf-server-cert.pem)的合法性,毫无疑问这是会成功的!

// server
$go run main.go
2023/10/05 22:49:17 Server started

// client

$go run main.go
2023/10/05 22:49:22 hello, tls
2023/10/05 22:49:23 hello, tls
... ...

注:运行上述代码之前,需修改/etc/hosts文件,添加server.com的IP为127.0.0.1。

不过要注意的是,在这里用CA根证书(ca-cert.pem)直接验证叶子证书(leaf-server-cert.pem)会失败,因为根证书不是叶子证书的直接签发者,必须通过验证证书链来建立根证书和叶子证书之间的信任链。

3. 证书链

实际生产中,服务器实体证书和根证书分别只有一张,但中间证书可以有多张,这些中间证书在客户端并不一定存在,这就可能导致客户端与服务端的连接无法建立。通过openssl命令也可以印证这一点:

// 在make_certs目录下

// CA根证书无法直接验证叶子证书
$openssl verify -CAfile ca-cert.pem leaf-server-cert.pem
leaf-server-cert.pem: O = Go Server, CN = server.com
error 20 at 0 depth lookup:unable to get local issuer certificate

// 证书链不完整,也无法验证
$openssl verify -CAfile inter-cert.pem leaf-server-cert.pem
leaf-server-cert.pem: O = Go Intermediate CA
error 2 at 1 depth lookup:unable to get issuer certificate

// 需要用完整证书链来验证
$openssl verify -CAfile ca-cert.pem -untrusted inter-cert.pem leaf-server-cert.pem
leaf-server-cert.pem: OK

为此在建连阶段,服务端不仅要将服务器实体证书发给客户端,还要发送完整的证书链(如下图所示)。

证书链的最顶端是CA根证书,它的签名值是自己签名的,验证签名的公钥就包含在根证书中,根证书的签发者(Issuer)与使用者(Subject)是相同的。除了根证书,每个证书的签发者(Issuer)是它的上一级证书的使用者(Subject)。以上图为例,下列关系是成立的:

- ca-cert.pem的Issuer == ca-cert.pem的Subject
- inter1-cert.pem的Issuer == ca-cert.pem的Subject
- inter2-cert.pem的Issuer == inter1-cert.pem的Subject
... ...
- interN-cert.pem的Issuer == interN-1-cert.pem的Subject
- leaf-server-cert.pem的Issuer == interN-cert.pem的Subject

每张证书包含的重要信息是签发者(Issuer)、数字签名算法、签名值、使用者(Subject)域名、使用者公钥。除了根证书,每个证书(比如inter2-cert.pem证书)被它的上一级证书(比如inter1-cert.pem证书)对应的私钥签名,签名值包含在证书中,上一级证书包含的公钥可以用来验证该证书中的签名值(inter2-cert.pem证书可以用来验证inter1-cert.pem证书中的签名值)。

那么如何在服务端返回证书链呢?如何在客户端接收并验证证书链呢?我们来看下面示例。在这个示例中,客户端仅部署了根证书(ca-cert.pem),而服务端需要将服务证书与签发服务证书的中间CA证书以证书链的形式返回给客户端。

我们先来看服务端:

// tls-certs-binding/bind_single_cert/server-with-certs-chain/main.go

// 服务端
func startServer(certFile, keyFile string) {
    // 读取证书和密钥
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        log.Fatal(err)
    }

    interCertBytes, err := os.ReadFile("inter-cert.pem")
    if err != nil {
        log.Fatal(err)
    }

    interCertblock, _ := pem.Decode(interCertBytes)

    // 将中间证书添加到证书链
    cert.Certificate = append(cert.Certificate, interCertblock.Bytes)

    // 创建TLS配置
    config := &tls.Config{
        Certificates: []tls.Certificate{cert},
    }

    // 启动TLS服务器
    listener, err := tls.Listen("tcp", ":8443", config)
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    log.Println("Server started")

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Println(err)
            continue
        }
        handleConnection(conn)
    }
}

我们看到:服务端在加载完服务端证书后,又将中间CA证书inter-cert.pem attach到cert.Certificate,这样cert.Certificate中就构造出了一个证书链,而不单单是一个服务端证书了。

我们要注意证书链构造时的顺序,这里按照的是如下顺序构造证书链的:

- 服务端证书 (leaf certificate)
- 中间CA证书N
- 中间CA证书N-1
... ...
- 中间CA证书2
- 中间CA证书1

如果客户端没有根CA证书 (root certificate),在服务端构造证书链时,需要将根CA证书作为最后一个证书attach到证书链中。

下面则是客户端验证证书链的代码:

// tls-certs-binding/bind_single_cert/client-verify-certs-chain/main.go

func main() {
    // 加载ca-cert.pem
    caCertBytes, err := os.ReadFile("ca-cert.pem")
    if err != nil {
        log.Fatal(err)
    }

    caCertblock, _ := pem.Decode(caCertBytes)
    caCert, err := x509.ParseCertificate(caCertblock.Bytes)
    if err != nil {
        log.Fatal(err)
    }

    // 创建TLS配置
    config := &tls.Config{
        InsecureSkipVerify: true, // trigger to call VerifyPeerCertificate

        // 设置证书验证回调函数
        VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
            // 解析服务端返回的证书链(顺序:server-cert.pem, inter-cert.pem,inter-cert.pem's issuer...)

            var issuer *x509.Certificate
            var cert *x509.Certificate
            var err error

            if len(rawCerts) == 0 {
                return errors.New("no server certificate found")
            }

            issuer = caCert

            for i := len(rawCerts) - 1; i >= 0; i-- {
                cert, err = x509.ParseCertificate(rawCerts[i])
                if err != nil {
                    return err
                }

                if !verifyCert(issuer, cert) {
                    return errors.New("verifyCert failed")
                }

                issuer = cert
            }
            return nil
        },
    }

    conn, err := tls.Dial("tcp", "server.com:8443", config)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    // 每秒发送信息
    ticker := time.NewTicker(time.Second)
    for range ticker.C {
        msg := "hello, tls"
        conn.Write([]byte(msg))

        // 读取回复
        buf := make([]byte, len(msg))
        conn.Read(buf)
        log.Println(string(buf))
    }

}

// 验证cert是否是issuer的签发
func verifyCert(issuer, cert *x509.Certificate) bool {

    // 验证证书
    certPool := x509.NewCertPool()
    certPool.AddCert(issuer) // ok
    opts := x509.VerifyOptions{
        Roots: certPool,
    }
    _, err := cert.Verify(opts)
    return err == nil
}

从代码可以看到,当正常验证失败或没有使用正常验证的情况下,我们需要将InsecureSkipVerify设置为true才能触发证书链的自定义校验逻辑(VerifyPeerCertificate)。在VerifyPeerCertificate中,我们先用ca根证书校验位于证书链最后的那个证书,验证成功后,用验证成功的证书验证倒数第二个证书,依次类推,知道全部证书都校验ok,说明证书链是可信任的。

服务端绑定一个证书或一套证书链是最简单的,也是最常见的方案,但在一些场景下,比如考虑支持多个域名、证书轮换等,TLS服务端可能需要绑定多个证书以满足要求。下面我们就来看看如何为TLS服务端绑定多个证书。

4. 绑定多个TLS证书

这个示例的证书绑定情况如下图:

我们在服务端部署并绑定了三个证书,三个证书与域名的对应关系如下:

- 证书leaf-server-cert.pem 对应 server.com
- 证书leaf-server1-cert.pem 对应 server1.com
- 证书leaf-server2-cert.pem 对应 server2.com

注:在/etc/hosts中添加server1.com和server2.com对应的ip均为127.0.0.1。

// tls-certs-binding/bind_multi_certs/server/main.go

func main() {
    certFiles := []string{"leaf-server-cert.pem", "leaf-server1-cert.pem", "leaf-server2-cert.pem"}
    keyFiles := []string{"leaf-server-key.pem", "leaf-server1-key.pem", "leaf-server2-key.pem"}

    // 启动服务器
    startServer(certFiles, keyFiles)
}

// 服务端
func startServer(certFiles, keyFiles []string) {
    // 读取证书和密钥

    var certs []tls.Certificate
    for i := 0; i < len(certFiles); i++ {
        cert, err := tls.LoadX509KeyPair(certFiles[i], keyFiles[i])
        if err != nil {
            log.Fatal(err)
        }
        certs = append(certs, cert)
    }

    // 创建TLS配置
    config := &tls.Config{
        Certificates: certs,
    }

    // 启动TLS服务器
    listener, err := tls.Listen("tcp", ":8443", config)
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    log.Println("Server started")

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Println(err)
            continue
        }
        handleConnection(conn)
    }
}

我们看到,绑定多个证书与绑定一个证书的原理是完全一样的,tls.Config的Certificates字段原本就是一个切片,可以容纳单个证书,也可以容纳证书链,容纳多个证书也不是问题。

客户端代码变化不大,我们仅是通过下面代码输出了服务端返回的证书的Subject.CN:

// tls-certs-binding/bind_multi_certs/client/main.go

// 解析连接的服务器证书
certs := conn.ConnectionState().PeerCertificates
if len(certs) > 0 {
    log.Println("Server CN:", certs[0].Subject.CommonName)
}

接下来我们通过client连接不同的域名,得到如下执行结果:

// 服务端
$go run main.go
2023/10/06 10:22:38 Server started

// 客户端
$go run main.go -server server.com:8443
2023/10/06 10:22:57 Server CN: server.com
2023/10/06 10:22:58 hello, tls

$go run main.go -server server1.com:8443
2023/10/06 10:23:02 Server CN: server1.com
2023/10/06 10:23:03 hello, tls
2023/10/06 10:23:04 hello, tls

$go run main.go -server server2.com:8443
2023/10/06 10:23:08 Server CN: server2.com
2023/10/06 10:23:09 hello, tls
... ...

我们看到,由于绑定多个域名对应的证书,程序可以支持访问不同域名的请求,并根据请求的域名,返回对应域名的证书。

5. 自定义证书选择绑定逻辑

无论是单一TLS证书、证书链还是多TLS证书,他们都有一个共同特点,那就是证书的绑定是事先已知的,是一种“静态”模式的绑定;有些场景下,服务端在初始化启动后并不会绑定某个固定的证书,而是根据客户端的连接需求以及特定规则在证书池中选择某个匹配的证书。在这种情况下,我们需要使用GetCertificate回调从自定义的证书池中选择匹配的证书,而不能在用上面示例中那种“静态”模式了。

我们来看一个自定义证书选择逻辑的示例,下面示意图展示了客户端和服务端的证书部署情况:

我们主要看一下服务端的代码逻辑变动:

// tls-certs-binding/bind_custom_logic/server/main.go

func startServer(certsPath string) {

    // 创建TLS配置
    config := &tls.Config{
        GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
            // 根据clientHello信息选择cert

            certFile := fmt.Sprintf("%s/leaf-%s-cert.pem", certsPath, info.ServerName[:len(info.ServerName)-4])
            keyFile := fmt.Sprintf("%s/leaf-%s-key.pem", certsPath, info.ServerName[:len(info.ServerName)-4])

            // 读取证书和密钥
            cert, err := tls.LoadX509KeyPair(certFile, keyFile)
            return &cert, err
        },
    }
    ... ...
}

我们看到: tls.Config我们建立了一个匿名函数赋值给了GetCertificate字段,该函数的实现逻辑就是根据客户端clientHello信息(tls握手时发送的信息)按照规则从证书池目录中查找并加载对应的证书与其私钥信息。示例使用ServerName来查找带有同名信息的证书。

例子的运行结果与上面的示例都差不多,这里就不赘述了。

利用这种动态的证书选择逻辑,我们还可以实现通过执行外部命令来获取证书、从数据库加载证书等。

6. 小结

通过本文的介绍,我们全面了解了在Go服务端绑定单个、多个TLS证书的各种方式。我们首先介绍了生成自签名证书的方法,这为我们的示例程序奠定了基础。然后我们详细探讨了绑定单证书、证书链、多证书、定制从证书池取特定证书的逻辑等不同机制的用法、优劣势和适用场景。同时,在介绍每种用法时,我们都用代码示例进一步解释了这些绑定方式的具体实现流程。

单证书TLS简单易理解,运行性能优异。多证书TLS在提高性能、安全性、便利管理等方面有着重要意义。而自定义证书选取逻辑则更加灵活。通过综合运用各种绑定机制,可以使我们的Go语言服务器端更加强大和灵活。

本文示例所涉及的Go源码可以在这里下载。

注:代码仓库中的证书和key文件有效期为一年,大家如发现证书已经过期,可以在make_certs目录下重新生成各种证书和私钥并copy到对应的其他目录中去。

7. 参考资料


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

聊聊Go与TLS 1.3

本文永久链接 – https://tonybai.com/2023/01/13/go-and-tls13

除了一些综述类文章和译文,我的文章选题多来源于实际工作和学习中遇到的问题。这次我们来聊聊近期遇到一个问题:如何加快基于TLS安全通信的海量连接的建连速度?

TLS(Transport Layer Security)传输安全层的下面是TCP层,我们首先可能会想到的是优化内核有关TCP握手的相关参数来快速建立TCP连接,比如:

  • net.ipv4.tcp_max_syn_backlog
  • net.ipv4.tcp_syncookies
  • net.ipv4.tcp_synack_retries
  • net.ipv4.tcp_abort_on_overflow
  • net.core.somaxconn
  • … …

关于Linux内核参数调优,大家可以参考一下极客时间专栏《系统性能调优必知必会》

此外为了加速海量连接的建连速度,提高应用从内核accept连接队列获取连接的速度,我们还可以采用多线程/多goroutine并发Listen同一个端口并并发Accept的机制,如果你使用的是Go语言,可以看看go-reuseport这个包。

说完TCP层,那么TLS层是否有可优化的、对建连速度有影响的地方呢?有的,那就是使用TLS 1.3版本来加速握手过程,从而加快建连速度。TLS 1.3是2018年发布的新TLS标准,近2-3年才开始得到主流语言、浏览器和web服务器的支持。那么它与之前的TLS 1.2有何不同呢?Go对TLS 1.3版本的支持程度如何?如何用Go编写使用TLS 1.3的安全通信代码?TLS 1.3建连速度究竟比TLS 1.2快多少呢?

带着这些问题,我们进入本篇正文部分!我们先来简要看看TLS 1.3与TLS 1.2版本的差异。

1. TLS 1.3与TLS 1.2的差异

TLS是由互联网工程任务组(Internet Engineering Task Force, IETF)制定和发布的、用于替代SSL的、基于加解密和签名算法的安全连接协议标准,其演进过程如下图:

其中TLS 1.0和1.1版本因不再安全,于2020年被作废,目前主流的版本,也是应用最为广泛的是2008年发布的TLS 1.2版本(使用占比如下图统计),而最新版本则是2018年正式发布的TLS 1.3,而TLS 1.3版本的发布也意味着TLS 1.2版本进入“作废期”,虽然实际中TLS 1.2的“下线”还需要很长时间:

TLS 1.3与TLS 1.2并不不兼容,在TLS 1.3协议规范中,我们能看到列出的TLS 1.3相对于TLS 1.2的一些主要改动:

  • 去除了原先对称加密算法列表中的非AEAD(Authenticated Encryption with Associated Data)算法,包括3DES、RC4、AES-CBC等,只支持更安全的加密算法。

注:常见的AEAD算法包括:AES-128-GCM、AES-256-GCM、ChaCha20-IETF-Poly1305等。在具备AES加速的CPU(桌面,服务器)上,建议使用AES-XXX-GCM系列,移动设备建议使用ChaCha20-IETF-Poly1305系列。

  • 静态RSA和Diffie-Hellman密码套件(cipher suites)已被删除;所有基于公钥的密钥交换机制现在都提供前向安全性。

注:前向安全(Forward Secrecy)是指的是长期使用的主密钥泄漏不会导致过去的会话密钥泄漏。前向安全能够保护过去进行的通讯不受密码或密钥在未来暴露的威胁。如果系统具有前向安全性,就可以保证在主密钥泄露时历史通讯的安全,即使系统遭到主动攻击也是如此。

  • TLS 1.2版本的协商机制已被废弃,引入了一个更快的新的密钥协商机制:PSK(Pre-Shared Key),简化了握手流程(下图是TLS 1.2与TLS 1.3握手流程的对比)。同时在ServerHello之后的所有握手信息现在都被加密了,以前在ServerHello中以明文方式发送的各种扩展信息现在也可以享受加密保护。

  • 增加了一个零往返时间(0-RTT)模式,在恢复连接建立时为一些应用数据节省了一个往返时间。但代价是丧失了某些安全属性。

注:当客户端(例如浏览器)首次成功完成与服务器的TLS 1.3握手后,客户端和服务器都可在本地存储预共享的加密密钥,这称为恢复主密钥。如果客户端稍后再次与服务器建立连接,则可以使用此恢复密钥将其第一条消息中的加密应用程序数据发送到服务器,而无需第二次执行握手。0-RTT模式有一个安全弱点。通过恢复模式发送数据不需要服务器的任何交互,这意味着攻击者(一般是中间人(middle-man))可以捕获加密的0-RTT数据,然后将其重新发送到服务器,或重放(Replay)它们。解决此问题的方法是确保所有0-RTT请求都是幂等的。

在这些主要变化中,与初次建连速度有关的显然是TLS 1.3握手机制的变化:从2-RTT缩短到1-RTT(如上图所示)。下面我们就用Go作为示例来看看TLS 1.3相对于TLS 1.2在建连速度方面究竟有怎样的提升。

2. Go对TLS 1.3的支持

  • Go语言从Go 1.12版本开始提供对TLS 1.3的可选支持。在Go 1.12版本下,你通过设置GODEBUG=tls13=1并且不显式设置tls Config的MaxVersion的情况下,便会开启TLS 1.3。这个版本实现中暂不支持TLS 1.3的0-RTT的特性。

  • Go 1.13版本默认情况下开启TLS 1.3,你可以使用GODEBUG=tls13=0关闭对TLS 1.3的支持。

  • 等到了Go 1.14版本,TLS 1.3成为默认TLS版本选项且无法再用GODEBUG=tls13=0关闭了!不过,通过Config.MaxVersion可以配置要使用的TLS版本。

  • Go 1.16版本中,在服务端或客户端不支持AES硬件加速的情况下,server端会优先选择其他AEAD的密码套件(cipher suite),比如ChaCha20Poly1305,而不会选择AES-GCM密码套件。

  • Go 1.18版本中,client端的Config.MinVersion将默认为TLS 1.2, 以替代原先的默认值TLS 1.0/TLS 1.1。不过你可以通过显式设置客户端的Config.MinVersion来改变这个设置。不过这个改动不影响server端。

了解了这些后,我们来看一个简单的使用Go和TLS 1.3版本的客户端与服务端示例。

3. Go TLS 1.3客户端与服务端通信示例

这次我们不去参考Go标准库crypto/tls包的样例,我们玩把时髦儿的:通过AI辅助生成一套基于TLS的client与server端的通信代码示例。ChatGPT不对大陆开放,我这里用的是AI编程助手(AICodeHelper),下面是生成过程的截图:

AICodeHelper为我们生成了大部分代码,但是server端代码有两个问题:只能处理一个client端连接和没有生成传入server证书和私钥的代码段,我们基于上面的框架代码做一下修改,得到我们的server和client端代码:

server端代码:

// https://github.com/bigwhite/experiments/blob/master/go-and-tls13/server.go
package main

import (
    "bufio"
    "crypto/tls"
    "fmt"
    "net"
)

func main() {
    cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        fmt.Println(err)
        return
    }

    config := &tls.Config{Certificates: []tls.Certificate{cer}}
    ln, err := tls.Listen("tcp", "localhost:8443", config)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer ln.Close()

    for {
        conn, err := ln.Accept()
        if err != nil {
            fmt.Println(err)
            continue
        }
        go handleConnection(conn)
    }
}

func handleConnection(conn net.Conn) {
    defer conn.Close()
    r := bufio.NewReader(conn)
    for {
        msg, err := r.ReadString('\n')
        if err != nil {
            fmt.Println(err)
            return
        }

        println(msg)

        n, err := conn.Write([]byte("hello, world from server\n"))
        if err != nil {
            fmt.Println(n, err)
            return
        }
    }
}
// https://github.com/bigwhite/experiments/blob/master/go-and-tls13/client.go

package main

import (
    "crypto/tls"
    "log"
)

func main() {
    conf := &tls.Config{
        InsecureSkipVerify: true,
    }

    conn, err := tls.Dial("tcp", "localhost:8443", conf)
    if err != nil {
        log.Println(err)
        return
    }
    defer conn.Close()

    n, err := conn.Write([]byte("hello, world from client\n"))
    if err != nil {
        log.Println(n, err)
        return
    }

    buf := make([]byte, 100)
    n, err = conn.Read(buf)
    if err != nil {
        log.Println(n, err)
        return
    }

    println(string(buf[:n]))
}

为了方便期间,这里使用自签名证书,并且客户端不对服务端的公钥数字证书进行验签(我们无需生成创建CA的相关key和证书),我们只需要使用下面命令生成一对server.key和server.crt:

$openssl genrsa -out server.key 2048
Generating RSA private key, 2048 bit long modulus
..........................+++
................................+++
e is 65537 (0x10001)

$openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:
State or Province Name (full name) []:
Locality Name (eg, city) []:
Organization Name (eg, company) []:
Organizational Unit Name (eg, section) []:
Common Name (eg, fully qualified host name) []:localhost
Email Address []:

关于非对称加密和数字证书方面的详细内容,可以参见我的《Go语言精进之路》一书的第51条“使用net/http包实现安全通信”

运行Server和Client,这里我使用的是Go 1.19版本编译器:

$go run server.go
hello, world from client

EOF

$go run client.go
hello, world from server

我们的示例已经可以正常运行了!那么如何证明示例中的client与server间使用的是1.3版本的TLS连接呢?或者如何查看client与server间使用的是哪个TLS版本呢?

有小伙伴可能会说:用wireshark抓包看,这个可行,但是用wireshark抓tls包,尤其是1.3建连包比较费劲。我们有更简单的方式,我们在开发环境可以通过修改标准库来实现。我们继续往下看。

4. server和client端的TLS版本的选择

TLS握手过程由client端发起,从client的视角,当client收到serverHello的响应后便可得到决策后要使用的TLS版本。因此这里我们改造一下crypto/tls/handshake_client.go的clientHandshake方法,在其实现中利用fmt.Printf输出TLS连接相关的信息即可(见下面代码中”====”开头的输出内容):

// $GOROOT/src/crypto/tls/handshake_client.go

func (c *Conn) clientHandshake(ctx context.Context) (err error) {
    ... ...
    hello, ecdheParams, err := c.makeClientHello()
    if err != nil {
        return err
    }
    c.serverName = hello.serverName

    fmt.Printf("====client: supportedVersions: %x, cipherSuites: %x\n", hello.supportedVersions, hello.cipherSuites)

    ... ...

    msg, err := c.readHandshake()
    if err != nil {
        return err
    }

    serverHello, ok := msg.(*serverHelloMsg)
    if !ok {
        c.sendAlert(alertUnexpectedMessage)
        return unexpectedMessageError(serverHello, msg)
    }

    if err := c.pickTLSVersion(serverHello); err != nil {
        return err
    }

    ... ...

    if c.vers == VersionTLS13 {
        fmt.Printf("====client: choose tls 1.3, server use ciphersuite: [0x%x]\n", serverHello.cipherSuite)
        ... ...
        // In TLS 1.3, session tickets are delivered after the handshake.
        return hs.handshake()
    }
    fmt.Printf("====client: choose tls 1.2, server use ciphersuite: [0x%x]\n", serverHello.cipherSuite)

    hs := &clientHandshakeState{
        ... ...
    }

    if err := hs.handshake(); err != nil {
        return err
    }
    ... ...
}

修改完标准库后,我们再来重新运行一下上面的client.go:

$go run client.go
====client: supportedVersions: [304 303], cipherSuites: [c02b c02f c02c c030 cca9 cca8 c009 c013 c00a c014 9c 9d 2f 35 c012 a 1301 1302 1303]
====client: choose tls 1.3, server use ciphersuite: [0x1301]
hello, world from server

这里我们看一下第一行输出的内容,这里输出的是client端构建clientHello握手包中的内容,展示的是client端支持的TLS版本以及密码套件(cipher suites),我们看到客户端支持0×304、0×303两个TLS版本,这两个数字与下面代码中的常量分别对应:

// $GOROOT/src/crypto/tls/common.go
const (
    VersionTLS10 = 0x0301
    VersionTLS11 = 0x0302
    VersionTLS12 = 0x0303
    VersionTLS13 = 0x0304

    // Deprecated: SSLv3 is cryptographically broken, and is no longer
    // supported by this package. See golang.org/issue/32716.
    VersionSSL30 = 0x0300
)

而输出的cipherSuites中包含的那些十六进制数则来自下面常量:

// $GOROOT/src/crypto/tls/cipher_suites.go
const (
    // TLS 1.0 - 1.2 cipher suites.
    TLS_RSA_WITH_RC4_128_SHA                      uint16 = 0x0005
    TLS_RSA_WITH_3DES_EDE_CBC_SHA                 uint16 = 0x000a
    TLS_RSA_WITH_AES_128_CBC_SHA                  uint16 = 0x002f
    TLS_RSA_WITH_AES_256_CBC_SHA                  uint16 = 0x0035
    TLS_RSA_WITH_AES_128_CBC_SHA256               uint16 = 0x003c
    TLS_RSA_WITH_AES_128_GCM_SHA256               uint16 = 0x009c
    TLS_RSA_WITH_AES_256_GCM_SHA384               uint16 = 0x009d
    TLS_ECDHE_ECDSA_WITH_RC4_128_SHA              uint16 = 0xc007
    TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA          uint16 = 0xc009
    TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA          uint16 = 0xc00a
    TLS_ECDHE_RSA_WITH_RC4_128_SHA                uint16 = 0xc011
    TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA           uint16 = 0xc012
    TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA            uint16 = 0xc013
    TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA            uint16 = 0xc014
    TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256       uint16 = 0xc023
    TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256         uint16 = 0xc027
    TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256         uint16 = 0xc02f
    TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256       uint16 = 0xc02b
    TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384         uint16 = 0xc030
    TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384       uint16 = 0xc02c
    TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256   uint16 = 0xcca8
    TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 uint16 = 0xcca9

    // TLS 1.3 cipher suites.
    TLS_AES_128_GCM_SHA256       uint16 = 0x1301
    TLS_AES_256_GCM_SHA384       uint16 = 0x1302
    TLS_CHACHA20_POLY1305_SHA256 uint16 = 0x1303
    ... ...
}

而从client.go运行结果中的第二行输出可以看出:这次建连,双方最终选择了TLS 1.3版本和TLS_AES_128_GCM_SHA256这个cipher suite。这与前面我们在回顾Go语言对TLS 1.3的支持历史中的描述一致,TLS 1.3是建连版本的默认选择。

那么我们是否可以选择建连时使用的版本呢?当然可以,我们既可以在server端配置,也可以在客户端配置。我们先来看看在Server端如何配置:

// https://github.com/bigwhite/experiments/blob/master/go-and-tls13/server_tls12.go

func main() {
    cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        fmt.Println(err)
        return
    }

    config := &tls.Config{
        Certificates: []tls.Certificate{cer},
        MaxVersion:   tls.VersionTLS12,
    }
    ... ...
}

我们基于server.go创建了server_tls12.go,在这个新源文件中,我们在tls.Config中增加一个配置MaxVersion,并将其值设置为tls.VersionTLS12,其含义是其最高支持的TLS版本为TLS 1.2。这样当我们使用client.go与基于server_tls12.go运行的服务端程序建连时,我们将得到下面输出:

$go run client.go
====client: supportedVersions: [304 303], cipherSuites: [c02b c02f c02c c030 cca9 cca8 c009 c013 c00a c014 9c 9d 2f 35 c012 a 1301 1302 1303]
====client: choose tls 1.2, server use ciphersuite: [0xc02f]
hello, world from server

我们看到,交互的双方最后选择了TLS 1.2版本,使用的密码套件为0xc02f,即TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256。

同理,如果你想在client端配置最高支持TLS 1.2版本的话,也可以采用同样的方式,大家可以看看本文对应代码库中的client_tls12.go这个源文件,这里就不赘述了。

到这里,一些小伙伴可能有了一个疑问:我们可以配置使用的TLS的版本,那么对于TLS 1.3而言,我们是否可以配置要使用的密码套件呢?答案是目前不可以,理由来自于Config.CipherSuites字段的注释:“Note that TLS 1.3 ciphersuites are not configurable”:

// $GOROOT/src/crypto/tls/common.go

type Config struct {
    ... ...
    // CipherSuites is a list of enabled TLS 1.0–1.2 cipher suites. The order of
    // the list is ignored. Note that TLS 1.3 ciphersuites are not configurable.
    //
    // If CipherSuites is nil, a safe default list is used. The default cipher
    // suites might change over time.
    CipherSuites []uint16
    ... ...
}

tls包会根据系统是否支持AES加速来选择密码套件,如果支持AES加速,就使用下面的defaultCipherSuitesTLS13,这样AES相关套件会被优先选择,否则defaultCipherSuitesTLS13NoAES会被使用,TLS_CHACHA20_POLY1305_SHA256会被优先选择:

// $GOROOT/src/crypto/tls/cipher_suites.go

// defaultCipherSuitesTLS13 is also the preference order, since there are no
// disabled by default TLS 1.3 cipher suites. The same AES vs ChaCha20 logic as
// cipherSuitesPreferenceOrder applies.
var defaultCipherSuitesTLS13 = []uint16{
    TLS_AES_128_GCM_SHA256,
    TLS_AES_256_GCM_SHA384,
    TLS_CHACHA20_POLY1305_SHA256,
}   

var defaultCipherSuitesTLS13NoAES = []uint16{
    TLS_CHACHA20_POLY1305_SHA256,
    TLS_AES_128_GCM_SHA256,
    TLS_AES_256_GCM_SHA384,
}

注:joe shaw曾写过一篇文章“Abusing go:linkname to customize TLS 1.3 cipher suites”,文中描述了一种通过go:linkname定制TLS 1.3密码套件的方法,有兴趣的小伙伴们可以去阅读一下。

5. 建连速度benchmark

最后我们再来看看相较于TLS 1.2,TLS 1.3的建连速度究竟快了多少。考虑到两个版本在RTT数量上的差异,即网络延迟对建连速度影响较大,我特意选择了一个ping在20-30ms的网络。我们为TLS 1.2和TLS 1.3分别建立Benchmark Test:

// https://github.com/bigwhite/experiments/blob/master/go-and-tls13/benchmark/benchmark_test.go

package main

import (
    "crypto/tls"
    "testing"
)

func tls12_dial() error {
    conf := &tls.Config{
        InsecureSkipVerify: true,
        MaxVersion:         tls.VersionTLS12,
    }

    conn, err := tls.Dial("tcp", "192.168.11.10:8443", conf)
    if err != nil {
        return err
    }
    conn.Close()
    return nil
}

func tls13_dial() error {
    conf := &tls.Config{
        InsecureSkipVerify: true,
    }

    conn, err := tls.Dial("tcp", "192.168.11.10:8443", conf)
    if err != nil {
        return err
    }
    conn.Close()
    return nil
}

func BenchmarkTls13(b *testing.B) {
    b.ReportAllocs()

    for i := 0; i < b.N; i++ {
        err := tls13_dial()
        if err != nil {
            panic(err)
        }
    }
}

func BenchmarkTls12(b *testing.B) {
    b.ReportAllocs()

    for i := 0; i < b.N; i++ {
        err := tls12_dial()
        if err != nil {
            panic(err)
        }
    }
}

server部署在192.168.11.10上,针对每个benchmark test,我们给予10s钟的测试时间,下面是运行结果:

$go test -benchtime 10s -bench .
goos: linux
goarch: amd64
pkg: demo
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkTls13-8        216   56036809 ns/op   47966 B/op     608 allocs/op
BenchmarkTls12-8        145   82395933 ns/op   26655 B/op     283 allocs/op
PASS
ok  demo 37.959s

我们看到相对与TLS 1.2,TLS 1.3建连速度的确更快些。不过从内存分配的情况来看,Go TLS 1.3的实现似乎更复杂一些。

6. 参考资料

  • RFC 8446:The Transport Layer Security (TLS) Protocol Version 1.3 – https://datatracker.ietf.org/doc/rfc8446/
  • 前向安全 – https://sunhuachuang.gitbooks.io/sun-note/content/cryptography/forward_backward_secrecy.html

本文涉及的源码可以在这里下载。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats