banner
kindjeff

kindjeff

jike
twitter_id
misskey

需求:給網站綁一萬個域名,自動生成 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:

# 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
}

最後,大功告成。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。