跳轉到

DNS-over-HTTPS (DoH)

ShadowDNS 在 /dns-query 提供一個 RFC 8484 的 DNS-over-HTTPS 端點,重用與 UDP/TCP listener 相同的權威查詢路徑。其用途偏向維運:讓操作者能透過標準 HTTPS(TCP/443)驗證 zone 紀錄——例如穿過只放行 TCP/443 的防火牆或 middlebox——而不必開放 port 53。DoH 查詢會先被解碼,交給 UDP/TCP 路徑使用的同一個 handler,再把 wire-format 答案透過 HTTPS 回傳。

Warning

ShadowDNS DoH 是權威(AUTHORITATIVE)且非遞迴(NON-RECURSIVE)的。 它只回答 ShadowDNS 所託管的 zone;任何 out-of-zone 查詢都回 REFUSED。它不是通用的遞迴 DoH resolver——請不要把瀏覽器或用戶端裝置指向它並期待它做公共名稱解析。它的存在只是為了透過 HTTPS 驗證 ShadowDNS 自身的權威紀錄,僅此而已。


啟用方式

DoH 完全透過 shadowdns.yaml 中的 doh: 區段設定。當此區段不存在時,不會啟動任何 DoH 伺服器,且二進位檔的行為與沒有此功能的版本完全相同。

必填欄位如下:

欄位 用途
listen DoH HTTPS 服務綁定的位址(TCP/443)
acme.directory_url ACME directory 端點(例如 https://acme-v02.api.letsencrypt.org/directory
acme.ip 簽發憑證所對應的公開 IP
acme.http01_listen ACME HTTP-01 challenge 回應器綁定的位址(TCP/80)
acme.account_key_file 持久化 ACME 帳號私鑰的絕對路徑(見 ACME 帳號金鑰持久化

完整欄位表與範例區塊見 shadowdns.yaml


RFC 8484 協定

端點在 /dns-query 路徑上同時接受 GET 與 POST:

  • GET /dns-query?dns=<base64url-no-padding> — DNS 查詢訊息以 base64url 編碼(不帶 padding)放在 dns query 參數中。
  • POST /dns-query — 原始 DNS 查詢訊息為 request body,Content-Typeapplication/dns-message

回應一律以 Content-Type: application/dns-message 回傳。

錯誤處理:

情況 狀態碼
/dns-query 的路徑 404 Not Found
非 GET 或 POST 的方法 405 Method Not Allowed
無法解碼成 DNS 訊息的請求 400 Bad Request
POST body 大於 65535 bytes 413 Payload Too Large

curl 範例

# GET:base64url 編碼(不帶 padding)的 DNS 查詢放在 `dns` 參數
curl -sS 'https://203.0.113.10/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' \
  | xxd

# POST:原始 DNS 訊息作為 request body
curl -sS -H 'content-type: application/dns-message' \
  --data-binary @query.bin \
  https://203.0.113.10/dns-query | xxd

要產生 query.bin,可擷取一段 wire-format 查詢——例如用 dig +noedns +qr www.example.com A 取出 request bytes,或任何能輸出原始 DNS 訊息的工具。


application/dns-json 格式

除了 RFC 8484 wire format,/dns-query 端點在 GET 請求上也提供 Google Public DNS/CloudFlare 的事實格式 application/dns-json。這讓你能用 curl + jq、零用戶端編碼即可驗證紀錄——不必手動組出 base64url 的 wire 查詢。

Note

application/dns-json 並非 RFC;它對齊 Google Public DNS 的回應 schema。JSON 的欄位順序與空白不具規範性——只有欄位名稱、型別與值具意義。

格式協商

GET 路徑上的格式選擇規則如下:

  • 帶有 ?dns= 參數的請求一律以 RFC 8484 wire-format 處理,不論其 Accept header。?dns= 參數優先,因此 wire 查詢絕不會被誤導到 JSON 解析。
  • 沒有 ?dns= 參數、且 Accept header 列出 application/dns-json 的請求,會以 JSON 提供,Content-Typeapplication/dns-json
  • POST 一律為 wire-format;JSON 格式僅限 GET(對齊 Google/CloudFlare)。

查詢參數

參數 必填 說明
name 查詢名稱,不可為空。會正規化為結尾帶點的 FQDN;on-wire 大小寫予以保留(因此 ExAmple.COM 會原樣回傳)。
type 否(預設 A DNS 紀錄型別。接受大小寫不敏感的 mnemonic(TXTtxtTxt)或 0–65535 範圍內的數值碼。
edns_client_subnet <ip>[/<prefix>] 表示的 client subnet,會被注入為 EDNS Client Subnet option(見下)。省略 prefix 時,IPv4 預設 /24、IPv6 預設 /56
cd 被接受但忽略——ShadowDNS 非遞迴、不做 DNSSEC 驗證,且永遠不會設定回應的 CD bit。

doct 參數不被理會;出現時予以忽略,且不會造成錯誤。

回應 schema

成功的回應是一個對齊 Google Public DNS schema 的 JSON 物件:

{
  "Status": 0,
  "TC": false,
  "RD": true,
  "RA": false,
  "AD": false,
  "CD": false,
  "Question": [{ "name": "www.example.com.", "type": 1 }],
  "Answer": [{ "name": "www.example.com.", "type": 1, "TTL": 300, "data": "203.0.113.20" }]
}
  • Status 是整數 DNS RCODE(例如 0 NOERROR、3 NXDOMAIN、5 REFUSED)。
  • RD 永遠為 true(送出的查詢設了 recursion-desired);CD 永遠為 false
  • Answer[].data 是剝除紀錄 header 後的 RDATA presentation format,因此多欄位 RDATA(SOA、MX)與帶引號的 TXT 資料都會完整保留。
  • 回應帶有與 wire 路徑相同的 Cache-Control: max-age=N header,受最小的 Answer TTL 上限約束。

DNS 層的結果以 Status 表達,而非用 HTTP 錯誤碼:

情況 HTTP 狀態碼
格式正確的查詢(任何 RCODE,含 REFUSED/NXDOMAIN/空答) 200 OK
缺少、空白或格式不正確的 name(label 超過 63 octets 或整體超過 255 octets)、無法解析的 type、或無法解析的 edns_client_subnet 400 Bad Request
送出的查詢未捕獲任何回應(內部失敗) 500 Internal Server Error

拒絕 zone transfer

type=AXFRtype=IXFR 會以 Status 5(REFUSED)與空 Answer 拒絕,與 wire 路徑相同——zone transfer 是多訊息串流,在單一 JSON 回應中無法表達。

curl + jq 範例

# 以 JSON 查詢一筆 A 紀錄
curl -sS -H 'accept: application/dns-json' \
  'https://203.0.113.10/dns-query?name=www.example.com&type=A' | jq

# 只取出 answer 的 data
curl -sS -H 'accept: application/dns-json' \
  'https://203.0.113.10/dns-query?name=www.example.com&type=TXT' \
  | jq -r '.Answer[].data'

模擬 client subnet(ECS)

當伺服器啟用 ECS(--ecs-enable)時,edns_client_subnet 參數讓單一主機就能模擬來自任意網段的查詢,因此你不必從該網段送出流量即可驗證 split-horizon/GeoIP 的 view 選擇:

curl -sS -H 'accept: application/dns-json' \
  'https://203.0.113.10/dns-query?name=www.example.com&type=A&edns_client_subnet=198.51.100.0/24' \
  | jq '{Answer, edns_client_subnet}'

prefix 以外的 host bits 會被自動遮罩(例如 198.51.100.5/24 變成 198.51.100.0/24),因此草率的值不會造成 FORMERR。當 ECS 生效時,回應會包含一個 edns_client_subnet 欄位,格式為 <network>/<source-prefix>/<scope-prefix>。ShadowDNS 是權威伺服器、不會把 scope 縮小到 geo 邊界,因此 scope-prefix 即 source-prefix 的回顯——它只證明該 subnet 被接受並用於 view 選擇,僅此而已。

Warning

--ecs-enable 關閉(預設)時,注入的 edns_client_subnet 會被靜默忽略——與 wire 查詢攜帶 ECS 但 ECS 未啟用時完全相同——且回應不會帶任何 edns_client_subnet 欄位。


TLS 與憑證

DoH listener 以一張為 IP 位址acme.ip)簽發的憑證提供 TLS,該憑證透過 ACME HTTP-01 驗證自動取得,採用 Let's Encrypt 的短期憑證 profile(約 6 天效期)。ShadowDNS 會在到期前充分提早自動續期,並把新憑證不重啟地熱替換進運行中的 listener——進行中與後續的連線都會透明地接上新憑證。

由於憑證綁定的是 IP 而非主機名,用戶端直接連到該 IP(如上方 curl 範例)。


ACME HTTP-01 listener 收斂

Port 80 上的 HTTP-01 回應器(acme.http01_listen)在設計上是 ShadowDNS 唯一全公開的 HTTP 表面——它必須接受來自整個 Internet 的連線,ACME 伺服器才連得到。為了把這個攻擊面與指紋壓到最小,此 listener 只回應唯一一種請求,其餘一律斷線。

只有在同時滿足以下所有條件時,請求才會被服務(回 200 OK 並附上 key authorization body):

  • 方法為 GET,且
  • 路徑落在 /.well-known/acme-challenge/ 之下(結尾的斜線很重要),且
  • token 命中某個目前正在被 Present、尚在進行中的授權。

其餘所有請求——未知路徑、未知或空白的 token、結尾無斜線的裸 /.well-known/acme-challenge、或任何非 GET 方法——都會在連線層被中止。ShadowDNS 完全不送任何 HTTP response:沒有 status line、沒有 header、沒有 body。用戶端只會看到 connection reset/EOF,server 端也不會輸出 stack trace。這與 nginx 的 return 444 語意相同。特別是對未知路徑不再回 404、對結尾無斜線的 subtree 路徑不再做 301 redirect——這兩者本來都會洩漏「有 server 正在監聽、且它是什麼」的資訊。

此收斂行為對合法的憑證簽發或續期沒有任何影響:ACME validator 只會請求 ShadowDNS 剛開始 Present 的那個確切 token,而那正是會回 200 的唯一請求形態。憑證仍可正常簽發與續期。


ACME 帳號金鑰持久化

ShadowDNS 會把 ACME 帳號私鑰持久化到 acme.account_key_file 指定的絕對路徑,並跨重啟與註冊重試重用。建議放在 systemd 狀態目錄之下:

acme:
  account_key_file: "/var/lib/shadowdns/acme/account.key"

打包的 systemd unit 宣告了 StateDirectory=shadowdns,因此 /var/lib/shadowdns 會在每次啟動時由服務使用者以 0700 權限建立。

行為:

  • 首次啟動——檔案不存在時,ShadowDNS 產生一把新的 P256 帳號金鑰,以 PKCS#8 PEM、0600 權限寫入該路徑,再註冊 ACME 帳號。
  • 重啟/重試——載入同一把金鑰,因此 ACME directory 會回傳既有帳號(RFC 8555 §7.3),而非註冊新帳號。這正是讓重新註冊具冪等性、並在 crash loop 或反覆註冊失敗時避免耗盡每來源 IP 的 new-account 速率限制的關鍵。
  • 金鑰檔毀損或無法讀取——ShadowDNS 大聲失敗:記錄一筆點名該檔的錯誤,且不會靜默改鑄替代金鑰或註冊新帳號(靜默重建正是會觸發速率限制的行為)。由於 obtainer 在失敗時不會被快取,此錯誤會在每次續期重試時重現,直到你修復或移除該檔為止;在那之前 DoH 無法提供任何憑證。

維運注意事項:

  • 帳號金鑰是機密。請保持 0600 且擁有者為服務使用者;切勿提交版控或複製到共享位置。
  • 持久化保證依賴靜態服務使用者(User=shadowdns)。請勿把 unit 改成 DynamicUser=yes——每次開機變動的 UID 會改變 StateDirectory 的擁有者,使既有金鑰無法讀取,靜默重現 new-account churn。
  • 變更 account_key_file 需重啟行程才會生效。在 SIGHUP reload 時會被偵測為 DoH 設定漂移,並如其他 doh.acme.* 欄位一樣記錄「restart to apply」提示。

防火牆與 port 部署

DoH 使用兩個 TCP port,曝險需求差異很大:

  • Port 80acme.http01_listen必須能從公開 Internet 連到,ACME 伺服器才能完成 HTTP-01 驗證。此回應器是 ShadowDNS 唯一全公開的 HTTP 表面,因此被收斂成只回應唯一一種請求:對某個現存 challenge token 的 GET 會回 200 並附上 key authorization;其餘所有請求一律在連線層被中止——完全不送任何 HTTP response(不回 404、也不做 301 redirect),用戶端只會收到 reset/EOF。見 ACME HTTP-01 listener 收斂。它不承載任何 DNS 資料。
  • Port 443listen,DoH 服務)應以防火牆限制為受信任的來源 IP。它不需要讓 ACME 伺服器連到,只需讓用來驗證紀錄的操作者連到。

典型部署會把 port 80 對全世界開放(僅限 challenge),並把 port 443 限制在一小份操作者位址的 allowlist 內。


來源 IP 與 view

DoH 的 view 選擇使用 TCP 連線的來源 IP——也就是 ShadowDNS 在傳輸層觀察到的位址。X-Forwarded-ForForwarded HTTP header 會被忽略。這是刻意設計的安全邊界:用戶端無法藉由設定 header 來偽造 view。


Cache header

每個 DoH 回應都帶有 Cache-Control: max-age=N header,其中 N 受回應中最小的 Answer TTL 上限約束。對於沒有正效期答案的回應(空 answer 區段),N0


可觀測性

DoH 查詢與 UDP、TCP 一同呈現在標準 metrics 中:

  • shadowdns_dns_requests_total 帶有 proto="doh" label,與 proto="udp"proto="tcp" 區隔,因此可單獨統計與追蹤 DoH 流量速率。
  • shadowdns_doh_cert_renewals_total{result="success"|"failure"} 依結果計數憑證續期嘗試。
  • shadowdns_doh_cert_not_after_timestamp_seconds 以 Unix timestamp 記錄目前憑證的到期時間,可用於即將到期的告警。
  • shadowdns_doh_acme_dropped_total{reason="unknown_path"|"unknown_token"|"bad_method"} 計數 port 80 HTTP-01 listener 未回應即中止的探測連線(見 ACME HTTP-01 listener 收斂)。可用於觀測 port 80 被探測的量。

這些 metrics 如何被 scrape 與做成 dashboard,見監控


Reload 行為(SIGHUP)

doh: 區段會在 SIGHUP 時重新驗證,但對 doh.listen 或任何 doh.acme.* 欄位的變更需要重啟程序才會生效——listener 與 ACME 帳號是在啟動時建立的。當 reload 偵測到這類變更時,ShadowDNS 會記錄一筆 advisory 日誌,說明需要重啟;在此之前運行中的 listener 會沿用先前的設定。


FAQ

簽發下來的 TLS 憑證會儲存嗎?還是每次重啟都重新簽發?

每次重啟都會重新簽發。 leaf 憑證(及其私鑰)只存在於記憶體中——從不寫入磁碟。每次行程啟動時,ShadowDNS 都會在 listener 處理第一個 handshake 之前,先向 ACME directory 取得一張全新的憑證。磁碟上唯一持久化的 ACME 素材是帳號金鑰(見 ACME 帳號金鑰持久化),那是不同的東西:它讓重啟得以重用同一個 ACME 帳號,而非註冊新帳號。

這是刻意的取捨。憑證為短期(約 6 天),而重啟預期遠比這稀少,因此「啟動時重簽」讓設計保持單純,也完全不必把憑證私鑰寫到磁碟。

維運後果:每次重啟都是一次真實的憑證簽發。 持久化帳號金鑰能避免 new-account 速率限制,但無法避免每 IP 的憑證/new-order 限制。請勿讓 ShadowDNS 對 production ACME directory 陷入 crash loop 或快速重啟循環;測試時若需反覆重啟,請改用 staging directory。

憑證自動續期後,會跑一次完整的 config reload 嗎?

不會。 續期與 SIGHUP config reload 互相獨立——它不會重讀設定、不會重新開啟 zone 資料,也不會重啟 listener。一個背景迴圈取得續期後的憑證,並把它原子地替換進 tls.Config.GetCertificate 每次 handshake 都會讀取的持有點,因此下一個 TLS handshake 就會接上新憑證,而進行中的連線不受中斷。沒有任何東西會重新綁定 port,也不會動到其他子系統。

這兩條路徑彼此正交:憑證輪替是自動且僅限於 listener 本身,而 SIGHUP reload 會重讀其餘設定但不會動到憑證(且 doh.* 的變更仍需重啟——見 Reload 行為(SIGHUP))。


延伸閱讀