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