需求:给网站绑一万个域名,自动生成 HTTPS 证书

先解决标题党的问题

实际需求和标题有些差异,但很难在一个短标题里描述出完整的需求。先把这个标题相关的问题解决好了。

如果是一万个已知的域名,想要给他们批量签证书:例如已经存在一个域名列表(听起来有点灰产)。那么需要做的应该是:

  1. 用脚本批量给他们做好 DNS 解析
    • 比如都解析到 IP w.x.y.z
  2. w.x.y.z 起服务,用来做 HTTP Challenge
    • 启动一个 cert-manager,把一万个域名灌给它,让他开始签证书
  3. 网关监听 443,并挂上 cert-manager 产生的公私钥
    • 取决于网关的功能,如果网关要求写清楚每一个域名,用脚本自动生成一万个路由配置即可

听起来很不优雅,但「又不是不能用.jpg」;毕竟它只是个一次性的任务,能离线完成的任务当然是离线完成更安全。

真实的需求

真实的需求是:

  • xlog 是一个基于 crossbell 链的写作平台
  • 用户可以产生自定义的二级域名如 jeff.xlog.app
  • 并且,用户可以绑定自己的任意域名到自己的主页
    • 例如 x.jeff.wtf
  • 用户绑定好、DNS 指向 xlog 以后,可以自动签发 HTTPS 证书;访问时全程 HTTPS

cert-manager❎

首先还是来看看老办法 cert-manager 。

顺着上面的思路,一个很直接的想法就是:

  1. 用户在 xlog.app 面板操作绑定完成时,把这个想绑定的域名发给 cert-manager,让它开始签发证书
  2. 得到证书以后,修改网关配置并 reload (取决于使用的网关)
  3. 用户来访问时,网关已经拥有了签好的证书,于是直接建立了 HTTPS 链接

除了 业务(xlog)基建(cert-manager) 有一点小小的耦合以外,貌似没有什么大问题。

哪里不对劲?

不对劲的地方在于,用户修改 DNS 解析这一步在哪里?

上面这个方案错误的地方是签发的时机

  • 如果用户还没有解析完成,cert-manager 根本无法通过 HTTP-Challenge
  • cert-manager 怎么知道域名已经解析过来了?
    • 最简单的答案是:如果有一个 Host: 想绑的域名 的请求发到了 xlog 的地址,这时我们认为这是用户的第一次访问,此时已经解析完成
      • (虽然很容易作假,但我们并不会损失什么)

所以签发的时机只能是等到用户来请求。如果等到请求已经到了,我们再去做这些事:

  • 发域名给 cert-manager 签证书
  • 签完证书改配让网关 reload
    • 如果为了高可用,网关数量大于一,则是等所有网关 reload 完成

这就意味着用户的头几个(或者更多)请求一定是失败的。即使用一点小小的优化比如定时循环尝试证书签发,这个时间也不太可控。

为什么只能让网关来发起签证书?

作为对上面的补充说明,可以想象这样一个场景:

  1. 用户在 xlog 面板绑定了自己的域名
  2. 但他一直没有改 DNS 解析
  3. 直到某一天,他突然想起来,于是去做了解析
  4. 解析生效后,他访问网站,此时应该正常建立 HTTPS 链接

如果使用类似「定时尝试签发证书」这样的方案,大量资源被浪费;而且这是一种很明显可以被攻击的漏洞:我只要不断绑定域名,但不做解析,服务器就会有无限的资源被浪费。

所以,触发签发的时机必须是 网关第一次收到这个域名作为 Host 的请求

Traefik

补充说明:

虽然提起自动签 HTTPS 证书的 web server,第一个能想到的通常是 Caddy。但是我们正在使用的网关是 Traefik,理由是:

  1. Traefik 原生自带一个 Kubernetes Ingress Controller,天然支持 k8s
  2. Traefik-Mesh 可以非常方便地完成 k8s 集群内的 Service Mesh
  3. 从开始调研 k8s 网关的那天到写文章的这一天,Caddy 的 Ingress Controller 仍然是 WIP 状态。如果想要在 k8s 集群里使用,要么使用 WIP 版本,要么自己开发 Ingress Controller

防时空穿越的补充说明:写文章时,Traefik 版本号为 v2.8.3

恰好 Traefik 有自动签发证书功能。于是乎先调研一下 Traefik 符不符合这个需求。

Traefik 自动签发证书的设定是这样,启用以后:

  1. 用户写路由规则 (IngressRoute),Traefik 会读取配置 tls.domain 或者是匹配规则的 Host 部分
  2. Traefik 根据 IngressRoute 自动尝试签发、续期等

按照上面的设定,我们要做的应该是:

  1. 用户在 xlog 绑定好域名后,创建一个 IngressRoute
  2. 等等,好像不太对!这个逻辑不就和发给 cert-manager 一样了么?

那我们换一种:

  • 我们编写一个 Traefik Middleware,使得某个域名解析完成第一次访问到 Traefik 时,自动创建 IngressRoute 来让它签发证书

虽然有点别扭,弯弯绕绕有点多,但功能上好像挺完美的;只有一些(或许)可以忍受的小缺点:

  1. 需要编写一个 Middleware,并且运行一个服务
    • 逻辑大概是:验证域名有没有绑过 -> 创建 IngressRoute
    • 如果想用网关安全的自动签发证书,这样的逻辑应该是不可避免的
  2. 至少第一次来访问仍然是失败或者非 HTTPS 的

哪里不对劲?

不对劲就在于 Traefik 的自动签证书功能可以看做一个玩具:

  • 在这个如果没有逐条看都发现不了的文档里,说明了 Traefik 2.0 被设定为一个完全无状态的服务,多个 Traefik 之间并不共享什么东西。因此,如果要管理证书,它必须是一个单点(或者你可以花钱买企业版)。
    • 这段文字并不在 TLS / Let's Encrypt / Kubernetes and Let's Encrypt 这些标题下面,而是在介绍 IngressRoute 的文档里
    • 这也是 Traefik 的 ratelimit 相当难算的原因,你必须知道集群里现在运行了几个 Traefik,然后靠概率模拟计算

我们为什么要花钱买一个很别扭的方案?

Caddy✅️

最后,还得是 Caddy。

它有两种自动签发证书的逻辑:

  1. 第一种是常用的,明确域名的模式:
    1. 必须先把域名解析完成
    2. 再启动 caddy,配置文件里要写明监听的域名
    3. 启动后,caddy 会立即签好证书,用户来访问直接就是可用的
  2. 第二种按需模式:
    1. 只要启用,来一个域名就签一个

我们需要第二种,它的缺点是:

  1. 第一次访问会比较慢(要签证书)
  2. 有安全风险,很容易成为被攻击的入口
    • 因此 caddy 要求,生产环境应该提供一个 ask 配置,询问一个 HTTP 接口,得到应不应该签发的响应
  3. 默认情况下,caddy (当前 v2.5.2) 需要给每个实例配置一个 data 的持久化目录,也就意味着默认存储配置下它也必须是个单点
    • 好在有第三方的存储插件可以让多个 caddy 实例读写同一处存储

为了解决问题 2,我们要写一个简单的 HTTP 服务用来校验域名(是否解析好、是否绑定过等);为了解决问题 3,我们必须自己编译 caddy:

1
2
3
# caddy-tlsredis 这个插件会把数据放在 redis 里,让多个 caddy 实例共享以达到高可用
# 可以在这里直接用打包好的镜像 https://github.com/sljeff/caddy-tlsredis-docker
xcaddy build --with github.com/gamalan/caddy-tlsredis

然后我们的 Caddyfile 会是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
storage redis {
# 存储改为 redis,这里可以为空,然后通过环境变量来覆盖配置
# 详见 https://github.com/gamalan/caddy-tlsredis
}

on_demand_tls {
# 这里是我们写好的验证服务,可以和每份 caddy 一起部署
ask http://localhost:5000/
}
}

:80, :443 {
tls {
# 自动按需签发
on_demand
}

# 这里是实际的上游服务
reverse_proxy 127.0.0.1:3000
}

这样就实现了来一个域名签一个证书;缺点是第一次来会比较慢。

最后一点小优化

回顾一下我们的需求,里面有一条:

  • 用户可以产生自定义的二级域名如 jeff.xlog.app

这就意味着所有用户都会产生一个二级域名;如果我们给每个二级域名都单独签发一个证书,好像浪费有点多。

更合理的做法是给 *.xlog.app 签发泛域名证书。但是泛域名证书需要 DNS-challenge(证明整个域名的所有权),因此我们要更新 caddy 配置:

1
2
# 需要把 xlog.app 的 dns 服务商的插件也加进去编译,以便签发和续期泛域名证书
xcaddy build --with github.com/gamalan/caddy-tlsredis --with github.com/caddy-dns/cloudflare
1
2
3
4
5
6
7
8
9
# Caddyfile 中间加这一段匹配,让其余的走到 :80, :443

xlog.app, *.xlog.app {
tls {
dns cloudflare {env.CF_API_TOKEN}
}

reverse_proxy 127.0.0.1:3000
}

最后,大功告成。