Android の VPN は IPv6 で動くのに、ブラウザーは IPv4 を選ぶ
プライベート IPv6 アドレスが消える 4 つの段階を追います。リゾルバー、VpnService の宣言、ブラウザーの候補判定、ルートの配信です。
VPN は起動していて、プライベートな Web UI はノート PC から IPv6 で応答しているのに、Android の端末は同じページを IPv4 で開くか、まったく読み込まない。AAAA レコードは存在し、トンネルは IPv6 アドレスを報告している。クライアント側に不審な点は見当たらない。結果を決めるのは 四つの別々の判断である。どのリゾルバーが答えたのか、VpnService が Android に何を宣言したのか、ブラウザーがどのアドレスファミリーを試す気になったのか、そしてパケットが往復できるのか。既定ルートを足して症状が動くことを願うのではなく、この順に潰していく。
手順 1: AAAA クエリにどのリゾルバーが答えたかを証明する
Android の DNS 経路は一つではない。VPN は自網用の DNS サーバーを押し込めるし、システムは適格と判断したホスト名に Private DNS を使うことがあり、Chrome は chrome://settings/security から独自の暗号化リゾルバーを動かせる。トンネルが 10.61.0.1 を押し込む一方で Private DNS が公開プロバイダーを指している端末では、プライベートゾーン内の名前はそのゾーンを所有する機械では解決されていない。ブラウザーはその名前に対する権限を持たないリゾルバーから答えを受け取り、存在しないものとして扱う。エラー文言に DNS は一切出てこない。
ほかを変える前に、トンネルのリゾルバーへ直接問い合わせる。最初の二つのコマンドは、Android が有効なネットワークにどの DNS サーバーを付けたか、Private DNS が自動か厳格かを示す。最後のコマンドは端末ではなく、トンネル内の任意のクライアントから実行する。
adb shell dumpsys connectivity | grep -A3 "DnsAddresses"
adb shell settings get global private_dns_mode
nslookup -type=AAAA nas.lan 10.61.0.1
手順 2: VpnService が実際に何を宣言したかを読む
Android 側は推測ではなく明示である。VpnService.Builder は addAddress でアドレスを、addRoute でルートを、addDnsServer で DNS サーバーを追加し、allowFamily でトンネル全体を 一つのアドレスファミリーに制限できる。allowFamily(AF_INET) を呼んだ後に IPv6 ルートを足したビルダーは、システムに矛盾する二つを伝えている。システムが残すのはファミリー制限のほうだ。fd00:61::/64 を覆うルートに別プレフィックスのインターフェイスアドレスを組み合わせても同じ理由で失敗する。アドレスは存在するが、そのルートにとって on-link ではないのだ。
アプリのトグルを信じるのではなく宣言を読む。dumpsys netd は tun インターフェイスに付いたアドレスとルートを出力し、dumpsys connectivity の中のネットワーク単位の LinkProperties は、ネットワークが主張するファミリーの隣に DNS 一覧を表示する。
adb shell ip -6 addr show tun0
adb shell ip -6 route show table all | grep tun0
adb shell dumpsys connectivity | grep -E "LinkAddresses|Routes"
tun インターフェイスに IPv6 アドレスがあるのに、サービスのプレフィックスに一致するルートが一本もなければ、ブラウザーが関与する前に IPv6 の配信はすでに壊れている。これは試すのを拒むブラウザーとは別の不具合であり、別の修正が要る。
手順 3: IPv6 を試すかどうかを決めるのはブラウザー
Chromium は受け取った AAAA レコードをすべて使うわけではない。ホストリゾルバーには IPv6 到達性の検査と候補の絞り込みがあり、ネットワークが運べないと判断されれば IPv6 を落としたり優先度を下げたりする。リゾルバーのソースには、その検査と周辺の条件が並べて示されている。論理はバージョンとフラグに依存する。同じトンネル上の 二台の端末が違う挙動をするのは、まさにそのためだ。候補ができた後は Happy Eyeballs が接続ごとにファミリーを競争させるので、応答の遅い IPv6 経路は、速く応答する IPv4 に負けることがある。
推測せず、判断を観察する。chrome://net-export で記録し netlog ビューアーで再生したセッションには、各解決と選ばれたファミリーが出る。chrome://net-internals/#dns にはキャッシュされた結果が出る。自分のリゾルバーで AAAA がある名前について A レコードしか返らない解決は、この段階をまっすぐ指している。
手順 4: パケットが往復できることを確かめる
アドレスの選択は配信ではない。Android はネットワークごとのルーティングテーブルとポリシールールを持ち、fwmark でテーブルを選ぶ。メインテーブルに見えるルートが、ソケットが使うルートとは限らない。ルールを読み、カーネルに送信元アドレスに対する宛先を解決させることで、この二つを切り分けられる。
adb shell ip -6 rule show
adb shell ip -6 route get fd00:61::20 from fd00:61::10
問いに答えるのは route get の出力だ。tun0 と出ているのにパケットが死ぬなら、端末に戻るのではなく、トンネルの MTU とゲートウェイの IPv6 転送へ進む。
::/0 を足すとたいてい悪化する理由
::/0 を足すと、トンネルの主張は 一つのプレフィックスからすべての IPv6 宛先に変わる。端末側では到達性の検査を満たしうる。プレフィックス外のテストアドレスが、今は経路ありに見えるからだ。しかしゲートウェイに IPv6 の上流容量が生まれるわけではなく、多くの家庭用ゲートウェイには動く IPv6 上流がそもそもない。結果は公開 IPv6 のブラックホールである。以前なら IPv4 に落ちていた名前がぶら下がり、プライベートページだけが良く見えるのは、検査が別のものを測っているからにすぎない。
ポリシーのコストもある。トンネルに ::/0 があると、ネットワーク所有者が運ぶことに同意していない通信も含め、すべての IPv6 通信が VPN から出ていく。これは名前のある担当者が決める配備判断として扱うべきで、午前 1 時にやるデバッグ手順ではない。
これを終わらせる作業順
AAAA の答えがゾーンを持つリゾルバーから来ていることを確認し、tun インターフェイスの VpnService と netd の状態を読み、Chromium が候補を落としたかを見るために net-export のトレースを取り、最後に ip -6 route get で往復を試す。実際に失敗した層を直し、分割ポリシーには触れない。プライベート名に公開名と違う答えが必要なら、それが split tunneling の役目だ。
端末のビルド、ブラウザーのバージョン、問い合わせたリゾルバーを記録する。この三つがあれば、「IPv6 is broken」は次の人が再現できる何かになる。