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 は、不具合を再現しながら端末が報告するネットワークと機能を素早く確認する方法である。

関連記事