Android VPNトンネルからサブネットを除外する方法
VpnServiceはルートを除外できないため、補集合を追加します。/8用に生成した8つのプレフィックスに加え、残りを捕まえるIPv6・DNS・照会の確認も行います。
AndroidのVpnServiceには除外ルートがなく、APIをどれだけ読んでも作れません。Builder.addRoute()はトンネルに宛先を追加するだけなので、LANセグメントをローカルに保つとは、そのセグメントがルート一覧に一切入らないという意味です。代わりに追加するのはセグメントの補集合で、通常のプレフィックスとして表します。作業は算術であり、いくつかの予測できる箇所で失敗します。
以下の確認を順番に進めます。どれも前の確認が成立していることを前提とするので、下流の症状を修繕するのではなく最初の失敗で止まります。
トンネルが主張する内容から始める
0.0.0.0/0をaddRoute()に渡すと、AndroidはすべてのIPv4宛先がトンネルに属すると判断します。多くのクライアントが分割トンネリングをできないように見えるのはそのためで、アドレス空間全体を主張してからそこから差し引こうとします。ビルダーの中で差し引くものはありません。ローカルに残したいネットワークに対するより具体的なルートは期待とは逆に働き、最長一致のプレフィックスが勝ってそのネットワークはトンネルの内側に入ってしまいます。
ルート一覧がポリシーのすべてです。その背後に宛先ごとの拒否はなく、Builderリファレンスにもそれを提供するメソッドは載っていません。
プレフィックスの算術から補集合を生成する
除外するプレフィックスの補集合は、アドレスツリーの根へ向かう経路上の兄弟の集合です。1から除外する長さまでの各長さについて、その長さで自分のプレフィックスを含むブロックを取り、プレフィックスのすぐ下のビットを反転して出力します。実行するまで抽象的に聞こえます。
10.0.0.0/8を除外すると、128.0.0.0/1、64.0.0.0/2、32.0.0.0/3、16.0.0.0/4、0.0.0.0/5、12.0.0.0/6、8.0.0.0/7、11.0.0.0/8の八本のルートが得られます。どれも10.0.0.0/8を含まず、合わせて残りすべてを覆います。
八行で任意のプレフィックスについてその一覧を生成できます。
import ipaddress
def complement(addr, length):
base = int(ipaddress.IPv4Address(addr))
out = []
for k in range(1, length + 1):
block = base & (0xFFFFFFFF << (32 - k))
sibling = block ^ (1 << (32 - k))
out.append(f"{ipaddress.IPv4Address(sibling)}/{k}")
return out
print(complement("10.0.0.0", 8))
ルート数はプレフィックス長に等しく、それが注視すべき数です。/21の除外一つは21本のルートを要し、読める範囲にとどまります。除外が十個になると数百本になることがあり、通常はジェネレーターではなくネットワーク設計を変えるべきだという意味になります。
アドレスファミリーとトンネルの範囲を確認する
IPv4のルートはIPv6には何の効果もありません。トンネルがIPv6アドレスを主張するなら、IPv6のLANプレフィックスには独自の補集合が必要で、128ビット上で同じ算術を使って生成します。紙の上では除外が機能するのにローカル端末に届かないよくある原因は、ホストにはIPv6で到達しており、切り出されていたのがIPv4だけだったことです。算術が間違っていると結論づける前に、両方のファミリーを確認してください。
範囲は確認のもう半分です。Androidはネットワークごと、アプリごとにルーティングを追跡するため、同じプレフィックスでもVPNの宣言方法によって、あるアプリではトンネルを通り、別のアプリでは直接になることがあります。
DNSとアプリごとのルールを適切な場所に置く
ルートの除外はリゾルバーを変えません。ローカルのホスト名がトンネルのDNSサーバーで解決されながら、そのアドレスはローカルにルーティングされることがあり、不一致はLANから到達できない先に解決される名前として現れます。リゾルバーのポリシーは検索ドメインや分割DNSのルールで意図的に設定してください。ルートが決めてくれると想定してはいけません。
addDisallowedApplication()は一つのアプリをトンネルの外に保ちますが、内側に残るアプリに対して宛先を外に保つわけではありません。protect()は自分で開いたソケットをトンネルから迂回させ、これによってトンネルは自身の通信を捕まえずに済みます。どちらも補集合の代わりにはなりません。
自分の一覧ではなく照会で検証する
トンネルが上がったら、自分で作った一覧を読み直すのではなく、アドレスが実際にどこへ行くのかを端末に尋ねます。iproute2を同梱するビルドでは、adb shell ip route get 10.1.2.3がカーネルが選んだインターフェースを名指しします。公開アドレスに対して同じコマンドを実行し、トンネルを名指しすることを確認します。
さらに二つの確認でループが閉じます。まずローカル名を解決し、次に解決済みアドレスでadb shell ip route getを実行してトンネルではないインターフェースを要求するのは、成功したadb shell ping -c 1 host.lanが証明するのが到達性であって経路ではないからです。トンネルがIPv6を主張するなら、アドレスファミリーの試験をその上で繰り返します。VPNネットワークがルーティングテーブルにどう露出するかはリリースごとに変わっているため、出荷するAPIレベルで試験してください。紙の上で正しく見えるルート一覧と、誤ったインターフェースを選ぶ照会は別の不具合であり、利用者に届くのは後者だけです。
除外一覧が増え続けるなら、ルートの生成をやめ、リモートネットワークを採番し直すか、代わりにトンネルを狭めてください。ジェネレーターは欠けたAPIの回避策であり、設計ツールではありません。