1. 内网服务为什么老被HTTPS卡脖子
你给内网服务配过 HTTPS 吗?我猜十有八九遇到过这样的场景:内网部署了几个 Web 服务,浏览器一打开,地址栏那个“不安全”标记怎么都消不掉。自己用 OpenSSL 签一张自签证书吧,三分钟能搞定,可每台客户端都要手动导入一次证书,过期了又要重新导;后来换成脚本自动续签,脚本本身又成了新的维护负担,到了日期没跑成功,线上直接告警。
Caddy 这个 Web 服务器在自动化方面做得比较顺:你只要在 Caddyfile 里写明站点域名和转发规则,它启动时会自动申请证书、自动续期、自动重载。但 Caddy 的自动 HTTPS 默认是给公网域名设计的,走的是 Let's Encrypt 这类公共 CA。内网域名怎么办?这是不少后端和运维同学卡住的地方。这篇文章就从 Caddy 的配置代码讲起,把内部域名证书签发这条链路由头到尾拆一遍:Caddy 收到你的配置后,到底怎么判断该用哪个 CA、怎么发起证书申请、验证怎么做、证书回来后存在哪、什么时候续期。适合搞内网服务、做网关、维护 Kubernetes 集群、或者被自签证书折腾过的人。
2. 先搞懂内部域名证书签发的基本逻辑
2.1 公开CA为什么不管内网域名
要理解内部域名证书为什么难,得先看 公开 CA 的签发机制。以 Let's Encrypt 为代表的 CA 遵循 ACME 协议,签发证书前必须验证你确实拥有这个域名。验证方式主要有三种:HTTP-01 要求 CA 能通过公网访问你 80 端口上的一个临时文件;TLS-ALPN-01 要求 CA 能通过 443 端口完成一次特殊握手;DNS-01 要求你能在域名的 DNS 记录里添加指定的 TXT 记录。
这三种方式的前提,都是域名能被 CA 从外部访问或者能操作公网 DNS。内网域名基本做不到:它们要么是.local、.internal这样的特殊后缀,要么解析出来是192.168.x.x、10.x.x.x这类私有 IP。CA 访问不了你的内网,自然没法验证控制权。即使你技术上能用 DNS-01 验证,很多企业也不愿意把内部域名登记到公开的证书透明度日志里,这等于把内网拓扑暴露给所有人看。
所以内网证书这条路只能走内部 CA。内部 CA 的根证书由你自己掌控,签发流程同样可以走 ACME,只是验证方从公网 CA 换成了你自己部署的 CA 服务。
2.2 Caddy的证书签发决策链
Caddy 管理证书用的是底层的 certmagic 库,这套库有一套自己的“域名分类”逻辑。Caddy 启动或重新加载配置时,会收集所有站点的主机名,然后逐个判断:这个域名适不适合申请公网证书。判断标准大致是:如果主机名是 localhost、IP 地址、或者解析出来的 IP 落在私有地址段,就认为它不适合用公共 CA,默认会用 Caddy 内置的本地 CA 直接签发一张证书。
这个设计本意是好的:内网 IP 地址和 localhost 直接给你签一张本地证书,省去配置麻烦。但很多人踩坑也踩在这——明明内网域名已经解析到私有 IP,自己配了个自定义 CA 的地址,结果一看证书还是 Caddy 内置 CA 签的,一脸懵。原因就是你没显式覆盖 Caddy 的默认签发器。
要改变默认行为,有两个途径:一种是在站点的tls指令里指定ca,指向你自己的内部 CA 的 ACME 目录地址;另一种是用全局配置统一指定。这样 Caddy 就不再走“内网域名自动签本地证书”的默认路径,而是把所有域名都交给指定的 CA 去签发。理解了 Caddy 这个决策链,后面的配置就不会觉得“玄学”了。
2.3 两条内部证书路线的权衡
接触过 Caddy 的人应该知道,它自己也能当 CA 用:你只要在站点配置里写一句tls internal,Caddy 就会生成本地根证书和中间证书,然后签出站点证书。这算一条路线,好处是零配置,几十秒就能让内网服务跑上 TLS。坏处也很明显:每台 Caddy 实例的根证书都是独立的,客户端要访问 A 机器得信任 A 的根,访问 B 机器又得信任 B 的根,管理面一旦变大就是灾难。它更适合本地开发环境,或者临时验证某个功能。
另一条路线是部署一个独立的内部 CA,比如 Smallstep 出品的 Step CA,或者用 Windows Server 的 AD 证书服务。所有服务器上的 Caddy 都对接这一个 CA,客户端只要信任这个 CA 的根证书,整张内网就全部信任了。证书的签发、吊销、审计都能集中管理,这才是生产内网该有的样子。
我重点讲后一条路线。原因很简单:它才能体现“从配置代码到证书”的完整闭环——Caddyfile 里写一个 CA 地址,证书申请、验证、落盘、续期全部自动化,中间不需要手动跑任何 openssl 命令。
3. 从Caddy配置代码到证书落盘:完整机制拆解
3.1 配置代码里到底写的是什么
一张典型的内网站点配置长这样:
{ tls { ca_root /etc/caddy/internal_ca.crt ca https://ca.internal.example.com:8443/acme/acme/directory } } app.internal.example.com { reverse_proxy 127.0.0.1:8080 }第一眼看上去不复杂,但每一行背后都有讲究。全局tls块里的ca_root,是用来指定内部 CA 根证书在 Caddy 本机上的路径。为什么必须写这个?因为 Caddy 的 ACME 客户端要发起对 CA 目录地址的 HTTPS 请求,而 Step CA 默认用的就是自签证书链,系统信任库根本不认识它。不指定ca_root,Caddy 连接 CA 目录这一步就会报“证书校验失败”。
ca那一行是核心,它是内部 CA 对外暴露的 ACME 目录端点。Step CA 的地址格式一般是https://CA主机:8443/acme/provisioner名称/directory。Caddy 拿到这个地址后,所有 ACME 交互都会打到这个端点,而不是默认的 Let's Encrypt。
站点块里的app.internal.example.com不只是反向代理的入口,它同时是一个“证书申请单”。Caddy 解析到这个主机名,就会为它去申请证书,并自动把证书和这个站点的 HTTPS 服务绑定起来。如果某个域名你只想代理但不想签证书,可以用tls off显式关掉;反过来,如果你想签证书但不做代理,也可以配合其他指令实现。
3.2 ACME申请在内部CA场景下是如何跑通的
当 Caddy 首次启动并且本地没有这个域名的证书时,它会走一次完整的 ACME 申请流程。这个流程一共分五步。
第一步,Caddy 向ca指定的目录地址发一个 GET 请求,拿到 CA 支持的newNonce、newAccount、newOrder等端点。第二步,如果 Caddy 本地还没有 ACME 账号私钥,它会生成一对新密钥,然后向newAccount注册一个新账号。第三步,Caddy 构造一个证书订单,订单里包含你站点的主机名,提交给newOrder。CA 收到订单后,会返回一个订单对象,里面带着需要完成授权的 authorization 列表。
第四步是关键,也就是“证明域名是你的”。内网 CA 默认支持的验证方式和公网 CA 一样,HTTP-01 用得最多。Caddy 会在自己的 80 端口上开启一个临时响应,内容是一串随机 token;Step CA 会回头通过内网 DNS 解析这个域名,然后访问 Caddy 的 80 端口来核对 token。这个环节对网络拓扑有一个硬性要求:CA 机器必须能访问到 Caddy 所在机器的 80 端口。如果内网防火墙把 80 端口挡了,或者 CA 无法 DNS 解析这个域名,这步就会失败。
第五步,授权通过后,Caddy 生成站点证书私钥,构造一个包含所有 SAN 域的 CSR,提交给订单的 finalize 端点。CA 验签通过,签发正式证书并发回来。Caddy 把证书和私钥写入本地存储。到这一步,你打开浏览器访问内网域名时,就能看到一张由你内部 CA 签发的合法证书了。
3.3 证书回来之后Caddy做了什么
很多人以为 Caddy 拿到证书就算完事了,其实后面还有不少幕后工作。证书和私钥会按域名、按签发器保存到数据目录里。Caddy 在内存中维护一张证书映射表,客户端发起 HTTPS 请求时,Caddy 根据 TLS 握手中的 SNI 字段挑选对应证书返回。
同时,Caddy 会在后台启动一个定时任务,周期性检查所有托管证书的剩余有效期。一旦发现证书快过期,就自动重新走一遍 ACME 流程。这个续期检查是独立的,不依赖你的配置重载。哪怕你改了 Caddyfile 里某个站点规则,只要域名没变,证书就不会重新签发,已有的证书继续用。
这个设计给运维带来了一个很舒服的体验:证书的生命周期和配置变更解耦。你不会因为改一行代理规则就触发一次全量续签,也不会因为caddy reload就导致内网服务中断。等 Caddy 自己判断该续了,它再悄悄去完成整个流程。
4. 实操:用内部CA给Caddy签发内网域名证书
4.1 初始化内部CA
实操阶段我用 Step CA 来做示例。选择它不是因为它是唯一选择,而是它的 ACME 支持最省心,一个命令就能装好。
先在 CA 服务器上安装并初始化。初始化命令大致如下:
step ca init \ --name="企业内网CA" \ --dns=ca.internal.example.com \ --address=:8443 \ --password-file=ca_password.txt--name只是显示名称;--dns指定其他机器访问这个 CA 时用的域名,必须能被内网 DNS 正确解析;--address决定 CA 监听哪个端口。初始化完成后,根证书在certs/root_ca.crt,中间证书、CA 私钥都在对应目录下。ca_password.txt里存的是保护私钥的密码,这个文件要放好,千万别丢。
接下来确认 ACEM 类型 provisioner 是否存在。早期版本初始化时可能不会默认添加,现在一般会带,稳妥起见手动加一遍也没问题:
step ca provisioner add acme --type=ACME添加之后,CA 的 ACME 目录地址就是https://ca.internal.example.com:8443/acme/acme/directory,后面的acme就是 provisioner 的名称。
4.2 配置Caddy并完成签发
在 Caddy 机器上,把根证书放到指定位置,然后写配置。我用上一节的 Caddyfile 配置,启动前先确认三件事:
一是 Caddy 机器能解析app.internal.example.com这个域名,并且解析结果是内网 IP。二是 Caddy 机器能访问 CA 的 8443 端口。三是 CA 机器能访问 Caddy 机器的 80 或 443 端口,用于 HTTP-01 验证。这三个条件缺一个,签发就会卡住。
启动 Caddy 后,观察日志。正常情况下会出现类似“证书获取成功”的日志,然后站点直接以 HTTPS 形式提供服务。验证证书最直接的方式是看这条命令的输出:
openssl s_client -connect app.internal.example.com:443 -showcerts输出里 Issuer 一栏应该显示你刚创建的“企业内网CA”名称,证书链也会包含 CA 签发的中间证书。如果 Issuer 是 PUBLIC 的 Let's Encrypt,说明你配置没生效;如果 Issuer 是 Caddy 内置本地 CA,说明没有指定ca或者配置没重载。
有时候你会发现 80 端口已经被其他服务占了,导致 HTTP-01 验证做不了。这时可以在tls块里改用 TLS-ALPN-01,让验证跑在 443 端口上;或者给 Caddy 配 DNS-01 验证。不过后一种方案需要 Caddy 能操作内网 DNS 服务器,配置成本高不少,一般用不到。
4.3 让客户端信任内部CA的根证书
服务端签发完证书只是第一步。客户端访问时,浏览器会检查证书链的根是否在系统信任库里,不在就继续显示不安全。所以必须把root_ca.crt分发到所有客户端。
Linux 上是这样:
sudo cp root_ca.crt /usr/local/share/ca-certificates/internal_ca.crt sudo update-ca-certificatesmacOS 上可以用命令导入:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain root_ca.crtWindows 上最方便的是:
certutil -addstore -f Root root_ca.crt装完之后用curl -v https://app.internal.example.com验证,输出里不再出现“self-signed”“unable to verify”这类关键词,就说明整个链路通了。这里有一个容易被忽略的细节:根证书分发的范围决定了你的 HTTPS 范围。如果只装在公司统一管理的电脑上,员工自己的电脑还是会弹警告;用移动设备访问内网服务的话,也得把描述文件装到手机或平板上。
5. 续期、存储与高可用运维
5.1 有效期的坑和自动续期规则
Step CA 签发的 ACME 证书默认有效期是 24 小时,这是很多人第一次上手会吓一跳的地方。其实不用慌,Caddy 的续期机制能兜住,前提是 Caddy 和 CA 都在线。certmagic 的默认逻辑是证书剩余有效期低于总时长的三分之一左右时触发续期,24 小时证书大概每 8 小时会续一次,对 CA 来说压力也不大。
真正要防的是“Caddy 停机时间超过证书有效期”。比如 Caddy 停了一周,期间的证书已经过期,重启后它会发现本地证书不可用,自动重新走一遍 ACME 流程,把新证书签下来。这要求 CA 在 Caddy 重启时在线。如果内网 CA 是单点且跟着业务一起关机了,等 Caddy 先起来、CA 还起不来,那一小段时间内 HTTPS 服务就是断的。
所以我建议在 Step CA 侧把 ACME 签发的证书有效期调长一点。大企业内网用 90 天证书是常见选择,既保留自动续期的优势,又不至于对 CA 的可用性要求苛刻到分钟级。
5.2 证书存储结构与多实例部署
证书文件的位置取决于 Caddy 的数据目录。Linux 下用官方包安装时,默认数据目录是/var/lib/caddy,证书路径大致是.local/share/caddy/certificates/<签发器目录>/<域名>/<域名>.crt。如果你用二进制方式跑在普通用户下,可能在~/.local/share/caddy下面。
单实例场景下不用关心目录结构,Caddy 自己管好就行。多实例做负载均衡的时候就有讲究了:默认情况下,每台 Caddy 各自维护自己的证书,这没问题,因为 ABC 三个实例各自和 CA 签一张同域名的证书,客户端任意访问都能完成握手。缺点是证书数量翻倍,CA 上的订单记录也会多三倍。
如果想统一管理,可以在全局配置里把存储指向共享盘,比如 NFS 或者分布式存储:
{ storage file { root /data/caddy } }共享存储的注意点是并发安全。多个 Caddy 实例同时续期同一域名证书时,必须保证只有一个实例真正发起 ACME 请求,其他实例等结果。certmagic 的存储抽象本身带锁,只要用的是它支持的存储类型,锁机制是可靠的。
5.3 容器和Kubernetes中的实践
容器化环境下,最重要的是把数据目录做成持久化卷。Caddy 容器如果跑在普通 Docker 里,至少要把证书对应的数据目录挂出来,否则容器重建一次证书就重新签一次,CA 那边会留一堆无效订单。
Kubernetes 里把 Caddy 当 Ingress 用是另一个常见玩法。这时可以在全局配置里指定存储位置和 CA 参数:
{ storage file { root /data/caddy } tls { ca_root /config/ca.crt ca https://ca.internal.example.com:8443/acme/acme/directory } } *.internal.example.com { reverse_proxy backend-service:8080 }要注意的是,K8s 中 Pod 重建后,如果 PVC 还在,Caddy 会直接复用旧证书,不会重复签发。这其实是理想行为。如果每个 Pod 都各签一张,证书数量会失控,而且 Ingress 场景下多个 Pod 同时持有同域名证书也不利于证书会话复用。所以生产集群里务必给 Caddy 配共享 PVC 或使用支持分布式锁的存储后端。
6. 高频问题与排查思路
6.1 明明配置了CA,证书还是没签出来
Caddy 日志里最常见的几类报错,原因其实很集中。我整理过一张排查表:
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 连接 CA 目录超时 | Caddy 访问不了 CA 的 8443 端口 | 检查内网路由和防火墙 |
| 证书校验失败 | 没配ca_root或根证书路径写错了 | 在全局tls块里配ca_root |
| authorization 一直失败 | HTTP-01 验证时 CA 访问不到 Caddy | 检查 80 端口放行和 DNS 解析 |
| 404 或 directory not found | ACME 目录地址写错 provisioner 名称 | 确认/acme/acme/directory拼写 |
| 证书签出来了但不是预期 CA | 配置未重载或命中了默认内部 CA | 确认tls块生效,执行caddy reload |
排查时先看 Caddy 日志,再通过curl手动访问一下 CA 的目录地址,基本能定位问题。目录地址可用但不代表 ACME 流程能走通,后面那几个环节才是真正的坑。
6.2 客户端不信任证书
服务端明明显示证书正常,客户端却报证书无效,绝大多数情况下是根证书没装到客户端。这个可以通过 openssl 验证来确认:
openssl verify -CAfile root_ca.crt -untrusted intermediate.crt server.crt如果验证失败,优先怀疑证书链不完整。Caddy 一般会把证书和中间证书拼在一起发给客户端,但如果你的内部 CA 有复杂的中间层级,拉取完整链可能失败。还有一种情况是客户端机器时间不对,TLS 握手会直接因为证书有效期校验不通过而失败,这个问题在刚装好的虚拟机里尤其常见。
6.3 通配符证书和SAN证书的取舍
内网服务经常想配*.internal.example.com这样的通配符证书。先说结论:Caddy 默认不是为通配符设计的,除非你给它配 DNS-01 验证插件,否则它很难向内部 CA 申请通配符证书。因为通配符没法用 HTTP-01 验证,必须证明你控制整个域名,唯一的方式就是 DNS TXT 记录。
如果你的内网 DNS 恰好有 API 可以动态添加记录,那就配一个dns插件,让 Caddy 在 ACME 验证前自动添加 TXT 记录,验证完再删掉。这个方案可行,但我一般不建议一上来就上通配符。更省事的办法是同一 server 块里写多个域名,Caddy 会自动把它们合并进一张 SAN 证书:
app.internal.example.com, api.internal.example.com, admin.internal.example.com { reverse_proxy 127.0.0.1:8080 }一张证书带三个 SAN,覆盖了绝大多数内网场景。证书数量少,续期只有一条链路,排查起来也简单。
6.4 容易踩的几个小坑
第一个坑是根证书的私钥备份。根证书可以随便分发,但根私钥一旦丢失,整个内网证书体系就废了,所有客户端要重新信任新根。我见过有人把 CA 服务器的私钥跟着系统盘一起格式化掉的,后面全部重来一遍,教训深刻。
第二个坑是 Step CA 的地址规划。你初始化时指定的--dns是什么,之后所有 Caddy 都会用这个地址连 CA。如果这个主机名是某个人的电脑名而不是一个稳定的 CNAME,后面换机器就要全局改配置。最好在规划阶段就给 CA 一个独立的内网域名,别图省事用 IP 或者短主机名。
第三个坑是 Caddy 重载配置导致瞬间断连。虽然证书不重新签发,但重载瞬间 Caddy 会重新监听套接字,如果配置里改了敏感参数,可能会有一两秒的连接中断。内网服务一般无所谓,但如果你是给支付接口做网关,这个细节要提前评估。
我在实际处置中的体验
从维护一堆自签脚本切换到这个方案之后,最直观的感受是证书这件事终于不用人惦记了。原来用 Nginx 加定时脚本,每次续期都有概率出岔子,要么脚本权限不对,要么证书链没拼全,告警群里三天两头响。Caddy 对接内部 CA 后,证书申请、续期、落盘都是它自己在后台推进,我的工作从“救火”变成了“偶尔看一眼日志”。
还有一点想提醒所有人:把内部 CA 的根证书和私钥当成核心资产对待。根证书要备份三份、放在不同的人手里,私钥的密码要写进公司资产清单。证书体系一旦建立,它就是整个内网安全信任的基石,这块的容错空间比你想的要小得多。