Cloudflare WorkersでDNS Proxyを動かせないかなと思い、試してみたらお手軽に作れて動いたのでご紹介です。
DNS Proxyは、プロキシサーバーのDNS版で、DNSクライアントとDNSサーバーの間に入って中継をするものです。DNS Pxoryを使うことで、DNSサーバーからクライントのIPアドレスを隠したり、特定のドメインのDNSの問い合わせをブロックすることなどができます。そのため、広告ブロックの方法の1つとして使われています。
DNSの問い合わせ内容からどのようなサイトを普段見ているのかを知ることができるため、自前で気軽にDNS Proxyを運用できるとうれしいです。(ただし、DNS Proxyを使うことで全ての懸念がクリアになるわけではありません)
Cloudflare Workersは、簡単に軽量なWebアプリケーションを動かせるエッジコンピューティングサービスです。非常にコストも安い上に、0ms cold startなどいろいろ面白い特徴もあり、気軽に自前で動かすにはぴったりです。
コードは以下のGitHubリポジトリに置いているので、具体的な実装についてはそちらを見ていただければと思います。
Cloudflare Workersは、JavaScriptエンジンのV8が実行基盤なので、JavaScriptやTypeScriptでアプリケーションを書くのが多いですが、V8はWASMも動かすことができるので、WASMにできれば何でも動かすことができます。
RustはWASMをかなり強力にサポートしており、Cloudflare Workers側もRustでアプリケーションを簡単に書いて動かすことができるバインディングを用意してくれています。
使い方・解説はREADMEやcreate.ioのドキュメントなどに譲りますが、リクエスト・Env・Contextを受け取ってレスポンスを返す関数を定義してあげれば、それがCloudflare Workersで動くアプリケーションになります。
#[worker::event(fetch)]
async fn fetch2(
_req: worker::Request,
_env: worker::Env,
_ctx: worker::Context,
) -> worker::Result<worker::Response> {
worker::Response::from_json(&"Hello, World!")
}
もちろん、KVやDurable ObjectなどのCloudflare Workersの機能もRustから使うことができます。面倒なJavaScriptとのつなぎこみ部分などはworkers-rsがやってくれるので、非常にお手軽にRustをWorkers開発に使うことができます。
プロジェクトの作成は、cargo generate
でworkers-rsのテンプレートのうちtemplates/hello-world-http
から始めるのが良いと思います。
cargo generate --git https://github.com/cloudflare/workers-rs.git
詳細なWorkersプロジェクトの作成や基本的なガイドについては、Cloudflareのドキュメントやworkers-rsのGet Startedを参照してください。
DNS Proxy自体はDNSリクエストを受け取って、それをDNSサーバーに問い合わせし、その結果をそのままクライアントに返すだけというシンプルなものです。
しかしながら、Cloudflare WorkersはHTTP(S)のWebアプリケーションのプラットフォームであるため、一般的にはUDPの53番ポートで動くDNSは動きません。
よって、今回作るDNS ProxyはDNS over HTTPS (DoH)に基づいて、DNSリクエスト・レスポンスをHTTPS上でやり取りするものになります。
DoHは、その名の通りDNSのやり取りをHTTPS上で行うことでセキュリティ・プライバシーを改善することを狙ったプロトコルです。
通常のDNSでは特に暗号化などもされないため、DNSの問い合わせ内容を容易に第三者が覗くことができてしまいます。また、接続先が正しいDNSサーバーであることも確認できません。
DoHは当然TLSに基づくものであるため、上記の問題については解決されます。(通信相手が信頼できるか、内容が信頼できるかなど他にも問題はあります)
ちなみに、似たようなプロトコルにDNS over TLS (DoT)がありますが、主な違いとしてはDoHが通常のHTTPSでも使われる443番ポートを使うのに対して、DoTは853版ポートを専用として使う点があげられます。
今回はCloudflare Workersで動かすので、DoHを使うことになります。
DoHの詳細な解説・問題点などについては上記のような他のサイトや文献を参照していただくとして、実装面について概説します。
DoHの実装面は非常にシンプルです。
リクエストは、
dns
にDNSメッセージをURL SafeなBase64でエンコードしたものセットcontent-type
にapplication/dns-message
を使用という形でDNSリクエストを送ります。
レスポンスについても基本的にはDNSのレスポンスのバイト列がレスポンスボディにセットされて帰ってきます。(AcceptヘッダーなどによってはJSONで帰ってきたりもします)
DoHに対応しているDNSサーバーを提供しているCloudflareやGoogleがドキュメントにしてくれています。
プロキシとしてやることは受け取ったDNSリクエストをそのままDNSサーバーに問い合わせ、そのレスポンスをそのままクライアントに返すだけです。
実装は以下の通り非常に単純です。(importなどについては省略してます)
#[worker::event(fetch)]
async fn fetch(
mut req: worker::Request,
_env: worker::Env,
_ctx: worker::Context,
) -> worker::Result<worker::Response> {
let message_b64 = match req.method() {
Method::Get => {
let query = req.query::<QueryParams>()?;
query.dns
}
Method::Post => URL_SAFE.encode(&req.bytes().await?),
_ => return worker::Response::error("Not Implemented", 501),
};
let res = reqwest::Client::new()
.get(TARGET_URL) // フルサービスリゾルバのDoHエンドポイントを指定
.header("accept", "application/dns-message")
.header("content-type", "application/dns-message")
.query(&[("dns", message_b64)])
.send()
.await
.map_err(|e| worker::Error::RustError(format!("Failed to send request: {}", e)))?;
let bytes = res
.bytes()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
Ok(worker::Response::builder()
.with_header("content-type", "application/dns-message")
.unwrap()
.with_status(200)
.fixed(bytes.to_vec()))
}
上記のように、DNS Wireformat(パケット)のデコード・エンコードすら必要ありません。
特筆しておくべき場所としてはreqwest
クレートを使ってDoHでDNSサーバーに問い合わせているぐらいです。
reqwest
クレートはCloudflare Workers公式のSupported cratesの1つにリストアップされているので、Workers側からHTTPリクエストを送る際には基本これを使うのがたぶん良いでしょう。
DNSの問い合わせ結果を毎回DNSサーバーに問い合わせていると、短時間で同じドメインについて何回も問い合わせしてしまったりしますし、外部サーバーへのアクセスは非効率です。
そのため、フルサービスリゾルバと同様にDNSの問い合わせ結果をキャッシュする処理を実装してみましょう。
DNSの問い合わせ結果は、問い合わせ結果のTTL(Time To Live)に基づいてキャッシュするのが望ましいです。
キャッシュのキーとなるドメインやTTLを確認するために、DNS Wireformatをデコードする必要があります。
DNS Wireformatの構造や仕様は上記のRFCやサイトなどを参照していただくとして、今回はhickory-dns
のデコーダーを使います。
hickory-dns
は、Rustで書かれたDNSのクライアント/サーバー/リゾルバです。Safe Rustのみで実装することを目標の1つとしており、そのおかげでWASMにもしやすいと思われます。(Cやネイティブライブラリ依存などがあると一気にWASMにするのが面倒になります)
DNS Wireformatのデコードは、hickory-dns
のうちのhickory-proto
クレートのBinDecoder
を使うだけです。
use hickory_proto::{
op::Message,
serialize::binary::{BinDecodable, BinDecoder},
};
let bytes = [0u8; 32]; // DNS Wireformatのバイト列
let mut decoder = BinDecoder::new(&bytes);
let message = Message::read(&mut decoder)?;
デコードしたMessage
構造体からドメインやTTLを取得することができます。
let query = message_question // クライアントからリクエストされたDNSメッセージ
.query()
.ok_or(worker::Error::RustError("Query is empty".to_string()))?;
let mut domain = query.name().to_utf8();
let ttl = message_answer // フルサービスリゾルバからのレスポンス
.answers()
.iter()
.filter_map(|a| Some(a.ttl()))
.max();
あとは、これらをもとにキャッシュセット・キャッシュを使うようにするだけです。
Cloudflare Workersではキャッシュを実装する方法がいくつか考えられますが、今回はCache APIを使います。
use worker::Cache;
let domain = "example.com"; // 問い合わせドメイン
let cache_key = format!("https://{}", domain);
let ttl = 256; // レスポンスから取得したTTL
let cache_control = match ttl {
Some(ttl) => format!("public, max-age={}, s-maxage={}", ttl, ttl),
None => "public, max-age=60, s-maxage=60".to_string(),
};
response
.headers_mut()
.set("cache-control", &cache_control)?;
// キャッシュのセット
Cache::default().put(&cache_key, response.cloned()?).await?;
// キャッシュの取得
if let Some(response) = Cache::default().get(&cache_key, true).await? {
worker::console_debug!("Cache hit for domain: {}", domain);
return Ok(response);
}
通常のCloudflare Workersと同様に、JavaScriptのCacheに基づいたCache APIを使うことができます。
ポイントとしては、キャッシュのキーはURLとすること、TTLに基づいてcache-control
ヘッダーを設定すること、Cache APIからのレスポンスはworker::Response
であることぐらいでしょうか。
Cache::default().put
のキーとしてString
が使えますが、ただの文字列ではダメで、https
かhttp
から始まるURL形式である必要があります。
Cloudflare WorkersのCache APIの有効期限は、cache-control
ヘッダーのmax-age
やs-maxage
を参照するので、今回はTTLに基づいて設定しています。
Cacheのセット・取得はworkers::Response
となるので、DNS Wireformatのバイト列をそのままセットしたりはできません。
これで、DNS Proxyにキャッシュ機能を追加することができました。
実際にDNS Proxyのログを確認してみると、キャッシュヒットしているときはCPUウォールタイムがフルサービスリゾルバまで問い合わせたときと比較して大きく改善されていることがわかります。
Cloudflare WorkersのCache APIは、同一データセンターのみで有効だったり、cache-control
ヘッダーに基づいて必ずキャッシュが有効になるわけでは無いなどの制限はありますが、非常にお手軽にWebアプリケーションにキャッシュ機構を実装できるので、大変便利です。
DNS Proxyの典型的な使い方である、特定のドメインをブロックするフィルタリング機能を実装してみましょう。
企業や学校などで特定のサイトへのアクセスを遮断するためだったり、広告を非表示にするアドブロックの1つの方法として使われます。
今回は、キーバリューストアのWorkers KVを使ってブロックリストの管理・フィルタリングを実装します。
Workers KVは、グローバルに一貫性のあるキーバリューストアで、Cloudflare Workersから簡単に読み取り・書き込みができます。書き込みは最大60秒の遅延があるものの、読み取りは非常に高速なので、今回のような書き込み頻度が低く、読み取り頻度が高い用途にはぴったりです。
実装は非常に単純で、以下のようにWorkers KVに問い合わせるドメインが存在するかを確認するだけです。
if let Some(_) = env.kv("BLOCKLIST")?.get(&domain).bytes().await? {
let mut response = worker::Response::builder()
.with_header("Cache-Control", "public, max-age=360, s-maxage=86400")
.unwrap()
.with_status(http::StatusCode::NOT_FOUND.into())
.empty();
Cache::default().put(&cache_key, response.cloned()?).await?;
return Ok(response.into());
}
JavaScriptのWorkers KVと同様に、Rustでもenv.kv("BLOCKLIST")?
のように、wrangler.toml
のkv_namespaces
で書いたbindingを使ってKVにアクセスします。
KVの初期セットアップなど基本的な使い方についてはCloudflareのドキュメントを参照してください。
今回はブロック対象のドメインだった場合は、404 Not Foundのレスポンスを返していますが、DNSレコードが見つからなかったよレスポンスを返すべきだと思うので、このあたりは既存のDNS Proxyの実装・挙動を参考にして、みなさんカスタマイズしてみてください。
Workers KVへのブロックリストの登録は、コマンドラインからwrangler
でKVを直接触ったり、その他APIを使って各自いい感じに実装してください。
# ローカルの開発環境
wrangler kv key put "(ブロックするドメイン)" " " --binding BLOCKLIST --preview true
# 本番環境
wrangler kv key put "(ブロックするドメイン)" " " --binding BLOCKLIST --preview false
これで一通りDNS Proxyの実装ができたので、Cloudflare Workersにデプロイして使ってみましょう。
wrangler deploy
でRustで書いたコードがWASMにコンパイルされ、生成されたJavaScriptのバインディングといっしょにCloudflare Workersにデプロイされます。
https://XXXXXX.YYYYYY.workers.dev/
のようなURLが発行され、これがDNS Proxyのエンドポイントになります。
--doh-url
オプションで、DNS over HTTPSのエンドポイントを指定して、DNSリクエストを送ることができます。
先ほどデプロイしたCloudflare Workersのエンドポイントを指定して、DNSリクエストを送ってみましょう。
curl --doh-url https://XXXXXX.YYYYYY.workers.dev/ google.com
普通にcurlできたら成功です。
PC・AndroidのChromeでは、[設定] > [プライバシーとセキュリティ] > [セキュリティ] > [セキュアDNSを使用する]を有効にします。
DNSプロバイダとして[カスタム DNSサービスプロバイダ]を選択し、先ほどデプロイしたCloudflare Workersのエンドポイントを指定します。
エラー表示が出ずに、サイトにアクセスできれば成功です。また、KVにブロックしたいドメインを登録して、そのサイトにアクセスできないことも確認してみましょう。
iOSは本体設定から頑張ってください。
今回は簡単にDNS Proxyを手づくりして、Cloudflare Workersで動かす方法を紹介しました。
非常にまだ単純な実装なので、上述した通りドメインをブロックしたときの挙動や、KVにも問い合わせ結果をキャッシュする、ODoH(Oblivious DoH)を実装してみるなど、カスタマイズする余地がたくさんあると思います。
みなさんだけの最強のDNS Proxyを作ってみてください。
軽量なWebアプリケーションを圧倒的なコストで動かせながらスケールもするCloudflare Workersは、遊ぶのにはもってこいの場所です。
Node.jsでやるには辛いバイト列や高負荷な処理を実装・動かすのにはRustが向いていると思うので、みなさんもCloudflare WorkersでRustを使ってみてください。(大変な目にもあったりもすると思いますが)
content-type
を指定しないと怒られるcontent-type
としてapplication/dns-message
を指定せずに、application/octet-stream
などを指定すると、正しいDNSレスポンスでもChromeはDNS_PROBE_FINISHED_BAD_SECURE_CONFIG
で動きません。
[カスタム DNSサービスプロバイダ]を設定したときは有効なプロバイダであることを確認するか、しばらくしてからもう一度お試しください
としか言ってくれないので、デバッグのときは設定したまま適当なサイトにアクセスして、エラーコードを確認するのが良いでしょう。
workers::ResponseBuilder::from_bytes
を使うと、content-type
が自動的にapplication/octet-stream
になってしまうので、注意が必要です。(1敗)
DNS Wireformatのフィールドの1つとして、ID
フィールドがあります。
このフィールドはリクエストにランダムに割り当てられるIDで、レスポンスはリクエストと同じIDとする仕様です。
しかしながら、今回の実装では特にリクエストのIDを気にせず、キャッシュからレスポンスを返したりしていましたが、普通に動いてラッキーと思っていました。
念の為、RFC 8484を確認してみると、全てのDNSリクエストでDNS IDは0を使うようにと書かれてました。
よって、そもそもリクエストのIDは(RFC通りにクライアントが実装されているなら)全て0だったので、問題なかったというだけのことでした。
ちゃんとRFCは読んでみるものですね。
workers-rs
では、RustのWebフレームワークの1つであるaxumを使うこともできます。
しかしながら、Cache APIにおいてはworkers::Response
のみのサポートであったり、結局いろいろ型の周りでつじつま合わせが必要であったりと、結局axum
を使わずに書き直してしまいました。
ルーティングが複雑だったり、JSON API祭りだったりするならばaxum
を使うメリットもある気はしますが、今回のようなシンプルなものであれば、http
featureのみで書くのが現状では良いかなと思います。