Android アプリが VPN の外で名前を解決する: バインディング確認手順
VPN が有効でも Android はデフォルトネットワーク経由で名前を解決することがあります。プロセスを VPN ネットワークにバインドし、アプリが実際に使ったリゾルバーを確認します。
VPN クライアント自身のブラウザーは届くのに、Android アプリだけが社内の名前を解決できない。同じビルドが別の端末では動く。URL が変数ではない。変数は、アプリのプロセスが名前解決に使うことを許されているネットワークであり、Android は、有効な VPN がソケットの使うネットワークであることを保証しない。次の確認を順番に行い、リゾルバーのスナップショットが前提と食い違ったところで止める。
ステップ 1: アプリがどのネットワークにいるかを証明する
ステータスバーではなく、アプリ自身のビューを読む。ConnectivityManager から、有効なネットワーク、その LinkProperties、DNS サーバーの一覧、プライベート DNS の設定を取得する。一覧に VPN のリゾルバーが含まれていれば、アプリはそこへの経路を持つ。Wi-Fi のリゾルバーが含まれていれば、そのプロセスにとってトンネルはデフォルトネットワークではない。
VPN アイコンはトンネルが存在することしか示さない。アプリのプロセスがそこにバインドされたことは示さず、Android は常に有効に見えるネットワーク経由で名前解決をルーティングするわけではない。別の端末へ転送するローカル VPN アプリ、インターネットのない Wi-Fi ネットワーク、ベンダーのビルドはどれも、アプリがトンネルのサーバーではなくデフォルトのリゾルバーに問い合わせたままにしうる。
ステップ 2: VPN トランスポートを要求し、プロセスをバインドする
アプリがトンネルのリゾルバーを使わなければならない場合、VPN トランスポートを持つネットワークを要求し、コールバック内でプロセスをバインドする。こうすれば、コードがネットワークの出現を推測しなくて済む:
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
.build()
connectivityManager.requestNetwork(request, object : NetworkCallback() {
override fun onAvailable(network: Network) {
val bound = connectivityManager.bindProcessToNetwork(network)
Log.i(TAG, "bound to VPN network: " + bound)
}
})
この呼び出しはプロセス全体のデフォルトネットワークを変えるので、その後に作られるすべてのソケットがそれを使う。すでに存在するソケットは作成時のネットワークを保持するため、バインド前に開いた接続プールは古い経路で解決を続けることがある。戻り値は boolean で、false はバインドが起きなかったことを意味する。短い遅延を置いて再試行し、両方の試行をログに残す。
ステップ 3: バインドを必要な範囲に限定する
プロセス全体のバインドは乱暴だ。テレメトリー、更新確認、サードパーティ SDK もトンネルへ移り、トンネルにインターネットがなければそれらが失敗し始める。単一のリクエストにはネットワークのソケットファクトリーを使い、それ以外は端末のデフォルトのままにする。接続ごとの狭いバインドか、VPN が消えたときに明示的に解除するプロセスバインドか、どちらかのモデルを意図的に選ぶ。
リゾルバーの二つの機能は、エラーを出さずにバインドを無効化しうる。厳格モードのプライベート DNS はクエリをネットワークのリゾルバーではなく名前付きの DoT サーバーへ送り、独自の DNS-over-HTTPS クライアントを積んだアプリはシステムのリゾルバーを完全に迂回する。バインドが失敗したと結論づける前に、両方を確認する。VPN の DNS サーバーに決して届かないクエリは、トンネル側のどのログにも現れない。
ステップ 4: CONNECTED 制約を信頼しない
WorkManager の NetworkType.CONNECTED は、ネットワークが接続されているという意味であり、VPN トランスポートは基盤の Wi-Fi やセルラーリンクが消えていてもこれを満たす。ジョブは実行され、すべてのリクエストが失敗し、再試行ポリシーがそれをスケジューリングのバグに見える嵐に変える。トンネルはネットワークであり、この制約はインターネットの確認ではなかった。
その作業が本当に必要とする機能、たとえば検証済みのインターネットアクセスを確認し、バックオフ付きの上限ある再試行ポリシーを保つ。ジョブがプライベートな名前を必要とするなら、作業の前に選択したネットワークでその名前を解決し、成功しえないリクエストを再試行するのではなく、明確なエラーで早く失敗させる。
ステップ 5: 証拠を記録し、範囲を再テストする
選択したネットワークのトランスポート種別、LinkProperties から得た DNS サーバー、バインドが true を返したか、最初のリクエストの正確な失敗をログに残す。そのうえで、同じバインドの下でプライベート名と公開名を実行する。この組が、アプリがトンネルのリゾルバーを使ったのか、黙って端末のデフォルトへ戻ったのかを示し、それがユーザーの体験する差になる。
サポートする最も古い Android バージョンと、現行のリリースの一つで繰り返す。デフォルト経路を正しく見せるリゾルバーの挙動は、まさに端末とバージョンが異なる場所であり、最新のテスト端末だけで通るビルドは、出荷する範囲で検証されていない。adb shell dumpsys connectivity は、不具合を再現しながら端末が報告するネットワークと機能を素早く確認する方法である。