如何使用 Docker 容器实施互操作 TLS

随着容器和微服务的出现,您的服务现在可能比以往任何时候都更多地通过 HTTP 等协议相互通信。但是,当你的服务穿越不受信任的网络(例如在云中)时,你如何确保它们的通信是安全的呢?一种方法是通过相互传输层安全(mTLS)–它可以帮助服务相互验证(我们如何知道服务是否如他们所说的那样?但 mTLS 是如何工作的呢?让我们深入了解一下。

双方相互验证和加密当然不是什么新鲜事。它是 SSH 和 IPSec(为大多数 VPN 技术提供动力)等协议的基础,最近还被 Istio 和 Linkerd 等服务网格项目所采用。

对于生产用例来说,服务网格是一种开箱即用 mTLS 的好方法,但在采用服务网格之前,你可能会好奇两个 docker 容器之间的 mTLS 如何简单实现。

请参考以下 GitHub 代码库: https://github.com/blhagadorn/mutual-tls-docker

基本客户端和服务器设置

让我们使用 Go 语言设置一个基本的客户端和服务器–请导航至 GitHub 仓库中的 01-client-server-basic 目录,以便跟进。设置好基本客户端和服务器后,我们将添加 mTLS。

以下是基本服务器的要点(可在此处找到

func helloHandler(w http.ResponseWriter, r *http.Request) {
  io.WriteString(w, "Hello, world without mutual TLS!\n")
}func main() {
  http.HandleFunc("/hello", helloHandler)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

本质上,我们监听 8080 端口上的 /hello 路由,并在调用时返回一个字符串。

下面是基本客户端的要点(可在此处找到):

r, err := http.Get("https://localhost:8080/hello")
if err != nil {
  log.Fatal(err)
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)

本质上,我们向 https://localhost:8080/hello 发送 HTTP GET 请求,然后写出响应。

让我们构建并运行目前所拥有的程序,所有这些都位于 01-client-server-basic/ 目录中。

$ docker build -t basic-server -f Dockerfile.server . && docker run -it --rm --network host basic-server

让服务器保持运行,在同一目录下打开一个新窗口,然后运行客户端:

$ docker build -t basic-client -f Dockerfile.client . && docker run -it --rm --network host basic-client
> Hello, world without mutual TLS!

成功了!现在,客户端和服务器可以在不同的 Docker 容器中相互对话了。注意 --network host 的使用,它在容器之间创建了一个共享网络,因此两个容器的 localhost 是相同的。

我们可以选择使用 tcpdump 来验证运行客户端时明文的发送情况:

$ docker run -it --network host --rm dockersec/tcpdump tcpdump -i any port 8080 -c 100 -A
> Date: Sat, 03 Feb 2024 15:05:20 GMT
> Content-Length: 33
> Content-Type: text/plain; charset=utf-8> Hello, world without mutual TLS!

我们知道没有使用 TLS,原因很简单,因为我们可以读取文本(如果文本是加密的,就无法读取)。

添加相互 TLS

要添加互用 TLS,首先需要为连接生成私钥和相应的证书。如果你在 GitHub 代码库中查看了其他示例,请导航至 02-client-server-mtls 目录。

openssl req -newkey rsa:2048 \
-nodes -x509 \
-days 3650 \
-keyout key.pem \
-out cert.pem \
-subj "/C=US/ST=Montana/L=Bozeman/O=Organization/OU=Unit/CN=localhost" \
-addext "subjectAltName = DNS:localhost"

在此,我们生成一个私钥(key.pem)和一个证书(cert.pem),其中包含一个相应的公钥,该公钥带有本地主机的 CN(通用名称)和 SAN(主题备选名称)。

注意:CN 已被弃用,大多数现代 TLS 库都要求设置 SAN,包括 Golang 的 net/http。在本例中,我们同时设置了 CN 和 SAN,因为某些库仍要求设置 CN 或将其作为备用。

证书(公钥)和私钥在这里有几个作用。首先,私钥/公钥组合用于为建立会话的通信加密。其次,证书信息用于身份验证,证书要保护的域名是 localhost(SAN)。

关于最佳安全实践的说明:在本例中,两个服务共享同一个私钥。这并不是生产环境中推荐的信任关系,但为了简单起见,客户端和服务器都使用相同的私钥。

现在让我们检查客户端代码(在 client-mtls.go 中),下面的函数使用给定的证书和密钥返回 HTTPS 客户端:

func getHTTPSClientFromFile() *http.Client {
  caCert, err := ioutil.ReadFile("cert.pem")
  if err != nil {
    log.Fatal(err)
  }
  caCertPool := x509.NewCertPool()
  caCertPool.AppendCertsFromPEM(caCert)
  cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
  if err != nil {
    log.Fatal(err)
  } 
  client := &http.Client{
  Transport: &http.Transport{
    TLSClientConfig: &tls.Config{
      RootCAs:      caCertPool,
      Certificates: []tls.Certificate{cert},
      },
    },
  } return client
}

这里发生了几件事–首先,RootCAs 被设置为我们创建的证书池(其中只有一个证书)。这是一组根证书,客户端将用它来验证不同的证书颁发机构。由于在我们的示例中没有生成中间证书,这并不意味着什么,但在许多交易中,这定义了信任根(由根签署的任何证书都是有效的)。其次,我们要传递证书密钥对 cert,它定义了客户端在建立安全连接时要传递给服务器的证书。此外,证书密钥对还包含用于加密通信的私人密钥。

现在让我们看看相关的服务器代码:

func main() {
  http.HandleFunc("/hello", helloHandler)  caCert, err := ioutil.ReadFile("cert.pem")
  if err != nil {
    log.Fatal(err)
  }
 
  caCertPool := x509.NewCertPool()
  caCertPool.AppendCertsFromPEM(caCert)  tlsConfig := &tls.Config{
    ClientCAs:  caCertPool,
    ClientAuth: tls.RequireAndVerifyClientCert,
  }
  tlsConfig.BuildNameToCertificate()  server := &http.Server{
    Addr:      ":8443",
    TLSConfig: tlsConfig,
  }
  log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}

服务器配置与客户端配置十分相似(这是合理的,因为这是相互验证)。根 CA 的定义与此类似,TLS 配置也已设置,最后服务器使用证书和证书密钥对开始监听。与客户端类似,作为 TLS 握手的一部分,服务器会将其证书传递给希望与之连接的相关方(这样客户端就能根据证书公钥加密通信),同样,密钥也用于加密信息和验证证书中传递的公钥的所有权。

现在,让我们在 02-client-server-mtls 目录下运行我们的示例。

首先是服务器:

$ docker build -t mtls-server -f Dockerfile.server . && docker run -it --rm --network host mtls-server

然后是客户端:

$ docker build -t mtls-client -f Dockerfile.client . && docker run -it --rm --network host mtls-client
> Hello, world WITH mutual TLS!

再次成功!

我们可以再次使用 tcpdump 验证是否存在明文,以及容器之间的通信是否经过加密。

$ docker run -it --network host --rm dockersec/tcpdump tcpdump -i any port 8443 -c 100 -A>..V.(.@.................................. .&............0.........
O.f........

请注意,输出完全无法辨认,也无法嗅探,这意味着我们使用了加密技术

快速示意图

请看下图,它展示了我们刚才进行的 mTLS 交互

总结

至此,我们已经成功创建了两个客户端-服务器交互,一个没有相互 TLS,另一个有相互 TLS。我们以 localhost 作为 SAN,通过生成密钥和证书添加了 TLS。之后,我们编辑了客户端代码,加入了根 CA 的 TLS 配置,并指定了要加密通信的证书和私钥。同样,在服务器代码中,我们指定了根 CA 以及服务器应该监听的证书和密钥。

在这之后,我希望你已经为跨微服务(尤其是服务网格)考虑 mTLS 打下了基础。在我们的示例中,我们只生成了一次证书和密钥,并将它们手动输入到配置中,但服务网格通常可以在较短的更新时间内自动轮换这些证书,此外,它们通常会将所有流量路由到一个侧车代理(sidecar proxy),然后将正常通信升级为 mTLS,并在到达目的地后进行解密。从本质上讲,mTLS 是隐形的,这就是强大的代理配置和控制平面的神奇之处。

希望你能从这篇文章中学到一些关于容器化工作负载安全的知识,并一如既往地关注我在 Medium 上发表的更多文章,或在 Twitter 上关注我

本文文字及图片出自 How to Implement Mutual TLS with Docker Containers

阅读余下内容
 

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号