ESP32でWireGuard接続する

概要

ESP32でUDPを利用した軽いVPN実装であるWireGuardに接続してみたいと思います。VPNですので途中経路の暗号化と、別ネットワークへの接続性を確保することができます。

WireGuardとは?

WireGuard: fast, modern, secure VPN tunnel
WireGuard: fast, modern, secure VPN tunnel

UDPパケットを利用した非常に軽いVPNプロトコルで、マルチプラットホームに対応しています。特徴として設定が単純で、スマートフォンだと設定ファイルのQRコードを読み込むことで接続が可能です。

専用アプリは必要ですがシンプルで使いやすいのと、既存VPNはAndroidとiOSで使えるものが違ったりといろいろ面倒なことを考えなくてもよいVPNになります。

ただログ機能などが最低限だったり、コンフィグ項目の一覧ドキュメントなどが整備されていない気がします。シンプルなのであまり設定が必要ない裏返しでもあります。

WireGuardの設定方法

GL.iNet USB GL-MT300N-V2 (Mango) 無線LAN VPNトラベルルーター 中継器ブリッジ 11n/g/b 高性能300Mbps 128MB RAM コンパクト ホテル用 Openwrtインストール OpenVPN/WireGuardクライアントとサーバーインストール 日本語設定画面
GL.iNet
¥3,999(2024/04/28 04:39時点)
【ワイヤレス モバイル トラベル ルーター】 四つのモード(ルーター/無線拡張/AP/WDS)。たった39グラムで、持ち運びに便利。ご使用の前に、最新のファームウェアをアップグレードしてください!

たとえば、上記のような小型ルーターなどでWireGuardが利用可能です。WireGuard自体はサーバーとクライアントという区分はなく、公開鍵暗号方式で相互に接続可能ですが役割的にハブとなるサーバーと、各種端末のクライアントにわかれます。

サンプル設定

WireGuard Tools - Configuration Generator

上記のサイトを利用して、サンプル設定ファイルを作ってみます。

設定画面です。

項目役割
Random Seed鍵を作成するためのランダムな値でリロードでかわります。通常は自動設定されますので個別には設定しません。
Listen Portサーバー側での待受ポート番号になります。基本的にはこのポート番号で待受をするのが好ましいですが、ルーターからの穴あけなどは別の番号でも構わないと思います。
Number of Clients設定するクライアント端末数です。あとから追加可能ですが必要な数を最初に作ったほうが楽ではあります。
CIDRWireGuardで利用するローカルネットワークのIPアドレスレンジになります。既存ネットワークとは別のものを指定します。
Client Allowed IPs非常にわかりにくいですが、クライアント端末のルーティング設定になります。「0.0.0.0/0」だと全通信をWireGuard経由で通信します。
「0.0.0.0/1, 128.0.0.0/1」などと2つに分割するとロンゲストマッチでローカルネットワークにはデフォルトルートで通信するようになります。
「10.0.0.0/24」とIPレンジを指定するとそのIPレンジだけWireGuard経由で通信をします。
Endpoint (Optional)サーバー側のIPアドレスになります。ルーター側で穴あけしている場合にはルーターのIPで、動的IPの場合にはDDNSなどを利用します。
DNS (Optional)通常ローカルネットワークに接続していると192.168.1.1とかのルーターのDNSサーバーが指定されていると思います。外のネットワークに接続する場合にはそのルーターに接続性がないので指定されたDNSサーバーや「8.8.8.8」などのオープンDNSを指定してください。
Post-Up ruleWireGuardを起動したときに実行するコマンドです。
Ubuntuサーバーなどで動かす場合には指定する場合がありますが、静的な設定でもよいと思います。
Post-Down ruleWireGuardを停止したときに実行するコマンドです。

上記を設定して、作成すると以下のようなコンフィグができあがります。

サーバー用

[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = QLEfffzhvtCnuwXZytwRZ++oFDARYJMD/GVOB6XeKHY=
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

[Peer]
PublicKey = yzZ1/fQ2qu8LN7NFDTf4H/iI9gGRBJPTPNdK0iFgQwQ=
AllowedIPs = 10.0.0.2/32

[Peer]
PublicKey = caaxbL00S7yh6z0AmfqZ1tPwkhuOPXLleOZi/t7rhWo=
AllowedIPs = 10.0.0.3/32

[Peer]
PublicKey = Y7e0WKHd14QA9o6Cot6vzf1c6usIxNO/OiJymnlBzQ0=
AllowedIPs = 10.0.0.4/32

[Interface]の部分が自分になります。51820ポートで待ち受けしており、WireGuardの内部ネットワークだと10.0.0.1/24のIPアドレスになります。PrivateKeyが秘密鍵になります。

[Peer]が3つあり、クライアント端末の3アカウントとなります。割り当てるIPアドレスと公開鍵を指定しています。

クライアント1

[Interface]
Address = 10.0.0.2/24
ListenPort = 51820
PrivateKey = 8I9m/8+OFGJ1atOt17tp5ft+QxWW01gXNbVrplhwHEI=
DNS = 8.8.8.8

[Peer]
PublicKey = SoR4+b5YGBzw2knq1ZvwAbZN/LtnSL0jCN2CntkLrQY=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = myserver.dyndns.org:51820

端末側の設定です。[Interface]が自分の設定で、WireGuardの内部ネットワークだと10.0.0.2/24のIPアドレスになります。PrivateKeyを使ってサーバーに接続します。DNSを8.8.8.8に指定しています。

[Peer]がサーバー側の設定になります。Endpointがサーバー側のIPアドレスとポート番号になり、PublicKeyを利用してサーバー側の秘密鍵が正しいかを確認します。AllowedIPsが0.0.0.0/0のためすべての通信をWireGuard経由にします。

クライアント2

[Interface]
Address = 10.0.0.3/24
ListenPort = 51820
PrivateKey = oAVZNjJKY4WB1iNQ9FK2eVv4Ac+sZkXmPkK+shYjeXg=
DNS = 8.8.8.8

[Peer]
PublicKey = SoR4+b5YGBzw2knq1ZvwAbZN/LtnSL0jCN2CntkLrQY=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = myserver.dyndns.org:51820

[Interface]のみ変わり、[Peer]は同じ設定(=同じサーバーに接続)。

クライアント3

[Interface]
Address = 10.0.0.4/24
ListenPort = 51820
PrivateKey = YCAkjbWFz7EZuYepxgPiWcsRGw2/ovWFVxInuRXogHo=
DNS = 8.8.8.8

[Peer]
PublicKey = SoR4+b5YGBzw2knq1ZvwAbZN/LtnSL0jCN2CntkLrQY=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = myserver.dyndns.org:51820

2と同じく[Interface]のみ変わり、[Peer]は同じ設定(=同じサーバーに接続)。

設定の概要

サーバー側は共通設定で、クライアント情報を追加していく形です。クライアント側は自分とサーバー側の設定をするだけになります。今回ESP32はクライアント側になりますのでクライアント側のコンフィグ相当をESP32に設定する必要があります。

#場所設定名
1Interface(ESP32側)Address10.0.0.2/24
2Interface(ESP32側)ListenPort51820
3Interface(ESP32側)PrivateKey8I9m/8+OFGJ1atOt17tp5ft+QxWW01gXNbVrplhwHEI=
4Interface(ESP32側)DNS8.8.8.8
5Peer(サーバー側)PublicKeySoR4+b5YGBzw2knq1ZvwAbZN/LtnSL0jCN2CntkLrQY=
6Peer(サーバー側)AllowedIPs0.0.0.0/0, ::/0
7Peer(サーバー側)Endpointmyserver.dyndns.org:51820

上記7項目のコンフィグがあります。このうちESP32では6のAllowedIPsは設定できません。ESP32にはルーティングという考えがなく、基本的にはデフォルトゲートウェイしかない状態になります。内部的にはIFを持っていますので、明示的に使い分けることは可能ですが、WireGuard接続をするとすべての通信がWireGuard経由になると思ったほうがシンプルだと思います。

ESP32用WireGuardライブラリ

GitHub - ciniml/WireGuard-ESP32-Arduino: WireGuard implementation for ESP32 Arduino
WireGuard implementation for ESP32 Arduino. Contribute to ciniml/WireGuard-ESP32-Arduino development by creating an acco...

上記のライブラリとなります。Arduino IDEのライブラリマネージャーからインストール可能ですので事前に追加しておきます。

「WireGuard-ESP32-Arduino」が正式名称ですがArduino IDEのライブラリマネージャーは公式ライブラリ以外Arudinoとつけることができないルールがあるので「WireGuard-ESP32」で登録されているはずです。

スケッチ例

#include <WiFi.h>
#include <WireGuard-ESP32.h>
#include <lwip/dns.h>
#include <HTTPClient.h>
#include <WiFiClient.h>

char private_key[] = "8I9m/8+OFGJ1atOt17tp5ft+QxWW01gXNbVrplhwHEI=";  // [Interface] PrivateKey
IPAddress local_ip(10, 0, 0, 2);                                      // [Interface] Address
char public_key[] = "SoR4+b5YGBzw2knq1ZvwAbZN/LtnSL0jCN2CntkLrQY=";   // [Peer] PublicKey
char endpoint_address[] = "myserver.dyndns.org";                      // [Peer] Endpoint
int endpoint_port = 51820;                                            // [Peer] Endpoint

ip_addr_t dnsserver = IPADDR4_INIT_BYTES(8, 8, 8, 8);

const char url[] = "http://httpbin.org/ip";

static WireGuard wg;
WiFiClient client;

void getIp(void){
  HTTPClient http;

  Serial.printf("[HTTP] GET %s\n", url);
  if (http.begin(client, url)) {
    int httpCode = http.GET();
    if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY) {
      String payload = http.getString();
      Serial.println(payload);
    } else {
      Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
    }

    http.end();
  } else {
    Serial.printf("[HTTP] Unable to connect\n");
  }
}

void setup() {
  Serial.begin(115200);
  Serial.println("Connecting to the AP...");
  WiFi.begin();
  while (!WiFi.isConnected()) {
    delay(1000);
  }

  Serial.println("Adjusting system time...");
  configTime(9 * 60 * 60, 0, "ntp.jst.mfeed.ad.jp", "ntp.nict.jp", "time.google.com");
  Serial.println("Connected.");

  struct tm timeInfo;
  if (getLocalTime(&timeInfo)) {
    Serial.print("Local Time  : ");
    Serial.printf("%04d-%02d-%02d ", timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday);
    Serial.printf("%02d:%02d:%02d\n", timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec);
  }

  // Wi-Fi default
  getIp();

  // Start wg
  Serial.println("Initializing WG interface...");
  if (!wg.begin(
        local_ip,
        private_key,
        endpoint_address,
        public_key,
        endpoint_port)) {
    Serial.println("Failed to initialize WG interface.");
  }
  delay(1000);

  // set dns
  dns_setserver(0, &dnsserver);
}

void loop() {
  getIp();

  delay(5000);
}

上記がHTTPでアクセスするスケッチ例です。

  WiFi.begin();
  while (!WiFi.isConnected()) {
    delay(1000);
  }

Wi-Fiにまずは接続します。引数なしですので最後に接続したSSIDになります。接続したことがない場合にはWiFi.begin(“SSID”, “KEY”)などのように引数にSSIDとキーを指定してください。

  Serial.println("Adjusting system time...");
  configTime(9 * 60 * 60, 0, "ntp.jst.mfeed.ad.jp", "ntp.nict.jp", "time.google.com");
  Serial.println("Connected.");

  struct tm timeInfo;
  if (getLocalTime(&timeInfo)) {
    Serial.print("Local Time  : ");
    Serial.printf("%04d-%02d-%02d ", timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday);
    Serial.printf("%02d:%02d:%02d\n", timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec);
  }

WireGuardの接続には時刻情報を利用するので、NTPを利用して時刻合わせをする必要があります。確認のため画面上に現在時刻を表示していますが、必須ではありません。

  // Wi-Fi default
  getIp();

まずはWireGuard経由でないアクセス元IPを取得します。ただし、同じローカルネットワークにサーバーを設置した場合には同じIPになると思います。

  Serial.println("Initializing WG interface...");
  if (!wg.begin(
        local_ip,
        private_key,
        endpoint_address,
        public_key,
        endpoint_port)) {
    Serial.println("Failed to initialize WG interface.");
  }
  delay(1000);

サーバーに接続します。これ以降の通信はWireGuard経由となります。最後にWaitをいれていますが、環境によりすぐに接続完了しないので必要な場合があります。後続処理ですぐに通信をしない場合にはいらないWaitだと思います。

  // ip_addr_t dnsserver = IPADDR4_INIT_BYTES(8, 8, 8, 8);
  dns_setserver(0, &dnsserver);

DNSサーバーを指定しています。上記ではGoogleのオープンDNSになります。IoT系の閉域ネットワークの場合には指定されているDNSサーバーを指定する必要があります。

void getIp(void){
  HTTPClient http;

  Serial.printf("[HTTP] GET %s\n", url);
  if (http.begin(client, url)) {
    int httpCode = http.GET();
    if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY) {
      String payload = http.getString();
      Serial.println(payload);
    } else {
      Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
    }

    http.end();
  } else {
    Serial.printf("[HTTP] Unable to connect\n");
  }
}

通信をしている部分は通常のESP32とまったく同じです。つまりwg.begin()を呼び出さないと接続しているWi-Fi経由のアクセスで、wg.begin()を呼び出すとWireGuard経由のアクセスになります。

既存への影響が少なくVPN化が可能な使いやすいライブラリです。

まとめ

非常に便利なライブラリですが、WireGuardサーバーを構築するのは若干面倒な気がします。Raspberry PiやGL-MT300N-V2 (Mango)などだと比較的情報が多いので立ち上げやすいです。

また内部ネットワークにMQTTサーバーやプロキシサーバーを置くことでESP32のプログラムからは暗号なしのHTTPなどで送信しているが、途中経路はWireGuardで暗号化されているいる環境が作れます。

HTTPやMQTTを暗号化するのは証明書などがありちょっと面倒なのですが、WireGuardを使うことで既存コードへの影響を最小に抑えることができます。

ただ、何個か注意点があり現状のライブラリはMTUが1420固定です。おそらくDS-Liteなどを経由するとMTUが通らない危険性があります。環境により接続できない場合にはライブラリを直接編集してMTUを下げる必要があります。

コメント