🔒

WSL2 mirrored mode で SoftEther VPN ON すると外部接続できなくなる問題への対処

に公開

TL;DR

WSL2 mirrored mode (networkingMode=mirrored) を使いつつ、Windows 側の SoftEther VPN クライアントを ON にすると、WSL2 から外部通信ができなくなる

WSL2 内に独自の SoftEther Linux client を立てて Windows VPN と独立して動かす構成で解決した。

問題

WSL2 の mirrored networking mode は Windows 側の Chrome を WSL2 から localhost で叩く (WSL2でもWindows側のログイン済みChromeを操作したい(Chrome DevTools MCP) 等) のに便利で愛用していたが、Windows 側の SoftEther VPN クライアントを ON にすると以下の症状が出た。

  • WSL2 から外部サイトへ ping/curl が通らない
  • Windows ホスト自体は VPN 経由で問題なく通信できる
  • VPN OFF にすれば WSL2 通信は復活
  • NAT mode に切り替えれば動くが、mirrored の利点を捨てたくない

原因

mirrored mode では Windows の VPN仮想NIC が WSL2 側に eth1 として「ミラー」される。WSL2 にも 192.168.30.x 系の IP が振られ、default route を奪う形になる。

ところが mirrored の構造上、SoftEther server からの戻りパケットが WSL2 まで届かない(Windows の SoftEther 仮想LANカードが「自分宛じゃない」と NDIS 層で破棄してしまうと推測)。出は通るが入りが死ぬので、何をやっても外部通信ができない状態になる。

解決策の方針

mirrored 由来の戻り経路問題を回避するため、WSL2 自身が SoftEther client として独立接続する構成にする。Windows VPN とは別のクライアントとして VPN サーバに接続するので、WSL2 への戻りパケットが正しく届く。

全体構成

WSL2 と Windows が 別々の VPN セッション を張る。WSL2 のトラフィックは vpn_xxx 経由で VPN サーバに到達。Windows のトラフィックは Windows の SoftEther client 経由で別セッション。

実装

1. WSL2 に SoftEther client をインストール

sudo apt install -y build-essential libreadline-dev libssl-dev libncurses5-dev libsodium-dev wget

# 公式 stable tarball
VER=v4.44-9807-rtm
DATE=2025.04.16
wget "https://github.com/SoftEtherVPN/SoftEtherVPN_Stable/releases/download/${VER}/softether-vpnclient-${VER}-${DATE}-linux-x64-64bit.tar.gz"
tar xzf softether-vpnclient-*.tar.gz
cd vpnclient
echo -e "1\n1\n1" | make    # 規約3つに同意

sudo mkdir -p /usr/local/vpnclient
sudo cp -r ./* /usr/local/vpnclient/

systemd 化:

/etc/systemd/system/vpnclient.service
[Unit]
Description=SoftEther VPN Client
After=network.target

[Service]
Type=forking
ExecStart=/usr/local/vpnclient/vpnclient start
ExecStop=/usr/local/vpnclient/vpnclient stop
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now vpnclient

1.5. Windows 側 VPN も併用するなら arp_ignore=1 を永続化

Windows 側の SoftEther client も同時に使う運用 (Windows ⇄ WSL2 をそれぞれ独立に VPN 接続) を想定するなら、 Linux カーネルの ARP 挙動を厳格化する設定が必要。これがないと Windows VPN tap が DHCP 取得失敗で APIPA (169.254.x) に fallback してしまう (詳細は Appendix 4-1 参照)。

sudo tee /etc/sysctl.d/99-softether-vpn-arp.conf > /dev/null << 'EOF'
# WSL2 mirrored networking で vpn_xxx が同 subnet (192.168.30.0/24) の
# 他 IP の ARP probe に誤 reply する問題を回避。
# これがないと Windows VPN tap が DAD 失敗で APIPA (169.254.x) に fallback する。
net.ipv4.conf.default.arp_ignore = 1
net.ipv4.conf.all.arp_ignore = 1
EOF
sudo sysctl -p /etc/sysctl.d/99-softether-vpn-arp.conf

WSL2 単独使用 (Windows 側 VPN を使わない) なら不要。

2. VPN アカウント設定

vpncmd で接続設定を作る。

PORT=$(sudo ss -tlnp | awk '/users:.*"vpnclient"/ {print $4}' | grep -oE ':[0-9]+' | head -1 | tr -d ':')

VPNCMD="sudo /usr/local/vpnclient/vpncmd /CLIENT 127.0.0.1:${PORT}"

$VPNCMD /CMD NicCreate myvpn
$VPNCMD /CMD AccountCreate myvpn /SERVER:vpn.example.com:443 /HUB:HUBNAME /USERNAME:user1 /NICNAME:myvpn
$VPNCMD /CMD AccountPasswordSet myvpn /PASSWORD:xxxx /TYPE:standard
$VPNCMD /CMD AccountConnect myvpn

3. IP割当 (静的)

sudo ip addr add 192.168.30.12/24 dev vpn_myvpn

これで split tunnel 状態になる。192.168.30.0/24 のみ VPN 経由、それ以外は自宅 LAN 直。

4. Full tunnel 化 (全通信を VPN 経由に)

ALB/CDN 系の動的 IP サイトへの接続には full tunnel が必要。

# VPN server の公開IP取得
SERVER_IP=$(dig +short vpn.example.com | head -1)

# ★必須: VPN server IP を home LAN経由で固定 (再帰ループ防止)
sudo ip route add ${SERVER_IP}/32 via 192.168.1.1

# 全IP空間を VPN 経由に (default route ではなく /1 + /1 を使う)
sudo ip route add 0.0.0.0/1 via 192.168.30.1 dev vpn_myvpn
sudo ip route add 128.0.0.0/1 via 192.168.30.1 dev vpn_myvpn

これで外部 IP が VPN 出口 IP に切り替わる。

運用化: シェル関数

毎回コマンド打つのは面倒なので、関数にして ~/.bashrc から source する。最低限のテンプレート:

vpn-functions.sh (環境ごとに値を書き換えて使用)
vpn-functions.sh
#!/bin/bash
# WSL2 SoftEther VPN 操作用シェル関数

# ---- 環境固有設定 ----
VPN_ACCOUNT="myvpn"               # vpncmd の AccountCreate で指定した名前
VPN_NIC="vpn_myvpn"               # 自動的に "vpn_<NIC名>" になる
VPN_IP="192.168.30.12/24"
VPN_GW="192.168.30.1"
VPN_SERVER_HOST="vpn.example.com"
HOME_GW="192.168.1.1"
HOME_METRIC="281"

VPNCMD="/usr/local/vpnclient/vpncmd"
VPNCMD_PORT="9930"   # ss -tlnp で実際のポート確認、変わったら更新

# HOME_DEV は動的検出 (WSL2 mirrored で eth* 割当が再起動毎にずれるため。詳細 Appendix 5)
_home_dev() {
    local dev
    dev=$(ip route show default 2>/dev/null | awk -v gw="$HOME_GW" '$3==gw {print $5; exit}')
    if [ -z "$dev" ]; then
        local prefix=${HOME_GW%.*}
        dev=$(ip -br -4 addr show | awk -v p="$prefix" '$2=="UP" && $3 ~ "^" p "\\." {print $1; exit}')
    fi
    echo "${dev:-eth0}"
}

_vpn_port() {
    local p
    p=$(sudo -n ss -tlnp 2>/dev/null | awk '/users:.*"vpnclient"/ {print $4}' \
        | grep -oE ':[0-9]+' | head -1 | tr -d ':')
    [ -n "$p" ] && echo "$p" || echo "$VPNCMD_PORT"
}

_vpncmd() {
    sudo $VPNCMD /CLIENT 127.0.0.1:$(_vpn_port) "$@"
}

_ensure_home_default() {
    local home_dev=$(_home_dev)
    if ! ip route show default | grep -q "via $HOME_GW dev $home_dev"; then
        sudo ip route add default via $HOME_GW dev $home_dev metric $HOME_METRIC 2>/dev/null
    fi
}

vpn-on-split() {
    _vpncmd /CMD AccountConnect $VPN_ACCOUNT > /dev/null 2>&1
    sleep 2
    sudo ip addr add $VPN_IP dev $VPN_NIC 2>/dev/null
    sudo ip route del default dev eth1 2>/dev/null
    _ensure_home_default
}

vpn-on() {  # full tunnel
    vpn-on-split
    local server_ip=$(dig +short $VPN_SERVER_HOST | head -1)
    local home_dev=$(_home_dev)
    sudo ip route add ${server_ip}/32 via $HOME_GW dev $home_dev 2>/dev/null
    sudo ip route add 0.0.0.0/1 via $VPN_GW dev $VPN_NIC 2>/dev/null
    sudo ip route add 128.0.0.0/1 via $VPN_GW dev $VPN_NIC 2>/dev/null
    echo "外部IP: $(curl -s --connect-timeout 5 https://ifconfig.me)"
}

vpn-off() {
    sudo ip route del 0.0.0.0/1 via $VPN_GW dev $VPN_NIC 2>/dev/null
    sudo ip route del 128.0.0.0/1 via $VPN_GW dev $VPN_NIC 2>/dev/null
    local server_ip=$(dig +short $VPN_SERVER_HOST | head -1)
    [ -n "$server_ip" ] && sudo ip route del ${server_ip}/32 2>/dev/null
    _vpncmd /CMD AccountDisconnect $VPN_ACCOUNT > /dev/null 2>&1
    sudo ip addr del $VPN_IP dev $VPN_NIC 2>/dev/null
    _ensure_home_default
}

vpn-mirrored-off() {
    # Windows VPN ON で eth1 が default を奪った時の救済
    sudo ip route del default dev eth1 2>/dev/null
    _ensure_home_default
}

password なしで sudo が動くように /etc/sudoers.d/wsl-vpn-auto も最小限で設定:

yourusername ALL=(ALL) NOPASSWD: /usr/local/vpnclient/vpncmd
yourusername ALL=(ALL) NOPASSWD: /usr/sbin/ip route *
yourusername ALL=(ALL) NOPASSWD: /usr/sbin/ip addr *

ip link を含めない点が重要(誤って eth1 を down する事故防止)。

使い方

日常コマンド一覧

~/.bashrc から source ~/vpn-functions.sh 済みの前提。

vpn-on                    # ★ Full tunnel ON (普段使い)。VPN接続 + IP割当 + 自宅default保証 + /1ルート
vpn-on-split              # Split tunnel ON。VPN接続 + IP割当のみ (192.168.30.x のみ VPN経由)
vpn-off-split             # Full → Split に降格 (VPN接続は維持、/1ルートだけ削除)
vpn-off                   # VPN完全切断 (全クリーン)
vpn-status                # 状態確認 (split / full どちらか表示)
vpn-mirrored-off          # Windows VPN だけON時のWSL2救済 (eth1のhijack default削除)
vpn-route api.example.com # 手動 /32 ルート追加 (split時にホスト指定でVPN経由)
vpn-route 1.2.3.4         # IP直接でも可
vpn-route-rm 1.2.3.4      # 削除
vpn-route-list            # VPN経由ルート一覧
vpn-reset-port            # PC再起動後 WSL2 vpncmd が Windows daemon に流れる時の port swap (試行錯誤中、 Appendix 4-2)

使い分けケース表

シナリオ Windows VPN WSL2 VPN コマンド
普段 (自宅LANだけ) OFF OFF (何もしない)
WSL2 から VPN経由アクセス OFF または ON ON vpn-on (full) or vpn-on-split
Windows だけ VPN、WSL2 は自宅直 ON OFF vpn-mirrored-off を1回
両方同時 VPN ON ON ON 両独立に on/off 可 (arp_ignore=1 永続化が前提、 Appendix 4-1)
再起動後 WSL2 vpn-on で繋がらない - - vpn-reset-port (試行錯誤中、 Appendix 4-2)
終了 OFF OFF vpn-off

Tunnel mode の使い分け

  • vpn-on (Full tunnel) ← 普段使い: 全トラフィック VPN 経由。外部IP は VPN 出口になる。ALB/CDN 系のIP制限サイトもこれでアクセス可
  • vpn-on-split (Split tunnel): 192.168.30.0/24 のみ VPN、それ以外は自宅 LAN 直。軽量、社内固定IPリソースだけ触りたい場合
  • vpn-off-split: full → split に降格 (VPN接続は維持)
  • vpn-off: 完全終了

動作確認環境

  • Windows 11 Pro 24H2/25H2 (Build 26100/26200)
  • WSL2 Ubuntu 22.04, kernel 6.6
  • SoftEther VPN 4.44 Build 9807
  • mirrored networking mode

まとめ

mirrored mode + SoftEther VPN の同居は構造的に難しいが、以下の組み合わせで実用解になった。

  • WSL2 内に独自 SoftEther client (Windows VPN とは別接続)
  • Full tunnel 化には /1 + /1 ルート + VPN server bypass が必須
  • Linux 版 SoftEther は routing 自動化なし、wrapper script 自作が現実的

Windows 版 client なら同じことが GUI のチェックボックス1つで済む処理を、Linux 版では自分で routing 周りを面倒見る必要があり、勉強になりました。

そもそも SoftEther は最近あまり更新されていなそうで、WireGuard 系の方が近代的そうです。Claude に比較調査してもらった印象では、セルフホストで料金が抑えられて配布時の設定も楽そうな NetBird あたりが候補です。

とはいえ SoftEther は純国産で日本語対応が強く、事務の人にもスムーズに導入できているので、まだ強みはあるかなとも思っています。

WSL2 での開発環境まわりはまだまだ罠が眠っていそうで、苦難の道は続きそうです。

Appendix: ハマりどころの詳細

最初は気にしなくていいが、躓いた時に読むセクション。

1. Default route じゃなく /1 + /1 ルート

単純に default via VPN_GW dev vpn_xxx を追加すると 何故か全通信が止まる(VPN gateway への ping すら失敗)。

代わりに 0.0.0.0/1128.0.0.0/1 の2本で IPv4 全空間をカバーする。これは OpenVPN の redirect-gateway def1 で使われている定番テク。

表記 範囲 アドレス数
0.0.0.0/1 0.0.0.0〜127.255.255.255 (前半) 約21億
128.0.0.0/1 128.0.0.0〜255.255.255.255 (後半) 約21億
合計 全 IPv4 約43億

/1 は default より specific (longest prefix match で優先される) ので、元の自宅向け default route を消さなくて済む。

2. VPN server IP の bypass route 必須

/1 + /1 ルートを設定すると、VPN tunnel 自体の宛先 (vpn.example.com の公開IP) も VPN 経由になる → tunnel が VPN 経由で server へ届こうとする無限再帰ループ。

VPN server IP の /32 だけを home LAN 経由に固定して回避する。

sudo ip route add ${SERVER_IP}/32 via 192.168.1.1

これは Windows 版 SoftEther client が自動でやってくれる処理を、Linux 版では手動でやる必要がある(Linux 版は routing 自動化を提供しない設計)。

WSL2 内で sudo ip link set eth1 down してはいけない。

mirrored mode では eth1 は Windows VPN仮想NIC の "view"。WSL2 で link を down にすると、裏側の Hyper-V 仮想スイッチを通じて Windows 側 NIC まで影響し、Windows VPN クライアントは「接続済み」表示のまま Windows ブラウザからの VPN 経由通信が一切できなくなる

eth1 が default route を奪っていたら link を触らずに route 操作だけで対処する:

# OK: hijack default を削除して home に戻す
sudo ip route del default dev eth1
sudo ip route add default via 192.168.1.1

# NG: eth1 を down (Windows VPN仮想NIC を巻き込み破壊)
# sudo ip link set eth1 down  # ← やってはいけない

4. 両 daemon 同時運用時の注意点

WSL2 daemon と Windows daemon を 同時に独立運用する こと自体は構造的に可能 (server 側で同一 user の 2 session を許可している前提)。ただし mirrored localhost 共有による副作用がいくつかあり、対処が必要。

4-1. arp_ignore=1 の永続化 (これがないと Windows 側が APIPA になる)

両 daemon を同時に走らせると、Windows VPN tap が DHCP で IP 取得しようとした時に APIPA (169.254.x) に fallback して使い物にならない症状が起きる。

原因: WSL2 mirrored で vpn_kanamei (WSL2 VPN tap) と eth1 (Windows VPN tap mirror) が同じ subnet 192.168.30.0/24 を共有する状況で、Linux の arp_ignore=0 (デフォルト) だと vpn_kanamei192.168.30.0/24 の任意 IP の ARP probe (Windows DHCP client の Duplicate Address Detection) に 誤 reply してしまい、Windows DHCP client が「IP 重複あり」と判定して APIPA に fallback。tcpdump で実際に vpn_kanamei192.168.30.10 is-at <vpn_kanamei MAC> と reply してるところを観測して特定した。

対処は arp_ignore=1 を永続化:

sudo tee /etc/sysctl.d/99-softether-vpn-arp.conf > /dev/null << 'EOF'
# WSL2 mirrored networking で vpn_kanamei が同 subnet (192.168.30.0/24) の
# 他 IP の ARP probe に誤 reply する問題を回避。
# これがないと Windows VPN tap が DAD 失敗で APIPA (169.254.x) に fallback する。
net.ipv4.conf.default.arp_ignore = 1
net.ipv4.conf.all.arp_ignore = 1
EOF
sudo sysctl -p /etc/sysctl.d/99-softether-vpn-arp.conf

これで Windows / WSL2 の VPN を独立に on/off 可能になる。arp_ignore=1「ARP request の target IP が 受信 interface に configure された IP の時のみ reply」 という、より厳格な ARP 挙動 (BSD や Windows と同等)。通常のデスクトップ用途では副作用ほぼなし。

4-2. PC 再起動後 WSL2 vpncmd が Windows daemon に流れる時の対処 (試行錯誤中)

SoftEther の vpncmd 接続実装 (Cedar/Client.cCcConnectRpcEx) は:

  • Linux 版 vpncmd: 9930-9935 を hardcode で順に試行 (引数の port は実質無視)
  • Windows 版 vpncmd / GUI Client Manager: localhost 指定時、Windows レジストリ HKLM\Software\SoftEther Project\SoftEther VPN\ClientRpcPort 値を読んで daemon の現在 port を取得
  • 両 daemon: 起動時に空き port を 9930 から順に探す (DisableRpcDynamicPortListener=false のデフォルト動作)

これらの組み合わせで、PC 起動順次第で挙動が変わる:

  • 通常 (Windows daemon 自動起動 → WSL2 後): Windows daemon が先に 9930 を取得 → WSL2 daemon は 9931 fallback → WSL2 vpncmd は hardcode 9930-9935 探索で mirrored 共有経由で Windows daemon に到達してしまうvpn-on が WSL2 daemon ではなく Windows daemon を制御する事故
  • WSL2 daemon に後から 9930 を取り直させれば、WSL2 vpncmd は WSL2 daemon に届く。Windows GUI は registry RpcPort 経由なので Windows daemon に正しく届く

その場合の回避手順 (vpn-functions.shvpn-reset-port 関数として収録):

vpn-reset-port() {
    powershell.exe -Command "Stop-Service SEVPNCLIENT"   # UAC 承認必要
    sudo systemctl restart vpnclient                     # WSL2 daemon が 9930 取り直し
    powershell.exe -Command "Start-Service SEVPNCLIENT"  # Windows daemon は 9931 fallback
}

実行後、Windows GUI は registry RpcPort=9931 で Windows daemon に到達 + WSL2 vpncmd は 9930 で WSL2 daemon に到達 + arp_ignore=1 のおかげで DHCP 衝突なし、で両独立動作。

ただしこの手順の必要性・確実性は十分検証できておらず、再起動後すんなり動くこともある (起動順タイミングや WSL2 daemon の起動完了時刻の race 等)。「再起動後 vpn-on で繋がらない」 等の症状が出た時に試す位置づけで、必要時のみ実行する。今後の検証で記述更新の可能性あり。

4-3. それでも繋がらない時の試行錯誤チェックリスト

arp_ignore=1 永続化済 + vpn-reset-port を実行しても繋がらないケースが稀にある。原因が完全には特定できていない race condition 系の挙動なので、 以下の操作を試行錯誤で組み合わせて「動く組み合わせ」 を探すのが現実的。

Windows 側 SEVPNCLIENT 停止・開始 (管理者 PowerShell):

Stop-Service SEVPNCLIENT
Start-Service SEVPNCLIENT

WSL2 側:

vpn-off                            # session 切断 + routing クリア
vpn-on                             # 再接続
sudo systemctl restart vpnclient   # WSL2 daemon の完全 restart (port 取り直し)

Windows GUI Client Manager (vpncmgr_x64.exe):

  • タスクトレイの SoftEther アイコンを右クリック → 終了
  • スタートメニューや本体アプリから再起動 (registry の RpcPort を再読み込みする効果)

よくある症状と試すこと:

症状 試すこと
Windows 側で「接続完了」 表示なのに Windows 側 IP が VPN 経由にならない Windows GUI を タスクトレイから完全終了 → 再起動 → 再接続 (registry RpcPort キャッシュ更新)
WSL2 vpn-on で繋がらない vpn-reset-port (= SEVPNCLIENT 停止 → WSL2 daemon restart → SEVPNCLIENT 再開) + vpn-on
両方繋がっているが片方が遅い・不安定 両方 disconnect → Windows 先 → WSL2 後 の順で再接続
Windows 側 VPN tap が APIPA (169.254.x) Appendix 4-1 の arp_ignore=1 永続化が抜けてないか確認

これらの試行錯誤で動かしながら、 自分の環境で「動いた組み合わせ」 をメモしておくと、 再現時に楽。

5. HOME_DEV のハードコードは避ける (実装メモ)

WSL2 mirrored networking では Windows 側 NIC 構成 (VPN タップの追加・Hyper-V 仮想スイッチ・ドック抜き差し等) で eth0/eth1/eth2/... の enumeration が boot 毎にずれる ことがある。

HOME_DEV="eth2" のようにハードコードしていると、再起動後に eth2 が DOWN だったり別用途になっていたりした際に ip route add default via 192.168.1.1 dev eth2 が機能しないリスクがある。ip route show default から動的検出する形にしておくとよい:

# HOME_DEV は動的検出 (default route の出口、なければ HOME_GW と同じサブネットの UP な NIC)
_home_dev() {
    local dev
    dev=$(ip route show default 2>/dev/null | awk -v gw="$HOME_GW" '$3==gw {print $5; exit}')
    if [ -z "$dev" ]; then
        local prefix=${HOME_GW%.*}
        dev=$(ip -br -4 addr show | awk -v p="$prefix" '$2=="UP" && $3 ~ "^" p "\\." {print $1; exit}')
    fi
    echo "${dev:-eth0}"
}

参考

Discussion