免费搞定!分离式 DNS、WAF 与 ACME 协议为内部服务配置 TLS 证书

发布时间:2026/7/11 2:43:20
免费搞定!分离式 DNS、WAF 与 ACME 协议为内部服务配置 TLS 证书 为内部服务正确配置 TLS 证书2026 年 7 月 9 日 Jakub Kołodziejczak标题有点像噱头 —— 效果因人而异但请听我解释为什么我认为“这就是正确的方法”。我们先从一个简单的例子说起我们有一台服务器上面托管着一堆 HTTP 服务。其中一些是外部服务另一些则是内部服务。要访问内部服务你需要连接到 VPN。为了简单起见我们有两种选择1. 使用 [ICANN 规定的供私人使用的顶级域名](https://itp.cdn.icann.org/en/files/root-system/identification-tld-private-use-24-01-2024-en.pdf)例如 .internal。2. 使用我们自己拥有的公共顶点域名例如 tuxnet.dev。我们以 Grafana 作为内部应用的示例。假设它可以通过内部 IP 地址 10.0.1.10 访问并且我们的 VPN 具备 DNS 解析功能。那么使用 .internal 有什么问题呢我们可以简单地创建一个类型为 “A” 的 DNS 记录将其解析到内部 IP 地址 10.0.1.10例如 grafana.tuxnet.internal。但如果我们不想让它成为纯文本的 HTTP 服务就需要创建一个自签名证书。好处是有很多教程会教你如何操作例如 [这篇](https://www.digitalocean.com/community/tutorials/how-to-create-a-self-signed-ssl-certificate-for-nginx-in-ubuntu-16-04)。但麻烦的是突然之间每个 HTTP 客户端都需要配置为信任这个自签名证书。或者我们只能让用户忽略 TLS 证书错误 ¯\_(ツ)_/¯。如何“正确地”配置呢这就要说到“分离式 DNS”配置了。对于公共 DNS 解析器我们的 grafana.tuxnet.dev 域名会解析到一个公共 IP而对于连接到 VPN 的客户端这个域名会解析到一个内部 IP。好处是由于它解析到公共 IP我们可以使用一些公共 CA如 Let’s Encrypt 或 ZeroSSL。不过我们仍然需要一个 Web 应用防火墙WAF来拒绝非来自 VPN 的流量。综合考虑这两种方案的优缺点我认为在一个地方我们的服务器上设置 WAF 要比在每台加入内部网络的机器上安装自签名证书或者建议用户忽略 TLS 错误容易得多。“空谈无益代码为证”现在我们有了理论基础是时候动手实践了。我们需要1. 一个具备 DNS 解析功能的 VPN我选择了 [NetBird](https://netbird.io/)。2. 一个用于颁发证书的 ACME 客户端我选择了 [acme.sh](https://github.com/acmesh-official/acme.sh)。3. 一个位于 Grafana 前面、具备 WAF 功能的反向代理我选择了 [nginx](https://nginx.org/)。如果你读过我其他的博客文章可能会注意到我是 NetBird 的粉丝抱歉Tailscale。借助 [自定义区域](https://docs.netbird.io/manage/dns/custom-zones) 功能NetBird 为我们完成了“分离式 DNS”所需的所有繁重工作。通过使用 [用户组](https://docs.netbird.io/manage/access-control#user-groups) 或 [对等组](https://docs.netbird.io/manage/access-control#peer-groups)我们可以有选择地应用自定义区域让服务器为 grafana.tuxnet.dev 使用公共 DNS 解析器。为什么要将服务器排除在该自定义区域之外呢除非我们想使用 [http-01 挑战](https://letsencrypt.org/docs/challenge-types/)否则这不是必需的。使用其他方法也是可行的但在这篇博客文章中我选择了 http-01。好了现在让我们来获取证书acme.sh --issue -d grafana.tuxnet.dev --server letsencrypt --standaloneacme.sh 非常灵活有 [很多模式](https://github.com/acmesh-official/acme.sh#%EF%B8%8F-supported-modes)。独立模式通过 --standalone 标志启用的好处是我们的 nginx 根本不需要监听 80 端口。只有当 acme.sh 获取证书时这个端口才会“激活”。现在我们可以让 nginx 开始工作了。以下是我们的配置upstream grafana {server localhost:3000;}map $http_upgrade $connection_upgrade {default upgrade; close;}server {listen our-server.netbird.cloud:443 ssl;server_name grafana.tuxnet.dev;http2 on;ssl_certificate /etc/ssl/certs/grafana.tuxnet.dev.crt;ssl_certificate_key /etc/ssl/private/grafana.tuxnet.dev.key;access_log /var/log/nginx/grafana.tuxnet.dev.access.log main;error_log /var/log/nginx/grafana.tuxnet.dev.error.log warn;location / {proxy_pass http://grafana;proxy_set_header Host $host;}# 代理 Grafana Live WebSocket 连接。location /api/live/ {proxy_pass http://grafana;proxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection $connection_upgrade;proxy_set_header Host $host;}}这个配置中有一个关键设置值得解释一下 —— listen our-server.netbird.cloud:443 ssl;。我们将其绑定到服务器的 VPN 网络接口。这里的 our-server.netbird.cloud 也可以是一个 VPN IP 地址。实际上这将拒绝所有来自公共互联网对 grafana.tuxnet.dev 的流量这就是我们的 Web 访问防火墙。安全是分层的就像洋葱和怪物一样。我们的第一层是分离式 DNS但如果出于某种原因它失效了或者被巧妙绕过我们还有第二层 —— WAF它应该能守住防线。最后还有证书自动续订。acme.sh 有一个现成的 --cron 标志。现在我们需要一个每天运行的 cron 任务来调用acme.sh --cronacme.sh 会自动选择需要续订的证书。我们只需要确保 cron 任务将新证书复制到 nginx 的 ssl_certificate 和 ssl_certificate_key 所定义的位置。同时还需要重新加载 nginx 以使用新证书。我们的 cron 任务可能如下所示main() {refresh_certssync_api_tuxnet_dev_certssync_internal_tuxnet_dev_certsreload_nginx}refresh_certs() {setcap CAP_NET_BIND_SERVICEep /usr/bin/socat1sudo -u acmesh /home/acmesh/acme.sh/acme.sh --cronsetcap -r /usr/bin/socat1}sync_api_tuxnet_dev_certs() {local green$(get_checksum $API_SRC_KEY)local blue$(get_checksum $API_DST_KEY)local key_allowed_groupwww-dataif [[ $green ! $blue ]]; thensync_certs $API_SRC_KEY $API_DST_KEY $API_SRC_CERT $API_DST_CERT $key_allowed_group}}sync_internal_tuxnet_dev_certs() {local green$(get_checksum $INTERNAL_SRC_KEY)local blue$(get_checksum $INTERNAL_DST_KEY)local key_allowed_groupwww-dataif [[ $green ! $blue ]]; thensync_certs $INTERNAL_SRC_KEY $INTERNAL_DST_KEY $INTERNAL_SRC_CERT $INTERNAL_DST_CERT $key_allowed_group}}reload_nginx() {nginx -tsystemctl reload nginx}get_checksum() {sha256sum $1 | cut -d -f1}sync_certs() {local src_key$1local dst_key$2local src_cert$3local dst_cert$4local key_allowed_group$5cp -v $src_key $dst_keylogger synced $dst_keycp -v $src_cert $dst_certlogger synced $dst_certchown root:${key_allowed_group} $dst_keychown root:ssl-cert $dst_certchmod 640 $dst_key $dst_cert}main $关于 setcap CAP_NET_BIND_SERVICEep /usr/bin/socat1 有一个小说明。acme.sh 在独立模式下使用 socat 来监听 80 端口。一方面如果可以的话我们不想以 root 身份运行 acme.sh另一方面80 端口是“特权端口”之一默认情况下非 root 进程不能绑定到特权端口。这就是 CAP_NET_BIND_SERVICEep 发挥作用的地方。如果你对这个话题感兴趣可以查看 [这篇文章](https://www.baeldung.com/linux/bind-process-privileged-port)。生活很美好我们的 TLS 正常工作了 —— 无论服务是内部的还是外部的无论是“明天”还是“明年”。额外内容 —— SANs 和 CNAMEs如果我们有更多的内部服务怎么办如果我们想让它们使用不同的子域名呢我们是否需要为每个服务生成单独的证书答案是“不需要”我们有两种解决方案1. 通配符证书 —— 我不太喜欢这种方式因为它存在 [安全隐患](https://knowledge.digicert.com/quovadis/ssl-certificates/ssl-general-topics/what-are-the-pros-and-cons-of-a-wildcard-certificate)。2. TLS SAN主题备用名称 —— 除了 CN通用名称之外我们还可以定义 SANs。更多信息请查看 。所以实际上我们可以为 internal.tuxnet.dev 创建一个 “A” 记录然后为 grafana.tuxnet.dev 和 analytics.tuxnet.dev 等创建 “CNAME” 记录将它们解析到 internal.tuxnet.dev。然后我们生成一个证书如下所示acme.sh --issue -d internal.tuxnet.dev -d grafana.tuxnet.dev -d analytics.tuxnet.dev --server letsencrypt --standalone其详细信息如下echo | openssl s_client -connect internal.tuxnet.dev:443 2/dev/null | openssl x509 -noout -subject -ext subjectAltNamesubjectCNinternal.tuxnet.devX509v3 Subject Alternative Name:DNS:analytics.tuxnet.dev, DNS:grafana.tuxnet.dev, DNS:internal.tuxnet.dev现在我们只需要在 nginx 配置的不同 server 定义中重复使用同一个证书即可。总结我们学习了如何为内部服务安全地配置 TLS 证书而不会给下游的 HTTP 客户端带来 TLS 问题。这都要归功于分离式 DNS、WAF 和 ACME 协议而且一切都是免费的Hacker News 评论可查看阅读其他文章* * *[ [使用 K3S 和 NetBird 扩展 Kubernetes 集群] ](/posts/stretch-cluster-k3s/)