Permanent WSL2 dev fix for multi-subnet source-address trap

Mirrored mode re-clones the Windows NIC's addresses each boot, so the kernel
keeps picking the wrong source for stacked device subnets (10.0.10.x sourced
from 192.168.1.123) — ARP resolves but ping/TCP dies, and every runtime
ip-route fix is wiped by wsl --shutdown.

deploy/wsl-fix-route-source.sh pins each scope-link route's src to this
host's own address in that subnet (no hardcoded IPs, idempotent, preserves
metric, non-fatal per route, waits for the route at boot). deploy/parking-net
.service reapplies it on every boot.

Dev-box only; the appliance is bare-metal Linux with static networkd config.
Verified: camera pings with no -I flag; driver pulls a snapshot with no
localAddress set.
This commit is contained in:
2026-06-15 16:17:59 +02:00
parent fa65b2df86
commit 2ab5a39a57
4 changed files with 187 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Parking dev: pin route source addresses (WSL2 mirrored-mode fix)
# Run after WSL has populated the mirrored interfaces/addresses.
After=network.target wsl-pro.service
Wants=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
# Idempotent; safe to re-run. Path is the repo checkout on this dev box.
ExecStart=/home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1
# Mirrored-mode addresses can land slightly after boot; one retry covers the race.
ExecStartPost=/bin/sh -c 'sleep 3; /home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1 || true'
[Install]
WantedBy=multi-user.target
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# WSL2 mirrored-mode source-address fix (dev box only).
#
# Problem: in WSL2 mirrored networking the Windows host's interfaces — and ALL
# their IPs — are cloned into Linux on every boot. When two device subnets land
# on one NIC (e.g. 192.168.1.x AND 10.0.10.x on eth1), the kernel's connected
# routes come up `scope link` with NO preferred source, and source selection can
# pick the WRONG address (sourcing 10.0.10.x traffic from 192.168.1.123). ARP
# still resolves (L2), so the device looks REACHABLE while every ping/TCP times
# out. See wiki/concepts/wsl-dev-networking.md.
#
# Fix: for each connected `scope link` route, pin its preferred `src` to THIS
# host's own address in that same subnet. No hardcoded IPs — derived at runtime,
# so it also covers future device subnets. Idempotent; a no-op when nothing needs
# fixing. Runs at boot via parking-net.service.
#
# Production note: the real appliance is bare-metal Linux, not WSL — there this
# is just static networkd/netplan config. This script exists only for the dev box.
# NB: intentionally NOT `set -e`. This is a best-effort boot fixer; an individual
# `ip` call failing (e.g. a route not up yet) must not abort the rest.
set -uo pipefail
fix_iface() {
local iface="$1"
# Each connected /N route on this iface that the kernel manages (proto kernel,
# scope link) — i.e. the directly-attached subnets. Capture the full line so we
# can preserve attributes (notably `metric`) when we replace the route.
ip -4 route show dev "$iface" proto kernel scope link | while read -r line; do
local subnet="${line%% *}" # e.g. "10.0.10.0/24"
local prefix="${subnet%/*}"
# Preserve a metric if the route has one (mirrored-mode routes carry e.g. 281);
# replacing without it would change the route's priority.
local metric=""
case "$line" in *" metric "*) metric="metric ${line##* metric }";; esac
# Find THIS host's own address inside the same subnet — the correct src.
local hostip=""
local cidr
for cidr in $(ip -4 -o addr show dev "$iface" | awk '{print $4}'); do
if ipcalc_net "$cidr" "$subnet"; then hostip="${cidr%/*}"; break; fi
done
[ -n "$hostip" ] || continue
local current
current=$(ip -4 route get "$prefix" 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)
[ "$current" = "$hostip" ] && continue # already correct — no-op
# `replace` creates-or-updates, so it works whether or not the route is
# present yet (avoids the boot-race RTNETLINK "No such file" that `change` hits).
# Non-fatal: a single failure must not abort the whole boot fixer.
if ip route replace "$subnet" dev "$iface" proto kernel scope link src "$hostip" $metric; then
echo "pinned $subnet -> src $hostip (was ${current:-none})"
else
echo "warn: could not pin $subnet -> src $hostip" >&2
fi
done
return 0
}
# True if address $1 (a.b.c.d/p) is inside subnet $2 (n.n.n.0/p), same prefix len.
ipcalc_net() {
local addr="${1%/*}" alen="${1#*/}"
local net="${2%/*}" nlen="${2#*/}"
[ "$alen" = "$nlen" ] || return 1
# Compare the network part by masking both to /nlen.
local a n
a=$(mask_to_net "$addr" "$nlen")
n=$(mask_to_net "$net" "$nlen")
[ "$a" = "$n" ]
}
# Mask an IPv4 dotted-quad to its /len network address.
mask_to_net() {
local ip="$1" len="$2"
local IFS=. ; read -r o1 o2 o3 o4 <<<"$ip"
local int=$(( (o1<<24) + (o2<<16) + (o3<<8) + o4 ))
local mask=$(( len == 0 ? 0 : (0xFFFFFFFF << (32 - len)) & 0xFFFFFFFF ))
local net=$(( int & mask ))
echo "$(( (net>>24)&255 )).$(( (net>>16)&255 )).$(( (net>>8)&255 )).$(( net&255 ))"
}
main() {
# Default to eth1 (the mirrored LAN NIC here); accept overrides as args.
local ifaces=("${@:-eth1}")
# Boot race: WSL mirrored mode can populate the interface's addresses/routes a
# beat after the unit starts. Wait (bounded) for at least one connected route
# to appear on the first interface before pinning.
local i tries=0
for i in "${ifaces[@]}"; do
while [ "$tries" -lt 15 ] \
&& [ -z "$(ip -4 route show dev "$i" proto kernel scope link 2>/dev/null)" ]; do
sleep 1; tries=$((tries + 1))
done
break
done
for i in "${ifaces[@]}"; do
ip link show "$i" >/dev/null 2>&1 && fix_iface "$i"
done
}
main "$@"
+53
View File
@@ -56,6 +56,59 @@ Mirrored networking is necessary but **not sufficient** — these still bit us:
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
Field devices arrive **statically configured on assorted `/24`s** by whoever installed them last
(e.g. a camera on `10.0.10.121`, a printer on `10.0.10.6`, others on `192.168.1.x`). The host
copes by carrying **one IP per device subnet on a single NIC** (this is correct — you do **not**
need a NIC per subnet). But stacking subnets on one interface exposes a Linux source-selection
trap:
- Connected routes come up as `proto kernel scope link` **with no preferred source**. With two
such subnets on one NIC, the kernel may pick the **wrong source address** — e.g. sourcing
traffic to `10.0.10.121` from `192.168.1.123`.
- Symptom is baffling: **ARP resolves and the neighbor shows `REACHABLE`** (L2 is fine, source
address is irrelevant to ARP) while **every ping and TCP connect times out** (replies have a
wrong/unroutable source → dropped, possibly by uRPF). Looks like "the device is down / the whole
subnet is unreachable" when nothing is actually broken.
- **Diagnose:** `ip route get <device-ip>` shows the chosen `src` — if it's an address on a
*different* subnet, that's the bug. Confirm by forcing the right source:
`ping -I <correct-src> <device-ip>` (or `curl --interface <correct-src> …`) — instant replies.
- **Fix (runtime):** pin the preferred source on the connected route, per subnet:
`sudo ip route replace <subnet>/24 dev <nic> proto kernel scope link src <correct-host-ip> metric <m>`
(use `replace`, not `change` — `change` errors `RTNETLINK: No such file` if the route isn't up
yet). Do **not** delete the other subnet's address unless it's genuinely unwanted — you need all
of them to reach all the devices.
- **Fix (permanent, this box):** `deploy/wsl-fix-route-source.sh` + `deploy/parking-net.service`.
The script walks each `proto kernel scope link` route on the NIC and pins `src` to THIS host's own
address in that same subnet — **no hardcoded IPs**, so it also covers future device subnets; it's
idempotent, preserves the route metric, and tolerates a missing route. The systemd unit (oneshot,
`enabled`) reapplies it on every WSL boot — which is the point, since `wsl --shutdown` otherwise
wipes the runtime fix (mirrored mode re-clones the Windows addresses fresh each boot, see below).
Install once: copy the unit to `/etc/systemd/system/`, `systemctl enable --now parking-net`.
Gotchas hit while building it: `network.target` is too early for mirrored-mode addresses (the
script waits up to 15s for a route to appear); and it must NOT `set -e` or one failed `ip` call
aborts the whole boot fixer.
> **Root cause is on the Windows side.** Mirrored mode clones the Windows host NIC's addresses into
> Linux at every boot, so the stray `192.168.1.x` lives on Windows — the truly permanent fix is to
> remove/reconfigure it there (or set `SkipAsSource`/interface metric). The systemd hook is the
> self-contained Linux-side answer that needs no Windows changes.
Verified on hardware (2026-06-15): after the hook, `10.0.10.121` pings and the real [[lpr-camera]]
Hikvision driver pulls a snapshot with **no** source-forcing (`localAddress` becomes optional).
## On the real appliance: multi-subnet is a deployment config, not a WSL hack
Production is a **dedicated hardened Linux appliance** ([[disk-os-hardening]]), so the WSL story
above is dev-only. The device-subnet problem persists, though, and is solved the same way at the
OS level: the appliance NIC carries **one address per device subnet**, each connected route with a
pinned `src`, made persistent (systemd-networkd / netplan). Per the threat model this still rides
on **[[network-isolation]]** — device subnets are isolated segments reachable only by the host.
The long-term clean answer is to **re-IP the devices onto one planned parking-system subnet** at
install so the host needs only one address; the multi-subnet config is what you run until then.
## Alternative if you can't use mirrored mode
Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows**
+17
View File
@@ -338,3 +338,20 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic).
- Updated [[lpr-camera]].
## [2026-06-15] fix | Permanent WSL2 source-address fix (systemd hook)
- The multi-subnet source-address trap kept recurring (every `wsl --shutdown` wipes the runtime
`ip route` pin — mirrored mode re-clones the Windows NIC's addresses fresh each boot, and NOTHING
inside Linux owns them: networkd/NM/netplan all inactive). Made it permanent on the dev box.
- `deploy/wsl-fix-route-source.sh`: walks each `proto kernel scope link` route on the NIC and pins
`src` to the host's own address in that same subnet — no hardcoded IPs (covers future device
subnets), idempotent, preserves route metric, non-fatal per route. `deploy/parking-net.service`:
oneshot, enabled, reapplies on every boot.
- BUGS hit + fixed while building it: (1) `ip route change` errors `RTNETLINK: No such file` when
the route isn't up yet at boot → use `replace`; (2) `set -e` made one failed `ip` abort the whole
unit → dropped it, per-route warnings instead; (3) `network.target` fires before mirrored-mode
addresses land → script waits up to 15s for a route.
- VERIFIED: service enabled+active, journal shows `pinned 10.0.10.0/24 -> src 10.0.10.203`, camera
pings with NO -I flag (0% loss), and the real Hikvision driver pulls a snapshot with NO
`localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the
self-contained Linux answer.
- Updated [[wsl-dev-networking]].