
内网服务 TLS 证书最佳实践:用 Split-Horizon DNS 告别自签名证书
无需自签名证书、无需手动信任每个客户端,通过 Split-Horizon DNS + Let's Encrypt + 反向代理 WAF 实现内网服务的自动化 TLS 方案
原文来源:TLS certificates for internal services done right — 一套零成本、免自签名的内网 TLS 自动化方案,通过 Split-Horizon DNS 让 Let's Encrypt 为 VPN 内的服务签发可信证书
一个老问题
内网服务(比如 Grafana、NocoDB、内部 API)只有通过 VPN 才能访问,这就带来一个头疼的问题:HTTPS 怎么办?
最常见的方式是用自签名证书。但这种方案有个致命伤——每一台客户端都必须手动信任这个自签名证书。对于团队来说,这意味着每加入一个人、每换一台设备,都要重复一遍信任操作。而且多了之后管理混乱,谁也不知道哪个证书过期了、哪个没更新。
有朋友会说:"那就在内网搭一个私人 CA,统一签发。" 这确实是个方案,但维护一个 CA 基础设施本身就不是轻松的事——你得管理根证书、中间证书、吊销列表,还要在每台设备上分发根证书。
有没有更省心的方案?
—— 广告 ——
思路:为什么不用公共 CA?
内网服务的痛点在于域名——如果用的是 .internal 或 .local 这样的私有域名,公共 CA(如 Let's Encrypt)确实签发不了证书,因为它们无法验证你对这个域名的控制权。
但换个角度想:如果你有一个真实的公网域名,能不能让内网服务也用上它?
答案是:可以。核心思路是 Split-Horizon DNS(分裂视图 DNS)——对同一个域名,VPN 内的客户端解析到内网 IP,公网请求解析到外网 IP(或者被 WAF 拦截)。这样 Let's Encrypt 就可以通过 HTTP 挑战验证你对域名的控制权,为你签发证书。
听起来有点绕?我们一步步来看。
组件关系
这套方案需要以下几个组件配合:
| 组件 | 角色 |
|---|---|
公网域名(如 example.com) | 用于申请 Let's Encrypt 证书 |
| Split-Horizon DNS | VPN 内网客户端解析到内网 IP,公网解析到外网 IP |
| VPN 的 DNS 解析器(如 NetBird) | 提供自定义 DNS 区域,实现内网解析 |
| ACME 客户端(如 acme.sh) | 自动从 Let's Encrypt 申请和续期证书 |
| 反向代理 + WAF(如 Nginx 绑定到 VPN 接口) | 拒绝非 VPN 来源的公网流量 |
核心逻辑很简单:证书用公网域名签发,但流量只允许 VPN 内网访问。
实战:一步步搭建
第一步:设置 Split-Horizon DNS
以 NetBird 为例(其他 VPN 如 Tailscale、WireGuard + 自建 DNS 同理):
- 在 NetBird 管理后台创建一个自定义 DNS 区域,域名设为你的公网域名(比如
tuxnet.dev) - 将内网服务(如
grafana.tuxnet.dev)映射到内网 IP(如10.0.1.10) - 关键:将服务器本机排除在自定义区域之外,这样服务器在向 Let's Encrypt 发起 HTTP 挑战时,会使用公网 DNS 解析
这样配置之后:
- VPN 内的客户端请求
grafana.tuxnet.dev→ DNS 返回内网 IP10.0.1.10 - 公网请求
grafana.tuxnet.dev→ DNS 返回公网 IP - 服务器本机解析
grafana.tuxnet.dev→ 使用公网 DNS(确保 ACME 挑战走公网)
第二步:签发证书
用 acme.sh 的 standalone 模式签发证书:
acme.sh --issue -d grafana.tuxnet.dev --server letsencrypt --standalone--standalone 模式下,acme.sh 会临时启动一个 HTTP 服务监听 80 端口来完成 Let's Encrypt 的 HTTP-01 挑战。这意味着 Nginx 不需要在公网 80 端口上监听——acme.sh 只在签发期间短暂占用 80 端口。
签发成功后,证书会保存在 /root/.acme.sh/grafana.tuxnet.dev/ 目录下。
第三步:配置 Nginx 反向代理 + WAF
核心配置是将 Nginx 的 listen 指令绑定到 VPN 接口的 IP 上,这样只有 VPN 内的流量才能到达 Nginx:
upstream grafana {
server localhost:3000;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen your-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 your-server.netbird.cloud:443 ssl; 是关键——Nginx 只监听 VPN 网络接口的 443 端口,来自公网的所有请求都会被网络层面拒绝。这相当于在 Nginx 层面实现了一层 WAF(Web 应用防火墙)。
第四步:自动化证书续期
Let's Encrypt 的证书有效期是 90 天,所以需要自动续期。acme.sh 自带 --cron 模式,我们可以把它包装成一个脚本:
#!/bin/bash
main() {
refresh_certs
sync_grafana_certs
reload_nginx
}
refresh_certs() {
# standalone 模式需要绑定 80 端口,给 acme.sh 临时权限
setcap CAP_NET_BIND_SERVICE=+ep /usr/bin/socat1
sudo -u acmesh /home/acmesh/acme.sh/acme.sh --cron
setcap -r /usr/bin/socat1
}
sync_grafana_certs() {
cp /home/acmesh/.acme.sh/grafana.tuxnet.dev/*.crt /etc/ssl/certs/
cp /home/acmesh/.acme.sh/grafana.tuxnet.dev/*.key /etc/ssl/private/
}
reload_nginx() {
nginx -s reload
}
main把这个脚本放到 crontab 里每天执行一次:
0 3 * * * /usr/local/bin/renew-certs.sh这里为什么用 setcap?因为 standalone 模式下 acme.sh 需要绑定 80 端口,但以非 root 用户运行更安全。CAP_NET_BIND_SERVICE=+ep 这条命令给 socat 二进制文件授予了绑定特权端口的权限,而无需整个进程以 root 运行。
进阶:SAN 多域名证书
如果有多个内网服务需要证书,不必为每个服务单独签发一个证书。用 Subject Alternative Names(SAN) 可以一张证书覆盖多个域名:
acme.sh --issue -d internal.tuxnet.dev \
-d grafana.tuxnet.dev \
-d analytics.tuxnet.dev \
-d prometheus.tuxnet.dev \
--server letsencrypt --standalone签发后的证书:
subject=CN=internal.tuxnet.dev
X509v3 Subject Alternative Name:
DNS:analytics.tuxnet.dev, DNS:grafana.tuxnet.dev, DNS:internal.tuxnet.dev
然后在 DNS 层面,为主域名 internal.tuxnet.dev 创建一个 A 记录,其他服务用 CNAME 指向它。同一张证书可以在多个 Nginx server 块中复用。
为什么不直接用 Tailscale/Frizbee 的 HTTPS?
Tailscale 提供了 --https 参数可以自动为节点签发证书,Frizbee 也有类似功能。但它们的问题是:
- 域名不可控:证书域名是
hostname.tailnet-name.ts.net,不是你自己的域名 - 绑定厂商:一旦换了 VPN 方案,所有证书都得重新签发
- 不够灵活:对于多域名、子路径路由等复杂场景支持有限
本文的方案用的是你自己的域名,换 VPN 只是换 DNS 解析器的问题。
这套方案的好处
- 零成本:Let's Encrypt、acme.sh、Nginx 全部免费开源
- 无需手动信任:所有客户端天然信任 Let's Encrypt 的根证书,不像自签名证书那样每台机器都要手动操作
- 双层安全:Split-Horizon DNS + Nginx 绑定 VPN 接口,即使 DNS 配置错误也有第二层防护
- 自动续期:acme.sh cron 自动处理 90 天续期,到期前一个月就开始续期尝试
- 可扩展:SAN 证书可以轻松覆盖多个服务,新增服务只需要加一个 CNAME 记录
注意事项
- acme.sh standalone 模式需要 80 端口短暂可用,确保没有其他服务长期占用
- Nginx 绑定 VPN 接口时,务必确认绑定的 IP 是正确的 VPN 接口 IP(可以用
ip addr show查看) - 如果团队规模大,建议把证书同步和 Nginx reload 做成更健壮的脚本,加入失败告警
- Split-Horizon DNS 的排除规则(服务器本机不走内网解析)一定要配对,否则 ACME 挑战会失败
总结
这套方案的精髓在于用公共 CA 签内网证书,用网络层面做访问控制。它不需要自签名证书、不需要维护私有 CA、不需要手动信任客户端。只要有一个公网域名和一个支持自定义 DNS 的 VPN,就能搭建起一套全自动的 TLS 体系。
原文作者 Jakub Kołodziejczak 总结得好:"Life's good, we have a TLS that just works — no matter if the service is internal or external, no matter if it's 'next day' or 'next year'."
© 2026 四月
原文链接:https://www.aprilzz.com/tutorials/internal-tls-certificates-split-horizon-dns
相关文章
Homebrew 6.0 升级迁移实战指南:掌握 Tap Trust、沙箱机制等关键新特性
Homebrew 6.0.0 正式发布,带来了 Tap Trust 安全机制、Bubblewrap Linux 沙箱、默认内部 JSON API、brew bundle 并行安装等一系列重大更新。这篇教程带你逐一了解新特性、完成安全升级迁移。
PostgreSQL B-Tree 索引深度解析:从原理到 pageinspect 实操
从 B-Tree 数据结构讲起,用 pageinspect 插件直接查看 PostgreSQL 索引的内部结构,理解索引如何工作、如何调优。适合想真正搞懂索引原理的开发者。
2026 年用 AI 开发 App 完整实战指南:从想法到上线的 8 步工作流
结合 Cursor/Claude Code 等 AI 编程工具,从需求分析到应用上线的完整 8 步工作流。适合想做独立产品的开发者。