从 Caddy 到 Nginx + acme.sh:通配符证书签发与 80/443 割接实录
场景与目标
一台 Docker 化的 frp 跳板机:家内网服务(博客、NAS、密码管理器等)通过 frp 隧道反连到云上,公网 80/443 原本由 frps 容器直接占用。目标是把 TLS 收口交给 nginx:用一张 *.example.com 通配符证书统一终止,新增子域不再逐个签证书。这套链路里踩的坑比预想多,逐一记录。
第一站:Caddy 的 TLS 透传不可行
最初设想用 Caddy 占 80/443,把非自身站点的流量 TLS 透传给 frps。但标准版 caddy:latest 实测不支持透明 TLS 透传:
caddy validate # tls passthrough(内联或全局 listener_wrappers 写法)均报错
正确等价做法是 Caddy 终止 TLS 后反代 frps(重加密),但这同样需要证书。权衡后决定弃用 Caddy,改用 nginx + acme.sh——nginx 反代语义更可控,acme.sh 的 DNS API 集成也更成熟。
dns-persist-01:新特性别信公告时间表
acme.sh 新支持 --dns-persist 挑战(一次 TXT 记录绑定账户后可反复签发,不用每次改 DNS),我们按要求配好了 _validation-persist TXT 记录,dig 复核值完全一致。然后签发报错:
acme.sh --issue -d example.com -d "*.example.com" --dns-persist --server letsencrypt
# Supported validation types are: dns-01 tls-alpn-01 http-01,
# but you specified: dns-persist-01
取证过程值得复用:
- 查 CA 的 ACME 目录
meta字段——无dns-persist-01端点,无validationMethods; - 带
--debug 2复跑,直接读 ACME 服务端返回的 authorization 响应:通配符域名的 challenges 仅有["dns-01"]。
这是协议级铁证:截至 2026-09,Let’s Encrypt 生产环境未开放 dns-persist-01(公告目标 Q2 2026 已跳票)。ZeroSSL 目录同样没有该挑战、且强制 EAB。结论:TXT 记录「就绪待启用」,等 LE 开放后现有命令即可直接签出,无需再动 DNS。
教训:新 ACME 特性上线与否,以「目录 meta + authorization challenges」为准,公告时间表不可信。
签发坑:AKID 不是 DNSPod 老令牌
改走 DNS API。第一组腾讯云 AKID... 格式凭据配老版 dns_dp(dnsapi.cn,login_token=ID,KEY 数字令牌协议)返回 401——AKID 格式的 SecretId 走的是腾讯云 API 3.0,与老版 DNSPod 令牌是两套体系。用 dns_tencent(dnspod.tencentcloudapi.com)签发成功:
acme.sh --issue -d example.com -d "*.example.com" --dns dns_tencent --server letsencrypt
# 产物:ECC 通配符证书,SAN=*.example.com,example.com,有效期 90 天
割接与踩坑:frps 的 vhost 端口抢占用
割接方案:frps 退掉主机 80/443 映射(仅保留 mTLS 接入端口),nginx 容器占 80:80 / 443:443,其余 *.example.com 反代回 frps 的 HTTPS vhost(重加密并 proxy_ssl_name $host 保留 SNI 路由)。
重建后全线 502。根因:frps 配置里把 vhostHttpPort/vhostHttpsPort 显式钉死成了 1080/1443,而家侧 frpc 的 http/http2 类型代理恰恰注册到这两个端口——nginx 占用 80/443 后 frps 的 vhost 与 frpc 代理抢同一组端口,代理注册失败(port unavailable)。修复:把 vhost 端口改到 8080/8443 让出 1080/1443,重启 frps 后 tcp 代理注册成功,站点恢复 200。
教训:给容器换端口前,先把「谁还在用这组端口」查干净——frp 的 vhost 端口是 frpc http 代理的注册目标,不是单纯的监听项。
nginx 优化与 HTTP/3
http2 on;新语法(1.25+),替代废弃的listen ... http2;- TLS 会话复用:
ssl_session_cache shared:SSL:10m+ssl_session_timeout 1d+ssl_session_tickets off(前向安全友好); - gzip、
TLSv1.2 TLSv1.3+ 现代 ECDHE 套件、server_tokens off; - HTTP/3:官方 nginx 镜像已编译 v3 模块,compose 加
443:443/udp,server 块加listen 443 quic;+http3 on;。nginx 不自动下发 Alt-Svc,需手动add_header Alt-Svc 'h3=":443"; ma=86400'。踩坑:多个 server 块都带reuseport会触发duplicate listen options for 0.0.0.0:443崩溃循环——reuseport 只是多 worker UDP 负载优化,非 HTTP/3 必需,去掉即可。若上游也发 Alt-Svc,记得proxy_hide_header Alt-Svc避免重复冲突。
证书自动续期:容器里的 acme.sh 摸不到 nginx
acme.sh 跑在临时容器里,访问不到 nginx 的证书目录,标准 deploy hook 不可用。改成宿主机级续期脚本:
docker run ... acme.sh --renew --ecc -d example.com -d "*.example.com" \
--env-file tencent.env # 不带 --dns!
# 仅当证书文件实际变化才 cp 进 nginx/certs 并 kill -HUP 重载
# acme.sh 退出码 0(成功) / 2(未到窗口) 均视为正常
一个大坑:--renew 时多带了 --dns dns_tencent,acme.sh 直接重新签发了一张 RSA 证书(而不是续期现有 ECC 证书),白耗签发额度。规则:续期不加 –dns,acme.sh 按现有证书元数据走 ARI/续期窗口。cron 每日错峰跑(避开备份时段),未到窗口自动 no-op,续期后 nginx 自动换证重载。
验证清单
nginx -t无警告;- openssl 验证对外 443:issuer=Let’s Encrypt、SAN 含通配符;
- 各子域 HTTP/2 200、
alt-svc: h3=":443"单发无重复; - http→https 301;
- 续期脚本 dry-run:exit 0、日志 “no cert change”、无多余证书目录。
参考文档
- acme.sh project. acme.sh — An ACME Shell script[EB/OL]. https://github.com/acmesh-official/acme.sh (访问日期: 2026-09-04).
- nginx.org. nginx Module Reference: ngx_http_v3_module[EB/OL]. https://nginx.org/en/docs/http/ngx_http_v3_module.html (访问日期: 2026-09-04).
- IETF. Persistent DNS Challenge for ACME (draft)[EB/OL]. https://datatracker.ietf.org/doc/draft-ietf-acme-dns-account-01/ (访问日期: 2026-09-04).