先解决标题党的问题#
实际需求和标题有些差异,但很难在一个短标题里描述出完整的需求。先把这个标题相关的问题解决好了。
如果是一万个已知的域名,想要给他们批量签证书:例如已经存在一个域名列表(听起来有点灰产)。那么需要做的应该是:
- 用脚本批量给他们做好 DNS 解析
- 比如都解析到 IP
w.x.y.z
- 比如都解析到 IP
- 在
w.x.y.z
起服务,用来做 HTTP Challenge- 启动一个 cert-manager,把一万个域名灌给它,让他开始签证书
- 网关监听 443,并挂上 cert-manager 产生的公私钥
- 取决于网关的功能,如果网关要求写清楚每一个域名,用脚本自动生成一万个路由配置即可
听起来很不优雅,但「又不是不能用.jpg」;毕竟它只是个一次性的任务,能离线完成的任务当然是离线完成更安全。
真实的需求#
真实的需求是:
- xlog 是一个基于 crossbell 链的写作平台
- 用户可以产生自定义的二级域名如
jeff.xlog.app
- 并且,用户可以绑定自己的任意域名到自己的主页
- 例如
x.jeff.wtf
- 例如
- 用户绑定好、DNS 指向 xlog 以后,可以自动签发 HTTPS 证书;访问时全程 HTTPS
cert-manager❎#
首先还是来看看老办法 cert-manager 。
顺着上面的思路,一个很直接的想法就是:
- 用户在 xlog.app 面板操作绑定完成时,把这个想绑定的域名发给 cert-manager,让它开始签发证书
- 得到证书以后,修改网关配置并 reload (取决于使用的网关)
- 用户来访问时,网关已经拥有了签好的证书,于是直接建立了 HTTPS 链接
除了 业务(xlog) 和 基建(cert-manager) 有一点小小的耦合以外,貌似没有什么大问题。
哪里不对劲?#
不对劲的地方在于,用户修改 DNS 解析这一步在哪里?
上面这个方案错误的地方是签发的时机:
- 如果用户还没有解析完成,cert-manager 根本无法通过 HTTP-Challenge
- cert-manager 怎么知道域名已经解析过来了?
- 最简单的答案是:如果有一个
Host: 想绑的域名
的请求发到了 xlog 的地址,这时我们认为这是用户的第一次访问,此时已经解析完成- (虽然很容易作假,但我们并不会损失什么)
- 最简单的答案是:如果有一个
所以签发的时机只能是等到用户来请求。如果等到请求已经到了,我们再去做这些事:
- 发域名给 cert-manager 签证书
- 签完证书改配让网关 reload
- 如果为了高可用,网关数量大于一,则是等所有网关 reload 完成
这就意味着用户的头几个(或者更多)请求一定是失败的。即使用一点小小的优化比如定时循环尝试证书签发,这个时间也不太可控。
为什么只能让网关来发起签证书?#
作为对上面的补充说明,可以想象这样一个场景:
- 用户在 xlog 面板绑定了自己的域名
- 但他一直没有改 DNS 解析
- 直到某一天,他突然想起来,于是去做了解析
- 解析生效后,他访问网站,此时应该正常建立 HTTPS 链接
如果使用类似「定时尝试签发证书」这样的方案,大量资源被浪费;而且这是一种很明显可以被攻击的漏洞:我只要不断绑定域名,但不做解析,服务器就会有无限的资源被浪费。
所以,触发签发的时机必须是 网关第一次收到这个域名作为 Host 的请求。
Traefik❎#
补充说明:
虽然提起自动签 HTTPS 证书的 web server,第一个能想到的通常是 Caddy。但是我们正在使用的网关是 Traefik,理由是:
- Traefik 原生自带一个 Kubernetes Ingress Controller,天然支持 k8s
- Traefik-Mesh 可以非常方便地完成 k8s 集群内的 Service Mesh
- 从开始调研 k8s 网关的那天到写文章的这一天,Caddy 的 Ingress Controller 仍然是 WIP 状态。如果想要在 k8s 集群里使用,要么使用 WIP 版本,要么自己开发 Ingress Controller
防时空穿越的补充说明:写文章时,Traefik 版本号为 v2.8.3
恰好 Traefik 有自动签发证书功能。于是乎先调研一下 Traefik 符不符合这个需求。
Traefik 自动签发证书的设定是这样,启用以后:
- 用户写路由规则 (IngressRoute),Traefik 会读取配置
tls.domain
或者是匹配规则的Host
部分 - Traefik 根据 IngressRoute 自动尝试签发、续期等
按照上面的设定,我们要做的应该是:
- 用户在 xlog 绑定好域名后,创建一个 IngressRoute
- 等等,好像不太对!这个逻辑不就和发给 cert-manager 一样了么?
那我们换一种:
- 我们编写一个 Traefik Middleware,使得某个域名解析完成第一次访问到 Traefik 时,自动创建 IngressRoute 来让它签发证书
虽然有点别扭,弯弯绕绕有点多,但功能上好像挺完美的;只有一些(或许)可以忍受的小缺点:
- 需要编写一个 Middleware,并且运行一个服务
- 逻辑大概是:验证域名有没有绑过 -> 创建 IngressRoute
- 如果想用网关安全的自动签发证书,这样的逻辑应该是不可避免的
- 至少第一次来访问仍然是失败或者非 HTTPS 的
哪里不对劲?#
不对劲就在于 Traefik 的自动签证书功能可以看做一个玩具:
- 在这个如果没有逐条看都发现不了的文档里,说明了 Traefik 2.0 被设定为一个完全无状态的服务,多个 Traefik 之间并不共享什么东西。因此,如果要管理证书,它必须是一个单点(或者你可以花钱买企业版)。
- 这段文字并不在
TLS
/Let's Encrypt
/Kubernetes and Let's Encrypt
这些标题下面,而是在介绍 IngressRoute 的文档里 - 这也是 Traefik 的 ratelimit 相当难算的原因,你必须知道集群里现在运行了几个 Traefik,然后靠概率模拟计算
- 这段文字并不在
我们为什么要花钱买一个很别扭的方案?
Caddy✅️#
最后,还得是 Caddy。
它有两种自动签发证书的逻辑:
- 第一种是常用的,明确域名的模式:
- 必须先把域名解析完成
- 再启动 caddy,配置文件里要写明监听的域名
- 启动后,caddy 会立即签好证书,用户来访问直接就是可用的
- 第二种按需模式:
- 只要启用,来一个域名就签一个
我们需要第二种,它的缺点是:
- 第一次访问会比较慢(要签证书)
- 有安全风险,很容易成为被攻击的入口
- 因此 caddy 要求,生产环境应该提供一个
ask
配置,询问一个 HTTP 接口,得到应不应该签发的响应
- 因此 caddy 要求,生产环境应该提供一个
- 默认情况下,caddy (当前 v2.5.2) 需要给每个实例配置一个 data 的持久化目录,也就意味着默认存储配置下它也必须是个单点
- 好在有第三方的存储插件可以让多个 caddy 实例读写同一处存储
为了解决问题 2,我们要写一个简单的 HTTP 服务用来校验域名(是否解析好、是否绑定过等);为了解决问题 3,我们必须自己编译 caddy:
# caddy-tlsredis 这个插件会把数据放在 redis 里,让多个 caddy 实例共享以达到高可用
# 可以在这里直接用打包好的镜像 https://github.com/sljeff/caddy-tlsredis-docker
xcaddy build --with github.com/gamalan/caddy-tlsredis
然后我们的 Caddyfile 会是这样
{
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 配置:
# 需要把 xlog.app 的 dns 服务商的插件也加进去编译,以便签发和续期泛域名证书
xcaddy build --with github.com/gamalan/caddy-tlsredis --with github.com/caddy-dns/cloudflare
# Caddyfile 中间加这一段匹配,让其余的走到 :80, :443
xlog.app, *.xlog.app {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy 127.0.0.1:3000
}
最后,大功告成。