iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔒

Fixing Connectivity Issues with SoftEther VPN in WSL2 Mirrored Mode

に公開

TL;DR

When using WSL2 mirrored mode (networkingMode=mirrored) while the Windows-side SoftEther VPN client is enabled, external communication from WSL2 stops working.

I solved this by setting up a dedicated SoftEther Linux client inside WSL2, running independently of the Windows VPN.

The Problem

WSL2's mirrored networking mode is convenient and one of my favorites for things like accessing Windows-side Chrome from WSL2 via localhost (e.g., Operating the logged-in Chrome on Windows from WSL2 (Chrome DevTools MCP)). However, enabling the SoftEther VPN client on the Windows side led to the following symptoms:

  • ping/curl from WSL2 to external sites fails.
  • The Windows host itself communicates fine through the VPN.
  • Disabling the VPN restores WSL2 communication.
  • It works if I switch to NAT mode, but I don't want to lose the benefits of mirrored mode.

The Cause

In mirrored mode, the Windows VPN virtual NIC is "mirrored" into WSL2 as eth1. WSL2 is assigned an IP in the 192.168.30.x range and claims the default route.

However, due to the structure of mirrored mode, return packets from the SoftEther server do not reach WSL2 (it is assumed that the Windows SoftEther virtual LAN card discards them at the NDIS layer as "not intended for me"). Outbound traffic passes through, but inbound traffic dies, leaving external communication completely broken.

Resolution Strategy

To avoid the return-path problem inherent in mirrored mode, I configured WSL2 itself to connect independently as a SoftEther client. Since it connects to the VPN server as a separate client from the Windows VPN, return packets to WSL2 are delivered correctly.

Overall Architecture

WSL2 and Windows establish separate VPN sessions. WSL2 traffic reaches the VPN server via vpn_xxx. Windows traffic goes through its own session via the Windows SoftEther client.

Implementation

1. Install SoftEther client on WSL2

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

# Official 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    # Agree to the three terms

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

Convert to 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. If using Windows-side VPN simultaneously, persist arp_ignore=1

If you plan to use the Windows-side SoftEther client simultaneously (VPN connections for Windows and WSL2 independently), you need to tighten the Linux kernel's ARP behavior. Without this, the Windows VPN tap will fail DHCP acquisition and fall back to APIPA (169.254.x) (see Appendix 4-1 for details).

sudo tee /etc/sysctl.d/99-softether-vpn-arp.conf > /dev/null << 'EOF'
# Prevent issues where vpn_xxx replies incorrectly to ARP probes for other IPs 
# in the same subnet (192.168.30.0/24) due to WSL2 mirrored networking.
# Without this, the Windows VPN tap falls back to APIPA (169.254.x) due to DAD failure.
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

This is not necessary if you only use WSL2 alone (without Windows-side VPN).

2. VPN Account Settings

Create connection settings using 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 Assignment (Static)

sudo ip addr add 192.168.30.12/24 dev vpn_myvpn

This puts you in a split tunnel state. Only 192.168.30.0/24 goes through the VPN, and everything else goes directly to the home LAN.

4. Full Tunneling (All traffic via VPN)

Full tunneling is required for connections to sites with dynamic IPs like those using ALB/CDN.

# Get the public IP of the VPN server
SERVER_IP=$(dig +short vpn.example.com | head -1)

# ★Required: Fix VPN server IP via home LAN (prevents recursive loop)
sudo ip route add ${SERVER_IP}/32 via 192.168.1.1

# Send all IP space via VPN (using /1 + /1 instead of default route)
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

This switches the external IP to the VPN exit IP.

Operationalizing: Shell Functions

Typing commands every time is tedious, so I created functions and sourced them in ~/.bashrc. A minimal template:

vpn-functions.sh (Edit values for your environment)
vpn-functions.sh
#!/bin/bash
# Shell functions for WSL2 SoftEther VPN operation

# ---- Environment specific settings ----
VPN_ACCOUNT="myvpn"               # Name specified in vpncmd AccountCreate
VPN_NIC="vpn_myvpn"               # Automatically becomes "vpn_<NICNAME>"
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"   # Check actual port with ss -tlnp and update if changed

# HOME_DEV is detected dynamically (since eth* assignment shifts on reboot in WSL2 mirrored mode. See 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 "External 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() {
    # Salvage WSL2 when Windows VPN ON hijacks default route
    sudo ip route del default dev eth1 2>/dev/null
    _ensure_home_default
}

To allow sudo to run without a password, configure /etc/sudoers.d/wsl-vpn-auto minimally:

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

Important: Do not include ip link to prevent accidental deletion of eth1.

Usage

Everyday Commands

Assuming source ~/vpn-functions.sh in ~/.bashrc.

vpn-on                    # ★ Full tunnel ON (Daily). VPN conn + IP assignment + ensure home default + /1 routes
vpn-on-split              # Split tunnel ON. Only VPN conn + IP assignment (192.168.30.x via VPN)
vpn-off-split             # Full → Split downgrade (keep VPN conn, delete /1 routes)
vpn-off                   # VPN full disconnect (all clean)
vpn-status                # Check status (split / full)
vpn-mirrored-off          # Salvage WSL2 when only Windows VPN is ON (delete eth1 default hijack)
vpn-route api.example.com # Manually add /32 route (for host-specific routing in split)
vpn-route 1.2.3.4         # IP works too
vpn-route-rm 1.2.3.4      # Delete
vpn-route-list            # List routes via VPN
vpn-reset-port            # Port swap after reboot when WSL2 vpncmd connects to Windows daemon (still trial and error, Appendix 4-2)

Scenarios

Scenario Windows VPN WSL2 VPN Command
Daily (Home LAN only) OFF OFF (Do nothing)
VPN access from WSL2 OFF or ON ON vpn-on (full) or vpn-on-split
Windows VPN only, WSL2 home direct ON OFF vpn-mirrored-off once
Both VPN ON ON ON Independent control (arp_ignore=1 persistence required, Appendix 4-1)
vpn-on doesn't connect after reboot - - vpn-reset-port (trial and error, Appendix 4-2)
Quit OFF OFF vpn-off

Tunnel Mode Differences

  • vpn-on (Full tunnel) ← Daily: All traffic via VPN. External IP becomes VPN exit. Accesses ALB/CDN IP-restricted sites.
  • vpn-on-split (Split tunnel): Only 192.168.30.0/24 via VPN, rest via Home LAN. Lightweight, for internal static IP resources.
  • vpn-off-split: Downgrade full → split (keep VPN conn).
  • vpn-off: Full exit.

Verification Environment

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

Conclusion

Coexistence of mirrored mode and SoftEther VPN is structurally difficult, but I found a practical solution with:

  • Dedicated SoftEther client inside WSL2 (separate session from Windows VPN).
  • For full tunnel: /1 + /1 routes + VPN server bypass are essential.
  • Linux SoftEther lacks automatic routing, so wrapper scripts are necessary.

It was a great learning experience to handle routing manually in the Linux version for something that is just a checkbox in the Windows client.

SoftEther does not seem to be updated frequently, so WireGuard seems more modern. Based on a comparison Claude did, NetBird looks like a candidate for self-hosting with low costs and easy distribution. However, SoftEther is strong in Japan, so it still has its place for ease of introduction.

There are still traps hidden in WSL2 development environments, and the difficult road continues.

Appendix: Pitfalls

You don't need to worry about this initially, but refer to this section when things go wrong.

1. /1 + /1 instead of default route

Adding default via VPN_GW dev vpn_xxx simply stops all traffic (even pinging the VPN gateway fails).

Instead, use 0.0.0.0/1 and 128.0.0.0/1 to cover the entire IPv4 space. This is a classic trick used in OpenVPN's redirect-gateway def1.

Notation Range Address Count
0.0.0.0/1 0.0.0.0 - 127.255.255.255 (first half) approx 2.1B
128.0.0.0/1 128.0.0.0 - 255.255.255.255 (second half) approx 2.1B
Total All IPv4 approx 4.3B

Since /1 is more specific (preferred by longest prefix match), you don't need to delete the original default route for home.

2. VPN server IP bypass route is mandatory

With /1 + /1 routes, the destination of the VPN tunnel itself (vpn.example.com public IP) goes through the VPN, resulting in an infinite recursive loop. Fix the /32 of the VPN server IP via the home LAN.

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

This is what the Windows version automates, but you must do it manually in the Linux version.

Do not run sudo ip link set eth1 down inside WSL2.

In mirrored mode, eth1 is a "view" of the Windows VPN virtual NIC. Bringing the link down in WSL2 affects the Windows NIC through the Hyper-V virtual switch, and the Windows VPN client will show as connected while all VPN traffic from Windows browsers dies.

If eth1 hijacks the default route, deal with it via route operations only:

# OK: delete hijack, return to home
sudo ip route del default dev eth1
sudo ip route add default via 192.168.1.1

# NG: down eth1 (destroys Windows VPN virtual NIC linkage)
# sudo ip link set eth1 down  # ← Don't do this

4. Co-running both daemons

Running both WSL2 and Windows daemons simultaneously is structurally possible (if the server permits 2 sessions for the same user). However, side effects from shared localhost exist.

4-1. Persist arp_ignore=1 (Otherwise Windows falls to APIPA)

Running both causes the Windows VPN tap to fall back to APIPA (169.254.x) during DHCP acquisition.

Reason: WSL2 mirrored + subnet sharing. Linux arp_ignore=0 (default) causes the WSL2 VPN tap to incorrectly reply to ARP probes from the Windows DHCP client (Duplicate Address Detection), making Windows think there is an IP conflict. tcpdump confirms the WSL2 tap replying. Fix via arp_ignore=1 persistence:

sudo tee /etc/sysctl.d/99-softether-vpn-arp.conf > /dev/null << 'EOF'
# Prevent WSL2 mirrored VPN tap from replying to ARP probes of the same subnet.
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

This makes ARP behavior stricter (reply only if target IP is configured on the interface), similar to BSD/Windows, with no side effects for desktop use.

4-2. Port handling after reboot

SoftEther vpncmd connection logic behaves differently between platforms, leading to potential port conflicts (9930-9935). If the Windows daemon grabs 9930 first on boot, WSL2 vpncmd might connect to the Windows daemon via shared localhost.

If that happens, vpn-reset-port is needed:

vpn-reset-port() {
    powershell.exe -Command "Stop-Service SEVPNCLIENT"   # Requires UAC
    sudo systemctl restart vpnclient                     # WSL2 daemon re-grabs 9930
    powershell.exe -Command "Start-Service SEVPNCLIENT"  # Windows daemon falls back to 9931
}

Note: This is still trial-and-error. Run it only when connections fail after reboot.

4-3. Troubleshooting checklist

If it still won't connect, try these combinations:

  1. Restart Windows SEVPNCLIENT service.
  2. WSL2: vpn-off, vpn-on, then systemctl restart vpnclient.
  3. Close Windows GUI client manager and restart it (refreshes registry RpcPort).

5. Avoid hardcoding HOME_DEV

Network interface enumeration shifts every boot in WSL2. Always detect the interface dynamically:

_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}"
}

References

Discussion