トンネルは上がり、鍵アイコンも表示されるのに、ブラウザのリクエストはすべてタイムアウトする。VpnService アプリでこの組み合わせは通常、読み取りループは生きていて出力側が壊れているという意味であり、サーバーがハンドシェイクを拒否したわけではない。Android はすでにインターフェースを作成し、アプリの通信をそこへ移し、プロセスにファイルディスクリプタを渡している。そのディスクリプタ以降はすべて自分のコードであり、呼び出しが一つ欠けるだけでストリーム全体が飲み込まれる。

システムが渡すもの、渡さないもの

establish() は tun インターフェース用の ParcelFileDescriptor を返す。システムは外向きの IP パケットをそこへ書き込み、プロセスがそれを読み、行き先を決め、通常のソケットで送ることを期待している。下にカーネルの転送はないため、誰も読まなければパケットは破棄され、インターフェースのカウンタは決して動かない。

推測ではなく計測から始める。読み取りが成功するたびにカウンタを増やし、最初の数パケットの宛先とプロトコルを記録する。通信が発生しているのにカウンタがゼロのままなら、障害は転送コードより上流にあり、調査範囲は builder の設定と読み取りモードに絞られる。カウンタが動くのにブラウザが失敗するなら、障害は送信経路か戻り経路にある。

ブロッキング読み取りがループを動かすかを決める

setBlocking(true) はディスクリプタをブロッキングモードにし、API 21 から存在する。古いリリースでは、生のディスクリプタに対して fcntl で O_NONBLOCK を消す小さな JNI ヘルパーが回避策になる。モードが重要なのは、待機パケットのない読み取りが何をしてよいかを決めるからだ。

非ブロッキングモードではその読み取りは負の値で即座に戻り、結果を無視するループは通信を一切動かさずにコアを回し続ける。ブロッキングモードでは読み取りが待機し、これは転送スレッドが望む動作だが、その代わりスレッドを中断できなくなる。読み取りを失敗させるサポートされた方法はディスクリプタを閉じることで、これでループは終了できる。プロセスを殺したりスレッドを中断したりすると、次の起動がインターフェースと一致しないディスクリプタを抱えることがある。

呼び出しごとにインターフェースの MTU サイズのバッファへ一パケットを読み、ゼロや負の戻り値は空パケットではなくエラーとして扱う。

protect() は最初の送信の前、後ではない

カプセル化パケットを運ぶソケットはすべて先に保護しなければならない。保護されていないソケットは設定したルーティング規則に従うため、パケットはトンネルへ戻り、自分の読み取りループが再び拾ってしまう。その結果トンネルは自分の出力を無限に転送し、外から見ると接続は開くのに応答しない状態になる。

順序は protect、次に connect または bind、次に send だ。connect の後に protect を呼ぶと以前のルート決定が残り、呼び出しが true を返してもソケットは誤った経路を使い続ける。戻り値の真偽を確認し、false なら動作しない送信へ進むのではなく、はっきり失敗させる。

protect() を一度も触っていないソケットで sendto が返す許可エラーは、この誤りの典型的な兆候だ。

UDP のサンプルは HTTPS を運べない

多くのチュートリアルがコピーする転送サンプルは UDP だけを扱う。TCP にはハンドシェイク、シーケンス番号、確認応答、再送が必要で、tun ディスクリプタとソケットの間でペイロードをコピーするループはそのどれも提供しない。だから UDP の DNS 問い合わせは通るのに、HTTPS のリクエストはすべて止まる。ユーザー空間の TCP スタックを動かすか、端末で TCP を終端してストリームを転送するローカルプロキシに接続を渡すこと。

書き換える前に切り分ける。UDP の問い合わせを一つ送り、同じホストへ TCP 接続を一つ開く。UDP 経路が動き、TCP 経路が最初の SYN で止まるなら、問題は転送コードでありサーバーではない。この一度の比較で、有益な情報のないサーバーログを読む時間を大きく節約できる。

ルート一覧とアプリ別フィルタが見える範囲を決める

addAddress、addRoute、addDns がシステムのトンネルへの送信内容を決める。 ルート一覧の外にある宛先はディスクリプタに決して届かない ので、転送ロジックをいくら足しても直らない。自分が行う builder 呼び出しを記録し、アプリが実際に到達しようとするアドレスと比べる。

アプリ別選択には下限がある。addAllowedApplication と addDisallowedApplication は API 21 が必要で、古いリリースではメソッド自体が存在しない。パッケージで絞る設計はそこでは動かない。

再確立は古いディスクリプタを残す

establish() を再度呼ぶと新しいディスクリプタが返り、前のインターフェースは閉じられる。古いディスクリプタは無効になり、そこでまだブロックしているスレッドはエラーを受け、古い経路用に作ったソケットは差し替えが要るかもしれない。onRevoke はユーザーやシステムが取り上げたトンネルのコールバックだ。再確立の前に読み取り側を止める形でライフサイクルを一つの所有者に保てば、接続済みなのに死んでいる状態のほとんどを防げる。

エラーは属する段階ごとに読む。recvfrom の接続リセットは、コードがまだ読んでいる間に相手側が閉じたという意味だ。不正アドレスのエラーはヘッダ解析やオフセットの不具合を示し、ネットワークの問題ではない。

作業順序は、ブロッキングモードを有効にし、読み取りごとに一パケット、ヘッダを解析し、ソケットを保護し、ペイロードを送り、引き継ぎ時にきれいに閉じ、宛先を覆うルート一覧を用意すること。

関連記事