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:
@@ -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
|
||||||
Executable
+101
@@ -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 "$@"
|
||||||
@@ -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
|
(`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]].)
|
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
|
## Alternative if you can't use mirrored mode
|
||||||
|
|
||||||
Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows**
|
Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows**
|
||||||
|
|||||||
+17
@@ -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).
|
10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic).
|
||||||
- Updated [[lpr-camera]].
|
- 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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user