LOST IN BLUE

2025/05/31

Cloudflare Workersで動かす手づくりDNS Proxy

Cloudflare WorkersでDNS Proxyを動かせないかなと思い、試してみたらお手軽に作れて動いたのでご紹介です。

DNS Proxyは、プロキシサーバーのDNS版で、DNSクライアントとDNSサーバーの間に入って中継をするものです。DNS Pxoryを使うことで、DNSサーバーからクライントのIPアドレスを隠したり、特定のドメインのDNSの問い合わせをブロックすることなどができます。そのため、広告ブロックの方法の1つとして使われています。

DNS Proxyの概要

DNSの問い合わせ内容からどのようなサイトを普段見ているのかを知ることができるため、自前で気軽にDNS Proxyを運用できるとうれしいです。(ただし、DNS Proxyを使うことで全ての懸念がクリアになるわけではありません)
Cloudflare Workersは、簡単に軽量なWebアプリケーションを動かせるエッジコンピューティングサービスです。非常にコストも安い上に、0ms cold startなどいろいろ面白い特徴もあり、気軽に自前で動かすにはぴったりです。

コードは以下のGitHubリポジトリに置いているので、具体的な実装についてはそちらを見ていただければと思います。

Cloudflare WorkersでRustを動かす

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をCloudflare Workersで動かす

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のレスポンスのバイト列がレスポンスボディにセットされて帰ってきます。(AcceptヘッダーなどによってはJSONで帰ってきたりもします)

DoHに対応しているDNSサーバーを提供しているCloudflareやGoogleがドキュメントにしてくれています。

RustでDNS Proxyを実装する

プロキシとしてやることは受け取った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の問い合わせ結果をキャッシュする処理を実装してみましょう。

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が使えますが、ただの文字列ではダメで、httpshttpから始まるURL形式である必要があります。
Cloudflare WorkersのCache APIの有効期限は、cache-controlヘッダーのmax-ages-maxageを参照するので、今回はTTLに基づいて設定しています。 Cacheのセット・取得はworkers::Responseとなるので、DNS Wireformatのバイト列をそのままセットしたりはできません。

これで、DNS Proxyにキャッシュ機能を追加することができました。
実際にDNS Proxyのログを確認してみると、キャッシュヒットしているときはCPUウォールタイムがフルサービスリゾルバまで問い合わせたときと比較して大きく改善されていることがわかります。

DNS Proxyのログ

Cloudflare WorkersのCache APIは、同一データセンターのみで有効だったり、cache-controlヘッダーに基づいて必ずキャッシュが有効になるわけでは無いなどの制限はありますが、非常にお手軽にWebアプリケーションにキャッシュ機構を実装できるので、大変便利です。

DNSフィルタリングを実装してみる

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.tomlkv_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のエンドポイントになります。

curlで試す

--doh-urlオプションで、DNS over HTTPSのエンドポイントを指定して、DNSリクエストを送ることができます。
先ほどデプロイしたCloudflare Workersのエンドポイントを指定して、DNSリクエストを送ってみましょう。

curl --doh-url https://XXXXXX.YYYYYY.workers.dev/ google.com

普通にcurlできたら成功です。

Chromeで試す

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を使ってみてください。(大変な目にもあったりもすると思いますが)

小ネタ・つまづきポイント

Chromeはちゃんと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敗)

DoHにおいてIDは気にしなくて良い

DNS Wireformatのフィールドの1つとして、IDフィールドがあります。
このフィールドはリクエストにランダムに割り当てられるIDで、レスポンスはリクエストと同じIDとする仕様です。
しかしながら、今回の実装では特にリクエストのIDを気にせず、キャッシュからレスポンスを返したりしていましたが、普通に動いてラッキーと思っていました。

念の為、RFC 8484を確認してみると、全てのDNSリクエストでDNS IDは0を使うようにと書かれてました。

よって、そもそもリクエストのIDは(RFC通りにクライアントが実装されているなら)全て0だったので、問題なかったというだけのことでした。
ちゃんとRFCは読んでみるものですね。

Axumは無理に使わないほうが良い

workers-rsでは、RustのWebフレームワークの1つであるaxumを使うこともできます。
しかしながら、Cache APIにおいてはworkers::Responseのみのサポートであったり、結局いろいろ型の周りでつじつま合わせが必要であったりと、結局axumを使わずに書き直してしまいました。

ルーティングが複雑だったり、JSON API祭りだったりするならばaxumを使うメリットもある気はしますが、今回のようなシンプルなものであれば、http featureのみで書くのが現状では良いかなと思います。

参考リンク