如何使用 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