Android VpnService の許可リスト:許可したページがそれでも固まる理由
許可リストはおそらく正しい。ページが固まるのは DNS 応答、ルートテーブル、リゾルバのソケット、または MTU のせいだ。確認する順序を示す。
ドメインを許可リストに入れ、問い合わせはアプリに届き、照合器は許可を返しているのに、ページはいつまでも読み込まれない。この段階で多くの人は照合器を書き直し、ワイルドカード比較を足し始める。私の経験では照合器はたいてい正しく、障害は一つ下の層にある。自分で合成した DNS 応答、設定したルート、あるいは問い合わせを転送するために使ったソケットだ。リストは簡単な部分であり、許可リストが壊れるのはパケットの経路のほうである。
照合器が原因になることはほとんどない
まず切り分けるべきは、ブロックと破損だ。ブロックされた名前は速く、目に見える形で失敗すべきである。ブラウザは名前が存在しないという応答を受け取り、そこで止まるからだ。壊れた名前は固まり、IPv6 で再試行し、別のトランスポートに落ち、読み込み中の表示だけが残る。読み込み中の表示が出ているなら、許可リストはクライアントが扱えない何かを返している可能性が高い。
空の A レコードが典型的な誤りだ。回答ゼロの NOERROR は正しい応答に見えるため、スタブリゾルバはそれを受け入れ、ブラウザは再試行し、AAAA を試し、別のリゾルバを試し、ずっと後になって諦める。応答コード 3、つまり NXDOMAIN を、質問セクションをそのまま返しトランザクション ID を写して返せば、会話は即座に終わる。意図的に拒否するすべての名前に対してこれを使うこと。
IPv6 も既定値ではなく同じ判断を受けるべきだ。A に NXDOMAIN を返し AAAA を通してしまうと、クライアントは IPv6 経由でブロックした名前に到達でき、フィルタが漏れる。実装によっては、ブロックした AAAA 問い合わせに loopback アドレスを返し、代替経路に落ちるのではなく即座に接続を失敗させる。一つの方針を選び、両方のレコード種別に適用し、両方をログに残すこと。
すべてを捕まえるルートはすべてを所有する
DNS だけの設計は addRoute("0.0.0.0", 0) を決して追加しないので、TUN インターフェースに入るのは問い合わせだけで、それ以外は下位のネットワークを使い続ける。これが安全な形であり、Wi-Fi では動くのに携帯網では失敗する許可リストがある理由でもある。
既定ルートが存在した瞬間、すべてのフローは自分のものになる。インターフェースに届いて転送も応答もされない TCP パケットは、クライアントがタイムアウトするまでキューに残る。何も失敗していないので、記録すべきエラーもない。壊れた許可リストに丸一日を費やした人がいたが、実体はポート 443 の転送器が欠けていただけだった。破棄すると決めたパケットのプロトコルと宛先は必ず記録すること。
リゾルバのソケットはトンネルの外に出さなければならない
転送が次の罠の場所だ。通信事業者のリゾルバはトンネルから到達できないプライベートアドレスにあることが多く、転送した問い合わせはどこにも行かず、アプリは読み取りタイムアウトを報告する。承認した問い合わせは到達可能と分かっているリゾルバに送り、そのソケットをまず VpnService.protect() に渡す。保護されていないソケットは問い合わせを自分自身のトンネルに戻し、そこで再びアプリに届いてタイムアウトまで回り続ける。
障害はネットワーク障害とまったく同じに見えるため、バグ報告の中で長く生き残る。送信した問い合わせと受信した応答のカウンタを取ること。前者が増えて後者がゼロのままなら、見るべきはリストではなくソケットだ。
サイズとチェックサムはメッセージなしで失敗する
静かな破棄はサイズからも起きる。大きな回答集合を持つ DNS 応答や、余裕のあるオプションを付けた TCP ハンドシェイクは、明示的に設定していない経路 MTU を超えることがある。断片は上流で消え、クライアントはより小さいサイズで再試行するか、再試行をやめる。インターフェースの MTU を 1280 に設定するのは保守的な選択で、多くの携帯網の経路で持ちこたえる。誰も測っていない既定値を引き継ぐより、明示するほうがよい。
パケットを自分で組み立てるなら、チェックサムがもう一つの静かな殺し屋だ。UDP 長やプロトコルフィールドを省いた疑似ヘッダで計算した UDP チェックサムは、自分の側では正しく見え、受信側では痕跡なく捨てられるパケットを生む。疑似ヘッダを仕様とフィールド単位で突き合わせ、デバッグ中は既知のフロー一つについて計算したチェックサムを出力すること。
VpnService が代わりにやってくれないこと
設計する前に知っておく価値のある制限が二つある。通常のアプリは Wi-Fi の外でシステム全体のプロキシや PAC ファイルをインストールできない。この部分は変わっていない。アプリ単位の経路は変わった。API 29 より前は VpnService.Builder がプロキシをまったく運べず、API 29 以降は setHttpProxy(ProxyInfo.buildPacProxy(uri)) が VPN ネットワーク向けに評価する PAC スクリプトをプラットフォームに渡す。古い API を対象にする場合や PAC モデルで規則を表現できない場合は、自分のコードで規則を評価すること。UDP も無料ではない。ブラウザは SOCKS プロキシ経由で TCP しか送らず、ほとんどの SOCKS5 クライアントは UDP ASSOCIATE を実装しないため、プロキシ方式の設計は TCP が健全に見える一方で UDP を落とすか漏らす。
この二つ目の制限が、UDP ベースのプロトコルが普通のネットワークでは動くのに、VPN が接続した瞬間に失敗する理由だ。トンネルが UDP を運ぶことを想定するなら、転送器はソケット種別を変えた TCP 経路だけでなく、UDP の転送経路を実装しなければならない。
この順序で確認する
リストを順に進め、最初に失敗した段階で止めること。前の段階が保たれない限り、後の確認はすべて雑音にすぎない。
- 設定済みのルートを一覧し、DNS 以外のパケットがそもそも TUN インターフェースに入るかを確かめる。
- ブロックした名前と承認した名前それぞれについて、問い合わせ名、判断、応答コードを記録する。
- リゾルバのソケットが保護され、選んだリゾルバがトンネル内から応答することを確かめる。
- インターフェースの MTU と、手作業で組み立てた応答一つのチェックサムを確かめる。
- 同じページを Wi-Fi と携帯網で繰り返す。事業者のリゾルバと経路 MTU が異なるためだ。
五つすべてが保たれてもページが固まるなら、次の容疑者は転送器そのものだ。捕捉した要求と捕捉した応答を比べ、バイト数の欠落や半閉じのソケットを探す。VpnService.Builder のリファレンスはルート、DNS、MTU の契約を扱っており、protect() は builder のオプションではなく独立した VpnService 呼び出しである。接続済みのインターフェースは最初の契約が満たされたことだけを意味する。