VpnService アプリのスプリットトンネリングは、いくつかの具体的な箇所で壊れ、それぞれ修復方法が異なります。クライアントは失敗した宛先を一つしか表示しないため、まずどの境界に当たっているのかを切り分けます。builder が受け取ったルート、認証できないプロキシ、広告しただけのリゾルバ、あるいはプラットフォームがユーザーごとに適用するアプリ一覧です。だからこそ四段階の確認を用意しています。以下の順に実行し、最初の失敗で止めます。

builder がプラットフォームに渡した内容を確認する

VpnService.Builder は宣言の一覧です。establish() は仮想インターフェース用の ParcelFileDescriptor を返し、プラットフォームは addAddress と addRoute のプレフィックスに一致する通信だけをそのディスクリプタへ送ります。addDnsServer はどのリゾルバに問い合わせるかをクライアントへ伝えます。追加したどのプレフィックスにも入らない宛先は、インターフェースが何を約束してもアプリに届きません。この一つの事実が、コードを変える前のスプリットトンネリング障害報告の大半を説明します。

builder が組み立てたアドレス、ルート、DNS の一覧をログに出力し、失敗する宛先と突き合わせます。管理用ホストの /32 が欠けていることのほうが、トンネルが壊れているよりはるかに多いのです。一覧が正しければ次へ進み、誤っていれば残りのチェックは無駄になります。

excludeRoute は API 33 からしか存在しない

宛先ベースの除外は、最もよくサポートスレッドになる要望です。builder.excludeRoute(IpPrefix) は API 33 で登場しました。それ以前に使える唯一の近似は、除外するプレフィックス以外のすべてにルートを追加することで、補完プレフィックスを自分で計算する必要があります。二つの呼び出しは鏡像ではありません。一方はインターフェースのルート集合から引き、他方は集合をゼロから構築します。古い端末は未知の呼び出しを例外にするか黙って無視します。Build.VERSION.SDK_INT で分岐し、実機で両方の経路を試してください。

補完を計算するときはプレフィックスの整合に注意します。/16 ルート内の /24 除外には、残りを実際に表す被覆プレフィックスが必要で、壊れた /8 一つでは足りません。最終的な一覧をログに残し、除外したホストがトンネル外の経路で解決できることを確認します。

プロキシの資格情報は表現できない

setHttpProxy(ProxyInfo) はインターフェースを HTTP または SOCKS プロキシへ向け、PAC URL は宛先ごとに両者を選べます。ProxyInfo も PAC 形式もユーザー名とパスワードを持たないため、認証が必要なプロキシはプラットフォーム API の外にあります。必要とするアプリは通常、ユーザー空間スタック、tun2socks、あるいは同種のエンジンを組み込み、407 チャレンジに自分で応答します。

この選択には影響があります。ユーザー空間スタックは自分へルーティングした通信しか見えないため、同じディスクリプタ境界の内側で動く必要があり、自身の上りソケットにも同じ protect() が求められます。protect() を省くとトンネルが自分の転送を取り込み、最初のパケットの後に止まるハンドシェイクとして現れます。資格情報を要求するプロキシで試してください。オープンなプロキシはプラットフォーム経路を通るだけで何も証明しません。

アプリ一覧と DNS も宣言である

Builder には addAllowedApplication と addDisallowedApplication の両方があり、両者は排他です。両方を呼ぶと例外になります。いずれにせよ一覧はインターフェース設定時にユーザーごとに適用されるため、仕事用プロファイルのアプリは自分のトンネルではなく仕事用プロファイルのトンネルに従います。どの API も与えないのは、他のアプリの判断を制御することです。外部からパケットをトンネルへ押し込むことはできず、すでに有効な VPN を閉じたり再設定したりもできません。

DNS も同じ構図です。addDnsServer はリゾルバのアドレスを広告するだけで、アプリが問い合わせに答えることはできません。名前を自分で解決したいなら、リゾルバを動かしそのアドレスをトンネルへ流します。固定のホスト名に DoT や DoH を使うアプリは広告したアドレスを迂回できるため、問い合わせ経路も傍受しない限り名前解決はベストエフォートと考えてください。

マニフェスト、常時接続、四段階テスト

適格性はマニフェストで宣言します。BIND_VPN_SERVICE、android.net.VpnService のインテントフィルタ、そしてバックグラウンドでトンネルを維持するならフォアグラウンドサービスです。常時接続 VPN はさらにクリーンな onRevoke を要求します。ユーザーが設定を変えたときや別のアプリが引き継いだときに、システムがこれを呼ぶからです。

確認は順番に実行し、最初の失敗で止めます。第一に、builder へ渡したアドレス、ルート、DNS の一覧を出力します。第二に、establish() がディスクリプタを返し、転送ソケットが保護されていることを確かめます。第三に、含まれるプレフィックス内と除外したプレフィックス内の宛先を一つずつ要求します。第四に、自分のリゾルバしか解決できないホスト名を問い合わせ、実際に応答したリゾルバを特定します。

ハンドシェイクの完了は後の段階を何も証明しません。各確認は一つの層を切り分けるので、第二段階の成功は第四段階について何も語りません。

VpnService.Builder のリファレンスは builder の全表面を列挙しています。それを超えて必要なものは、別の API、別のアプリ、あるいは自前のユーザー空間スタックであり、それはスプリットトンネルの不具合ではなく設計判断です。

関連記事