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)放在dnsquery 參數中。 - POST
/dns-query— 原始 DNS 查詢訊息為 request body,Content-Type為application/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 處理,不論其Acceptheader。?dns=參數優先,因此 wire 查詢絕不會被誤導到 JSON 解析。 - 沒有
?dns=參數、且Acceptheader 列出application/dns-json的請求,會以 JSON 提供,Content-Type為application/dns-json。 - POST 一律為 wire-format;JSON 格式僅限 GET(對齊 Google/CloudFlare)。
查詢參數¶
| 參數 | 必填 | 說明 |
|---|---|---|
name |
是 | 查詢名稱,不可為空。會正規化為結尾帶點的 FQDN;on-wire 大小寫予以保留(因此 ExAmple.COM 會原樣回傳)。 |
type |
否(預設 A) |
DNS 紀錄型別。接受大小寫不敏感的 mnemonic(TXT、txt、Txt)或 0–65535 範圍內的數值碼。 |
edns_client_subnet |
否 | 以 <ip>[/<prefix>] 表示的 client subnet,會被注入為 EDNS Client Subnet option(見下)。省略 prefix 時,IPv4 預設 /24、IPv6 預設 /56。 |
cd |
否 | 被接受但忽略——ShadowDNS 非遞迴、不做 DNSSEC 驗證,且永遠不會設定回應的 CD bit。 |
do 與 ct 參數不被理會;出現時予以忽略,且不會造成錯誤。
回應 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(例如0NOERROR、3NXDOMAIN、5REFUSED)。RD永遠為true(送出的查詢設了 recursion-desired);CD永遠為false。Answer[].data是剝除紀錄 header 後的 RDATA presentation format,因此多欄位 RDATA(SOA、MX)與帶引號的 TXT 資料都會完整保留。- 回應帶有與 wire 路徑相同的
Cache-Control: max-age=Nheader,受最小的 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=AXFR 與 type=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 狀態目錄之下:
打包的 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 80(
acme.http01_listen)必須能從公開 Internet 連到,ACME 伺服器才能完成 HTTP-01 驗證。此回應器是 ShadowDNS 唯一全公開的 HTTP 表面,因此被收斂成只回應唯一一種請求:對某個現存 challenge token 的 GET 會回200並附上 key authorization;其餘所有請求一律在連線層被中止——完全不送任何 HTTP response(不回404、也不做301redirect),用戶端只會收到 reset/EOF。見 ACME HTTP-01 listener 收斂。它不承載任何 DNS 資料。 - Port 443(
listen,DoH 服務)應以防火牆限制為受信任的來源 IP。它不需要讓 ACME 伺服器連到,只需讓用來驗證紀錄的操作者連到。
典型部署會把 port 80 對全世界開放(僅限 challenge),並把 port 443 限制在一小份操作者位址的 allowlist 內。
來源 IP 與 view¶
DoH 的 view 選擇使用 TCP 連線的來源 IP——也就是 ShadowDNS 在傳輸層觀察到的位址。X-Forwarded-For 與 Forwarded HTTP header 會被忽略。這是刻意設計的安全邊界:用戶端無法藉由設定 header 來偽造 view。
Cache header¶
每個 DoH 回應都帶有 Cache-Control: max-age=N header,其中 N 受回應中最小的 Answer TTL 上限約束。對於沒有正效期答案的回應(空 answer 區段),N 為 0。
可觀測性¶
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))。
延伸閱讀¶
shadowdns.yaml—doh:區段的欄位參考與範例。- CLI 參考 中的相關旗標。
- 監控 中上述的 DoH metrics。