「VpnService アプリがシステムのスイッチを切り替え、それ以降スマホは保護される」というのが誤った前提です。実際に起きることはもっと限定的で、アプリは同意を求め、インターフェースを構築し、ファイルディスクリプタを通じてパケットを読み書きし、停止するときにインターフェースを返します。こうしたトンネルは同時にひとつしか生きられず、システムは尋ねずに取り上げることができます。人々がプロトコルのせいにする混乱の多くは、この所有モデルから来ています。

ライフサイクルの各遷移を、例外的なケースではなく設計の一部として扱ってください。

俗説:アプリがスマホの VPN をオンにする

流れは VpnService.prepare() から始まり、これはユーザーが受け入れる必要のある Intent を返し、establish() へと続きますが、これも後からアプリが制御できない理由で失敗し得ます。ふたつ目のトンネルを確立すると最初のインターフェースが無効になり、システム通知が置き換わります。この手順のどこにも、アプリはデバイス全体のスイッチを所有しておらず、所有しているのは与えられたディスクリプタとそこに届くパケットだけです。常駐通知さえもアプリの設計ではなくシステムに属します。

インターフェースは奪うものではなく与えられるものなので、他のクライアントがいつでもその枠を取れます。アプリはそれを戻り値ではなくコールバックで知ります。

アプリの強制停止が通知シェードに見えるのも同じ理由で、システムは自分が何を与えたかを報告しており、アプリが何を表示しようと選んだかではありません。

俗説:同意は一度だけ与えられる

システムがトンネルを別のクライアントへ渡すとき、基底クラスが onBind() から返す binder に対して取り消しトランザクションを送ります。その binder を自前のものに差し替えるアプリは、取り消しトランザクションを自分で転送しなければならず、そうしなければ VpnService.onRevoke() は決して発火せず、サービスは存在しないディスクリプタへ書き続けます。承認はユーザーが取り消すこともでき、システムは再起動や常時接続の再接続の際にサービスを停止できます。

これへの対処はログ一行ではなく具体的な作業で、ディスクリプタの読み取りを止め、閉じ、接続済みフラグを消し、ユーザーにどの状態かを伝えます。そのうえで、常時接続の構成ではシステムが勝手にサービスを戻すことがあるため、アプリが再確立すべきか、もう一度尋ねるべきか、待つべきかを決めます。

取り消しを状態ではなく例外として扱うアプリは、書き込むパケットがすべて捨てられているのに接続済みと報告し続けますが、これはプライバシー道具にとって最悪の故障モードです。

俗説:インターフェースが通信を選別してくれる

届くのは生の IP です。傍受や選別とは、IP、TCP、UDP、IPv6 の各ヘッダを解析し、フローを追跡し、応答をディスクリプタへ書き戻すことであり、キャプチャアプリはシステムの機能ではなく小さなネットワークスタックになります。protect() があるのは、アプリ自身のトランスポートソケットが自分を捕まえるのではなく自分のトンネルを迂回するためで、トンネルが立ち上がった後に作られるすべてのソケットで確認する価値のある真偽値を返します。

ビルダーの許可リストと禁止リストは、誰の通信がインターフェースに入るかを決めます。それらはインターフェースが運ぶ IP パケットを超えたペイロードへのアクセスを与えず、他のクライアントのトンネルを公開する API も システムの VPN プロファイルを一覧することもありません。ネットワーク機能は、あるネットワークが VPN のトランスポートであるとは言えても、それが誰のトンネルかは言えません。

他のアプリについての状態の問いに矛盾した答えが返ってくるのは、その情報が単にあなたのプロセスに公開されていないからです。

ライフサイクルが実際に求めるもの

自分が制御する状態、つまり準備済み、確立済み、取り消し済み、停止済みを追跡します。再接続は解体してから確立する形にし、ディスクリプタとトランスポートソケットの所有者はひとつにします。確立を競う二つのコンポーネントはただ一つの空き枠を奪い合うため、また現代の Android ではバックグラウンド起動が制限され起動ブロードキャストが保証にならないため、API レベルが許すところでは通知付きのフォアグラウンドサービスを使ってください。

最終状態だけでなく遷移を記録し、すべての経路を試験できる程度に状態機械を小さく保ちます。試験で到達できない状態は、ログなしで現場で出会う状態です。

実機でライフサイクルを試す

エミュレータは接続性を偽装できても、同意と取り消しの経路は説得力をもって偽装できないので、枠を奪う二台目の VPN クライアントを入れた実機でこれらを実行してください。

adb shell dumpsys connectivity | grep -i -A3 vpn
adb shell dumpsys activity services | grep -i -A2 vpnservice
adb logcat -s VpnService:V AndroidRuntime:E
adb shell am force-stop com.example.vpnapp

トンネルを起動し、通知とインターフェースを確認してから、もう一方のクライアントを接続し、取り消しコールバックが走るか、状態が消えるかを見ます。アプリを強制停止し、まだ生きたディスクリプタを報告するものがないか確かめます。

常時接続の再起動のあとで同じ手順を繰り返します。その経路はユーザーが始めた接続とは違うので、探すべきものは緑のアイコンではなく、アプリがもう所有していないディスクリプタを使うのをやめたという証拠です。

関連記事