TL; DR
- Linuxカーネルモジュール版のwireguard通信は画像のような仕組みで行われます
- 起動時に作成されるwireguardインタフェースの背後にカーネルモジュールが控えており、あたかもその先にVPNピアが存在するかのように通信が行われます
Wireguardについて
私は宅鯖をやっており、様々なサービスを自宅内で運用しているのですが、これらを外出先からも安全にアクセスしたいと考えVPNを導入しようとしています。 VPNには種類も様々ありOSS・製品などあり、実は過去にOpenVPNを導入したこともあるのですが、運用が複雑だったり速度がそこまでだったりと結局あまり使わなかったという苦い過去があります。
そこで着目したのがWireGuardで、シンプルな設計と高速な通信を特徴としているようです。 詳しくは公式ドキュメントやwhitepaperを参照してほしいですが、最新の暗号プロトコルを利用している他UDPを利用することで高速な通信を実現しているようです。
今回はこのWireguardについて、内部でどのような通信が行われているのかを理解するために、実際に検証してみたいと思います。
検証環境
Wireguardは元々Linuxカーネルモジュールとして開発されたため当然Linuxで動作させることを想定しているのですが、他のプラットフォーム向けに開発されたユーザーランド版もあります。 今回は最もパフォーマンスが出るとされているLinuxカーネルモジュール版を利用して検証を行います。
Linux版についてはlinuxserver.ioからDockerイメージが提供されているので、これを利用します。 ホストOSはUbuntu 24.04.3 LTSで、カーネルバージョンは6.8.0-71-genericです。
このイメージとVPNの確認用のNginxを立ち上げる以下のようなcomposeファイルを用意しました。
services: wireguard: image: lscr.io/linuxserver/wireguard:latest container_name: wireguard cap_add: - NET_ADMIN - SYS_MODULE environment: PUID: 1000 PGID: 1000 TZ: Asia/Tokyo SERVERURL: 172.16.1.55 # optional SERVERPORT: 51820 # optional PEERS: 1 # optional PEERDNS: 1.1.1.1 # optional INTERNAL_SUBNET: 10.13.13.0 # optional ALLOWEDIPS: 0.0.0.0/0 # optional PERSISTENTKEEPALIVE_PEERS: "" # optional LOG_CONFS: "true" # optional ports: - "51820:51820/udp" volumes: - /lib/modules:/lib/modules sysctls: net.ipv4.conf.all.src_valid_mark: "1" restart: unless-stopped
nginx: image: nginx:alpine container_name: nginxコンテナを立ち上げるとログにQRコードが表示されるため、これをiOSのwireguardアプリで読み取ることでVPN接続が確立できます。 この状態でNginxコンテナのIPアドレス(172.23.0.2)にアクセスすると、wireguardコンテナのみからアクセスできるはずのNginxのデフォルトページが表示されるため、正しくVPN接続がなされていることを確認できました。
❯ docker exec -it nginx ip a1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever2: eth0@if79: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP link/ether 7e:c9:c8:60:2b:70 brd ff:ff:ff:ff:ff:ff inet 172.23.0.2/16 brd 172.23.255.255 scope global eth0 valid_lft forever preferred_lft foreverそれでは実際にVPN接続が確立できたところで、検証に移っていきます。
検証
改めて今回の検証環境は以下のようになっており、明らかにしたいのはVPNトンネル部分についてです。
まずはwireguardの設定を見てみます。 コンテナの/config配下にサーバー側設定ファイル(wg0.conf)とクライアント側設定ファイル(peer1.conf)がありますが、これをみるとサーバーのIPアドレスは10.13.13.1であり、クライアントのIPアドレスは10.13.13.2であることがわかります。 また、起動・終了時にiptablesのスクリプトを実行するように設定されていることがわかります。
# wg0.conf[Interface]Address = 10.13.13.1ListenPort = 51820PrivateKey = #省略PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth+ -j MASQUERADEPostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth+ -j MASQUERADE
[Peer]# peer1PublicKey = #省略PresharedKey = #省略AllowedIPs = 10.13.13.2/32
# peer1.conf[Interface]Address = 10.13.13.2PrivateKey = #省略ListenPort = 51820DNS = 1.1.1.1
[Peer]PublicKey = #省略PresharedKey = #省略Endpoint = 172.16.1.55:51820AllowedIPs = 0.0.0.0/0これを踏まえてwireguardコンテナのネットワーク情報を見てみます。 nginxコンテナと比較するとわかりやすいですが、先ほど確認したサーバー側IPアドレスが設定されたwg0インタフェースが追加されていることがわかります。 また、このインタフェース経由でピアへの通信がルーティングされることもわかります。
❯ docker exec -it wireguard ip a1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever2: eth0@if88: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether f2:ac:93:46:4e:60 brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 172.23.0.3/16 brd 172.23.255.255 scope global eth0 valid_lft forever preferred_lft forever4: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN group default qlen 1000 link/none inet 10.13.13.1/32 scope global wg0 valid_lft forever preferred_lft forever
❯ docker exec -it wireguard ip routedefault via 172.23.0.1 dev eth010.13.13.2 dev wg0 scope link172.23.0.0/16 dev eth0 proto kernel scope link src 172.23.0.3ここで実際の実行ログを改めて確認すると、以下のような出力がなされていることに気づくため、先ほどの設定はwireguardの起動時に実行された結果であるとわかります。 つまり、VPNトンネルを作成するためにこれらの設定がなされているわけですが、これをみても(少なくとも)私にはチンンプンカンプンなので、パケットキャプチャをみることで通信を可視化してみます。
**** Activating tunnel /config/wg_confs/wg0.conf ****[#] ip link add dev wg0 type wireguard[#] wg setconf wg0 /dev/fd/63[#] ip -4 address add 10.13.13.1 dev wg0[#] ip link set mtu 1420 up dev wg0[#] ip -4 route add 10.13.13.2/32 dev wg0[#] iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth+ -j MASQUERADEeth0とwg0双方でパケットキャプチャを実施し、結果をマージしたものが下の画像で、赤枠の部分は3wayハンドシェイクの一部(syn・syn/ack)と思しき部分です1。

大きく分けて、
- eth0で取得された、クライアント・サーバー間のwireguardプロトコルパケット(No8/13)
- wg0で取得された、クライアントのプライベートIP・クライアントの通信先間の通信パケット(No9/12)
- L2アドレスがないためhwアドレスがN/Aとなっている
- eth0で取得された、サーバーのプライベートIP・クライアントの通信先の通信パケット(No10/11)
- サイズが14バイト大きい・送信元アドレスが変わっている以外は2番目のパケットと同じ
という3つのやり取りが確認できます。
これをみると先ほどの経路やiptablesの設定がどのように機能しているかがわかってきますが、文章だけではわかりにくいので、図解してみます。
図解
図解をしたのが下の画像となります。
まず、VPNピアからのUDP 51820ポートへのwireguardプロトコルパケットはeth0インタフェースで受信されます。 カーネルモジュールとして動作しているwireguardがこのポートをlistenしており公開鍵暗号を利用してパケットの複合を行います(黒実線)
wireguardカーネルモジュールは、復号されたパケットをwg0インタフェースに「入力」します。 これによって、L2レイヤのアドレスがない生のIPパケットがwg0で受信されます(赤実線)。
ここからはwg0インタフェースでの受信処理を通常のLinuxのネットワークスタックが実施していきます。 上で確認したように、ルーティングテーブルではnginxコンテナの172.23.0.2への通信はeth0経由で行われるとされています。 そのため、このパケットはwg0からeth0へと転送されますが、iptablesの設定でPOSTROUTINGでのMASQUERADEが設定されているため、送信元IPアドレスはeth0のIPアドレスに書き換えられます(青実線)。
IPマスカレード・転送されたパケットはeth0インタフェースから送信され、通常の通信処理が行われます。 インターネットへの通信であれば外部のルーターへと送られますが今回は同じホストのnginxコンテナへの通信なので、dockerのネットワークを通じてコンテナへと到達します(緑実線)。
ここまでが受信から暗号化パケットの転送処理の流れとなります。 続いて、逆側の通信(今回の場合にはnginxコンテナからの応答をVPNクライアントへ返す通信)の流れをみていきます。
基本的には受信の流れの逆となり、nginxからの応答パケットはeth0インタフェースで受信されIPマスカレードが解除され送信先IPアドレスがVPNピアのプライベートIPアドレスに書き換えられます(緑破線)。 ルーティングテーブルではVPNピアのプライベートIPアドレスへの通信はwg0経由で行われることになっているためeth0からwg0へと転送されますが、今回はMASQUERADEが設定されていないためパケット編集は特に行われません(青破線)。
wg0インタフェースから「送信」されたパケットは実際に送信されることはなく、wireguardカーネルモジュールによって暗号化されて(赤破線)、eth0からwireguardプロトコルパケットとして送信されていきます(黒破線)。
先ほどのパケットキャプチャにアノテーションを加えたものが以下の画像です。 Linuxのネットワークスタック内部で完結する青線を除いた部分がキャプチャと1対1に対応していることがわかります。
以上の通信の流れを総括すると、wireguardではwg0の背後に控えたカーネルモジュールによって、あたかもwg0の先にVPNピアが存在するかのように通信が実現されていることがわかります。

まとめ
この記事では、Linuxカーネルモジュール版のwireguardにおけるVPN通信の流れを実際のパケットやネットワークスタック情報を元に説明しました。 図に起こしてみると意外とシンプルな流れであることがわかります。
理解することができたので本来の目的であったwireguardの導入に向けて検証を進めていきたいと思います。
Footnotes
-
実際にはこれよりも前にVPN通信確立のためのwireguardハンドシェイクが行われていますが、今回は割愛します。 ↩