VM内のVPN経由でホストの通信をルーティングする:六つの確認
VMをホスト自身が通信をルーティングするVPNゲートウェイにするための六つの順序立った確認:アダプターモード、転送、NAT、ホストルート、DNS、MTU。
順番に実行する六つの確認が、VMが自分のホストのVPNゲートウェイになれるかを決める。ハイパーバイザーはこのうち何も代行しない:トンネルはゲストが、ルートはホストが持ち、その間のネットワークはただのルーティングだ。この一覧を手放さないのは、手順を一つ飛ばすといつも同じ症状になるからだ:ホストはゲストに快くpingを返すのに、内部の何にも届かない沈黙だ。
まず理由から始める。企業クライアントがゲスト内にしか入れられないのか、ポリシーがそこに留めているのか、いずれにせよホストから内部ビルドサーバーには届かせたい。ホストに自前のクライアントを置けるならそちらを選ぶべきで、ホスト上のトンネル一本は二つのネットワークとNATの組み合わせにいつも勝る。
確認 1:ホストにはアドレスを振れるリンクが必要
ホストは、ゲストの二番目のアダプターと同じプレフィックスにあるインターフェースを持つ必要がある。VirtualBoxのhost-only、VMware Workstationのhost-onlyまたは独自VMnet、QEMU/KVMの内部ブリッジやtapデバイス、Hyper-VのInternalスイッチはすべて条件を満たす。ゲスト 192.168.56.2、ホスト 192.168.56.1、プレフィックス 192.168.56.0/24 が、私がラボで使う配置だ。
QEMUのユーザーモードネットワーク(-netdev user、既定のSLIRPバックエンド)とVirtualBoxのNATは、この確認で完全に落ちる。ゲストのパケットはホスト上のプロキシプロセスに届くだけなので、ホストはゲストのアドレスを載せたデバイスを一度も見ず、ルートを向ける先も持たない。Hyper-VのDefault Switchも同じ話だ。ホストからの ping 192.168.56.2 が失敗するならそこで止める:後から足したルートでは直らない。
確認 2:ゲストが転送し、変換する
ホストからのパケットはhost-only側の送信元アドレスと内部の宛先で届くので、ゲストはそれを転送し、自分のトンネルアドレスの背後に隠す必要があり、Linuxゲストなら転送、FORWARD accept一組、トンネル向けのsource NATということになる。
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -A FORWARD -i eth1 -o tun0 -j ACCEPT
sudo iptables -A FORWARD -i tun0 -o eth1 -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo iptables -t nat -A POSTROUTING -s 192.168.56.0/24 -o tun0 -j MASQUERADE
MASQUERADEルールこそが抜け落ちる部分だ。企業のルーターは 192.168.56.0/24 の存在をほとんど知らず、このルールがあれば内部サーバーはゲストのトンネルアドレスを見て応答し、その応答はゲストへ戻る道を見つける。
Windowsゲストはもっと不器用だ。SYSTEM\CurrentControlSet\Services\Tcpip\Parameters の下で IPEnableRouter=1 を立てると変換なしの転送になり、企業側のルーティングがすでにhost-onlyプレフィックスへ戻っている場合しか動かない。Internet Connection Sharingは変換するが、共有アダプターを 192.168.137.1 に振り直すので、ホストのルートを書く前に実際に取ったアドレスを読むこと。
確認 3:ホストのルートはトンネルではなくゲストを指す
ゲスト越しに送るのは、ホストが必要とするプレフィックスだけにし、Linuxならプレフィックスごとにルート一本で足り、きれいに元へ戻せる。
sudo ip route add 10.20.0.0/16 via 192.168.56.2
ip route get 10.20.30.40
Windowsでの相当は route add 10.20.0.0 mask 255.255.0.0 192.168.56.2 だ。テスト中は -p を外しておき、壊れたルートが再起動で消えるようにし、経路が通ってから付け、ホストの既定ルートは、ホストのすべてのアプリがトンネルを使うべき場合を除いて消さず、本当にフルトンネルが要件なら /1 のルートを二本足し、ハイパーバイザーのネットワークへの直接ルートは残す。
sudo ip route add 0.0.0.0/1 via 192.168.56.2
sudo ip route add 128.0.0.0/1 via 192.168.56.2
Windowsホストではインターフェースメトリックも確認する。host-onlyアダプターが無効な間に足したルートはテーブルに残っても通信を運ばず、メトリックの低い二番目のアダプターが同じプレフィックスの検索に勝つことがある。
確認 4:DNSはトンネルの既定ではなくルートに従う
ルートはパケットを動かし、リゾルバーはどの名前がパケットを生むかを決める。ホストのリゾルバーを置き換えるのではなく、企業リゾルバーを企業ゾーンに限定し、新しいルートがすでに届くDNSサーバーへ向ける(ホストでsystemd-resolvedを使うなら次のとおり)。
resolvectl dns eth1 10.20.0.53
resolvectl domain eth1 '~corp.example.com'
Windowsでは名前解決ポリシーテーブルのエントリーが同じ役割を果たし、Add-DnsClientNrptRule -Namespace ".corp.example.com" -NameServers 10.20.0.53 を使う。ゲストがhost-onlyアドレスで独自のリゾルバーを動かしているなら、loopbackではなく 192.168.56.2 で応答することを確認したうえで、そのアドレスを使う。
次に、遅いページをDNSのせいにする前にMTUを確認する。トンネルのインターフェースはホストの1500バイトのインターフェースより小さいので、DFビットを立てたフルサイズのセグメントはゲストのどこかで捨てられる。典型的な症状は、ping は通るのに内部のWebアプリがTLSハンドシェイク後に止まることで、ゲストの転送経路でMSSを絞る。
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
確認 5:両端から検証する
ホストから内部アドレスへ traceroute または tracert を打つと、最初のホップに物理ゲートウェイではなく 192.168.56.2 が出るはずだ。ゲスト側では同じ試行が出ていく様子を見る。tcpdump -ni any host 10.20.30.40 はhost-only側で転送された要求と、トンネル上で変換された要求を示し、外に出て戻ってきたのにホストが黙ったままなら、戻り経路を疑う。
その大半は二つの失敗で説明できる。NATルールが無いためにゲストのトンネルが host-onlyプレフィックスへの戻りルートを持たない か、再起動後にゲストが持たなくなったアドレスをホストのルートが指しているかだ。前回のメモを信じず、ip -br addr と ip route get でアドレスを読み直す。
確認 6:再起動とハイパーバイザーの更新に耐えさせる
両方を永続化する。ホスト側はnetplanかNetworkManagerのルート設定、Windowsでは route add ... -p、そして /etc/sysctl.d/ に net.ipv4.ip_forward=1 を置き、ゲスト側で再読み込みされるファイアウォール規則も用意して、設定を確認できたら次の企業クライアント更新が来る前にゲストのスナップショットを取る。
host-onlyネットワークは、ハイパーバイザーがアダプターを作り直すと採番し直され、192.168.56.0/24 の上に築いたルートとNATルールが黙って無効になる。更新後は最初の三つの確認を順にやり直し、一分で済むうえ、ホストにルートが残ったままゲストが新しいサブネットへ移ったケースを捕まえられる。
IPv6には独自の確認が要る。ip6tables のルール、AAAAレコード、二つ目のホストルート群はIPv4の設定に追随せず、IPv6で届く企業サービスはIPv4が動いている間ずっと壊れて見える。
合否は順に、ホストがゲストのhost-onlyアドレスにpingできる、ゲストがトンネルへ転送とMASQUERADEをする、ip route get が内部プレフィックスのゲートウェイとしてゲストを返す、企業名が限定リゾルバーで解決される、traceroute が最初にゲストを示す、そして同じ六つの答えが再起動後も残る。