既存の VPN 越しに 2 拠点間で IPv6 をルーティングする方法
IPv4 しか通らない WAN でも内部 IPv6 は運べる。セレクタ、カーネルルート、転送、そして応答を黙って捨てる戻りプレフィックスを追う。
拠点間トンネルに IPv6 プレフィックスを追加しても、パケットは図が示すようには進まない。ローカルゲートウェイは IPv6 パケット全体を取り出して暗号化し、その前に外側の IP ヘッダーを付ける。アドレスファミリーを決めるのは中身ではなく、端点のアドレスだ。遠端のゲートウェイはその外側ヘッダーを剥がし、元のパケットを自分の LAN へ転送する。そこでは再び普通の IPv6 パケットである。外側のトンネルと内側のトラフィックは、鍵と端点の組だけを共有する別々の会話だ。
二つのヘッダー、二つのアドレスファミリー
IPsec トンネルモードでは、内側の IPv6 パケットが ESP ヘッダーの後ろのペイロードになる。その ESP パケットを運ぶ外側ヘッダーは IPv4 でも IPv6 でもよく、local_addrs と remote_addrs に従う。WireGuard も同じことをより少ない儀式で行う。内側パケットはピアの端点へ向かう UDP データグラムのペイロードである。どちらも内側ヘッダーに触れないので、遠端はカプセル解除後にそれをルーティングできる。
拠点間の経路が見るのは UDP 4500(IPsec が NAT 越えを使う場合)か、既定の WireGuard リスナーの UDP 51820 だけだ。途中のルーターは IPv4 専用でもよく、IPv6 トラフィックはそれでも通過する、その経路上で IPv6 パケットとして現れることがないからだ。これで最初の切り分けが明確になる。tcpdump -ni eth0 udp port 51820 は暗号化された経路、tcpdump -ni wg0 ip6 は内側のトラフィックを示す。外側のキャプチャが忙しく内側が静かなら、カーネルが IPv6 をトンネルに入れていない。それはルーティングかポリシーの問題で、リンク状態ではない。
ゲートウェイは転送時に内側の Hop Limit も減らすので、端点は IPv6 経路上のルーターとして数えられる。外側のホップ数と内側の Hop Limit は独立に動き、遠端のゲートウェイで死ぬパケットは転送で死んだのであって、暗号化で死んだのではない。
セレクタと AllowedIPs が許可リストだ
IKEv2 は child SA がどの内側プレフィックスを運べるかを交渉する。strongSwan 5.9 以降では、swanctl.conf に書き出すと次のようになる。
children {
net6 {
local_ts = 2001:db8:aa::/48
remote_ts = 2001:db8:bb::/48
mode = tunnel
esp_proposals = aes256gcm16-prfsha384-ecp384
start_action = trap
}
}
local_ts と remote_ts を省くと、strongSwan は選んだインターフェースのサブネットからそれらを導く。デュアルスタックのホストではこの近道が IPv4 だけの組を生むことが多く、SA は上がるが IPv6 は ESP コードに届く前に拒否される。両方のプレフィックスを書けば SA はそれらを運べるようになり、start_action = trap が最初に一致するパケットで SA を立ち上げる。
WireGuard のリストは ひとつで二つの仕事をする。ピアブロックの AllowedIPs = 2001:db8:bb::/48, fd00:bb::/48 は送信時にインストールされるルートであり、受信時に適用されるフィルターでもある。内側の送信元がそのピアのリストの外にある復号済みパケットは黙って捨てられる。wg-quick は項目ごとにルートを追加するので、下のピアブロックに別の ip -6 route コマンドは要らない。
[Interface]
Address = 2001:db8:aa:ff::1/64, fd00:aa:ff::1/64
ListenPort = 51820
[Peer]
PublicKey = <site-b-public-key>
Endpoint = 203.0.113.20:51820
AllowedIPs = 2001:db8:bb::/48, fd00:bb::/48
二つのリストは鏡像でなければならず、拠点 B は拠点 A のプレフィックスを遠端セレクタとして受け入れ、それを戻す方向にルーティングし、トンネルは両方向が一致して初めて機能する。非対称なリストは最も時間を食う症状を生む。正常なハンドシェイク、片側だけ動くカウンタ、そして A から B への ping は通るのに、B から A へは何も届かない。
転送、ルート、戻りプレフィックス
Linux は指示するまで IPv6 を転送しない。設定はインターフェース単位ではなく名前空間単位だ。
sysctl -w net.ipv6.conf.all.forwarding=1
ip -6 route add 2001:db8:bb::/48 dev wg0
ip -6 route get 2001:db8:bb::10 from 2001:db8:aa::10
二行目こそ、ルートベースの設計で忘れられる行だ。ポリシーベースの IPsec はプレフィックスに一致する XFRM ポリシーを入れるが、vti6 や WireGuard を使うルートベースの構成には、そのデバイスを指す実際の IPv6 ルートが要る。sysctl を /etc/sysctl.d/ の下に永続化しよう。さもないと再起動で転送が黙って無効に戻る。
転送には知っておくべき副作用がある。カーネルはその後、net.ipv6.conf.<wan>.accept_ra が 2 でない限りルーター広告を無視するので、 WAN アドレスを SLAAC で受け取るゲートウェイは、転送を有効にした瞬間にデフォルトルートを失うことがあり、同じファイルでそのインターフェースに accept_ra = 2 を設定すればルートは残る。拠点 B のコアもトンネルゲートウェイから 2001:db8:aa::/48 を学ぶ必要がある(静的ルートか、トンネル上のルーティングプロトコルで)。さもないと応答は B 自身のネットワーク内で死ぬ。
断絶箇所を特定する 四段階チェック
この順で進め、最初に失敗した段階で止める。後の段階は前の段階に依存しているからだ。
- セレクタ:
swanctl --list-sasが child SA 上の IPv6 プレフィックスの組を示すか、wg show wg0 allowed-ipsが遠端プレフィックスと新しいlatest-handshakesを示すべきだ。 - カーネルルート:
ip -6 route get 2001:db8:bb::10は WAN インターフェースではなくトンネルデバイスで答える必要がある。 - 内側キャプチャ:実際の LAN ホスト間で ping しながら
tcpdump -ni wg0 ip6を実行する。出て行くが戻らないパケットは B のルートかファイアウォールを指す。パケットが全くないなら第 1 か第 2 段階に戻る。 - 宛先:B ホスト上で
ss -6 -ltnがサービスが IPv6 で待ち受けていることを確認し、ローカルのファイアウォールカウンタがパケットの受理か破棄を示す。
MTU も同じ回で見る。IPv6 ルーターは断片化しないので、大きすぎるパケットは破棄され、Packet Too Big が送信者に返る。ハンドシェイクと小さな ping は通るのにファイル転送が止まるなら、通るまでペイロードを小さくする。ping -6 -s 1400 -c 3 2001:db8:bb::10。WireGuard インターフェースの既定 MTU は 1420 バイトで、IPv4 上の IPsec はさらに削るので、内側 MTU を設定する方が、ICMPv6 を捨てるファイアウォール越しの経路 MTU 探索に期待するより確実だ。
ファイアウォール問題に見える障害モード
最も多いのはファイアウォールですらない。サービスが :: ではなく 0.0.0.0 に束縛され、AAAA レコードは存在し、クライアントは IPv6 で即座に拒否され、デュアルスタックのブラウザは IPv4 に落ちてそれを隠す。一部のアプリだけ失敗するなら、ルールセットに触る前に待ち受けアドレスファミリーを確認しよう。
二つ目は ICMPv6 のフィルタリングだ。Neighbor Discovery と Packet Too Big は通常運用の一部であり、診断ではない。すべての ICMPv6 を捨てるポリシーは、接続し、認証し、そして小さなパケットより大きいものすべてで止まるトンネルを作る。経路が依存するタイプを許可し、破棄が見えるようカウンタを残そう。
三つ目は、トンネルには追加されたが、それを所有する拠点のアドレス計画には決して追加されないプレフィックスだ。だからどのルーターも広告せず、どのホストもそこから応答できない。ひとつのプレフィックスと一組のホストから始め、四段階で両方向を証明してから残りを足し、壊れたプレフィックスは設計のやり直しではなく、設定の一行で済む。常に開いておく価値がある公式リファレンスは RFC 8200 で、トンネルが運ぶヘッダーを定義している。