Android VpnService トンネルが通信をルーティングしなくなる理由
本番環境で破綻する VpnService の四つの思い込み: 同時に一本のトンネル、ルートの範囲、アプリ別リスト、停止の順序。それぞれに確認方法を添えます。
Android VpnService についての四つの思い込みで、私はかつて一週間の大半を無駄にしました。そのどれもプロトコルの話ではありません。アプリは接続済みと表示し、鍵のアイコンはステータスバーに居座り、通信は今も通常のルートから出ていました。私はトンネルが自分で止めるまで自分のものだと思い込み、追加したルートがそのまま手に入るルートだと思い込み、stopSelf を呼べばインターフェースも一緒に落ちると思い込んでいました。サービスは文書どおりに動いていました。壊れていたのは私のメンタルモデルです。
神話 1: トンネルは止めるまで立ち続ける
Android は一度に一つの VPN インターフェースしか保持しません。二本目を確立すると、プラットフォームはそれを待ち行列に入れず、あなたのものを無効化して、あなたのサービス上で onRevoke() を呼びます。コードがボタン押下でしか解体しない場合、乗っ取りはディスクリプタとリーダースレッドを残し、次の起動がその両方を引き継ぎます。
VpnService のリファレンス は明快です。アプリがすでに同意を持っていれば prepare() は null を返し、ユーザーがまだ承認する必要があれば Intent を返します。その承認は、インテントを startActivityForResult() 経由で渡したときにだけ届きます。受け渡しを省くと establish() が null を返し、これは権限の問題ではなく静かな失敗として読めてしまいます。
Always-on VPN はあなたが制御できない所有者を加えます。システムは起動後やクラッシュ後にトンネルを復活させることがあり、ユーザーが手動で起動した VPN アプリを追い出します。onRevoke() は通常の停止と同じリソースを閉じるように作り、これを例外的な経路ではなく主要な停止経路として扱ってください。ユーザーは VPN アプリを乗り換えます。コードはそれに耐える必要があります。
神話 2: addRoute は列挙したとおりに捕捉する
ルートはどのパケットがディスクリプタに届くかを決めます。範囲の誤りは、一部の通信だけがトンネルを通るという腹立たしい状況を生みます。addRoute("0.0.0.0", 0) は IPv4 をすべて取るので、すべてのパケットがあなたに届き、転送するか捨てるかが必要になります。単一ホスト向けの /32 はほとんど何も取らず、それ以外は VPN を一度も入れたことがないように振る舞います。
ファミリーは別々に数えられます。IPv4 だけのルートは IPv6 を下位ネットワークに残すため、デュアルスタックのホストはトンネル内では完全につながって見えながら、外では名前解決も接続もできてしまいます。
DNS も同じ論理に従います。addDnsServer() で追加したサーバーはトンネルのリゾルバーを使うアプリに届きますが、独自の暗号化 DNS エンドポイントを持つアプリや Private DNS をホスト名に設定したアプリはそれを完全に無視します。あるアプリでは名前解決が失敗し別のアプリでは成功するときは、ルートに触る前にリゾルバーの経路を確認してください。
神話 3: アプリ別ルールはどちらにも切り替えられるスイッチである
addDisallowedApplication() と addAllowedApplication() は二つのフィルターではなく、二つのモードです。ビルダーはトンネルごとに一つのモデルしか受け付けないので、三つのパッケージ以外すべてを望むアプリは禁止リストを使い、許可リストは空のままにします。両者は排他で、設定画面がすでに両方のトグルを出した後で、実行時にそれが分かります。
パッケージの可視性も噛みつきます。Android 11 以降では見えるパッケージしか指定できないため、アプリが実行を許されていないクエリから組み立てた除外リストは、黙って項目を取りこぼします。信じる前に、パッケージマネージャーが実際に返すものとリストを突き合わせてください。
ワークプロファイルはまた対象を分けます。プロファイル内で開始したトンネルはそのプロファイルとアプリを覆い、個人側は独自の接続を保ちます。これは一度だけ決める範囲の判断であり、サポートチケットではなく UI に属します。
神話 4: stopSelf はトンネルを終わらせる
終わりません。インターフェースは establish() が返すディスクリプタの中に生きており、そのディスクリプタが開いたままサービスオブジェクトは死ねます。まず ParcelFileDescriptor を閉じ、参照を手放し、リーダーとライターのスレッドを join するか中断し、そのうえで stopForeground(true) と stopSelf() をこの順に呼びます。
忘れられたディスクリプタは、サービスが自身の停止要求を生き延びたように見える通常の原因であり、それを追加したアプリより長生きする古いルートの説明にもなります。
逆の間違いも同じくらいよくあります。リーダースレッドが読み取りの途中でディスクリプタを閉じ、そのスレッドに誰も書いていない例外の処理を押し付けるのです。停止の順序を一か所にまとめ、ユーザー操作と onRevoke() の両方からそこを呼ばせてください。
トンネルが死んで見えるときに確認すること
トンネルが上がる前に、長命のソケットを protect() で保護します。それがなければトンネル自身の転送がトンネルに戻され、接続はサーバー障害と見分けがつかない形で止まります。テストはリモートネットワークの背後にしか存在しないアドレスに対して行い、キャッシュや下位経路から答える公開サイトに対して行わないでください。
establish() の結果、ルート、除外したパッケージ、停止理由を記録します。その四行があれば、パケットキャプチャなしで同意の問題、範囲の問題、ライフサイクルの問題を切り分けられます。