Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save aont/d11b820b176cde85335a85ebb85586ab to your computer and use it in GitHub Desktop.

Select an option

Save aont/d11b820b176cde85335a85ebb85586ab to your computer and use it in GitHub Desktop.

Route Only a Specific IP Range from Windows via WSL2 (Alpine) with openfortivpn

If you want Windows to reach only a particular IP range through your corporate VPN—while everything else stays on your normal Internet—one clean approach is to use WSL2 (Alpine Linux) as a tiny VPN router. Windows sends just the target routes to the WSL VM; Alpine dials the VPN with openfortivpn and NATs the traffic. Here’s a concise walkthrough.


What you’ll build

  • A lightweight Alpine Linux distro inside WSL2.

  • openfortivpn to connect to a FortiGate VPN without auto-installed routes.

  • An Alpine script that:

    • Detects the PPP interface created by openfortivpn
    • Enables IP forwarding + NAT
    • Adds routes only for your destination IP range
    • Keeps the setup stable even if the VPN reconnects
  • A short PowerShell snippet on Windows to add a single route to WSL for that destination range.


1) Install Alpine in WSL2 (Windows)

Download the Alpine minirootfs and import it as a new WSL distro (named forti below):

curl.exe -LO https://dl-cdn.alpinelinux.org/alpine/vXXX/releases/x86_64/alpine-minirootfs-XXX-x86_64.tar.gz
wsl --import forti path\to\wsl\forti path\to\alpine-minirootfs-XXX-x86_64.tar.gz --version 2

Replace XXX with the actual Alpine version you want.


2) Enable the testing repo inside Alpine

From the forti WSL shell:

echo "https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories

3) Install required packages

apk update
apk add openfortivpn iptables iproute2 jq
  • openfortivpn: VPN client
  • iptables: NAT and forwarding rules
  • iproute2: route and interface control
  • jq: JSON parsing for robust interface detection

4) Add a Windows route to send only the target range into WSL

Put destination prefix and WSL distro name on ~/.myvpnrc.ps1:

$dstIpRange = "A.B.C.D/E"   # Your target network(s)
$wslName    = "forti"       # The Alpine WSL instance

The script setup.ps1 tells Windows: “Send packets for A.B.C.D/E to the WSL VM.” Everything else continues using your normal adapter.


5) Run the Alpine helper script

Put destination prefix on ~/.myvpnrc:

VPN_IPRANGE=A.B.C.D/E

The script setup.sh:

  • Runs openfortivpn --no-routes so the client doesn’t override routes
  • Detects the new PPP interface
  • Enables IP forwarding and NAT
  • Ensures the VPN endpoint itself is reached via your default gateway (not tunneled)
  • Adds the specific VPN_IPRANGE route via the PPP interface
  • Handles reconnects gracefully

Why this works

  • Selective routing (split tunneling by route): Windows forwards only the target network (A.B.C.D/E) to WSL.
  • No conflicting routes: openfortivpn --no-routes prevents broad VPN routes from hijacking all traffic.
  • NAT from Alpine: iptables masquerades traffic out the PPP interface, so the remote network sees the VPN endpoint IP.
  • Stability: The script auto-detects the PPP device, preserves access to the VPN endpoint via your normal gateway, and survives reconnects.

Tips & troubleshooting

  • If DNS for the private range is required, either add per-process DNS on Windows or run a lightweight DNS forwarder inside WSL and point specific lookups to it.
  • If Windows can’t reach the target hosts, re-run the PowerShell route command—WSL IPs can change after a reboot or distro restart.
  • If openfortivpn fails to connect, verify credentials and any required openfortivpn options (host/port, 2FA, etc.) in your config file.

With this setup, you keep your regular Internet fast and untouched, and only the required private subnets go through the VPN—clean, minimal, and easy to reason about.

. $Env:USERPROFILE\.myvpnrc.ps1
# $dstIpRange = "A.B.C.D/E"
# $wslName = "forti"
$wslDefaultRoute = wsl -d "$wslName" ip -j route show default | ConvertFrom-Json | ForEach-Object { $_ } | Select-Object -First 1
$wslGw = $wslDefaultRoute.gateway
$wslIf = $wslDefaultRoute.dev
Write-Host "[debug] wslGw=$wslGw"
Write-Host "[debug] wslIf=$wslIf"
$hvIf = Get-NetIPAddress -IPAddress "$wslGw"
$ifIndex = $hvIf.InterfaceIndex
$ifAlias = $hvIf.InterfaceAlias
Write-Host "[debug] ifIndex=$ifIndex"
Write-Host "[debug] ifAlias=$ifAlias"
$wslIp = wsl -d "$wslName" ip -j addr show "$wslIf" | ConvertFrom-Json | ForEach-Object { $_.addr_info | Where-Object { $_.family -eq "inet" } | Select-Object -ExpandProperty local }
# Check if the route already exists
$existingRoute = Get-NetRoute -DestinationPrefix "$dstIpRange" -InterfaceIndex "$ifIndex" -ErrorAction SilentlyContinue
if ($existingRoute -eq $null) {
# Add the route only if it does not exist
New-NetRoute -DestinationPrefix "$dstIpRange" -NextHop "$wslIp" -InterfaceIndex "$ifIndex"
Write-Host "[info Route added: $dstIpRange -> $wslIp ($ifAlias)"
}
elseif ($existingRoute.NextHop -ne $wslIp) {
# Update the route if the existing one has a different NextHop
Remove-NetRoute -DestinationPrefix "$dstIpRange" -InterfaceIndex "$ifIndex" -Confirm:$false
New-NetRoute -DestinationPrefix "$dstIpRange" -NextHop "$wslIp" -InterfaceIndex "$ifIndex"
Write-Host "[info] Existing route updated: $dstIpRange -> $wslIp ($ifAlias)"
}
else {
# Do nothing if the same route already exists
Write-Host "[info] Route already exists: $dstIpRange -> $wslIp ($ifAlias)"
}
#!/bin/bash
source "${HOME}/.myvpnrc"
# VPN_IPRANGE="A.B.C.D/E"
do_cmd() {
echo "[debug] cmd: $@" 1>&2
"$@"
}
if ! [[ -v VPN_IPRANGE ]]; then
echo "some of necessary variables are not defained" 1>&2
exit 1
fi
DEF_DEV=""
IPGW=""
read -r DEF_DEV IPGW < <(
ip -j route show default \
| jq -r '.[0] | [.dev, .gateway] | @tsv'
)
echo "[debug] DEF_DEV=$DEF_DEV" 1>&2
echo "[debug] IPGW=$IPGW" 1>&2
IF_LIST_BEFORE=$(ip -j link show | jq -r '.[].ifname' | sort)
echo "[debug] IF_LIST_BEFORE=$(echo -n "${IF_LIST_BEFORE}" | tr '\n' ',')" 1>&2
on_interrupt() {
echo "Interrupt received. Will terminate gracefully after current operation..."
# terminate_requested=1
}
trap on_interrupt SIGINT
while [[ 1 ]]; do
date "+[%Y/%-m/%-d %H:%M:%S]"
openfortivpn --no-routes &
fortipid=$!
sleep 0.3
if ! kill -0 "$fortipid" 2>/dev/null; then
break
fi
if [[ -z "${PPPD_IFNAME}" ]]; then
while true; do
IF_LIST_AFTER=$(ip -j link show | jq -r '.[].ifname' | sort)
echo "[debug] IF_LIST_AFTER=$(echo -n "${IF_LIST_AFTER}" | tr '\n' ',')" 1>&2
PPPD_IFNAME="$(comm -13 <(echo "$IF_LIST_BEFORE") <(echo "$IF_LIST_AFTER"))"
if ! [[ -z "$PPPD_IFNAME" ]]; then
echo "[debug] PPPD_IFNAME=${PPPD_IFNAME}" 1>&2
break
fi
sleep 0.3
done
IPFORWARD_SAVE="$(sysctl -n net.ipv4.ip_forward)"
echo "[debug] IPFORWARD_SAVE=${IPFORWARD_SAVE}" 1>&2
do_cmd sysctl -q net.ipv4.ip_forward=1
do_cmd iptables -A FORWARD -i "${PPPD_IFNAME}" -o "$DEF_DEV" -m state --state RELATED,ESTABLISHED -j ACCEPT
do_cmd iptables -A FORWARD -i "$DEF_DEV" -o "${PPPD_IFNAME}" -j ACCEPT
do_cmd iptables -t nat -A POSTROUTING -o "${PPPD_IFNAME}" -j MASQUERADE
fi
while true; do
CMD=(ip -j address show "${PPPD_IFNAME}")
echo "[debug] cmd: ${CMD[@]}" 1>&2
IPJSON="$("${CMD[@]}")"
echo "[debug] IPJSON=${IPJSON}" 1>&2
VPN_EP_IP="$(echo "$IPJSON" | jq -r '.[0].addr_info[] | select(.family=="inet") | .address')"
echo "[debug] VPN_EP_IP=${VPN_EP_IP}" 1>&2
if ! [[ -z "$VPN_EP_IP" ]]; then
break
fi
sleep 0.3
done
do_cmd ip route add "${VPN_EP_IP}" via "${IPGW}" dev "${DEF_DEV}"
do_cmd ip route del "${VPN_EP_IP}" dev "${PPPD_IFNAME}"
do_cmd ip route add "${VPN_IPRANGE}" dev "${PPPD_IFNAME}"
while true; do
wait "$fortipid"
status_code=$?
if [[ $status_code == 130 ]]; then
kill -INT "$fortipid"
sleep 1
else
do_cmd iptables -t nat -D POSTROUTING -o "${PPPD_IFNAME}" -j MASQUERADE
do_cmd iptables -D FORWARD -i "$DEF_DEV" -o "${PPPD_IFNAME}" -j ACCEPT
do_cmd iptables -D FORWARD -i "${PPPD_IFNAME}" -o "$DEF_DEV" -m state --state RELATED,ESTABLISHED -j ACCEPT
do_cmd sysctl -q net.ipv4.ip_forward="${IPFORWARD_SAVE}"
break
fi
done
if [[ $status_code -eq 0 ]]; then
break
fi
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment