KMC活動ブログ

京大マイコンクラブの活動の様子を紹介します!!

RubyKaigi 2023でのセキュアなDNSリゾルバの運用 ― DNS-over-HTTPSとDDR

こんにちは,id:hanazukiです.いよいよ明日からRubyKaigi 2023が始まりますね.私は昨日から松本入りして,今日は会場の設営をやっていて,ちょうどいま(17:00),お昼休憩をとっています…….会期が終わったあとにまとめを書こう〜なんて思っているとたいていそのまま忘れてしまうので,今年は準備と並行して記事をしたためている次第です.

私は,RubyKaigiで来場者向けに提供しているWi-Fiの構築・運用を2017年から手伝っています.毎年おなじことをやっていてもおもしろくないというか,だんだんとやることが目減りしてしまいますよね.今年は何か新しいことをできないかと考えて,RubyKaigi会場ネットワークのDNSゾルバをDNS-over-HTTPSに対応させることにしました.

この記事では,

  1. DNS-over-HTTPSが普通のDNSとはどう違うのかを見てから,
  2. RubyKaigi会場ネットワークのDNSゾルバがどのように作られているのか紹介し,
  3. どうすれば今年のRubyKaigiの会場でDNS-over-HTTPSを試せるかを説明したいと思います.

(2023-05-17追記)実際に運用をして分かったことと分からなかったことを記事末尾に追記しました.

1. DNS-over-HTTPSとは

そもそもDNSとは何だったか

まずは,用語の整理も兼ねてDNSをおさらいしましょう.この記事ではDNSに関連する用語はできるだけRFC8499に従いたいと思います.また,JPRSによるRFC8499の日本語訳も参考にしています.

DNSは,ドメイン名(例えばkmc.gr.jp.)をキーとして様々な情報を引ける階層型の分散データベースシステムです.たとえば,kmc.gr.jp.のAレコードをクエリすると,KMC部室のIPv4アドレス192.50.220.129がわかります.みなさんがブラウザでhttps://kmc.gr.jpにアクセスしようとすると,裏でこっそりこのようなクエリが行われています.ドメイン名からIPアドレスを得る手続きなので「名前解決」とも呼ばれます.

kmc.gr.jp.以下のドメインに関するコンテンツはKMCが管理しています.これは,jp.ドメインを管理しているJPRSに利用料を払ってkmc.gr.jp.以下を使う権利を買うことで実現しています.JPRSもルートドメイン.を管理してるICANNから国を代表してjp.を使う権利を取得しています.kmc.gr.jp.jp..といった管理の単位はゾーンと呼ばれています.特定のゾーンに関するコンテンツを保持しているDNSサーバを権威サーバと呼びます.権威サーバはそれぞれのゾーンを管理する組織が責任を持って運用しています.このようにドメインの親子関係によって管理主体がわかれていることから,DNSは階層型かつ分散的であると言えます.この記事では権威サーバは脇役なのであまり登場しません.

一方で,権威サーバに世界中のクライアント端末からクエリが殺到すると大変なので,キャッシュサーバを設置するのが一般的です.キャッシュサーバは,伝統的にはISPやオフィスなど,ある程度まとまった利用者のいるネットワーク管理主体が設置していました.最近はGoogle Public DNSやCloudflare DNSなど,世界中からアクセス可能なキャッシュサーバも増えています.クライアント端末からのクエリを取りまとめて,情報を持っている権威サーバからレコードを取得し,後に備えてキャッシュするDNSサーバのことをフルサービスリゾルバと呼びます.

この記事ではフルサービスリゾルバの意味で「DNSゾルバ」という言葉を用います.RubyKaigiの会場にも1,000人ほどのRubyistが2,000台*1くらいのガジェットを携えてやってくるので,伝統的な流儀に則ってDNSゾルバを設置してRubyistのみなさんに提供しています.この記事ではRubyKaigi会場で提供しているDNSゾルバの話をします.

古き良きインターネット時代のDNS

通信プロトコルとしてのDNSはごく素朴に設計されました.ドメイン名(たとえばkmc.gr.jp.)と欲しい情報の種類(たとえばIPv4アドレスが欲しいときはAレコードを表す整数1)を何ビットかのフラグと一緒に送ると,いくらかの付加情報をともに192.50.220.129が返ってくるだけです.

ハンドシェイクの不要なUDPプロトコルが用いられていましたが,おそらく当時の技術的な制限でメッセージあたり512バイトまでに限られていました.クエリやレスポンスの大きさがこの上限を超えた場合はTCPにフォールバックする決まりでしたが,TCPはハンドシェイクが必要なので余分な時間がかかります.

後にプロトコルを拡張する中でフラグのビット数が足りなくなったり,メッセージが512バイトに収まらないことが増えたりしたことから,拡張規格のEDNS0が考案され,通信経路が許せばUDPで65,535バイトまで送ってもよいことになりました.

このプロトコルが今日まで使われ続けています.当初のインターネットは平和だったので暗号化の機能はありません.

暗号化インターネット時代のDNS

2000年代ごろにもDNSプロトコルを暗号化しようとする試みはあったそうですが,あまり普及はしなかったようです.2010年代にさまざまな事件があり,実は国家規模の権力と動機と資金があればインターネットをまるごと盗聴することも不可能ではないらしいと分かってきました(本当のところはよく知りませんが……).これによってさまざまなインターネットプロトコルを暗号化しようという機運が高まります[RFC7435].

そして,TCP上でTLSを用いて通信を暗号化するDNS-over-TLS (DoT)が考案されました[RFC7858].TCPハンドシェイクに加えて,TLSハンドシェイクのオーバーヘッドがかかるも,背に腹は変えられないというわけです.とはいえ無策ではなく,TCP/TLSコネクションをキープアライブして,クエリをパイプライン化するなどの工夫はされます.現在このプロトコルAndroidやsystemdなどのクライアント環境に実装されています.

DNS-over-HTTPS

さて,伝統的な平文のDNSプロトコルは53/udpと53/tcpポートを使います(それゆえレトロニムでDo53とも呼ばれています).一方,DoTは853/tcpを利用します.みなれないポート番号なので,環境によってはファイアウォールなどがあやしい通信とみなして遮断してしまうことがありました.

そこで,DNSのリクエストレスポンスをHTTPS上で行うDNS-over-HTTPS (DoH)が考案されました(Google Public DNSなどで実装され,RFC8484で標準化).いわばDNS over HTTP over TLS over TCPということになりますね.443/tcpで通信をするため,外見上は普通のHTTPSと区別がつかず多くの環境で使えるはずという狙いです.前のクエリ・レスポンスが完了するまで次のクエリを送れないHoLブロッキングの問題を回避するために,マルチストリーム通信の可能なHTTP/2 RFC9113が主に利用されます.

HTTPSはおそらく現代で最も開発研究の労力が投じられているインターネットプロトコルの一つで,DoHは自動的にHTTPSの改善の恩恵にあずかれるというメリットもあります.例を挙げると,Google Public DNSとCloudflare DNSにはHTTP/3を使ったDoHがすでに実装されていて,FirefoxChromeはこれを使うことができます(DNS over HTTP over QUIC over UDP).HTTP/3には,HTTP/2と違ってTCP層でのHoLブロッキングを回避できる利点があり,この特徴がDoHにも自動的に導入されるというわけです.

DNS-over-???

その他に,UDP上でDTLSを用いて通信を暗号化するDNS-over-DTLS (DoD)も考案されましたが(DNS over DTLS over UDP)[RFC8094],実験以上の規模で実装されたという話は聞きません.

また,DNS-over-QUIC (DoQ)というプロトコルも提案されています(DNS over QUIC over UDP)[RFC9250].HTTP/3を使うDoHに比べて,HTTP層を省くことでより軽量になるという謳いのようです.まだDoQをサポートするクライアントOS・ブラウザの話は聞かないですが,はやるでしょうか? 一部の公開DNSゾルバには実装が始まっています*2.ちなみにデフォルトでは853/udpポートを使うことになっていて,DoDのポート番号がリサイクルされています.

2. RubyKaigiのDNSゾルバはいかに作られているか

RubyKaigi 2023の物理ネットワークの一部.ケーブルをタップすれば,個人規模の権力と動機と資金でもDNSトラフィックの盗聴ができる(忙しいのでめんどうごとは増やさないでくださいね).

サーバソフトウェア

RubyKaigiのDNSゾルバには,フルサービスリゾルバの機能をもったUnboundを使っています.Unboundの最新版はDo53に加えてDoTとDoH (HTTP/2)をサポートしています.Ubuntu 22.04でパッケージされているバージョン1.13.1ではDoH機能に不具合があり,特定の条件でリクエストに応答しそこねるようでした*3.RubyKaigi程度の規模であれば,通常のチューニングをしておけば問題はないと考えています.DoT/DoH特有の事情として,これらのプロトコルTCPソケットを使うのでincoming-num-tcpを十分に大きく取る必要があります(デフォルトの10では足りません).

UnboundのDoHサーバはHTTP/2を喋りますが,クライアントはすでにHTTP/3を使うものも現れているので(後述),EnvoyHTTPSリバースプロキシとして置いています.DoHだからといって特別な設定は必要なく,信頼できないクライアントからのリクエストを受けるために通常のエッジプロキシとしての設定をしておけばよかろうと思います.UnboundのHTTP/2サーバ機能にまだあまり利用実績はないでしょうからやや心配で,HTTP/2のリクエストも一旦Envoyで受けてプロキシする形にしています.

Unboundのメトリクスを取るためにletsencrypt/unbound_exporterにいくつかパッチを当てたものを利用しています.パッチの一部はプルリクエストにしており,マージされると良いのですが.グラフを眺めるのが好きな人のために会期中限定でGrafanaのダッシュボードを公開しています.公開終了しました.スナップショットをご覧ください.

EKSとNLB

EnvoyやUnboundのような細々としたサーバソフトウェアたちを走らせるために,AWSのマネージドKubernetesサービスのAmazon EKSを利用しています.そして,Kubernetesで管理されるコンテナを束ねて冗長化するため,AWSのNetwork Load Balancer (NLB)を使っています.

伝統的なDo53のクライアントは,UDPデータグラムにメッセージが収まりきらなかった場合,同じIPアドレス宛のTCP通信にフォールバックします.ポート番号は53から変えられません.また,日和見的にDoTにアップグレードするクライアントは,Do53と同じIPアドレスの853/tcpに接続を試みます.これらの要請から,Do53 (UDP/TCP),DoTのリゾルバはすべて同じIPアドレスで提供する必要があります.さらに,今回はDoTとDoHのリゾルバをどちらもresolver.rubykaigi.netというドメイン名で使えるようにしたかったため,DoTとDoH (UDP/TCP)も同じIPアドレスで提供する必要が生まれました.

AWS Load Balancer Controllerを使うと,Kubernetes上のServiceリソースの定義によってAWS上のロードバランサを管理できるようになります.しかしながら,1つのServiceでTCPUDP両方の通信を受けることができない制限があります(これを可能にする機能追加はMixedProtocolLBServiceとして開発中です).さらに,KubernetesのServiceの抽象化では,53番ポートへの通信をunboundのPodに流し,443番ポートへの通信をenvoyのPodへ流すといった構成も取れません.こういった制約から,NLB自体はKubernetesの外部で管理して,Service配下のPod一覧とNLBのtarget groupを同期するTargetGroupBindingという機構を使っています.

ネットワーク

ここまででDNSゾルバを起動するところまでできました.では,松本のRubyKaigi会場と東京のAWS,そして世界はどのように繋がっているのでしょうか.この話をするとかなり長くなってしまうので,この記事では大枠だけを紹介したいと思います.ネットワークのL1/L2の設計,L3/L4部分の構築はWi-Fiスポンサーのクックパッドさんが担当してくれているので,そちらから本格的な解説が出るのを一緒に願いましょう.その代わりに私からは,分かった気持ちになれるかもしれない絵を用意しました.

右側がAWS東京リージョン,左上が都内DC,左下が松本の会場.会場・DC間はフレッツNGN網内折り返しにより,DC・AWS間はAWS Direct Connectにより接続されている.AWS上にはDNSゾルバがデプロイされている.実際はそれぞれの部分をさまざまな層で冗長化しているが図では省略している.

まず,松本の会場にはNTT東日本フレッツ光ネクストを引いています.これは一般のご家庭で使っているものと同じグレードの光ファイバー回線なのでなじみがあると思います.そして,クックパッドさん提供の都内データセンター(DC)のラックスペースにRubyKaigi用のルータを設置してあります.このラックにもフレッツ回線が引かれていて,会場とこのルータの間はフレッツNGN網内の折返し通信によって接続されています.NGNについては,部員のid:sora_hによる解説記事『NTTフレッツ光における通信速度などの現状について、背景や仕組みから正しく理解する2020*4をおすすめします.

実はKMCもクックパッドさんから同じラックとキャリア回線をお借りして,KMCが運用する自律システムAS59128 / KMC-ASのPOPを開設しています(このプロジェクトについても解説を読めるとうれしいですね!).KMCはこのPOPからRubyKaigiネットワークに対して,IPアドレスと対外接続を提供しています.RubyKaigi会場でRubyistたちがインターネットと通信をする場合,会場からフレッツ網を通って都内DCへ,都内DCからKMCのASを経由してインターネットへと抜けてゆきます.実際,会場Wi-Fiに接続した端末がどのパブリックIPアドレスを使って通信しているか調べると,KMCが保有するIPアドレス192.50.220.0/24を使っていることがわかると思います.

さて,AWSとの接続はどうでしょう.これもクックパッドさんの提供で,AWS Direct Connectの専用線でRubyKaigiのルータとAWS VPCを結んでいます.Wi-Fiに繋がった端末から名前解決すると,フレッツ網を通って都内DCへ,都内DCからDirect Connectを通ってVPCへ向かいます.VPCではNLBによってEnvoyやUnboundのコンテナのどれかへ振り分けられます.

  • 会場のクライアントからインターネットへのトラフィックは,NGNを通って都内DCへゆき,KMC-AS経由でインターネットへ出る(左側の水色矢印).
  • クライアントからのDNS再帰クエリは,Direct Connectを通ってAWSへたどり着き,NLBによってEnvoyコンテナに振り分けられ,EnvoyからUnboundへプロクシされる(右下側の赤色矢印).
  • Unboundからの反復検索クエリは,Private NAT GatewayでNAPTされ,Direct Connectを通って都内DCへ向かい,KMC-AS経由でインターネットへ到達する(上側の黄色矢印).
  • 一部のドメイン名の名前解決は,UnboundからRoute53 Resolverに転送されて,Route53のprivate hosted zoneを参照している(右上側の赤色矢印).

こうしてDNSゾルバがクライアントからのクエリを受けられるようになりました.あとは,リゾルバがインターネットの向こうの権威サーバーへクエリできるようにしたいです.通常,AWS VPCで稼働しているサービスがIPv4インターネットと通信する場合,パブリックサブネットに設置したNAT GatewayでNAPTして,Internet Gateway経由で外部のホストと通信する構成が一般的です.

ところがRubyKaigiでは,Unboundから権威サーバへのクエリは,VPCからDirect Connectで都内DCへ抜けて,そこからKMC-AS経由でインターネットへ出すようにしています.これは,DNSを使った広域負荷分散(GSLB)に悪影響を与えないようにするためです.一部のCDNなどでは,アクセス元の地理的位置・インターネット上の論理的な位置を考慮して,ユーザをできるだけ近いサーバに接続させるために,権威サーバがどのネットワークからクエリが来たかを認識して異なるレスポンスを返し分けるということを行っています.したがって,ユーザのトラフィックはKMC-ASからインターネットへ出ているのに,権威サーバはAWSのネットワークからインターネットに繋がっているのでは都合が悪いのです.たとえば,会場のWi-Fiに接続して,o-o.myaddr.l.google.com.のTXTレコード(このDNSサーバからみてクライアントのIPアドレスを返してくれるおもしろいエンドポイントです)を引いてみると,KMCの保有するIPアドレス192.50.220.0/24になっていると思います.残念ながら,暗号化通信に対応している権威サーバは未だ少なく,リゾルバから権威サーバ間の通信は平文で行われているのが現状です.

また,rubykaigi.orgサブドメインなど一部のドメインについてsplit-horizon DNSを行っています.特定のサービスに会場内からアクセスするのにインターネットを経由せずプライベートなDirect Connect経由でVPCに通信できるよう,プライベートアドレスを返すようにしています.これを実現するために,それらのドメインに関する再帰クエリはUnboundからRoute53 Resolverに転送してRoute53でホストしているプライベートゾーンのコンテンツを返すようにしています.

クックパッド社のid:sora_hがRubyKaigiネットワークの解説を書いてくれました.もっと詳しく知りたい!と思った方はあわせてお読みください.

3. RubyKaigiの会場でDNS-over-HTTPSを試す

クライアントOS・ブラウザの対応状況

PCのChrome,EdgeやFirefoxを利用している場合,ブラウザの設定でカスタムDNSプロバイダを使用するよう選択し,プロバイダとしてhttps://resolver.rubykaigi.net/dns-query{?dns}と入力すると,DoHで名前解決するようにできます.会場外ではこのリゾルバを使えないので,外へ出たら設定を戻し忘れないように気をつけてください.Windows 11のInsider PreviewにもDoHが実装された*5と小耳に挟みましたが,まだ試せていません.

最近AndroidにもDoHが実装され,Android 11までバックポートされているようですが,特定の公開リゾルバに対してしかDoHを使わないそうです*6.残念ですね.そのかわりAndroidは「プライベートDNSモード」が「自動」に設定されていると,DoTを使った日和見暗号化を行います.DoTはAndroid Pieから実装されているそうなので*7,多くの現役スマートフォンは対応しているのではないでしょうか.

iOS/iPadOS 16とmacOS Venturaでは何もする必要がありません.最近リリースされたこれらのOSには,DNSゾルバがDoHに対応していることを自動的に検出してアップグレードする仕組みが実装されています.この機能はDiscovery of Designated Resolvers (DDR)と呼ばれています.AppleのOSのセキュアDNSへの対応は,DNSSECの話と併せてWWDC22でも紹介されていたようです*8

RubyKaigiの参加者はMacBookを使っている割合が比較的たかいと思いますが,せっかくなので他OSのユーザ向けにDDRをどのように実装すればよいか解説してみたいと思います.DDRの仕様draft-ietf-add-ddrIETFで起稿中で,AppleのOSにもおおよそこのドラフト通りに実装されているようでした.draft-ietf-add-ddrは,同じくまだドラフト段階のdraft-ietf-dnsop-svcb-httpsietf-add-svcb-dnsに依存しています.これらの規格は,DNSゾルバがどのようなプロトコルに対応しているかをDNS上で公開・取得する方法を定義しています.

この記事を書いた時点での最新版はそれぞれdraft-ietf-add-ddr-10draft-ietf-dnsop-svcb-https-12draft-ietf-add-svcb-dns-08です.以下ではこれらの版の内容に従います.

DDRを実装する

さて,みなさんの端末がどのようなOSで動いているとしてもRubyはインストールされているでしょうから,ここからはRubyのコードを使って説明をします.

まず,Bundlerで必要なライブラリをインストールするためにGemfileを用意します.

source 'https://rubygems.org'

# DoHは,HTTPS,Base64,URI Templateを知っていれば実装できます.
gem 'net-http'
gem 'base64'
gem 'addressable'

# もちろんDNSの知識も必要でした.
# RubyのDNSライブラリ(resolv)はDDRに必要な機能にまだ対応していないので,パッチを当てたものを使います.
#
# このパッチの詳細については以下のプルリクエストを参照してください:
# - https://github.com/ruby/resolv/pull/32
# - https://github.com/ruby/resolv/pull/33
gem 'resolv', git: 'https://github.com/hanazuki/ruby-resolv', branch: 'svcb-dns'

そして,Rubyスクリプトを書きます.

#!/usr/bin/env ruby
require 'addressable'
require 'base64'
require 'net/http'
require 'resolv'

DoHResolver = Data.define(:hostname, :socket, :dohpath)

# Wi-Fiに接続するとDHCPで端末にIPアドレスが割り振られ,DNSリゾルバ(たち)のIPアドレスを教えてもらえます.
# 従来のDHCPの機能では,これらのリゾルバがどのような暗号化プロトコルに対応しているか伝える方法がありません.
#
# RubyKaigiへバーチャル参加の方は,1.1.1.1や8.8.8.8に書き換えて試してみてください.
RESOLVERS = %w[192.50.220.164 192.50.220.165]

# IPアドレスを与えられたときにDoHをサポートしているリゾルバを探して接続する手続きです.
def connect_doh_resolver(unencrypted_resolvers)

  # DHCPで教えてもらえるDNSリゾルバは優先度順に並んでいるので,前から順番に試します.
  unencrypted_resolvers.each do |unencrypted_resolver|

    # ブートストラップのため,まずは平文で問い合わせをします.
    Resolv::DNS.open(nameserver: [unencrypted_resolver]) do |dns|
      dns.timeouts = 3

      # "resolver.arpa."は,問い合わせ先のDNSリゾルバ自身を表す特殊なドメインです.
      # このリゾルバがDNSのサービス(_dns)を,どのようなプロトコルで提供しているか(SVCB)を問い合わせます.
      svcb_rrset = dns.getresources('_dns.resolver.arpa.', Resolv::DNS::Resource::IN::SVCB)

      # RubyKaigiで運用しているリゾルバは,このようなSVCBレコードを返します:
      #
      #                                   優先度 -- 小さい方が強い
      #                                   |  ターゲット名
      #                                   | /                        ここから後ろはパラメータ
      #                                   | |                       /
      #   _dns.resolver.arpa. 300 IN SVCB 1 resolver.rubykaigi.net. alpn="h3,h2" dohpath="/dns-query{?dns}"
      #                              SVCB 2 resolver.rubykaigi.net. alpn="dot"
      #                              SVCB 9 resolver.rubykaigi.net. alpn="http/1.1" dohpath="/dns-query{?dns}"
      #
      # HTTP/3とHTTP/2でのDoHと,DoT,おまけとしてHTTP/1.1でのDoHをサポートしているとわかります.

      # 優先度順に並べかえます.
      svcb_rrset.sort_by!(&:priority)

      # 今回は簡単のためにAliasMode(優先度0のレコード)への対応を省きます.
      # ざっくりいうと,AliasModeはCNAMEのように別のドメイン名を参照するよう指示するものです.
      fail 'TODO: AliasMode is not supported' if svcb_rrset.any?(:alias_mode?)

      # HTTPファミリーのプロトコルをサポートしていて,dohpathパラメータの指定されているSVCBが,
      # DoHに使うのに適しています.
      #
      # 通常,DoHではHTTP/2またはHTTP/3を使いますが,今回は簡単のためHTTP/1.1を使うことにします.
      # HTTP/1.1と同じくTCPを使うHTTP/2のターゲットにもHTTP/1.1を喋れないか聞いてみることにします.
      svcb_rrset.select! do |svcb|
        svcb.params[:dohpath] &&
          (svcb.params[:alpn]&.protocol_ids&.include?('http/1.1') ||
           svcb.params[:alpn]&.protocol_ids&.include?('h2'))
      end

      svcb_rrset.each do |svcb_doh|

        # ターゲット名からIPアドレスを引きます.
        # IPv6に対応するにはここでAAAAレコードも引きますが,今回は省きます.
        a_rrset = dns.getresources(svcb_doh.target, Resolv::DNS::Resource::IN::A)
        fail "#{svcb_doh.target} does not serve over IPv4" if a_rrset.empty?

        # それぞれのIPアドレスについて,TLSハンドシェイクを試してゆきます.
        a_rrset.shuffle.each do |a|

          # Aレコードで手に入ったIPアドレスに向けたTCPソケットを作ります.
          # SVCBレコードでポート番号が指定されていれば従います.
          socket = TCPSocket.new(a.address.to_s, svcb_doh.params[:port]&.port || 443)

          # ALPNで,サーバがHTTP/1.1に対応しているか訊きます.
          ctx = OpenSSL::SSL::SSLContext.new
          ctx.set_params(alpn_protocols: %w[http/1.1])

          # TLS接続をする際に,実際の接続先サーバのIPアドレスではなく,
          # 最初にDHCP等で設定された平文DNSリゾルバのIPアドレスを使ってサーバ証明書を検証します.
          # これがDDRの肝で,ここまでの平文DNSの通信が改竄されていた場合に気づくことができます.
          #
          # 現在のnet/httpライブラリでは,このような検証方法に対応していないようなので,
          # このコードではローレベルなAPIを使っています.
          ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ctx)
          ssl_socket.sync_close = true
          ssl_socket.hostname = unencrypted_resolver  # <- これが最も大切
          Timeout.timeout(3) { ssl_socket.connect }

          return DoHResolver.new(
            hostname: unencrypted_resolver,
            socket: Net::BufferedIO.new(ssl_socket),
            dohpath: Addressable::Template.new(svcb_doh.params[:dohpath].template),
          )
        rescue
          warn $!
        end
      rescue
        warn $!
      end
    end
  rescue
    warn $!
  end

  fail 'No resolver supports DoH'
end

# ここからは,DoHでクエリを行う手順です.
def doh_query(resolver, qname, qtype)

  # DoHでは,クエリ・レスポンスともに通常のDNSプロトコルで使われているのと同じワイヤフォーマットを用います.
  # そのため,既存のメッセージエンコーダ・デコーダをそのまま使い回せます(RubyではResolv::DNS::Message).
  #
  # 従来のDNSでは,16bitのメッセージIDを使ってクエリとレスポンスのメッセージの対応をとります.
  # HTTPはもともとリクエスト・レスポンス方式のプロトコルで,メッセージを対応づける仕組みが組み込まれているので,
  # DoHではDNS層のメッセージIDとして常に0を使います.
  query = Resolv::DNS::Message.new(0).tap do |m|
    m.rd = 1  # 再帰検索を要求
    m.add_question(qname, qtype)
  end

  # DNSメッセージをBase64でエンコードしたもので,SVCBで指定されたURIテンプレートの変数"dns"を置き換えます.
  # 通例,このURIテンプレートは"/dns-query{?dns}"のような値で,
  # 変数を代入すると"/dns-query?dns=AAABAAABAAAAAAAAA3d3dwNrbWMCZ3ICanAAAAEAAQ"のようなURIになります.
  path = resolver.dohpath.expand(
    dns: Base64.urlsafe_encode64(query.encode, padding: false)
  )

  # HTTP GETリクエストを送ります.
  # コンテントネゴシエーションなど,HTTPの機能を使って自然に拡張する余地があるのはDoHのおもしろいところですね.
  req = Net::HTTP::Get.new(path)
  req['Connection'] = 'keep-alive'
  req['Host'] = resolver.hostname
  req['Accept'] = 'application/dns-message'
  req.exec(resolver.socket, '1.1', req.path)

  # HTTPレスポンスを読みます.
  begin
    res = Net::HTTPResponse.read_new(resolver.socket)
  end while res.is_a?(Net::HTTPInformation)

  res.reading_body(resolver.socket, req.response_body_permitted?) do

    # DNSのレスポンスは,HTTPのレスポンスボディに通常のワイヤフォーマットで書かれています.
    # そのままデコードしてあげればレスポンスを読めます.
    if res.code == '200' && res['Content-Type'] == 'application/dns-message'
      return Resolv::DNS::Message.decode(res.body).answer
    end
  end

  fail "Request failed: #{res.inspect}"
end

# それでは試しに,KMC部室のIPアドレスを引いてみましょう.
doh_resolver = connect_doh_resolver(RESOLVERS)
doh_query(doh_resolver, 'kmc.gr.jp', Resolv::DNS::Resource::IN::A).each do |_, _, a|
  p a.address
end  # => #<Resolv::IPv4 192.50.220.129>

DDRの守備範囲

さて,いま見たようにDDRは,DHCPなどで非暗号化DNSゾルバのIPアドレスが与えられたときに,安全にDoHやDoTなどの暗号化プロトコルにアップグレードを行う枠組みです.この安全性はPKIで検証したTLS証明書によって暗号化DNSゾルバを認証することで担保されています.TLS証明書をうまく使うことによって,非暗号化DNSゾルバと暗号化DNSゾルバを別のIPアドレスで運用することも許されています.

非暗号化DNSゾルバと暗号化DNSゾルバのIPアドレスが一致する場合にはTLS証明書の検査を省略してもよい——とも書いてありますが,そうしないクライアントが存在する限りは正規の証明書を用意する必要があります.Subject Alt Name (SAN)にIPアドレスを載せた証明書を手に入れる必要がありますが,Let's Encryptのような有名どころの認証局はIP SANの証明書を発行してくれないので,すこし手間がかかりますね.RubyKaigiでどの認証局を使ったか気になる方は,会場に来て証明書を確認するか192.220.50.0/24のCTログを覗いてみてください.

また,パブリック認証局はプライベートIPアドレスを使う証明書を発行しないため,DHCPで配布するリゾルバのIPアドレスにはパブリックIPアドレスを使う必要があります(リゾルバがインターネットからアクセス可能である必要はありませんし,深く考えずにそうすべきではありません).IPv4アドレスの調達に難がある場合も多いかもしれません.社内環境など,全てのクライアントにプライベート認証局証明書をインストールできる条件であればこれらの要件は緩和されるでしょう.

一方,DDRの枠組みでは防げない攻撃手段も存在します.たとえば,a)攻撃者がDNSゾルバのIPアドレスへの経路を侵害し,認証局から正規のTLS証明書の発行を受けた場合,b)攻撃者にDHCPパケットを捏造された場合,c)攻撃者にSVCBレコードのクエリ・レスポンスを遮断された場合,d)攻撃者の用意した偽のWi-Fiアクセスポイントにつないでしまった場合などです.これらの攻撃からユーザを守るには,a) RPKIなどによる経路情報の認証・証明書透明性(CT)ログの監視による攻撃検知,b) Wi-FiアクセスポイントでのDHCPスヌーピング,d) IEEE 802.1Xによるネットワーク認証など,多層的な防衛が必要です.

このうちc)に関しては,DNRという別の規格が考えられている途中です[draft-ietf-add-dnr].おおまかには,DDRで平文のクエリによって取得していた暗号化DNSゾルバの情報を,最初からDHCPなどのオプションに詰め込んで渡してしまおうという作戦です.

DDRDNRをはじめとした,クライアントがネットワーク環境に応じて適切なDNSゾルバやプロトコルを選ぶための技術が,IETFAdaptive DNS Discovery (ADD)ワーキンググループで開発されています.この分野のRFCドラフトの著者の所属を見ると,クライアントOS・ブラウザの開発元,公開DNSゾルバの運営元,セキュリティアプライアンスの開発元などの企業が揃っていますね.近いうちに一般の環境にも対応が広がるのではないかと期待しています.RubyKaigi 2023のDNSゾルバには,安全なWi-Fiを提供するためと言いつつおもしろ半分でDoT/DoH/DDRを実装したので,みなさんもぜひ遊んでみてください.

RubyKaigi 2023を終えて(Day 7追記)

RubyKaigiはあっという間に終わってしまいましたね.私はDay 5に会場で機材整理をしたあと,名残惜しさから予定外に1泊増やして松本の街を楽しみました.3日間の会期は短いですが(かといって,それ以上の期間Wi-Fiを運用し続ける体力はありませんが),ひさしぶりのオフライン・フルスケールでのRubyKaigiは濃密な時間に感じられました.Kaigi Wi-FiがRubyistsのコラボレーションに役立っていたなら幸いです.

会期中にメトリクスを眺めていたところ,いくつか分かったことと分からなかったことがありました.

  • Wi-Fiの同時接続数1300くらいに対して,クエリレートは300–400 qps程度でした.このくらいであればなにも考えずに運用できますね.UnboundもEnvoyもたいへん安定しています.
  • タイプ別のクエリ数では,A/AAAAがほぼ同数でトップで,HTTPSがその7割程度でした.AAAAHTTPSへのアンサーが存在した割合のメトリクスも取れていればもう少しおもしろいデータになったかもしれないですね.PTRのリクエストもそこそこありました.これはmacOSが自機のホスト名を調べるために逆引きを行っているようです.
  • 1/3くらいのクエリがDoHを使っていて,午後休憩の時間帯ではこの割合が1/2近くに達していた瞬間もありました.みんなApple製品を大好きなことがわかりますね.実際のところは,iOS/macOSのスタブリゾルバはDoHリクエストのヘッダでUser-Agentを名乗らないようで,どのくらいの割合がAppleのOSなのか,その他の実装でDoHに対応しているものがあるのかないのかはよくわからないのです.User-Agentがフィンガープリンティングに悪用されがちな問題があるのはわかりますが,相互運用性が気になる立場ではもどかしい気持ちです.
  • DoHクエリのうち10%程度がHTTP/3を使っていて,残りはHTTP/2でした.自宅でドッグフーディングをしていたときにも,iPadOSが極稀にHTTP/3を喋っていたのですが発動条件がよくわかりませんでした.
  • 正しくDNSSEC署名されていたドメインはクエリに対する割合で5%弱でした.まだまだ普及しませんね.

RubyのKaigiでネットワークの記事を書いてもあまり読まれないかと思っていましたが,現地で感想を伝えてくださったRubyistがいたのも大変うれしかったです.Kaigi Wi-FiはあくまでRubyKaigiを支えるインフラですが,せっかくコミュニティメンバーで手作りしているので,Rubyistsにおもしろがってもらえるコンテンツも提供してゆけたらと思っています.その点で,今年は現地で手を動かせるおもちゃを用意できて,刺さる人には刺さっていたようでよかったです.半ばNOCメンバーが好き勝手あそんでいる風にもなっていますが,今年のWi-Fiは落ちなかったのでよしとしてもらいましょう.実は会期明けすぐにKMCのご近所にDDoSが着弾していて,ヒヤッとしたりしました.

RubyKaigi 2024開催地の沖縄は,インターネット上のコンテンツが集まる東京から地理的に遠く,ややチャレンジングな立地ですが,安定したインターネットを提供できるようにがんばりたいと思います.インターネット標準も引き続きウォッチしてゆくつもりなので,また来年もなにかおもしろいものを試せるとよいですね.

それでは沖縄でお会いしましょう!

おまけ

いい話すぎない(???)

*1:最近のW-Fi端末は,MACアドレスをランダム化するなどトラッキングを回避するための努力をするので,実際に接続された延べ台数はよくわかりません.それゆえ,数に根拠はありません.

*2:AdGuard - DNS over QUIC

*3:詳しく追っていませんが,おそらくNLnetLabs/unbound#420のパッチが必要

*4:サイトが落ちていることが多いので,Internet Archiveも探してみてください.

*5:Windows Insiders gain new DNS over HTTPS controls

*6:DNS-over-HTTP/3 in Android — Google Security Blog

*7:DNS over TLS support in Android P Developer Preview — Android Developers Blog

*8:Improve DNS security for apps and servers - WWDC22