教程·阅读约 3 分钟·
内网服务 TLS 证书最佳实践:用 Split-Horizon DNS 告别自签名证书

内网服务 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 DNSVPN 内网客户端解析到内网 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 同理):

  1. 在 NetBird 管理后台创建一个自定义 DNS 区域,域名设为你的公网域名(比如 tuxnet.dev
  2. 将内网服务(如 grafana.tuxnet.dev)映射到内网 IP(如 10.0.1.10
  3. 关键:将服务器本机排除在自定义区域之外,这样服务器在向 Let's Encrypt 发起 HTTP 挑战时,会使用公网 DNS 解析

这样配置之后:

  • VPN 内的客户端请求 grafana.tuxnet.dev → DNS 返回内网 IP 10.0.1.10
  • 公网请求 grafana.tuxnet.dev → DNS 返回公网 IP
  • 服务器本机解析 grafana.tuxnet.dev → 使用公网 DNS(确保 ACME 挑战走公网)

第二步:签发证书

用 acme.sh 的 standalone 模式签发证书:

code
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:

code
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 模式,我们可以把它包装成一个脚本:

code
#!/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 里每天执行一次:

code
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) 可以一张证书覆盖多个域名:

code
acme.sh --issue -d internal.tuxnet.dev \
  -d grafana.tuxnet.dev \
  -d analytics.tuxnet.dev \
  -d prometheus.tuxnet.dev \
  --server letsencrypt --standalone

签发后的证书:

code
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 解析器的问题。

这套方案的好处

  1. 零成本:Let's Encrypt、acme.sh、Nginx 全部免费开源
  2. 无需手动信任:所有客户端天然信任 Let's Encrypt 的根证书,不像自签名证书那样每台机器都要手动操作
  3. 双层安全:Split-Horizon DNS + Nginx 绑定 VPN 接口,即使 DNS 配置错误也有第二层防护
  4. 自动续期:acme.sh cron 自动处理 90 天续期,到期前一个月就开始续期尝试
  5. 可扩展: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'."

分享到
微博Twitter

© 2026 四月

原文链接:https://www.aprilzz.com/tutorials/internal-tls-certificates-split-horizon-dns