先解決標題党的問題#
實際需求和標題有些差異,但很難在一個短標題裡描述出完整的需求。先把這個標題相關的問題解決好了。
如果是一萬個已知的域名,想要給他們批量簽證書:例如已經存在一個域名列表(聽起來有點灰產)。那麼需要做的應該是:
- 用腳本批量給他們做好 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
}
最後,大功告成。