Android VPN: VpnService のディスクリプタと root tun デバイスの比較
Android アプリは自分で tun0 を作れない。establish() が何を返すか、Binder がどこで止まり JNI がどこから始まるか、root が作ったデバイスがいつ勝つか。
二つの経路が Android アプリの前に IP インターフェースを置くことができ、どちらも同じ種類のファイルディスクリプタに行き着くが、所有者は大きく異なる。一方は VpnService とそのビルダーを通り、もう一方は root シェルと /dev/net/tun を通る。そのディスクリプタ上の読み取りはすべて送信パケットであり、書き込みはすべて受信パケットである。経路を分けるのは、ポリシーを誰が持つか、OS のアップグレードをどちらが生き残るか、ルーティングテーブルのどこまで触れるかである。
プラットフォームの経路を選ぶべきであり、それが断る具体的な内容を名指しできる場合だけ別の道を取る。その拒否は通常 TAP、自分のアプリ集合の外でのキャプチャ、あるいはフレームワークが決して公開しないルーティング規則である。
プラットフォームの経路では、コマンドではなく意図を宣言する。少なくとも一つのアドレス、必要なルート、DNS サーバー、任意のアプリ一覧を渡すと、Android のシステムサービスが自身の ID で特権的な設定を行う。インターフェース名を事前に知ることはなく、それを作る ioctl を発行することもなく、そこが要点である。
IP インターフェースを得る二つの方法
root の経路は古い手法である。 /dev/net/tun を開き、IFF_TUN で TUNSETIFF を呼び、ip link でインターフェースを立ち上げ、アドレスとルートを手作業で加える。どの手順も自分で正しく行う必要があり、フレームワークが黙って処理したはずの手順も含まれる。ルートが一つ欠けるだけで、利用者にはサーバーが落ちたのと同じ報告が届く。
デバッグも設定と同じだけ異なる。root の経路ではインターフェースが存在するか、ioctl が直接読めるエラーを返したかのどちらかである。プラットフォームの経路では establish() が成功した後にしかインターフェースは存在しないため、失敗は例外か null ディスクリプタとして届き、触って確かめられるデバイスノードにはならない。
establish() が実際に返すもの
Builder.establish() には三つの結果があり、それぞれ別の対応が必要である。ディスクリプタはインターフェースが設定済みで使用できることを、null は要求が通らなかったことを、投げられた例外はシステムサービスが拒否したことを意味する。null と成功を一つの boolean にまとめるのは、パケットを一切運ばないトンネルを動作中と報告する最も一般的な方法である。Builder リファレンス はアドレスの要件を示しており、サーバーを責める前に読む価値がある。
方向はディスクリプタが決めるのであって、プロトコルではない。読み取りは VPN 用にすでに選択された送信パケットを返し、書き込みは受信パケットをインターフェースへ注入する。暗号化、認証、フレーミングは自分のトランスポートが行う、プラットフォームはパケット境界で止まるからである。
Binder は一ホップ、JNI は別の軸
ビルダーの要求は Binder を通ってシステムに入り、その下のどこかでプラットフォームがネイティブコードを呼んでデバイスを設定する。一つの呼び出しの中に積み重なった二つの異なる仕組みである。Binder はアプリとシステムサービスの間のプロセス間通信であり、JNI はプロセス内部の Java からネイティブへの橋である。Android 9 の AOSP ツリーを読むとスタックが文字通りに見えるが、内部クラス名やファイルパスはリリース間で移動することを覚えておくべきである。
動かないのは頼ってよい契約であり、ビルダー、それが返すディスクリプタ、その周りのサービスのライフサイクルである。インターフェースを得るために内部のサービストランザクションを呼んだり、デバイスノードを作り直したりするアプリはあってはならない。コードが AOSP のクラス名に依存しているなら、テストしていないリリースで壊れる。
TUN は IP を運び、TAP は Ethernet を運ぶ
TUN は IP インターフェースである。TAP は Ethernet インターフェースであり、その違いがブリッジが可能かどうかを決める。TUN デバイスには MAC アドレスも転送すべき Ethernet ヘッダーもないため、ルートをどう書いても TUN クライアントはレイヤー2 のブリッジに参加できない。
VpnService は IP 版を提供し、アプリ水準の VPN が必要とし、生の Ethernet スタックなしで支えられるのはこれである。設計がブリッジを必要とするなら、プラットフォームの経路では到達できず、root の経路でもカーネルとデバイスの権限が協力する場合だけ到達できる。
root の経路が今も勝つ場面
三つの作業が人をプラットフォームの経路から押し出す。レイヤー2 のブリッジには TAP が要る。自分の VPN 範囲外のアプリの通信を捕まえるには フレームワークが与えない可視性 が要る。ビルダーが決して公開しないポリシールーティング、たとえば一つの宛先のための別テーブルには、自分で入れる規則が要る。
費用は後から来る。ロック解除された bootloader や root 化したイメージ、自分の足元で動く SELinux ポリシー、OTA の後に正しく振る舞わなくなるデバイスであり、root 化した手法は通常のアプリ配布でも出荷できないため、ほとんどのチームは製品ではなく内部の診断経路として保つ。
どちらを選ぶか
持っているコードではなく、必要なインターフェースから始める。答えがアプリ限定のルートを持つ TUN デバイスなら、プラットフォームの経路は短く、アップグレードにも耐える。答えが Ethernet フレーム、他のアプリのキャプチャ、ビルダーが拒むルーティング規則を含むなら、root の領域であり、運用コストとして見積もるべきである。
ほとんどの議論を決着させる確認方法はこうだ。まずプラットフォームの経路を作り、establish() の結果を遠隔セッションの状態とは別に記録し、各方向に一つのパケットを確認し、欠けた機能が対応済みの経路を離れる理由になるかは、それから判断する。