编程

从 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”、无多余证书目录。

参考文档

  1. acme.sh project. acme.sh — An ACME Shell script[EB/OL]. https://github.com/acmesh-official/acme.sh (访问日期: 2026-09-04).
  2. 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).
  3. IETF. Persistent DNS Challenge for ACME (draft)[EB/OL]. https://datatracker.ietf.org/doc/draft-ietf-acme-dns-account-01/ (访问日期: 2026-09-04).

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注