VPNクライアントは接続し、ブラウザは動き、本当に必要なあの一つのコマンドは今も自宅回線から出ていく。逆のことも起き、トンネルが既定ルートを奪ってローカルのファイルサーバーがデスクトップから消える。私はビルドエージェントでこれに遭遇し、社内のアーティファクトミラーに届く必要があった一方でホストの残りは社内LANに残り、クライアントが提示した選択は「全トラフィック」か「なし」の二つだけだった。どちらの向きでも要求は同じで、マシン全体ではなくこのプログラムだけというものだが、多くのクライアントにそのチェックボックスがないのは、ルートテーブルがプログラムという概念を一度も知らなかったからだ。

ルートテーブルがプログラムを見られない理由

プロセスがパケットを送ると、スタックはルートテーブルで宛先アドレスを引く。実行ファイル名もポートも、その参照には入らない。ドメイン名が失敗するのも同じ理由で、トンネルはトラフィックをアドレスへ運び、名前がアドレスに解決された時点でカーネルに残るレバーはルーティングだけになる。だから「このサイトだけVPN」は結局プロキシを意味し、「このプログラムだけVPN」は少数の仕組みのどれかを意味する。

MechanismWhat selectsCoversOn tunnel drop
Socket bindsource addressa client you controlconnections fail
UID rule (Linux)owning userevery process of one accountconnections fail
Firewall profile (Windows)adapter profileone programfails closed
Network namespacenamespace membershipeverything started insideconnections fail
SOCKS hopapp proxy settingone app or one browser profileconnections fail

これらはいずれもプロセス単位のルートではない。どれも判断を、プログラム・ユーザー・アプリ自身の設定を見られる層へ移す。

単一の宛先はやはりルートであり、プログラムの規則ではない。macOSでの定番は「すべてのトラフィックをVPN経由で送信」のチェックを外し、sudo route add -host internal-tuner.example -interface ppp0でホストルートを足し、sudo route delete -host internal-tuner.example -interface ppp0で再び削除することだ。まずifconfigからインターフェース名を読み、ppp0はあくまで例にすぎない。ホストルートではワイルドカードが効かないので複数の名前と話すサービスには名前ごとに一件の登録が要り、この規則はマシン上のすべてのプロセスを対象にして、あなたが想定した一つだけではない。

ソケットをトンネルのアドレスにバインドする

クライアントを自分で制御できるなら、バインドが最も安い選択子だ。トンネルのローカルアドレスにバインドしたソケットは、そこに宛先へのルートがある限りそのインターフェースから出て、マシン上の他のものは通常の経路を保つ。微妙なのは、バインドが送信元アドレスを選ぶ一方でルートテーブルが依然として出口を選ぶ点だ。バインドだけではトンネルへのルートを持たないトラフィックは動かず、「バインドしたのに依然として逆側から出る」という報告のほとんどはこの誤解だ。

import socket
s = socket.socket()
s.bind(("10.8.0.2", 0))
s.connect(("build.internal.example", 443))

高水準のツールも同じ発想を晒しており、curl --interface 10.8.0.2、PHPのCURLOPT_INTERFACE、PythonのHTTPクライアントのsource_address引数がそれだ。アドレスはトンネルに属している必要があり、ここでの打ち間違いはルーティングの不満ではなく普通の接続エラーとして失敗する。アドレスではなくデバイス名にバインドするのはLinuxの特権でWindowsやmacOSには引き継がれず、だから移植可能なスクリプトはアドレスを渡す。

Linux: 所有者で一致させ、プログラム名ではない

カーネルはソケットの所有ユーザーで一致させられるので、専用アカウントが選択子になる。トンネルを独自のルートテーブルに入れ、そのアカウントのトラフィックだけをそこへ送る:

ip rule add uidrange 1001-1001 lookup 100
ip route add default dev tun0 table 100

これはトンネルが既に既定ルートでない場合にだけ働く。クライアントを--route-nopullで起動するか、--pull-filter ignore "redirect-gateway"でOpenVPN 2.4以降がプッシュするリダイレクトを除外し、その後でテーブルエントリを自分で足す。そうしなければ既定が二つ存在し、優先度の低い方が勝つ。意図的にそうする版は、既定ルートをトンネルのゲートウェイに向けるか切断スクリプトから行うもので、トンネルが落ちたときにトラフィックをインターネットから遠ざけるが、それはキルスイッチであり選択子ではなく、誰かが外すまで残る。

より強い版はネットワーク名前空間だ。それを作り、その中でクライアントを走らせ、ip netns execでアプリを起動する。名前空間の外からはトンネルのインターフェースが見えないので、アプリが誤ってホストのルートへ戻ることはない。代償は現実で、名前空間は物理ネットワークに届くためのvethペアと独自のリゾルバファイルを必要とする(マウント名前空間がホストの解決設定を共有しないため)。

二つの失敗の型がほとんどの漏れを説明する。名前解決は今もホストのリゾルバで行われ、TCP接続が内側に入っても問い合わせはトンネルの外へ出る。そしてIPv6には、v4の規則では覆えないため独自のip -6 ruleが要る。

WindowsとmacOS: ファイアウォールを絞るかプロキシを渡す

Windowsにもアプリ単位のルート選択子はないが、ファイアウォール規則はプロファイルに絞れ、VPNアダプターを「パブリック」に分類したまま自宅LANを「プライベート」にできる。「プライベート」と「ドメイン」のプロファイルでそのプログラムの送信ブロック規則を作り、「パブリック」を開けたままにすれば、アプリはトンネル経由でしかネットワークに届かない。まず接続のネットワークカテゴリを設定し、次にファイアウォールの監視ペインで規則が一致したことを確認する。これはブロックでありルートではなく、トンネルが落ちれば閉じた状態で失敗し、ファイアウォールを無視するアプリは止められない。

macOSは別の道具で同じ論理をたどる。SSH経由で届くSOCKSリスナーが最も侵襲の少ない選択肢で、ssh -D 1080 user@hostがローカルポートを一つ作り、それを使うよう設定されたアプリだけがその経路を通る。pfの規則はユーザーやグループでプログラムを遮断できるが、そのプログラムだけのためにトンネルを選ぶことはまだできない。Tunnelblickはドメインで絞ることが全くできず、ルートはアドレスで動くからで、OpenConnectの利用者はサーバーが広すぎるプレフィックス一覧を押し出したときにvpn-sliceへ頼る。

二つの回避策が、プラットフォームの道具が取りこぼす部分を覆う。ドメインごとにプロキシを切り替えるブラウザ拡張は「このサイトだけ」への実用的な答えで、名前がまだ存在するアプリ層で働くからであり、対象は一つのブラウザだけで他はない。荒っぽい方法は、ベンダーのクライアントを仮想マシンで走らせてゲストがルート変更を持ちホストが経路を保つようにするもので、維持すべきネットワークスタックが二つになる。

バインド手段のない道具のためのSOCKSホップ

ほとんどのアプリはインターフェースについて何も知らなくてもHTTPやSOCKSプロキシへ向けられる。SSHクライアントはサーバー側を変えずに一つ用意する:

ssh -N -D 127.0.0.1:9999 [email protected]

そのポートを使うよう設定されたものはすべて踏み台で外へ出て、デスクトップの残りは通常の接続を保つ。トンネルのアドレスにバインドしたローカルの3proxyインスタンスはSSHなしで一つのポートについて同じことをし、stunnelはTLSで包まれた単一ポートを覆う。限界は現実的で、プロキシは使い方を知っているプロトコルしか運ばず、UDPにはSOCKS5 UDP関連付けが要り、多くのクライアントの「SOCKS5」設定では有効にならず、DNSはプロキシで解決する必要があり(curlではsocks5h)、そうでなければ名前がローカルのリゾルバへ漏れる。

別のマシンが要らない限り、リスナーはループバックに置く。

経路は状態表示ではなくキャプチャで確かめる

アプリの状態ウィンドウは設定を述べるだけでパケットは述べない。両方のインターフェースで取得して比べる:

tcpdump -i any -n port 443
ss -tnp | grep 443

接続が物理インターフェースに現れ、トンネル側に対応するエントリがないなら、クライアントが何を報告しても選択子は効いていない。IPv6とリゾルバを別個に確認し、補助プロセスや継承されたシステムプロキシのような第二の経路がないかも確認する。

そして要件に合う境界を選び、一つのサイトにはプロキシで足り、一つのアカウントにはUID規則で足り、接続が閉じた状態で失敗すべきならファイアウォールプロファイルで足り、名前空間だけがトンネルを外のすべてから隠せる。

関連記事