SSL/TLS实战以及在kubeadm中的应用(上)

本文介绍了Go语言中SSL/TLS的实现,包括对称加密、非对称加密、TLS握手过程,并展示了在kubeadm中创建HTTPS服务器和客户端的步骤。通过实例代码解析了证书生成、签名验证和加密解密等关键操作。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

背景介绍

kubeadm是Kubernetes提供的自动化部署方案,可以极大地简化广为诟病的Kubernetes集群搭建的复杂性。kubeadm init命令对Kubernetes集群主节点的初始化流程中包含了以下若干步骤:

  1. PreflightPhase
  2. KubeletStartPhase
  3. CertsPhase
  4. KubeConfigPhase
  5. ControlPlanePhase
  6. EtcdPhase
  7. UploadConfigPhase
  8. UploadCertsPhase
  9. MarkControlPlanePhase
  10. BootstrapTokenPhase
  11. AddonPhase

CertsPhase用于生成https服务端/客户端需要使用的证书、公钥/秘钥等。

这一部分大量使用到了go函数库中crypto模块的功能。一般而言,crypto模块的功能在日常开发中涉及较少,对kubeadm代码会造成不小的障碍。本文梳理了go语言标准库中crypto模块常用的场景,以及针对各场景给出了简短的代码示例。本文主要关注加密、解密等安全功能在go语言中的实践,相关的理论知识(比如各算法的原理、产生背景以及应用)此处不再赘述。

小文件的hash计算

import (
   "crypto/md5"
   "crypto/sha1"
   "crypto/sha256"
   "crypto/sha512"
   "fmt"
   "io/ioutil"
)
data, err := ioutil.ReadFile(filename)
// 计算文件内容的hash值,并输出
fmt.Printf("Md5: %x\n\n", md5.Sum(data))
fmt.Printf("Sha1: %x\n\n", sha1.Sum(data))
fmt.Printf("Sha256: %x\n\n", sha256.Sum256(data))
fmt.Printf("Sha512: %x\n\n", sha512.Sum512(data))

大文件的hash计算

// 打开文件
file, err := os.Open(filename)
// 构建hasher对象(实现了writer接口)
hasher := md5.New()
_, err = io.Copy(hasher, file)
checksum := hasher.Sum(nil)

对称加密

生成随机数的三种方法

我们可以将生成随机数视为生成秘钥。

方法1
import (
   "crypto/rand"
   "math"
   "math/big"
)
limit := int64(math.MaxInt64) // 允许的最大值
randInt, err := rand.Int(rand.Reader, big.NewInt(limit))
方法2

binary.Read()方法将读取足够的字节以填充相应的数据类型

import (
   "crypto/rand"
   "encoding/binary"
)
var number uint32
err = binary.Read(rand.Reader, binary.BigEndian, &number)
方法3

直接生成相应长度的字节码slice

import (
   "crypto/rand"
)
numBytes := 4
randomBytes := make([]byte, numBytes)
rand.Read(randomBytes)
fmt.Println("Random byte values: ", randomBytes)

加密算法

常用的对称加密算法是AES,而我们偶尔能够听到的DES是一个相对于AES更老的版本。下面我们演示的例子是基于go标准库中提供的aes算法实现。初始向量(initialization vector)的作用是使得对相同消息的加密结果也不一样,进一步提升数据的安全性。

import (
   "crypto/aes"
   "crypto/cipher"
   "crypto/rand"
   "io"
)
func encrypt(key, message []byte) ([]byte, error) {
   // 初始化block cipher
   block, err := aes.NewCipher(key)

   // 创建字节slice以持有后续产生的加密消息
   cipherText := make([]byte, aes.BlockSize+len(message))

   // 生成Initialization Vector (IV) nonce
   // 这部分数据存储在slice的头部
   // 其长度与AES的block大小一样
   iv := cipherText[:aes.BlockSize]
   _, err = io.ReadFull(rand.Reader, iv)

   // 选择block cipher的工作模式
   // 此处使用cipher feedback (CFB)模式
   // 当然,CBCEncrypter也是一个备选项
   cfb := cipher.NewCFBEncrypter(block, iv)
   // 生成加密的消息,并将其存储在剩下的字节slice中
   cfb.XORKeyStream(cipherText[aes.BlockSize:], message)

   return cipherText, nil
}

解密

// AES解密
func decrypt(key, cipherText []byte) ([]byte, error) {
   // 初始化block cipher
   block, err := aes.NewCipher(key)

   // 将IV nonce与加密消息分开
   iv := cipherText[:aes.BlockSize]
   cipherText = cipherText[aes.BlockSize:]

   // 使用CFB block模式将加密消息解密
   cfb := cipher.NewCFBDecrypter(block, iv)
   cfb.XORKeyStream(cipherText, cipherText)

   return cipherText, nil
}

非对称加密

由于对称加密需要通讯双方共享秘钥,这一要求在互联网环境中会成为安全的隐患。由此安全专家们提出了RSA等非对称加密算法。

生成公钥/秘钥对

等价的openssl命令如下

# 生成秘钥
openssl genrsa -out priv.pem 2048
# 从私钥中提取公钥
openssl rsa -in priv.pem -pubout -out public.pem

对应的go代码如下

import (
   "crypto/rand"
   "crypto/rsa"
   "crypto/x509"
   "encoding/pem"
   "os"
)
// 生成公钥/秘钥对,并将其存储在PEM格式的文件中

privateKey, err := rsa.GenerateKey(rand.Reader, keySize)
// 分别将秘钥公钥进行PEM格式的编码
privatePem := getPrivatePemFromKey(privateKey)
publicPem := generatePublicPemFromKey(privateKey.PublicKey)
// Save the PEM output to files   
savePemToFile(privatePem, privatePemFilename)  
savePemToFile(publicPem, publicPemFilename)

其中getPrivatePemFromKey的实现为

// 将私钥编码到PEM文件中,本质上PEM是基于base64的
func getPrivatePemFromKey(privateKey *rsa.PrivateKey) *pem.Block {
   encodedPrivateKey := x509.MarshalPKCS1PrivateKey(privateKey)
   var privatePem = &pem.Block {
      Type: "RSA PRIVATE KEY",
      Bytes: encodedPrivateKey,
   }
   return privatePem
}

generatePublicPemFromKey的实现如下

// 将公钥编码到PEM文件中
func generatePublicPemFromKey(publicKey rsa.PublicKey) *pem.Block {
   encodedPubKey, err := x509.MarshalPKIXPublicKey(&publicKey)
   // 构建公钥的PEM数据结构
   var publicPem = &pem.Block{
      Type:  "PUBLIC KEY",
      Bytes: encodedPubKey,
   }
   return publicPem
}

保存到pem文件的实现如下

func savePemToFile(pemBlock *pem.Block, filename string) {
   // 将公钥PEM保存到文件中
   publicPemOutputFile, err := os.Create(filename)
   defer publicPemOutputFile.Close()
  // pem.Encode()方法是将pem.Block序列化到字节流的方法
   err = pem.Encode(publicPemOutputFile, pemBlock)
}

为消息签名

为了对消息进行签名,我们首先需要生成消息的hash值,随后根据私钥将该hash值进行加密;我们得到的加密hash值即所谓的“签名”;go的核心函数是rsa.SignPKCS1v15

下面的程序示例会根据消息和私钥,生成其签名

import (
   "crypto"
   "crypto/rand"
   "crypto/rsa"
   "crypto/sha256"
   "crypto/x509"
   "encoding/pem"
   "fmt"
   "io/ioutil"
)
// 将文件中加载消息和私钥
message,err := ioutil.ReadFile(messageFilename)
// 从文件中加载私钥
privateKey,err := loadPrivateKeyFromPemFile(privateKeyFilename)

// 对消息进行签名
hashed := sha256.Sum256(message)
signature, err := rsa.SignPKCS1v15(
   rand.Reader,
   privateKey,
   crypto.SHA256,
   hashed[:],
)

加载秘钥的步骤为

// 从PEM格式的文件中加载RSA私钥
func loadPrivateKeyFromPemFile(privateKeyFilename string) *rsa.PrivateKey {
   // 将文件快速加载到内存
   fileData, err := ioutil.ReadFile(privateKeyFilename)
   // 从PEM格式中解码
   block, _ := pem.Decode(fileData)
   // 将字节码解析到合适的数据结构中
   privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)

   return privateKey
}

对签名的验证

我们收到消息和签名之后,首先使用发送者的公钥将消息解密,然后将该值与原消息的hash值进行比对;核心函数为rsa.VerifyPKCS1v15()

import (
   "crypto"
   "crypto/rsa"
   "crypto/sha256"
   "crypto/x509"
   "encoding/pem"
   "fmt"
   "io/ioutil"
   "log"
   "os"
)
// 从磁盘中加载所有数据
publicKey := loadPublicKeyFromPemFile(publicKeyFilename)

// 生成消息的hash值
hashedMessage := sha256.Sum256(message)
// 验证签名是否合法的核心函数
err := rsa.VerifyPKCS1v15(
   publicKey,
   crypto.SHA256,
   hashedMessage[:],
   signature,
)
if err != nil {
   // 那么说明签名不合法
}

从文件中加载公钥的实现如下

// 从PEM格式的文件中解码出公钥
func loadPublicKeyFromPemFile(publicKeyFilename string) *rsa.PublicKey {
   // 将文件中的数据快速加载到内存
   fileData, err := ioutil.ReadFile(publicKeyFilename)

   // 从PEM格式解码到普通字节数据
   block, _ := pem.Decode(fileData)

   // 从字节数据中解析出公钥
   publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)

   return publicKey.(*rsa.PublicKey) // 转换到PublicKey格式
}

TLS

由于算法的限制,非对称加密只能对不大于key长度的信息进行加密,而key常见的长度为2048字节。由于大小的限制,非对称RSA算法用于加密整个文档是不实际的;对称加密比如AES可以用于加密整个大文件,但是需要在两边共享秘钥。TLS/SSL使用两者的结合。在握手阶段使用非对称加密生成并共享秘钥,连接建立之后使用对称加密传输数据。TLS本质上是一种结合了对称加密与非对称加密的技术。

生成自签名的证书

我们首先需要下面两个信息

  1. 公钥/秘钥对
  2. 证书模板(certificate template)

如果我们生成自签名证书,那么证书模板也用作颁发认证的parent certificate;核心的方法是x509.CreateCertificate()

函数签名为

func CreateCertificate (rand io.Reader, template, parent *Certificate, pub, 
   priv interface{}) (cert []byte, err error)

如果使用cfssl创建自签名证书(可作为CA)的命令如下

// 创建 ca-csr.json 文件
{
  "CN": "kubernetes",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "CN",
      "ST": "BeiJing",
      "L": "BeiJing",
      "O": "k8s",
      "OU": "System"
    }
  ],
    "ca": {
       "expiry": "87600h"
    }
}
// 生成证书和私钥
cfssl gencert -initca ca-csr.json | cfssljson -bare ca

cfssl生成默认的配置文件

cfssl print-defaults config > config.json
cfssl print-defaults csr > csr.json

当我们运行TLS Server的时候,只需要提供证书与秘钥即可。为了演示的简单,此处证书的owner信息和hostname被设置为localhost

go代码如下

import (
   "crypto/rand"
   "crypto/rsa"
   "crypto/x509/pkix"
   "crypto/x509"
   "encoding/pem"
   "fmt"
   "io/ioutil"
   "log"
   "math/big"
   "net"
   "os"
   "time"
)
// 证书签发者的秘钥
privKey := loadPrivateKeyFromPemFile(privPemFilename)

// 证书申请者的公钥,而此处由于是自签名证书,所以签发者和申请者是同一人
pubKey := privKey.PublicKey

// 设置证书模板的信息
certTemplate := setupCertificateTemplate(isCA)

// 创建证书(核心为使用私钥进行签名)
certificate, err := x509.CreateCertificate(
   rand.Reader,
   &certTemplate,
   &certTemplate,
   &pubKey,
   privKey,
)

// 将证书保存为PEM格式的文件
writeCertToPemFile(certOutputFilename, certificate)

而其中setupCertificateTemplate的实现如下所示

func setupCertificateTemplate(isCA bool) x509.Certificate {
   // 设置证书的有效日期
   notBefore := time.Now()
   notAfter := notBefore.Add(time.Hour * 24 * 365) // 1年即365天

   // 生成随机的序列号
   serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
   randomNumber, err := rand.Int(rand.Reader, serialNumberLimit)

  // 证书所有者的信息:姓名、单位、住址等
   nameInfo := pkix.Name{
      Organization: []string{"My Organization"},
      CommonName: "localhost",
      OrganizationalUnit: []string{"My Business Unit"},
      Country:        []string{"US"}, // 2-character ISO code
      Province:       []string{"Texas"}, // State
      Locality:       []string{"Houston"}, // City
   }

  // 生成证书模板:随机数、个人信息、过期时间等
   certTemplate := x509.Certificate{
      SerialNumber: randomNumber,
      Subject: nameInfo,
      EmailAddresses: []string{"test@localhost"},
      NotBefore: notBefore,
      NotAfter: notAfter,
      KeyUsage: x509.KeyUsageKeyEncipherment |   
         x509.KeyUsageDigitalSignature,
      // ExtKeyUsage字段默认是any
      // 但是可以被设置为仅作为服务端认证、客户端认证、签发等
      ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
      BasicConstraintsValid: true,
      IsCA: false,
   }

   // 创建可以对CSR进行签名的CA证书
   if isCA {
      certTemplate.IsCA = true
      certTemplate.KeyUsage = certTemplate.KeyUsage |  
         x509.KeyUsageCertSign
   }

   // 将此证书可以覆盖的IP地址或者hostname添加到此处
   // 示例中仅覆盖到localhost
   certTemplate.IPAddresses = []net.IP{net.ParseIP("127.0.0.1")}
   certTemplate.DNSNames = []string{"localhost", "localhost.local"}

   return certTemplate
}

将证书保存到pem文件中

// 将证书保存到PEM编码的文件中Save the certificate as a PEM encoded file
func writeCertToPemFile(outputFilename string, derBytes []byte ) {
   // 从证书创建PEM块
   certPem := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}

   // 打开文件以便写入
   certOutfile, err := os.Create(outputFilename)
   pem.Encode(certOutfile, certPem)
   certOutfile.Close()
}

创建CSR

CSR是certificate signing request的缩写。

核心的函数调用为

x509.CreateCertificateRequest()

等价的openssl命令为

# Create CSR 
openssl req -new -key priv.pem -out csr.pem 
# View details to verify request was created properly 
openssl req -verify -in csr.pem -text -noout

等价的的cfssl命令为

// 创建 kubernetes 证书签名请求文件 kubernetes-csr.json
{
    "CN": "kubernetes",
    "hosts": [
      "127.0.0.1",
      "172.20.0.112",
      "172.20.0.113",
      "172.20.0.114",
      "172.20.0.115",
      "10.254.0.1",
      "kubernetes",
      "kubernetes.default",
      "kubernetes.default.svc",
      "kubernetes.default.svc.cluster",
      "kubernetes.default.svc.cluster.local"
    ],
    "key": {
        "algo": "rsa",
        "size": 2048
    },
    "names": [
        {
            "C": "CN",
            "ST": "BeiJing",
            "L": "BeiJing",
            "O": "k8s",
            "OU": "System"
        }
    ]
}
// 生成证书和私钥
$ cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=kubernetes kubernetes-csr.json | cfssljson -bare kubernetes

其中ca-config.json定义了若干profile,这些profile规定了生成的证书的某些特性,比如过期时间等

cat > ca-config.json <<EOF
{
  "signing": {
    "default": {
      "expiry": "87600h"
    },
    "profiles": {
      "kubernetes": {
        "usages": [
            "signing",
            "key encipherment",
            "server auth",
            "client auth"
        ],
        "expiry": "87600h"
      }
    }
  }
}
EOF

go代码实现为

import (
   "crypto/rand"
   "crypto/rsa"
   "crypto/x509"
   "crypto/x509/pkix"
   "encoding/pem"
   "fmt"
   "io/ioutil"
   "log"
   "net"
)

privKey := loadPrivateKeyFromPemFile(privKeyFilename)

// 准备证书的相关信息:姓名、单位、住址等
nameInfo := pkix.Name{
   Organization:       []string{"My Organization Name"},
   CommonName:         "localhost",
   OrganizationalUnit: []string{"Business Unit Name"},
   Country:            []string{"US"}, // 2-character ISO code
   Province:           []string{"Texas"},
   Locality:           []string{"Houston"}, // City
}

// 准备CSR模板,其实与上节创建Certificate的信息很像,我们列在下面以便对比。有不同者在于:
// 1. 随机序列号
// 2. 过期时间
// certTemplate := x509.Certificate{
//    SerialNumber: randomNumber,
//    Subject: nameInfo,
//    EmailAddresses: []string{"test@localhost"},
//    NotBefore: notBefore,
//    NotAfter: notAfter,
//    KeyUsage: x509.KeyUsageKeyEncipherment |   
//       x509.KeyUsageDigitalSignature,
//    // ExtKeyUsage字段默认是any
//    // 但是可以被设置为仅作为服务端认证、客户端认证、签发等
//    ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
//    BasicConstraintsValid: true,
//    IsCA: false,
// }
// certTemplate.IPAddresses = []net.IP{net.ParseIP("127.0.0.1")}
// certTemplate.DNSNames = []string{"localhost", "localhost.local"}
csrTemplate := x509.CertificateRequest{
   Version:            2, // Version 3, zero-indexed values
   SignatureAlgorithm: x509.SHA256WithRSA,
   PublicKeyAlgorithm: x509.RSA,
   PublicKey:          privKey.PublicKey,
   Subject:            nameInfo,

   // Subject Alternate Name values.
   DNSNames:       []string{"Business Unit Name"},
   EmailAddresses: []string{"test@localhost"},
   IPAddresses:    []net.IP{},
}

// Create the CSR based off the template
csr, err := x509.CreateCertificateRequest(rand.Reader,  
   &csrTemplate, privKey)
saveCSRToPemFile(csr, csrOutFilename)

其中持久化CSR到文件的实现为

func saveCSRToPemFile(csr []byte, filename string) {
   csrPem := &pem.Block{
      Type:  "CERTIFICATE REQUEST",
      Bytes: csr,
   }
   csrOutfile, err := os.Create(filename)
   pem.Encode(csrOutfile, csrPem)
}

为CSR签名

使用签发者的证书作为parent certificate

等价的openssl命令如下

# Create signed certificate using
# the CSR, CA certificate, and private key 
openssl x509 -req -in csr.pem -CA cacert.pem \
        -CAkey capriv.pem -CAcreateserial \
        -out cert.pem -sha256
# Print info about cert 
openssl x509 -in cert.pem -text -noout 

而核心的go函数为

func CreateCertificate(rand io.Reader, template, parent *Certificate, pub, 
   priv interface{}) (cert []byte, err error)

其中各个参数的含义如下

  1. rand: 这是安全的随机数生成器
  2. template: 从CSR中提取的证书模板
  3. parent: 签发者的证书
  4. pub: 请求者的公钥
  5. priv: 签发者的秘钥

TLS服务器

下面演示的例子是

import (
   "bufio"
   "crypto/tls"
   "fmt"
   "log"
   "net"
   "os"
)
// 从文件中加载证书与私钥
serverCert, err := tls.LoadX509KeyPair(certFilename, privKeyFilename)

// 设置证书、host/ip和端口
config := &tls.Config{
   // 指定服务器端的证书
   Certificates: []tls.Certificate{serverCert},

   // 默认情况下不需要客户端证书By
   // ClientAuth指定的类型可以是以下几种:
   //    NoClientCert, RequestClientCert, RequireAnyClientCert,
   //    VerifyClientCertIfGiven, RequireAndVerifyClientCert

   // ClientAuth: tls.RequireAndVerifyClientCert

   // 指定可以信任的客户端的CA
   // ClientCAs: *x509.CertPool
}

// 构建TLS socket
listener, err := tls.Listen("tcp", hostString, config)
defer listener.Close()
// 在循环中监听连接
for {
   clientConnection, err := listener.Accept()
   // 对每个connection启动goroutine进行处理
   go handleConnection(clientConnection)
}
// 处理connection的函数
func handleConnection(clientConnection net.Conn) {
   defer clientConnection.Close()
   socketReader := bufio.NewReader(clientConnection)
   for {
      // 从客户端读取消息
      message, err := socketReader.ReadString('\n')
      // 将消息返回客户端
      numBytesWritten, err := clientConnection.Write([]byte(message))
      fmt.Printf("Wrote %d bytes back to client.\n", numBytesWritten)
   }
}

TLS客户端

// TLS配置
tlsConfig := &tls.Config{
   // 是否接收自签名的证书
   InsecureSkipVerify: true, 
   // 可以在此处给出自身的证书
   // Certificates: []Certificate

   // 用于验证hostname 
   // ServerName: string,

   // 指定新人的CA
   // RootCAs: *x509.CertPool
}

// 设置dialer以连接到服务器端
connection, err := tls.Dial("tcp", hostString, tlsConfig)defer connection.Close()

// 向socket写数据
numBytesWritten, err := connection.Write([]byte(messageToSend))
fmt.Printf("Wrote %d bytes to the socket.\n", numBytesWritten)

// 从socket中读取数据并输出到stdout
buffer := make([]byte, 100)
numBytesRead, err := connection.Read(buffer)
fmt.Printf("Read %d bytes to the socket.\n", numBytesRead)
fmt.Printf("Message received:\n%s\n", buffer)

https服务器

import (
   "fmt"
   "net/http"
   "log"
)

func indexHandler(writer http.ResponseWriter, request *http.Request) {
   fmt.Fprintf(writer, "You requested: "+request.URL.Path)
}

func main() {
   http.HandleFunc("/", indexHandler)
   err := http.ListenAndServeTLS( 
      "localhost:8181", 
      "cert.pem", 
      "privateKey.pem", 
      nil, 
   )
}

在https服务器使用ca

func main() {
	s := &http.Server{
		Addr: ":443",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      commonName := r.TLS.PeerCertificates[0].Subject.CommonName
			fmt.Fprintf(w, "Hello, %s\n", commonName)
		}),
		TLSConfig: &tls.Config{
			ClientCAs:  loadCA("ca.crt"),
			ClientAuth: tls.RequireAndVerifyClientCert,
		},
	}

	e := s.ListenAndServeTLS("server.crt", "server.key")
	if e != nil {
		log.Fatal("ListenAndServeTLS: ", e)
	}
}

// loadCA的实现为
func loadCA(caFile string) *x509.CertPool {
	pool := x509.NewCertPool()

	if ca, e := ioutil.ReadFile(caFile); e != nil {
		log.Fatal("ReadFile: ", e)
	} else {
		pool.AppendCertsFromPEM(ca)
	}
	return pool
}

https客户端使用CA

func main() {
	pair, e := tls.LoadX509KeyPair("client.crt", "client.key")
	if e != nil {
		log.Fatal("LoadX509KeyPair:", e)
	}

	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				RootCAs:      loadCA("ca.crt"),
				Certificates: []tls.Certificate{pair},
			},
		}}

	resp, e := client.Get("https://localhost")
	if e != nil {
		log.Fatal("http.Client.Get: ", e)
	}
	defer resp.Body.Close()
	io.Copy(os.Stdout, resp.Body)
}

总结

本文梳理了golang标准库中crypto模块常用的功能以及给出了代码示例,我们涉及到的功能主要是以下几种:

  1. 针对不同文件大小生成hash值的方法
  2. 对称加密/解密
  3. 非对称加密/解密
  4. TLS涉及的证书生成方法
  5. TLS服务端/客户端的使用(基于tcp层面)
  6. HTTPS服务器的使用

Kubernetes中将上述的功能封装到pkiutil包中,在下篇我们会为大家梳理kubeadm生成https相关的安全凭证的步骤。

(360技术原创内容,转载请务必保留文末二维码,谢谢~)
在这里插入图片描述

关于360技术
360技术是360技术团队打造的技术分享公众号,每天推送技术干货内容 更多技术信息欢迎关注“360技术”微信公众号

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值