From 2ab5a39a57cf77120efd7eef18a9476e36900425 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 15 Jun 2026 16:17:59 +0200 Subject: [PATCH] Permanent WSL2 dev fix for multi-subnet source-address trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- deploy/parking-net.service | 16 +++++ deploy/wsl-fix-route-source.sh | 101 ++++++++++++++++++++++++++++ wiki/concepts/wsl-dev-networking.md | 53 +++++++++++++++ wiki/log.md | 17 +++++ 4 files changed, 187 insertions(+) create mode 100644 deploy/parking-net.service create mode 100755 deploy/wsl-fix-route-source.sh diff --git a/deploy/parking-net.service b/deploy/parking-net.service new file mode 100644 index 0000000..1b63d5a --- /dev/null +++ b/deploy/parking-net.service @@ -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 diff --git a/deploy/wsl-fix-route-source.sh b/deploy/wsl-fix-route-source.sh new file mode 100755 index 0000000..dbe318a --- /dev/null +++ b/deploy/wsl-fix-route-source.sh @@ -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 "$@" diff --git a/wiki/concepts/wsl-dev-networking.md b/wiki/concepts/wsl-dev-networking.md index bd24e2d..0f27b17 100644 --- a/wiki/concepts/wsl-dev-networking.md +++ b/wiki/concepts/wsl-dev-networking.md @@ -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 ` 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 ` (or `curl --interface …`) — instant replies. +- **Fix (runtime):** pin the preferred source on the connected route, per subnet: + `sudo ip route replace /24 dev proto kernel scope link src metric ` + (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** diff --git a/wiki/log.md b/wiki/log.md index 8041c81..da9a74f 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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]].